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