diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16d7303..0898697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,8 @@ jobs: Resources/install_runtime.sh \ "$RUNNER_TEMP/VoiceSwitch-Runtime" \ worker/asr_worker.py \ - worker/text_worker.py + worker/text_worker.py \ + gigaam - name: Validate installer retry diagnostics run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63c2d2e..a80a0ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,8 @@ jobs: notes_file="RELEASE_NOTES_${GITHUB_REF_NAME}.md" args=( "$GITHUB_REF_NAME" + dist/VoiceSwitch-*.dmg + dist/VoiceSwitch-*.dmg.sha256 dist/VoiceSwitch-*.zip dist/VoiceSwitch-*.zip.sha256 --title "VoiceSwitch ${GITHUB_REF_NAME}" diff --git a/.gitignore b/.gitignore index 0b1645f..0313bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ Runtime/ outputs/ +backups/ *.xcuserstate *.wav *.jsonl diff --git a/INSTALL.md b/INSTALL.md index 1999559..5b32d20 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,25 +2,84 @@ ## Готовый beta-релиз -1. Скачайте arm64 ZIP из раздела - [GitHub Releases](https://github.com/mitimaicode/VoiceSwitch/releases). -2. Сверьте SHA-256 при необходимости: - - ```zsh - shasum -a 256 VoiceSwitch-*.zip - ``` - -3. Распакуйте архив и перенесите `VoiceSwitch.app` в `/Applications`. -4. Поскольку beta пока не нотарифицирована Apple, щёлкните приложение правой - кнопкой, выберите **Открыть** и подтвердите запуск. -5. В меню VoiceSwitch нажмите **Установить модели**. - -Установщик загружает закреплённую версию `uv`, изолированный Python 3.12, -Python-зависимости, ffmpeg и веса GigaAM, Whisper, Qwen3-ASR 1.7B и -текстового редактора Qwen3-4B. +### 1. Скачивание + +Откройте [GitHub Releases](https://github.com/mitimaicode/VoiceSwitch/releases) +и скачайте файл с окончанием `macos-arm64.dmg` из самого нового релиза. +DMG — рекомендуемый и самый простой вариант. ZIP предназначен для тех, у кого +образ диска не открывается. + +При необходимости сверьте SHA-256 с соседним файлом `.sha256`: + +```zsh +shasum -a 256 VoiceSwitch-*.dmg +``` + +### 2. Копирование в «Программы» + +1. Дважды нажмите скачанный DMG. +2. Перетащите `VoiceSwitch.app` на ярлык **«Программы»** внутри открывшегося + окна. +3. Дождитесь завершения копирования и закройте DMG. + +Не запускайте VoiceSwitch прямо из DMG или папки «Загрузки»: путь приложения +важен для разрешения автоматической вставки. + +### 3. Первый запуск неподписанной beta + +Публичная версия подписана ad-hoc и не нотарифицирована Apple, потому что +проект не использует платную подписку Apple Developer. + +1. Откройте Finder → **Программы**. +2. Нажмите `VoiceSwitch` правой кнопкой мыши. +3. Выберите **Открыть**. +4. В предупреждении macOS ещё раз нажмите **Открыть**. + +Обычный двойной щелчок при первом запуске может показать сообщение, что Apple +не удалось проверить разработчика. В этом случае закройте предупреждение и +используйте правый клик → **Открыть**. + +### 4. Выбор моделей + +Встроенный мастер предлагает три стартовых комплекта: + +| Комплект | Состав | Примерный объём | Для чего | +|---|---|---:|---| +| Русская диктовка | GigaAM | 2,5 ГБ | Рекомендуемый первый запуск | +| Русский + английский | GigaAM и Whisper | 4 ГБ | Смешанная речь | +| Все возможности | Все ASR-модели и Qwen3-4B | 10 ГБ | Сравнение и локальная редактура | + +Whisper, Qwen3-ASR и редактор Qwen3-4B можно установить позднее из меню +VoiceSwitch. Установщик загружает закреплённую версию `uv`, изолированный +Python 3.12, Python-зависимости, ffmpeg и только выбранные веса. Администраторский пароль не требуется; системный Python и shell-профиль не изменяются. +### 5. Разрешения macOS + +Мастер последовательно попросит: + +- **Микрофон** — для записи речи; +- **Распознавание речи** — только при использовании системной модели Apple; +- **Универсальный доступ** — для глобальной клавиши и автоматической вставки. + +Для универсального доступа нажмите **Открыть настройки**, найдите VoiceSwitch +в разделе **Конфиденциальность и безопасность → Универсальный доступ** и +включите переключатель. macOS может запросить Touch ID или пароль владельца +Mac. Вернитесь в VoiceSwitch и нажмите **Проверить снова**. + +### 6. Первая проверка + +1. Откройте любое поле ввода и поставьте в него курсор. +2. Нажмите `fn + Option` — появится красный индикатор записи. +3. Произнесите короткую фразу. +4. Нажмите `fn + Option` повторно. +5. Дождитесь завершения распознавания и зелёного индикатора. + +Готовый текст должен появиться в исходном поле. Одновременно он сохраняется в +буфере обмена, поэтому при проблеме его можно вставить вручную через +`Command + V`. + ### Если загрузка моделей прервалась Не удаляйте папку Runtime. Нажмите **Продолжить установку** в меню @@ -38,34 +97,52 @@ VoiceSwitch показывает этап и причину последнего Перед публикацией issue удалите из журнала локальные пути или другие данные, которые не хотите раскрывать. Обычно достаточно приложить последние 30 строк. -## Разрешения macOS - -VoiceSwitch просит только разрешения, необходимые для работы: +## Текст распознаётся, но не вставляется -- **Микрофон** — запись речи; -- **Распознавание речи** — локальный системный движок Apple; -- **Универсальный доступ** — глобальная горячая клавиша и вставка текста в - активное приложение. +Это означает, что модели работают, но macOS не разрешила VoiceSwitch послать +команду вставки. Расшифровка при этом уже находится в буфере обмена. -Если текст распознаётся, но не вставляется: +Сначала выполните простой вариант: 1. откройте **Системные настройки → Конфиденциальность и безопасность → Универсальный доступ**; -2. включите VoiceSwitch; -3. полностью завершите и снова запустите приложение. +2. выключите и снова включите переключатель VoiceSwitch; +3. подтвердите действие через Touch ID или пароль; +4. полностью завершите VoiceSwitch через пункт **Выход** и откройте снова из + `/Applications`. -Если переключатель выключается снова после первой диктовки, удалите -устаревшую TCC-запись: +После обновления проверьте, что в списке нет старой копии VoiceSwitch из DMG, +«Загрузок» или другой папки. Разрешён должен быть установленный файл: + +```text +/Applications/VoiceSwitch.app +``` + +Если переключатель выглядит включённым, но вставка не работает, сбросьте +только разрешение универсального доступа VoiceSwitch: ```zsh tccutil reset Accessibility io.github.mitimaicode.VoiceSwitch ``` -После этого нажмите `+` в списке «Универсальный доступ», выберите установленный -`/Applications/VoiceSwitch.app` и перезапустите приложение. +Затем снова откройте VoiceSwitch, нажмите **Разрешить доступ**, включите его в +списке и перезапустите приложение. Микрофон, модели и расшифровки эта команда +не удаляет. Без универсального доступа распознанный текст копируется в буфер обмена. +## Обновление + +1. Завершите VoiceSwitch через пункт **Выход**. +2. Откройте новый DMG. +3. Перетащите новую версию на «Программы» и подтвердите замену. +4. Откройте приложение правой кнопкой → **Открыть**. + +Модели повторно загружать не нужно: они находятся отдельно в +`~/Library/Application Support/VoiceSwitch`. Из-за ad-hoc подписи macOS может +попросить заново включить универсальный доступ после замены приложения — это +ограничение бесплатной публичной beta. + ## Файлы и удаление Приложение: @@ -87,7 +164,7 @@ tccutil reset Accessibility io.github.mitimaicode.VoiceSwitch - только Apple Silicon и macOS 14+; - Apple SpeechAnalyzer доступен только на macOS 26+; -- первая установка требует около 12 ГБ свободного места и может быть долгой; +- рекомендуемая установка требует около 2,5 ГБ; полный комплект — около 10 ГБ; - приложение пока не подписано сертификатом Apple Developer ID; - автоматическое обновление ещё не реализовано. diff --git a/README.md b/README.md index c9130e9..2d12882 100644 --- a/README.md +++ b/README.md @@ -45,25 +45,42 @@ VoiceSwitch записывает речь по глобальной горяче - Mac с Apple Silicon (`M1` или новее); - macOS 14 Sonoma или новее; - рекомендуется 16 ГБ оперативной памяти; -- около 12 ГБ свободного места для Python, зависимостей и четырёх загружаемых моделей; +- около 2,5 ГБ для рекомендуемой установки с GigaAM или около 10 ГБ для всех моделей; - интернет только во время первоначальной установки моделей. ## Установка -1. Откройте раздел [Releases](https://github.com/mitimaicode/VoiceSwitch/releases) - и скачайте архив `VoiceSwitch-…-macos-arm64.zip`. -2. Распакуйте архив и перенесите `VoiceSwitch.app` в «Программы». -3. При первом запуске щёлкните приложение правой кнопкой и выберите - **Открыть**. Подтвердите запуск beta-версии. -4. Нажмите значок VoiceSwitch в строке меню и выберите - **Установить модели**. Загрузка занимает несколько гигабайт. -5. Разрешите доступ к микрофону. Для глобальной клавиши и автоматической - вставки включите VoiceSwitch в +1. Откройте [GitHub Releases](https://github.com/mitimaicode/VoiceSwitch/releases), + выберите самый новый релиз и скачайте `VoiceSwitch-…-macos-arm64.dmg`. +2. Откройте DMG и обязательно перетащите `VoiceSwitch.app` на ярлык + **«Программы»**. Не запускайте приложение прямо из DMG. +3. Откройте папку «Программы», щёлкните VoiceSwitch правой кнопкой и выберите + **Открыть**. Затем ещё раз подтвердите **Открыть** в предупреждении macOS. +4. В мастере выберите стартовый комплект. Для первой проверки рекомендуется + **«Русская диктовка»**: только GigaAM, около 2,5 ГБ. Остальные модели и + локальный редактор можно добавить позже. +5. Разрешите микрофон и включите VoiceSwitch в разделе **Системные настройки → Конфиденциальность и безопасность → - Универсальный доступ**. + Универсальный доступ**. Это необходимо для `fn + Option` и автоматической + вставки. +6. Поставьте курсор в любое поле ввода, нажмите `fn + Option`, произнесите + фразу и нажмите сочетание повторно. Дождитесь зелёного индикатора. Подробности и решение типовых проблем приведены в [INSTALL.md](INSTALL.md). +### Обновление без повторной загрузки моделей + +Завершите VoiceSwitch, замените старый `/Applications/VoiceSwitch.app` новой +версией и снова откройте приложение правой кнопкой. Модели, настройки и журнал +хранятся отдельно в `~/Library/Application Support/VoiceSwitch`, поэтому +скачивать их заново не требуется. + +Публичная beta не подписана Apple Developer ID. После замены приложения macOS +иногда отключает универсальный доступ. Если распознавание работает, но текст +только копируется в буфер обмена, выключите и снова включите VoiceSwitch в +разделе **Универсальный доступ**, затем перезапустите приложение. Полная +инструкция восстановления приведена в [INSTALL.md](INSTALL.md#текст-распознаётся-но-не-вставляется). + Если этап установки прервётся, нажмите **Продолжить установку**: VoiceSwitch восстановит недостающие зависимости и использует уже загруженные файлы. Причина сбоя показывается в меню, а полный журнал сохраняется в @@ -89,7 +106,9 @@ VoiceSwitch записывает речь по глобальной горяче компактное сообщение для переписки. Если редактор вернёт ошибку, VoiceSwitch автоматически вставит исходную расшифровку. -Модель загружается лениво. При переключении предыдущая загружаемая модель +Модель загружается лениво. Если выбранный движок ещё не установлен, +VoiceSwitch покажет его размер и предложит загрузить только этот компонент. +При переключении предыдущая загруженная модель выгружается, чтобы не занимать память. Apple использует системные ресурсы распознавания и может отдельно запросить разрешение «Распознавание речи». @@ -168,9 +187,13 @@ chmod +x scripts/*.sh Resources/install_runtime.sh Создание компактного release-архива без весов моделей: ```zsh -./scripts/package_release.sh 0.3.2-beta +./scripts/package_release.sh 0.3.3-beta ``` +Публичный release-скрипт по умолчанию использует ad-hoc подпись. Для стабильной +локальной подписи разработки можно передать имя собственной code-signing +identity через `VOICESWITCH_CODESIGN_IDENTITY`. + ## Обратная связь - воспроизводимые ошибки — [Issues](https://github.com/mitimaicode/VoiceSwitch/issues); diff --git a/RELEASE_NOTES_v0.3.3-beta.md b/RELEASE_NOTES_v0.3.3-beta.md new file mode 100644 index 0000000..d213b14 --- /dev/null +++ b/RELEASE_NOTES_v0.3.3-beta.md @@ -0,0 +1,33 @@ +# VoiceSwitch v0.3.3-beta + +Более простая установка без платной подписи Apple. + +## Что изменилось + +- Добавлен мастер первого запуска: выбор моделей, разрешения macOS и проверка готовности. +- GigaAM стал рекомендуемым стартовым комплектом вместо обязательной загрузки всех моделей. +- Whisper, Qwen3-ASR и редактор Qwen3-4B устанавливаются независимо и только по требованию. +- Текущая полная установка Runtime v4 распознаётся автоматически и не требует повторной загрузки моделей. +- Release-скрипт создаёт DMG с ярлыком «Программы» и короткой инструкцией первого запуска. +- GitHub Release публикует DMG, ZIP и контрольные суммы обоих файлов. +- Исправлена автоматическая вставка в Codex, Electron и WebView: VoiceSwitch + проверяет фактически активное приложение и использует надёжный резервный + `Command + V`, если поле сообщает ложный успех Accessibility. +- В журнал добавлена диагностика доставки текста: разрешение универсального + доступа, приложение-получатель и выбранный способ вставки. + +## Первый запуск + +1. Откройте DMG и перенесите VoiceSwitch в «Программы». +2. Нажмите приложение правой кнопкой и выберите «Открыть». +3. В мастере выберите «Русская диктовка». +4. Разрешите микрофон и универсальный доступ. + +Если после обновления речь распознаётся, но текст только копируется в буфер, +выключите и снова включите VoiceSwitch в разделе **Системные настройки → +Конфиденциальность и безопасность → Универсальный доступ**, затем полностью +перезапустите приложение. Публичная beta использует бесплатную ad-hoc подпись, +поэтому macOS иногда требует повторное разрешение после замены приложения. + +Модели и настройки сохраняются в `~/Library/Application Support/VoiceSwitch`, +поэтому при обновлении приложения повторная загрузка не требуется. diff --git a/Resources/Info.plist b/Resources/Info.plist index 01a3b2f..4a2fdff 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.3.2 + 0.3.3 CFBundleVersion - 5 + 12 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Resources/install_runtime.sh b/Resources/install_runtime.sh index 5baf9f7..6921d8a 100755 --- a/Resources/install_runtime.sh +++ b/Resources/install_runtime.sh @@ -4,6 +4,7 @@ set -euo pipefail RUNTIME_ROOT=${1:?Usage: install_runtime.sh RUNTIME_ROOT ASR_WORKER TEXT_WORKER} ASR_WORKER=${2:?Usage: install_runtime.sh RUNTIME_ROOT ASR_WORKER TEXT_WORKER} TEXT_WORKER=${3:?Usage: install_runtime.sh RUNTIME_ROOT ASR_WORKER TEXT_WORKER} +COMPONENTS_CSV=${4:-gigaam,whisper,qwen,text} TOOLS_ROOT="${RUNTIME_ROOT}/Tools" UV_ROOT="${TOOLS_ROOT}/uv" UV_EXECUTABLE="${UV_ROOT}/uv" @@ -13,6 +14,7 @@ MODEL_ROOT="${RUNTIME_ROOT}/Models" BIN_ROOT="${RUNTIME_ROOT}/bin" UV_CACHE="${RUNTIME_ROOT}/uv-cache" LOG_FILE="${RUNTIME_ROOT}/install.log" +COMPONENT_MARKERS_ROOT="${RUNTIME_ROOT}/components" UV_VERSION="0.11.32" GIGAAM_COMMIT="559d88d6b72541412743929f633a6ae7c9950b85" @@ -21,6 +23,38 @@ GIGAAM_ARCHIVE="https://github.com/salute-developers/GigaAM/archive/${GIGAAM_COM STATUS_MARKER="__VOICESWITCH_SETUP__" ERROR_MARKER="__VOICESWITCH_SETUP_ERROR__" RETRY_DELAY_SECONDS="${VOICESWITCH_RETRY_DELAY_SECONDS:-2}" +REQUESTED_COMPONENTS=("${(@s:,:)COMPONENTS_CSV}") + +component_requested() { + local candidate=$1 + local component + for component in "${REQUESTED_COMPONENTS[@]}"; do + [[ "${component}" == "${candidate}" ]] && return 0 + done + return 1 +} + +mark_component() { + local component=$1 + mkdir -p "${COMPONENT_MARKERS_ROOT}" + print -r -- "installed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + > "${COMPONENT_MARKERS_ROOT}/${component}.ready" +} + +write_install_marker() { + local temporary_marker="${RUNTIME_ROOT}/install-complete.txt.tmp" + { + print -r -- "runtime_version=4" + print -r -- "selective_install=1" + print -r -- "uv_version=${UV_VERSION}" + print -r -- "gigaam_commit=${GIGAAM_COMMIT}" + local marker + for marker in "${COMPONENT_MARKERS_ROOT}"/*.ready(N); do + print -r -- "component=${marker:t:r}" + done + } > "${temporary_marker}" + mv "${temporary_marker}" "${RUNTIME_ROOT}/install-complete.txt" +} status() { print -r -- "${STATUS_MARKER}$1" @@ -96,8 +130,16 @@ download_with_resume() { [[ -f "${TEXT_WORKER}" ]] || fail "Не найден модуль локального редактора." command -v curl >/dev/null 2>&1 || fail "В macOS не найден curl." +for component in "${REQUESTED_COMPONENTS[@]}"; do + case "${component}" in + gigaam|whisper|qwen|text) ;; + *) fail "Неизвестный компонент установки: ${component}" ;; + esac +done +(( ${#REQUESTED_COMPONENTS[@]} > 0 )) || fail "Не выбраны модели для установки." + if [[ "${VOICESWITCH_SETUP_VALIDATE_ONLY:-0}" == "1" ]]; then - status "Проверка установщика завершена." + status "Проверка установщика завершена: ${COMPONENTS_CSV}." exit 0 fi @@ -111,7 +153,8 @@ mkdir -p \ "${PYTHON_ROOT}" \ "${MODEL_ROOT}" \ "${BIN_ROOT}" \ - "${UV_CACHE}" + "${UV_CACHE}" \ + "${COMPONENT_MARKERS_ROOT}" exec > >(/usr/bin/tee -a "${LOG_FILE}") 2>&1 print -r -- "=== Запуск установки $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" @@ -122,6 +165,18 @@ else status "Начинаю установку локальных моделей." fi +# Все установки Runtime v4 до появления выборочных компонентов были полными. +# Создаём маркеры миграции до перезаписи общего файла состояния. +if [[ -f "${RUNTIME_ROOT}/install-complete.txt" ]] && \ + grep -q '^runtime_version=4$' "${RUNTIME_ROOT}/install-complete.txt" && \ + ! grep -q '^selective_install=1$' "${RUNTIME_ROOT}/install-complete.txt"; then + for component in gigaam whisper qwen text; do + [[ -f "${COMPONENT_MARKERS_ROOT}/${component}.ready" ]] || \ + print -r -- "migrated_from=runtime-v4" \ + > "${COMPONENT_MARKERS_ROOT}/${component}.ready" + done +fi + export UV_NO_MODIFY_PATH=1 export UV_NO_PROGRESS=1 export UV_HTTP_RETRIES=5 @@ -173,24 +228,13 @@ PYTHON="${VENV_ROOT}/bin/python3" [[ -x "${PYTHON}" ]] || fail "Не удалось создать Python-окружение." run_with_retries \ - "Устанавливаю локальные движки распознавания…" \ + "Устанавливаю базовое локальное окружение…" \ 3 \ "${UV_EXECUTABLE}" pip install \ --python "${PYTHON}" \ "numpy<3" \ huggingface_hub \ - imageio-ffmpeg \ - "mlx-lm==0.31.3" \ - mlx-whisper \ - "mlx-qwen3-asr==0.3.5" \ - torchaudio \ - "${GIGAAM_ARCHIVE}" || fail_step "Устанавливаю локальные движки распознавания" - -status "Проверяю зависимости GigaAM…" -if ! "${PYTHON}" -c 'import torch, torchaudio, gigaam'; then - fail \ - "Не удалось загрузить зависимости GigaAM (torch/torchaudio). Нажмите «Продолжить установку», чтобы восстановить окружение. Журнал: ${LOG_FILE}" -fi + imageio-ffmpeg || fail_step "Устанавливаю базовое локальное окружение" if ! FFMPEG_EXECUTABLE=$( "${PYTHON}" -c 'import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())' @@ -200,42 +244,85 @@ fi ln -sf "${FFMPEG_EXECUTABLE}" "${BIN_ROOT}/ffmpeg" || \ fail_step "Настраиваю ffmpeg" -run_with_retries \ - "Загружаю Whisper Large V3 Turbo…" \ - 3 \ - "${PYTHON}" "${ASR_WORKER}" \ - --download \ - --engine whisper \ - --cache "${MODEL_ROOT}" || fail_step "Загружаю Whisper Large V3 Turbo" +if component_requested gigaam; then + run_with_retries \ + "Устанавливаю движок GigaAM…" \ + 3 \ + "${UV_EXECUTABLE}" pip install \ + --python "${PYTHON}" \ + torchaudio \ + "${GIGAAM_ARCHIVE}" || fail_step "Устанавливаю движок GigaAM" + + status "Проверяю зависимости GigaAM…" + if ! "${PYTHON}" -c 'import torch, torchaudio, gigaam'; then + fail \ + "Не удалось загрузить зависимости GigaAM (torch/torchaudio). Нажмите «Продолжить установку», чтобы восстановить окружение. Журнал: ${LOG_FILE}" + fi -run_with_retries \ - "Загружаю GigaAM v3 E2E RNNT…" \ - 3 \ - "${PYTHON}" "${ASR_WORKER}" \ - --download \ - --engine gigaam \ - --cache "${MODEL_ROOT}" || fail_step "Загружаю GigaAM v3 E2E RNNT" + run_with_retries \ + "Загружаю GigaAM v3 E2E RNNT…" \ + 3 \ + "${PYTHON}" "${ASR_WORKER}" \ + --download \ + --engine gigaam \ + --cache "${MODEL_ROOT}" || fail_step "Загружаю GigaAM v3 E2E RNNT" + mark_component gigaam +fi -run_with_retries \ - "Загружаю Qwen3-ASR 1.7B…" \ - 3 \ - "${PYTHON}" "${ASR_WORKER}" \ - --download \ - --engine qwen \ - --cache "${MODEL_ROOT}" || fail_step "Загружаю Qwen3-ASR 1.7B" +if component_requested whisper; then + run_with_retries \ + "Устанавливаю движок Whisper…" \ + 3 \ + "${UV_EXECUTABLE}" pip install \ + --python "${PYTHON}" \ + mlx-whisper || fail_step "Устанавливаю движок Whisper" -run_with_retries \ - "Загружаю локальный редактор Qwen3-4B…" \ - 3 \ - "${PYTHON}" "${TEXT_WORKER}" \ - --download \ - --cache "${MODEL_ROOT}" || fail_step "Загружаю локальный редактор Qwen3-4B" + run_with_retries \ + "Загружаю Whisper Large V3 Turbo…" \ + 3 \ + "${PYTHON}" "${ASR_WORKER}" \ + --download \ + --engine whisper \ + --cache "${MODEL_ROOT}" || fail_step "Загружаю Whisper Large V3 Turbo" + mark_component whisper +fi + +if component_requested qwen; then + run_with_retries \ + "Устанавливаю движок Qwen3-ASR…" \ + 3 \ + "${UV_EXECUTABLE}" pip install \ + --python "${PYTHON}" \ + "mlx-qwen3-asr==0.3.5" || fail_step "Устанавливаю движок Qwen3-ASR" + + run_with_retries \ + "Загружаю Qwen3-ASR 1.7B…" \ + 3 \ + "${PYTHON}" "${ASR_WORKER}" \ + --download \ + --engine qwen \ + --cache "${MODEL_ROOT}" || fail_step "Загружаю Qwen3-ASR 1.7B" + mark_component qwen +fi + +if component_requested text; then + run_with_retries \ + "Устанавливаю локальный редактор…" \ + 3 \ + "${UV_EXECUTABLE}" pip install \ + --python "${PYTHON}" \ + "mlx-lm==0.31.3" || fail_step "Устанавливаю локальный редактор" + + run_with_retries \ + "Загружаю локальный редактор Qwen3-4B…" \ + 3 \ + "${PYTHON}" "${TEXT_WORKER}" \ + --download \ + --cache "${MODEL_ROOT}" || fail_step "Загружаю локальный редактор Qwen3-4B" + mark_component text +fi -print -r -- "runtime_version=4" > "${RUNTIME_ROOT}/install-complete.txt" -print -r -- "uv_version=${UV_VERSION}" >> "${RUNTIME_ROOT}/install-complete.txt" -print -r -- "gigaam_commit=${GIGAAM_COMMIT}" >> "${RUNTIME_ROOT}/install-complete.txt" -print -r -- "qwen_model=Qwen/Qwen3-ASR-1.7B" >> "${RUNTIME_ROOT}/install-complete.txt" -print -r -- "text_model=Qwen/Qwen3-4B-MLX-4bit" >> "${RUNTIME_ROOT}/install-complete.txt" +write_install_marker -status "Готово — распознавание и локальный редактор установлены." +status "Готово — выбранные локальные модели установлены." print -r -- "=== Установка успешно завершена $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" diff --git "a/Resources/\320\237\320\225\320\240\320\222\320\253\320\231 \320\227\320\220\320\237\320\243\320\241\320\232.txt" "b/Resources/\320\237\320\225\320\240\320\222\320\253\320\231 \320\227\320\220\320\237\320\243\320\241\320\232.txt" new file mode 100644 index 0000000..eefb7ef --- /dev/null +++ "b/Resources/\320\237\320\225\320\240\320\222\320\253\320\231 \320\227\320\220\320\237\320\243\320\241\320\232.txt" @@ -0,0 +1,23 @@ +VoiceSwitch — первый запуск + +1. Перетащите VoiceSwitch.app на ярлык «Программы». Не запускайте его из DMG. +2. Дождитесь копирования и откройте папку «Программы». +3. Нажмите VoiceSwitch правой кнопкой и выберите «Открыть». +4. Ещё раз нажмите «Открыть» в предупреждении macOS. +5. Следуйте встроенному мастеру: выберите модели и разрешите микрофон. +6. Для горячей клавиши и автоматической вставки разрешите VoiceSwitch + в разделе «Системные настройки → Конфиденциальность и безопасность → + Универсальный доступ». +7. Поставьте курсор в поле ввода и дважды используйте fn + Option: один раз + для старта записи, второй — для остановки. + +Это бесплатная beta без платной подписи Apple. Действие «правый клик → +Открыть» требуется только при первом запуске новой копии приложения. + +Модели и настройки хранятся отдельно от приложения в папке: +~/Library/Application Support/VoiceSwitch + +Поэтому при обновлении удалять модели не нужно. + +Если после обновления текст распознаётся, но не вставляется, выключите и снова +включите VoiceSwitch в разделе «Универсальный доступ» и перезапустите его. diff --git a/Sources/VoiceSwitch/ASRService.swift b/Sources/VoiceSwitch/ASRService.swift index 78d2ade..18c410e 100644 --- a/Sources/VoiceSwitch/ASRService.swift +++ b/Sources/VoiceSwitch/ASRService.swift @@ -117,6 +117,12 @@ final class ASRService { "Apple SpeechAnalyzer не использует Python worker." ) } + if let component = engine.runtimeComponent, + !RuntimePaths.isInstalled(component) { + throw VoiceSwitchError.runtimeMissing( + "Модель \(component.title) ещё не установлена. Выберите её в меню VoiceSwitch." + ) + } if let process, process.isRunning, currentEngine == engine { return } diff --git a/Sources/VoiceSwitch/AppState.swift b/Sources/VoiceSwitch/AppState.swift index 6ea5571..c8e3e36 100644 --- a/Sources/VoiceSwitch/AppState.swift +++ b/Sources/VoiceSwitch/AppState.swift @@ -2,15 +2,17 @@ import AppKit import AVFoundation import Combine import Foundation +import SwiftUI final class AppState: ObservableObject { @Published var selectedEngine: ASREngine { didSet { UserDefaults.standard.set(selectedEngine.rawValue, forKey: "selectedEngine") asrService.switchEngine(to: selectedEngine) - if (selectedEngine.requiresRuntime || textProcessingMode.usesLocalModel), - !runtimeReady { - status = "Для \(selectedEngine.shortTitle) обновите локальные модели." + if let component = selectedEngine.runtimeComponent, + !installedComponents.contains(component) { + selectedInstallComponents.insert(component) + status = "Для \(selectedEngine.shortTitle) установите модель \(component.title)." } else { status = "Выбрана \(selectedEngine.shortTitle). Модель подготовится при первой записи." } @@ -30,8 +32,10 @@ final class AppState: ObservableObject { if !textProcessingMode.usesLocalModel { textProcessingService.shutdown() } - if textProcessingMode.usesLocalModel, !runtimeReady { - status = "Для режима «\(textProcessingMode.title)» обновите локальные модели." + if let component = textProcessingMode.runtimeComponent, + !installedComponents.contains(component) { + selectedInstallComponents.insert(component) + status = "Для режима «\(textProcessingMode.title)» установите локальный редактор." } else { status = textProcessingMode.usesLocalModel ? "Выбран режим «\(textProcessingMode.title)». Редактор подготовится после распознавания." @@ -54,9 +58,18 @@ final class AppState: ObservableObject { @Published private(set) var lastRating: String? @Published private(set) var permissionsVersion = 0 @Published private(set) var runtimeReady = RuntimePaths.isRuntimeReady + @Published private(set) var installedComponents = RuntimePaths.installedComponents @Published private(set) var isInstallingRuntime = false @Published private(set) var runtimeInstallStatus = "" @Published private(set) var runtimeInstallError: String? + @Published var selectedInstallPreset: RuntimeInstallPreset = .russian { + didSet { + selectedInstallComponents = selectedInstallPreset.components + } + } + @Published var selectedInstallComponents: Set = [.gigaam] + @Published private(set) var onboardingStep: OnboardingStep = .welcome + @Published private(set) var onboardingCompleted = false let hudModel: HUDModel @@ -70,6 +83,7 @@ final class AppState: ObservableObject { private var targetPID: pid_t? private var recordingSource = "button" private var lastResult: TranscriptionResult? + private var uiTestWindow: NSWindow? init() { let savedEngine = UserDefaults.standard.string(forKey: "selectedEngine") @@ -92,6 +106,19 @@ final class AppState: ObservableObject { let hudModel = HUDModel() self.hudModel = hudModel + if ProcessInfo.processInfo.environment["VOICESWITCH_FORCE_ONBOARDING"] == "1" { + onboardingCompleted = false + } else if UserDefaults.standard.object(forKey: "onboardingCompleted") == nil { + onboardingCompleted = RuntimePaths.isRuntimeReady + } else { + onboardingCompleted = UserDefaults.standard.bool(forKey: "onboardingCompleted") + } + + installedComponents = RuntimePaths.installedComponents + if installedComponents.isEmpty { + selectedInstallComponents = [.gigaam] + } + hotKey.onPress = { [weak self] in self?.toggleRecording(source: "hotkey") } @@ -111,10 +138,15 @@ final class AppState: ObservableObject { self.status = message } - if !runtimeReady, - selectedEngine.requiresRuntime || textProcessingMode.usesLocalModel { - status = "Установите локальные модели перед первой записью." - runtimeInstallStatus = "Потребуется около 12 ГБ свободного места." + if !selectedEngineReady { + status = "Установите выбранные локальные модели перед первой записью." + runtimeInstallStatus = "Рекомендуемый стартовый комплект занимает около 2,5 ГБ." + } + + if ProcessInfo.processInfo.environment["VOICESWITCH_FORCE_ONBOARDING"] == "1" { + DispatchQueue.main.async { [weak self] in + self?.showUITestWindow() + } } } @@ -144,8 +176,33 @@ final class AppState: ObservableObject { } var selectedEngineReady: Bool { - (!selectedEngine.requiresRuntime || runtimeReady) - && (!textProcessingMode.usesLocalModel || runtimeReady) + let engineReady = selectedEngine.runtimeComponent.map { + installedComponents.contains($0) + } ?? true + let editorReady = textProcessingMode.runtimeComponent.map { + installedComponents.contains($0) + } ?? true + return engineReady && editorReady + } + + var missingComponentsForCurrentSelection: Set { + var required: Set = [] + if let component = selectedEngine.runtimeComponent { + required.insert(component) + } + if let component = textProcessingMode.runtimeComponent { + required.insert(component) + } + return required.subtracting(installedComponents) + } + + var shouldShowRuntimeSetup: Bool { + isInstallingRuntime || !missingComponentsForCurrentSelection.isEmpty + } + + var microphoneAuthorized: Bool { + _ = permissionsVersion + return Permissions.microphoneAuthorized } var isBusy: Bool { @@ -278,14 +335,24 @@ final class AppState: ObservableObject { } func installRuntime() { - guard !isInstallingRuntime, !runtimeReady else { return } + let requestedComponents = selectedInstallComponents.subtracting(installedComponents) + guard !isInstallingRuntime, !requestedComponents.isEmpty else { + if selectedInstallComponents.isSubset(of: installedComponents) { + runtimeInstallStatus = "Выбранные модели уже установлены." + if !onboardingCompleted, onboardingStep == .models { + onboardingStep = .permissions + } + } + return + } isInstallingRuntime = true runtimeInstallError = nil runtimeInstallStatus = "Подготавливаю установку…" - status = "Установка локальных моделей…" + status = "Установка выбранных локальных моделей…" runtimeInstaller.install( + components: requestedComponents, status: { [weak self] message in guard let self else { return } self.runtimeInstallStatus = message @@ -296,13 +363,18 @@ final class AppState: ObservableObject { self.isInstallingRuntime = false switch result { case .success: - self.runtimeReady = RuntimePaths.isRuntimeReady + self.refreshRuntimeState() self.runtimeInstallError = nil - self.runtimeInstallStatus = "Распознавание и локальный редактор готовы к работе." - self.status = "Готово к записи" + self.runtimeInstallStatus = "Выбранные модели готовы к работе." + self.status = self.selectedEngineReady + ? "Готово к записи" + : "Выберите установленную модель или добавьте недостающую." self.hudController.showSuccess("Модели установлены") + if !self.onboardingCompleted, self.onboardingStep == .models { + self.onboardingStep = .permissions + } case .failure(let error): - self.runtimeReady = RuntimePaths.isRuntimeReady + self.refreshRuntimeState() self.runtimeInstallError = error.localizedDescription self.runtimeInstallStatus = error.localizedDescription self.status = "Не удалось установить модели." @@ -312,6 +384,42 @@ final class AppState: ObservableObject { ) } + func selectInstallPreset(_ preset: RuntimeInstallPreset) { + selectedInstallPreset = preset + } + + func toggleInstallComponent(_ component: RuntimeComponent) { + if selectedInstallComponents.contains(component) { + selectedInstallComponents.remove(component) + } else { + selectedInstallComponents.insert(component) + } + } + + func beginOnboarding() { + onboardingStep = .models + selectedInstallPreset = .russian + } + + func continueOnboardingFromPermissions() { + refreshPermissions() + onboardingStep = .ready + } + + func completeOnboarding() { + onboardingCompleted = true + UserDefaults.standard.set(true, forKey: "onboardingCompleted") + status = selectedEngineReady + ? "Готово к записи" + : "Выберите установленную модель." + } + + func requestMicrophonePermission() { + Permissions.requestMicrophone { [weak self] _ in + self?.refreshPermissions() + } + } + func cancelRuntimeInstallation() { guard isInstallingRuntime else { return } runtimeInstallStatus = "Останавливаю установку…" @@ -357,6 +465,11 @@ final class AppState: ObservableObject { permissionsVersion += 1 } + func refreshRuntimeState() { + runtimeReady = RuntimePaths.isRuntimeReady + installedComponents = RuntimePaths.installedComponents + } + func openLogFolder() { let folder = RuntimePaths.logFile.deletingLastPathComponent() try? FileManager.default.createDirectory( @@ -374,6 +487,17 @@ final class AppState: ObservableObject { NSApplication.shared.terminate(nil) } + private func showUITestWindow() { + let controller = NSHostingController(rootView: ContentView(state: self)) + let window = NSWindow(contentViewController: controller) + window.title = "VoiceSwitch — настройка" + window.styleMask = [.titled, .closable, .miniaturizable] + window.center() + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + uiTestWindow = window + } + private func process( transcription: TranscriptionResult, mode: TextProcessingMode, @@ -431,6 +555,13 @@ final class AppState: ObservableObject { lastText = text targetPID = nil + ComparisonLogger.appendDelivery( + autoPaste: autoPaste, + accessibilityAuthorized: Permissions.accessibilityAuthorized, + pasteTarget: pasteTarget, + frontmostPID: NSWorkspace.shared.frontmostApplication?.processIdentifier + ) + if autoPaste { let pasted = TextInjector.paste(text, into: pasteTarget) if fallback { diff --git a/Sources/VoiceSwitch/ComparisonLogger.swift b/Sources/VoiceSwitch/ComparisonLogger.swift index 2a6d41f..ff0be76 100644 --- a/Sources/VoiceSwitch/ComparisonLogger.swift +++ b/Sources/VoiceSwitch/ComparisonLogger.swift @@ -90,6 +90,48 @@ enum ComparisonLogger { appendRaw(record) } + static func appendInjection( + targetPID: pid_t?, + targetName: String, + frontmostPID: pid_t?, + method: String, + result: String, + focusedRole: String, + errorCode: Int32 + ) { + let formatter = ISO8601DateFormatter() + let record: [String: Any] = [ + "timestamp": formatter.string(from: Date()), + "type": "text_injection", + "target_pid": targetPID ?? 0, + "target_name": targetName, + "frontmost_pid": frontmostPID ?? 0, + "method": method, + "result": result, + "focused_role": focusedRole, + "ax_error_code": errorCode + ] + appendRaw(record) + } + + static func appendDelivery( + autoPaste: Bool, + accessibilityAuthorized: Bool, + pasteTarget: pid_t?, + frontmostPID: pid_t? + ) { + let formatter = ISO8601DateFormatter() + let record: [String: Any] = [ + "timestamp": formatter.string(from: Date()), + "type": "text_delivery", + "auto_paste": autoPaste, + "accessibility_authorized": accessibilityAuthorized, + "paste_target_pid": Int(pasteTarget ?? 0), + "frontmost_pid": Int(frontmostPID ?? 0) + ] + appendRaw(record) + } + private static func appendRaw(_ record: [String: Any]) { let url = RuntimePaths.logFile let directory = url.deletingLastPathComponent() diff --git a/Sources/VoiceSwitch/ContentView.swift b/Sources/VoiceSwitch/ContentView.swift index ebf1103..da00a21 100644 --- a/Sources/VoiceSwitch/ContentView.swift +++ b/Sources/VoiceSwitch/ContentView.swift @@ -5,9 +5,25 @@ struct ContentView: View { @ObservedObject var state: AppState var body: some View { + Group { + if state.onboardingCompleted { + mainContent + } else { + onboardingContent + } + } + .padding(16) + .frame(width: 430) + .onAppear { + state.refreshPermissions() + state.refreshRuntimeState() + } + } + + private var mainContent: some View { VStack(alignment: .leading, spacing: 14) { header - if !state.runtimeReady || state.isInstallingRuntime { + if state.shouldShowRuntimeSetup { runtimeSetupSection } enginePicker @@ -17,22 +33,206 @@ struct ContentView: View { settingsSection footer } - .padding(16) - .frame(width: 410) - .onAppear { - state.refreshPermissions() + } + + @ViewBuilder + private var onboardingContent: some View { + VStack(alignment: .leading, spacing: 16) { + header + + switch state.onboardingStep { + case .welcome: + welcomeStep + case .models: + modelSelectionStep + case .permissions: + permissionsStep + case .ready: + readyStep + } + } + } + + private var welcomeStep: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Настроим диктовку за несколько шагов") + .font(.title3.weight(.semibold)) + + Text("VoiceSwitch работает локально: аудио и готовый текст не отправляются во внешние API.") + .font(.callout) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 9) { + onboardingFeature("1.circle.fill", "Выберем только нужные модели") + onboardingFeature("2.circle.fill", "Разрешим микрофон и автоматическую вставку") + onboardingFeature("3.circle.fill", "Проверим первую диктовку") + } + + unsignedNotice + + Button("Начать настройку") { + state.beginOnboarding() + } + .buttonStyle(.borderedProminent) + .tint(.indigo) + .controlSize(.large) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private var modelSelectionStep: some View { + VStack(alignment: .leading, spacing: 13) { + Text("Выберите стартовый комплект") + .font(.title3.weight(.semibold)) + Text("Другие модели можно установить позже. Для начала рекомендуем GigaAM.") + .font(.callout) + .foregroundStyle(.secondary) + + ForEach(RuntimeInstallPreset.allCases) { preset in + Button { + state.selectInstallPreset(preset) + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: state.selectedInstallPreset == preset + ? "checkmark.circle.fill" + : "circle") + .foregroundStyle(state.selectedInstallPreset == preset ? .indigo : .secondary) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(preset.title) + .font(.subheadline.weight(.semibold)) + if preset == .russian { + Text("Рекомендуется") + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.indigo.opacity(0.12)) + .clipShape(Capsule()) + } + Spacer() + Text(preset.storage) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(preset.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(10) + .background( + state.selectedInstallPreset == preset + ? Color.indigo.opacity(0.1) + : Color.secondary.opacity(0.06) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .disabled(state.isInstallingRuntime) + } + + installProgress + + if !state.isInstallingRuntime { + Button(state.runtimeInstallError == nil ? "Установить и продолжить" : "Продолжить установку") { + state.installRuntime() + } + .buttonStyle(.borderedProminent) + .tint(.indigo) + .controlSize(.large) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + + private var permissionsStep: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Разрешения macOS") + .font(.title3.weight(.semibold)) + Text("VoiceSwitch не может включить эти разрешения самостоятельно. Они нужны только для записи и вставки текста.") + .font(.callout) + .foregroundStyle(.secondary) + + permissionRow( + icon: "mic.fill", + title: "Микрофон", + detail: state.microphoneStatus, + granted: state.microphoneAuthorized, + actionTitle: "Разрешить", + action: state.requestMicrophonePermission + ) + permissionRow( + icon: "checkmark.shield.fill", + title: "Универсальный доступ", + detail: "Горячая клавиша и автоматическая вставка", + granted: state.accessibilityAuthorized, + actionTitle: "Открыть настройки", + action: state.requestAccessibility + ) + + HStack { + Button("Проверить снова") { + state.refreshPermissions() + } + Spacer() + Button("Продолжить") { + state.continueOnboardingFromPermissions() + } + .buttonStyle(.borderedProminent) + .tint(.indigo) + } } } + private var readyStep: some View { + VStack(alignment: .leading, spacing: 14) { + Label("VoiceSwitch готов", systemImage: "checkmark.circle.fill") + .font(.title3.weight(.semibold)) + .foregroundStyle(.green) + + Text("Установлено: \(installedComponentNames). Нажмите fn + Option, скажите фразу и нажмите сочетание ещё раз.") + .font(.callout) + + if !state.accessibilityAuthorized { + Text("Автоматическая вставка пока недоступна. Расшифровка будет оставаться в буфере обмена, пока вы не разрешите универсальный доступ.") + .font(.caption) + .foregroundStyle(.orange) + } + + Button("Открыть VoiceSwitch") { + state.completeOnboarding() + } + .buttonStyle(.borderedProminent) + .tint(.indigo) + .controlSize(.large) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private var unsignedNotice: some View { + VStack(alignment: .leading, spacing: 4) { + Label("Бесплатная beta без подписи Apple", systemImage: "exclamationmark.triangle.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + Text("При первом запуске откройте VoiceSwitch через правый клик → «Открыть». После обновления macOS может попросить разрешить универсальный доступ повторно.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(10) + .background(Color.orange.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + } + private var runtimeSetupSection: some View { - VStack(alignment: .leading, spacing: 9) { + VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: state.isInstallingRuntime ? "arrow.down.circle" : "externaldrive.badge.plus") .foregroundStyle(.indigo) VStack(alignment: .leading, spacing: 2) { - Text("Установка локальных моделей") + Text("Добавить локальные модели") .font(.subheadline.weight(.semibold)) - Text("Распознавание и Qwen3-4B для редактуры · около 12 ГБ") + Text("Установленные модели и данные останутся на Mac") .font(.caption2) .foregroundStyle(.secondary) } @@ -40,43 +240,34 @@ struct ContentView: View { } if state.isInstallingRuntime { - ProgressView() - .controlSize(.small) - Text(state.runtimeInstallStatus) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(3) - Button("Остановить") { - state.cancelRuntimeInstallation() - } - .controlSize(.small) + installProgress } else { + ForEach(RuntimeComponent.allCases) { component in + modelComponentRow(component) + } + if let installError = state.runtimeInstallError { Text(installError) .font(.caption) .foregroundStyle(.red) - .lineLimit(6) + .lineLimit(5) .textSelection(.enabled) - Text("Повторный запуск продолжит загрузку и использует уже сохранённые файлы.") - .font(.caption2) - .foregroundStyle(.secondary) - } else { - Text("Данные речи остаются на Mac. Компоненты загружаются из Astral, Hugging Face, PyPI, GigaAM и Qwen.") - .font(.caption) - .foregroundStyle(.secondary) } + HStack { - Button(state.runtimeInstallError == nil ? "Установить модели" : "Продолжить установку") { + Button(state.runtimeInstallError == nil ? "Установить выбранное" : "Продолжить установку") { state.installRuntime() } .buttonStyle(.borderedProminent) .tint(.indigo) + .disabled(state.selectedInstallComponents.isSubset(of: state.installedComponents)) if state.runtimeInstallError != nil { Button("Журнал") { state.openRuntimeInstallLog() } .controlSize(.small) } + Spacer() Button("Папка Runtime") { state.openRuntimeFolder() } @@ -89,6 +280,64 @@ struct ContentView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } + @ViewBuilder + private var installProgress: some View { + if state.isInstallingRuntime { + VStack(alignment: .leading, spacing: 7) { + ProgressView() + .controlSize(.small) + Text(state.runtimeInstallStatus) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(4) + Button("Остановить") { + state.cancelRuntimeInstallation() + } + .controlSize(.small) + } + } else if let installError = state.runtimeInstallError { + Text(installError) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(5) + .textSelection(.enabled) + } + } + + private func modelComponentRow(_ component: RuntimeComponent) -> some View { + let isInstalled = state.installedComponents.contains(component) + let isSelected = state.selectedInstallComponents.contains(component) + + return Button { + guard !isInstalled else { return } + state.toggleInstallComponent(component) + } label: { + HStack(spacing: 8) { + Image(systemName: component.icon) + .frame(width: 18) + .foregroundStyle(isInstalled ? .green : .indigo) + VStack(alignment: .leading, spacing: 1) { + Text(component.title) + .font(.caption.weight(.semibold)) + Text("\(component.detail) · \(component.downloadSize)") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if isInstalled { + Label("Установлено", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } else { + Image(systemName: isSelected ? "checkmark.square.fill" : "square") + .foregroundStyle(isSelected ? .indigo : .secondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + private var header: some View { HStack(spacing: 10) { ZStack { @@ -273,7 +522,7 @@ struct ContentView: View { .foregroundStyle(.secondary) HStack { - Button("Подготовить модели") { + Button("Подготовить модель") { state.prewarmSelectedEngine() } .disabled(!state.selectedEngineReady || state.isBusy || state.isInstallingRuntime) @@ -302,4 +551,50 @@ struct ContentView: View { .font(.caption) } } + + private var installedComponentNames: String { + let names = RuntimeComponent.allCases + .filter(state.installedComponents.contains) + .map(\.title) + return names.isEmpty ? "системный режим Apple" : names.joined(separator: ", ") + } + + private func onboardingFeature(_ icon: String, _ title: String) -> some View { + Label(title, systemImage: icon) + .font(.callout) + .foregroundStyle(.primary) + } + + private func permissionRow( + icon: String, + title: String, + detail: String, + granted: Bool, + actionTitle: String, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: 10) { + Image(systemName: icon) + .frame(width: 22) + .foregroundStyle(granted ? .green : .indigo) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(granted ? "Разрешено" : detail) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if granted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Button(actionTitle, action: action) + .controlSize(.small) + } + } + .padding(10) + .background(Color.secondary.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + } } diff --git a/Sources/VoiceSwitch/Models.swift b/Sources/VoiceSwitch/Models.swift index fde3f15..bcab32b 100644 --- a/Sources/VoiceSwitch/Models.swift +++ b/Sources/VoiceSwitch/Models.swift @@ -1,5 +1,125 @@ import Foundation +enum RuntimeComponent: String, CaseIterable, Identifiable, Codable, Hashable { + case gigaam + case whisper + case qwenASR = "qwen" + case textEditor = "text" + + var id: String { rawValue } + + var title: String { + switch self { + case .gigaam: + return "GigaAM" + case .whisper: + return "Whisper" + case .qwenASR: + return "Qwen3-ASR" + case .textEditor: + return "Редактор Qwen3-4B" + } + } + + var detail: String { + switch self { + case .gigaam: + return "Русская речь · рекомендуемая стартовая модель" + case .whisper: + return "Смешанная русско-английская речь" + case .qwenASR: + return "Экспериментальное многоязычное распознавание" + case .textEditor: + return "Режимы «Исправить» и «Кратко»" + } + } + + var downloadSize: String { + switch self { + case .gigaam: + return "≈ 0,5 ГБ + базовое окружение" + case .whisper: + return "≈ 1,5 ГБ" + case .qwenASR: + return "≈ 4,4 ГБ" + case .textEditor: + return "≈ 2 ГБ" + } + } + + var icon: String { + switch self { + case .gigaam: + return "waveform" + case .whisper: + return "character.bubble" + case .qwenASR: + return "globe" + case .textEditor: + return "text.badge.checkmark" + } + } +} + +enum RuntimeInstallPreset: String, CaseIterable, Identifiable { + case russian + case mixed + case full + + var id: String { rawValue } + + var title: String { + switch self { + case .russian: + return "Русская диктовка" + case .mixed: + return "Русский + английский" + case .full: + return "Все возможности" + } + } + + var detail: String { + switch self { + case .russian: + return "Только GigaAM. Самый быстрый способ начать работу." + case .mixed: + return "GigaAM и Whisper для переключения языков." + case .full: + return "Все модели распознавания и локальный редактор." + } + } + + var storage: String { + switch self { + case .russian: + return "около 2,5 ГБ" + case .mixed: + return "около 4 ГБ" + case .full: + return "около 10 ГБ" + } + } + + var components: Set { + switch self { + case .russian: + return [.gigaam] + case .mixed: + return [.gigaam, .whisper] + case .full: + return Set(RuntimeComponent.allCases) + } + } +} + +enum OnboardingStep { + case welcome + case models + case permissions + case ready +} + enum ASREngine: String, CaseIterable, Identifiable, Codable { case gigaam case whisper @@ -51,6 +171,19 @@ enum ASREngine: String, CaseIterable, Identifiable, Codable { self != .apple } + var runtimeComponent: RuntimeComponent? { + switch self { + case .gigaam: + return .gigaam + case .whisper: + return .whisper + case .qwen: + return .qwenASR + case .apple: + return nil + } + } + var supportsContext: Bool { switch self { case .whisper, .qwen, .apple: @@ -117,6 +250,10 @@ enum TextProcessingMode: String, CaseIterable, Identifiable, Codable { var usesLocalModel: Bool { self != .verbatim } + + var runtimeComponent: RuntimeComponent? { + usesLocalModel ? .textEditor : nil + } } struct TranscriptionResult { diff --git a/Sources/VoiceSwitch/RuntimeInstaller.swift b/Sources/VoiceSwitch/RuntimeInstaller.swift index c46ecf1..f727e83 100644 --- a/Sources/VoiceSwitch/RuntimeInstaller.swift +++ b/Sources/VoiceSwitch/RuntimeInstaller.swift @@ -23,12 +23,23 @@ final class RuntimeInstaller { } func install( + components: Set, status: @escaping StatusHandler, completion: @escaping Completion ) { queue.async { [weak self] in guard let self else { return } guard self.process?.isRunning != true else { return } + guard !components.isEmpty else { + DispatchQueue.main.async { + completion(.failure( + VoiceSwitchError.runtimeMissing( + "Выберите хотя бы одну локальную модель." + ) + )) + } + return + } self.outputBuffer.removeAll() self.reportedFailure = nil @@ -54,7 +65,11 @@ final class RuntimeInstaller { installer.path, RuntimePaths.runtimeRoot.path, RuntimePaths.workerScript.path, - RuntimePaths.textWorkerScript.path + RuntimePaths.textWorkerScript.path, + components + .map(\.rawValue) + .sorted() + .joined(separator: ",") ] newProcess.standardOutput = newOutput newProcess.standardError = newOutput @@ -90,8 +105,12 @@ final class RuntimeInstaller { ) DispatchQueue.main.async { + let allRequestedComponentsReady = components.allSatisfy { + RuntimePaths.isInstalled($0) + } if terminated.terminationStatus == 0, - RuntimePaths.isRuntimeReady { + RuntimePaths.isRuntimeReady, + allRequestedComponentsReady { completion(.success(())) } else { completion(.failure( diff --git a/Sources/VoiceSwitch/RuntimePaths.swift b/Sources/VoiceSwitch/RuntimePaths.swift index e20c4e8..7f899b0 100644 --- a/Sources/VoiceSwitch/RuntimePaths.swift +++ b/Sources/VoiceSwitch/RuntimePaths.swift @@ -86,15 +86,65 @@ enum RuntimePaths { static var isRuntimeReady: Bool { guard FileManager.default.isExecutableFile(atPath: pythonExecutable.path), - let marker = try? String( - contentsOf: runtimeRoot.appendingPathComponent("install-complete.txt"), - encoding: .utf8 - ) else { + let marker = installMarkerContents else { return false } return marker.contains("runtime_version=\(expectedRuntimeVersion)") } + static var installedComponents: Set { + guard isRuntimeReady, let marker = installMarkerContents else { + return [] + } + + let explicitComponents = Set( + marker + .split(separator: "\n") + .compactMap { line -> RuntimeComponent? in + let prefix = "component=" + guard line.hasPrefix(prefix) else { return nil } + return RuntimeComponent(rawValue: String(line.dropFirst(prefix.count))) + } + ) + + if marker.contains("selective_install=1") { + return explicitComponents.union(componentMarkerComponents) + } + + // Runtime v4 до выборочной установки всегда загружал все четыре модели. + // Считаем его полным, чтобы обновление приложения не заставляло людей + // повторно скачивать уже существующие веса. + return Set(RuntimeComponent.allCases) + } + + static func isInstalled(_ component: RuntimeComponent) -> Bool { + installedComponents.contains(component) + } + + static var installMarker: URL { + runtimeRoot.appendingPathComponent("install-complete.txt") + } + + static var componentMarkersRoot: URL { + runtimeRoot.appendingPathComponent("components", isDirectory: true) + } + + static func componentMarker(_ component: RuntimeComponent) -> URL { + componentMarkersRoot.appendingPathComponent("\(component.rawValue).ready") + } + + private static var installMarkerContents: String? { + try? String(contentsOf: installMarker, encoding: .utf8) + } + + private static var componentMarkerComponents: Set { + Set( + RuntimeComponent.allCases.filter { + FileManager.default.fileExists(atPath: componentMarker($0).path) + } + ) + } + static var modelCache: URL { runtimeRoot.appendingPathComponent("Models", isDirectory: true) } diff --git a/Sources/VoiceSwitch/TextInjector.swift b/Sources/VoiceSwitch/TextInjector.swift index 676e8ff..e3d5cc9 100644 --- a/Sources/VoiceSwitch/TextInjector.swift +++ b/Sources/VoiceSwitch/TextInjector.swift @@ -34,6 +34,14 @@ final class FrontmostApplicationTracker { } enum TextInjector { + private static let focusRestoreDelay: TimeInterval = 0.35 + + private struct AccessibilityAttempt { + let succeeded: Bool + let role: String + let errorCode: Int32 + } + static func copy(_ text: String) { let pasteboard = NSPasteboard.general pasteboard.clearContents() @@ -41,35 +49,198 @@ enum TextInjector { } static func paste(_ text: String, into targetPID: pid_t?) -> Bool { + let accessibilityAuthorized = Permissions.accessibilityAuthorized + let initialTargetName = targetPID + .flatMap(NSRunningApplication.init(processIdentifier:))? + .localizedName ?? "unknown" + ComparisonLogger.appendInjection( + targetPID: targetPID, + targetName: initialTargetName, + frontmostPID: NSWorkspace.shared.frontmostApplication?.processIdentifier, + method: "begin", + result: accessibilityAuthorized ? "authorized" : "permission_denied", + focusedRole: "", + errorCode: 0 + ) + copy(text) - guard Permissions.accessibilityAuthorized else { + guard accessibilityAuthorized else { return false } - if let targetPID, - let target = NSRunningApplication(processIdentifier: targetPID) { - target.activate(options: []) + let currentApplication = NSWorkspace.shared.frontmostApplication + let currentPID = currentApplication.flatMap { application -> pid_t? in + guard application.processIdentifier != ProcessInfo.processInfo.processIdentifier, + !application.isTerminated else { + return nil + } + return application.processIdentifier + } + let validTargetPID = currentPID ?? targetPID.flatMap { pid -> pid_t? in + guard let target = NSRunningApplication(processIdentifier: pid), + !target.isTerminated else { + return nil + } + return pid } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { - guard let source = CGEventSource(stateID: .hidSystemState), - let keyDown = CGEvent( - keyboardEventSource: source, - virtualKey: 0x09, - keyDown: true - ), - let keyUp = CGEvent( - keyboardEventSource: source, - virtualKey: 0x09, - keyDown: false - ) else { + let targetApplication = validTargetPID.flatMap(NSRunningApplication.init(processIdentifier:)) + let targetName = targetApplication?.localizedName ?? "unknown" + targetApplication?.activate(options: []) + + DispatchQueue.main.asyncAfter(deadline: .now() + focusRestoreDelay) { + let frontmostPID = NSWorkspace.shared.frontmostApplication?.processIdentifier + if let validTargetPID { + let attempt = insertIntoFocusedElement( + text, + applicationPID: validTargetPID + ) + if attempt.succeeded { + ComparisonLogger.appendInjection( + targetPID: validTargetPID, + targetName: targetName, + frontmostPID: frontmostPID, + method: "accessibility_selected_text", + result: "success", + focusedRole: attempt.role, + errorCode: attempt.errorCode + ) + return + } + + let method = frontmostPID == validTargetPID + ? "keyboard_global" + : "keyboard_target_pid" + postPasteShortcut( + to: frontmostPID == validTargetPID ? nil : validTargetPID + ) + ComparisonLogger.appendInjection( + targetPID: validTargetPID, + targetName: targetName, + frontmostPID: frontmostPID, + method: method, + result: "fallback_posted", + focusedRole: attempt.role, + errorCode: attempt.errorCode + ) return } - keyDown.flags = .maskCommand - keyUp.flags = .maskCommand - keyDown.post(tap: .cghidEventTap) - keyUp.post(tap: .cghidEventTap) + + postPasteShortcut(to: nil) + ComparisonLogger.appendInjection( + targetPID: nil, + targetName: "unknown", + frontmostPID: frontmostPID, + method: "keyboard_global", + result: "fallback_posted", + focusedRole: "", + errorCode: 0 + ) } return true } + + private static func insertIntoFocusedElement( + _ text: String, + applicationPID: pid_t + ) -> AccessibilityAttempt { + let application = AXUIElementCreateApplication(applicationPID) + var focusedValue: CFTypeRef? + let focusedResult = AXUIElementCopyAttributeValue( + application, + kAXFocusedUIElementAttribute as CFString, + &focusedValue + ) + guard focusedResult == .success, let focusedValue else { + return AccessibilityAttempt( + succeeded: false, + role: "", + errorCode: focusedResult.rawValue + ) + } + + let focusedElement = focusedValue as! AXUIElement + var roleValue: CFTypeRef? + _ = AXUIElementCopyAttributeValue( + focusedElement, + kAXRoleAttribute as CFString, + &roleValue + ) + let role = roleValue as? String ?? "" + var selectedTextSettable = DarwinBoolean(false) + let settableResult = AXUIElementIsAttributeSettable( + focusedElement, + kAXSelectedTextAttribute as CFString, + &selectedTextSettable + ) + guard settableResult == .success, selectedTextSettable.boolValue else { + return AccessibilityAttempt( + succeeded: false, + role: role, + errorCode: settableResult.rawValue + ) + } + + var valueBeforeInsertion: CFTypeRef? + let valueBeforeResult = AXUIElementCopyAttributeValue( + focusedElement, + kAXValueAttribute as CFString, + &valueBeforeInsertion + ) + let insertionResult = AXUIElementSetAttributeValue( + focusedElement, + kAXSelectedTextAttribute as CFString, + text as CFString + ) + var valueAfterInsertion: CFTypeRef? + let valueAfterResult = AXUIElementCopyAttributeValue( + focusedElement, + kAXValueAttribute as CFString, + &valueAfterInsertion + ) + let valueChanged = valueBeforeResult != .success + || valueAfterResult != .success + || (valueBeforeInsertion as? String) != (valueAfterInsertion as? String) + + return AccessibilityAttempt( + succeeded: insertionResult == .success && valueChanged, + role: role, + errorCode: insertionResult == .success && !valueChanged + ? -1 + : insertionResult.rawValue + ) + } + + private static func postPasteShortcut(to targetPID: pid_t?) { + guard let source = CGEventSource(stateID: .hidSystemState), + let keyDown = CGEvent( + keyboardEventSource: source, + virtualKey: 0x09, + keyDown: true + ), + let keyUp = CGEvent( + keyboardEventSource: source, + virtualKey: 0x09, + keyDown: false + ) else { + return + } + + keyDown.flags = .maskCommand + keyUp.flags = .maskCommand + + if let targetPID { + keyDown.postToPid(targetPID) + } else { + keyDown.post(tap: .cghidEventTap) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { + if let targetPID { + keyUp.postToPid(targetPID) + } else { + keyUp.post(tap: .cghidEventTap) + } + } + } } diff --git a/Sources/VoiceSwitch/TextProcessingService.swift b/Sources/VoiceSwitch/TextProcessingService.swift index 7619aaa..8f6158d 100644 --- a/Sources/VoiceSwitch/TextProcessingService.swift +++ b/Sources/VoiceSwitch/TextProcessingService.swift @@ -69,6 +69,11 @@ final class TextProcessingService { } private func ensureWorker() throws { + guard RuntimePaths.isInstalled(.textEditor) else { + throw VoiceSwitchError.runtimeMissing( + "Локальный редактор Qwen3-4B ещё не установлен. Выберите его в меню VoiceSwitch." + ) + } if let process, process.isRunning { return } diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 7c4e4e9..79cca92 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -85,6 +85,13 @@ if [[ "${VOICESWITCH_INCLUDE_RUNTIME:-0}" == "1" && -d "${PROJECT_ROOT}/Runtime" cp -cR "${PROJECT_ROOT}/Runtime/." "${OUTPUT_ROOT}/Runtime/" fi -codesign --force --deep --sign - "${APP_ROOT}" +SIGNING_IDENTITY="${VOICESWITCH_CODESIGN_IDENTITY:--}" +if [[ -z "${VOICESWITCH_CODESIGN_IDENTITY:-}" ]] && + security find-identity -v -p codesigning 2>/dev/null | + /usr/bin/grep -Fq '"VoiceSwitch Local Signing"'; then + SIGNING_IDENTITY="VoiceSwitch Local Signing" +fi + +codesign --force --deep --sign "${SIGNING_IDENTITY}" "${APP_ROOT}" print "Собрано: ${APP_ROOT}" diff --git a/scripts/package_release.sh b/scripts/package_release.sh index 592873d..97090c7 100755 --- a/scripts/package_release.sh +++ b/scripts/package_release.sh @@ -8,6 +8,8 @@ ARCH="$(uname -m)" DIST_ROOT="${PROJECT_ROOT}/dist" STAGING_ROOT="${DIST_ROOT}/VoiceSwitch-${VERSION}-macos-${ARCH}" ARCHIVE="${DIST_ROOT}/VoiceSwitch-${VERSION}-macos-${ARCH}.zip" +DMG="${DIST_ROOT}/VoiceSwitch-${VERSION}-macos-${ARCH}.dmg" +DMG_STAGING="${DIST_ROOT}/.dmg-${VERSION}-${ARCH}" if [[ "${ARCH}" != "arm64" ]]; then print -u2 "Release beta поддерживает только Apple Silicon (arm64)." @@ -18,6 +20,7 @@ fi mkdir -p "${STAGING_ROOT}" "${DIST_ROOT}" VOICESWITCH_INCLUDE_RUNTIME=0 \ +VOICESWITCH_CODESIGN_IDENTITY="${VOICESWITCH_CODESIGN_IDENTITY:--}" \ "${PROJECT_ROOT}/scripts/build_app.sh" "${STAGING_ROOT}" cp "${PROJECT_ROOT}/INSTALL.md" "${STAGING_ROOT}/INSTALL.md" @@ -40,4 +43,26 @@ ARCHIVE_NAME="${ARCHIVE:t}" cd "${DIST_ROOT}" shasum -a 256 "${ARCHIVE_NAME}" > "${ARCHIVE_NAME}.sha256" ) + +/bin/rm -rf "${DMG_STAGING}" +mkdir -p "${DMG_STAGING}" +ditto "${STAGING_ROOT}/VoiceSwitch.app" "${DMG_STAGING}/VoiceSwitch.app" +ln -s /Applications "${DMG_STAGING}/Программы" +cp "${PROJECT_ROOT}/Resources/ПЕРВЫЙ ЗАПУСК.txt" "${DMG_STAGING}/ПЕРВЫЙ ЗАПУСК.txt" + +/bin/rm -f "${DMG}" +hdiutil create \ + -volname "VoiceSwitch" \ + -srcfolder "${DMG_STAGING}" \ + -ov \ + -format UDZO \ + "${DMG}" +/bin/rm -rf "${DMG_STAGING}" + +DMG_NAME="${DMG:t}" +( + cd "${DIST_ROOT}" + shasum -a 256 "${DMG_NAME}" > "${DMG_NAME}.sha256" +) print "Релиз: ${ARCHIVE}" +print "Установочный образ: ${DMG}" diff --git a/scripts/setup_models.sh b/scripts/setup_models.sh index 66603e1..80e9624 100755 --- a/scripts/setup_models.sh +++ b/scripts/setup_models.sh @@ -4,8 +4,10 @@ set -euo pipefail SCRIPT_DIR=${0:A:h} PROJECT_ROOT=${SCRIPT_DIR:h} RUNTIME_ROOT="${PROJECT_ROOT}/Runtime" +COMPONENTS="${1:-gigaam,whisper,qwen,text}" "${PROJECT_ROOT}/Resources/install_runtime.sh" \ "${RUNTIME_ROOT}" \ "${PROJECT_ROOT}/worker/asr_worker.py" \ - "${PROJECT_ROOT}/worker/text_worker.py" + "${PROJECT_ROOT}/worker/text_worker.py" \ + "${COMPONENTS}"