chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+1104
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
# G-ART — Engine Overview
|
||||
|
||||
Este documento descreve a arquitetura interna do **G-ART / fcp-mcp-server**
|
||||
(repositório `G-ART`), uma aplicação **MCP (Model Context Protocol) server em
|
||||
Python** que lê, analisa e reescreve arquivos **FCPXML** do Final Cut Pro —
|
||||
a ponte entre o Final Cut Pro e IA.
|
||||
|
||||
Diferente do CommandPost (automação de GUI via Lua/Hammerspoon), este projeto
|
||||
opera **fora** do Final Cut Pro: você exporta o XML, o servidor processa o
|
||||
documento como dados estruturados e devolve um XML modificado para importação.
|
||||
Nada é patcheado, nenhuma API privada é usada.
|
||||
|
||||
Toda a análise foi feita a partir do código-fonte em `server.py` e `fcpxml/`.
|
||||
|
||||
> **Guia rápido:** [01 Arquitetura](docs/01_ARCHITECTURE.md) ·
|
||||
> [02 Módulos](docs/02_MODULES.md) · [03 Server/Tools](docs/03_SERVER_TOOLS.md) ·
|
||||
> [04 Testes & Workflow](docs/04_TESTS_AND_WORKFLOW.md) ·
|
||||
> [05 Experiências](docs/05_EXPERIENCIAS.md) · [06 Boas Práticas](docs/06_BOAS_PRATICAS.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. O que o programa faz
|
||||
|
||||
1. **Um motor de parse/serialização FCPXML** — transforma timelines do Final
|
||||
Cut Pro (XML v1.8–v1.14, flat `.fcpxml` e bundles `.fcpxmld`) em objetos
|
||||
Python, e reescreve de volta sem perda de sidecars (object tracking,
|
||||
Cinematic).
|
||||
|
||||
2. **Uma camada MCP de 62 ferramentas** — expõe análise, edição em lote, QC,
|
||||
geração, exportação cross-NLE, inteligência de mídia (silêncio/beats) e
|
||||
edição baseada em transcrição, tudo acessível por um cliente MCP (Claude).
|
||||
|
||||
3. **Modo Live (macOS)** — faz *push* de um FCPXML direto para o Final Cut Pro
|
||||
em execução via Apple events oficiais (Open Document), sem re-importação
|
||||
manual. Leitura de bibliotecas abertas via dicionário AppleScript read-only.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pilha tecnológica
|
||||
|
||||
| Camada | Tecnologia |
|
||||
|--------|-----------|
|
||||
| Linguagem | **Python 3.10+** (~7.1k linhas em `server.py` + `fcpxml/`) |
|
||||
| Protocolo MCP | **mcp** (`mcp` SDK), servidor por stdio |
|
||||
| Parsing XML | **defusedxml** em todos os 4 entry points + `lxml`/`ElementTree` |
|
||||
| Tempo racional | frações `numerador/denominador` no formato `"600/2400s"` |
|
||||
| Análise de mídia | **ffmpeg** `silencedetect` (opcional) e **librosa** (extra `[intelligence]`) |
|
||||
| Transcrição | **Whisper** local (extra `[transcribe]`) |
|
||||
| Controle Live | **osascript** / Apple events para o bundle `com.apple.FinalCut` |
|
||||
| Validação | **xmllint** contra os DTDs oficiais do bundle do Final Cut Pro |
|
||||
| Licença | MIT |
|
||||
|
||||
---
|
||||
|
||||
## 3. Estrutura geral do repositório
|
||||
|
||||
```
|
||||
G-ART/
|
||||
├── server.py # MCP server — 62 tools, prompts, resources, dispatch
|
||||
├── fcpxml/ # "Engine" — biblioteca Python de núcleo
|
||||
│ ├── models.py # TimeValue, Timecode, Clip, Timeline, enums, QC models
|
||||
│ ├── parser.py # FCPXML → objetos Python (spine, connected clips, roles)
|
||||
│ ├── writer.py # Modifica e grava FCPXML (markers, trim, gaps, speed)
|
||||
│ ├── rough_cut.py # Gera timelines novas (rough cuts, montages, A/B)
|
||||
│ ├── diff.py # Motor de comparação de timelines
|
||||
│ ├── export.py # Export DaVinci Resolve v1.9 + FCP7 XMEML v5
|
||||
│ ├── media_intel.py # Detecção real de silêncio (ffmpeg) e beats (librosa)
|
||||
│ ├── transcribe.py # Transcrição Whisper local + edição por transcrição
|
||||
│ ├── templates.py # Templates de timeline (intro/outro, lower thirds)
|
||||
│ ├── live.py # Modo Live — push_to_fcp / list_fcp_libraries
|
||||
│ ├── safe_xml.py # Wrappers defusedxml + serialize_xml()
|
||||
│ └── dtd.py # Validação contra DTDs oficiais da Apple
|
||||
├── Engine/ # Esta documentação da arquitetura
|
||||
├── admin/ # Scripts de manutenção (graphify.sh, graphify.md)
|
||||
├── docs/ # WORKFLOWS, CAPABILITY-AUDIT, specs
|
||||
├── examples/ # Fixture de teste (sample.fcpxml)
|
||||
├── tests/ # 1032 testes / 24 suítes
|
||||
└── tools/ # Pacote Python (__init__)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. O "Engine": a biblioteca `fcpxml/`
|
||||
|
||||
É o núcleo desacoplado do MCP. Não conhece o protocolo MCP nem os argumentos
|
||||
das ferramentas — trabalha apenas com objetos Python e XML. `server.py` atua
|
||||
como *camada de transporte/adaptação* que chama este núcleo.
|
||||
|
||||
### 4.1 Fundamentos de tempo — `models.TimeValue`
|
||||
|
||||
Toda hora é uma fração racional, nunca float. Isso elimina erro de arredondamento
|
||||
em trim/split/speed em qualquer frame rate:
|
||||
|
||||
```python
|
||||
TimeValue(600, 2400) # "600/2400s" == 0.25s
|
||||
```
|
||||
|
||||
- Comparações por **multiplicação cruzada** (`a/b < c/d` → `a*d < c*b`),
|
||||
permanecendo sempre em inteiros.
|
||||
- Denominadores normalizados para positivos na construção — o sinal vive no
|
||||
numerador.
|
||||
- Soma/subtração compartilham um único caminho `_binop()` (fast-path de mesmo
|
||||
denominador + alinhamento por LCM).
|
||||
|
||||
### 4.2 Modelos principais — `models.py`
|
||||
|
||||
| Classe | Função |
|
||||
|--------|--------|
|
||||
| `TimeValue`, `Timecode` | Tempo racional e formatação/parse de timecode |
|
||||
| `Clip`, `VideoClip`, `AudioClip` | Clips da timeline (offset, start, duration, markers) |
|
||||
| `ConnectedClip` | Clips com atributo `lane` (acima/abaixo da espinha) |
|
||||
| `CompoundClip` | Clips compostos |
|
||||
| `Timeline`, `Project` | Contêineres de espinha + connected clips |
|
||||
| `Marker`, `MarkerType`, `MarkerColor` | Marcadores; `INCOMPLETE` é canônico, `TODO` é alias |
|
||||
| `SilenceCandidate`, `FlashFrame`, `GapInfo`, `DuplicateGroup` | Resultados de QC |
|
||||
| `ValidationIssue`, `ValidationResult` | Resultados de validação |
|
||||
| `SegmentSpec`, `PacingConfig`, `MontageConfig` | Parâmetros de geração |
|
||||
|
||||
**Single source of truth**: `MarkerType` enum é dono da serialização —
|
||||
`from_string()` para entrada, `from_xml_element()` para parse, `xml_attrs` para
|
||||
escrita. `from_xml_element` faz match estrito do atributo `completed`
|
||||
(`'0'`/`'1'` apenas), rejeitando valores com padding de espaço.
|
||||
|
||||
### 4.3 Subsistemas
|
||||
|
||||
| Subsistema | Módulo | Função |
|
||||
|-----------|--------|--------|
|
||||
| Parser | `parser.py` | FCPXML → objetos Python: espinha, connected clips, secondary storylines, roles |
|
||||
| Modifier | `writer.FCPXMLModifier` | Edição index-based (clips/resources/formats dicts) do documento existente |
|
||||
| Writer | `writer.FCPXMLWriter` | Gera FCPXML novo a partir de objetos Python |
|
||||
| Rough cut | `rough_cut.py` | Gera timelines (rough cuts, montages, A/B roll) |
|
||||
| Diff | `diff.py` | Compara timelines — detecta added/removed/moved/trimmed |
|
||||
| Export | `export.py` | DaVinci Resolve v1.9 + FCP7 XMEML v5 |
|
||||
| Media intel | `media_intel.py` | Detecção real de silêncio (ffmpeg) e beats (librosa, lazy) |
|
||||
| Transcript | `transcribe.py` | Whisper local + edição por transcrição |
|
||||
| Templates | `templates.py` | Estruturas pré-prontas (intro/outro, lower thirds, music video) |
|
||||
| Live | `live.py` | push_to_fcp (Apple event) e list_fcp_libraries (AppleScript) |
|
||||
| Segurança XML | `safe_xml.py` | Wrappers defusedxml centralizados + `serialize_xml()` |
|
||||
| DTD | `dtd.py` | Valida output contra DTDs oficiais no bundle do FCP |
|
||||
|
||||
---
|
||||
|
||||
## 5. A camada MCP — `server.py`
|
||||
|
||||
### 5.1 Padrão de dispatch
|
||||
|
||||
Não há cadeias gigantes de `if/elif`. Um dicionário mapeia nome → handler
|
||||
assíncrono:
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
"analyze_timeline": handle_analyze_timeline,
|
||||
"list_clips": handle_list_clips,
|
||||
# ... 62 tools
|
||||
}
|
||||
```
|
||||
|
||||
Cada ferramenta tem seu `async def handle_<name>(arguments: dict)`. Todas
|
||||
retornam via `_text_result(text)`, que envolve strings no `TextContent` MCP.
|
||||
|
||||
### 5.2 Helpers centrais
|
||||
|
||||
| Helper | Linha | Função |
|
||||
|--------|-------|--------|
|
||||
| `_parse_project()` | `server.py:319` | Parseia FCPXML → `(tree, timeline, project)`; a maioria dos handlers começa aqui |
|
||||
| `_resolve_io_paths()` | `server.py:357` | Consolida validação de caminho de entrada/saída |
|
||||
| `_setup_modifier()` / `_setup_generator()` | `server.py:390` / `:414` | Preparam modifier/generator com validação |
|
||||
| `_format_clip_table()` / `_markdown_table()` | `server.py:245` / `:259` | Renderização de tabelas |
|
||||
| `_parse_timestamp_parts()` | `server.py:433` | Parse de timestamps (min:seg, H:MM:SS, SMPTE) |
|
||||
| `_detect_flash_frames()` / `_detect_gaps()` / `_detect_duplicate_groups()` | `server.py:1667+` | Detectores de QC |
|
||||
| `_validate_filepath()` / `_validate_output_path()` | `server.py:103` / `:149` | Sandbox de I/O |
|
||||
| `_check_json_depth()` | `server.py:83` | Rejeita payloads aninhados além de 50 níveis |
|
||||
|
||||
### 5.3 Modo Live — `fcpxml/live.py`
|
||||
|
||||
Rode apenas as superfícies sancionadas da Apple — sem patch de binário, sem
|
||||
APIs privadas, sem acessibilidade:
|
||||
|
||||
- **push_to_fcp** — import FCPXML via Apple event *Open Document*. Injeta um
|
||||
`<import-options>` no documento (local da biblioteca, copy/link assets,
|
||||
suprimir avisos). Requer um caminho `.fcpbundle` para *zero-click* de verdade;
|
||||
sem ele, o FCP abre um modal "Open Library" que bloqueia até resposta humana.
|
||||
- **list_fcp_libraries** — enumera bibliotecas → eventos → projetos via o
|
||||
dicionário AppleScript read-only (suite `com.apple.FinalCut.library.inspection`).
|
||||
|
||||
**A assimetria estrutural**: import é scriptable, mas a Apple não oferece export
|
||||
programático — para puxar a timeline atual de volta, você ainda roda
|
||||
`File > Export XML`. O modo Live *empurra*; round-trips voltam pelas ferramentas
|
||||
XML.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fluxo de um pedido
|
||||
|
||||
```
|
||||
Cliente MCP (Claude)
|
||||
│ JSON-RPC (stdio)
|
||||
▼
|
||||
server.py ── dispatcher (TOOL_HANDLERS)
|
||||
│
|
||||
├── handle_* (valida caminho, _parse_project, opera)
|
||||
│
|
||||
├── fcpxml/parser.py (XML → objetos)
|
||||
├── fcpxml/writer.py (edita / grava)
|
||||
├── fcpxml/rough_cut.py (gera novas timelines)
|
||||
├── fcpxml/export.py (cross-NLE)
|
||||
│
|
||||
▼
|
||||
output_<suffix>.fcpxml (original nunca é sobrescrito)
|
||||
│
|
||||
▼
|
||||
Final Cut Pro: File → Import → XML (ou push_to_fcp, sem cliques)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Validação padrão pós-correção (é obrigatório)
|
||||
|
||||
Regra do padrão do sistema: **sempre após concluir qualquer correção de
|
||||
código, o sistema é automaticamente executado/validado.**
|
||||
|
||||
O gatilho é o script `[Engine/run_after_fix.sh](run_after_fix.sh)`. Toda vez
|
||||
que você terminar uma correção, acione-o:
|
||||
|
||||
```bash
|
||||
./Engine/run_after_fix.sh
|
||||
```
|
||||
|
||||
Ele roda (a partir de qualquer diretório) e falha (`set -e`) se algo não
|
||||
passar:
|
||||
|
||||
1. **`uv run ruff check . --exclude docs/`** — lint com zero erros.
|
||||
2. **`uv run pytest tests/ -v`** — toda a suíte de testes passa.
|
||||
|
||||
Se falhar, corrija antes de prosseguir. Esta validação é o mesmo critério já
|
||||
descrito em `CLAUDE.md` (Pre-Commit) — a diferença é que agora há um comando
|
||||
único padronizado que garante a execução automática do sistema após cada
|
||||
correção, sem depender de lembrar dos dois comandos no pre-commit.
|
||||
|
||||
---
|
||||
|
||||
## 8. Como navegar a documentação
|
||||
|
||||
> Esta pasta `Engine/` é o **hub da documentação**. Comece por aqui.
|
||||
|
||||
### Documentação padrão (leia nesta ordem)
|
||||
- [docs/01_ARCHITECTURE.md](docs/01_ARCHITECTURE.md) — como o sistema é dividido
|
||||
(camadas: admin → server → fcpxml/) e como se conectam. **Leia antes de qualquer mudança.**
|
||||
- [docs/02_MODULES.md](docs/02_MODULES.md) — guia módulo a módulo do `fcpxml/`
|
||||
(responsabilidade, tamanho, APIs públicas).
|
||||
- [docs/03_SERVER_TOOLS.md](docs/03_SERVER_TOOLS.md) — a camada MCP `server.py`,
|
||||
62 ferramentas, helpers e o padrão de handler.
|
||||
- [docs/04_TESTS_AND_WORKFLOW.md](docs/04_TESTS_AND_WORKFLOW.md) — suíte de testes,
|
||||
fluxo de trabalho (lint + pytest), execução e estado atual do sistema.
|
||||
- [docs/05_EXPERIENCIAS.md](docs/05_EXPERIENCIAS.md) — **memória de projeto**:
|
||||
registro cumulativo de problemas estruturais, erros recorrentes e decisões.
|
||||
**Atualize sempre que um problema for detectado/corrigido.**
|
||||
- [docs/06_BOAS_PRATICAS.md](docs/06_BOAS_PRATICAS.md) — **boas práticas de
|
||||
programação** a aplicar em toda alteração/correção; inclui checklist final.
|
||||
|
||||
### Outros documentos
|
||||
- [../CLAUDE.md](../CLAUDE.md) — visão geral, key patterns, execução e pre-commit.
|
||||
- [../docs/CAPABILITY-AUDIT-2026-06.md](../docs/CAPABILITY-AUDIT-2026-06.md) —
|
||||
auditoria do ecossistema e roadmap dual-mode (XML + Live).
|
||||
- [../docs/WORKFLOWS.md](../docs/WORKFLOWS.md) — 8 receitas de workflow de produção.
|
||||
- [../docs/specs/](../docs/specs/) — schemas de tools, estrutura FCPXML, pseudocódigo
|
||||
do writer, algoritmo de rough cut, implementação do server, roadmap, modelos.
|
||||
- [../admin/graphify.md](../admin/graphify.md) — pipeline de graphify do código.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 01 — Arquitetura do Sistema (G-ART / fcp-mcp-server)
|
||||
|
||||
> Referência canônica de como o sistema está dividido e implementado. Leia este
|
||||
> documento antes de qualquer mudança de código.
|
||||
|
||||
## 1. Visão de cima (camadas)
|
||||
|
||||
O sistema é um **servidor MCP em Python** que lê/analisa/reescreve arquivos
|
||||
**FCPXML** do Final Cut Pro. Há **três camadas** bem separadas:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ admin/ — Aplicações complementares (fora do MCP) │
|
||||
│ models_api.py API (FastAPI) p/ gerenciar modelos │
|
||||
│ models_gui.py UI desktop (Flet) p/ gerenciar modelos │
|
||||
│ graphify.sh/.md Pipeline de graphify do código │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ server.py — CAMADA MCP / TRANSPORTE (NÃO tem lógica) │
|
||||
│ 62 tools, handlers, prompts, resources, dispatch │
|
||||
│ Só valida entrada/saída e traduz JSON-RPC → chamadas │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ fcpxml/ — "ENGINE" = NÚCLEO PURO Python (desacoplado) │
|
||||
│ Não conhece MCP nem argumentos de tool. │
|
||||
│ Trabalha com objetos Python e XML. │
|
||||
│ É o foco / onde quase tudo mora. │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Regra de arquitetura:** `server.py` NUNCA implementa lógica de timeline —
|
||||
ele delega ao `fcpxml/`. Tudo em `fcpxml/` é testável isoladamente (1032 testes).
|
||||
|
||||
## 2. Regras transversais (convenções em todo o código)
|
||||
|
||||
| Conceito | Regra |
|
||||
|----------|-------|
|
||||
| **Tempo** | `TimeValue` fração racional `"600/2400s"`. Nunca use float p/ tempo. |
|
||||
| **I/O paths** | Sempre via helpers `_validate_filepath` / `_validate_output_path` (sandbox). |
|
||||
| **Nome de saída** | Nunca sobrescrever original: `output_<suffix>.fcpxml`. |
|
||||
| **Segurança XML** | Sempre `defusedxml` (via `safe_xml.py`). Nunca `xml.etree` direto. |
|
||||
| **Deps opcionais** | `librosa`/`ffmpeg`/`huggingface_hub` importados **lazy**, degradam com `None`. |
|
||||
| **Lint** | `ruff check . --exclude docs/` — zero erros. |
|
||||
| **Validação pós-correção** | `./Engine/run_after_fix.sh` SEMPRE após cada correção. |
|
||||
|
||||
## 3. Fluxo de um request (round-trip)
|
||||
|
||||
```
|
||||
Cliente MCP (Claude)
|
||||
│ JSON-RPC (stdio)
|
||||
▼
|
||||
server.py ── dispatcher (TOOL_HANDLERS)
|
||||
│ valida path, parseia projeto, chama engine
|
||||
▼
|
||||
fcpxml/parser.py XML → objetos
|
||||
fcpxml/writer.py edita / grava
|
||||
fcpxml/rough_cut.py gera novas timelines
|
||||
fcpxml/export.py cross-NLE
|
||||
▼
|
||||
output_<suffix>.fcpxml (original intocado)
|
||||
▼
|
||||
Final Cut Pro: File → Import → XML (ou push_to_fcp, sem cliques)
|
||||
```
|
||||
|
||||
## 4. Dual-mode: XML + Live
|
||||
|
||||
O sistema opera em **dois modos complementares**:
|
||||
|
||||
- **Modo XML (principal):** exporta FCPXML, processa como dados, reimporta.
|
||||
Roda fora do FCP. Nenhuma API privada.
|
||||
- **Modo Live (`fcpxml/live.py`):** *push* do FCPXML direto p/ o FCP em
|
||||
execução via Apple events oficiais (`Open Document`), com `import-options`.
|
||||
Leitura de bibliotecas via AppleScript read-only.
|
||||
|
||||
**Assimetria estrutural:** import é scriptable, mas a Apple não oferece export
|
||||
programático — round-trips voltam pelas ferramentas XML.
|
||||
|
||||
## 5. Onde está cada responsabilidade
|
||||
|
||||
| Responsabilidade | Fica em |
|
||||
|------------------|---------|
|
||||
| Modelos de dados (tempo, clips, markers) | `fcpxml/models.py` |
|
||||
| Parse FCPXML → objetos | `fcpxml/parser.py` |
|
||||
| Editing/escrita (modifier + writer) | `fcpxml/writer.py` |
|
||||
| Geração de timeline nova | `fcpxml/rough_cut.py` |
|
||||
| Comparação de timelines | `fcpxml/diff.py` |
|
||||
| Export cross-NLE (Resolve, FCP7) | `fcpxml/export.py` |
|
||||
| Inteligência de mídia (silêncio/beats) | `fcpxml/media_intel.py` |
|
||||
| Transcrição Whisper local | `fcpxml/transcribe.py` |
|
||||
| Gestão de modelos Whisper | `fcpxml/model_manager.py` |
|
||||
| Templates de timeline | `fcpxml/templates.py` |
|
||||
| Controle Live do FCP | `fcpxml/live.py` |
|
||||
| Segurança XML (`defusedxml`, `serialize_xml`) | `fcpxml/safe_xml.py` |
|
||||
| Validação contra DTDs da Apple | `fcpxml/dtd.py` |
|
||||
| Transporte MCP (62 tools) | `server.py` |
|
||||
|
||||
## 6. Mapa de dependências (você está aqui se for mexer no X → quem tocar)
|
||||
|
||||
```
|
||||
server.py ──► fcpxml/parser, writer, rough_cut, export, diff,
|
||||
media_intel, transcribe, templates, live, dtd
|
||||
admin/models_gui.py ──► fcpxml/media_intel, model_manager,
|
||||
parser, transcribe
|
||||
admin/models_api.py ──► fcpxml/model_manager
|
||||
fcpxml/writer.py ──► fcpxml/models, safe_xml, dtd
|
||||
fcpxml/__init__.py ──► reexporta a API pública
|
||||
```
|
||||
|
||||
> Se você cria uma **nova ferramenta MCP**, o trabalho principal é em `fcpxml/`
|
||||
> (função pura + testes). O handler em `server.py` fica fino: validação de
|
||||
> caminho → `_parse_project` → chama a função → `_text_result`.
|
||||
@@ -0,0 +1,114 @@
|
||||
# 02 — Módulos do Engine (`fcpxml/`)
|
||||
|
||||
Guia módulo a módulo do núcleo Python. Tamanho em linhas, responsabilidade e as
|
||||
funções/classes públicas de cada um. APIs públicas são reexportadas em
|
||||
`fcpxml/__init__.py` (fonte da verdade para o `__all__`).
|
||||
|
||||
## Versão atual
|
||||
`__version__ = "0.6.35"` — ver `fcpxml/__init__.py`.
|
||||
|
||||
---
|
||||
|
||||
| Módulo | Linhas | Papel |
|
||||
|--------|-------:|-------|
|
||||
| `models.py` | 930 | Data classes e enums (tempo, clips, markers, QC) |
|
||||
| `parser.py` | 367 | FCPXML → objetos Python |
|
||||
| `writer.py` | 3154 | Edição e escrita de FCPXML (o maior) |
|
||||
| `rough_cut.py` | 798 | Geração de timelines novas |
|
||||
| `dtd.py` | 112 | Validação contra DTDs oficiais |
|
||||
| `safe_xml.py` | 113 | Wrappers `defusedxml` + `serialize_xml()` |
|
||||
| `media_intel.py` | 173 | Silêncio (ffmpeg) e beats (librosa) |
|
||||
| `transcribe.py` | 184 | Transcrição Whisper + edição por transcrição |
|
||||
| `model_manager.py` | 298 | Gestão de modelos Whisper (cache/catálogo) |
|
||||
| `export.py` | 226 | Export DaVinci Resolve v1.9 + FCP7 XMEML v5 |
|
||||
| `diff.py` | 269 | Comparação de timelines |
|
||||
| `live.py` | 273 | Modo Live — push_to_fcp / list_fcp_libraries |
|
||||
| `templates.py` | 387 | Templates de timeline |
|
||||
| `__init__.py` | 139 | Reexporta API pública |
|
||||
|
||||
---
|
||||
|
||||
## `models.py` — modelos e enums
|
||||
Single source of truth para estrutura de dados. NUNCA mexa aqui sem rodar
|
||||
`test_models.py`.
|
||||
|
||||
- **Tempo:** `TimeValue` (fração racional), `Timecode`.
|
||||
- **Clips:** `Clip`, `VideoClip`, `AudioClip`, `ConnectedClip` (lane),
|
||||
`CompoundClip`, `Transition`.
|
||||
- **Contêineres:** `Timeline`, `Project`, `Keyword`.
|
||||
- **Markers:** `Marker`, `MarkerType`, `MarkerColor`, `MARKER_XML_TAGS`.
|
||||
`MarkerType` é o dono da serialização (`from_string`/`from_xml_element`/`xml_attrs`).
|
||||
Match estrito do atributo `completed` (`'0'`/`'1'`, sem padding).
|
||||
- **QC:** `SilenceCandidate`, `FlashFrame`, `GapInfo`, `DuplicateGroup`,
|
||||
`ValidationIssue`, `ValidationResult`.
|
||||
- **Geração:** `SegmentSpec`, `PacingConfig`, `PacingStyle`, `RoughCutResult`.
|
||||
|
||||
## `parser.py` — leitura
|
||||
- `parse_fcpxml(path)` → `Project`.
|
||||
- `FCPXMLParser` — lê spine, connected clips (lanes), secondary storylines, roles.
|
||||
|
||||
## `writer.py` — o coração (3154 linhas)
|
||||
Duas classes principais:
|
||||
|
||||
- **`FCPXMLModifier`** — edita documento existente de forma index-based
|
||||
(dicts de `clips`/`resources`/`formats`), imune a ambiguidade de nomes duplicados.
|
||||
Métodos: `insert_clip`, `add_marker`, `trim_clip`, `delete_clip`, `split_clip`,
|
||||
`change_speed`, `cut_clip_ranges` (usado pela remoção de silêncio), etc.
|
||||
- **`FCPXMLWriter`** — gera FCPXML novo a partir de objetos Python.
|
||||
|
||||
Helpers de nível de arquivo: `modify_fcpxml`, `add_marker_to_file`,
|
||||
`trim_clip_in_file`, `build_marker_element`, `write_fcpxml`, `validate_fcpxml`,
|
||||
`list_effects`, `FCP_EFFECTS`.
|
||||
|
||||
## `rough_cut.py` — geração
|
||||
- `RoughCutGenerator`, `generate_rough_cut`, `generate_segmented_rough_cut`.
|
||||
|
||||
## `media_intel.py` — inteligência de mídia (v0.10)
|
||||
- Silêncio via `ffmpeg silencedetect` (subprocess limitado), `remove_silence_candidates`,
|
||||
mapeamento source→timeline.
|
||||
- Beats via `librosa` (import lazy, extra `[intelligence]`).
|
||||
- Degrada para `None` quando `ffmpeg` ausente.
|
||||
|
||||
## `transcribe.py` — Whisper local
|
||||
- `transcribe(media_path, model_size, language)` → dict com `words` (spans).
|
||||
- `ALLOWED_MODELS` — allowlist de nomes de modelo (também usado por `model_manager`).
|
||||
- Edição por transcrição: remove filler words, aparar por transcrição.
|
||||
|
||||
## `model_manager.py` — gestão de modelos
|
||||
Catálogo `models.json` + cache no HF hub. Config em `~/.fcp-mcp-server/config.json`.
|
||||
Funções: `get/save_models_dir`, `list_installed_models`, `download_model`,
|
||||
`delete_model`, `get/load_selected_model`, `save_selected_model`, `load_catalog`.
|
||||
Permite cancelamento de download via `threading.Event`. Segue convenções:
|
||||
allowlist, lazy imports, degradação graciosa.
|
||||
|
||||
## `export.py` — cross-NLE
|
||||
- `DaVinciExporter` — FCPXML v1.9 p/ DaVinci Resolve.
|
||||
- Export FCP7 XMEML v5.
|
||||
|
||||
## `diff.py` — comparação
|
||||
- `compare_timelines`, `TimelineDiff`, `ClipDiff`, `MarkerDiff`.
|
||||
- Detecta added/removed/moved/trimmed clips & markers.
|
||||
|
||||
## `live.py` — FCP ao vivo (macOS)
|
||||
- `push_to_fcp(path, library, options)` — Apple event *Open Document* + `<import-options>`.
|
||||
Requer `.fcpbundle` p/ zero-click real.
|
||||
- `list_fcp_libraries()` — AppleScript read-only.
|
||||
|
||||
## `templates.py`
|
||||
- `Template`, `TemplateSlot`, `ClipSpec`, `BUILTIN_TEMPLATES`, `apply_template`,
|
||||
`list_templates`. Estruturas prontas: intro/outro, lower thirds, music video.
|
||||
|
||||
## `safe_xml.py`
|
||||
Wrappers `defusedxml` centralizados + `serialize_xml()`. Todo parse/escrita passa aqui.
|
||||
|
||||
## `dtd.py`
|
||||
Valida output contra DTDs oficiais no bundle do FCP (via `xmllint`; exige o caminho
|
||||
do DTD percent-encoded por causa dos espaços em "Final Cut Pro.app").
|
||||
|
||||
---
|
||||
|
||||
## Como adicionar um módulo novo
|
||||
1. Criar `fcpxml/<seu_modulo>.py` — função pura, sem conhecer MCP.
|
||||
2. Reexportar classes/funções em `fcpxml/__init__.py` (`__all__`).
|
||||
3. Cobrir em `tests/test_<seu_modulo>.py`.
|
||||
4. Rodar `./Engine/run_after_fix.sh`.
|
||||
@@ -0,0 +1,84 @@
|
||||
# 03 — Camada MCP (`server.py`) — 62 ferramentas
|
||||
|
||||
`server.py` (3824 linhas) é a camada de transporte. Não tem lógica de timeline —
|
||||
mapeia nome → handler e delega ao Engine. O dispatch é um dicionário
|
||||
`TOOL_HANDLERS` (padrão de despacho, sem cadeias gigantes de if/elif).
|
||||
|
||||
## Helpers centrais (use-os, não reinvente)
|
||||
|
||||
| Helper | Linha | Função |
|
||||
|--------|------:|--------|
|
||||
| `_check_json_depth()` | 83 | Rejeita payloads além de 50 níveis |
|
||||
| `_validate_filepath()` | 103 | Sandbox de entrada |
|
||||
| `_validate_output_path()` | 149 | Sandbox de saída |
|
||||
| `_format_clip_table()` | 245 | Renderização de tabela |
|
||||
| `_markdown_table()` | 259 | Renderização de tabela markdown |
|
||||
| `_parse_project()` | 319 | Parseia FCPXML → `(tree, timeline, project)`; quase todos os handlers começam aqui |
|
||||
| `_resolve_io_paths()` | 357 | Validação de caminho de entrada/saída |
|
||||
| `_setup_modifier()` | 390 | Prepara modifier com validação |
|
||||
| `_setup_generator()` | 414 | Prepara generator com validação |
|
||||
| `_parse_timestamp_parts()` | 433 | Parse de timestamps (min:seg, H:MM:SS, SMPTE) |
|
||||
| `_detect_flash_frames/gaps/duplicate_groups()` | 1667+ | Detectores de QC |
|
||||
|
||||
## As 62 ferramentas por categoria
|
||||
|
||||
### Timeline & análise (Projeto)
|
||||
`list_projects`, `analyze_timeline`, `list_clips`, `list_markers`, `list_connected_clips`,
|
||||
`list_compound_clips`, `list_library_clips`, `list_roles`, `list_keywords`, `list_effects`.
|
||||
|
||||
### QC e detecção
|
||||
`find_short_cuts`, `find_long_clips`, `analyze_pacing`, `detect_flash_frames`,
|
||||
`detect_duplicates`, `detect_gaps`, `validate_timeline`, `detect_silence_candidates`,
|
||||
`detect_media_silence`, `remove_silence_candidates`, `remove_media_silence`, `detect_beats`.
|
||||
|
||||
### Edição
|
||||
`add_marker`, `batch_add_markers`, `trim_clip`, `reorder_clips`, `add_transition`,
|
||||
`change_speed`, `delete_clips`, `split_clip`, `insert_clip`, `fix_flash_frames`,
|
||||
`rapid_trim`, `fill_gaps`, `add_audio`, `create_compound_clip`, `flatten_compound_clip`.
|
||||
|
||||
### Geração
|
||||
`auto_rough_cut`, `generate_montage`, `generate_ab_roll`, `list_templates`, `apply_template`.
|
||||
|
||||
### Beats / markers importados
|
||||
`import_beat_markers`, `snap_to_beats`, `import_srt_markers`, `import_transcript_markers`.
|
||||
|
||||
### Roles
|
||||
`assign_role`, `filter_by_role`, `export_role_stems`.
|
||||
|
||||
### Transcrição & edição por transcrição
|
||||
`transcribe_media`, `edit_by_transcript`, `remove_filler_words`.
|
||||
|
||||
### Diferenciação
|
||||
`diff_timelines`.
|
||||
|
||||
### Export / relink
|
||||
`export_edl`, `export_csv`, `export_resolve_xml`, `export_fcp7_xml`, `relink_media`.
|
||||
|
||||
### Reformat
|
||||
`reformat_timeline`.
|
||||
|
||||
### Live (macOS)
|
||||
`push_to_fcp`, `list_fcp_libraries`.
|
||||
|
||||
---
|
||||
|
||||
## Padrão de handler (a forma de fazer)
|
||||
|
||||
```python
|
||||
async def handle_<nome>(arguments: dict):
|
||||
tree, timeline, project = _parse_project(arguments) # 1. parseia
|
||||
# ...opera com o Engine (parser/writer/rough_cut/export)...
|
||||
return _text_result(text) # 2. devolve
|
||||
```
|
||||
|
||||
Regras:
|
||||
- Todo handler valida caminho com `_validate_filepath`/`_validate_output_path`.
|
||||
- Saídas sempre com sufixo `_modified`, `_chapters`, etc. — original nunca é tocado.
|
||||
- Cada handler tem o seu `async def handle_<name>(arguments: dict)`.
|
||||
- Sempre retornam via `_text_result(text)` (envolve o texto em `TextContent` MCP).
|
||||
|
||||
## Para adicionar uma ferramenta nova
|
||||
1. Escrever a função no módulo do Engine (`fcpxml/…`) + testes.
|
||||
2. Criar `handle_<nome>` em `server.py` seguindo o padrão acima.
|
||||
3. Registrar no dicionário `TOOL_HANDLERS`.
|
||||
4. Rodar `./Engine/run_after_fix.sh`.
|
||||
@@ -0,0 +1,84 @@
|
||||
# 04 — Testes, Fluxo de Trabalho e Estado Atual
|
||||
|
||||
## 1. Suíte de testes
|
||||
|
||||
**1032 testes em 24 arquivos** em `tests/`. Rode com `uv run pytest tests/ -v`.
|
||||
|
||||
| Arquivo | Cobre |
|
||||
|---------|-------|
|
||||
| `test_models.py` | `TimeValue` (aritmética), `Timecode`, propriedades de Clip, modelos de validação, helpers de Timeline |
|
||||
| `test_writer.py` | insert_clip, add_marker (todos os tipos), trim_clip, delete_clip, split_clip, change_speed |
|
||||
| `test_server.py` | handlers MCP, parser, dispatch |
|
||||
| `test_rough_cut.py` | `RoughCutGenerator` |
|
||||
| `test_features_v05.py` | connected clips, roles, timeline diff, reformat, silêncio, export, compatibilidade |
|
||||
| `test_features_v06.py` | features da v0.6 |
|
||||
| `test_marker_pipeline.py` | `build_marker_element`, batch auto-modes, índices de clip duplicados, `write_fcpxml` |
|
||||
| `test_refactored_helpers.py` | `_index_elements`, `_iter_spine_clips`, `_find_spine_clip_at_seconds`, `_resolve_clip_duration`, `_make_asset_clip`, `_format_batch_result`, `serialize_xml` |
|
||||
| `test_transcribe.py` | spans de frase/filler, álgebra de merge/invert de intervalos, degradação do Whisper, handlers por transcrição |
|
||||
| `test_media_intel.py` | parse de `silencedetect`, mapeamento source→timeline, bounds de parâmetros, integração WAV real (skips sem ffmpeg) |
|
||||
| `test_diff.py` | comparação de timelines |
|
||||
| `test_export.py` | export Resolve / FCP7 |
|
||||
| `test_live.py` | modo live |
|
||||
| `test_security.py` | segurança de path / XML |
|
||||
| `test_edge_cases.py`, `test_diversity.py`, `test_parser.py`, `test_validation.py`, `test_bundles.py`, `test_dtd_validation.py`, `test_relink.py`, `test_speed_cutting.py`, `test_targeted_gaps.py`, `test_fcpxml_writer.py` | demais suítes |
|
||||
|
||||
**Fixtures:** `examples/sample.fcpxml` + fixtures XML inline. NOTA: `sample.fcpxml`
|
||||
NÃO é DTD-conformante (assets pré-`media-rep`, markers de capítulo no sequence) —
|
||||
não o use como fixture de validade DTD. Testes criam arquivos temporários e limpam.
|
||||
|
||||
**Obs. transcribe/media:** testes exigem deps opcionais (`ffmpeg`, whisper).
|
||||
Sem eles, os testes relevantes fazem `skip` — o CI instala.
|
||||
|
||||
## 2. Fluxo de trabalho padrão (obrigatório)
|
||||
|
||||
> **Regra do sistema:** sempre após concluir UMA correção de código, o sistema é
|
||||
> automaticamente executado/validado.
|
||||
|
||||
```bash
|
||||
./Engine/run_after_fix.sh
|
||||
```
|
||||
|
||||
O que ele faz (e falha via `set -e` se qualquer um não passar):
|
||||
|
||||
1. `uv run ruff check . --exclude docs/` → **zero erros de lint**.
|
||||
2. `uv run pytest tests/ -v` → **toda a suíte passa**.
|
||||
|
||||
### Equivalente manual (pre-commit)
|
||||
```bash
|
||||
ruff check . --exclude docs/ # lint — zero erros
|
||||
pytest tests/ -v # testes — todos passam
|
||||
```
|
||||
|
||||
O CI roda ambos em todo push para `main`. Se um falhar, o commit ganha X no GitHub.
|
||||
Corrija o lint **antes** de commitar.
|
||||
|
||||
## 3. Como rodar a aplicação
|
||||
|
||||
```bash
|
||||
uv run server.py # Inicia o servidor MCP (stdio)
|
||||
uv run python admin/models_gui.py # UI desktop de gestão de modelos (Flet)
|
||||
uv run --extra dev pytest tests/ -v # Testes com extra de dev
|
||||
```
|
||||
|
||||
## 4. Estado atual do sistema (resumo "até agora")
|
||||
|
||||
- **v0.6.35** — núcleo FCPXML completo em Python (`fcpxml/`).
|
||||
- **62 ferramentas MCP** em `server.py`, organizadas por dispatch `TOOL_HANDLERS`.
|
||||
- **Suporte FCPXML 1.8–1.14** (`.fcpxml` e bundles `.fcpxmld` com sidecars),
|
||||
escrita padrão 1.13.
|
||||
- **Dual-mode:** XML (principal) + Live (push_to_fcp / list_fcp_libraries via Apple events).
|
||||
- **Inteligência de mídia (v0.10):** silêncio via ffmpeg + beats via librosa (lazy).
|
||||
- **Transcrição local Whisper** + gestão de modelos (`model_manager.py`, catálogo
|
||||
`models.json`, cache HF, cancelamento de download).
|
||||
- **UI desktop (Flet):** `admin/models_gui.py` — aba Modelos (download/selecionar/
|
||||
remover/config pasta de modelos) e aba Transcrição (projeto FCPXML → transcrição).
|
||||
- **API complementar:** `admin/models_api.py`.
|
||||
- **Validação DTD:** contra DTDs oficiais do bundle do FCP (v0.10+).
|
||||
- **Export cross-NLE:** DaVinci Resolve v1.9 + FCP7 XMEML v5.
|
||||
|
||||
## 5. Evolução prevista (roadmaps)
|
||||
- `docs/CAPABILITY-AUDIT-2026-06.md` — auditoria do ecossistema + roadmap dual-mode.
|
||||
- `docs/specs/06_IMPLEMENTATION_ROADMAP.md` — roadmap de implementação.
|
||||
- `docs/TRANSCRIPTION-MODELS.md` — fases da gestão de modelos (MCP handlers e
|
||||
wiring em `transcribe()` vêm em fase posterior; hoje `model_manager.py` é o
|
||||
esqueleto com catálogo e primitivas de cache reais).
|
||||
@@ -0,0 +1,708 @@
|
||||
# 05 — Experiências: Registro de Problemas, Erros e Decisões
|
||||
|
||||
> **Propósito:** registrar, de forma cumulativa, todos os problemas estruturais,
|
||||
> erros que se repetiram em várias tentativas e decisões difíceis enfrentadas
|
||||
> durante o desenvolvimento do G-ART. Serve de memória de trabalho para que
|
||||
> futuras implementações **não repitam os mesmos erros** e para que decisões já
|
||||
> tomadas não sejam redescobertas do zero.
|
||||
|
||||
**Regra:** sempre que um problema for detectado (estrutural ou funcional) e
|
||||
houver uma correção ou trabalho em torno dele, **adicione um registro aqui**
|
||||
antes de prosseguir. Um problema que se repete em várias tentativas é sinal de
|
||||
que merece entrada.
|
||||
|
||||
---
|
||||
|
||||
## Como registrar (template de entrada)
|
||||
|
||||
Use o bloco abaixo como modelo. Uma entrada = um problema resolvido/reconhecido.
|
||||
|
||||
```markdown
|
||||
### [DATA] Título curto do problema
|
||||
|
||||
- **Sintoma:** o que acontecia / o erro observado.
|
||||
- **Causa raiz:** o que realmente causava o problema (após investigação).
|
||||
- **Onde:** arquivo(s) e, se útil, função/linha.
|
||||
- **Tentativas que falharam:** o que já foi tentado e não funcionou.
|
||||
- **Solução adotada:** a correção que resolveu.
|
||||
- **Aprendizado:** regra/comportamento a lembrar nas próximas implementações.
|
||||
- **Estado:** `aberto` | `resolvido` | `mitigado` | `evitado por design`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registro de Experiências
|
||||
|
||||
### 2026-08-17 — Garantir que dois blocos nunca se sobreponham: empilhar pela TINTA real, não pela cap-height
|
||||
|
||||
- **Sintoma:** na composição progressiva, a cedilha de "começar" (Playfair
|
||||
Display Medium Italic, 230pt) invadia a linha de apoio logo abaixo. As
|
||||
caixas "lógicas" não se cruzavam — as renderizadas, sim.
|
||||
- **Causa raiz:** o empilhamento usava altura nominal `font_size * 0.75`
|
||||
(cap-height). Numa serifada de display itálica os acentos sobem a 1,007em e
|
||||
os descendentes descem a -0,241em: a tinta real ocupa quase o dobro da
|
||||
cap-height, e a folga nominal some.
|
||||
- **Onde:** `fcpxml/font_metrics.py` (`VERTICAL_METRICS`),
|
||||
`fcpxml/text_layout.py` (`ink_extent`, `compose_sentence`, `PlacedBlock`).
|
||||
- **Tentativas que falharam:** aumentar `line_gap` — afasta as linhas em todos
|
||||
os casos e perde o bloco compacto da referência, sem garantir nada: basta
|
||||
uma fonte com acentos mais altos para colidir de novo.
|
||||
- **Solução adotada:** métricas verticais reais extraídas das fontes
|
||||
(`ascent`/`descent` da caixa de linha que o FCP centra na Position, mais os
|
||||
extremos de tinta por classe de glifo: caixa alta, ascendente, x-height,
|
||||
acento maiúsculo/minúsculo, descendente). `ink_extent()` calcula o topo e a
|
||||
base da tinta DO TEXTO em questão; `compose_sentence` empilha essas caixas
|
||||
borda a borda com folga fixa. Não-sobreposição vira propriedade da
|
||||
aritmética, não de um fator de segurança. Fonte sem métricas medidas usa um
|
||||
fallback com 8% de folga extra.
|
||||
- **Aprendizado:** medir largura resolve colisão lado a lado; colisão entre
|
||||
linhas exige medir altura de tinta — e ela depende dos caracteres da linha,
|
||||
não só do corpo da fonte.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Legendas saíam palavra a palavra centradas, e não como a composição progressiva diagramada da referência
|
||||
|
||||
- **Sintoma:** o usuário mandou o reel de referência (@fernandoluz.d) e disse
|
||||
"elas devem aparecer assim": `[que vão] / [melhorar] / [sua legenda]` — um
|
||||
bloco por trecho, palavra-chave grande em serifada itálica, complementares
|
||||
pequenas em grotesca, linhas escalonadas. O gerador entregava um `<title>`
|
||||
por PALAVRA, todos na mesma família, cada linha centrada.
|
||||
- **Causa raiz:** `layout_sentence` empacota palavra a palavra e centra cada
|
||||
linha; o `rhythm` variava tamanho/cor por índice (indigo/amarelo/cinza), não
|
||||
por papel semântico da palavra. Nenhum dos dois produz a diagramação.
|
||||
- **Onde:** `fcpxml/text_layout.py` (`compose_sentence`, `pick_emphasis_index`,
|
||||
`PlacedBlock`), `fcpxml/models.py` (looks editoriais, `granularity`),
|
||||
`fcpxml/writer.py` (emissão por unidade), `fcpxml/font_metrics.py`,
|
||||
`server.py` (parâmetros da tool).
|
||||
- **Tentativas que falharam:** tentar aproximar o visual só trocando os
|
||||
tamanhos do `rhythm` — sem agrupar as palavras de apoio num único título, o
|
||||
resultado continua sendo legenda corrida.
|
||||
- **Solução adotada:** modo `granularity="phrase"` (padrão): a frase vira
|
||||
linhas — apoio antes, palavra-chave sozinha, apoio depois —, uma linha por
|
||||
`<title>`, entrando no instante da sua primeira palavra e todas limpando
|
||||
juntas. Destaque em Playfair Display Medium Italic (métricas reais extraídas
|
||||
da fonte instalada e embutidas em `font_metrics`), apoio em Helvetica Neue
|
||||
Bold, tudo branco, linhas escalonadas por `REFERENCE_STAGGER_RATIO`. O modo
|
||||
antigo continua disponível em `granularity="word"`.
|
||||
- **Aprendizado:** o destaque é semântico, não posicional — escolher a palavra
|
||||
por índice num ciclo nunca reproduz uma diagramação. E toda fonte nova exige
|
||||
métricas reais antes de entrar no layout: sem elas, a medição de largura
|
||||
erra e duas linhas colidem.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Importação recusada: `id` do `<text-style-def>` derivado do texto da legenda não é um XML Name válido
|
||||
|
||||
- **Sintoma:** ao gerar títulos/legendas, a validação de DTD falhava com
|
||||
`Syntax of value for attribute ref of text-style is not valid` +
|
||||
`Syntax of value for attribute id of text-style-def is not valid`, e o Final
|
||||
Cut recusava o arquivo na importação.
|
||||
- **Causa raiz:** `_make_text_title_clip` montava o id como
|
||||
`f"{name}_ts0"`, e `name` vem do texto da legenda
|
||||
(`"3 coisas que você precisa saber - Text"`). No DTD, `id` é do tipo `ID` e
|
||||
`ref` do tipo `IDREF`: o valor precisa ser um **XML Name** — sem espaços,
|
||||
sem acentos, nunca começando por dígito. Os três casos apareciam de uma vez
|
||||
em texto português.
|
||||
- **Onde:** `fcpxml/writer.py` (`_make_text_title_clip`, novo
|
||||
`_unique_text_style_id`).
|
||||
- **Tentativas que falharam:** confiar em `_sanitize_xml_value`, que protege
|
||||
*conteúdo* de atributo (CDATA) mas não impõe as regras de XML Name.
|
||||
- **Solução adotada:** `_unique_text_style_id()` — dobra o texto para ASCII
|
||||
(NFKD), troca tudo que não seja `[A-Za-z0-9_.-]` por `_`, prefixa com `ts_`
|
||||
(garante início por letra, inclusive quando o texto é só CJK/emoji e o slug
|
||||
fica vazio) e sufixa um contador conferido contra um cache de ids do
|
||||
documento, mantendo unicidade document-wide sem varrer a árvore por título.
|
||||
- **Aprendizado:** todo atributo do tipo `ID`/`IDREF` no FCPXML precisa ser
|
||||
gerado, nunca derivado de texto do usuário. Sanitizar valor de atributo e
|
||||
sanitizar identificador são problemas diferentes.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Títulos ("Essencial - Título"/"Título Básico") nunca apareciam no FCP; o template que renderiza é o "Text" (Basic Text)
|
||||
|
||||
- **Sintoma:** título gerado no início do vídeo ficava invisível ou "sumia da
|
||||
timeline" (mas continuava na lista de clipes), mesmo com posição/offset
|
||||
aparentemente corretos. Com o template animado, o texto não desenhava; com o
|
||||
estático, o título caía antes do in-point do clipe e o FCP o descartava.
|
||||
- **Causa raiz (duas, no mesmo ciclo):**
|
||||
1. Os dois templates que usávamos — "Essencial - Título"
|
||||
(`Essential Title.moti`) e "Título Básico" (`Bumper:Opener/Basic
|
||||
Title.moti`) — **não resolvem para um template desenhável** no FCP. A
|
||||
importação é silenciosa: nada aparece, sem erro. É o MESMO modo de falha
|
||||
silenciosa já registrado duas vezes antes nesta sessão (uid fabricado).
|
||||
2. No caminho `animated=False`, o offset era gravado como **relativo**
|
||||
(`0s`) em vez de coordenadas de mídia-fonte (`start` do clipe-pai +
|
||||
relativo). O FCP lê `0s` como "0s da mídia", antes do in-point do clipe
|
||||
(`start="220062843/24000s"`), então o título nunca cai sobre o vídeo.
|
||||
- **Onde:** `fcpxml/writer.py` — `_TEXTO_TITLE_UID`, `_BASIC_TITLE_UID`,
|
||||
`_TEXTO_TITLE_PARAMS`, `_make_texto_title_clip`, `_make_basic_title_clip`,
|
||||
`generate_dynamic_subtitles`.
|
||||
- **Como o usuário resolveu:** criou dois títulos à mão no FCP e exportou
|
||||
(`teste.fcpxmld` e `posição.fcpxmld`, FCP 1.14 em inglês). Ambos usam o
|
||||
template **"Text"** (`uid=".../Titles.localized/Basic
|
||||
Text.localized/Text.localized/Text.moti"`, `name="Text"`), `start` fixo
|
||||
`86486400/24000s`, e um bloco de `<param>` com margens/alinhamento/`Custom
|
||||
Speed` (com `<keyframeAnimation>` de tempos nominais constantes). A posição
|
||||
é o param `Position` (chave `.../13260/3296672360/1/100/101`) com valor
|
||||
estático `"x y"` — **sem** `<adjust-transform>`.
|
||||
- **Solução adotada:** substituir os dois templates por um único "Text"
|
||||
(Basic Text), copiado verbatim dos exports reais. Novo
|
||||
`_make_text_title_clip` + `_ensure_text_title_effect` + `add_text_title`.
|
||||
`generate_dynamic_subtitles` agora usa sempre o "Text" e grava offset em
|
||||
coordenadas de mídia-fonte (`start` do pai + relativo) para **todos** os
|
||||
títulos; posição via param `Position`, não `adjust-transform`. Removidos os
|
||||
templates/código morto "Essencial - Título"/"Título Básico".
|
||||
- **Aprendizado:** o único teste que vale para template de título é um
|
||||
roundtrip de importação REAL no FCP — e o padrão-ouro é o export que o
|
||||
próprio FCP produz quando o usuário adiciona o título à mão. Quando isso
|
||||
existir, copiar **verbatim** (uid, params, `start`) e não "simplificar"
|
||||
nada. Título conectado SEMPRE usa `start` do clipe-pai como origem do
|
||||
offset, nunca `0s`.
|
||||
- **Estado:** `resolvido` no XML — pendente de confirmação de importação real
|
||||
no FCP pelo usuário.
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Legendas dinâmicas sobrepondo entre clipes: título conectado NÃO é aparado pelo out-point do clipe-pai
|
||||
|
||||
- **Sintoma:** ao gerar, blocos de legenda de um clipe continuavam na tela por
|
||||
cima das legendas do clipe seguinte — duas frases desenhadas ao mesmo tempo.
|
||||
- **Causa raiz:** um `<title>` conectado a um `asset-clip` **não** é cortado
|
||||
pelo fim do clipe-pai; o FCP segue desenhando sobre o que vier depois. O
|
||||
último bloco de cada clipe terminava no `end` da última palavra do Whisper —
|
||||
que frequentemente ultrapassa o corte — e palavras cujo `start` já caía
|
||||
depois do corte também eram emitidas.
|
||||
- **Onde:** `fcpxml/writer.py`, `generate_dynamic_subtitles()`.
|
||||
- **Tentativas que falharam:** comprimir a palavra tardia para o último frame
|
||||
do clipe (empilhava vários títulos no mesmo frame e na mesma lane).
|
||||
- **Solução adotada:** descartar palavras que começam depois da duração do
|
||||
clipe-pai e limitar (`clamp`) o fim de cada bloco a essa duração. Testes em
|
||||
`tests/test_dynamic_subtitles.py::TestClipBoundaryClamping`.
|
||||
- **Aprendizado:** nada anexado a um clipe pode sobreviver ao próprio clipe;
|
||||
tempos vindos do Whisper precisam sempre ser recortados pela janela do clipe,
|
||||
não só filtrados pelo `start`.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Palavras sobrepostas na tela: largura de texto ESTIMADA subestimava; solução foi embutir as métricas reais das fontes
|
||||
|
||||
- **Sintoma:** o usuário reportou que as palavras apareciam **todas sobrepostas** no Final
|
||||
Cut. A verificação automática do XML dizia "0 sobreposições" — porque conferia contra a
|
||||
minha própria estimativa de largura, não contra o que o FCP realmente desenha. Verificar
|
||||
um cálculo com o mesmo cálculo não verifica nada.
|
||||
- **Duas causas, uma de processo e uma técnica:**
|
||||
1. **Processo:** o arquivo que o usuário importou era a versão anterior, gerada antes do
|
||||
posicionamento existir (todas as palavras em `0 -45.4935`, literalmente no mesmo
|
||||
ponto). Como o caminho de saída é sempre o mesmo (`_dynamic_subtitles.fcpxmld`), é
|
||||
fácil reabrir a versão velha sem perceber.
|
||||
2. **Técnica, e real:** `measure_text` estimava a largura por uma tabela AFM genérica de
|
||||
Helvetica. Comparada com as fontes reais do macOS, o erro ia de **-1,4% a +7,0%** — e
|
||||
o caso negativo é fatal: uma largura menor que a real faz duas palavras encostarem.
|
||||
"dificuldade" a 170pt media 867,8 contra 880,1 reais.
|
||||
- **Investigação:** as variantes reais (`Helvetica.ttc`, `HelveticaNeue.ttc`) diferem entre
|
||||
si em até **21,5% do em** em alguns glifos — Light, Regular, Neue e Light Italic têm
|
||||
avanços distintos. Nenhuma tabela única serve para todas.
|
||||
- **Solução adotada:** extrair os avanços reais das fontes do sistema com `fontTools` e
|
||||
**embutir como tabela** em `fcpxml/font_metrics.py` (9 variantes × 143 glifos). O
|
||||
`fontTools` foi usado só na geração, via `uv run --with` — **não** virou dependência do
|
||||
projeto, e o layout não lê fonte em runtime, então o resultado é idêntico em qualquer
|
||||
máquina. Erro medido depois: **+2,0% constante** (só a margem de segurança), nunca
|
||||
abaixo. Margem de segurança reduzida de 1,03 para 1,02, já que a medida agora é exata.
|
||||
Espaço entre palavras passou de 5 pontos fixos para 14% do corpo da fonte maior.
|
||||
- **Ferramenta que destravou o problema:** gerar um **preview HTML** que desenha as
|
||||
palavras nas posições calculadas, com as fontes e tamanhos reais. Permite ver o layout
|
||||
sem reimportar no FCP a cada tentativa. Nota: o painel de preview bloqueia JavaScript
|
||||
(CSP), então o HTML precisa ser estático, com as posições já escritas no `style` de cada
|
||||
elemento — nada de calcular no navegador.
|
||||
- **Aprendizado:** **nunca validar uma saída com a mesma estimativa que a produziu.** Se o
|
||||
código estima larguras, a verificação tem de medir contra a fonte real, senão ela apenas
|
||||
confirma o próprio erro. E quando existe uma fonte de verdade acessível (o arquivo de
|
||||
fonte no disco), extrair os dados dela e embutir sai mais barato e mais exato do que
|
||||
qualquer aproximação — sem custo de dependência.
|
||||
- **Estado:** `resolvido` (layout aprovado pelo usuário no preview HTML; confirmação de
|
||||
importação no FCP pendente)
|
||||
|
||||
### 2026-08-17 — Calibrar coordenadas de título pedindo um export ao usuário, em vez de adivinhar a escala
|
||||
|
||||
- **Sintoma:** para posicionar cada palavra na tela era preciso escrever o param
|
||||
`Posição` do template Essential Title, mas não havia como saber a unidade nem a escala.
|
||||
O único valor existente no código era `0 -45.4935`, idêntico em todos os títulos —
|
||||
variância zero, portanto nada a inferir.
|
||||
- **Risco reconhecido antes de agir:** este mesmo arquivo já registra (entrada de
|
||||
2026-08-14) que um `<param name="Position">` **fabricado** foi removido justamente por
|
||||
ser inadivinhável, e que nem o DTD nem os testes unitários pegam `key`/valor inválido.
|
||||
Chutar aqui reproduziria a falha silenciosa pela terceira vez.
|
||||
- **Solução adotada:** em vez de estimar, pedir ao usuário um export do Final Cut com
|
||||
palavras posicionadas à mão. Ele enviou `Exemplo Letra.fcpxmld` (projeto 2160x3840) com
|
||||
a frase "Toda a minha vida, assim," — cinco palavras posicionadas no Inspetor, o resto
|
||||
no default. Três fatos saíram dos números:
|
||||
1. **Posição usa a mesma unidade que `fontSize`.** As distâncias centro a centro na
|
||||
linha 1 (254,06 e 265,08) batem com a soma das meias-larguras calculadas pelas
|
||||
métricas Helvetica nos tamanhos 170/128/151 (245,1 e 265,0). Uma unidade diferente
|
||||
apareceria como razão constante; não há nenhuma.
|
||||
2. **O canvas é 1080x1920 pontos** — metade do quadro, porque o FCP posiciona em pontos
|
||||
sobre mídia 2x. A linha 1 vai de -460,3 a +495,7, preenchendo essa largura com
|
||||
margens pequenas, exatamente como o quadro de referência aparenta.
|
||||
3. **y cresce para cima**: "vida," (linha 2) em -233,65 contra a linha 1 em ~-101.
|
||||
Também desambiguou **qual** param responde ao Inspetor: as cinco palavras carregam
|
||||
valores distintos em `9999/10085/10086/1/100/101`, enquanto `.../2/358` permanece
|
||||
`0 69` em todos os títulos do arquivo.
|
||||
- **Validação:** o layout recalculado reproduz o do usuário — espaçamento entre linhas
|
||||
131,8 contra 132,7 (erro de 0,7%) e o y das duas linhas coincidindo na casa decimal.
|
||||
Os valores viraram testes (`tests/test_text_layout.py`,
|
||||
`TestCalibrationAgainstRealExport`), então qualquer regressão de escala falha.
|
||||
- **Descoberta colateral:** o espaçamento entre linhas do usuário (132,65) é menor que o
|
||||
corpo da maior fonte da linha (170), o que só fecha porque o texto ocupa a altura de
|
||||
caixa-alta (~0,75 do corpo), não o em-box inteiro. Usar o em-box afastaria as linhas
|
||||
~40% a mais do que ele fez.
|
||||
- **Aprendizado:** quando um valor não é derivável dos dados em mãos, **pedir um artefato
|
||||
de calibração ao usuário custa minutos e elimina a adivinhação**. Cinco palavras
|
||||
arrastadas à mão renderam escala, unidade, orientação do eixo, espaçamento e a paleta —
|
||||
tudo o que três rodadas anteriores de chute não conseguiram. E vale desconfiar de
|
||||
qualquer constante que apareça idêntica em todas as instâncias de um arquivo: variância
|
||||
zero significa que ela nunca foi exercitada, não que esteja certa.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
### 2026-08-17 — Legendas dinâmicas invisíveis porque eram geradas como CAPTION, não como TÍTULO — e a "referência verificada" do código nunca tinha funcionado
|
||||
|
||||
- **Sintoma:** legendas dinâmicas **nunca** apareceram no Final Cut. Importava sem
|
||||
nenhum erro, DTD passava, e nada era desenhado sobre o vídeo. Sintoma idêntico ao das
|
||||
entradas anteriores (uid inválido → descarte silencioso), o que levou várias rodadas de
|
||||
correção a atacarem o alvo errado (uid, offset, dispatch de builder).
|
||||
- **Causa raiz:** o programa emitia **caption**, não **título animado**. Duas coisas
|
||||
acopladas, ambas erradas:
|
||||
1. `_TEXTO_TITLE_UID` apontava para `.../Subtitles.localized/Subtitle.localized/Subtitle.moti`
|
||||
— o template de **legenda/caption** do FCP, não um template de título.
|
||||
2. Cada `<title>` recebia `role="subtitles.subtitles-1"`. Esse role faz o Final Cut
|
||||
tratar o elemento como legenda e roteá-lo para a **pista de captions**, que não é
|
||||
desenhada sobre o vídeo a menos que a exibição de legendas esteja ligada.
|
||||
- **Como foi descoberto:** o usuário montou títulos à mão dentro do FCP e exportou
|
||||
(`Teste de Texto.fcpxmld`, `exemplo de arquivos.fcpxmld`). Comparando os títulos dele
|
||||
(que aparecem) com os gerados (que somem): os dele usam `Essential Title.moti` /
|
||||
`Essential Fade.moti` / `Text.moti` e **não têm atributo `role` nenhum`**; os gerados
|
||||
usavam `Subtitle.moti` + `role="subtitles.*"`. Prova adicional: no re-export, o FCP
|
||||
devolveu os `caption_*` com offsets negativos e fora do clipe (−2,13s num clipe de
|
||||
1,835s), porque os realocou como captions noutro sistema de coordenadas, enquanto os
|
||||
títulos manuais voltaram coerentes dentro do clipe (0,58s e 1,38s).
|
||||
- **Onde:** `fcpxml/writer.py` (`_TEXTO_TITLE_UID`, `_TEXTO_TITLE_ROLE`,
|
||||
`_TEXTO_TITLE_PARAMS`, `_TEXTO_TITLE_START`, `_make_texto_title_clip`),
|
||||
`fcpxml/models.py` (`DynamicSubtitleConfig`), `server.py`, `MacApp/Sources/*.swift`.
|
||||
- **Premissa falsa que ancorou os erros anteriores:** um comentário no próprio
|
||||
`fcpxml/writer.py` afirmava que `WHISPERX/code/"Teste do dia.fcpxmld"` era um export
|
||||
real do FCP *"actually imported and played back"*. O usuário confirmou que **nunca
|
||||
funcionou** — aquele arquivo é output do próprio programa. Como o comentário foi tratado
|
||||
como fonte de verdade, cada correção seguinte se apoiava nele e reproduzia a estrutura
|
||||
errada (inclusive o `role` de caption). A entrada anterior desta lista herdou o mesmo erro.
|
||||
- **Solução adotada:**
|
||||
1. `_TEXTO_TITLE_UID` → `.../Titles.localized/Essential Titles.localized/Essential Title.localized/Essential Title.moti`
|
||||
e nome do efeito → `"Essencial - Título"`.
|
||||
2. `_TEXTO_TITLE_ROLE` **removido** e `elem.set('role', ...)` eliminado de
|
||||
`_make_texto_title_clip`. Nenhum título gerado carrega `role`.
|
||||
3. `_TEXTO_TITLE_START` → `86486400/24000s` (valor que o FCP escreve para o Essential Title).
|
||||
4. `_TEXTO_TITLE_PARAMS` → só os 5 params de layout do Essential Title. Os ~75 params de
|
||||
animação foram deixados de fora de propósito: carregam `<keyframeAnimation>` com tempos
|
||||
**absolutos** calibrados à duração de uma instância específica, e replicá-los em títulos
|
||||
de outra duração produz animação truncada/congelada. Sem eles o template Motion anima
|
||||
pelos próprios defaults.
|
||||
5. Granularidade: `max_words_per_line` 4 → **1** e `lane_count` 3 → **9** (defaults
|
||||
alinhados em `models.py`, `server.py` e no MacApp), gerando um `<title>` por palavra.
|
||||
6. Comentários falsos reescritos apontando para os exports reais do usuário.
|
||||
7. Novo teste de regressão `test_titles_carry_no_caption_role`.
|
||||
- **Aprendizado:** **legenda dinâmica = título animado, não caption.** Um
|
||||
`role="subtitles.*"` num `<title>` o esconde atrás do toggle de legendas — importa limpo
|
||||
e nunca aparece, exatamente o mesmo sintoma de um uid inválido, o que torna os dois fáceis
|
||||
de confundir. E, mais importante: **um comentário dizendo "verificado" não é verificação.**
|
||||
Só vale como referência um arquivo que o usuário confirmou ter saído do Final Cut. Antes de
|
||||
tratar qualquer arquivo como ground truth, checar se ele é output do próprio programa —
|
||||
se os `name=` seguem o padrão que o código gera (`caption_<hex>`), ele é.
|
||||
- **Estado:** `resolvido` no XML (verificado na saída: efeito Essential Title, zero roles,
|
||||
1 título por palavra, offsets dentro da janela do clipe) — **pendente de confirmação de
|
||||
importação real no FCP pelo usuário**, que é o único teste que conta neste histórico.
|
||||
|
||||
### 2026-08-17 — Legendas dinâmicas não apareciam no FCP: dispatch misturava Título Básico com bloco/role do Subtitle + `offset` em coordenada errada (timeline em vez de mídia)
|
||||
|
||||
> **Nota (revisão posterior):** esta entrada trata `WHISPERX/code/"Teste do dia.fcpxmld"`
|
||||
> como export real verificado do FCP. **Isso está errado** — aquele arquivo é output do
|
||||
> próprio programa e nunca funcionou. Ver a entrada acima. A parte de `offset` em
|
||||
> coordenada de mídia continua correta (reconfirmada contra `exemplo de arquivos.fcpxmld`),
|
||||
> mas o `role="subtitles.*"` e o template Subtitle.moti descritos aqui eram a causa real
|
||||
> das legendas invisíveis.
|
||||
|
||||
- **Sintoma:** ao gerar legendas dinâmicas num projeto real (`Depoimento da Erika - Original.fcpxmld`, clipe único com `start="226220995/24000s"` ≈ 256.59s na mídia de origem) e abrir o resultado no Final Cut, as legendas simplesmente não apareciam — nem na timeline, nem na lista de roles. O app chamava o handler sem `animated`, então caía no default e produzia um `<title>` com `ref="r_title_basic"` (Título Básico) mas com `role="subtitles.subtitles-1"`, `start="86400314/24000s"` e os 19 params do template "Legenda" — um híbrido impossível de resolver no FCP (descarte silencioso, padrão documentado nas entradas de 2026-08-15/2026-08-17).
|
||||
- **Causa raiz (dois bugs num ciclo):**
|
||||
1. `generate_dynamic_subtitles` (`fcpxml/writer.py`) escolhia o efeito certinho por `config.animated` (linhas 2893-2896), mas SEMPRE construía o clip com `_make_texto_title_clip` (linha 2944) — ignorando o `animated`. `_make_basic_title_clip` (que monta o Título Básico sem role/start e só os 2 params `Compactar`/`Alinhamento`) existia mas **nunca era chamado** (código morto). Resultado default: ref do básico + corpo do subtitle → o FCP descarta silenciosamente.
|
||||
2. Mesmo no caminho animado, o `offset` era escrito como valor **relativo à timeline** (ex.: `1/4800s`). Mas o export real verificado (`WHISPERX/code/"Teste do dia.fcpxmld"`) mostra que o template "Legenda"/Subtitle posiciona os captions em **coordenadas da mídia de origem**: `offset = start_do_clipe + relativo` (ex.: `240822582/24000s` = start `240817577/24000s` + 0.2085s), enquanto o "Título Básico" (r3) usa offset relativo (ex.: `1001/4800s`). Escrever offset relativo pequeno num clip cujo start é ~256s/9000s faz o FCP ler ~0s da mídia — antes do in-point do clipe — e a legenda cai "fora" (caso `Legendas fora.fcpxmld`).
|
||||
- **Onde:** `fcpxml/writer.py` (`generate_dynamic_subtitles`, linhas ~2893-2956), `fcpxml/models.py` (`DynamicSubtitleConfig.animated`, default errado `False`), `tests/test_dynamic_subtitles.py`.
|
||||
- **Solução adotada:**
|
||||
1. `generate_dynamic_subtitles` agora despacha pelo `config.animated`: `True` → `_make_texto_title_clip` (template "Legenda"), `False` → `_make_basic_title_clip` (Título Básico). O híbrido impossível não existe mais.
|
||||
2. No caminho animado, `offset = media_origin + snap(relativo)` com `media_origin = _parse_time(parent.get('start'))` (coordenada da mídia, igual ao export real); no estático, offset permanece relativo (como r3).
|
||||
3. `DynamicSubtitleConfig.animated` agora é `True` por padrão (decisão do usuário: a feature é a legenda animada/ediável; `False` só para texto queimado no frame).
|
||||
4. Adicionados testes de regressão (`test_animated_offset_uses_source_media_coordinates`, `test_animated_effect_is_legenda_subtitle`, `test_static_mode_uses_basic_title_no_role`, `test_animated_and_static_use_separate_effects`).
|
||||
- **Aprendizado:** dois templates diferentes num mesmo método exigem dispatch por builder, e cada um tem seu próprio sistema de coordenadas de `offset` — o template de caption/legenda usa a posição na mídia de origem (nunca um offset relativo pequeno), o título estático usa offset relativo ao clipe. Comparar sempre com o export real (coordenadas + role + params) antes de fechar uma estrutura, e nunca ignorar o branch `else` de um `if config.X` que decide o template — builder único = mistura de corpo de um template com ref de outro = descarte silencioso no FCP.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Clipe-fantasma de 1 frame no início e no fim após remoção de silêncio (raiz real no gerador, diferente da entrada de 2026-08-14)
|
||||
|
||||
- **Sintoma:** usuário testou `remove_silences` num projeto real (`Depoimento da Erika_silence_removed.fcpxmld`) e reportou dois clipinhos minúsculos: o primeiro e o último clipe da spine gerada tinham `duration="1001/24000s"` — exatamente 1 frame a 23.976fps.
|
||||
- **Causa raiz:** diferente da entrada de 2026-08-14 ("Micro-clips... são criados pelo FCP, não pelo corte") — aqui os slivers já vinham no `Info.fcpxml` bruto gerado pelo programa, confirmado lendo o XML direto, sem passar pelo FCP. `handle_remove_media_silence`/`cut_clip_ranges` (`fcpxml/writer.py`) aplica um `padding` (respiro, padrão 0.05s) antes/depois de cada trecho de silêncio cortado. Quando o silêncio detectado toca a própria borda do clipe (começo ou fim), não sobra fala nenhuma daquele lado para o padding "respirar perto de" — o padding vira, sozinho, o segmento "kept" (mantido) daquela ponta, e após o snap para o grid de frames (`snap_seconds_to_frame`) esse segmento de ~0.05s vira exatamente 1 frame, virando clipe próprio em vez de ser absorvido.
|
||||
- **Onde:** `fcpxml/writer.py::FCPXMLModifier.cut_clip_ranges`, construção da lista `keeps` (complemento dos `cut_ranges` mesclados).
|
||||
- **Tentativas que falharam:** n/a — diagnóstico direto lendo o XML bruto e cruzando com a lógica de `cut_clip_ranges`; os números batem exatamente (0.05s de padding ≈ 1.2 frames a 23.976fps → arredonda para 1 frame).
|
||||
- **Solução adotada:** a pedido do usuário ("em vez de criar esse [micro-clipe] novo, ele pode pegar o próprio segundo clipe e aumentar a duração dele para começar antes") — depois de montar `keeps`, se o primeiro segmento tiver menos que ~2 frames de duração, ele é fundido no segmento seguinte (que passa a começar mais cedo); simétrico no fim (o penúltimo segmento passa a terminar mais tarde, absorvendo o último). Isso também restaura o pequeno trecho de silêncio adjacente que teria sido cortado ali — troca aceitável por não deixar clipe-fantasma na timeline.
|
||||
- **Aprendizado:** ao gerar clipes a partir de um algoritmo de corte com padding, sempre checar segmentos residuais nas BORDAS da mídia/clipe (não só entre dois cortes no meio) — o padding não tem "vizinho de fala" do lado de fora do clipe, então o caso de borda precisa de tratamento explícito (fundir em vez de emitir). Existe um padrão irmão já usado alhures no código (`_absorb_into_neighbor`, `fcpxml/writer.py:1112`) para a mesma ideia geral.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — As 481 legendas do usuário ficaram todas presas num único clipe errado: `generate_dynamic_subtitles` resolvia o clipe-pai por `name`, ambíguo depois de corte de silêncio
|
||||
|
||||
- **Sintoma:** mesmo com o `uid` e os `offset`/`duration` corrigidos (entradas abaixo), o usuário mandou o `.fcpxmld` real depois de importar no FCP ("como ficou depois de importar") e as 481 legendas apareciam todas grudadas em ~8-17 segundos de um único clipe da timeline, com frases completamente sem relação entre si ("vontade.", "Sou erica Fernanda, tenho", "sou casada.") — claramente vindas de pontos bem distantes de uma entrevista de ~17 minutos, não de um trecho de 8 segundos.
|
||||
- **Causa raiz:** `server.py::handle_generate_dynamic_subtitles` itera cada clipe da spine (`for el in spine_clips`), calcula a janela de palavras correta *relativa àquele clipe* (`el`), mas chamava `modifier.generate_dynamic_subtitles(name, ...)` passando `name = el.get("name", "")` — uma STRING — em vez do elemento. Depois de qualquer `remove_silences`/corte com ripple, TODOS os fragmentos resultantes de um clipe original mantêm o mesmo `name` herdado (aqui, ~482 clipes, todos `name="0E6A8829"`, o nome do asset de origem). `_require_clip()` resolve por `self.clips[key]`, um dict indexado por `id` ou, na falta dele, por `name` (`fcpxml/writer.py:_index_elements`) — com nomes duplicados, cada novo clipe indexado SOBRESCREVE o anterior, então `self.clips["0E6A8829"]` acaba apontando para só UM clipe (o último indexado). Toda chamada do loop, para qualquer um dos 482 clipes reais, resolvia para esse mesmo clipe errado — empilhando ali as legendas de quase o vídeo inteiro.
|
||||
- **Onde:** `server.py` (`handle_generate_dynamic_subtitles`, a chamada a `modifier.generate_dynamic_subtitles`), `fcpxml/writer.py` (`generate_dynamic_subtitles`, `_require_clip`, `_index_elements`).
|
||||
- **Solução adotada:** `generate_dynamic_subtitles` agora aceita `parent_clip` como `str | ET.Element` — se receber o elemento diretamente, usa-o sem passar pelo lookup por nome; só cai em `_require_clip(name)` (mantido para compatibilidade com chamadas antigas/testes) quando recebe uma string. `server.py` foi atualizado para passar `el` (o elemento já em mãos no loop) em vez de `name`. Adicionado teste de regressão (`test_element_param_bypasses_ambiguous_duplicate_name_lookup`) que simula dois clipes com o mesmo `name` e confirma que passar o elemento anexa cada legenda ao clipe certo.
|
||||
- **Aprendizado:** **nunca identificar um clipe específico por `name` num handler que itera múltiplos clipes** — qualquer operação de corte/ripple/remoção de silêncio no FCPXML preserva o `name` original em todos os fragmentos resultantes, então `name` deixa de ser único assim que o timeline é editado. Sempre que o chamador já tem o `ET.Element` em mãos (por ter vindo de uma iteração como `_iter_spine_clips()`), passe o elemento adiante em vez de re-resolvê-lo por um identificador que pode colidir.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Legendas dinâmicas sumiam silenciosamente do Final Cut por `uid` de efeito inválido (mistura de dois templates); dois bugs num só ciclo
|
||||
|
||||
- **Sintoma:** depois de corrigir os erros de frame-boundary do `offset`/`duration` (entrada abaixo, mesma sessão), o Final Cut não reportava mais nenhum erro de importação — mas os 481 `<title>` de legenda simplesmente não apareciam em lugar nenhum: nem na timeline, nem na lista de roles do projeto.
|
||||
- **Causa raiz:**
|
||||
1. O `uid` fixado em `_TEXTO_TITLE_UID` (`.../Titles.localized/Basic Text.localized/Text.localized/Text.moti`) mistura os nomes de dois templates diferentes ("Basic Text" e "Text") e não corresponde a nenhum Motion template real instalado no FCP. Quando o `uid` de um `<effect>` referenciado por um clipe conectado não resolve para um template existente, o Final Cut **descarta silenciosamente** os clipes conectados que dependem dele durante a importação — sem erro, sem aviso na UI. É a segunda vez que um `uid` fabricado aqui é a causa raiz (ver entrada de 2026-08-15 abaixo — da primeira vez foi o "Basic Title", desta vez foi um "Texto" que só passou pelos testes internos porque nunca foi de fato importado de novo no FCP depois de escrito).
|
||||
2. Um dos `<param>` copiados junto (`Opacidade` = `"0"`) fixava a opacidade do texto em zero — mesmo se o `uid` estivesse certo, o texto ficaria invisível.
|
||||
- **Onde:** `fcpxml/writer.py` — `_TEXTO_TITLE_UID`, `_TEXTO_TITLE_PARAMS`, `_TEXTO_TITLE_START`, `_ensure_texto_title_effect`, `_make_texto_title_clip`.
|
||||
- **Solução adotada:** o usuário identificou um export real, já importado e reproduzido com sucesso no FCP, presente no próprio repositório em `WHISPERX/code/"Teste do dia.fcpxmld"/Info.fcpxml` — efeito `r4` nomeado **"Legenda"**, `uid=".../Titles.localized/Subtitles.localized/Subtitle.localized/Subtitle.moti"`, usado em cinco `<title>` conectados por palavra/linha com `role="subtitles.subtitles-1"`. Copiado o `uid`, o `name` ("Legenda"), o `start` fixo (`86400314/24000s`, diferente do valor anterior), o atributo `role`, e o bloco de 19 `<param>` inteiro verbatim — que não inclui nenhum param de opacidade nem `<keyframeAnimation>` manual (a revelação palavra-a-palavra é nativa do template via os params `Animar`/`Intervalo`, não uma curva de velocidade fabricada como no template anterior).
|
||||
- **Aprendizado:** um `uid`/bloco de params "plausível" que passa nos testes internos (`fcpxml/dtd.py`, pytest) **não é prova de que é real** — só um roundtrip de importação de verdade no FCP prova isso, e mesmo assim a falha pode ser silenciosa (sem erro) em vez de uma rejeição explícita. Regra geral reforçada: nunca fabricar/adivinhar `uid` de efeito nativo do FCP, mesmo que o formato pareça consistente com outros exports reais — sempre copiar de um `.fcpxmld`/`Info.fcpxml` que o usuário confirma ter sido importado e reproduzido com sucesso.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — Palavras das legendas dinâmicas empilhadas: o template "Essencial - Título" ANIMA por padrão e a animação ignora a posição estática — solução foi desligar `Animar`
|
||||
|
||||
- **Sintoma:** o usuário reportou, repetidamente, **todas as palavras uma em cima da outra** no Final Cut. O XML gerado tinha posições estáticas e distintas (verificado), mas o FCP **ignorava** a posição e empilhava tudo — o sintoma persistiu mesmo com `value="x y"` correto em cada palavra.
|
||||
- **Causa raiz (a definitiva):** o template "Essencial - Título" (Essential Title, Motion) tem um parâmetro **`Animar`** (Animate). No export de calibração (`Exemplo Letra.fcpxmld`, as 5 palavras que o usuário arrastou à mão e funcionaram), cada título carrega **~111 params**, incluindo `Animar = "4 (Tudo)"` mais dezenas de params de animação por caractere (`X/Y/Z deslocamento`, `Objeto Original`, `Deslocamento Inicial/Final`, `Direção`, `Velocidade Personalizada` com keyframes). Nós só escrevíamos 5 params de layout e **omitíamos o `Animar`**. Sem ele, o FCP usa a **animação default** do template — a animação de "Tudo" (fly-in 3D por caractere) — e **é essa animação que posiciona as letras**, não o nosso `Posição`. Resultado: cada caractere cai na posição default e tudo empilha. As palavras da calibração só ficaram no lugar porque o FCP escreveu o bloco de animação completo junto.
|
||||
- **Por que NÃO copiar o bloco de animação:** os params por caractere (`X deslocamento`, `Y deslocamento`, `Z deslocamento`, `Objeto Original`) têm valores **diferentes por palavra** (ex.: `X deslocamento = -960.047` em "Tod" vs `-243` em "a") — são dados 3D por caractere que o FCP calcula e que não dá para reproduzir. Já os `time` dos keyframes são **idênticos em todas as palavras** (`0s`, `1567433324/1000000000s`, `19915648/3840000s`, `6686008967/1000000000s`), confirmando que são a curva default do template, não calibrados por instância.
|
||||
- **Onde:** `fcpxml/writer.py::_make_texto_title_clip`, novo `_TEXTO_ANIMAR_KEYS` (10 chaves `.../201/203` de `Animar`), `tests/test_dynamic_subtitles.py`.
|
||||
- **Tentativas que falharam:** (1) embrulhar a posição em `keyframeAnimation` — piorou, pois o param é estático e o FCP descartou tudo para o default `0 -45.4935`; (2) reverter só para o valor estático — ainda empilhava, porque a animação default continuava ignorando a posição.
|
||||
- **Solução adotada:** escrever `Animar = "0 (Nenhum)"` nas 10 chaves de animação do template, desligando a animação. O título fica **estático** e respeita o `Posição` gravado; a revelação palavra-por-palavra continua vindo do `offset`/`duration` de cada palavra (não da animação). Teste atualizado: 5 params de layout + 10 `Animar = "0 (Nenhum)"`, sem `keyframeAnimation`.
|
||||
- **Aprendizado:** num template Motion de título, a **posição visual pode ser controlada pela animação, não pelo param de layout** — se um título "arrastado à mão" funciona e o gerado empilha, comparar o bloco de params **inteiro** (não só a posição) entre os dois arquivos. O `Animar` é o interruptor-mestre: `4 (Tudo)` anima (e aí só os dados 3D por caractere — irreproduzíveis — colocam as letras no lugar); `0 (Nenhum)` desliga e devolve o controle ao param `Posição`. E: a mesma conclusão errada foi registrada e corrigida duas vezes nesta sessão — a cada iteração, reler o export de calibração **inteiro** antes de decidir o mecanismo.
|
||||
- **Estado:** `resolvido` no XML (posições estáticas distintas + 10× `Animar = "0 (Nenhum)"` verificados no output real; 1124 testes verdes) — **pendente de confirmação de importação real no FCP pelo usuário**, único teste que conta neste histórico.
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-17 — `offset`/`duration` de legendas dinâmicas fora do grid de frames (denominador `/23s` em vez de `/24000s`)
|
||||
|
||||
- **Sintoma:** importação real no Final Cut rejeitada com 457 de 481 erros "O item não está em um limite de quadro de edição", todos apontando para `offset`/`duration` de `<title>` com denominador `/23s` (ex.: `offset="2/23s"`, `duration="67/23s"`).
|
||||
- **Causa raiz:** `generate_dynamic_subtitles` (`fcpxml/writer.py`) construía cada offset/duration com `TimeValue.from_seconds(seconds, self.fps)`. Esse classmethod (`fcpxml/models.py`) faz `int(fps)` — para um projeto NTSC a 23.976fps (`frameDuration="1001/24000s"`, `self.fps ≈ 23.976`), `int(fps)` trunca para `23`, uma base de tempo inválida para o FCPXML. Todo o resto do XML (asset-clips, cortes de silêncio, sequence) já usava a base exata `1001/24000s`.
|
||||
- **Onde:** `fcpxml/writer.py:2841-2850` (chamada) e `fcpxml/models.py:314-318` (`TimeValue.from_seconds`, bug latente — outros call-sites como marcadores em `fcpxml/writer.py:1442,1511` usam o mesmo padrão e podem ter o mesmo problema em taxas NTSC, não corrigido nesta rodada por estar fora do escopo do bug relatado).
|
||||
- **Solução adotada:** trocado `TimeValue.from_seconds(start, self.fps)` / `TimeValue.from_seconds(end - start, self.fps)` por `self.snap_seconds_to_frame(...)` — helper já existente (`fcpxml/writer.py:746`) que usa a fração exata de `frameDuration` (via `frame_duration_fraction()`, `fcpxml/writer.py:727`) em vez do float truncado, já usado em outro lugar do writer para snap da spine. Também trocado `min_dur_seconds = 1.0 / self.fps` por `float(self.frame_duration_fraction())`.
|
||||
- **Aprendizado:** qualquer conversão de segundos-float para `TimeValue` num projeto FCPXML deve usar a fração exata do `frameDuration` do `<format>` da sequência (via `frame_duration_fraction()`/`snap_seconds_to_frame()`), nunca `int(fps)` — taxas NTSC (23.976/29.97/59.94) sempre truncam errado com um fps float.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-15 — Abandonado o Compound Clip em `generate_dynamic_subtitles`; voltado a títulos soltos em lanes cicladas, com estrutura copiada de um export real
|
||||
|
||||
- **Sintoma/decisão:** mesmo depois de corrigir os 3 erros de importação do Compound Clip (entrada abaixo), o usuário decidiu recuar da abordagem por completo — "muito problema e muito erro" — e pediu para voltar ao básico: títulos soltos, direto na timeline, em várias lanes, sem Compound Clip.
|
||||
- **O que mudou:** `generate_dynamic_subtitles` não cria mais `<media>`/`<sequence>`/`<gap>`/`<ref-clip>` nenhum. Cada linha (chunk de palavras) vira um `<title>` autônomo, anexado direto no clipe pai via `_dtd_insert`, ciclando por `config.lane_count` lanes (round-robin). A duração de cada linha se estende até sua própria lane ser reaproveitada `lane_count` linhas depois (ou até seu próprio fim, se for uma das últimas) — isso empilha visualmente várias linhas ao mesmo tempo (efeito cascata) sem nunca sobrepor duas linhas na MESMA lane.
|
||||
- **Fonte da estrutura XML:** o usuário mandou um FCPXML real (`com exemplo de título.fcpxmld/Info.fcpxml`) com 3 títulos criados manualmente no FCP usando o template **"Texto"** (`uid=".../Titles.localized/Basic Text.localized/Text.localized/Text.moti"` — diferente do "Basic Title" usado antes). Copiei o bloco de `<param>` inteiro (margens, alinhamento, quebra automática, o par Opacidade/Velocidade Personalizada com `<keyframeAnimation>` que parece ser a animação de revelação nativa do template) e o atributo `start` fixo (`86486400/24000s`, idêntico nos três títulos do export) como constantes fixas em `_TEXTO_TITLE_PARAMS`/`_TEXTO_TITLE_START` — não tentei entender/simplificar esses valores, só copiei verbatim, já que "simplificar" um bloco de params reais foi exatamente o que causou os erros anteriores.
|
||||
- **Importante (o usuário corrigiu isso no meio da conversa):** os offsets/timings do arquivo de exemplo eram só ilustrativos — não estavam sincronizados com nenhuma fala real. O timing de verdade continua vindo 100% da transcrição Whisper (`words` com `start`/`end` reais), usando a mesma lógica de `TimeValue`/mapeamento fonte→timeline já estabelecida no projeto. Só a ESTRUTURA XML (uid, params, `start` fixo) foi copiada do exemplo, nunca os números de tempo.
|
||||
- **Onde:** `fcpxml/writer.py` (`FCPXMLModifier.generate_dynamic_subtitles`, `_make_texto_title_clip`, `_ensure_texto_title_effect`), `fcpxml/models.py` (`DynamicSubtitleConfig.lane_count` substituindo `lane`), `server.py`, `admin/models_api.py`, `MacApp/Sources/CaptionsView.swift`.
|
||||
- **Aprendizado:** quando o usuário oferece um export real do FCP como referência, tratar isso como fonte de verdade para a ESTRUTURA (uid, ordem de elementos, bloco de params), mas nunca para os NÚMEROS de tempo específicos de um exemplo ilustrativo — a menos que ele diga explicitamente que os números também são reais. E: depois de duas rodadas de erro de importação real, a abordagem mais simples e mais próxima de um export real validado sempre vale mais que uma abstração mais "elegante" (Compound Clip) que ninguém verificou contra o importador de verdade do FCP.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-15 — Três rejeições de importação real no Final Cut Pro em `generate_dynamic_subtitles` (uid fabricado, `<title>` ancorado em `<gap>`, duração 0)
|
||||
|
||||
- **Sintoma:** ao importar de verdade no Final Cut Pro (não só validar internamente), o app recusou o `Info.fcpxml` com três classes de erro: (1) `uid=".../Titles.localized/Basic Text.localized/..." — O item não pôde ser lido`; (2) `Edição inválida sem nenhuma mídia respectiva` apontando para `.../gap[1]/title[1]`; (3) `Um valor inesperado foi encontrado (duration="0/1s")` em vários `<gap>` dentro dos `<media>` de legenda.
|
||||
- **Causa raiz:**
|
||||
1. O `uid` do efeito "Basic Title" foi **inventado** (nunca verificado contra um export real) — o caminho correto tem `Bumper:Opener.localized`, não `Basic Text.localized`.
|
||||
2. Os `<title>` por palavra estavam sendo anexados como *connected clip* (via `lane`) dentro de um `<gap>` usado só para "segurar" a duração da linha — mas um `<gap>` não é mídia, e FCP rejeita qualquer clipe conectado a um `<gap>` como âncora.
|
||||
3. Palavras com `start`/`end` muito próximos (ou vindas de um transcript com timestamps imprecisos) geravam durações que arredondavam para 0 frames no fps da sequência.
|
||||
- **Onde:** `fcpxml/writer.py`, `FCPXMLModifier.generate_dynamic_subtitles` / `_make_title_clip` / `_ensure_basic_title_effect`.
|
||||
- **Tentativas que falharam:** validar apenas com `fcpxml/dtd.py` e com os testes unitários — nenhum dos dois pega uid/params inválidos (o DTD da Apple não estava disponível neste ambiente) nem a regra "conectado precisa de mídia real por trás", que só o importador real do FCP aplica.
|
||||
- **Solução adotada:**
|
||||
1. Encontrado um `uid` **real e correto** dentro do próprio repositório, em `WHISPERX/code/*.fcpxmld/Info.fcpxml` (um projeto de verdade exportado pelo usuário) — usado esse valor em vez de inventar um novo. Os únicos dois `<param>` que esse template realmente usa (`Compactar` e `Alinhamento`, com `key` fixo) também foram copiados de lá; o `<param name="Position">` fabricado foi removido.
|
||||
2. Reestruturado o compound clip: os `<title>` por palavra agora são conteúdo **primário** da spine interna (como o próprio `<title>` já suporta ser primário), com `<gap>` só preenchendo silêncio real entre eles — nunca mais como pai/âncora de um clipe conectado.
|
||||
3. Adicionado um piso de duração mínima de 1 frame (`1.0 / fps`) tanto por palavra quanto pela linha inteira, em vez do padding fixo de `0.01s` que arredondava para 0 em fps altos.
|
||||
- **Aprendizado:** **nunca fabricar `uid`/`key` de efeitos nativos do FCP** — eles não são adivinháveis e a validação interna (`fcpxml/dtd.py`) só pega isso se o Final Cut Pro estiver instalado localmente; sempre que possível, procurar/pedir um export real como referência antes de inventar. Além disso, "conectado" (`lane`) sempre precisa de um clipe com mídia de verdade por trás — um `<gap>` nunca serve de âncora, mesmo que pareça funcionar nos testes internos (que só checam a árvore XML, não as regras semânticas do importador do FCP). E qualquer duração calculada a partir de subtração de floats de transcrição deve ter um piso de `1/fps`, nunca uma constante fixa pequena.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-15 — Corpo de `cmd_add_zoom` colado por engano dentro de `cmd_remove_silences` em `admin/models_api.py`
|
||||
|
||||
- **Sintoma:** lint (`ruff`) falhando com `F821 Undefined name 'clip_id'` e `F841 Local variable 'clip_id' is assigned to but never used`, em duas funções diferentes do bridge Python↔Swift.
|
||||
- **Causa raiz:** durante uma edição manual (introduzindo `_derived_output()` para suportar `output_dir` configurável), o corpo inteiro de `cmd_add_zoom` (checagem de `clip_id` + chamada a `handle_add_zoom`) foi colado dentro de `cmd_remove_silences`, antes do bloco correto que já chamava `handle_remove_media_silence` — deixando `cmd_remove_silences` com código morto/quebrado (chamava o handler errado e checava uma variável inexistente) e `cmd_add_zoom` truncado (só validava `path`/`clip_id` e não fazia mais nada).
|
||||
- **Onde:** `admin/models_api.py`, funções `cmd_remove_silences` e `cmd_add_zoom`.
|
||||
- **Tentativas que falharam:** n/a — identificado direto pelo lint e por leitura do código antes de qualquer tentativa de correção.
|
||||
- **Solução adotada:** movido o fragmento (checagem de `clip_id` + chamada a `handle_add_zoom`) de volta para dentro de `cmd_add_zoom`, removendo-o de `cmd_remove_silences`, que voltou a conter só a chamada correta a `handle_remove_media_silence`.
|
||||
- **Aprendizado:** depois de qualquer edição manual em `admin/models_api.py` (ou qualquer arquivo com várias funções `cmd_*` de shape parecido), rodar o lint imediatamente pega colagens cruzadas de função — `ruff` acusa tanto a variável usada-mas-nunca-definida (função que perdeu o trecho) quanto a definida-mas-nunca-usada (função que ganhou o trecho de outra) no mesmo commit, o que é um sinal forte de bloco trocado de lugar, não dois bugs independentes.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-15 — IDs duplicados de `text-style-def` rejeitados pelo Final Cut Pro em legendas dinâmicas
|
||||
|
||||
- **Sintoma:** ao importar o FCPXML gerado por `generate_dynamic_subtitles`, o Final Cut Pro recusava o arquivo com "A validação DTD falhou" e uma lista de IDs como `caption_L0W0_ts0 already defined`.
|
||||
- **Causa raiz:** os IDs de `<title>`/`<text-style-def>` eram montados como `caption_L{line_idx}W{word_idx}_ts{i}`, com `line_idx`/`word_idx` reiniciando em 0 a cada chamada de `generate_dynamic_subtitles`. Como o handler (`handle_generate_dynamic_subtitles` em `server.py`) chama esse método uma vez por clipe da spine na mesma instância de `FCPXMLModifier`, múltiplos clipes geravam exatamente os mesmos IDs — o DTD exige unicidade de ID no documento inteiro, não por clipe.
|
||||
- **Onde:** `fcpxml/writer.py`, método `FCPXMLModifier.generate_dynamic_subtitles`.
|
||||
- **Tentativas que falharam:** nenhuma alternativa testada — o padrão (índices posicionais que resetam por chamada) era o bug desde a primeira implementação; só foi pego ao testar a importação real no Final Cut Pro.
|
||||
- **Solução adotada:** trocar o índice posicional por um prefixo derivado de `uuid.uuid4().hex[:8]` por linha (`caption_{line_uid}_W{word_idx}`), garantindo unicidade mesmo entre chamadas repetidas na mesma instância do modifier. De quebra, corrigido também: o `<ref-clip>` (compound clip) estava sendo anexado via `ET.SubElement` direto no clipe pai, o que o colocava depois de marcadores (`keyword`, `chapter-marker`) já existentes — violando a ordem de filhos exigida pelo DTD (itens-âncora como `ref-clip` devem vir antes de itens de marcador). Trocado para `_dtd_insert(parent, ref_clip)`, que já resolve essa ordenação.
|
||||
- **Aprendizado:** qualquer ID gerado dentro de um método chamado em loop (uma vez por clipe/iteração) sobre a MESMA árvore XML não pode depender de um índice que reinicia a cada chamada — precisa ser único por invocação (UUID, contador persistido na instância, ou verificação contra os IDs já existentes no documento). Além disso, qualquer elemento anexado a um clipe existente via `SubElement` direto (em vez de `_dtd_insert`) só é seguro se o clipe nunca tiver marcadores/filhos de prioridade menor já presentes — na dúvida, sempre usar `_dtd_insert`.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Tolerância e ações consolidadas no processamento em lote
|
||||
|
||||
- **Sintoma:** a tolerância do corte ficava separada dos checkboxes e havia controles individuais repetindo as ações do lote.
|
||||
- **Causa raiz:** o layout foi evoluído incrementalmente, mantendo os fluxos antigos abaixo do novo processamento em lote.
|
||||
- **Solução adotada:** slider dentro do grupo "Remover silêncios", desabilitado quando a opção é desmarcada; campo de frases condicionado ao respectivo checkbox; removidos botões individuais e mantidas apenas as ações finais de abrir pasta e abrir no Final Cut.
|
||||
- **Aprendizado:** quando existe processamento em lote, os parâmetros devem ficar junto da operação e os comandos individuais não devem duplicar o fluxo principal.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Pasta única e processamento em lote no app
|
||||
|
||||
- **Sintoma:** cada operação salvava o resultado em locais diferentes e exigia abrir o Finder ou localizar manualmente cada arquivo.
|
||||
- **Causa raiz:** os comandos do bridge usavam apenas `generate_output_path` ao lado do projeto e a UI oferecia ações independentes, sem uma pasta de trabalho comum.
|
||||
- **Onde:** `MacApp/Sources/TranscriptionView.swift`, `admin/models_api.py` e `server.py`.
|
||||
- **Solução adotada:** nova pasta de saída configurável no topo, cinco checkboxes de processamento e botão único; as etapas são encadeadas e todos os XML/SRT recebem `output_dir` explícito.
|
||||
- **Aprendizado:** operações relacionadas devem compartilhar uma pasta de saída e uma entrada encadeada, evitando artefatos espalhados pelo sistema.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Mapeamento de legenda por intervalo, sem duração inventada
|
||||
|
||||
- **Sintoma:** a tentativa de impor duração mínima gerou legendas deslocadas, duplicadas e piores; havia também cues de duração zero que o FCP rejeitava.
|
||||
- **Causa raiz:** o mapeamento usava apenas o início do segmento e depois estendia artificialmente o fim, ignorando segmentos que atravessavam cortes.
|
||||
- **Onde:** `admin/models_api.py` (`cmd_export_srt`).
|
||||
- **Tentativas que falharam:** descartar todo cue menor que 0.5s e preencher cada cue até 1s.
|
||||
- **Solução adotada:** intersectar o intervalo completo da fala com cada janela de clipe mantida, mapear apenas a interseção, mesclar somente partes contíguas do mesmo segmento e omitir apenas spans que viram zero milissegundos.
|
||||
- **Aprendizado:** sincronização deve transformar intervalos fonte→timeline; nunca inventar duração para corrigir legibilidade.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Exportar legenda a partir da versão cortada, não do projeto original
|
||||
|
||||
- **Sintoma:** a legenda gerada cobria o projeto original (326s) em vez do vídeo cortado (255s), desalinhada com os frames finais.
|
||||
- **Causa raiz:** o botão "Exportar Legendas (SRT)" usava `projectPath` (projeto aberto na tela) em vez do resultado da remoção de silêncio (`processedPath`).
|
||||
- **Onde:** `MacApp/Sources/TranscriptionView.swift` (`exportSubtitles`).
|
||||
- **Tentativas que falharam:** gerar sempre do `projectPath`.
|
||||
- **Solução adotada:** `exportSubtitles` passa a usar `processedPath` (a cópia `_silence_removed`) quando existe, caindo para o `projectPath` caso contrário — a legenda sempre acompanha o corte mais recente.
|
||||
- **Aprendizado:** artefatos derivados do corte (SRT, marcadores) devem ser gerados da mesma versão editada que o usuário está usando, não do arquivo-fonte original.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Legenda SRT ultrapassava a duração do projeto (aviso do FCP)
|
||||
|
||||
- **Sintoma:** ao importar o SRT, o Final Cut avisava "as legendas se estendem além da duração do projeto" e sugeria conectá-las a um clipe vazio no final.
|
||||
- **Causa raiz:** o último bloco de legenda mapeado terminava após o fim da timeline (ex.: SRT até 257.4s num projeto de 255.6s), porque o timestamp final arredondava (`round`) para cima e nenhum teto impedia o overrun.
|
||||
- **Onde:** `admin/models_api.py` (`cmd_export_srt`, `srt_stamp`).
|
||||
- **Tentativas que falharam:** mapear o fim ao fim do clipe apenas; o último cue ainda podia exceder a sequência real.
|
||||
- **Solução adotada:** calcular `timeline_total = _timeline_duration().to_seconds()` e clampar `tl_start`/`tl_end` de cada cue a esse teto; usar `floor` (em vez de `round`) no `srt_stamp` para nunca subir acima de um limite de frame.
|
||||
- **Aprendizado:** SRT que termina após o último frame do projeto é rejeitado pelo FCP; sempre clampar o último cue ao total da timeline e truncar (não arredondar) timestamps.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Remoção de gap vazio final como padrão na remoção de silêncio
|
||||
|
||||
- **Sintoma:** a timeline do resultado de remoção de silêncio podia terminar com um gap "Espaço" vazio após o último clipe (introduzido no round-trip com o Final Cut), deixando um "objeto preto" no final.
|
||||
- **Causa raiz:** nenhum passo garantia a remoção de gaps ao final da spine; o FCP re-adicionava o espaço ao importar.
|
||||
- **Onde:** `fcpxml/writer.py` (novo `FCPXMLModifier.remove_trailing_gaps`) e `server.py` (`handle_remove_media_silence`).
|
||||
- **Tentativas que falharam:** depender do usuário apagar o gap manualmente no FCP.
|
||||
- **Solução adotada:** `remove_trailing_gaps()` remove apenas o `<gap>` final da spine (gaps no meio são preservados) e re-sincroniza a duração da sequência; chamado antes de salvar na remoção de silêncio. `cmd_remove_silences` (app) já herda via delegação ao handler.
|
||||
- **Aprendizado:** operações que encurtam a timeline devem remover gaps finais para o arquivo exportado terminar onde o conteúdo termina.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Micro-clips de 1 frame e gap "Espaço" no final são criados pelo FCP, não pelo corte
|
||||
|
||||
- **Sintoma:** o resultado da remoção de silêncio mostrava, após ~4:11, dezenas de micro-clips de 1 frame (0.042s) e um objeto preto/gap "Espaço" de 755s no final.
|
||||
- **Causa raiz:** o algoritmo gera um arquivo LIMPO (82 clipes, source `start` monotônico, sem micro-clips). O arquivo exportado pelo Final Cut tinha 162 clipes, 80 regressões de `start` e o gap "Espaço" — o FCP re-quebrou os clipes e inseriu o gap ao abrir/salvar/exportar, não o programa.
|
||||
- **Onde:** comparação entre `_out_test.fcpxml` (saída do `handle_remove_media_silence`) e `Legendas fora.fcpxmld` (exportado do FCP).
|
||||
- **Tentativas que falharam:** suspeitar do `cut_clip_ranges`/`_filter_children_for_segment`; o arquivo gerado pelo programa não tem esses micro-clips.
|
||||
- **Solução adotada:** confirmado que o bug não está no código de corte; é artefato da re-exportação pelo FCP. Ajustar a detecção de silêncio (`min_duration`/padding) não resolve porque o arquivo gerado já está correto.
|
||||
- **Aprendizado:** antes de assumir bug no gerador, reproduzir a saída crua e comparar com o artefato final — a re-importação no NLE pode reintroduzir clipes/gaps.
|
||||
- **Estado:** `resolvido` (diagnóstico)
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Legenda SRT fora de sincronia após corte de silêncio
|
||||
|
||||
- **Sintoma:** ao exportar legenda depois de remover silêncios, o SRT cobria o vídeo inteiro em vez de apenas os trechos que ficaram — legendas apareciam em partes já cortadas.
|
||||
- **Causa raiz:** `cmd_export_srt` gerava o SRT direto do transcript da mídia original (`segments_to_srt`), com timestamps da fonte bruta, ignorando os cortes da timeline editada.
|
||||
- **Onde:** `admin/models_api.py` (`cmd_export_srt`).
|
||||
- **Tentativas que falharam:** exportar os segmentos como vinham do Whisper.
|
||||
- **Solução adotada:** mapear cada segmento da fonte para a posição real na timeline com `clip_offset + (seg_start - clip_source_start)` (mesma lógica do `transcript_markers`), por clipe da spine editada, descartando falas fora da janela usada e ordenando por tempo.
|
||||
- **Aprendizado:** qualquer artefato derivado da transcrição (SRT, cortes) deve ser mapeado fonte→timeline, nunca usar o transcript bruto; reutilizar o mapa já existente em `handle_transcript_markers`.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Terceiro ponto do bug de `int(fps)`: `TimeValue.from_timecode()` corrompia qualquer segundo decimal em taxa NTSC
|
||||
|
||||
- **Sintoma:** ao implementar a nova tool `transcript_markers` (marca no timeline
|
||||
cada frase transcrita), `add_marker_at_timeline("317.9857s", ...)` lançava
|
||||
`ValueError: No spine clip at position 331.478s` — uma posição **fora** da
|
||||
timeline, mesmo com o timestamp de entrada correto e dentro dos limites.
|
||||
- **Causa raiz:** `TimeValue.from_timecode()` (`fcpxml/models.py`), no ramo que
|
||||
parseia segundos decimais simples (`"12.5s"`, sem `/`), calculava
|
||||
`frames = round(seconds * fps)` com o `fps` real (float), mas construía o
|
||||
`TimeValue` como `TimeValue(frames, int(fps))` — numerador calculado com o
|
||||
fps certo, denominador truncado. A 23.976fps isso infla o valor em ~1.04x
|
||||
(317.99s virou 331.48s). Terceiro local com essa mesma classe de bug (os
|
||||
outros dois: `to_frame_timevalue`/`_cut_transcript_spans` em `server.py`,
|
||||
já corrigidos na entrada anterior) — `from_timecode` é usado por
|
||||
`_parse_time()`, chamado por quase todo o `writer.py`, então qualquer
|
||||
handler que passe um timecode decimal (não fração) nessa taxa era afetado.
|
||||
- **Onde:** `fcpxml/models.py` (`TimeValue.from_timecode`).
|
||||
- **Tentativas que falharam:** nenhuma — bug novo, achado testando o handler
|
||||
novo contra a transcrição real em cache antes de expor na UI.
|
||||
- **Solução adotada:** reconstruir a fração exata do fps via
|
||||
`Fraction(fps).limit_denominator(100_000)` (recupera `24000/1001` a partir
|
||||
do float com precisão total) e usar `frames * fps_frac.denominator` /
|
||||
`fps_frac.numerator` como numerador/denominador — mantém os dois em
|
||||
unidades consistentes. Teste de regressão em
|
||||
`tests/test_models.py::test_from_seconds_string_ntsc_rate_exact`.
|
||||
- **Validado:** reproduzido o erro exato reportado, corrigido, e o handler
|
||||
novo (`transcript_markers`) rodou de ponta a ponta contra a transcrição
|
||||
real (54 marcadores, sem erro) depois da correção.
|
||||
- **Aprendizado:** qualquer função que aceite `fps: float` e construa um
|
||||
`TimeValue` diretamente (em vez de delegar pra uma fração exata) é suspeita
|
||||
de ter esse bug em taxas NTSC. Ao corrigir uma instância, procurar outras
|
||||
chamadas de `int(fps)` / `round(2400/fps)` no arquivo inteiro — não parar
|
||||
na primeira encontrada.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Legendas visíveis exigem SRT/título, não marcadores
|
||||
|
||||
- **Sintoma:** pedido de "legenda na timeline que apareça no vídeo" — os marcadores de navegação não mostram texto sobre o vídeo.
|
||||
- **Causa raiz:** marcadores (`transcript_markers`) são apenas navegação; legenda visível no FCP exige SRT importado como idioma de legenda ou um `<title>` conectado (fragilmente dependente da versão do FCP).
|
||||
- **Onde:** `MacApp/Sources/TranscriptionView.swift`, `admin/models_api.py` (`cmd_export_srt`), reuso de `segments_to_srt`.
|
||||
- **Tentativas que falharam:** usar marcadores para legenda; gerar `<title>` de texto no FCPXML é frágil entre versões.
|
||||
- **Solução adotada:** novo comando `export_srt` que gera um `.srt` por mídia transcrita (via transcript cacheado + `segments_to_srt`), exposto como botão "Exportar Legendas (SRT)". O FCP importa o SRT como legenda nativa e desenha sobre o vídeo.
|
||||
- **Aprendizado:** "legenda" no FCP = SRT/caption, não marcador; sempre distinguir navegação (marker) de texto sobreposto (caption).
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Remoção de silêncio/transcrição gerava XML fora da grade de frame em projetos NTSC (23.976/29.97fps), confirmado por importação real no FCP
|
||||
|
||||
- **Sintoma:** ao importar no Final Cut Pro o XML gerado por `remove_media_silence`,
|
||||
dezenas de avisos "O item não está em um limite de quadro de edição" em quase
|
||||
todo `asset-clip` da spine (projeto real de 82 clipes, `Depimento Erika`).
|
||||
- **Causa raiz:** `handle_remove_media_silence` e `_cut_transcript_spans`
|
||||
(`server.py`) calculavam os limites de corte com
|
||||
`TimeValue(round(seconds*fps) * round(2400/fps), 2400)` — uma base fixa de
|
||||
2400 ticks/segundo. Para 24/25/30/48/50/60fps isso é exato, mas para
|
||||
23.976fps (`frameDuration="1001/24000s"`) `round(2400/23.976)` arredonda para
|
||||
100, tratando cada frame como `1/24s` exato em vez do `1001/24000s` real —
|
||||
uma correção de `Engine/docs/05_EXPERIENCIAS.md` (entrada anterior) existia
|
||||
como `FCPXMLModifier.snap_spine_times_to_frames()` mas só era chamada por
|
||||
`handle_add_marker`; nenhum handler de corte/ripple a usava.
|
||||
- **Onde:** `server.py` (`handle_remove_media_silence`, `_cut_transcript_spans`)
|
||||
e `fcpxml/writer.py` (`FCPXMLModifier.save()`).
|
||||
- **Tentativas que falharam:** nenhuma — a correção certa (`Fraction` exato)
|
||||
já existia no código, só não estava conectada aos caminhos que realmente
|
||||
cortam a spine.
|
||||
- **Solução adotada:** (1) `save()` agora chama `snap_spine_times_to_frames()`
|
||||
incondicionalmente antes de serializar — todo handler que escreve passa por
|
||||
ali, então a proteção é universal e não depende de cada handler lembrar de
|
||||
chamar. (2) Os dois pontos de corte por segundos (`to_frame_timevalue` /
|
||||
`to_frame`) agora usam o novo `FCPXMLModifier.snap_seconds_to_frame()`, que
|
||||
arredonda para o frame mais próximo usando a fração exata de `frameDuration`
|
||||
em vez da base fixa de 2400. Teste de regressão em
|
||||
`tests/test_media_intel.py::test_ntsc_rate_output_stays_frame_aligned`
|
||||
reproduz `duration="41100/2400s"` com a lógica antiga (não-inteiro em frames)
|
||||
e confirma alinhamento exato com a nova.
|
||||
- **Validado:** reimportação real no Final Cut Pro pelo usuário, sem os avisos.
|
||||
- **Aprendizado:** qualquer cálculo de tempo que assuma uma base fixa de ticks
|
||||
(2400, 600, etc.) quebra silenciosamente em taxas NTSC fracionárias
|
||||
(23.976/29.97/59.94fps) — usar sempre `Fraction` a partir do `frameDuration`
|
||||
real da sequência, nunca `fps` arredondado. E quando existir uma correção
|
||||
"canônica" pronta no código, verificar que TODOS os caminhos relevantes a
|
||||
chamam, não só um.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Fluxo de transcrição dependia de clique redundante e ocultava falhas
|
||||
|
||||
- **Sintoma:** após selecionar um projeto, era necessário clicar novamente para abrir a transcrição; erros do processo Python podiam não aparecer na interface.
|
||||
- **Causa raiz:** a tela filha era condicionada a um botão intermediário, e o bridge não preservava fragmentos incompletos do JSONL nem convertia saída diferente de zero em erro.
|
||||
- **Onde:** `MacApp/Sources/ProjectView.swift` e `MacApp/Sources/PythonBridge.swift`.
|
||||
- **Tentativas que falharam:** depender apenas do callback de linhas completas e deixar a conclusão ignorar o código de saída.
|
||||
- **Solução adotada:** abrir a tela automaticamente após `inspect`, processar a última linha parcial e propagar falhas do subprocesso.
|
||||
- **Aprendizado:** bridges JSONL precisam tratar chunks arbitrários de stdout e sempre validar o status de saída.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
### 2026-08-14 — Limites de quadro no XML exportado após remoção de silêncio
|
||||
|
||||
- **Sintoma:** o Final Cut Pro rejeitava `Info.fcpxml` com avisos de que
|
||||
`offset` e `duration` não estavam em limites de quadro.
|
||||
- **Causa raiz:** a edição ripple produzia frações de tempo válidas
|
||||
matematicamente, mas desalinhadas do `frameDuration` exato da sequência.
|
||||
- **Onde:** `fcpxml/writer.py` e `server.py` no fluxo de remoção de silêncio.
|
||||
- **Tentativas que falharam:** usar FPS convertido para float e assumir uma
|
||||
base inteira, o que não funciona para 23.976/29.97.
|
||||
- **Solução adotada:** normalizar `offset` e `duration` da spine usando a
|
||||
fração exata de `frameDuration` antes de salvar a cópia modificada.
|
||||
- **Aprendizado:** limites de edição do FCPXML devem ser calculados com
|
||||
`Fraction`, nunca com FPS arredondado ou floats.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
<!-- NOVAS ENTRADAS DEVEM SER ADICIONADAS ACIMA DESTA LINHA, SEMPRE NO TOPO
|
||||
DA LISTA, PARA QUE A MAIS RECENTE FIQUE EM PRIMEIRO LUGAR. -->
|
||||
|
||||
### 2026-08-14 — Início do registro de experiências
|
||||
|
||||
- **Sintoma:** não havia um local centralizado para registrar erros/estruturas
|
||||
problemáticas; cada correção era tratada isoladamente.
|
||||
- **Causa raiz:** ausência de um artefato de memória de projeto; o contexto de
|
||||
bugs já resolvidos se perdia entre sessões.
|
||||
- **Onde:** `Engine/docs/05_EXPERIENCIAS.md` (este arquivo, recém-criado).
|
||||
- **Tentativas que falharam:** n/a (primeira entrada).
|
||||
- **Solução adotada:** criação deste arquivo com template padronizado, integrado
|
||||
ao fluxo de validação pós-correção (`Engine/run_after_fix.sh`).
|
||||
- **Aprendizado:** registrar problemas continuamente reduz o retrabalho; uma
|
||||
entrada clara evita reabrir bugs já entendidos.
|
||||
- **Estado:** `resolvido`
|
||||
|
||||
---
|
||||
|
||||
## Resumo rápido (índice)
|
||||
|
||||
| # | Data | Problema | Estado |
|
||||
|---|------|----------|--------|
|
||||
| 1 | 2026-08-14 | Início do registro de experiências | `resolvido` |
|
||||
| 4 | 2026-08-14 | XML fora da grade de frame em NTSC (23.976/29.97fps), confirmado no FCP | `resolvido` |
|
||||
| 5 | 2026-08-14 | `TimeValue.from_timecode` corrompia segundos decimais em NTSC (3º ponto do bug) | `resolvido` |
|
||||
| 6 | 2026-08-17 | Clipe-fantasma de 1 frame no início/fim após remoção de silêncio (padding sem vizinho na borda) | `resolvido` |
|
||||
| 7 | 2026-08-17 | Legendas dinâmicas sobrepondo entre clipes (título conectado não é aparado pelo out-point do pai) | `resolvido` |
|
||||
| 8 | 2026-08-17 | Importação recusada: `id` de `<text-style-def>` derivado do texto (acentos/espaços/dígito inicial) não é XML Name válido | `resolvido` |
|
||||
| 9 | 2026-08-17 | Legendas palavra a palavra centradas em vez da composição progressiva diagramada (bloco por trecho, palavra-chave em display italic) | `resolvido` |
|
||||
| 10 | 2026-08-17 | Cedilha/acentos da display italic invadindo a linha vizinha: empilhamento passou a usar a tinta real por classe de glifo | `resolvido` |
|
||||
|
||||
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
|
||||
@@ -0,0 +1,77 @@
|
||||
# 06 — Boas Práticas de Programação (G-ART)
|
||||
|
||||
> **Propósito:** registrar as melhores práticas de programação a serem aplicadas
|
||||
> **sempre** que qualquer alteração ou correção for feita neste programa.
|
||||
> Servem de checklist obrigatório antes de concluir qualquer mudança.
|
||||
|
||||
**Regra:** antes de considerar uma alteração concluída, confira os itens abaixo.
|
||||
Eles são o mesmo espírito do fluxo de validação pós-correção
|
||||
(`Engine/run_after_fix.sh`), mas cobrem também **qualidade de código** e
|
||||
**convenções do projeto**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Correções de código
|
||||
|
||||
1. **Sempre valide após corrigir** — rode `./Engine/run_after_fix.sh` (lint +
|
||||
testes). Nunca declare uma correção pronta sem que lint e a suíte passem.
|
||||
2. **Toda correção já corrigida vira registro** — registre o problema em
|
||||
`Engine/docs/05_EXPERIENCIAS.md` para que não se repita.
|
||||
3. **Mude o mínimo necessário** — altere apenas o que resolve o problema; evite
|
||||
refatorar código não relacionado na mesma mudança.
|
||||
|
||||
## 2. Tempo e FCPXML
|
||||
|
||||
4. **Nunca use `float` para tempo** — toda duração/offset é `TimeValue`
|
||||
(fração racional `"600/2400s"`). Float introduz erro de arredondamento.
|
||||
5. **`offset` é a posição na timeline; `start` é o in-point na origem** — não
|
||||
confundir nas edições de clip.
|
||||
6. **Markers são filhos dos clips, não irmãos** — e `<spine>` é a storyline
|
||||
primária; connected clips penduram-se com atributo `lane`.
|
||||
7. **Preserve sidecars em bundles `.fcpxmld`** — ao gravar um bundle, copie os
|
||||
arquivos de dados; caso contrário destrói object-tracking/Cinematic.
|
||||
|
||||
## 3. Estrutura e arquitetura
|
||||
|
||||
8. **Mantenha o núcleo desacoplado** — `fcpxml/` não conhece o protocolo MCP;
|
||||
`server.py` é a camada de transporte. Não vazem lógica MCP para o núcleo.
|
||||
9. **Use o padrão de dispatch** — sem cadeias gigantes de `if/elif`; use
|
||||
`TOOL_HANDLERS` (dicionário nome → handler assíncrono).
|
||||
10. **Reaproveite os helpers centrais** — `_parse_project()`, `_resolve_io_paths()`,
|
||||
`_setup_modifier()`, etc. Não duplique parse/validação de caminho.
|
||||
11. **Nunca sobrescreva o original** — use `generate_output_path()` e crie
|
||||
`_modified`, `_chapters`, etc.
|
||||
12. **Mantenha o `MarkerType` como single source of truth** — a serialização
|
||||
(parse/escrita) vive no enum, não espalhada por handlers.
|
||||
|
||||
## 4. Segurança
|
||||
|
||||
13. **Sempre use `safe_xml.py` (defusedxml)** — todos os entry points de parse;
|
||||
jamais `xml.etree` cru com input não confiável.
|
||||
14. **Valide caminhos com `_validate_filepath` / `_validate_output_path`** — o
|
||||
sandbox de I/O existe para impedir escrita fora do permitido.
|
||||
15. **Rejeite payloads excessivamente aninhados** — `_check_json_depth` protege
|
||||
contra payloads além de 50 níveis.
|
||||
16. **Nunca registre/commite segredos ou chaves** — nem em logs, nem em código.
|
||||
|
||||
## 5. Qualidade e clareza
|
||||
|
||||
17. **Sem comentários desnecessários** — código deve ser autoexplicativo;
|
||||
comente o *porquê*, não o *o quê*.
|
||||
18. **Mimice as convenções do projeto** — mesma estrutura de imports, nomes,
|
||||
padrões e bibliotecas já usadas nas vizinhas.
|
||||
19. **Lazy import de dependências opcionais** — `media_intel` (librosa) e
|
||||
`transcribe` (Whisper) importam sob demanda e degradam com graça (`None`).
|
||||
20. **Convenções de teste** — use `examples/sample.fcpxml` + fixtures XML inline;
|
||||
`sample.fcpxml` NÃO é DTD-conformante, não o use como fixture de validade DTD.
|
||||
|
||||
## 6. Checklist final antes de concluir uma alteração
|
||||
|
||||
- [ ] `./Engine/run_after_fix.sh` passou (lint zero erros + todos os testes).
|
||||
- [ ] Problema registrado em `Engine/docs/05_EXPERIENCIAS.md` (se aplicável).
|
||||
- [ ] Nenhum `float` usado em matemática de tempo.
|
||||
- [ ] Nenhum caminho original sobrescrito.
|
||||
- [ ] `safe_xml.py` usado em todo parse de input não confiável.
|
||||
- [ ] Nenhum segredo registrado ou commitado.
|
||||
- [ ] Mudança mínima, sem refatoração não relacionada.
|
||||
- [ ] Boa prática nova aprendida adicionada a esta lista.
|
||||
@@ -0,0 +1,121 @@
|
||||
# 07 — Estudo (SUSPENSO): Detectar o Projeto Ativo no Final Cut Pro
|
||||
|
||||
> **Status: SUSPENSO** (2026-08-14). Estudo retomável. Ver seção
|
||||
> ["Onde parámos e próximos passos"](#onde-paramos-e-proximos-passos) ao fundo.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Descobrir se é possível detectar, de forma programática, **qual projeto/timeline
|
||||
está aberto e em edição no Final Cut Pro** — não apenas enumerar os projetos —
|
||||
para que as ferramentas do MCP possam operar sobre o projeto ativo.
|
||||
|
||||
Contexto atual do repositório: `fcpxml/live.py` já implementa `list_fcp_libraries`
|
||||
(enumerar bibliotecas → eventos → projetos) e `push_to_fcp` (import via Open
|
||||
Document). O que **falta** é saber *qual* projeto o usuário está vendo/editando.
|
||||
|
||||
## Fatos verificados na máquina (2026-08-14)
|
||||
|
||||
- **FCP está em execução**, mas o executável **não está em `/Applications`**:
|
||||
localizado em `/Volumes/Merongo/Applications/Final Cut Pro Creator Studio.app`.
|
||||
Aplicativo aparece como "Final Cut Pro Creator Studio" mas o bundle id é
|
||||
`com.apple.FinalCut` (processo `Final Cut Pro`).
|
||||
- Ambiente: `osascript` funciona (permissão de automação concedida para o host).
|
||||
|
||||
## Superfície oficial: AppleScript (`ProEditor.sdef`)
|
||||
|
||||
O dicionário oficial fica em:
|
||||
`/Volumes/Merongo/Applications/Final Cut Pro Creator Studio.app/Contents/Resources/ProEditor.sdef`
|
||||
|
||||
Observações estruturais do `.sdef`:
|
||||
|
||||
- **Top-level = somente `libraries`** → `events` → `projects`/`sequences`.
|
||||
- **NÃO existe** propriedade `active project`, `front project` ou `active sequence`
|
||||
no dicionário. A única leitura possível é `get` (100% read-only).
|
||||
- O `sdef` inclui a suite padrão (`CocoaStandard.sdef`), o que traz `document`.
|
||||
|
||||
### Testes de terminal (resultados reais)
|
||||
|
||||
| Comando (via `osascript`) | Resultado |
|
||||
|---|---|
|
||||
| `get name of front document` | `Biblioteca Padrão` — retorna a **biblioteca** em primeiro plano (class `document` que corresponde a um `library`; o id casa com um `library`). |
|
||||
| `get class of front document` | `document` |
|
||||
| `get name of front window` | `Final Cut Pro` (apenas o nome da janela do app, **não** o projeto) |
|
||||
| `front project` | erro (`Não é possível obter project 1`) |
|
||||
| `active document` | erro de sintaxe (`active` não é palavra-chave) |
|
||||
| `events of front document` | erro `-1728` (eventos ficam em `library`, não em `document`) |
|
||||
| `get name of every project` | erro `-1728` (projetos não são top-level) |
|
||||
|
||||
### Conclusão da superfície oficial
|
||||
|
||||
- ✅ Dá para detectar a **biblioteca em primeiro plano** via `front document`
|
||||
(equivalente a `libraries` de maior prioridade na lista).
|
||||
- ❌ **NÃO dá** para detectar o **projeto ativo** (a timeline em edição) apenas
|
||||
pelo AppleScript oficial.
|
||||
|
||||
## Superfície não-oficial: CommandPost
|
||||
|
||||
CommandPost instalado: **v1.4.13** em `/Applications/CommandPost.app` (versão 1,
|
||||
gratuita; **NÃO** é a v2 que requer LateNite). LateNite **não está** instalado.
|
||||
|
||||
- Na v1.4.13 **não estava rodando** e a porta WebSocket `27480` **estava fechada**
|
||||
(essa porta é do servidor WebSocket da v2). Logo, a integração via WS na porta
|
||||
27480 **não se aplica** a esta instalação.
|
||||
- O app é baseado em **Hammerspoon** e tem um comando AppleScript próprio:
|
||||
**`execute lua code`** (declarado em
|
||||
`/Applications/CommandPost.app/Contents/Resources/CommandPost.sdef`).
|
||||
Isso permite rodar Lua dentro do ambiente do CommandPost (que tem acesso à
|
||||
árvore Accessibility/AX do FCP e à API `cp.*`).
|
||||
- O CommandPost usa **Accessibility (AX) scripting** para ler o FCP. A API interna
|
||||
(`cp.apple.finalcutpro`) é extensa e profunda; os caminhos para "documento ativo"
|
||||
não são uma propriedade direta e limpa — são derivados da árvore AX (ex.: título
|
||||
da janela da timeline).
|
||||
|
||||
## Superfície não-oficial: AX direto (System Events)
|
||||
|
||||
Testado: ler títulos das janelas do processo "Final Cut Pro" via `System Events`:
|
||||
|
||||
```
|
||||
tell application "System Events" to tell process "Final Cut Pro" to get title of every window
|
||||
```
|
||||
|
||||
- Resultado: **erro `-25211` — "osascript é um acesso assistivo não permitido"**
|
||||
(o host não tem permissão de Accessibility/Assistive Access).
|
||||
- Isso é uma permissão de sistema (System Settings → Privacy & Security →
|
||||
Accessibility) que precisa ser concedida ao processo host.
|
||||
- A princípio o título da janela da timeline do FCP **contém o nome do projeto**,
|
||||
o que tornaria essa a via mais simples — desde que a permissão AX exista.
|
||||
|
||||
## Mapa de decisão (resumo)
|
||||
|
||||
| Via | Detecta projeto ativo? | Permissão extra | Robustez |
|
||||
|---|---|---|---|
|
||||
| AppleScript oficial (`front document`) | Só a biblioteca, não o projeto | Automação (já ok) | Alta (sanctioned) |
|
||||
| CommandPost `execute lua code` | Sim (via AX/`cp.*`) | CommandPost rodando + AX do CommandPost | Média (frágil a updates do FCP) |
|
||||
| AX direto (`System Events` título da janela) | Sim (título da timeline tem o projeto) | Accessibility no host | Média-alta |
|
||||
| CommandPost v2 WS porta 27480 | Sim | CommandPost v2 + LateNite (~US$10) | — (não aplicável aqui, é v1) |
|
||||
|
||||
## Onde paramos e próximos passos
|
||||
|
||||
**Estado:** investigação inicial feita; **nenhum código foi escrito/alterado**.
|
||||
Nenhum arquivo do repositório foi modificado; nada foi commitado.
|
||||
|
||||
Próximos passos sugeridos ao retomar:
|
||||
|
||||
1. **Decidir a via** — provavelmente a mais promissora e de menor custo é a
|
||||
**AX direta via `System Events`** (ler o título da janela da timeline), pois
|
||||
dispensa CommandPost. Requer apenas conceder Accessibility ao host.
|
||||
2. **Conceder permissão** de Accessibility ao terminal/host em System Settings →
|
||||
Privacy & Security → Accessibility, e re-testar:
|
||||
```
|
||||
tell application "System Events" to tell process "Final Cut Pro" to get title of every window
|
||||
```
|
||||
Confirmar que o título da timeline contém o nome do projeto ativo.
|
||||
3. Se AX direto for inviável, **testar CommandPost `execute lua code`**:
|
||||
iniciar o CommandPost, e via osascript executar Lua que consulta a árvore AX
|
||||
do FCP (ex.: a janela `PrimaryWindow` / a `timeline`) para extrair o título.
|
||||
A API interna relevante vive em `extensions/cp/apple/finalcutpro/`.
|
||||
4. **Projetar o tool MCP** (ex.: `get_fcp_active_project` / `detect_fcp_project`),
|
||||
seguindo o padrão de `handle_*` em `server.py` e a documentação de boas
|
||||
práticas (`Engine/docs/06_BOAS_PRATICAS.md`).
|
||||
5. **Registrar** qualquer correção/erro recorrente em
|
||||
`Engine/docs/05_EXPERIENCIAS.md` e rodar `./Engine/run_after_fix.sh`.
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_after_fix.sh — VALIDAÇÃO PADRÃO PÓS-CORREÇÃO (G-ART)
|
||||
#
|
||||
# Sempre que qualquer correção de código for concluída, o sistema é
|
||||
# automaticamente executado/validado rodando ESTE script. Ele garante que o
|
||||
# lint passa com zero erros e que toda a suíte de testes passa antes de
|
||||
# qualquer commit. Se falhar, corrija antes de prosseguir.
|
||||
#
|
||||
# Uso:
|
||||
# ./Engine/run_after_fix.sh
|
||||
#
|
||||
# Requer: uv (veja pyproject.toml). Roda a partir de QUALQUER diretório.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve o diretório raiz do repositório (pai da pasta Engine).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo ""
|
||||
echo "==> [G-ART] Validação pós-correção iniciada..."
|
||||
echo " Diretório: $REPO_ROOT"
|
||||
echo ""
|
||||
|
||||
echo "==> 1/2 Lint (ruff) — deve passar com ZERO erros"
|
||||
# A flag --exclude sobrescreve o exclude declarado em pyproject.toml
|
||||
# (que já ignora docs/ e WHISPERX/). Rode sem flag para herdar a config.
|
||||
uv run ruff check .
|
||||
echo " Lint OK ✓"
|
||||
echo ""
|
||||
|
||||
echo "==> 2/2 Testes (pytest) — todos devem passar"
|
||||
uv run pytest tests/ -v
|
||||
echo ""
|
||||
|
||||
echo "=========================================================="
|
||||
echo " TUDO OK ✓"
|
||||
echo " O sistema passou na validação padrão pós-correção."
|
||||
echo " Agora você pode commitar com segurança."
|
||||
echo "=========================================================="
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
---
|
||||
description: Documentation for understanding how to use the Swift Composable Architecture's Tree Navigation feature.
|
||||
globs:
|
||||
---
|
||||
|
||||
Basics
|
||||
The tools for this style of navigation include the Presents() macro, PresentationAction, the ifLet(_:action:destination:fileID:filePath:line:column:) operator, and that is all. Once your feature is properly integrated with those tools you can use all of SwiftUI’s normal navigation view modifiers, such as sheet(item:), popover(item:), etc.
|
||||
|
||||
The process of integrating two features together for navigation largely consists of 2 steps: integrating the features’ domains together and integrating the features’ views together. One typically starts by integrating the features’ domains together. This consists of adding the child’s state and actions to the parent, and then utilizing a reducer operator to compose the child reducer into the parent.
|
||||
|
||||
For example, suppose you have a list of items and you want to be able to show a sheet to display a form for adding a new item. We can integrate state and actions together by utilizing the Presents() macro and PresentationAction type:
|
||||
|
||||
@Reducer
|
||||
struct InventoryFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
@Presents var addItem: ItemFormFeature.State?
|
||||
var items: IdentifiedArrayOf<Item> = []
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
enum Action {
|
||||
case addItem(PresentationAction<ItemFormFeature.Action>)
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
// ...
|
||||
}
|
||||
Note
|
||||
|
||||
The addItem state is held as an optional. A non-nil value represents that feature is being presented, and nil presents the feature is dismissed.
|
||||
|
||||
Next you can integrate the reducers of the parent and child features by using the ifLet(_:action:destination:fileID:filePath:line:column:) reducer operator, as well as having an action in the parent domain for populating the child’s state to drive navigation:
|
||||
|
||||
@Reducer
|
||||
struct InventoryFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable { /* ... */ }
|
||||
enum Action { /* ... */ }
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .addButtonTapped:
|
||||
// Populating this state performs the navigation
|
||||
state.addItem = ItemFormFeature.State()
|
||||
return .none
|
||||
|
||||
|
||||
// ...
|
||||
}
|
||||
}
|
||||
.ifLet(\.$addItem, action: \.addItem) {
|
||||
ItemFormFeature()
|
||||
}
|
||||
}
|
||||
}
|
||||
Note
|
||||
|
||||
The key path used with ifLet focuses on the @PresentationState projected value since it uses the $ syntax. Also note that the action uses a case path, which is analogous to key paths but tuned for enums.
|
||||
|
||||
That’s all that it takes to integrate the domains and logic of the parent and child features. Next we need to integrate the features’ views. This is done by passing a binding of a store to one of SwiftUI’s view modifiers.
|
||||
|
||||
For example, to show a sheet from the addItem state in the InventoryFeature, we can hand the sheet(item:) modifier a binding of a Store as an argument that is focused on presentation state and actions:
|
||||
|
||||
struct InventoryView: View {
|
||||
@Bindable var store: StoreOf<InventoryFeature>
|
||||
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.sheet(
|
||||
item: $store.scope(state: \.addItem, action: \.addItem)
|
||||
) { store in
|
||||
ItemFormView(store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
Note
|
||||
|
||||
We use SwiftUI’s @Bindable property wrapper to produce a binding to a store, which can be further scoped using SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:).
|
||||
|
||||
With those few steps completed the domains and views of the parent and child features are now integrated together, and when the addItem state flips to a non-nil value the sheet will be presented, and when it is nil’d out it will be dismissed.
|
||||
|
||||
In this example we are using the .sheet view modifier, but every view modifier SwiftUI ships can be handed a store in this fashion, including popover(item:), fullScreenCover(item:), navigationDestination(item:)`, and more. This should make it possible to use optional state to drive any kind of navigation in a SwiftUI application.
|
||||
|
||||
Enum state
|
||||
While driving navigation with optional state can be powerful, it can also lead to less-than-ideal modeled domains. In particular, if a feature can navigate to multiple screens then you may be tempted to model that with multiple optional values:
|
||||
|
||||
@ObservableState
|
||||
struct State {
|
||||
@Presents var detailItem: DetailFeature.State?
|
||||
@Presents var editItem: EditFeature.State?
|
||||
@Presents var addItem: AddFeature.State?
|
||||
// ...
|
||||
}
|
||||
However, this can lead to invalid states, such as 2 or more states being non-nil at the same time, and that can cause a lot of problems. First of all, SwiftUI does not support presenting multiple views at the same time from a single view, and so by allowing this in our state we run the risk of putting our application into an inconsistent state with respect to SwiftUI.
|
||||
|
||||
Second, it becomes more difficult for us to determine what feature is actually being presented. We must check multiple optionals to figure out which one is non-nil, and then we must figure out how to interpret when multiple pieces of state are non-nil at the same time.
|
||||
|
||||
And the number of invalid states increases exponentially with respect to the number of features that can be navigated to. For example, 3 optionals leads to 4 invalid states, 4 optionals leads to 11 invalid states, and 5 optionals leads to 26 invalid states.
|
||||
|
||||
For these reasons, and more, it can be better to model multiple destinations in a feature as a single enum rather than multiple optionals. So the example of above, with 3 optionals, can be refactored as an enum:
|
||||
|
||||
enum State {
|
||||
case addItem(AddFeature.State)
|
||||
case detailItem(DetailFeature.State)
|
||||
case editItem(EditFeature.State)
|
||||
// ...
|
||||
}
|
||||
This gives us compile-time proof that only one single destination can be active at a time.
|
||||
|
||||
In order to utilize this style of domain modeling you must take a few extra steps. First you model a “destination” reducer that encapsulates the domains and behavior of all of the features that you can navigate to. Typically it’s best to nest this reducer inside the feature that can perform the navigation, and the Reducer() macro can do most of the heavy lifting for us by implementing the entire reducer from a simple description of the features that can be navigated to:
|
||||
|
||||
@Reducer
|
||||
struct InventoryFeature {
|
||||
// ...
|
||||
|
||||
|
||||
@Reducer
|
||||
enum Destination {
|
||||
case addItem(AddFeature)
|
||||
case detailItem(DetailFeature)
|
||||
case editItem(EditFeature)
|
||||
}
|
||||
}
|
||||
Note
|
||||
|
||||
The Reducer() macro takes this simple enum description of destination features and expands it into a fully composed feature that operates on enum state with a case for each feature’s state. You can expand the macro code in Xcode to see everything that is written for you.
|
||||
|
||||
With that done we can now hold onto a single piece of optional state in our feature, using the Presents() macro, and we hold onto the destination actions using the PresentationAction type:
|
||||
|
||||
@Reducer
|
||||
struct InventoryFeature {
|
||||
@ObservableState
|
||||
struct State {
|
||||
@Presents var destination: Destination.State?
|
||||
// ...
|
||||
}
|
||||
enum Action {
|
||||
case destination(PresentationAction<Destination.Action>)
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
// ...
|
||||
}
|
||||
And then we must make use of the ifLet(_:action:destination:fileID:filePath:line:column:) operator to integrate the domain of the destination with the domain of the parent feature:
|
||||
|
||||
@Reducer
|
||||
struct InventoryFeature {
|
||||
// ...
|
||||
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
// ...
|
||||
}
|
||||
.ifLet(\.$destination, action: \.destination)
|
||||
}
|
||||
}
|
||||
Note
|
||||
|
||||
It’s not necessary to specify Destination in a trialing closure of ifLet because it can automatically be inferred due to how the Destination enum was defined with the Reducer() macro.
|
||||
|
||||
That completes the steps for integrating the child and parent features together.
|
||||
|
||||
Now when we want to present a particular feature we can simply populate the destination state with a case of the enum:
|
||||
|
||||
case addButtonTapped:
|
||||
state.destination = .addItem(AddFeature.State())
|
||||
return .none
|
||||
And at any time we can figure out exactly what feature is being presented by switching or otherwise destructuring the single piece of destination state rather than checking multiple optional values.
|
||||
|
||||
The final step is to make use of the library’s scoping powers to focus in on the Destination domain and further isolate a particular case of the state and action enums via dot-chaining.
|
||||
|
||||
For example, suppose the “add” screen is presented as a sheet, the “edit” screen is presented by a popover, and the “detail” screen is presented in a drill-down. Then we can use the .sheet(item:), .popover(item:), and .navigationDestination(item:) view modifiers that come from SwiftUI to have each of those styles of presentation powered by the respective case of the destination enum.
|
||||
|
||||
To do this you must first hold onto the store in a bindable manner by using the @Bindable property wrapper:
|
||||
|
||||
struct InventoryView: View {
|
||||
@Bindable var store: StoreOf<InventoryFeature>
|
||||
// ...
|
||||
}
|
||||
And then in the body of the view you can use the SwiftUI/Binding/scope(state:action:fileID:filePath:line:column:) operator to derive bindings from $store:
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.sheet(
|
||||
item: $store.scope(
|
||||
state: \.destination?.addItem,
|
||||
action: \.destination.addItem
|
||||
)
|
||||
) { store in
|
||||
AddFeatureView(store: store)
|
||||
}
|
||||
.popover(
|
||||
item: $store.scope(
|
||||
state: \.destination?.editItem,
|
||||
action: \.destination.editItem
|
||||
)
|
||||
) { store in
|
||||
EditFeatureView(store: store)
|
||||
}
|
||||
.navigationDestination(
|
||||
item: $store.scope(
|
||||
state: \.destination?.detailItem,
|
||||
action: \.destination.detailItem
|
||||
)
|
||||
) { store in
|
||||
DetailFeatureView(store: store)
|
||||
}
|
||||
}
|
||||
With those steps completed you can be sure that your domains are modeled as concisely as possible. If the “add” item sheet was presented, and you decided to mutate the destination state to point to the .detailItem case, then you can be certain that the sheet will be dismissed and the drill-down will occur immediately.
|
||||
|
||||
API Unification
|
||||
One of the best features of tree-based navigation is that it unifies all forms of navigation with a single style of API. First of all, regardless of the type of navigation you plan on performing, integrating the parent and child features together can be done with the single ifLet(_:action:destination:fileID:filePath:line:column:) operator. This one single API services all forms of optional-driven navigation.
|
||||
|
||||
And then in the view, whether you are wanting to perform a drill-down, show a sheet, display an alert, or even show a custom navigation component, all you need to do is invoke an API that is provided a store focused on some PresentationState and PresentationAction. If you do that, then the API can handle the rest, making sure to present the child view when the state becomes non-nil and dismissing when it goes back to nil.
|
||||
|
||||
This means that theoretically you could have a single view that needs to be able to show a sheet, popover, drill-down, alert and confirmation dialog, and all of the work to display the various forms of navigation could be as simple as this:
|
||||
|
||||
.sheet(
|
||||
item: $store.scope(state: \.addItem, action: \.addItem)
|
||||
) { store in
|
||||
AddFeatureView(store: store)
|
||||
}
|
||||
.popover(
|
||||
item: $store.scope(state: \.editItem, action: \.editItem)
|
||||
) { store in
|
||||
EditFeatureView(store: store)
|
||||
}
|
||||
.navigationDestination(
|
||||
item: $store.scope(state: \.detailItem, action: \.detailItem)
|
||||
) { store in
|
||||
DetailFeatureView(store: store)
|
||||
}
|
||||
.alert(
|
||||
$store.scope(state: \.alert, action: \.alert)
|
||||
)
|
||||
.confirmationDialog(
|
||||
$store.scope(state: \.confirmationDialog, action: \.confirmationDialog)
|
||||
)
|
||||
In each case we provide a store scoped to the presentation domain, and a view that will be presented when its corresponding state flips to non-nil. It is incredibly powerful to see that so many seemingly disparate forms of navigation can be unified under a single style of API.
|
||||
|
||||
Backwards compatible availability
|
||||
Depending on your deployment target, certain APIs may be unavailable. For example, if you target
|
||||
platforms earlier than iOS 16, macOS 13, tvOS 16 and watchOS 9, then you cannot use navigationDestination. Instead you can use NavigationLink, but you must define helper for driving navigation off of a binding of data rather than just a simple boolean. Just paste the following into your project:
|
||||
|
||||
@available(iOS, introduced: 13, deprecated: 16)
|
||||
@available(macOS, introduced: 10.15, deprecated: 13)
|
||||
@available(tvOS, introduced: 13, deprecated: 16)
|
||||
@available(watchOS, introduced: 6, deprecated: 9)
|
||||
extension NavigationLink {
|
||||
public init<D, C: View>(
|
||||
item: Binding<D?>,
|
||||
onNavigate: @escaping (_ isActive: Bool) -> Void,
|
||||
@ViewBuilder destination: (D) -> C,
|
||||
@ViewBuilder label: () -> Label
|
||||
) where Destination == C? {
|
||||
self.init(
|
||||
destination: item.wrappedValue.map(destination),
|
||||
isActive: Binding(
|
||||
get: { item.wrappedValue != nil },
|
||||
set: { isActive, transaction in
|
||||
onNavigate(isActive)
|
||||
if !isActive {
|
||||
item.transaction(transaction).wrappedValue = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
label: label
|
||||
)
|
||||
}
|
||||
}
|
||||
That gives you the ability to drive a NavigationLink from state. When the link is tapped the onNavigate closure will be invoked, giving you the ability to populate state. And when the feature is dismissed, the state will be nil’d out.
|
||||
|
||||
Integration
|
||||
Once your features are integrated together using the steps above, your parent feature gets instant access to everything happening inside the child feature. You can use this as a means to integrate the logic of child and parent features. For example, if you want to detect when the “Save” button inside the edit feature is tapped, you can simply destructure on that action. This consists of pattern matching on the PresentationAction, then the PresentationAction.presented(_:) case, then the feature you are interested in, and finally the action you are interested in:
|
||||
|
||||
case .destination(.presented(.editItem(.saveButtonTapped))):
|
||||
// ...
|
||||
Once inside that case you can then try extracting out the feature state so that you can perform additional logic, such as closing the “edit” feature and saving the edited item to the database:
|
||||
|
||||
case .destination(.presented(.editItem(.saveButtonTapped))):
|
||||
guard case let .editItem(editItemState) = state.destination
|
||||
else { return .none }
|
||||
|
||||
|
||||
state.destination = nil
|
||||
return .run { _ in
|
||||
self.database.save(editItemState.item)
|
||||
}
|
||||
Dismissal
|
||||
Dismissing a presented feature is as simple as nil-ing out the state that represents the presented feature:
|
||||
|
||||
case .closeButtonTapped:
|
||||
state.destination = nil
|
||||
return .none
|
||||
In order to nil out the presenting state you must have access to that state, and usually only the parent has access, but often we would like to encapsulate the logic of dismissing a feature to be inside the child feature without needing explicit communication with the parent.
|
||||
|
||||
SwiftUI provides a wonderful tool for allowing child views to dismiss themselves from the parent, all without any explicit communication with the parent. It’s an environment value called dismiss, and it can be used like so:
|
||||
|
||||
struct ChildView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
var body: some View {
|
||||
Button("Close") { self.dismiss() }
|
||||
}
|
||||
}
|
||||
When self.dismiss() is invoked, SwiftUI finds the closest parent view with a presentation, and causes it to dismiss by writing false or nil to the binding that drives the presentation. This can be incredibly useful, but it is also relegated to the view layer. It is not possible to use dismiss elsewhere, like in an observable object, which would allow you to have nuanced logic for dismissal such as validation or async work.
|
||||
|
||||
The Composable Architecture has a similar tool, except it is appropriate to use from a reducer, where the rest of your feature’s logic and behavior resides. It is accessed via the library’s dependency management system (see Dependencies) using DismissEffect:
|
||||
|
||||
@Reducer
|
||||
struct Feature {
|
||||
@ObservableState
|
||||
struct State { /* ... */ }
|
||||
enum Action {
|
||||
case closeButtonTapped
|
||||
// ...
|
||||
}
|
||||
@Dependency(\.dismiss) var dismiss
|
||||
var body: some Reducer<State, Action> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .closeButtonTapped:
|
||||
return .run { _ in await self.dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Note
|
||||
|
||||
The DismissEffect function is async which means it cannot be invoked directly inside a reducer. Instead it must be called from run(priority:operation:catch:fileID:filePath:line:column:).
|
||||
|
||||
When self.dismiss() is invoked it will nil out the state responsible for presenting the feature by sending a PresentationAction.dismiss action back into the system, causing the feature to be dismissed. This allows you to encapsulate the logic for dismissing a child feature entirely inside the child domain without explicitly communicating with the parent.
|
||||
|
||||
Note
|
||||
|
||||
Because dismissal is handled by sending an action, it is not valid to ever send an action after invoking dismiss():
|
||||
|
||||
return .run { send in
|
||||
await self.dismiss()
|
||||
await send(.tick) // ⚠️
|
||||
}
|
||||
To do so would be to send an action for a feature while its state is nil, and that will cause a runtime warning in Xcode and a test failure when running tests.
|
||||
|
||||
Warning
|
||||
|
||||
SwiftUI’s environment value @Environment(\.dismiss) and the Composable Architecture’s dependency value @Dependency(\.dismiss) serve similar purposes, but are completely different types. SwiftUI’s environment value can only be used in SwiftUI views, and this library’s dependency value can only be used inside reducers.
|
||||
|
||||
Testing
|
||||
A huge benefit of properly modeling your domains for navigation is that testing becomes quite easy. Further, using “non-exhaustive testing” (see Non-exhaustive testing) can be very useful for testing navigation since you often only want to assert on a few high level details and not all state mutations and effects.
|
||||
|
||||
As an example, consider the following simple counter feature that wants to dismiss itself if its count is greater than or equal to 5:
|
||||
|
||||
@Reducer
|
||||
struct CounterFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var count = 0
|
||||
}
|
||||
enum Action {
|
||||
case decrementButtonTapped
|
||||
case incrementButtonTapped
|
||||
}
|
||||
|
||||
|
||||
@Dependency(\.dismiss) var dismiss
|
||||
|
||||
|
||||
var body: some Reducer<State, Action> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .decrementButtonTapped:
|
||||
state.count -= 1
|
||||
return .none
|
||||
|
||||
|
||||
case .incrementButtonTapped:
|
||||
state.count += 1
|
||||
return state.count >= 5
|
||||
? .run { _ in await self.dismiss() }
|
||||
: .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
And then let’s embed that feature into a parent feature using the Presents() macro, PresentationAction type and ifLet(_:action:destination:fileID:filePath:line:column:) operator:
|
||||
|
||||
@Reducer
|
||||
struct Feature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
@Presents var counter: CounterFeature.State?
|
||||
}
|
||||
enum Action {
|
||||
case counter(PresentationAction<CounterFeature.Action>)
|
||||
}
|
||||
var body: some Reducer<State, Action> {
|
||||
Reduce { state, action in
|
||||
// Logic and behavior for core feature.
|
||||
}
|
||||
.ifLet(\.$counter, action: \.counter) {
|
||||
CounterFeature()
|
||||
}
|
||||
}
|
||||
}
|
||||
Now let’s try to write a test on the Feature reducer that proves that when the child counter feature’s count is incremented above 5 it will dismiss itself. To do this we will construct a TestStore for Feature that starts in a state with the count already set to 3:
|
||||
|
||||
@Test
|
||||
func dismissal() {
|
||||
let store = TestStore(
|
||||
initialState: Feature.State(
|
||||
counter: CounterFeature.State(count: 3)
|
||||
)
|
||||
) {
|
||||
CounterFeature()
|
||||
}
|
||||
}
|
||||
Then we can send the .incrementButtonTapped action in the counter child feature to confirm that the count goes up by one:
|
||||
|
||||
await store.send(\.counter.incrementButtonTapped) {
|
||||
$0.counter?.count = 4
|
||||
}
|
||||
And then we can send it one more time to see that the count goes up to 5:
|
||||
|
||||
await store.send(\.counter.incrementButtonTapped) {
|
||||
$0.counter?.count = 5
|
||||
}
|
||||
And then we finally expect that the child dismisses itself, which manifests itself as the PresentationAction.dismiss action being sent to nil out the counter state, which we can assert using the receive(_:timeout:assert:fileID:file:line:column:) method on TestStore:
|
||||
|
||||
await store.receive(\.counter.dismiss) {
|
||||
$0.counter = nil
|
||||
}
|
||||
This shows how we can write very nuanced tests on how parent and child features interact with each other.
|
||||
|
||||
However, the more complex the features become, the more cumbersome testing their integration can be. By default, TestStore requires us to be exhaustive in our assertions. We must assert on how every piece of state changes, how every effect feeds data back into the system, and we must make sure that all effects finish by the end of the test (see doc:Testing for more info).
|
||||
|
||||
But TestStore also supports a form of testing known as “non-exhaustive testing” that allows you to assert on only the parts of the features that you actually care about (see Non-exhaustive testing for more info).
|
||||
|
||||
For example, if we turn off exhaustivity on the test store (see exhaustivity) then we can assert at a high level that when the increment button is tapped twice that eventually we receive a dismiss action:
|
||||
|
||||
@Test
|
||||
func dismissal() {
|
||||
let store = TestStore(
|
||||
initialState: Feature.State(
|
||||
counter: CounterFeature.State(count: 3)
|
||||
)
|
||||
) {
|
||||
CounterFeature()
|
||||
}
|
||||
store.exhaustivity = .off
|
||||
|
||||
|
||||
await store.send(\.counter.incrementButtonTapped)
|
||||
await store.send(\.counter.incrementButtonTapped)
|
||||
await store.receive(\.counter.dismiss)
|
||||
}
|
||||
This essentially proves the same thing that the previous test proves, but it does so in much fewer lines and is more resilient to future changes in the features that we don’t necessarily care about.
|
||||
|
||||
That is the basics of testing, but things get a little more complicated when you leverage the concepts outlined in Enum state in which you model multiple destinations as an enum instead of multiple optionals. In order to assert on state changes when using enum state you must chain into the particular case to make a mutation:
|
||||
|
||||
await store.send(\.destination.counter.incrementButtonTapped) {
|
||||
$0.destination?.counter?.count = 4
|
||||
}
|
||||
See Also
|
||||
Tree-based navigation
|
||||
macro Presents()
|
||||
Wraps a property with PresentationState and observes it.
|
||||
enum PresentationAction
|
||||
A wrapper type for actions that can be presented.
|
||||
func ifLet<DestinationState, DestinationAction, Destination>(WritableKeyPath<Self.State, PresentationState<DestinationState>>, action: CaseKeyPath<Self.Action, PresentationAction<DestinationAction>>, destination: () -> Destination, fileID: StaticString, filePath: StaticString, line: UInt, column: UInt) -> some Reducer<Self.State, Self.Action>
|
||||
Embeds a child reducer in a parent domain that works on an optional property of parent state.
|
||||
Current page is Tree-based navigation
|
||||
Select a color scheme preference
|
||||
Light
|
||||
Dark
|
||||
Auto
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
---
|
||||
# Hex Overview
|
||||
|
||||
Hex is a macOS app that allows you to transcribe your voice into text with a global hotkey.
|
||||
Hex will paste the transcription into your current app.
|
||||
|
||||
|
||||
- Swift Composable Architecture
|
||||
- Swift 6
|
||||
- Swift Async/Await
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
github: kitlangton
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- Version [e.g. 22]
|
||||
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# GitHub Actions Workflows for Hex
|
||||
|
||||
This directory contains the CI/CD workflows for the Hex project.
|
||||
|
||||
## Workflows
|
||||
|
||||
### 1. CI (`ci.yml`)
|
||||
- **Trigger**: On every push to main and pull requests
|
||||
- **Purpose**: Continuous integration for code quality
|
||||
- **Jobs**:
|
||||
- Swift linting with SwiftLint
|
||||
- Build and test in both Debug and Release configurations
|
||||
- Caches Swift Package Manager dependencies
|
||||
|
||||
### 2. Build and Release (`build-and-release.yml`)
|
||||
- **Trigger**: On push to main and on version tags (v*)
|
||||
- **Purpose**: Build, test, and create releases
|
||||
- **Jobs**:
|
||||
- Build and test the app
|
||||
- Create release artifacts when a tag is pushed
|
||||
- Generate DMG installer
|
||||
- Create GitHub release with changelog
|
||||
|
||||
### 3. Manual Release (`release.yml`)
|
||||
- **Trigger**: Manual workflow dispatch
|
||||
- **Purpose**: Create signed and notarized releases
|
||||
- **Inputs**:
|
||||
- Version number (e.g., 0.2.4)
|
||||
- Build number (e.g., 37)
|
||||
- **Features**:
|
||||
- Code signing and notarization
|
||||
- DMG creation
|
||||
- Sparkle appcast update support
|
||||
|
||||
## Required Secrets
|
||||
|
||||
For the release workflows to work properly, you need to configure these secrets in your GitHub repository:
|
||||
|
||||
### For Code Signing (release.yml)
|
||||
- `MACOS_CERTIFICATE`: Base64 encoded .p12 certificate
|
||||
- `MACOS_CERTIFICATE_PWD`: Password for the certificate
|
||||
- `KEYCHAIN_PWD`: Password for the temporary keychain
|
||||
- `DEVELOPMENT_TEAM`: Your Apple Developer Team ID (QC99C9JE59)
|
||||
|
||||
### For Notarization (release.yml)
|
||||
- `APPLE_ID`: Your Apple ID email
|
||||
- `APPLE_ID_PASSWORD`: App-specific password for notarization
|
||||
- `TEAM_ID`: Your Apple Team ID
|
||||
|
||||
### For Sparkle Updates (optional)
|
||||
- `AWS_ACCESS_KEY_ID`: For uploading to S3
|
||||
- `AWS_SECRET_ACCESS_KEY`: For uploading to S3
|
||||
- `SPARKLE_PRIVATE_KEY`: For signing Sparkle updates
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a Release
|
||||
|
||||
1. **Using Tags** (Recommended for releases):
|
||||
```bash
|
||||
git tag v0.2.4
|
||||
git push origin v0.2.4
|
||||
```
|
||||
This will trigger the build-and-release workflow.
|
||||
|
||||
2. **Manual Release** (For signed/notarized releases):
|
||||
- Go to Actions → Release → Run workflow
|
||||
- Enter version and build numbers
|
||||
- The workflow will handle signing, notarization, and release creation
|
||||
|
||||
### Setting Up Secrets
|
||||
|
||||
1. Go to Settings → Secrets and variables → Actions
|
||||
2. Add each required secret
|
||||
|
||||
To create the certificate secret:
|
||||
```bash
|
||||
# Export your Developer ID certificate from Keychain Access as .p12
|
||||
# Then convert to base64:
|
||||
base64 -i certificate.p12 | pbcopy
|
||||
```
|
||||
|
||||
### Sparkle Integration
|
||||
|
||||
The workflows include placeholders for Sparkle appcast updates. To enable:
|
||||
|
||||
1. Set up your S3 bucket for hosting updates
|
||||
2. Configure AWS credentials as secrets
|
||||
3. Implement the appcast update logic in the workflow
|
||||
|
||||
## Notes
|
||||
|
||||
- The CI workflow runs on every push and PR for quick feedback
|
||||
- Release builds are only created for version tags or manual triggers
|
||||
- All builds target macOS 15+ and Apple Silicon
|
||||
- SwiftLint is configured but set to continue on error to avoid blocking PRs
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
build/
|
||||
updates/
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
# Xcode
|
||||
*.xcodeproj/xcuserdata/
|
||||
*.xcworkspace/xcuserdata/
|
||||
*.xcuserstate
|
||||
buildServer.json
|
||||
bin
|
||||
scripts
|
||||
tools
|
||||
ExportOptions.plist
|
||||
HexCore/.build
|
||||
HexCore/.swiftpm/xcode/xcuserdata/kit.xcuserdatad/xcschemes
|
||||
FluidAudio/
|
||||
node_modules/
|
||||
.npm-cache/
|
||||
.tmp/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
# SwiftLint configuration for Hex
|
||||
|
||||
disabled_rules:
|
||||
- trailing_whitespace
|
||||
- line_length
|
||||
- force_cast
|
||||
- identifier_name
|
||||
- type_name
|
||||
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
- closure_spacing
|
||||
- collection_alignment
|
||||
- contains_over_first_not_nil
|
||||
- empty_string
|
||||
- first_where
|
||||
- force_unwrapping
|
||||
- implicitly_unwrapped_optional
|
||||
- last_where
|
||||
- multiline_function_chains
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- prefer_self_type_over_type_of_self
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- trailing_closure
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- vertical_parameter_alignment_on_call
|
||||
- yoda_condition
|
||||
|
||||
excluded:
|
||||
- build
|
||||
- .build
|
||||
- SourcePackages
|
||||
- DerivedData
|
||||
- .swiftpm
|
||||
- Hex.xcodeproj
|
||||
- HexTests
|
||||
|
||||
line_length:
|
||||
warning: 150
|
||||
error: 200
|
||||
ignores_function_declarations: true
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
|
||||
function_body_length:
|
||||
warning: 60
|
||||
error: 100
|
||||
|
||||
file_length:
|
||||
warning: 500
|
||||
error: 1000
|
||||
|
||||
type_body_length:
|
||||
warning: 300
|
||||
error: 500
|
||||
|
||||
large_tuple:
|
||||
warning: 3
|
||||
error: 4
|
||||
|
||||
function_parameter_count:
|
||||
warning: 6
|
||||
error: 8
|
||||
|
||||
cyclomatic_complexity:
|
||||
warning: 15
|
||||
error: 20
|
||||
|
||||
nesting:
|
||||
type_level:
|
||||
warning: 2
|
||||
function_level:
|
||||
warning: 3
|
||||
|
||||
custom_rules:
|
||||
tca_reducer_protocol:
|
||||
name: "TCA Reducer Protocol"
|
||||
regex: "struct\\s+\\w+:\\s*Reducer"
|
||||
message: "Consider using ReducerProtocol for TCA reducers"
|
||||
severity: warning
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
CLAUDE.md
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8739929: Overhaul audio engine lifecycle: recover recordings across device/route changes instead of silently capturing nothing (#251, #252, #218, #226), rebuild the warm capture engine immediately when devices change while idle instead of leaving a stale engine running (#209), suspend the warm microphone while the screen is locked or asleep and rearm on wake, stop the sound-effects engine when idle (#209), and fix a crash on quit caused by racy audio teardown (#245)
|
||||
- 2c5d8ae: Add Whisper Large v3 Turbo (632MB) to the model library: near large-v3 accuracy at several times the speed (#185). Whisper models are now labeled by their real sizes (Tiny, Base) instead of Small/Medium.
|
||||
- 9c597cb: Prevent Hex from crashing on first launch before hotkey permissions are granted (#254)
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 224822d: Fix hotkeys dying after sleep or when macOS permission checks go stale: key events arriving at the tap now self-heal the Input Monitoring state instead of being dropped, the tap survives stale permission denials, and it is recreated on wake from sleep (#250)
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- eae358b: Never silently clear the selected model when a scan misfires, and route hotkey presses to model setup instead of transcribing with no model
|
||||
- eae358b: Redesign the model library: visible Download buttons, one-click model switching, and a menu to remove downloaded models or show them in Finder
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd971a3: Reuse existing Parakeet downloads after upgrading FluidAudio
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 276da69: Add lowercase and punctuation-removal paste transforms (#253)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 10e75b9: Simplify output transforms with clearer word rules, preview, and formatting controls.
|
||||
- 8e6e041: Improve update and changelog controls in Settings.
|
||||
- 71878b7: Defer Fast Mode capture rebuilds until audio route changes settle
|
||||
- db31b0a: Move transcription model selection into a focused model library with automatic downloads on selection.
|
||||
- c00a91d: Refresh microphones reliably, harden recording cleanup, and polish the settings overlay behavior.
|
||||
- db31b0a: Handle interrupted recording stops explicitly and ignore stale Fast Mode callbacks.
|
||||
- 2b5fb15: Recognize downloaded Parakeet models immediately after restarting Hex.
|
||||
- 8e6e041: Preserve literal dollar signs in word-remapping replacements.
|
||||
|
||||
## 0.7.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f4cecbb: Show in Finder reveals Parakeet caches at the correct path (#205)
|
||||
- f4cecbb: Stay in the menu bar when launched at login (#222)
|
||||
|
||||
## 0.7.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b5a19b4: Fix word remapping deletion crashes (#207)
|
||||
- b5a19b4: Enable Super Fast Mode by default for new users
|
||||
- b5a19b4: Restore macOS Sonoma compatibility (#215)
|
||||
|
||||
## 0.7.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5a4af9b: Fix silent recordings from multichannel input devices during calls (#204)
|
||||
- b78f049: Stop priming the sound-effects audio engine when sound effects are disabled so Hex avoids unnecessary background audio activity and sleep assertions (#200).
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7340d1e: Restore double-tap lock audio capture (#193)
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 55249a6: Keep the ends of recordings from getting clipped in super fast mode.
|
||||
- d9e40cc: Use the capture engine for normal recordings to reduce startup drift
|
||||
- d9e40cc: Keep the microphone picker visible and refresh it when audio devices change
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ed69836: Suppress startup windows when Hex launches as a hidden login item (#146)
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c5d5162: Add Super Fast mode to keep the mic warm and prepend a short in-memory buffer
|
||||
|
||||
## 0.6.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c018c40: Add setting to disable double-tap lock for hands-free recording
|
||||
- 7af7cd9: Update dependencies: TCA 1.23, Sparkle 2.8, swift-dependencies 1.11
|
||||
|
||||
## 0.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 74893ab: Support escape sequences (\n, \t, \\) in word remappings for newlines, tabs, and literal backslashes (#140)
|
||||
|
||||
## 0.6.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e2000d8: Fix Icon Composer app icon not displaying (#148)
|
||||
- 75bc323: Update macOS Tahoe app icon (#145)
|
||||
|
||||
## 0.6.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc99650: Prepare release metadata for 0.6.6
|
||||
|
||||
## 0.6.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3b6c966: Improve transcript modifications layout and remove log export settings
|
||||
- 3b6c966: Add opt-in regex word removals for transcripts (#121)
|
||||
|
||||
## 0.6.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 140c205: Fix Sparkle auto-update for sandboxed app by adding required XPC entitlements and SUEnableInstallerLauncherService. Users on 0.6.3 will need to manually download this update.
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c00f79e: Reduce code duplication: add ModelPatternMatcher, FileManager helpers, settingsCaption style, notification constants, and Core Audio helper
|
||||
- 658a755: Fix silent recordings caused by device-level microphone mute - automatically detects and fixes muted input devices before recording
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b4c54ce: Fix microphone priming and media pause races
|
||||
- 5217d3f: Add word remappings and remove LLM UI (#000)
|
||||
- 4d38708: Add persistent MCP config editing for Claude Code modes
|
||||
- bbd0b80: Show system default mic name in picker
|
||||
- bbd0b80: Fix Parakeet polling cleanup and organize paste flow
|
||||
- 3413d68: Rename Transformations tab to Modes
|
||||
- 4d38708: Fix microphone freezing and speech cutoff when using custom microphone. Only switch input device when actually needed, re-prime recorder after device changes, and add cleanup on app termination.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7e325ad: Fix Sequoia hotkey deadlock by removing Input Monitoring guard that prevented CGEventTap creation. Tap creation triggers permission prompt naturally. Re-add 'force quit Hex now' voice escape hatch from v0.5.8 (#122 #124)
|
||||
- 7e325ad: Add missing-model callout and focus settings when transcription starts without a model
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3bf2fb0: Fix voice prefix matching with punctuation - now strips punctuation (.,;:!?) when matching prefixes
|
||||
|
||||
## 0.5.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 083513c: Add comprehensive documentation to HotKeyProcessor and extract magic numbers into named constants (HexCoreConstants)
|
||||
|
||||
## 0.5.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 471310c: Fix Input Monitoring permission enforcement for hotkey reliability
|
||||
|
||||
## 0.5.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1deda2a: Route Advanced → Export Logs through the new swift-log diagnostics file so Sequoia permission bugs (#122 #124) can be diagnosed locally without relying on macOS unified logs.
|
||||
|
||||
## 0.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3560bdb: Keep hotkeys alive on Sequoia and add voice force-quit plus Advanced log export (#122 #124)
|
||||
|
||||
## 0.5.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6c2f1bd: Add comprehensive permissions logging for improved debugging and log export support
|
||||
|
||||
## 0.5.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 03b81c7: Let the hotkey tap start even when Input Monitoring is missing so Sequoia users get prompts again, while keeping the accessibility watchdog (#122 #124). Add a spoken “force quit Hex now” escape hatch in case permissions clobber input.
|
||||
|
||||
## 0.5.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 539b0a4: Pad sub-1.5s Parakeet recordings so FluidAudio accepts them
|
||||
|
||||
## 0.5.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a1eb1d0: Restore hotkeys when Input Monitoring permission is missing (#122, #124)
|
||||
- 1ee452a: Add non-interactive changeset creation for AI agents
|
||||
- 68475f5: Fix clipboard restore timing for slow apps – increased delay from 100ms to 500ms to prevent paste failures in apps that read clipboard asynchronously
|
||||
|
||||
## 0.5.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0045f28: Fix recording chime latency by switching to AVAudioEngine with pre-loaded buffers
|
||||
- 7f6c5db: Actually request macOS Input Monitoring permission when installing the key event tap so Sequoia users can record hotkeys again (#122, #124).
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix hotkey monitoring on macOS Sequoia 15.7.1 by properly handling Input Monitoring permissions (#122, #124)
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle update delivery by regenerating appcast with correct bundle versions and updating release tooling to prevent duplicate CFBundleVersion issues
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle update delivery by regenerating appcast with correct bundle versions and updating release tooling to prevent duplicate CFBundleVersion issues
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle appcast generation by cleaning duplicate bundle versions and updating release pipeline to preserve last 3 DMGs for delta generation
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 049592c: Add support for multiple Parakeet model variants: choose between English-only (v2) or multilingual (v3) based on your transcription needs.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- aca9ad5: Fix microphone access retained when recording canceled with ESC (#117)
|
||||
- 049592c: Polish paste-last-transcript hotkey UI with improved layout and clearer instructions.
|
||||
- 049592c: Improve hotkey reliability with accessibility trust monitoring and automatic recovery from tap disabled events (#89, #81, #87).
|
||||
- 049592c: Improve media pausing reliability by using MediaRemote API instead of simulated keyboard events.
|
||||
- 049592c: Fix menu bar rendering issue where items appeared as single embedded view instead of separate clickable menu items.
|
||||
- 1b9bd52: Optimize recorder startup by keeping AVAudioRecorder primed between sessions, eliminating ~500ms latency for successive recordings
|
||||
- 55fb4f8: Add a sound effects volume slider beneath the toggle so users can fine-tune feedback relative to the existing 20% baseline, keeping 100% at the legacy loudness (#000).
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e50478d: Add Parakeet TDT v3 plus the first-run model bootstrap, faster recording pipeline, and solid Fn/modifier hotkeys so the next release captures all of the recent feature work (#71, #97, #113, #89, #81, #87).
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ea42b5b: Move `HexSettings` + `RecordingAudioBehavior` into HexCore and add fixtures/tests so we can migrate historic settings blobs safely before shipping new media-ducking options.
|
||||
- e50478d: Adopt Changesets for SemVer + changelog management, wire release.ts to fail without pending fragments, and sync the aggregated release notes into the bundled changelog + GitHub releases.
|
||||
- 2fbbe7a: Wait for NSPasteboard changeCount to advance before pasting so panel apps always receive the latest transcript (#69, #42).
|
||||
|
||||
All notable changes to Hex are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added NVIDIA Parakeet TDT v3 support with a redesigned model manager so you can swap between Parakeet and curated Whisper variants without juggling files (#71).
|
||||
- Added first-run model bootstrap: Hex now automatically downloads the recommended model, shows progress/cancel controls, and prevents transcription from starting until a model is ready (#97).
|
||||
- Added a global hotkey to paste the last transcript plus contextual actions to cancel or delete model downloads directly from Settings, making recovery workflows faster.
|
||||
|
||||
### Improved
|
||||
|
||||
- Model downloads now surface the failing host/domain in their error message so DNS or network issues are easier to debug (#112).
|
||||
- Recording starts ~200–700 ms faster: start sounds play immediately, media pausing runs off the main actor, and transcription errors skip the extra cancel chime for less audio clutter (#113).
|
||||
- The transcription overlay tracks the active window so UI hints stay anchored to whichever app currently has focus.
|
||||
- HexSettings now lives inside HexCore with fixture-based migration tests, giving us a single source of truth for future settings changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Printable-key hotkeys (for example `⌘+'`) can now trigger short recordings just like modifier-only chords, so quick phrases aren’t discarded anymore (#113).
|
||||
- Fn and other modifier-only hotkeys respect left/right side selection, ignore phantom arrow events, and stop firing when combined with other keys, resolving long-standing regressions (#89, #81, #87).
|
||||
- Paste reliability: Hex now waits for the clipboard write to commit before firing ⌘V, so panel apps like Alfred, Raycast, and IntelliBar always receive the latest transcript instead of the previous clipboard contents (#69, #42).
|
||||
|
||||
## 1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bump version for stable release
|
||||
|
||||
## 0.1.33
|
||||
|
||||
### Added
|
||||
|
||||
- Add copy to clipboard option
|
||||
- Add support for complete keyboard shortcuts
|
||||
- Add indication for model prewarming
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix issue with Hex showing in Mission Control and Cmd+Tab
|
||||
- Improve paste behavior when text input fails
|
||||
- Rework audio pausing logic to make it more reliable
|
||||
|
||||
## 0.1.26
|
||||
|
||||
### Added
|
||||
|
||||
- Add changelog
|
||||
- Add option to set minimum record time
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
# Hex – Dev Notes for Agents
|
||||
|
||||
This file provides guidance for coding agents working in this repo.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Hex is a macOS menu bar application for on‑device voice‑to‑text. It supports Whisper (Core ML via WhisperKit) and Parakeet TDT v3 (Core ML via FluidAudio). Users activate transcription with hotkeys; text can be auto‑pasted into the active app.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
# Build the app
|
||||
xcodebuild -scheme Hex -configuration Release
|
||||
|
||||
# Run tests (must be run from HexCore directory for unit tests)
|
||||
cd HexCore && swift test
|
||||
|
||||
# Or run all tests via Xcode
|
||||
xcodebuild test -scheme Hex
|
||||
|
||||
# Open in Xcode (recommended for development)
|
||||
open Hex.xcodeproj
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The app uses **The Composable Architecture (TCA)** for state management. Key architectural components:
|
||||
|
||||
### Features (TCA Reducers)
|
||||
- `AppFeature`: Root feature coordinating the app lifecycle
|
||||
- `TranscriptionFeature`: Core recording and transcription logic
|
||||
- `SettingsFeature`: User preferences and configuration
|
||||
- `HistoryFeature`: Transcription history management
|
||||
|
||||
### Dependency Clients
|
||||
- `TranscriptionClient`: WhisperKit integration for ML transcription
|
||||
- `RecordingClient`: AVAudioRecorder wrapper for audio capture
|
||||
- `PasteboardClient`: Clipboard operations
|
||||
- `KeyEventMonitorClient`: Global hotkey monitoring via Sauce framework
|
||||
|
||||
### Key Dependencies
|
||||
- **WhisperKit**: Core ML transcription (tracking main branch)
|
||||
- **FluidAudio (Parakeet)**: Core ML ASR (multilingual) default model
|
||||
- **Sauce**: Keyboard event monitoring
|
||||
- **Sparkle**: Auto-updates (feed: https://hex-updates.s3.amazonaws.com/appcast.xml)
|
||||
- **Swift Composable Architecture**: State management
|
||||
- **Inject** Hot Reloading for SwiftUI
|
||||
|
||||
## Important Implementation Details
|
||||
|
||||
1. **Hotkey Recording Modes**: The app supports both press-and-hold and double-tap recording modes, implemented in `HotKeyProcessor.swift`. See `docs/hotkey-semantics.md` for detailed behavior specifications including:
|
||||
- **Modifier-only hotkeys** (e.g., Option) use a **0.3s threshold** to prevent accidental triggers from OS shortcuts
|
||||
- **Regular hotkeys** (e.g., Cmd+A) use user's `minimumKeyTime` setting (default 0.2s)
|
||||
- Mouse clicks and extra modifiers are discarded within threshold, ignored after
|
||||
- Only ESC cancels recordings after the threshold
|
||||
|
||||
2. **Model Management**: Models are managed by `ModelDownloadFeature`. Curated defaults live in `Hex/Resources/Data/models.json`. The Settings UI shows a compact opinionated list (Parakeet + three Whisper sizes). No dropdowns.
|
||||
|
||||
3. **Sound Effects**: Audio feedback is provided via `SoundEffect.swift` using files in `Resources/Audio/`
|
||||
|
||||
4. **Window Management**: Uses an `InvisibleWindow` for the transcription indicator overlay
|
||||
|
||||
5. **Permissions**: Requires audio input and automation entitlements (see `Hex.entitlements`)
|
||||
|
||||
6. **Logging**: All diagnostics should use the unified logging helper `HexLog` (`HexCore/Sources/HexCore/Logging.swift`). Pick an existing category (e.g., `.transcription`, `.recording`, `.settings`) or add a new case so Console predicates stay consistent. Avoid `print` and prefer privacy annotations (`, privacy: .private`) for anything potentially sensitive like transcript text or file paths.
|
||||
|
||||
## Models (2025‑11)
|
||||
|
||||
- Default: Parakeet TDT v3 (multilingual) via FluidAudio
|
||||
- Additional curated: Whisper Small (Tiny), Whisper Medium (Base), Whisper Large v3
|
||||
- Note: Distil‑Whisper is English‑only and not shown by default
|
||||
|
||||
### Storage Locations
|
||||
|
||||
- WhisperKit models
|
||||
- `~/Library/Application Support/com.kitlangton.Hex/models/argmaxinc/whisperkit-coreml/<model>`
|
||||
- Parakeet (FluidAudio)
|
||||
- We set `XDG_CACHE_HOME` on launch so Parakeet caches under the app container:
|
||||
- `~/Library/Containers/com.kitlangton.Hex/Data/Library/Application Support/FluidAudio/Models/parakeet-tdt-0.6b-v3-coreml`
|
||||
- Legacy `~/.cache/fluidaudio/Models/…` is not visible to the sandbox; re‑download or import.
|
||||
|
||||
### Progress + Availability
|
||||
|
||||
- WhisperKit: native progress
|
||||
- Parakeet: best‑effort progress by polling the model directory size during download
|
||||
- Availability detection scans both `Application Support/FluidAudio/Models` and our app cache path
|
||||
|
||||
## Building & Running
|
||||
|
||||
- macOS 14+, Xcode 15+
|
||||
|
||||
### Packages
|
||||
|
||||
- WhisperKit: `https://github.com/argmaxinc/WhisperKit`
|
||||
- FluidAudio: `https://github.com/FluidInference/FluidAudio.git` (link `FluidAudio` to Hex target)
|
||||
|
||||
### Entitlements (Sandbox)
|
||||
|
||||
- `com.apple.security.app-sandbox = true`
|
||||
- `com.apple.security.network.client = true` (HF downloads)
|
||||
- `com.apple.security.files.user-selected.read-write = true` (optional import)
|
||||
- `com.apple.security.automation.apple-events = true` (media control)
|
||||
|
||||
### Cache root (Parakeet)
|
||||
|
||||
Set at app launch and logged:
|
||||
|
||||
```
|
||||
XDG_CACHE_HOME = ~/Library/Containers/com.kitlangton.Hex/Data/Library/Application Support/com.kitlangton.Hex/cache
|
||||
```
|
||||
|
||||
FluidAudio models reside under `Application Support/FluidAudio/Models`.
|
||||
|
||||
## UI
|
||||
|
||||
- Settings → Transcription Model shows a compact list with radio selection, accuracy/speed dots, size on right, and trailing menu / download‑check icon.
|
||||
- Context menu offers Show in Finder / Delete.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Repeated mic prompts during debug: ensure Debug signing uses "Apple Development" so TCC sticks
|
||||
- Sandbox network errors (‑1003): add `com.apple.security.network.client = true` (already set)
|
||||
- Parakeet not detected: ensure it resides under the container path above; downloading from Hex places it correctly.
|
||||
|
||||
## Changelog Workflow Expectations
|
||||
|
||||
1. **Always add a changeset:** Any feature, UX change, or bug fix that ships to users must come with a `.changeset/*.md` fragment. The summary should mention the user-facing impact plus the GitHub issue/PR number (for example, "Improve Fn hotkey stability (#89)").
|
||||
2. **Use non-interactive changeset creation:** AI agents should use the non-interactive script:
|
||||
```bash
|
||||
bun run changeset:add-ai patch "Your summary here"
|
||||
bun run changeset:add-ai minor "Add new feature"
|
||||
bun run changeset:add-ai major "Breaking change"
|
||||
```
|
||||
3. **Only create changesets, don't process them:** Agents should only create changeset fragments. The release tool is responsible for running `changeset version` to collect changesets into `CHANGELOG.md` and syncing to `Hex/Resources/changelog.md`.
|
||||
4. **Reference GitHub issues:** When a change addresses a filed issue, link it in code comments and the changeset entry (`(#123)`) so release notes and Sparkle updates point users back to the discussion. If the work should close an issue, include "Fixes #123" (or "Closes #123") in the commit or PR description so GitHub auto-closes it once merged.
|
||||
|
||||
## Git Commit Messages
|
||||
|
||||
- Use a concise, descriptive subject line that captures the user-facing impact (roughly 50–70 characters).
|
||||
- Follow up with as much context as needed in the body. Include the rationale, notable tradeoffs, relevant logs, or reproduction steps—future debugging benefits from having the full story directly in git history.
|
||||
- Reference any related GitHub issues in the body if the change tracks ongoing work.
|
||||
|
||||
## Releasing a New Version
|
||||
|
||||
Releases are automated via a local CLI tool that handles building, signing, notarizing, and uploading.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **AWS credentials** must be set (for S3 uploads):
|
||||
```bash
|
||||
export AWS_ACCESS_KEY_ID=...
|
||||
export AWS_SECRET_ACCESS_KEY=...
|
||||
```
|
||||
|
||||
2. **Notarization credentials** stored in keychain (one-time setup):
|
||||
```bash
|
||||
xcrun notarytool store-credentials "AC_PASSWORD"
|
||||
```
|
||||
|
||||
3. **Dependencies installed** at project root and in tools:
|
||||
```bash
|
||||
bun install # project root (for changesets)
|
||||
cd tools && bun install # tools dependencies
|
||||
```
|
||||
|
||||
### Release Steps
|
||||
|
||||
1. **Ensure all changes are committed** - the release tool requires a clean working tree
|
||||
|
||||
2. **Ensure changesets exist** - any user-facing change should have a `.changeset/*.md` file:
|
||||
```bash
|
||||
bun run changeset:add-ai patch "Fix microphone selection"
|
||||
```
|
||||
|
||||
3. **Run the release command** from project root:
|
||||
```bash
|
||||
bun run tools/src/cli.ts release
|
||||
```
|
||||
|
||||
### What the Release Tool Does
|
||||
|
||||
1. Checks for clean working tree
|
||||
2. Finds pending changesets and applies them (bumps version in `package.json`)
|
||||
3. Syncs changelog to `Hex/Resources/changelog.md`
|
||||
4. Updates `Info.plist` and `project.pbxproj` with new version
|
||||
5. Increments build number
|
||||
6. Cleans DerivedData and archives with xcodebuild
|
||||
7. Exports and signs with Developer ID
|
||||
8. Notarizes app with Apple
|
||||
9. Creates and signs DMG
|
||||
10. Notarizes DMG
|
||||
11. Generates Sparkle appcast
|
||||
12. Uploads to S3 (versioned DMG + `hex-latest.dmg` + appcast.xml)
|
||||
13. Commits version changes, creates git tag, pushes
|
||||
14. Creates GitHub release with DMG and ZIP attachments
|
||||
|
||||
### If No Changesets Exist
|
||||
|
||||
The tool will prompt you to either:
|
||||
- Stop and create a changeset (recommended)
|
||||
- Continue with manual version bump (useful for re-running failed releases)
|
||||
|
||||
### Artifacts
|
||||
|
||||
Each release produces:
|
||||
- `Hex-{version}.dmg` - Signed, notarized DMG
|
||||
- `Hex-{version}.zip` - For Homebrew cask
|
||||
- `hex-latest.dmg` - Always points to latest
|
||||
- `appcast.xml` - Sparkle update feed
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **"Working tree is not clean"**: Commit or stash all changes before releasing
|
||||
- **Notarization fails**: Check Apple ID credentials and app-specific password
|
||||
- **S3 upload fails**: Verify AWS credentials and bucket permissions
|
||||
- **Build fails**: Ensure Xcode 16+ and valid code signing certificates
|
||||
Executable
+787
@@ -0,0 +1,787 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
4F0D6A012F1A000100000001 /* HexCore in Frameworks */ = {isa = PBXBuildFile; productRef = 476316262E5FB31400913CDE /* HexCore */; };
|
||||
4F0D6A022F1A000100000001 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 47E05E012D444EE900D26DA6 /* ComposableArchitecture */; };
|
||||
4F0D6A042F1A000100000001 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = 4F0D6A072F1A000100000001 /* Sharing */; };
|
||||
4F0D6A052F1A000100000001 /* Sharing in Frameworks */ = {isa = PBXBuildFile; productRef = 4F0D6A072F1A000100000001 /* Sharing */; };
|
||||
4F0D6A082F1A000100000001 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = 4F0D6A0C2F1A000100000001 /* CasePaths */; };
|
||||
4F0D6A092F1A000100000001 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 4F0D6A0D2F1A000100000001 /* IdentifiedCollections */; };
|
||||
4F0D6A0E2F1A000100000001 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = 4F0D6A0C2F1A000100000001 /* CasePaths */; };
|
||||
4F0D6A0F2F1A000100000001 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 47E05E092D44525B00D26DA6 /* Dependencies */; };
|
||||
4F0D6A102F1A000100000001 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 4F0D6A0D2F1A000100000001 /* IdentifiedCollections */; };
|
||||
47512ABF2E14D8C9000E25BA /* WhisperKit in Frameworks */ = {isa = PBXBuildFile; productRef = 47512ABE2E14D8C9000E25BA /* WhisperKit */; };
|
||||
476316272E5FB31400913CDE /* HexCore in Frameworks */ = {isa = PBXBuildFile; productRef = 476316262E5FB31400913CDE /* HexCore */; };
|
||||
4765045E2D45900200C7EA60 /* Pow in Frameworks */ = {isa = PBXBuildFile; productRef = 4765045D2D45900200C7EA60 /* Pow */; };
|
||||
476BAD3E2D47E7880088C61F /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 476BAD3D2D47E7880088C61F /* Sparkle */; };
|
||||
47C08AB62DE9F61B00564AE6 /* Inject in Frameworks */ = {isa = PBXBuildFile; productRef = 47C08AB52DE9F61B00564AE6 /* Inject */; };
|
||||
47E05E022D444EE900D26DA6 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 47E05E012D444EE900D26DA6 /* ComposableArchitecture */; };
|
||||
47E05E052D444EF800D26DA6 /* Sauce in Frameworks */ = {isa = PBXBuildFile; productRef = 47E05E042D444EF800D26DA6 /* Sauce */; };
|
||||
47E05E0A2D44525B00D26DA6 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 47E05E092D44525B00D26DA6 /* Dependencies */; };
|
||||
47E05E0C2D44525B00D26DA6 /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = 47E05E0B2D44525B00D26DA6 /* DependenciesMacros */; };
|
||||
47E16A622EC6C9D300885CF7 /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = 47E16A612EC6C9D300885CF7 /* FluidAudio */; };
|
||||
B5045C972D78DED500D0A119 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = B5045C962D78DED500D0A119 /* MarkdownUI */; };
|
||||
B53356002D7B8D4900E5F542 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B53355FF2D7B8D4900E5F542 /* Localizable.xcstrings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
478637A92D48725900319BFA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 47E05DE62D444EC600D26DA6 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 47E05DED2D444EC600D26DA6;
|
||||
remoteInfo = Hex;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
473544502D445936001FBCB5 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
|
||||
473544542D445959001FBCB5 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; };
|
||||
478637A52D48725900319BFA /* HexTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HexTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
47E05DEE2D444EC600D26DA6 /* Hex Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Hex Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B53355FF2D7B8D4900E5F542 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
B5A1C7E22E6A1C5B00AB1234 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.icon; name = AppIcon.icon; path = Hex/AppIcon.icon; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
476BAD402D47E8500088C61F /* Exceptions for "Hex" folder in "Hex" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 47E05DED2D444EC600D26DA6 /* Hex */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
4F0D6A032F1A000100000001 /* HexTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = HexTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
47E05DF02D444EC600D26DA6 /* Hex */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
476BAD402D47E8500088C61F /* Exceptions for "Hex" folder in "Hex" target */,
|
||||
);
|
||||
path = Hex;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
478637A22D48725900319BFA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4F0D6A022F1A000100000001 /* ComposableArchitecture in Frameworks */,
|
||||
4F0D6A012F1A000100000001 /* HexCore in Frameworks */,
|
||||
4F0D6A052F1A000100000001 /* Sharing in Frameworks */,
|
||||
4F0D6A0E2F1A000100000001 /* CasePaths in Frameworks */,
|
||||
4F0D6A0F2F1A000100000001 /* Dependencies in Frameworks */,
|
||||
4F0D6A102F1A000100000001 /* IdentifiedCollections in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
47E05DEB2D444EC600D26DA6 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
476BAD3E2D47E7880088C61F /* Sparkle in Frameworks */,
|
||||
476316272E5FB31400913CDE /* HexCore in Frameworks */,
|
||||
47E05E0A2D44525B00D26DA6 /* Dependencies in Frameworks */,
|
||||
B5045C972D78DED500D0A119 /* MarkdownUI in Frameworks */,
|
||||
47512ABF2E14D8C9000E25BA /* WhisperKit in Frameworks */,
|
||||
47E05E052D444EF800D26DA6 /* Sauce in Frameworks */,
|
||||
47E16A622EC6C9D300885CF7 /* FluidAudio in Frameworks */,
|
||||
47C08AB62DE9F61B00564AE6 /* Inject in Frameworks */,
|
||||
4765045E2D45900200C7EA60 /* Pow in Frameworks */,
|
||||
47E05E0C2D44525B00D26DA6 /* DependenciesMacros in Frameworks */,
|
||||
47E05E022D444EE900D26DA6 /* ComposableArchitecture in Frameworks */,
|
||||
4F0D6A042F1A000100000001 /* Sharing in Frameworks */,
|
||||
4F0D6A082F1A000100000001 /* CasePaths in Frameworks */,
|
||||
4F0D6A092F1A000100000001 /* IdentifiedCollections in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
4735444F2D445936001FBCB5 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
473544542D445959001FBCB5 /* Testing.framework */,
|
||||
473544502D445936001FBCB5 /* XCTest.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
47E05DE52D444EC600D26DA6 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B53355FF2D7B8D4900E5F542 /* Localizable.xcstrings */,
|
||||
B5A1C7E22E6A1C5B00AB1234 /* AppIcon.icon */,
|
||||
47E05DF02D444EC600D26DA6 /* Hex */,
|
||||
4F0D6A032F1A000100000001 /* HexTests */,
|
||||
4735444F2D445936001FBCB5 /* Frameworks */,
|
||||
47E05DEF2D444EC600D26DA6 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
47E05DEF2D444EC600D26DA6 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
47E05DEE2D444EC600D26DA6 /* Hex Debug.app */,
|
||||
478637A52D48725900319BFA /* HexTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
478637A42D48725900319BFA /* HexTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 478637AB2D48725900319BFA /* Build configuration list for PBXNativeTarget "HexTests" */;
|
||||
buildPhases = (
|
||||
478637A12D48725900319BFA /* Sources */,
|
||||
478637A22D48725900319BFA /* Frameworks */,
|
||||
478637A32D48725900319BFA /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
478637AA2D48725900319BFA /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
4F0D6A032F1A000100000001 /* HexTests */,
|
||||
);
|
||||
name = HexTests;
|
||||
packageProductDependencies = (
|
||||
47E05E012D444EE900D26DA6 /* ComposableArchitecture */,
|
||||
476316262E5FB31400913CDE /* HexCore */,
|
||||
4F0D6A072F1A000100000001 /* Sharing */,
|
||||
4F0D6A0C2F1A000100000001 /* CasePaths */,
|
||||
47E05E092D44525B00D26DA6 /* Dependencies */,
|
||||
4F0D6A0D2F1A000100000001 /* IdentifiedCollections */,
|
||||
);
|
||||
productName = HexTests;
|
||||
productReference = 478637A52D48725900319BFA /* HexTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
47E05DED2D444EC600D26DA6 /* Hex */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 47E05DFD2D444EC700D26DA6 /* Build configuration list for PBXNativeTarget "Hex" */;
|
||||
buildPhases = (
|
||||
47E05DEA2D444EC600D26DA6 /* Sources */,
|
||||
47E05DEB2D444EC600D26DA6 /* Frameworks */,
|
||||
47E05DEC2D444EC600D26DA6 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
47E05DF02D444EC600D26DA6 /* Hex */,
|
||||
);
|
||||
name = Hex;
|
||||
packageProductDependencies = (
|
||||
47E05E012D444EE900D26DA6 /* ComposableArchitecture */,
|
||||
47E05E042D444EF800D26DA6 /* Sauce */,
|
||||
47E05E092D44525B00D26DA6 /* Dependencies */,
|
||||
47E05E0B2D44525B00D26DA6 /* DependenciesMacros */,
|
||||
4765045D2D45900200C7EA60 /* Pow */,
|
||||
476BAD3D2D47E7880088C61F /* Sparkle */,
|
||||
B5045C962D78DED500D0A119 /* MarkdownUI */,
|
||||
47C08AB52DE9F61B00564AE6 /* Inject */,
|
||||
47512ABE2E14D8C9000E25BA /* WhisperKit */,
|
||||
476316262E5FB31400913CDE /* HexCore */,
|
||||
47E16A612EC6C9D300885CF7 /* FluidAudio */,
|
||||
4F0D6A072F1A000100000001 /* Sharing */,
|
||||
4F0D6A0C2F1A000100000001 /* CasePaths */,
|
||||
4F0D6A0D2F1A000100000001 /* IdentifiedCollections */,
|
||||
);
|
||||
productName = Hex;
|
||||
productReference = 47E05DEE2D444EC600D26DA6 /* Hex Debug.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
47E05DE62D444EC600D26DA6 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
478637A42D48725900319BFA = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
TestTargetID = 47E05DED2D444EC600D26DA6;
|
||||
};
|
||||
47E05DED2D444EC600D26DA6 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 47E05DE92D444EC600D26DA6 /* Build configuration list for PBXProject "Hex" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 47E05DE52D444EC600D26DA6;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
47E05E002D444EE900D26DA6 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */,
|
||||
47E05E032D444EF800D26DA6 /* XCRemoteSwiftPackageReference "Sauce" */,
|
||||
47E05E082D44525B00D26DA6 /* XCRemoteSwiftPackageReference "swift-dependencies" */,
|
||||
4765045C2D45900200C7EA60 /* XCRemoteSwiftPackageReference "Pow" */,
|
||||
476BAD3C2D47E7880088C61F /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
B5045C952D78DED500D0A119 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||
47C08AB42DE9F61B00564AE6 /* XCRemoteSwiftPackageReference "Inject" */,
|
||||
47512ABD2E14D8C9000E25BA /* XCRemoteSwiftPackageReference "WhisperKit" */,
|
||||
476316252E5FB31400913CDE /* XCLocalSwiftPackageReference "HexCore" */,
|
||||
47E16A602EC6C9D300885CF7 /* XCRemoteSwiftPackageReference "FluidAudio" */,
|
||||
4F0D6A062F1A000100000001 /* XCRemoteSwiftPackageReference "swift-sharing" */,
|
||||
4F0D6A0A2F1A000100000001 /* XCRemoteSwiftPackageReference "swift-case-paths" */,
|
||||
4F0D6A0B2F1A000100000001 /* XCRemoteSwiftPackageReference "swift-identified-collections" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 47E05DEF2D444EC600D26DA6 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
47E05DED2D444EC600D26DA6 /* Hex */,
|
||||
478637A42D48725900319BFA /* HexTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
478637A32D48725900319BFA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
47E05DEC2D444EC600D26DA6 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B53356002D7B8D4900E5F542 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
478637A12D48725900319BFA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
47E05DEA2D444EC600D26DA6 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
478637AA2D48725900319BFA /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 47E05DED2D444EC600D26DA6 /* Hex */;
|
||||
targetProxy = 478637A92D48725900319BFA /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
478637AC2D48725900319BFA /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 91;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 0.8.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kitlangton.HexTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hex Debug.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hex Debug";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
478637AD2D48725900319BFA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 91;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 0.8.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kitlangton.HexTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hex.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hex";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
47E05DFB2D444EC700D26DA6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = QC99C9JE59;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
47E05DFC2D444EC700D26DA6 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = QC99C9JE59;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
47E05DFE2D444EC700D26DA6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
AUTOMATION_APPLE_EVENTS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Hex/Hex.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 91;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Hex/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = QC99C9JE59;
|
||||
EMIT_FRONTEND_COMMAND_LINES = YES;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Hex/Info.plist;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Hex needs to control keyboard events to simulate paste operations.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Hex needs microphone access in order to transcribe your voice.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.8.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"-Xlinker",
|
||||
"-interposable",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kitlangton.Hex.debug;
|
||||
PRODUCT_MODULE_NAME = Hex;
|
||||
PRODUCT_NAME = "Hex Debug";
|
||||
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
|
||||
RUNTIME_EXCEPTION_ALLOW_JIT = NO;
|
||||
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;
|
||||
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
|
||||
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
|
||||
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
47E05DFF2D444EC700D26DA6 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
AUTOMATION_APPLE_EVENTS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Hex/Hex.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 91;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Hex/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = QC99C9JE59;
|
||||
EMIT_FRONTEND_COMMAND_LINES = NO;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Hex/Info.plist;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Hex needs to control keyboard events to simulate paste operations.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Hex needs microphone access in order to transcribe your voice.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.8.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kitlangton.Hex;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
|
||||
RUNTIME_EXCEPTION_ALLOW_JIT = NO;
|
||||
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;
|
||||
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
|
||||
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
|
||||
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
478637AB2D48725900319BFA /* Build configuration list for PBXNativeTarget "HexTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
478637AC2D48725900319BFA /* Debug */,
|
||||
478637AD2D48725900319BFA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
47E05DE92D444EC600D26DA6 /* Build configuration list for PBXProject "Hex" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
47E05DFB2D444EC700D26DA6 /* Debug */,
|
||||
47E05DFC2D444EC700D26DA6 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
47E05DFD2D444EC700D26DA6 /* Build configuration list for PBXNativeTarget "Hex" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
47E05DFE2D444EC700D26DA6 /* Debug */,
|
||||
47E05DFF2D444EC700D26DA6 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
476316252E5FB31400913CDE /* XCLocalSwiftPackageReference "HexCore" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = HexCore;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
47512ABD2E14D8C9000E25BA /* XCRemoteSwiftPackageReference "WhisperKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/argmaxinc/WhisperKit";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.15.0;
|
||||
};
|
||||
};
|
||||
4765045C2D45900200C7EA60 /* XCRemoteSwiftPackageReference "Pow" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/EmergeTools/Pow";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = 1b4b1dda28c50b95f0872927ee2226fe8b58950e;
|
||||
};
|
||||
};
|
||||
476BAD3C2D47E7880088C61F /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.9.4;
|
||||
};
|
||||
};
|
||||
47C08AB42DE9F61B00564AE6 /* XCRemoteSwiftPackageReference "Inject" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/krzysztofzablocki/Inject";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.6.0;
|
||||
};
|
||||
};
|
||||
47E05E002D444EE900D26DA6 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.26.0;
|
||||
};
|
||||
};
|
||||
47E05E032D444EF800D26DA6 /* XCRemoteSwiftPackageReference "Sauce" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Clipy/Sauce";
|
||||
requirement = {
|
||||
branch = master;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
47E05E082D44525B00D26DA6 /* XCRemoteSwiftPackageReference "swift-dependencies" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/pointfreeco/swift-dependencies";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.14.1;
|
||||
};
|
||||
};
|
||||
47E16A602EC6C9D300885CF7 /* XCRemoteSwiftPackageReference "FluidAudio" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/FluidInference/FluidAudio";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.15.5;
|
||||
};
|
||||
};
|
||||
4F0D6A062F1A000100000001 /* XCRemoteSwiftPackageReference "swift-sharing" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/pointfreeco/swift-sharing";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.9.1;
|
||||
};
|
||||
};
|
||||
4F0D6A0A2F1A000100000001 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/pointfreeco/swift-case-paths";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.8.0;
|
||||
};
|
||||
};
|
||||
4F0D6A0B2F1A000100000001 /* XCRemoteSwiftPackageReference "swift-identified-collections" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/pointfreeco/swift-identified-collections";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.1.1;
|
||||
};
|
||||
};
|
||||
B5045C952D78DED500D0A119 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.4.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
47512ABE2E14D8C9000E25BA /* WhisperKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 47512ABD2E14D8C9000E25BA /* XCRemoteSwiftPackageReference "WhisperKit" */;
|
||||
productName = WhisperKit;
|
||||
};
|
||||
476316262E5FB31400913CDE /* HexCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = HexCore;
|
||||
};
|
||||
4765045D2D45900200C7EA60 /* Pow */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4765045C2D45900200C7EA60 /* XCRemoteSwiftPackageReference "Pow" */;
|
||||
productName = Pow;
|
||||
};
|
||||
476BAD3D2D47E7880088C61F /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 476BAD3C2D47E7880088C61F /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
47C08AB52DE9F61B00564AE6 /* Inject */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 47C08AB42DE9F61B00564AE6 /* XCRemoteSwiftPackageReference "Inject" */;
|
||||
productName = Inject;
|
||||
};
|
||||
47E05E012D444EE900D26DA6 /* ComposableArchitecture */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 47E05E002D444EE900D26DA6 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */;
|
||||
productName = ComposableArchitecture;
|
||||
};
|
||||
47E05E042D444EF800D26DA6 /* Sauce */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 47E05E032D444EF800D26DA6 /* XCRemoteSwiftPackageReference "Sauce" */;
|
||||
productName = Sauce;
|
||||
};
|
||||
47E05E092D44525B00D26DA6 /* Dependencies */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 47E05E082D44525B00D26DA6 /* XCRemoteSwiftPackageReference "swift-dependencies" */;
|
||||
productName = Dependencies;
|
||||
};
|
||||
47E05E0B2D44525B00D26DA6 /* DependenciesMacros */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 47E05E082D44525B00D26DA6 /* XCRemoteSwiftPackageReference "swift-dependencies" */;
|
||||
productName = DependenciesMacros;
|
||||
};
|
||||
47E16A612EC6C9D300885CF7 /* FluidAudio */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 47E16A602EC6C9D300885CF7 /* XCRemoteSwiftPackageReference "FluidAudio" */;
|
||||
productName = FluidAudio;
|
||||
};
|
||||
4F0D6A072F1A000100000001 /* Sharing */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4F0D6A062F1A000100000001 /* XCRemoteSwiftPackageReference "swift-sharing" */;
|
||||
productName = Sharing;
|
||||
};
|
||||
4F0D6A0C2F1A000100000001 /* CasePaths */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4F0D6A0A2F1A000100000001 /* XCRemoteSwiftPackageReference "swift-case-paths" */;
|
||||
productName = CasePaths;
|
||||
};
|
||||
4F0D6A0D2F1A000100000001 /* IdentifiedCollections */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4F0D6A0B2F1A000100000001 /* XCRemoteSwiftPackageReference "swift-identified-collections" */;
|
||||
productName = IdentifiedCollections;
|
||||
};
|
||||
B5045C962D78DED500D0A119 /* MarkdownUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B5045C952D78DED500D0A119 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||
productName = MarkdownUI;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 47E05DE62D444EC600D26DA6 /* Project object */;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"originHash" : "8ca6a90764720064f60093a93501e626765bfac81ce6e7cea5843f1752d5a5d1",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/combine-schedulers",
|
||||
"state" : {
|
||||
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "fluidaudio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/FluidInference/FluidAudio",
|
||||
"state" : {
|
||||
"revision" : "19600a485baa4998812e4654b70d2bab8f2c9949",
|
||||
"version" : "0.15.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "inject",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/krzysztofzablocki/Inject",
|
||||
"state" : {
|
||||
"revision" : "f53380e986a8b056e37d7ce9ca68de610b8415ef",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "pow",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/EmergeTools/Pow",
|
||||
"state" : {
|
||||
"revision" : "1b4b1dda28c50b95f0872927ee2226fe8b58950e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sauce",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Clipy/Sauce",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "9c0de6c233f29d892e86dda68c2dd791aa10670c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
|
||||
"version" : "2.9.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da",
|
||||
"version" : "1.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||
"state" : {
|
||||
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
|
||||
"version" : "1.0.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||
"version" : "0.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-composable-architecture",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-composable-architecture",
|
||||
"state" : {
|
||||
"revision" : "e2fa1df6cd9eec6fa6314aa20513e47da576f24e",
|
||||
"version" : "1.26.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-custom-dump",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-custom-dump",
|
||||
"state" : {
|
||||
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
|
||||
"version" : "1.3.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-dependencies",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-dependencies",
|
||||
"state" : {
|
||||
"revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5",
|
||||
"version" : "1.14.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-identified-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-identified-collections",
|
||||
"state" : {
|
||||
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-jinja",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-jinja.git",
|
||||
"state" : {
|
||||
"revision" : "ba2364165002abe8f4f3992d74a7b547c635638e",
|
||||
"version" : "2.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
|
||||
"version" : "1.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-navigation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-navigation",
|
||||
"state" : {
|
||||
"revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-perception",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-perception",
|
||||
"state" : {
|
||||
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
|
||||
"version" : "2.0.9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sharing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-sharing",
|
||||
"state" : {
|
||||
"revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90",
|
||||
"version" : "2.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax",
|
||||
"state" : {
|
||||
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
|
||||
"version" : "602.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-transformers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-transformers.git",
|
||||
"state" : {
|
||||
"revision" : "573e5c9036c2f136b3a8a071da8e8907322403d0",
|
||||
"version" : "1.1.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "whisperkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/argmaxinc/WhisperKit",
|
||||
"state" : {
|
||||
"revision" : "664e1b5a65296cd957dfdf262cd120ca88f3b24b",
|
||||
"version" : "0.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "31073495cae9caf243c440eac94b3ab067e3d7bc",
|
||||
"version" : "1.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2610"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47E05DED2D444EC600D26DA6"
|
||||
BuildableName = "Hex.app"
|
||||
BlueprintName = "Hex"
|
||||
ReferencedContainer = "container:Hex.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "478637A42D48725900319BFA"
|
||||
BuildableName = "HexTests.xctest"
|
||||
BlueprintName = "HexTests"
|
||||
ReferencedContainer = "container:Hex.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47E05DED2D444EC600D26DA6"
|
||||
BuildableName = "Hex.app"
|
||||
BlueprintName = "Hex"
|
||||
ReferencedContainer = "container:Hex.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "INJECTION_PRESERVE_STATICS"
|
||||
value = "TRUE"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "INJECTION_KEYPATHS"
|
||||
value = "TRUE"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47E05DED2D444EC600D26DA6"
|
||||
BuildableName = "Hex.app"
|
||||
BlueprintName = "Hex"
|
||||
ReferencedContainer = "container:Hex.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import Sparkle
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CheckForUpdatesViewModel {
|
||||
init() {
|
||||
anyCancellable = controller.updater.publisher(for: \.canCheckForUpdates)
|
||||
.sink(receiveValue: { self.canCheckForUpdates = $0 })
|
||||
}
|
||||
|
||||
static let shared = CheckForUpdatesViewModel()
|
||||
|
||||
let controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
|
||||
var anyCancellable: AnyCancellable?
|
||||
|
||||
var canCheckForUpdates = false
|
||||
|
||||
func checkForUpdates() {
|
||||
controller.updater.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckForUpdatesView: View {
|
||||
@State var viewModel = CheckForUpdatesViewModel.shared
|
||||
@ObserveInjection var inject
|
||||
|
||||
var body: some View {
|
||||
Button("Check for Updates…", action: viewModel.checkForUpdates)
|
||||
.disabled(!viewModel.canCheckForUpdates)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import HexCore
|
||||
import OSLog
|
||||
|
||||
enum DiagnosticsLogging {
|
||||
private static var isBootstrapped = false
|
||||
private static let logger = Logger(subsystem: HexLog.subsystem, category: "Diagnostics")
|
||||
|
||||
static func bootstrapIfNeeded() {
|
||||
guard !isBootstrapped else { return }
|
||||
logger.notice("Diagnostics logging initialized")
|
||||
isBootstrapped = true
|
||||
}
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import Sparkle
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct HexApp: App {
|
||||
static let appStore = Store(initialState: AppFeature.State()) {
|
||||
AppFeature()
|
||||
}
|
||||
|
||||
@NSApplicationDelegateAdaptor(HexAppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
MenuBarCopyLastTranscriptButton()
|
||||
|
||||
Button("Settings…") {
|
||||
appDelegate.presentSettingsView()
|
||||
}.keyboardShortcut(",")
|
||||
|
||||
CheckForUpdatesView()
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit Hex") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}.keyboardShortcut("q")
|
||||
} label: {
|
||||
if let image = NSImage(named: "HexIcon").map({
|
||||
let ratio = $0.size.height / $0.size.width
|
||||
$0.size.height = 18
|
||||
$0.size.width = 18 / ratio
|
||||
return $0
|
||||
}) {
|
||||
Image(nsImage: image)
|
||||
} else {
|
||||
Image(systemName: "hexagon")
|
||||
}
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesView()
|
||||
|
||||
Button("Settings…") {
|
||||
appDelegate.presentSettingsView()
|
||||
}.keyboardShortcut(",")
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .help) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import SwiftUI
|
||||
|
||||
private let appLogger = HexLog.app
|
||||
private let cacheLogger = HexLog.caches
|
||||
|
||||
class HexAppDelegate: NSObject, NSApplicationDelegate {
|
||||
var invisibleWindow: InvisibleWindow?
|
||||
var settingsWindow: NSWindow?
|
||||
var statusItem: NSStatusItem!
|
||||
private var launchedAtLogin = false
|
||||
|
||||
@Dependency(\.soundEffects) var soundEffect
|
||||
@Dependency(\.recording) var recording
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
DiagnosticsLogging.bootstrapIfNeeded()
|
||||
// Ensure Parakeet/FluidAudio caches live under Application Support, not ~/.cache
|
||||
configureLocalCaches()
|
||||
if isTesting {
|
||||
appLogger.debug("Running in testing mode")
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await soundEffect.preloadSounds()
|
||||
await soundEffect.setEnabled(hexSettings.soundEffectsEnabled)
|
||||
}
|
||||
launchedAtLogin = wasLaunchedAtLogin()
|
||||
appLogger.info("Application did finish launching")
|
||||
appLogger.notice("launchedAtLogin = \(self.launchedAtLogin)")
|
||||
|
||||
// Set activation policy first
|
||||
updateAppMode()
|
||||
|
||||
// Add notification observer
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAppModeUpdate),
|
||||
name: .updateAppMode,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handlePresentSettingsWindow),
|
||||
name: .presentSettingsWindow,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Start long-running app effects (global hotkeys, permissions, etc.)
|
||||
startLifecycleTasksIfNeeded()
|
||||
|
||||
// Then present main views
|
||||
presentMainView()
|
||||
|
||||
guard shouldOpenForegroundUIOnLaunch else {
|
||||
appLogger.notice("Suppressing foreground windows for login launch")
|
||||
return
|
||||
}
|
||||
|
||||
presentSettingsView()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private var shouldOpenForegroundUIOnLaunch: Bool {
|
||||
// When Hex launches at login, stay quietly in the menu bar regardless of
|
||||
// the dock-icon preference. Users who enabled "Open on Login" expect a
|
||||
// background launch; the Settings window can be opened later from the
|
||||
// menu bar item or ⌘, when needed.
|
||||
!launchedAtLogin
|
||||
}
|
||||
|
||||
private func wasLaunchedAtLogin() -> Bool {
|
||||
guard let event = NSAppleEventManager.shared().currentAppleEvent else {
|
||||
return false
|
||||
}
|
||||
|
||||
return event.eventID == AEEventID(kAEOpenApplication)
|
||||
&& event.paramDescriptor(forKeyword: AEKeyword(keyAEPropData))?.enumCodeValue == AEEventClass(keyAELaunchedAsLogInItem)
|
||||
}
|
||||
|
||||
private func startLifecycleTasksIfNeeded() {
|
||||
Task { @MainActor in
|
||||
await HexApp.appStore.send(.task).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets XDG_CACHE_HOME so FluidAudio stores models under our app's
|
||||
/// Application Support folder, keeping everything in one place.
|
||||
private func configureLocalCaches() {
|
||||
do {
|
||||
let cache = try URL.hexApplicationSupport.appendingPathComponent("cache", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true)
|
||||
setenv("XDG_CACHE_HOME", cache.path, 1)
|
||||
cacheLogger.info("XDG_CACHE_HOME set to \(cache.path)")
|
||||
} catch {
|
||||
cacheLogger.error("Failed to configure local caches: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func presentMainView() {
|
||||
guard invisibleWindow == nil else {
|
||||
return
|
||||
}
|
||||
let transcriptionStore = HexApp.appStore.scope(state: \.transcription, action: \.transcription)
|
||||
let transcriptionView = TranscriptionView(store: transcriptionStore).padding().padding(.top).padding(.top)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
invisibleWindow = InvisibleWindow.fromView(transcriptionView)
|
||||
invisibleWindow?.orderFrontRegardless()
|
||||
}
|
||||
|
||||
func presentSettingsView() {
|
||||
if let settingsWindow = settingsWindow {
|
||||
settingsWindow.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let settingsView = AppView(store: HexApp.appStore)
|
||||
let settingsWindow = NSWindow(
|
||||
contentRect: .init(x: 0, y: 0, width: 700, height: 700),
|
||||
styleMask: [.titled, .fullSizeContentView, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
settingsWindow.titleVisibility = .visible
|
||||
settingsWindow.contentView = NSHostingView(rootView: settingsView)
|
||||
settingsWindow.isReleasedWhenClosed = false
|
||||
settingsWindow.minSize = .init(width: 620, height: 560)
|
||||
settingsWindow.setFrameAutosaveName("Settings")
|
||||
settingsWindow.center()
|
||||
settingsWindow.toolbarStyle = NSWindow.ToolbarStyle.unified
|
||||
settingsWindow.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.settingsWindow = settingsWindow
|
||||
}
|
||||
|
||||
@objc private func handleAppModeUpdate() {
|
||||
Task {
|
||||
await updateAppMode()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePresentSettingsWindow() {
|
||||
presentSettingsView()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateAppMode() {
|
||||
appLogger.debug("showDockIcon = \(self.hexSettings.showDockIcon)")
|
||||
if self.hexSettings.showDockIcon {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
} else {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
|
||||
presentSettingsView()
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_: Notification) {
|
||||
// Wait for audio teardown before the process exits: a fire-and-forget Task here
|
||||
// raced process exit, crashing inside AVAudioEngine teardown while tap callbacks
|
||||
// were still in flight (#245). Pump the main run loop while waiting instead of
|
||||
// blocking outright - cleanup() hops to the main actor/main queue (media-key
|
||||
// resume, Core Audio listener removal), which a blocked main thread would deadlock.
|
||||
let recording = recording
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task.detached {
|
||||
await recording.cleanup()
|
||||
semaphore.signal()
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while semaphore.wait(timeout: .now()) == .timedOut {
|
||||
guard Date() < deadline else {
|
||||
appLogger.error("Recording cleanup timed out during app termination")
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import AppKit
|
||||
import HexCore
|
||||
import Sauce
|
||||
|
||||
struct MenuBarCopyLastTranscriptButton: View {
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
|
||||
var body: some View {
|
||||
let lastText = transcriptionHistory.history.first?.text
|
||||
|
||||
let button = Button("Paste Last Transcript") {
|
||||
if let text = lastText {
|
||||
Task { await pasteboard.paste(text) }
|
||||
}
|
||||
}
|
||||
.disabled(lastText == nil)
|
||||
|
||||
if let hotkey = hexSettings.pasteLastTranscriptHotkey,
|
||||
let key = hotkey.key,
|
||||
let keyEquivalent = toKeyEquivalent(key) {
|
||||
button.keyboardShortcut(keyEquivalent, modifiers: toEventModifiers(hotkey.modifiers))
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
|
||||
private func toKeyEquivalent(_ key: Key) -> KeyEquivalent? {
|
||||
switch key.rawValue {
|
||||
case "a": return "a"
|
||||
case "b": return "b"
|
||||
case "c": return "c"
|
||||
case "d": return "d"
|
||||
case "e": return "e"
|
||||
case "f": return "f"
|
||||
case "g": return "g"
|
||||
case "h": return "h"
|
||||
case "i": return "i"
|
||||
case "j": return "j"
|
||||
case "k": return "k"
|
||||
case "l": return "l"
|
||||
case "m": return "m"
|
||||
case "n": return "n"
|
||||
case "o": return "o"
|
||||
case "p": return "p"
|
||||
case "q": return "q"
|
||||
case "r": return "r"
|
||||
case "s": return "s"
|
||||
case "t": return "t"
|
||||
case "u": return "u"
|
||||
case "v": return "v"
|
||||
case "w": return "w"
|
||||
case "x": return "x"
|
||||
case "y": return "y"
|
||||
case "z": return "z"
|
||||
case "0": return "0"
|
||||
case "1": return "1"
|
||||
case "2": return "2"
|
||||
case "3": return "3"
|
||||
case "4": return "4"
|
||||
case "5": return "5"
|
||||
case "6": return "6"
|
||||
case "7": return "7"
|
||||
case "8": return "8"
|
||||
case "9": return "9"
|
||||
case ",": return ","
|
||||
case ".": return "."
|
||||
case "/": return "/"
|
||||
case "\\": return "\\"
|
||||
case "'": return "'"
|
||||
case ";": return ";"
|
||||
case "[": return "["
|
||||
case "]": return "]"
|
||||
case "-": return "-"
|
||||
case "=": return "="
|
||||
case "`": return "`"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func toEventModifiers(_ modifiers: Modifiers) -> SwiftUI.EventModifiers {
|
||||
var result: SwiftUI.EventModifiers = []
|
||||
if modifiers.contains(kind: .command) { result.insert(.command) }
|
||||
if modifiers.contains(kind: .option) { result.insert(.option) }
|
||||
if modifiers.contains(kind: .shift) { result.insert(.shift) }
|
||||
if modifiers.contains(kind: .control) { result.insert(.control) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MenuBarCopyLastTranscriptButton()
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension NSNotification.Name {
|
||||
/// Posted when app mode settings change (dock icon visibility, etc.)
|
||||
static let updateAppMode = NSNotification.Name("UpdateAppMode")
|
||||
static let presentSettingsWindow = NSNotification.Name("PresentSettingsWindow")
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Executable
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"gray:0.19846,1.00000",
|
||||
"srgb:0.00000,0.00000,0.00000,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0
|
||||
},
|
||||
"stop" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "Image.png",
|
||||
"name" : "Image",
|
||||
"position" : {
|
||||
"scale" : 0.5,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "hex 1.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "hex 2.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="1488" height="1672" viewBox="0 0 1488 1672" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M709.727 10.3496L40.6947 387.748C15.5519 401.931 0 428.56 0 457.427V1209.64C0 1238.35 15.3893 1264.86 40.3236 1279.11L709.353 1661.22C734.093 1675.35 764.481 1675.26 789.134 1660.97L1448.1 1279.19C1472.8 1264.88 1488 1238.51 1488 1209.97V457.098C1488 428.407 1472.64 401.912 1447.73 387.662L788.764 10.5921C764.301 -3.40629 734.276 -3.49841 709.727 10.3496ZM709.112 114.321L131.279 439.924C106.119 454.101 90.5526 480.74 90.5526 509.62V1158.09C90.5526 1186.82 105.956 1213.34 130.908 1227.58L708.739 1557.25C733.462 1571.36 763.819 1571.26 788.455 1557L1357.52 1227.66C1382.23 1213.36 1397.45 1186.97 1397.45 1158.42V509.292C1397.45 480.587 1382.07 454.082 1357.15 439.837L788.085 114.563C763.638 100.589 733.645 100.497 709.112 114.321Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M708.005 299.036L292.852 532.609C267.665 546.78 252.079 573.432 252.079 602.332V1066.53C252.079 1095.28 267.503 1121.82 292.481 1136.05L707.631 1372.54C732.331 1386.61 762.642 1386.51 787.255 1372.29L1195.95 1136.13C1220.68 1121.83 1235.92 1095.43 1235.92 1066.86V602.003C1235.92 573.279 1220.52 546.76 1195.57 532.522L786.886 299.278C762.462 285.339 732.513 285.247 708.005 299.036ZM707.267 400.72L383.315 583.532C358.178 597.716 342.631 624.341 342.631 653.204V1016.29C342.631 1045 358.016 1071.51 382.943 1085.75L706.894 1270.85C731.64 1284.99 762.04 1284.9 786.698 1270.6L1105.48 1085.84C1130.17 1071.53 1145.37 1045.16 1145.37 1016.62V652.875C1145.37 624.188 1130.01 597.697 1105.11 583.446L786.328 400.963C761.859 386.956 731.823 386.863 707.267 400.72Z" fill="white"/>
|
||||
<path d="M538.299 700.708L730.259 592.584C739.763 587.23 751.381 587.266 760.852 592.677L949.866 700.674C959.525 706.193 965.487 716.465 965.487 727.59V943.013C965.487 954.079 959.589 964.305 950.01 969.847L760.995 1079.19C751.451 1084.72 739.692 1084.75 730.114 1079.29L538.156 969.814C528.484 964.298 522.513 954.02 522.513 942.886V727.718C522.513 716.525 528.547 706.201 538.299 700.708Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="1488" height="1672" viewBox="0 0 1488 1672" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M709.727 10.3496L40.6947 387.748C15.5519 401.931 0 428.56 0 457.427V1209.64C0 1238.35 15.3893 1264.86 40.3236 1279.11L709.353 1661.22C734.093 1675.35 764.481 1675.26 789.134 1660.97L1448.1 1279.19C1472.8 1264.88 1488 1238.51 1488 1209.97V457.098C1488 428.407 1472.64 401.912 1447.73 387.662L788.764 10.5921C764.301 -3.40629 734.276 -3.49841 709.727 10.3496ZM709.112 114.321L131.279 439.924C106.119 454.101 90.5526 480.74 90.5526 509.62V1158.09C90.5526 1186.82 105.956 1213.34 130.908 1227.58L708.739 1557.25C733.462 1571.36 763.819 1571.26 788.455 1557L1357.52 1227.66C1382.23 1213.36 1397.45 1186.97 1397.45 1158.42V509.292C1397.45 480.587 1382.07 454.082 1357.15 439.837L788.085 114.563C763.638 100.589 733.645 100.497 709.112 114.321Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M708.005 299.036L292.852 532.609C267.665 546.78 252.079 573.432 252.079 602.332V1066.53C252.079 1095.28 267.503 1121.82 292.481 1136.05L707.631 1372.54C732.331 1386.61 762.642 1386.51 787.255 1372.29L1195.95 1136.13C1220.68 1121.83 1235.92 1095.43 1235.92 1066.86V602.003C1235.92 573.279 1220.52 546.76 1195.57 532.522L786.886 299.278C762.462 285.339 732.513 285.247 708.005 299.036ZM707.267 400.72L383.315 583.532C358.178 597.716 342.631 624.341 342.631 653.204V1016.29C342.631 1045 358.016 1071.51 382.943 1085.75L706.894 1270.85C731.64 1284.99 762.04 1284.9 786.698 1270.6L1105.48 1085.84C1130.17 1071.53 1145.37 1045.16 1145.37 1016.62V652.875C1145.37 624.188 1130.01 597.697 1105.11 583.446L786.328 400.963C761.859 386.956 731.823 386.863 707.267 400.72Z" fill="white"/>
|
||||
<path d="M538.299 700.708L730.259 592.584C739.763 587.23 751.381 587.266 760.852 592.677L949.866 700.674C959.525 706.193 965.487 716.465 965.487 727.59V943.013C965.487 954.079 959.589 964.305 950.01 969.847L760.995 1079.19C751.451 1084.72 739.692 1084.75 730.114 1079.29L538.156 969.814C528.484 964.298 522.513 954.02 522.513 942.886V727.718C522.513 716.525 528.547 706.201 538.299 700.708Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
+611
@@ -0,0 +1,611 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Carbon
|
||||
import ComposableArchitecture
|
||||
import CoreGraphics
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import IOKit
|
||||
import IOKit.hidsystem
|
||||
import Sauce
|
||||
|
||||
private let logger = HexLog.keyEvent
|
||||
|
||||
struct KeyEventMonitorToken: Sendable {
|
||||
private let cancelHandler: @Sendable () -> Void
|
||||
|
||||
init(cancel: @escaping @Sendable () -> Void) {
|
||||
self.cancelHandler = cancel
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancelHandler()
|
||||
}
|
||||
|
||||
static let noop = KeyEventMonitorToken(cancel: {})
|
||||
}
|
||||
|
||||
public extension KeyEvent {
|
||||
init(cgEvent: CGEvent, type: CGEventType, isFnPressed: Bool) {
|
||||
let keyCode = Int(cgEvent.getIntegerValueField(.keyboardEventKeycode))
|
||||
// Accessing keyboard layout / input source via Sauce must be on main thread.
|
||||
let key: Key?
|
||||
if cgEvent.type == .keyDown {
|
||||
if Thread.isMainThread {
|
||||
key = Sauce.shared.key(for: keyCode)
|
||||
} else {
|
||||
key = DispatchQueue.main.sync { Sauce.shared.key(for: keyCode) }
|
||||
}
|
||||
} else {
|
||||
key = nil
|
||||
}
|
||||
|
||||
var modifiers = Modifiers.from(carbonFlags: cgEvent.flags)
|
||||
if !isFnPressed {
|
||||
modifiers = modifiers.removing(kind: .fn)
|
||||
}
|
||||
self.init(key: key, modifiers: modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
struct KeyEventMonitorClient {
|
||||
var listenForKeyPress: @Sendable () async -> AsyncThrowingStream<KeyEvent, Error> = {
|
||||
AsyncThrowingStream { _ in }
|
||||
}
|
||||
var handleKeyEvent: @Sendable (@Sendable @escaping (KeyEvent) -> Bool) -> KeyEventMonitorToken = { _ in .noop }
|
||||
var handleInputEvent: @Sendable (@Sendable @escaping (InputEvent) -> Bool) -> KeyEventMonitorToken = { _ in .noop }
|
||||
var startMonitoring: @Sendable () async -> Void = {}
|
||||
var stopMonitoring: @Sendable () -> Void = {}
|
||||
}
|
||||
|
||||
extension KeyEventMonitorClient: DependencyKey {
|
||||
static var liveValue: KeyEventMonitorClient {
|
||||
let live = KeyEventMonitorClientLive()
|
||||
return KeyEventMonitorClient(
|
||||
listenForKeyPress: {
|
||||
live.listenForKeyPress()
|
||||
},
|
||||
handleKeyEvent: { handler in
|
||||
live.handleKeyEvent(handler)
|
||||
},
|
||||
handleInputEvent: { handler in
|
||||
live.handleInputEvent(handler)
|
||||
},
|
||||
startMonitoring: {
|
||||
live.startMonitoring()
|
||||
},
|
||||
stopMonitoring: {
|
||||
live.stopMonitoring()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var keyEventMonitor: KeyEventMonitorClient {
|
||||
get { self[KeyEventMonitorClient.self] }
|
||||
set { self[KeyEventMonitorClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
class KeyEventMonitorClientLive {
|
||||
private var eventTapPort: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
private var continuations: [UUID: @Sendable (KeyEvent) -> Bool] = [:]
|
||||
private var inputContinuations: [UUID: @Sendable (InputEvent) -> Bool] = [:]
|
||||
private let queue = DispatchQueue(label: "com.kitlangton.Hex.KeyEventMonitor", attributes: .concurrent)
|
||||
private let queueSpecificKey = DispatchSpecificKey<Void>()
|
||||
private var isMonitoring = false
|
||||
private var wantsMonitoring = false
|
||||
private var accessibilityTrusted = false
|
||||
private var inputMonitoringTrusted = false
|
||||
/// Set when key events are observed arriving at the tap. Key events only flow when Input
|
||||
/// Monitoring is genuinely granted, so this overrides stale `IOHIDCheckAccess` denials (#250).
|
||||
private var inputMonitoringProvenByEvents = false
|
||||
private var trustMonitorTask: Task<Void, Never>?
|
||||
private var systemEventObservers: [NSObjectProtocol] = []
|
||||
private var isFnPressed = false
|
||||
private var hasPromptedForAccessibilityTrust = false
|
||||
private let accessibilityTrustProvider: @Sendable () -> Bool
|
||||
private let accessibilityTrustPrompt: @Sendable () -> Bool
|
||||
private let inputMonitoringTrustProvider: @Sendable () -> Bool
|
||||
@Shared(.hotkeyPermissionState) private var hotkeyPermissionState: HotkeyPermissionState
|
||||
|
||||
private let trustCheckIntervalNanoseconds: UInt64 = 100_000_000 // 100ms
|
||||
|
||||
init(
|
||||
accessibilityTrustProvider: @escaping @Sendable () -> Bool = {
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
return AXIsProcessTrustedWithOptions([promptKey: false] as CFDictionary)
|
||||
},
|
||||
accessibilityTrustPrompt: @escaping @Sendable () -> Bool = {
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
return AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
|
||||
},
|
||||
inputMonitoringTrustProvider: @escaping @Sendable () -> Bool = {
|
||||
IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted
|
||||
}
|
||||
) {
|
||||
self.accessibilityTrustProvider = accessibilityTrustProvider
|
||||
self.accessibilityTrustPrompt = accessibilityTrustPrompt
|
||||
self.inputMonitoringTrustProvider = inputMonitoringTrustProvider
|
||||
queue.setSpecific(key: queueSpecificKey, value: ())
|
||||
logger.info("Initializing HotKeyClient with CGEvent tap.")
|
||||
registerSystemEventObservers()
|
||||
}
|
||||
|
||||
deinit {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
for observer in systemEventObservers {
|
||||
center.removeObserver(observer)
|
||||
}
|
||||
self.stopMonitoring()
|
||||
}
|
||||
|
||||
private var hasHandlers: Bool {
|
||||
readState { !(continuations.isEmpty && inputContinuations.isEmpty) }
|
||||
}
|
||||
|
||||
func readState<Value>(_ operation: () -> Value) -> Value {
|
||||
// Handler registration performs permission checks from a barrier block on this queue.
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
return operation()
|
||||
}
|
||||
return queue.sync(execute: operation)
|
||||
}
|
||||
|
||||
private func setMonitoringIntent(_ value: Bool) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.wantsMonitoring = value
|
||||
}
|
||||
}
|
||||
|
||||
private func desiredMonitoringState() -> Bool {
|
||||
// Intentionally not gated on `inputMonitoringTrusted`: `IOHIDCheckAccess` is notorious for
|
||||
// returning stale denials (after sleep, MDM re-logins, or OS updates) while events still
|
||||
// flow, and tearing the tap down on that signal killed working hotkeys (#250). The tap only
|
||||
// needs Accessibility to exist; without Input Monitoring, macOS simply withholds key events
|
||||
// (modifiers still arrive), and creating the tap is what triggers the permission prompt.
|
||||
readState {
|
||||
wantsMonitoring
|
||||
&& accessibilityTrusted
|
||||
&& !(continuations.isEmpty && inputContinuations.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide a stream of key events.
|
||||
func listenForKeyPress() -> AsyncThrowingStream<KeyEvent, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let uuid = UUID()
|
||||
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.continuations[uuid] = { event in
|
||||
continuation.yield(event)
|
||||
return false
|
||||
}
|
||||
let shouldStart = self.continuations.count == 1 && self.inputContinuations.isEmpty
|
||||
|
||||
// Start monitoring if this is the first subscription
|
||||
if shouldStart {
|
||||
self.startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on cancellation
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
self?.removeHandlerContinuation(uuid: uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeHandlerContinuation(uuid: UUID) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.continuations[uuid] = nil
|
||||
if self.continuations.isEmpty && self.inputContinuations.isEmpty {
|
||||
self.stopMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInputContinuation(uuid: UUID) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.inputContinuations[uuid] = nil
|
||||
if self.continuations.isEmpty && self.inputContinuations.isEmpty {
|
||||
self.stopMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startMonitoring() {
|
||||
setMonitoringIntent(true)
|
||||
startTrustMonitorIfNeeded()
|
||||
refreshTrustedFlag(promptIfUntrusted: true)
|
||||
Task { [weak self] in
|
||||
await self?.refreshMonitoringState(reason: "startMonitoring")
|
||||
}
|
||||
}
|
||||
// TODO: Handle removing the handler from the continuations on deinit/cancellation
|
||||
func handleKeyEvent(_ handler: @Sendable @escaping (KeyEvent) -> Bool) -> KeyEventMonitorToken {
|
||||
let uuid = UUID()
|
||||
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.continuations[uuid] = handler
|
||||
let shouldStart = self.continuations.count == 1 && self.inputContinuations.isEmpty
|
||||
|
||||
if shouldStart {
|
||||
self.startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventMonitorToken { [weak self] in
|
||||
self?.removeHandlerContinuation(uuid: uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func handleInputEvent(_ handler: @Sendable @escaping (InputEvent) -> Bool) -> KeyEventMonitorToken {
|
||||
let uuid = UUID()
|
||||
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.inputContinuations[uuid] = handler
|
||||
let shouldStart = self.inputContinuations.count == 1 && self.continuations.isEmpty
|
||||
|
||||
if shouldStart {
|
||||
self.startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventMonitorToken { [weak self] in
|
||||
self?.removeInputContinuation(uuid: uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring() {
|
||||
setMonitoringIntent(false)
|
||||
Task { [weak self] in
|
||||
await self?.refreshMonitoringState(reason: "stopMonitoring")
|
||||
}
|
||||
cancelTrustMonitorIfNeeded()
|
||||
}
|
||||
|
||||
private func startTrustMonitorIfNeeded() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.trustMonitorTask == nil else { return }
|
||||
self.trustMonitorTask = Task { [weak self] in
|
||||
await self?.watchPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelTrustMonitorIfNeeded() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard !self.wantsMonitoring else { return }
|
||||
self.trustMonitorTask?.cancel()
|
||||
self.trustMonitorTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
// no separate helper; handled inline above
|
||||
|
||||
private func watchPermissions() async {
|
||||
var last = (
|
||||
accessibility: currentAccessibilityTrust(),
|
||||
input: currentInputMonitoringTrust()
|
||||
)
|
||||
await handlePermissionChange(accessibility: last.accessibility, input: last.input, reason: "initial")
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: trustCheckIntervalNanoseconds)
|
||||
let current = (
|
||||
accessibility: currentAccessibilityTrust(),
|
||||
input: currentInputMonitoringTrust()
|
||||
)
|
||||
|
||||
if current.accessibility != last.accessibility || current.input != last.input {
|
||||
let combinedBefore = last.accessibility && last.input
|
||||
let combinedAfter = current.accessibility && current.input
|
||||
let reason: String
|
||||
if combinedAfter && !combinedBefore {
|
||||
reason = "regained"
|
||||
} else if !combinedAfter && combinedBefore {
|
||||
reason = "revoked"
|
||||
} else {
|
||||
reason = "updated"
|
||||
}
|
||||
await handlePermissionChange(accessibility: current.accessibility, input: current.input, reason: reason)
|
||||
last = current
|
||||
} else if current.accessibility {
|
||||
await ensureTapIsRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePermissionChange(accessibility: Bool, input: Bool, reason: String) async {
|
||||
setPermissionFlags(accessibility: accessibility, input: input)
|
||||
logger.notice("Permission update: accessibility=\(accessibility), inputMonitoring=\(input), reason=\(reason)")
|
||||
if accessibility && input {
|
||||
logger.notice("Keyboard monitoring permissions granted (\(reason)).")
|
||||
} else {
|
||||
if !accessibility {
|
||||
logger.error("Accessibility permission missing (\(reason)); suspending tap.")
|
||||
}
|
||||
if !input {
|
||||
logger.error("Input Monitoring permission missing (\(reason)); keyed hotkeys may not fire until it is granted. Tap stays alive in case the check is stale.")
|
||||
}
|
||||
}
|
||||
await refreshMonitoringState(reason: "trust_\(reason)")
|
||||
}
|
||||
|
||||
private func ensureTapIsRunning() async {
|
||||
guard desiredMonitoringState() else { return }
|
||||
await activateTapOnMain(reason: "watchdog_keepalive")
|
||||
}
|
||||
|
||||
private func refreshMonitoringState(reason: String) async {
|
||||
let shouldMonitor = desiredMonitoringState()
|
||||
if shouldMonitor {
|
||||
await activateTapOnMain(reason: reason)
|
||||
} else {
|
||||
await deactivateTapOnMain(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func setPermissionFlags(accessibility: Bool, input: Bool) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.accessibilityTrusted = accessibility
|
||||
self?.inputMonitoringTrusted = input
|
||||
}
|
||||
recordSharedPermissionState(accessibility: accessibility, input: input)
|
||||
}
|
||||
|
||||
private func recordSharedPermissionState(accessibility: Bool, input: Bool) {
|
||||
$hotkeyPermissionState.withLock {
|
||||
$0.accessibility = accessibility ? .granted : .denied
|
||||
$0.inputMonitoring = input ? .granted : .denied
|
||||
$0.lastUpdated = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func activateTapOnMain(reason: String) async {
|
||||
await MainActor.run {
|
||||
self.activateTapIfNeeded(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateTapOnMain(reason: String) async {
|
||||
await MainActor.run {
|
||||
self.deactivateTap(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func activateTapIfNeeded(reason: String) {
|
||||
if isMonitoring {
|
||||
// The 100ms permission watchdog lands here while healthy; use it to revive taps that
|
||||
// macOS disabled without sending a tapDisabled event (observed after sleep, #250).
|
||||
if let eventTapPort, !CGEvent.tapIsEnabled(tap: eventTapPort) {
|
||||
CGEvent.tapEnable(tap: eventTapPort, enable: true)
|
||||
logger.notice("Re-enabled event tap that was silently disabled (reason: \(reason)).")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard hasHandlers else { return }
|
||||
|
||||
let accessibilityTrusted = currentAccessibilityTrust()
|
||||
let inputMonitoringTrusted = currentInputMonitoringTrust()
|
||||
setPermissionFlags(accessibility: accessibilityTrusted, input: inputMonitoringTrusted)
|
||||
guard accessibilityTrusted else {
|
||||
logger.error("Cannot start key event monitoring (reason: \(reason)); accessibility permission is not granted.")
|
||||
return
|
||||
}
|
||||
|
||||
if !inputMonitoringTrusted {
|
||||
logger.notice("Input Monitoring not yet granted; creating event tap will trigger permission prompt (reason: \(reason)).")
|
||||
}
|
||||
|
||||
let eventMask =
|
||||
((1 << CGEventType.keyDown.rawValue)
|
||||
| (1 << CGEventType.keyUp.rawValue)
|
||||
| (1 << CGEventType.flagsChanged.rawValue)
|
||||
| (1 << CGEventType.leftMouseDown.rawValue)
|
||||
| (1 << CGEventType.rightMouseDown.rawValue)
|
||||
| (1 << CGEventType.otherMouseDown.rawValue))
|
||||
|
||||
guard
|
||||
let eventTap = CGEvent.tapCreate(
|
||||
tap: .cghidEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: CGEventMask(eventMask),
|
||||
callback: { _, type, cgEvent, userInfo in
|
||||
guard
|
||||
let hotKeyClientLive = Unmanaged<KeyEventMonitorClientLive>
|
||||
.fromOpaque(userInfo!)
|
||||
.takeUnretainedValue() as KeyEventMonitorClientLive?
|
||||
else {
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout {
|
||||
hotKeyClientLive.handleTapDisabledEvent(type)
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
// An event arriving at the tap is authoritative proof the underlying permission is
|
||||
// granted. Never drop delivered events because a cached permission check went
|
||||
// stale (#250) — that turned recoverable TCC hiccups into dead hotkeys.
|
||||
hotKeyClientLive.noteEventDelivered(type: type)
|
||||
|
||||
if type == .leftMouseDown || type == .rightMouseDown || type == .otherMouseDown {
|
||||
_ = hotKeyClientLive.processInputEvent(.mouseClick)
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
hotKeyClientLive.updateFnStateIfNeeded(type: type, cgEvent: cgEvent)
|
||||
|
||||
let keyEvent = KeyEvent(cgEvent: cgEvent, type: type, isFnPressed: hotKeyClientLive.isFnPressed)
|
||||
let handledByKeyHandler = hotKeyClientLive.processKeyEvent(keyEvent)
|
||||
let handledByInputHandler = hotKeyClientLive.processInputEvent(.keyboard(keyEvent))
|
||||
|
||||
return (handledByKeyHandler || handledByInputHandler) ? nil : Unmanaged.passUnretained(cgEvent)
|
||||
},
|
||||
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
)
|
||||
else {
|
||||
logger.error("Failed to create event tap (reason: \(reason)).")
|
||||
return
|
||||
}
|
||||
|
||||
eventTapPort = eventTap
|
||||
|
||||
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
|
||||
self.runLoopSource = runLoopSource
|
||||
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
|
||||
CGEvent.tapEnable(tap: eventTap, enable: true)
|
||||
isMonitoring = true
|
||||
logger.info("Started monitoring key events via CGEvent tap (reason: \(reason)).")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deactivateTap(reason: String) {
|
||||
guard isMonitoring || eventTapPort != nil else { return }
|
||||
|
||||
if let runLoopSource = runLoopSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
|
||||
self.runLoopSource = nil
|
||||
}
|
||||
|
||||
if let eventTapPort = eventTapPort {
|
||||
CGEvent.tapEnable(tap: eventTapPort, enable: false)
|
||||
self.eventTapPort = nil
|
||||
}
|
||||
|
||||
isMonitoring = false
|
||||
clearInputMonitoringProof()
|
||||
logger.info("Suspended key event monitoring (reason: \(reason)).")
|
||||
}
|
||||
|
||||
private func clearInputMonitoringProof() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.inputMonitoringProvenByEvents = false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTapDisabledEvent(_ type: CGEventType) {
|
||||
let reason = type == .tapDisabledByTimeout ? "timeout" : "userInput"
|
||||
logger.error("Event tap disabled by \(reason); scheduling restart.")
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshMonitoringState(reason: "tap_disabled_\(reason)")
|
||||
}
|
||||
}
|
||||
|
||||
private func processEvent<T>(
|
||||
_ event: T,
|
||||
handlers: () -> [UUID: @Sendable (T) -> Bool]
|
||||
) -> Bool {
|
||||
let handlerList = readState { Array(handlers().values) }
|
||||
return handlerList.reduce(false) { handled, handler in
|
||||
handler(event) || handled
|
||||
}
|
||||
}
|
||||
|
||||
private func processKeyEvent(_ keyEvent: KeyEvent) -> Bool {
|
||||
processEvent(keyEvent, handlers: { continuations })
|
||||
}
|
||||
|
||||
private func processInputEvent(_ inputEvent: InputEvent) -> Bool {
|
||||
processEvent(inputEvent, handlers: { inputContinuations })
|
||||
}
|
||||
|
||||
/// Records that an event was delivered to the tap. Key events are proof that Input
|
||||
/// Monitoring is genuinely granted even when `IOHIDCheckAccess` reports otherwise (#250).
|
||||
fileprivate func noteEventDelivered(type: CGEventType) {
|
||||
guard type == .keyDown || type == .keyUp else { return }
|
||||
let alreadyProven = readState { inputMonitoringProvenByEvents }
|
||||
guard !alreadyProven else { return }
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.inputMonitoringProvenByEvents = true
|
||||
self?.inputMonitoringTrusted = true
|
||||
}
|
||||
if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) != kIOHIDAccessTypeGranted {
|
||||
logger.notice("Key events are flowing while IOHIDCheckAccess reports denied; treating Input Monitoring as granted (stale TCC cache, #250).")
|
||||
}
|
||||
}
|
||||
|
||||
/// Recreate the event tap after system transitions that are known to leave taps in a dead or
|
||||
/// stale state (wake from sleep, session reactivation after fast user switching / MDM logout).
|
||||
private func registerSystemEventObservers() {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
let events: [(Notification.Name, String)] = [
|
||||
(NSWorkspace.didWakeNotification, "system_wake"),
|
||||
(NSWorkspace.sessionDidBecomeActiveNotification, "session_active"),
|
||||
]
|
||||
for (name, reason) in events {
|
||||
let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.restartTapAfterSystemEvent(reason: reason)
|
||||
}
|
||||
systemEventObservers.append(observer)
|
||||
}
|
||||
}
|
||||
|
||||
private func restartTapAfterSystemEvent(reason: String) {
|
||||
logger.notice("System transition (\(reason)); recreating event tap to recover from any stale state.")
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.deactivateTapOnMain(reason: reason)
|
||||
await self.refreshMonitoringState(reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KeyEventMonitorClientLive {
|
||||
private func updateFnStateIfNeeded(type: CGEventType, cgEvent: CGEvent) {
|
||||
guard type == .flagsChanged else { return }
|
||||
let keyCode = Int(cgEvent.getIntegerValueField(.keyboardEventKeycode))
|
||||
guard keyCode == kVK_Function else { return }
|
||||
isFnPressed = cgEvent.flags.contains(.maskSecondaryFn)
|
||||
}
|
||||
|
||||
private func refreshTrustedFlag(promptIfUntrusted: Bool) {
|
||||
var accessibilityTrusted = currentAccessibilityTrust()
|
||||
if !accessibilityTrusted && promptIfUntrusted && !hasPromptedForAccessibilityTrust {
|
||||
accessibilityTrusted = requestAccessibilityTrustPrompt()
|
||||
hasPromptedForAccessibilityTrust = true
|
||||
logger.notice("Prompted for accessibility trust")
|
||||
}
|
||||
|
||||
let inputMonitoringTrusted = currentInputMonitoringTrust()
|
||||
setPermissionFlags(accessibility: accessibilityTrusted, input: inputMonitoringTrusted)
|
||||
}
|
||||
|
||||
private func currentAccessibilityTrust() -> Bool {
|
||||
accessibilityTrustProvider()
|
||||
}
|
||||
|
||||
private func requestAccessibilityTrustPrompt() -> Bool {
|
||||
accessibilityTrustPrompt()
|
||||
}
|
||||
|
||||
private func currentInputMonitoringTrust() -> Bool {
|
||||
if inputMonitoringTrustProvider() {
|
||||
return true
|
||||
}
|
||||
// A stale TCC cache can report denied while key events demonstrably flow (#250);
|
||||
// trust the events over the check so the watchdog and settings UI stay honest.
|
||||
return readState { inputMonitoringProvenByEvents }
|
||||
}
|
||||
|
||||
// Intentionally no request helper: creating the event tap prompts macOS 15+ for Input Monitoring
|
||||
// the same way older versions did, while we still track status for UI.
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum LegacyModelCacheMigrator {
|
||||
static func migrate(
|
||||
from legacyDirectory: URL,
|
||||
to currentDirectory: URL,
|
||||
fileManager: FileManager = .default,
|
||||
isValid: (URL) -> Bool
|
||||
) throws -> Bool {
|
||||
guard legacyDirectory.standardizedFileURL != currentDirectory.standardizedFileURL,
|
||||
fileManager.fileExists(atPath: legacyDirectory.path),
|
||||
!isValid(currentDirectory)
|
||||
else { return false }
|
||||
|
||||
let backupDirectory = currentDirectory
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(currentDirectory.lastPathComponent)-migration-\(UUID().uuidString)")
|
||||
let hadCurrentDirectory = fileManager.fileExists(atPath: currentDirectory.path)
|
||||
|
||||
if hadCurrentDirectory {
|
||||
try fileManager.moveItem(at: currentDirectory, to: backupDirectory)
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.moveItem(at: legacyDirectory, to: currentDirectory)
|
||||
guard isValid(currentDirectory) else {
|
||||
throw MigrationError.invalidLegacyModel
|
||||
}
|
||||
if hadCurrentDirectory {
|
||||
try? fileManager.removeItem(at: backupDirectory)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
if fileManager.fileExists(atPath: currentDirectory.path),
|
||||
!fileManager.fileExists(atPath: legacyDirectory.path)
|
||||
{
|
||||
try? fileManager.moveItem(at: currentDirectory, to: legacyDirectory)
|
||||
}
|
||||
if hadCurrentDirectory, fileManager.fileExists(atPath: backupDirectory.path) {
|
||||
try? fileManager.moveItem(at: backupDirectory, to: currentDirectory)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
enum MigrationError: Error {
|
||||
case invalidLegacyModel
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
import HexCore
|
||||
|
||||
#if canImport(FluidAudio)
|
||||
import FluidAudio
|
||||
|
||||
actor ParakeetClient {
|
||||
private var asr: AsrManager?
|
||||
private var models: AsrModels?
|
||||
private var currentVariant: ParakeetModel?
|
||||
private let logger = HexLog.parakeet
|
||||
private let vendorDirs = [
|
||||
// Our app-specific cache path convention (under XDG or com.kitlangton.Hex/cache)
|
||||
"fluidaudio/Models",
|
||||
"FluidAudio/Models"
|
||||
]
|
||||
|
||||
func isModelAvailable(_ modelName: String) async -> Bool {
|
||||
guard let variant = ParakeetModel(rawValue: modelName) else {
|
||||
logger.error("Unknown Parakeet variant requested: \(modelName)")
|
||||
return false
|
||||
}
|
||||
if currentVariant == variant, asr != nil { return true }
|
||||
|
||||
let directory = AsrModels.defaultCacheDirectory(for: variant.asrVersion)
|
||||
migrateLegacyCacheIfNeeded(variant, to: directory)
|
||||
let available = AsrModels.modelsExist(
|
||||
at: directory,
|
||||
version: variant.asrVersion
|
||||
)
|
||||
if available {
|
||||
logger.notice("Found Parakeet cache at \(directory.path)")
|
||||
} else {
|
||||
logger.debug("No Parakeet cache detected variant=\(variant.identifier) path=\(directory.path)")
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
func ensureLoaded(modelName: String, progress: @escaping (Progress) -> Void) async throws {
|
||||
guard let variant = ParakeetModel(rawValue: modelName) else {
|
||||
throw NSError(
|
||||
domain: "Parakeet",
|
||||
code: -4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unsupported Parakeet variant: \(modelName)"]
|
||||
)
|
||||
}
|
||||
if currentVariant == variant, asr != nil { return }
|
||||
if currentVariant != variant {
|
||||
asr = nil
|
||||
models = nil
|
||||
}
|
||||
migrateLegacyCacheIfNeeded(
|
||||
variant,
|
||||
to: AsrModels.defaultCacheDirectory(for: variant.asrVersion)
|
||||
)
|
||||
let t0 = Date()
|
||||
logger.notice("Starting Parakeet load variant=\(variant.identifier)")
|
||||
let p = Progress(totalUnitCount: 100)
|
||||
p.completedUnitCount = 1
|
||||
progress(p)
|
||||
|
||||
// Best-effort progress polling while FluidAudio downloads
|
||||
let fm = FileManager.default
|
||||
let support = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let faDir = support?.appendingPathComponent("FluidAudio/Models/\(variant.identifier)", isDirectory: true)
|
||||
let pollTask = Task {
|
||||
while p.completedUnitCount < 95 {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
if let dir = faDir, let size = directorySize(dir) {
|
||||
let target: Double = 650 * 1024 * 1024 // ~650MB
|
||||
let frac = max(0.0, min(1.0, Double(size) / target))
|
||||
p.completedUnitCount = Int64(5 + frac * 90)
|
||||
progress(p)
|
||||
}
|
||||
if Task.isCancelled { break }
|
||||
}
|
||||
}
|
||||
defer { pollTask.cancel() }
|
||||
|
||||
// Download + load the requested variant (returns when all assets are present)
|
||||
let models = try await AsrModels.downloadAndLoad(version: variant.asrVersion)
|
||||
self.models = models
|
||||
let manager = AsrManager(config: .init(), models: models)
|
||||
self.asr = manager
|
||||
self.currentVariant = variant
|
||||
p.completedUnitCount = 100
|
||||
progress(p)
|
||||
logger.notice("Parakeet ensureLoaded completed in \(String(format: "%.2f", Date().timeIntervalSince(t0)))s")
|
||||
}
|
||||
|
||||
private func directorySize(_ dir: URL) -> UInt64? {
|
||||
let fm = FileManager.default
|
||||
guard let en = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: .skipsHiddenFiles) else { return nil }
|
||||
var total: UInt64 = 0
|
||||
for case let url as URL in en {
|
||||
if let vals = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), vals.isRegularFile == true {
|
||||
total &+= UInt64(vals.fileSize ?? 0)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private func migrateLegacyCacheIfNeeded(_ variant: ParakeetModel, to directory: URL) {
|
||||
let legacyDirectory = directory
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(variant.identifier, isDirectory: true)
|
||||
|
||||
do {
|
||||
if try LegacyModelCacheMigrator.migrate(
|
||||
from: legacyDirectory,
|
||||
to: directory,
|
||||
isValid: {
|
||||
AsrModels.modelsExist(at: $0, version: variant.asrVersion)
|
||||
}
|
||||
) {
|
||||
logger.notice("Migrated legacy Parakeet cache from \(legacyDirectory.path) to \(directory.path)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to migrate legacy Parakeet cache: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func transcribe(_ url: URL) async throws -> String {
|
||||
guard let asr else { throw NSError(domain: "Parakeet", code: -1, userInfo: [NSLocalizedDescriptionKey: "Parakeet not initialized"]) }
|
||||
let t0 = Date()
|
||||
logger.notice("Transcribing with Parakeet file=\(url.lastPathComponent)")
|
||||
var decoderState = TdtDecoderState.make(decoderLayers: await asr.decoderLayerCount)
|
||||
let result = try await asr.transcribe(url, decoderState: &decoderState)
|
||||
logger.info("Parakeet transcription finished in \(String(format: "%.2f", Date().timeIntervalSince(t0)))s")
|
||||
return result.text
|
||||
}
|
||||
|
||||
// Delete cached Parakeet models from known locations and reset state
|
||||
func deleteCaches(modelName: String) async throws {
|
||||
guard let variant = ParakeetModel(rawValue: modelName) else { return }
|
||||
let fm = FileManager.default
|
||||
|
||||
var removedAny = false
|
||||
for dir in modelDirectories(variant) {
|
||||
if fm.fileExists(atPath: dir.path) {
|
||||
try? fm.removeItem(at: dir)
|
||||
removedAny = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reset live objects so a future download can proceed cleanly
|
||||
if removedAny {
|
||||
self.asr = nil
|
||||
self.models = nil
|
||||
if currentVariant == variant {
|
||||
currentVariant = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all candidate directories where a Parakeet model might be cached.
|
||||
/// Includes both exact matches and prefixed directories (e.g. versioned folders).
|
||||
private func modelDirectories(_ variant: ParakeetModel) -> [URL] {
|
||||
let fm = FileManager.default
|
||||
var result: [URL] = [AsrModels.defaultCacheDirectory(for: variant.asrVersion)]
|
||||
|
||||
for root in candidateRoots() {
|
||||
for vendor in vendorDirs {
|
||||
let base = root.appendingPathComponent(vendor, isDirectory: true)
|
||||
// Exact match directory
|
||||
let direct = base.appendingPathComponent(variant.identifier, isDirectory: true)
|
||||
result.append(direct)
|
||||
// Prefixed directories (e.g. versioned folders)
|
||||
if let items = try? fm.contentsOfDirectory(at: base, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) {
|
||||
for item in items where item.lastPathComponent.hasPrefix(variant.identifier) && item != direct {
|
||||
result.append(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var seen = Set<String>()
|
||||
return result.filter { seen.insert($0.standardizedFileURL.path).inserted }
|
||||
}
|
||||
|
||||
private func candidateRoots() -> [URL] {
|
||||
let fm = FileManager.default
|
||||
let xdg = ProcessInfo.processInfo.environment["XDG_CACHE_HOME"].flatMap { URL(fileURLWithPath: $0, isDirectory: true) }
|
||||
let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
||||
let appCache = try? URL.hexApplicationSupport.appendingPathComponent("cache", isDirectory: true)
|
||||
let userCache = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cache", isDirectory: true)
|
||||
return [xdg, appCache, appSupport, userCache].compactMap { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private extension ParakeetModel {
|
||||
var asrVersion: AsrModelVersion {
|
||||
switch self {
|
||||
case .englishV2: return .v2
|
||||
case .multilingualV3: return .v3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
actor ParakeetClient {
|
||||
func isModelAvailable(_ modelName: String) async -> Bool { false }
|
||||
func ensureLoaded(modelName: String, progress: @escaping (Progress) -> Void) async throws {
|
||||
throw NSError(
|
||||
domain: "Parakeet",
|
||||
code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Parakeet support not linked. Add Swift Package: https://github.com/FluidInference/FluidAudio.git and link FluidAudio to Hex."]
|
||||
)
|
||||
}
|
||||
func transcribe(_ url: URL) async throws -> String { throw NSError(domain: "Parakeet", code: -3, userInfo: [NSLocalizedDescriptionKey: "Parakeet not available"]) }
|
||||
func deleteCaches(modelName: String) async throws {}
|
||||
}
|
||||
|
||||
#endif
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import HexCore
|
||||
import os.log
|
||||
|
||||
struct ParakeetClipPreparationResult {
|
||||
let url: URL
|
||||
private let cleanupURL: URL?
|
||||
|
||||
init(url: URL, cleanupURL: URL?) {
|
||||
self.url = url
|
||||
self.cleanupURL = cleanupURL
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
guard let cleanupURL else { return }
|
||||
try? FileManager.default.removeItem(at: cleanupURL)
|
||||
}
|
||||
}
|
||||
|
||||
enum ParakeetClipPreparer {
|
||||
private enum Error: LocalizedError {
|
||||
case unsupportedFormat
|
||||
case bufferAllocationFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedFormat:
|
||||
return "Parakeet can only pad mono Float32 PCM recordings."
|
||||
case .bufferAllocationFailed:
|
||||
return "Unable to allocate buffer while preparing Parakeet audio clip."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FluidAudio's LastChunkHandling guidance recommends chunk_duration 1.5s,
|
||||
// so pad to at least that window to avoid decoder errors.
|
||||
static let defaultMinimumDuration: TimeInterval = 1.5
|
||||
|
||||
static func ensureMinimumDuration(
|
||||
url: URL,
|
||||
minimumDuration: TimeInterval = defaultMinimumDuration,
|
||||
logger: os.Logger = HexLog.parakeet
|
||||
) throws -> ParakeetClipPreparationResult {
|
||||
let audioFile = try AVAudioFile(forReading: url)
|
||||
let format = audioFile.processingFormat
|
||||
let duration = Double(audioFile.length) / format.sampleRate
|
||||
|
||||
logger.debug(
|
||||
"Parakeet clip check file=\(url.lastPathComponent) duration=\(String(format: "%.3f", duration))s sampleRate=\(String(format: "%.0f", format.sampleRate))Hz channels=\(format.channelCount)"
|
||||
)
|
||||
|
||||
guard duration < minimumDuration else {
|
||||
return ParakeetClipPreparationResult(url: url, cleanupURL: nil)
|
||||
}
|
||||
|
||||
guard format.commonFormat == .pcmFormatFloat32 else {
|
||||
throw Error.unsupportedFormat
|
||||
}
|
||||
|
||||
let minimumFrames = AVAudioFrameCount((minimumDuration * format.sampleRate).rounded(.up))
|
||||
let existingFrames64 = max(AVAudioFramePosition(0), audioFile.length)
|
||||
let sourceCapacity = max(AVAudioFrameCount(min(existingFrames64, AVAudioFramePosition(AVAudioFrameCount.max))), 1)
|
||||
|
||||
guard
|
||||
let readBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: sourceCapacity),
|
||||
let paddedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: minimumFrames)
|
||||
else {
|
||||
throw Error.bufferAllocationFailed
|
||||
}
|
||||
|
||||
try audioFile.read(into: readBuffer)
|
||||
let framesRead = min(readBuffer.frameLength, minimumFrames)
|
||||
|
||||
guard
|
||||
let sourceChannels = readBuffer.floatChannelData,
|
||||
let paddedChannels = paddedBuffer.floatChannelData
|
||||
else {
|
||||
throw Error.unsupportedFormat
|
||||
}
|
||||
|
||||
let channelCount = Int(format.channelCount)
|
||||
for channel in 0..<channelCount {
|
||||
let dest = paddedChannels[channel]
|
||||
let src = sourceChannels[channel]
|
||||
if framesRead > 0 {
|
||||
dest.update(from: src, count: Int(framesRead))
|
||||
}
|
||||
let padCount = Int(minimumFrames - framesRead)
|
||||
if padCount > 0 {
|
||||
dest.advanced(by: Int(framesRead)).initialize(repeating: 0, count: padCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
paddedBuffer.frameLength = minimumFrames
|
||||
|
||||
let paddedURL = makePaddedURL(from: url)
|
||||
if FileManager.default.fileExists(atPath: paddedURL.path) {
|
||||
try FileManager.default.removeItem(at: paddedURL)
|
||||
}
|
||||
|
||||
let paddedFile = try AVAudioFile(forWriting: paddedURL, settings: audioFile.fileFormat.settings)
|
||||
try paddedFile.write(from: paddedBuffer)
|
||||
|
||||
logger.notice(
|
||||
"Padded clip for Parakeet file=\(url.lastPathComponent) original=\(String(format: "%.3f", duration))s paddedTo=\(String(format: "%.3f", minimumDuration))s output=\(paddedURL.lastPathComponent)"
|
||||
)
|
||||
|
||||
return ParakeetClipPreparationResult(url: paddedURL, cleanupURL: paddedURL)
|
||||
}
|
||||
|
||||
private static func makePaddedURL(from url: URL) -> URL {
|
||||
let base = url.deletingLastPathComponent()
|
||||
let stem = url.deletingPathExtension().lastPathComponent
|
||||
return base.appendingPathComponent("\(stem)-parakeet-padded.wav")
|
||||
}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
//
|
||||
// PasteboardClient.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import Sauce
|
||||
import SwiftUI
|
||||
|
||||
private let pasteboardLogger = HexLog.pasteboard
|
||||
|
||||
@DependencyClient
|
||||
struct PasteboardClient {
|
||||
var paste: @Sendable (String) async -> Void
|
||||
var copy: @Sendable (String) async -> Void
|
||||
var sendKeyboardCommand: @Sendable (KeyboardCommand) async -> Void
|
||||
}
|
||||
|
||||
extension PasteboardClient: DependencyKey {
|
||||
static var liveValue: Self {
|
||||
let live = PasteboardClientLive()
|
||||
return .init(
|
||||
paste: { text in
|
||||
await live.paste(text: text)
|
||||
},
|
||||
copy: { text in
|
||||
await live.copy(text: text)
|
||||
},
|
||||
sendKeyboardCommand: { command in
|
||||
await live.sendKeyboardCommand(command)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var pasteboard: PasteboardClient {
|
||||
get { self[PasteboardClient.self] }
|
||||
set { self[PasteboardClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct PasteboardClientLive {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
private struct PasteboardSnapshot {
|
||||
let items: [[String: Any]]
|
||||
|
||||
init(pasteboard: NSPasteboard) {
|
||||
var saved: [[String: Any]] = []
|
||||
for item in pasteboard.pasteboardItems ?? [] {
|
||||
var itemDict: [String: Any] = [:]
|
||||
for type in item.types {
|
||||
if let data = item.data(forType: type) {
|
||||
itemDict[type.rawValue] = data
|
||||
}
|
||||
}
|
||||
saved.append(itemDict)
|
||||
}
|
||||
self.items = saved
|
||||
}
|
||||
|
||||
func restore(to pasteboard: NSPasteboard) {
|
||||
pasteboard.clearContents()
|
||||
for itemDict in items {
|
||||
let item = NSPasteboardItem()
|
||||
for (type, data) in itemDict {
|
||||
if let data = data as? Data {
|
||||
item.setData(data, forType: NSPasteboard.PasteboardType(rawValue: type))
|
||||
}
|
||||
}
|
||||
pasteboard.writeObjects([item])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func paste(text: String) async {
|
||||
if hexSettings.useClipboardPaste {
|
||||
await pasteWithClipboard(text)
|
||||
} else {
|
||||
simulateTypingWithAppleScript(text)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func copy(text: String) async {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendKeyboardCommand(_ command: KeyboardCommand) async {
|
||||
let source = CGEventSource(stateID: .combinedSessionState)
|
||||
|
||||
// Convert modifiers to CGEventFlags and key codes for modifier keys
|
||||
var modifierKeyCodes: [CGKeyCode] = []
|
||||
var flags = CGEventFlags()
|
||||
|
||||
for modifier in command.modifiers.sorted {
|
||||
switch modifier.kind {
|
||||
case .command:
|
||||
flags.insert(.maskCommand)
|
||||
modifierKeyCodes.append(55) // Left Cmd
|
||||
case .shift:
|
||||
flags.insert(.maskShift)
|
||||
modifierKeyCodes.append(56) // Left Shift
|
||||
case .option:
|
||||
flags.insert(.maskAlternate)
|
||||
modifierKeyCodes.append(58) // Left Option
|
||||
case .control:
|
||||
flags.insert(.maskControl)
|
||||
modifierKeyCodes.append(59) // Left Control
|
||||
case .fn:
|
||||
flags.insert(.maskSecondaryFn)
|
||||
// Fn key doesn't need explicit key down/up
|
||||
}
|
||||
}
|
||||
|
||||
// Press modifiers down
|
||||
for keyCode in modifierKeyCodes {
|
||||
let modDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
|
||||
modDown?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
// Press main key if present
|
||||
if let key = command.key {
|
||||
let keyCode = Sauce.shared.keyCode(for: key)
|
||||
|
||||
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
|
||||
keyDown?.flags = flags
|
||||
keyDown?.post(tap: .cghidEventTap)
|
||||
|
||||
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
||||
keyUp?.flags = flags
|
||||
keyUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
// Release modifiers in reverse order
|
||||
for keyCode in modifierKeyCodes.reversed() {
|
||||
let modUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
||||
modUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
pasteboardLogger.debug("Sent keyboard command: \(command.displayName)")
|
||||
}
|
||||
|
||||
/// Pastes current clipboard content to the frontmost application
|
||||
static func pasteToFrontmostApp() -> Bool {
|
||||
let script = """
|
||||
if application "System Events" is not running then
|
||||
tell application "System Events" to launch
|
||||
delay 0.1
|
||||
end if
|
||||
tell application "System Events"
|
||||
tell process (name of first application process whose frontmost is true)
|
||||
tell (menu item "Paste" of menu of menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1)
|
||||
if exists then
|
||||
log (get properties of it)
|
||||
if enabled then
|
||||
click it
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end if
|
||||
end if
|
||||
end tell
|
||||
tell (menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1)
|
||||
if exists then
|
||||
if enabled then
|
||||
click it
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end if
|
||||
else
|
||||
return false
|
||||
end if
|
||||
end tell
|
||||
end tell
|
||||
end tell
|
||||
"""
|
||||
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: script) {
|
||||
let result = scriptObject.executeAndReturnError(&error)
|
||||
if let error = error {
|
||||
pasteboardLogger.error("AppleScript paste failed: \(error)")
|
||||
return false
|
||||
}
|
||||
return result.booleanValue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func pasteWithClipboard(_ text: String) async {
|
||||
let pasteboard = NSPasteboard.general
|
||||
let snapshot = PasteboardSnapshot(pasteboard: pasteboard)
|
||||
let targetChangeCount = writeAndTrackChangeCount(pasteboard: pasteboard, text: text)
|
||||
_ = await waitForPasteboardCommit(targetChangeCount: targetChangeCount)
|
||||
let pasteSucceeded = await performPaste(text)
|
||||
|
||||
// Only restore original pasteboard contents if:
|
||||
// 1. Copying to clipboard is disabled AND
|
||||
// 2. The paste operation succeeded
|
||||
if !hexSettings.copyToClipboard && pasteSucceeded {
|
||||
let savedSnapshot = snapshot
|
||||
Task { @MainActor in
|
||||
// Give slower apps a short window to read the plain-text entry
|
||||
// before we repopulate the clipboard with the user's previous rich data.
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
pasteboard.clearContents()
|
||||
savedSnapshot.restore(to: pasteboard)
|
||||
}
|
||||
}
|
||||
|
||||
// If we failed to paste AND user doesn't want clipboard retention,
|
||||
// show a notification that text is available in clipboard
|
||||
if !pasteSucceeded && !hexSettings.copyToClipboard {
|
||||
// Keep the transcribed text in clipboard regardless of setting
|
||||
pasteboardLogger.notice("Paste operation failed; text remains in clipboard as fallback.")
|
||||
|
||||
// TODO: Could add a notification here to inform user
|
||||
// that text is available in clipboard
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func writeAndTrackChangeCount(pasteboard: NSPasteboard, text: String) -> Int {
|
||||
let before = pasteboard.changeCount
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
let after = pasteboard.changeCount
|
||||
if after == before {
|
||||
// Ensure we always advance by at least one to avoid infinite waits if the system
|
||||
// coalesces writes (seen on Sonoma betas with zero-length strings).
|
||||
return after + 1
|
||||
}
|
||||
return after
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForPasteboardCommit(
|
||||
targetChangeCount: Int,
|
||||
timeout: Duration = .milliseconds(150),
|
||||
pollInterval: Duration = .milliseconds(5)
|
||||
) async -> Bool {
|
||||
guard targetChangeCount > NSPasteboard.general.changeCount else { return true }
|
||||
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while ContinuousClock.now < deadline {
|
||||
if NSPasteboard.general.changeCount >= targetChangeCount {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(for: pollInterval)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Paste Orchestration
|
||||
|
||||
@MainActor
|
||||
private enum PasteStrategy: CaseIterable {
|
||||
case cmdV
|
||||
case menuItem
|
||||
case accessibility
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func performPaste(_ text: String) async -> Bool {
|
||||
for strategy in PasteStrategy.allCases {
|
||||
if await attemptPaste(text, using: strategy) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func attemptPaste(_ text: String, using strategy: PasteStrategy) async -> Bool {
|
||||
switch strategy {
|
||||
case .cmdV:
|
||||
return await postCmdV(delayMs: 0)
|
||||
case .menuItem:
|
||||
return PasteboardClientLive.pasteToFrontmostApp()
|
||||
case .accessibility:
|
||||
return (try? Self.insertTextAtCursor(text)) != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func postCmdV(delayMs: Int) async -> Bool {
|
||||
// Optional tiny wait before keystrokes
|
||||
try? await wait(milliseconds: delayMs)
|
||||
let source = CGEventSource(stateID: .combinedSessionState)
|
||||
let vKey = vKeyCode()
|
||||
let cmdKey: CGKeyCode = 55
|
||||
let cmdDown = CGEvent(keyboardEventSource: source, virtualKey: cmdKey, keyDown: true)
|
||||
let vDown = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true)
|
||||
vDown?.flags = .maskCommand
|
||||
let vUp = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false)
|
||||
vUp?.flags = .maskCommand
|
||||
let cmdUp = CGEvent(keyboardEventSource: source, virtualKey: cmdKey, keyDown: false)
|
||||
cmdDown?.post(tap: .cghidEventTap)
|
||||
vDown?.post(tap: .cghidEventTap)
|
||||
vUp?.post(tap: .cghidEventTap)
|
||||
cmdUp?.post(tap: .cghidEventTap)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func vKeyCode() -> CGKeyCode {
|
||||
if Thread.isMainThread { return Sauce.shared.keyCode(for: .v) }
|
||||
return DispatchQueue.main.sync { Sauce.shared.keyCode(for: .v) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func wait(milliseconds: Int) async throws {
|
||||
try Task.checkCancellation()
|
||||
try await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000)
|
||||
}
|
||||
|
||||
func simulateTypingWithAppleScript(_ text: String) {
|
||||
let escapedText = text.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
let script = NSAppleScript(source: "tell application \"System Events\" to keystroke \"\(escapedText)\"")
|
||||
var error: NSDictionary?
|
||||
script?.executeAndReturnError(&error)
|
||||
if let error = error {
|
||||
pasteboardLogger.error("Error executing AppleScript typing fallback: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
enum PasteError: Error {
|
||||
case systemWideElementCreationFailed
|
||||
case focusedElementNotFound
|
||||
case elementDoesNotSupportTextEditing
|
||||
case failedToInsertText
|
||||
}
|
||||
|
||||
static func insertTextAtCursor(_ text: String) throws {
|
||||
// Get the system-wide accessibility element
|
||||
let systemWideElement = AXUIElementCreateSystemWide()
|
||||
|
||||
// Get the focused element
|
||||
var focusedElementRef: CFTypeRef?
|
||||
let axError = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedElementRef)
|
||||
|
||||
guard axError == .success, let focusedElementRef = focusedElementRef else {
|
||||
throw PasteError.focusedElementNotFound
|
||||
}
|
||||
|
||||
let focusedElement = focusedElementRef as! AXUIElement
|
||||
|
||||
// Verify if the focused element supports text insertion
|
||||
var value: CFTypeRef?
|
||||
let supportsText = AXUIElementCopyAttributeValue(focusedElement, kAXValueAttribute as CFString, &value) == .success
|
||||
let supportsSelectedText = AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, &value) == .success
|
||||
|
||||
if !supportsText && !supportsSelectedText {
|
||||
throw PasteError.elementDoesNotSupportTextEditing
|
||||
}
|
||||
|
||||
// Insert text at cursor position by replacing selected text (or empty selection)
|
||||
let insertResult = AXUIElementSetAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, text as CFTypeRef)
|
||||
|
||||
if insertResult != .success {
|
||||
throw PasteError.failedToInsertText
|
||||
}
|
||||
}
|
||||
}
|
||||
+1701
File diff suppressed because it is too large
Load Diff
Executable
+196
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// SoundEffect.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/26/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import SwiftUI
|
||||
|
||||
// Thank you. Never mind then.What a beautiful idea.
|
||||
public enum SoundEffect: String, CaseIterable {
|
||||
case pasteTranscript
|
||||
case startRecording
|
||||
case stopRecording
|
||||
case cancel
|
||||
|
||||
public var fileName: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var fileExtension: String {
|
||||
"mp3"
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct SoundEffectsClient {
|
||||
public var play: @Sendable (SoundEffect) -> Void
|
||||
public var stop: @Sendable (SoundEffect) -> Void
|
||||
public var stopAll: @Sendable () -> Void
|
||||
public var preloadSounds: @Sendable () async -> Void
|
||||
public var setEnabled: @Sendable (Bool) async -> Void
|
||||
}
|
||||
|
||||
extension SoundEffectsClient: DependencyKey {
|
||||
public static var liveValue: SoundEffectsClient {
|
||||
let live = SoundEffectsClientLive()
|
||||
return SoundEffectsClient(
|
||||
play: { soundEffect in
|
||||
Task { await live.play(soundEffect) }
|
||||
},
|
||||
stop: { soundEffect in
|
||||
Task { await live.stop(soundEffect) }
|
||||
},
|
||||
stopAll: {
|
||||
Task { await live.stopAll() }
|
||||
},
|
||||
preloadSounds: {
|
||||
await live.preloadSounds()
|
||||
},
|
||||
setEnabled: { enabled in
|
||||
await live.setEnabled(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public extension DependencyValues {
|
||||
var soundEffects: SoundEffectsClient {
|
||||
get { self[SoundEffectsClient.self] }
|
||||
set { self[SoundEffectsClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
actor SoundEffectsClientLive {
|
||||
private let logger = HexLog.sound
|
||||
private let baselineVolume = HexSettings.baseSoundEffectsVolume
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
private var playerNodes: [SoundEffect: AVAudioPlayerNode] = [:]
|
||||
private var audioBuffers: [SoundEffect: AVAudioPCMBuffer] = [:]
|
||||
private var idleShutdownTask: Task<Void, Never>?
|
||||
/// Comfortably longer than any sound effect, short enough that an idle Hex doesn't keep
|
||||
/// an output IOProc (and coreaudiod) running around the clock (#209).
|
||||
private static let idleShutdownDelay: Duration = .seconds(10)
|
||||
|
||||
func play(_ soundEffect: SoundEffect) {
|
||||
guard hexSettings.soundEffectsEnabled else { return }
|
||||
guard let player = playerNodes[soundEffect], let buffer = audioBuffers[soundEffect] else {
|
||||
logger.error("Requested sound \(soundEffect.rawValue) not preloaded")
|
||||
return
|
||||
}
|
||||
prepareEngineIfNeeded()
|
||||
let clampedVolume = min(max(hexSettings.soundEffectsVolume, 0), baselineVolume)
|
||||
player.volume = Float(clampedVolume)
|
||||
player.stop()
|
||||
player.scheduleBuffer(buffer, at: nil, options: [], completionHandler: nil)
|
||||
player.play()
|
||||
scheduleIdleShutdown()
|
||||
}
|
||||
|
||||
/// Stops the output engine shortly after playback so it doesn't run while idle.
|
||||
/// Restarting it on the next play costs only a few milliseconds.
|
||||
private func scheduleIdleShutdown() {
|
||||
idleShutdownTask?.cancel()
|
||||
idleShutdownTask = Task {
|
||||
try? await Task.sleep(for: Self.idleShutdownDelay)
|
||||
guard !Task.isCancelled else { return }
|
||||
stopEngineIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func stop(_ soundEffect: SoundEffect) {
|
||||
playerNodes[soundEffect]?.stop()
|
||||
}
|
||||
|
||||
func stopAll() {
|
||||
playerNodes.values.forEach { $0.stop() }
|
||||
}
|
||||
|
||||
func preloadSounds() async {
|
||||
guard !isSetup else { return }
|
||||
|
||||
for soundEffect in SoundEffect.allCases {
|
||||
loadSound(soundEffect)
|
||||
}
|
||||
|
||||
isSetup = true
|
||||
}
|
||||
|
||||
func setEnabled(_: Bool) async {
|
||||
await preloadSounds()
|
||||
|
||||
// No prewarm on enable: play() starts the engine lazily, and an idle prewarm would
|
||||
// just be shut down again by the idle timer.
|
||||
if !hexSettings.soundEffectsEnabled {
|
||||
stopAll()
|
||||
idleShutdownTask?.cancel()
|
||||
stopEngineIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var isSetup = false
|
||||
|
||||
private func loadSound(_ soundEffect: SoundEffect) {
|
||||
guard let url = Bundle.main.url(
|
||||
forResource: soundEffect.fileName,
|
||||
withExtension: soundEffect.fileExtension
|
||||
) else {
|
||||
logger.error("Missing sound resource \(soundEffect.fileName).\(soundEffect.fileExtension)")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let file = try AVAudioFile(forReading: url)
|
||||
let frameCount = AVAudioFrameCount(file.length)
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else {
|
||||
logger.error("Failed to allocate buffer for \(soundEffect.rawValue)")
|
||||
return
|
||||
}
|
||||
try file.read(into: buffer)
|
||||
audioBuffers[soundEffect] = buffer
|
||||
|
||||
let player = AVAudioPlayerNode()
|
||||
engine.attach(player)
|
||||
engine.connect(player, to: engine.mainMixerNode, format: buffer.format)
|
||||
playerNodes[soundEffect] = player
|
||||
} catch {
|
||||
logger.error("Failed to load sound \(soundEffect.rawValue): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareEngineIfNeeded() {
|
||||
guard !engine.isRunning else { return }
|
||||
engine.prepare()
|
||||
if #available(macOS 13.0, *) {
|
||||
engine.isAutoShutdownEnabled = false
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
logger.error("Failed to start AVAudioEngine: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEngineIfNeeded() {
|
||||
guard engine.isRunning else { return }
|
||||
engine.stop()
|
||||
logger.debug("Sound effects engine stopped")
|
||||
}
|
||||
|
||||
deinit {
|
||||
playerNodes.values.forEach {
|
||||
$0.stop()
|
||||
engine.detach($0)
|
||||
}
|
||||
engine.stop()
|
||||
}
|
||||
}
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import HexCore
|
||||
|
||||
private final class FloatRingBuffer {
|
||||
private let lock = NSLock()
|
||||
private var buffer: [Float]
|
||||
private var writeIndex = 0
|
||||
private var validSampleCount = 0
|
||||
|
||||
init(capacity: Int) {
|
||||
buffer = Array(repeating: 0, count: max(1, capacity))
|
||||
}
|
||||
|
||||
func append(_ samples: UnsafeBufferPointer<Float>) {
|
||||
guard !samples.isEmpty else { return }
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
for sample in samples {
|
||||
buffer[writeIndex] = sample
|
||||
writeIndex = (writeIndex + 1) % buffer.count
|
||||
}
|
||||
|
||||
validSampleCount = min(buffer.count, validSampleCount + samples.count)
|
||||
}
|
||||
|
||||
func recentSamples(count requestedCount: Int) -> [Float] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let sampleCount = min(max(0, requestedCount), validSampleCount)
|
||||
guard sampleCount > 0 else { return [] }
|
||||
|
||||
let startIndex = (writeIndex - sampleCount + buffer.count) % buffer.count
|
||||
if startIndex + sampleCount <= buffer.count {
|
||||
return Array(buffer[startIndex ..< startIndex + sampleCount])
|
||||
}
|
||||
|
||||
let firstChunk = Array(buffer[startIndex ..< buffer.count])
|
||||
let secondChunk = Array(buffer[0 ..< (sampleCount - firstChunk.count)])
|
||||
return firstChunk + secondChunk
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
writeIndex = 0
|
||||
validSampleCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
private struct SuperFastCaptureConstants {
|
||||
static let sampleRate: Double = 16_000
|
||||
static let ringBufferDuration: TimeInterval = 1.0
|
||||
static let defaultPreRollDuration: TimeInterval = 0.45
|
||||
static let tapBufferSize: AVAudioFrameCount = 2_048
|
||||
static let fallbackStopGracePeriod: TimeInterval = 0.05
|
||||
static let minimumStopGracePeriod: TimeInterval = 0.02
|
||||
static let maximumStopGracePeriod: TimeInterval = 0.08
|
||||
static let stopGraceSafetyMargin: TimeInterval = 0.008
|
||||
static let callbackTimingWindowSize = 8
|
||||
}
|
||||
|
||||
enum CaptureRecordingMode: String {
|
||||
case standard = "standard"
|
||||
case superFast = "super-fast"
|
||||
|
||||
var preRollDuration: TimeInterval {
|
||||
switch self {
|
||||
case .standard:
|
||||
0
|
||||
case .superFast:
|
||||
SuperFastCaptureConstants.defaultPreRollDuration
|
||||
}
|
||||
}
|
||||
|
||||
var keepsWarmBuffer: Bool {
|
||||
self == .superFast
|
||||
}
|
||||
}
|
||||
|
||||
final class SuperFastCaptureController {
|
||||
enum FinishRecordingResult {
|
||||
case captured(URL)
|
||||
case failed(RecordingFailure)
|
||||
case idle
|
||||
}
|
||||
|
||||
struct StopTimingEstimate {
|
||||
let gracePeriod: TimeInterval
|
||||
let callbackInterval: TimeInterval
|
||||
let bufferDuration: TimeInterval
|
||||
}
|
||||
|
||||
private struct ActiveRecording {
|
||||
let url: URL
|
||||
let file: AVAudioFile
|
||||
let requestedAt: Date
|
||||
let prependedDuration: TimeInterval
|
||||
var didLogFirstBuffer: Bool
|
||||
}
|
||||
|
||||
private let logger = HexLog.recording
|
||||
private let processingQueue = DispatchQueue(label: "com.kitlangton.Hex.SuperFastCapture")
|
||||
private let meterContinuation: AsyncStream<Meter>.Continuation
|
||||
private let ringBuffer = FloatRingBuffer(
|
||||
capacity: Int(SuperFastCaptureConstants.sampleRate * SuperFastCaptureConstants.ringBufferDuration)
|
||||
)
|
||||
private let targetFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: SuperFastCaptureConstants.sampleRate,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
|
||||
private var engine: AVAudioEngine?
|
||||
private var converter: AVAudioConverter?
|
||||
private var configurationChangeObserver: NSObjectProtocol?
|
||||
private var activeRecording: ActiveRecording?
|
||||
private var captureGeneration = 0
|
||||
private var recordingFailure: RecordingFailure?
|
||||
private var keepWarmBuffer = false
|
||||
private var lastProcessedBufferAt: Date?
|
||||
private var recentCallbackIntervals: [TimeInterval] = []
|
||||
private var recentBufferDurations: [TimeInterval] = []
|
||||
private let onEngineConfigurationChange: @Sendable (Int) -> Void
|
||||
|
||||
init(
|
||||
meterContinuation: AsyncStream<Meter>.Continuation,
|
||||
onEngineConfigurationChange: @escaping @Sendable (Int) -> Void
|
||||
) {
|
||||
self.meterContinuation = meterContinuation
|
||||
self.onEngineConfigurationChange = onEngineConfigurationChange
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
|
||||
var isRunning: Bool {
|
||||
engine?.isRunning == true
|
||||
}
|
||||
|
||||
var isRecording: Bool {
|
||||
processingQueue.sync { activeRecording != nil }
|
||||
}
|
||||
|
||||
var stopTimingEstimate: StopTimingEstimate {
|
||||
processingQueue.sync {
|
||||
let callbackInterval = recentCallbackIntervals.max() ?? 0
|
||||
let bufferDuration = recentBufferDurations.max() ?? 0
|
||||
let observedCadence = max(callbackInterval, bufferDuration)
|
||||
let gracePeriod = min(
|
||||
max(
|
||||
observedCadence > 0
|
||||
? observedCadence + SuperFastCaptureConstants.stopGraceSafetyMargin
|
||||
: SuperFastCaptureConstants.fallbackStopGracePeriod,
|
||||
SuperFastCaptureConstants.minimumStopGracePeriod
|
||||
),
|
||||
SuperFastCaptureConstants.maximumStopGracePeriod
|
||||
)
|
||||
return StopTimingEstimate(
|
||||
gracePeriod: gracePeriod,
|
||||
callbackInterval: callbackInterval,
|
||||
bufferDuration: bufferDuration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func startIfNeeded(reason: String = "unknown", keepWarmBuffer: Bool = false) throws {
|
||||
processingQueue.sync {
|
||||
let didDisableWarmBuffer = self.keepWarmBuffer && !keepWarmBuffer
|
||||
self.keepWarmBuffer = keepWarmBuffer
|
||||
if didDisableWarmBuffer, activeRecording == nil {
|
||||
ringBuffer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
if engine?.isRunning == true {
|
||||
logger.debug("Capture engine already armed reason=\(reason)")
|
||||
return
|
||||
}
|
||||
|
||||
stop(reason: "restart-before-arm")
|
||||
try armEngine(reason: reason)
|
||||
}
|
||||
|
||||
/// Tears down and recreates the engine while keeping the active recording file open, so
|
||||
/// capture resumes onto the same file after a device/route change mid-recording
|
||||
/// (#251, #252, #218, #226). The ring buffer, timing metrics, and active recording survive;
|
||||
/// only the engine, tap, and converter are rebuilt.
|
||||
func restartPreservingRecording(reason: String) throws {
|
||||
logger.notice("Restarting capture engine preserving active recording reason=\(reason)")
|
||||
detachEngine()
|
||||
try armEngine(reason: reason)
|
||||
}
|
||||
|
||||
private func armEngine(reason: String) throws {
|
||||
let engine = AVAudioEngine()
|
||||
let inputNode = engine.inputNode
|
||||
let inputFormat = inputNode.inputFormat(forBus: 0)
|
||||
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
|
||||
throw NSError(
|
||||
domain: "SuperFastCapture",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to create the capture engine audio converter."]
|
||||
)
|
||||
}
|
||||
if inputFormat.channelCount > 1 {
|
||||
converter.channelMap = [NSNumber(value: 0)]
|
||||
}
|
||||
|
||||
let generation = processingQueue.sync {
|
||||
captureGeneration += 1
|
||||
self.converter = converter
|
||||
recordingFailure = nil
|
||||
return captureGeneration
|
||||
}
|
||||
|
||||
inputNode.installTap(onBus: 0, bufferSize: SuperFastCaptureConstants.tapBufferSize, format: inputFormat) {
|
||||
[weak self] buffer, _ in
|
||||
self?.enqueue(buffer, generation: generation)
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
inputNode.removeTap(onBus: 0)
|
||||
processingQueue.sync {
|
||||
captureGeneration += 1
|
||||
self.converter = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
self.engine = engine
|
||||
configurationChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange,
|
||||
object: engine,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.handleConfigurationChange(generation: generation)
|
||||
}
|
||||
logger.notice(
|
||||
"Capture engine armed reason=\(reason) sampleRate=\(String(format: "%.0f", inputFormat.sampleRate))Hz channels=\(inputFormat.channelCount) ringBuffer=\(String(format: "%.2f", SuperFastCaptureConstants.ringBufferDuration))s defaultPreRoll=\(String(format: "%.2f", SuperFastCaptureConstants.defaultPreRollDuration))s"
|
||||
)
|
||||
}
|
||||
|
||||
func stop(reason: String = "unknown") {
|
||||
if engine != nil {
|
||||
logger.notice("Capture engine stopped reason=\(reason)")
|
||||
}
|
||||
detachEngine(clearingRecordingState: true)
|
||||
}
|
||||
|
||||
/// Removes the tap, observer, converter, and engine. Bumps the capture generation so
|
||||
/// in-flight tap callbacks from the old engine are ignored. Recording state (active file,
|
||||
/// ring buffer, timing metrics) is preserved unless `clearingRecordingState` is set, which
|
||||
/// is what lets restartPreservingRecording resume capture onto the same file.
|
||||
private func detachEngine(clearingRecordingState: Bool = false) {
|
||||
if let inputNode = engine?.inputNode {
|
||||
inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
if let configurationChangeObserver {
|
||||
NotificationCenter.default.removeObserver(configurationChangeObserver)
|
||||
self.configurationChangeObserver = nil
|
||||
}
|
||||
processingQueue.sync {
|
||||
captureGeneration += 1
|
||||
converter = nil
|
||||
if clearingRecordingState {
|
||||
activeRecording = nil
|
||||
recordingFailure = nil
|
||||
ringBuffer.clear()
|
||||
lastProcessedBufferAt = nil
|
||||
recentCallbackIntervals.removeAll(keepingCapacity: false)
|
||||
recentBufferDurations.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
engine?.stop()
|
||||
engine = nil
|
||||
}
|
||||
|
||||
private func handleConfigurationChange(generation: Int) {
|
||||
guard processingQueue.sync(execute: { Self.shouldProcessCallback(callbackGeneration: generation, currentGeneration: captureGeneration) }) else {
|
||||
return
|
||||
}
|
||||
logger.notice("Capture engine configuration changed")
|
||||
onEngineConfigurationChange(generation)
|
||||
}
|
||||
|
||||
static func shouldProcessCallback(callbackGeneration: Int, currentGeneration: Int) -> Bool {
|
||||
callbackGeneration == currentGeneration
|
||||
}
|
||||
|
||||
func isCurrentGeneration(_ generation: Int) -> Bool {
|
||||
processingQueue.sync { generation == captureGeneration }
|
||||
}
|
||||
|
||||
func beginRecording(to url: URL, requestedAt: Date = Date(), mode: CaptureRecordingMode) throws {
|
||||
try startIfNeeded(reason: "begin-recording", keepWarmBuffer: mode.keepsWarmBuffer)
|
||||
|
||||
var startError: Error?
|
||||
processingQueue.sync {
|
||||
do {
|
||||
recordingFailure = nil
|
||||
let file = try AVAudioFile(
|
||||
forWriting: url,
|
||||
settings: [
|
||||
AVFormatIDKey: Int(kAudioFormatLinearPCM),
|
||||
AVSampleRateKey: SuperFastCaptureConstants.sampleRate,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVLinearPCMBitDepthKey: 32,
|
||||
AVLinearPCMIsFloatKey: true,
|
||||
AVLinearPCMIsBigEndianKey: false,
|
||||
AVLinearPCMIsNonInterleaved: true,
|
||||
],
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
interleaved: false
|
||||
)
|
||||
|
||||
let preRollDuration = mode.preRollDuration
|
||||
let preRollFrameCount = Int(preRollDuration * SuperFastCaptureConstants.sampleRate)
|
||||
let preRollSamples = ringBuffer.recentSamples(count: preRollFrameCount)
|
||||
let prependedDuration = Double(preRollSamples.count) / SuperFastCaptureConstants.sampleRate
|
||||
if !preRollSamples.isEmpty {
|
||||
try write(samples: preRollSamples, to: file)
|
||||
}
|
||||
|
||||
logger.notice(
|
||||
"Capture engine recording file opened prepended=\(String(format: "%.3f", prependedDuration))s requestedPreRoll=\(String(format: "%.3f", preRollDuration))s"
|
||||
)
|
||||
activeRecording = ActiveRecording(
|
||||
url: url,
|
||||
file: file,
|
||||
requestedAt: requestedAt,
|
||||
prependedDuration: prependedDuration,
|
||||
didLogFirstBuffer: false
|
||||
)
|
||||
} catch {
|
||||
startError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let startError {
|
||||
throw startError
|
||||
}
|
||||
}
|
||||
|
||||
func finishRecording(clearBuffer: Bool = true) -> FinishRecordingResult {
|
||||
processingQueue.sync {
|
||||
let result: FinishRecordingResult
|
||||
if let recordingFailure {
|
||||
result = .failed(recordingFailure)
|
||||
} else if let url = activeRecording?.url {
|
||||
result = .captured(url)
|
||||
} else {
|
||||
result = .idle
|
||||
}
|
||||
activeRecording = nil
|
||||
recordingFailure = nil
|
||||
if clearBuffer {
|
||||
ringBuffer.clear()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ buffer: AVAudioPCMBuffer, generation: Int) {
|
||||
guard let copy = clone(buffer) else { return }
|
||||
processingQueue.async { [weak self] in
|
||||
self?.process(copy, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func process(_ buffer: AVAudioPCMBuffer, generation: Int) {
|
||||
guard Self.shouldProcessCallback(callbackGeneration: generation, currentGeneration: captureGeneration) else {
|
||||
return
|
||||
}
|
||||
let now = Date()
|
||||
if let lastProcessedBufferAt {
|
||||
appendRecentMetric(now.timeIntervalSince(lastProcessedBufferAt), to: &recentCallbackIntervals)
|
||||
}
|
||||
lastProcessedBufferAt = now
|
||||
appendRecentMetric(Double(buffer.frameLength) / buffer.format.sampleRate, to: &recentBufferDurations)
|
||||
|
||||
guard let converted = convert(buffer),
|
||||
converted.frameLength > 0,
|
||||
let samples = converted.floatChannelData?[0]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let sampleCount = Int(converted.frameLength)
|
||||
if keepWarmBuffer, activeRecording == nil {
|
||||
ringBuffer.append(UnsafeBufferPointer(start: samples, count: sampleCount))
|
||||
}
|
||||
|
||||
if activeRecording != nil {
|
||||
meterContinuation.yield(meter(for: samples, count: sampleCount))
|
||||
}
|
||||
|
||||
guard var recording = activeRecording else { return }
|
||||
if !recording.didLogFirstBuffer {
|
||||
let timeToFirstBuffer = Date().timeIntervalSince(recording.requestedAt)
|
||||
logger.notice(
|
||||
"Capture engine first buffer latency=\(String(format: "%.3f", timeToFirstBuffer))s prepended=\(String(format: "%.3f", recording.prependedDuration))s frames=\(sampleCount)"
|
||||
)
|
||||
recording.didLogFirstBuffer = true
|
||||
activeRecording = recording
|
||||
}
|
||||
|
||||
do {
|
||||
try recording.file.write(from: converted)
|
||||
} catch {
|
||||
logger.error("Failed to write capture engine audio: \(error.localizedDescription)")
|
||||
activeRecording = nil
|
||||
recordingFailure = .captureWriteFailed(error.localizedDescription)
|
||||
FileManager.default.removeItemIfExists(at: recording.url)
|
||||
}
|
||||
}
|
||||
|
||||
private func convert(_ inputBuffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
guard let converter else { return nil }
|
||||
|
||||
let sampleRateRatio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
||||
let frameCapacity = AVAudioFrameCount(
|
||||
max(1, (Double(inputBuffer.frameLength) * sampleRateRatio).rounded(.up) + 32)
|
||||
)
|
||||
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCapacity) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var error: NSError?
|
||||
var consumedInput = false
|
||||
let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in
|
||||
if consumedInput {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
|
||||
consumedInput = true
|
||||
outStatus.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
|
||||
if let error {
|
||||
logger.error("Failed to convert capture engine audio: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .haveData, .inputRanDry, .endOfStream:
|
||||
return outputBuffer.frameLength > 0 ? outputBuffer : nil
|
||||
case .error:
|
||||
return nil
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func write(samples: [Float], to file: AVAudioFile) throws {
|
||||
guard !samples.isEmpty,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(samples.count)),
|
||||
let channelData = buffer.floatChannelData?[0]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
buffer.frameLength = AVAudioFrameCount(samples.count)
|
||||
samples.withUnsafeBufferPointer { sampleBuffer in
|
||||
guard let baseAddress = sampleBuffer.baseAddress else { return }
|
||||
channelData.update(from: baseAddress, count: sampleBuffer.count)
|
||||
}
|
||||
try file.write(from: buffer)
|
||||
}
|
||||
|
||||
private func meter(for samples: UnsafePointer<Float>, count: Int) -> Meter {
|
||||
guard count > 0 else {
|
||||
return Meter(averagePower: 0, peakPower: 0)
|
||||
}
|
||||
|
||||
var sumOfSquares: Float = 0
|
||||
var peak: Float = 0
|
||||
for index in 0 ..< count {
|
||||
let sample = samples[index]
|
||||
let magnitude = abs(sample)
|
||||
sumOfSquares += sample * sample
|
||||
peak = max(peak, magnitude)
|
||||
}
|
||||
|
||||
let rms = sqrt(sumOfSquares / Float(count))
|
||||
return Meter(averagePower: Double(rms), peakPower: Double(peak))
|
||||
}
|
||||
|
||||
private func clone(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
guard let copy = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: buffer.frameLength) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
copy.frameLength = buffer.frameLength
|
||||
|
||||
let sourceBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList)
|
||||
let destinationBuffers = UnsafeMutableAudioBufferListPointer(copy.mutableAudioBufferList)
|
||||
for index in sourceBuffers.indices {
|
||||
let source = sourceBuffers[index]
|
||||
let destination = destinationBuffers[index]
|
||||
guard let sourceData = source.mData, let destinationData = destination.mData else { continue }
|
||||
memcpy(destinationData, sourceData, Int(source.mDataByteSize))
|
||||
destinationBuffers[index].mDataByteSize = source.mDataByteSize
|
||||
}
|
||||
|
||||
return copy
|
||||
}
|
||||
|
||||
private func appendRecentMetric(_ value: TimeInterval, to metrics: inout [TimeInterval]) {
|
||||
guard value.isFinite, value > 0 else { return }
|
||||
metrics.append(value)
|
||||
if metrics.count > SuperFastCaptureConstants.callbackTimingWindowSize {
|
||||
metrics.removeFirst(metrics.count - SuperFastCaptureConstants.callbackTimingWindowSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
//
|
||||
// TranscriptionClient.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import WhisperKit
|
||||
|
||||
private let transcriptionLogger = HexLog.transcription
|
||||
private let modelsLogger = HexLog.models
|
||||
private let parakeetLogger = HexLog.parakeet
|
||||
|
||||
/// A client that downloads and loads WhisperKit models, then transcribes audio files using the loaded model.
|
||||
/// Exposes progress callbacks to report overall download-and-load percentage and transcription progress.
|
||||
@DependencyClient
|
||||
struct TranscriptionClient {
|
||||
/// Transcribes an audio file at the specified `URL` using the named `model`.
|
||||
/// Reports transcription progress via `progressCallback`.
|
||||
var transcribe: @Sendable (URL, String, DecodingOptions, @escaping (Progress) -> Void) async throws -> String
|
||||
|
||||
/// Ensures a model is downloaded (if missing) and loaded into memory, reporting progress via `progressCallback`.
|
||||
var downloadModel: @Sendable (String, @escaping (Progress) -> Void) async throws -> Void
|
||||
|
||||
/// Deletes a model from disk if it exists
|
||||
var deleteModel: @Sendable (String) async throws -> Void
|
||||
|
||||
/// Checks if a named model is already downloaded on this system.
|
||||
var isModelDownloaded: @Sendable (String) async -> Bool = { _ in false }
|
||||
|
||||
/// Fetches a recommended set of models for the user's hardware from Hugging Face's `argmaxinc/whisperkit-coreml`.
|
||||
var getRecommendedModels: @Sendable () async throws -> ModelSupport
|
||||
|
||||
/// Lists all model variants found in `argmaxinc/whisperkit-coreml`.
|
||||
var getAvailableModels: @Sendable () async throws -> [String]
|
||||
}
|
||||
|
||||
extension TranscriptionClient: DependencyKey {
|
||||
static var liveValue: Self {
|
||||
let live = TranscriptionClientLive()
|
||||
return Self(
|
||||
transcribe: { try await live.transcribe(url: $0, model: $1, options: $2, progressCallback: $3) },
|
||||
downloadModel: { try await live.downloadAndLoadModel(variant: $0, progressCallback: $1) },
|
||||
deleteModel: { try await live.deleteModel(variant: $0) },
|
||||
isModelDownloaded: { await live.isModelDownloaded($0) },
|
||||
getRecommendedModels: { await live.getRecommendedModels() },
|
||||
getAvailableModels: { try await live.getAvailableModels() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var transcription: TranscriptionClient {
|
||||
get { self[TranscriptionClient.self] }
|
||||
set { self[TranscriptionClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// An `actor` that manages WhisperKit models by downloading (from Hugging Face),
|
||||
// loading them into memory, and then performing transcriptions.
|
||||
|
||||
actor TranscriptionClientLive {
|
||||
// MARK: - Stored Properties
|
||||
|
||||
/// The current in-memory `WhisperKit` instance, if any.
|
||||
private var whisperKit: WhisperKit?
|
||||
|
||||
/// The name of the currently loaded model, if any.
|
||||
private var currentModelName: String?
|
||||
private var parakeet: ParakeetClient = ParakeetClient()
|
||||
|
||||
/// The base folder under which we store model data (e.g., ~/Library/Application Support/...).
|
||||
private lazy var modelsBaseFolder: URL = {
|
||||
do {
|
||||
return try URL.hexModelsDirectory
|
||||
} catch {
|
||||
fatalError("Could not create Application Support folder: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Ensures the given `variant` model is downloaded and loaded, reporting
|
||||
/// overall progress (0%–50% for downloading, 50%–100% for loading).
|
||||
func downloadAndLoadModel(variant: String, progressCallback: @escaping (Progress) -> Void) async throws {
|
||||
// If Parakeet, use Parakeet client path
|
||||
if isParakeet(variant) {
|
||||
try await parakeet.ensureLoaded(modelName: variant, progress: progressCallback)
|
||||
currentModelName = variant
|
||||
return
|
||||
}
|
||||
// Resolve wildcard patterns (e.g., "distil*large-v3") to a concrete variant
|
||||
let variant = await resolveVariant(variant)
|
||||
// Special handling for corrupted or malformed variant names
|
||||
if variant.isEmpty {
|
||||
throw NSError(
|
||||
domain: "TranscriptionClient",
|
||||
code: -3,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Cannot download model: Empty model name",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
let overallProgress = Progress(totalUnitCount: 100)
|
||||
overallProgress.completedUnitCount = 0
|
||||
progressCallback(overallProgress)
|
||||
|
||||
modelsLogger.info("Preparing model download and load for \(variant)")
|
||||
|
||||
// 1) Model download phase (0-50% progress)
|
||||
if !(await isModelDownloaded(variant)) {
|
||||
try await downloadModelIfNeeded(variant: variant) { downloadProgress in
|
||||
let fraction = downloadProgress.fractionCompleted * 0.5
|
||||
overallProgress.completedUnitCount = Int64(fraction * 100)
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
} else {
|
||||
// Skip download phase if already downloaded
|
||||
overallProgress.completedUnitCount = 50
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
|
||||
// 2) Model loading phase (50-100% progress)
|
||||
try await loadWhisperKitModel(variant) { loadingProgress in
|
||||
let fraction = 0.5 + (loadingProgress.fractionCompleted * 0.5)
|
||||
overallProgress.completedUnitCount = Int64(fraction * 100)
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
overallProgress.completedUnitCount = 100
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
|
||||
/// Deletes a model from disk if it exists
|
||||
func deleteModel(variant: String) async throws {
|
||||
if isParakeet(variant) {
|
||||
try await parakeet.deleteCaches(modelName: variant)
|
||||
if currentModelName == variant { unloadCurrentModel() }
|
||||
return
|
||||
}
|
||||
let modelFolder = modelPath(for: variant)
|
||||
|
||||
// Check if the model exists
|
||||
guard FileManager.default.fileExists(atPath: modelFolder.path) else {
|
||||
// Model doesn't exist, nothing to delete
|
||||
return
|
||||
}
|
||||
|
||||
// If this is the currently loaded model, unload it first
|
||||
if currentModelName == variant {
|
||||
unloadCurrentModel()
|
||||
}
|
||||
|
||||
// Delete the model directory
|
||||
try FileManager.default.removeItem(at: modelFolder)
|
||||
|
||||
modelsLogger.info("Deleted model \(variant)")
|
||||
}
|
||||
|
||||
/// Returns `true` if the model is already downloaded to the local folder.
|
||||
/// Performs a thorough check to ensure the model files are actually present and usable.
|
||||
func isModelDownloaded(_ modelName: String) async -> Bool {
|
||||
if isParakeet(modelName) {
|
||||
let available = await parakeet.isModelAvailable(modelName)
|
||||
parakeetLogger.debug("Parakeet available? \(available)")
|
||||
return available
|
||||
}
|
||||
let modelFolderPath = modelPath(for: modelName).path
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// First, check if the basic model directory exists
|
||||
guard fileManager.fileExists(atPath: modelFolderPath) else {
|
||||
// Don't print logs that would spam the console
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
// Check if the directory has actual model files in it
|
||||
let contents = try fileManager.contentsOfDirectory(atPath: modelFolderPath)
|
||||
|
||||
// Model should have multiple files and certain key components
|
||||
guard !contents.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for specific model structure - need both tokenizer and model files
|
||||
let hasModelFiles = contents.contains { $0.hasSuffix(".mlmodelc") || $0.contains("model") }
|
||||
let tokenizerFolderPath = tokenizerPath(for: modelName).path
|
||||
let hasTokenizer = fileManager.fileExists(atPath: tokenizerFolderPath)
|
||||
|
||||
// Both conditions must be true for a model to be considered downloaded
|
||||
return hasModelFiles && hasTokenizer
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a list of recommended models based on current device hardware.
|
||||
func getRecommendedModels() async -> ModelSupport {
|
||||
await WhisperKit.recommendedRemoteModels()
|
||||
}
|
||||
|
||||
/// Lists all model variants available in the `argmaxinc/whisperkit-coreml` repository.
|
||||
func getAvailableModels() async throws -> [String] {
|
||||
var names = try await WhisperKit.fetchAvailableModels()
|
||||
#if canImport(FluidAudio)
|
||||
for model in ParakeetModel.allCases.reversed() {
|
||||
if !names.contains(model.identifier) { names.insert(model.identifier, at: 0) }
|
||||
}
|
||||
#endif
|
||||
return names
|
||||
}
|
||||
|
||||
/// Transcribes the audio file at `url` using a `model` name.
|
||||
/// If the model is not yet loaded (or if it differs from the current model), it is downloaded and loaded first.
|
||||
/// Transcription progress can be monitored via `progressCallback`.
|
||||
func transcribe(
|
||||
url: URL,
|
||||
model: String,
|
||||
options: DecodingOptions,
|
||||
progressCallback: @escaping (Progress) -> Void
|
||||
) async throws -> String {
|
||||
let startAll = Date()
|
||||
if isParakeet(model) {
|
||||
transcriptionLogger.notice("Transcribing with Parakeet model=\(model) file=\(url.lastPathComponent)")
|
||||
let startLoad = Date()
|
||||
try await downloadAndLoadModel(variant: model) { p in
|
||||
progressCallback(p)
|
||||
}
|
||||
transcriptionLogger.info("Parakeet ensureLoaded took \(String(format: "%.2f", Date().timeIntervalSince(startLoad)))s")
|
||||
let preparedClip = try ParakeetClipPreparer.ensureMinimumDuration(url: url, logger: parakeetLogger)
|
||||
defer { preparedClip.cleanup() }
|
||||
let startTx = Date()
|
||||
let text = try await parakeet.transcribe(preparedClip.url)
|
||||
transcriptionLogger.info("Parakeet transcription took \(String(format: "%.2f", Date().timeIntervalSince(startTx)))s")
|
||||
transcriptionLogger.info("Parakeet request total elapsed \(String(format: "%.2f", Date().timeIntervalSince(startAll)))s")
|
||||
return text
|
||||
}
|
||||
let model = await resolveVariant(model)
|
||||
// Load or switch to the required model if needed.
|
||||
if whisperKit == nil || model != currentModelName {
|
||||
unloadCurrentModel()
|
||||
let startLoad = Date()
|
||||
try await downloadAndLoadModel(variant: model) { p in
|
||||
// Debug logging, or scale as desired:
|
||||
progressCallback(p)
|
||||
}
|
||||
let loadDuration = Date().timeIntervalSince(startLoad)
|
||||
transcriptionLogger.info("WhisperKit ensureLoaded model=\(model) took \(String(format: "%.2f", loadDuration))s")
|
||||
}
|
||||
|
||||
guard let whisperKit = whisperKit else {
|
||||
throw NSError(
|
||||
domain: "TranscriptionClient",
|
||||
code: -1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to initialize WhisperKit for model: \(model)",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// Perform the transcription.
|
||||
transcriptionLogger.notice("Transcribing with WhisperKit model=\(model) file=\(url.lastPathComponent)")
|
||||
let startTx = Date()
|
||||
let results = try await whisperKit.transcribe(audioPath: url.path, decodeOptions: options)
|
||||
transcriptionLogger.info("WhisperKit transcription took \(String(format: "%.2f", Date().timeIntervalSince(startTx)))s")
|
||||
transcriptionLogger.info("WhisperKit request total elapsed \(String(format: "%.2f", Date().timeIntervalSince(startAll)))s")
|
||||
|
||||
// Concatenate results from all segments.
|
||||
let text = results.map(\.text).joined(separator: " ")
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Resolve wildcard patterns (e.g. "distil*large-v3") to a concrete model name.
|
||||
/// Preference: downloaded > non-turbo > any match.
|
||||
private func resolveVariant(_ variant: String) async -> String {
|
||||
guard variant.contains("*") || variant.contains("?") else { return variant }
|
||||
|
||||
let names: [String]
|
||||
do { names = try await WhisperKit.fetchAvailableModels() } catch { return variant }
|
||||
|
||||
// Build tuple array with download status for matching models
|
||||
var models: [(name: String, isDownloaded: Bool)] = []
|
||||
for name in names where ModelPatternMatcher.matches(variant, name) {
|
||||
models.append((name, await isModelDownloaded(name)))
|
||||
}
|
||||
|
||||
return ModelPatternMatcher.resolvePattern(variant, from: models) ?? variant
|
||||
}
|
||||
|
||||
private func isParakeet(_ name: String) -> Bool {
|
||||
ParakeetModel(rawValue: name) != nil
|
||||
}
|
||||
|
||||
/// Creates or returns the local folder (on disk) for a given `variant` model.
|
||||
private func modelPath(for variant: String) -> URL {
|
||||
// Remove any possible path traversal or invalid characters from variant name
|
||||
let sanitizedVariant = variant.components(separatedBy: CharacterSet(charactersIn: "./\\")).joined(separator: "_")
|
||||
|
||||
return modelsBaseFolder
|
||||
.appendingPathComponent("argmaxinc")
|
||||
.appendingPathComponent("whisperkit-coreml")
|
||||
.appendingPathComponent(sanitizedVariant, isDirectory: true)
|
||||
}
|
||||
|
||||
/// Creates or returns the local folder for the tokenizer files of a given `variant`.
|
||||
private func tokenizerPath(for variant: String) -> URL {
|
||||
modelPath(for: variant).appendingPathComponent("tokenizer", isDirectory: true)
|
||||
}
|
||||
|
||||
// Unloads any currently loaded model (clears `whisperKit` and `currentModelName`).
|
||||
private func unloadCurrentModel() {
|
||||
whisperKit = nil
|
||||
currentModelName = nil
|
||||
}
|
||||
|
||||
/// Downloads the model to a temporary folder (if it isn't already on disk),
|
||||
/// then moves it into its final folder in `modelsBaseFolder`.
|
||||
private func downloadModelIfNeeded(
|
||||
variant: String,
|
||||
progressCallback: @escaping (Progress) -> Void
|
||||
) async throws {
|
||||
let modelFolder = modelPath(for: variant)
|
||||
|
||||
// If the model folder exists but isn't a complete model, clean it up
|
||||
let isDownloaded = await isModelDownloaded(variant)
|
||||
if FileManager.default.fileExists(atPath: modelFolder.path), !isDownloaded {
|
||||
try FileManager.default.removeItem(at: modelFolder)
|
||||
}
|
||||
|
||||
// If model is already fully downloaded, we're done
|
||||
if isDownloaded {
|
||||
return
|
||||
}
|
||||
|
||||
modelsLogger.info("Downloading model \(variant)")
|
||||
|
||||
// Create parent directories
|
||||
let parentDir = modelFolder.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
|
||||
|
||||
do {
|
||||
// Download directly using the exact variant name provided
|
||||
// WhisperKit 0.15.0 changed downloader params: passing
|
||||
// "argmaxinc/whisperkit-coreml" to a parameter interpreted as a host
|
||||
// yields NSURLErrorCannotFindHost in production builds that need
|
||||
// to fetch models for the first time. Let WhisperKit use its
|
||||
// default repo/host (Hugging Face) by omitting the repo/host arg.
|
||||
let tempFolder = try await WhisperKit.download(
|
||||
variant: variant,
|
||||
downloadBase: nil,
|
||||
useBackgroundSession: false,
|
||||
progressCallback: { progress in
|
||||
progressCallback(progress)
|
||||
}
|
||||
)
|
||||
|
||||
// Ensure target folder exists
|
||||
try FileManager.default.createDirectory(at: modelFolder, withIntermediateDirectories: true)
|
||||
|
||||
// Move the downloaded snapshot to the final location
|
||||
try moveContents(of: tempFolder, to: modelFolder)
|
||||
|
||||
modelsLogger.info("Downloaded model to \(modelFolder.path)")
|
||||
} catch {
|
||||
// Clean up any partial download if an error occurred
|
||||
FileManager.default.removeItemIfExists(at: modelFolder)
|
||||
|
||||
// Rethrow the original error
|
||||
modelsLogger.error("Error downloading model \(variant): \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a local model folder via `WhisperKitConfig`, optionally reporting load progress.
|
||||
private func loadWhisperKitModel(
|
||||
_ modelName: String,
|
||||
progressCallback: @escaping (Progress) -> Void
|
||||
) async throws {
|
||||
let loadingProgress = Progress(totalUnitCount: 100)
|
||||
loadingProgress.completedUnitCount = 0
|
||||
progressCallback(loadingProgress)
|
||||
|
||||
let modelFolder = modelPath(for: modelName)
|
||||
let tokenizerFolder = tokenizerPath(for: modelName)
|
||||
|
||||
// Use WhisperKit's config to load the model
|
||||
let config = WhisperKitConfig(
|
||||
model: modelName,
|
||||
modelFolder: modelFolder.path,
|
||||
tokenizerFolder: tokenizerFolder,
|
||||
// verbose: true,
|
||||
// logLevel: .debug,
|
||||
prewarm: false,
|
||||
load: true
|
||||
)
|
||||
|
||||
// The initializer automatically calls `loadModels`.
|
||||
whisperKit = try await WhisperKit(config)
|
||||
currentModelName = modelName
|
||||
|
||||
// Finalize load progress
|
||||
loadingProgress.completedUnitCount = 100
|
||||
progressCallback(loadingProgress)
|
||||
|
||||
modelsLogger.info("Loaded WhisperKit model \(modelName)")
|
||||
}
|
||||
|
||||
/// Moves all items from `sourceFolder` into `destFolder` (shallow move of directory contents).
|
||||
private func moveContents(of sourceFolder: URL, to destFolder: URL) throws {
|
||||
let fileManager = FileManager.default
|
||||
let items = try fileManager.contentsOfDirectory(atPath: sourceFolder.path)
|
||||
for item in items {
|
||||
let src = sourceFolder.appendingPathComponent(item)
|
||||
let dst = destFolder.appendingPathComponent(item)
|
||||
try fileManager.moveItem(at: src, to: dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
//
|
||||
// AppFeature.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/26/25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import SwiftUI
|
||||
|
||||
@Reducer
|
||||
struct AppFeature {
|
||||
private enum CancelID {
|
||||
case modelMissingFlash
|
||||
}
|
||||
|
||||
enum ActiveTab: Equatable {
|
||||
case settings
|
||||
case remappings
|
||||
case history
|
||||
case about
|
||||
}
|
||||
|
||||
@ObservableState
|
||||
struct State {
|
||||
var transcription: TranscriptionFeature.State = .init()
|
||||
var settings: SettingsFeature.State = .init()
|
||||
var history: HistoryFeature.State = .init()
|
||||
var activeTab: ActiveTab = .settings
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
|
||||
// Permission state
|
||||
var microphonePermission: PermissionStatus = .notDetermined
|
||||
var accessibilityPermission: PermissionStatus = .notDetermined
|
||||
var inputMonitoringPermission: PermissionStatus = .notDetermined
|
||||
}
|
||||
|
||||
enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
case transcription(TranscriptionFeature.Action)
|
||||
case settings(SettingsFeature.Action)
|
||||
case history(HistoryFeature.Action)
|
||||
case setActiveTab(ActiveTab)
|
||||
case task
|
||||
case pasteLastTranscript
|
||||
|
||||
// Permission actions
|
||||
case checkPermissions
|
||||
case permissionsUpdated(mic: PermissionStatus, acc: PermissionStatus, input: PermissionStatus)
|
||||
case appActivated
|
||||
case modelStatusEvaluated(Bool)
|
||||
}
|
||||
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.permissions) var permissions
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
|
||||
Scope(state: \.transcription, action: \.transcription) {
|
||||
TranscriptionFeature()
|
||||
}
|
||||
|
||||
Scope(state: \.settings, action: \.settings) {
|
||||
SettingsFeature()
|
||||
}
|
||||
|
||||
Scope(state: \.history, action: \.history) {
|
||||
HistoryFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .binding:
|
||||
return .none
|
||||
|
||||
case .task:
|
||||
return .merge(
|
||||
startPasteLastTranscriptMonitoring(),
|
||||
ensureSelectedModelReadiness(),
|
||||
startPermissionMonitoring()
|
||||
)
|
||||
|
||||
case .pasteLastTranscript:
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
guard let lastTranscript = transcriptionHistory.history.first?.text else {
|
||||
return .none
|
||||
}
|
||||
return .run { _ in
|
||||
await pasteboard.paste(lastTranscript)
|
||||
}
|
||||
|
||||
case .transcription(.modelMissing):
|
||||
HexLog.app.notice("Model missing - activating app and switching to settings")
|
||||
state.activeTab = .settings
|
||||
state.settings.shouldFlashModelSection = true
|
||||
return .run { send in
|
||||
await MainActor.run {
|
||||
HexLog.app.notice("Activating app for model missing")
|
||||
NotificationCenter.default.post(name: .presentSettingsWindow, object: nil)
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
await send(.settings(.set(\.shouldFlashModelSection, false)))
|
||||
}
|
||||
.cancellable(id: CancelID.modelMissingFlash, cancelInFlight: true)
|
||||
|
||||
case .transcription:
|
||||
return .none
|
||||
|
||||
case .settings(.requestMicrophone):
|
||||
return .run { send in
|
||||
_ = await permissions.requestMicrophone()
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
|
||||
case .settings(.requestAccessibility):
|
||||
return .run { send in
|
||||
await permissions.requestAccessibility()
|
||||
// Poll for status change (macOS doesn't provide callback)
|
||||
for _ in 0..<10 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
case .settings(.requestInputMonitoring):
|
||||
return .run { send in
|
||||
_ = await permissions.requestInputMonitoring()
|
||||
for _ in 0..<10 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
case .settings:
|
||||
return .none
|
||||
|
||||
case .history(.navigateToSettings):
|
||||
state.activeTab = .settings
|
||||
return .none
|
||||
case .history:
|
||||
return .none
|
||||
case let .setActiveTab(tab):
|
||||
state.activeTab = tab
|
||||
return .none
|
||||
|
||||
// Permission handling
|
||||
case .checkPermissions:
|
||||
return .run { send in
|
||||
async let mic = permissions.microphoneStatus()
|
||||
async let acc = permissions.accessibilityStatus()
|
||||
async let input = permissions.inputMonitoringStatus()
|
||||
await send(.permissionsUpdated(mic: mic, acc: acc, input: input))
|
||||
}
|
||||
|
||||
case let .permissionsUpdated(mic, acc, input):
|
||||
state.microphonePermission = mic
|
||||
state.accessibilityPermission = acc
|
||||
state.inputMonitoringPermission = input
|
||||
return .none
|
||||
|
||||
case .appActivated:
|
||||
// App became active - re-check permissions
|
||||
return .send(.checkPermissions)
|
||||
|
||||
case .modelStatusEvaluated:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPasteLastTranscriptMonitoring() -> Effect<Action> {
|
||||
.run { send in
|
||||
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
let token = keyEventMonitor.handleKeyEvent { keyEvent in
|
||||
// Skip if user is setting a hotkey
|
||||
if isSettingPasteLastTranscriptHotkey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this matches the paste last transcript hotkey
|
||||
guard let pasteHotkey = hexSettings.pasteLastTranscriptHotkey,
|
||||
let key = keyEvent.key,
|
||||
key == pasteHotkey.key,
|
||||
keyEvent.modifiers.matchesExactly(pasteHotkey.modifiers) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trigger paste action - use MainActor to avoid escaping send
|
||||
MainActor.assumeIsolated {
|
||||
send(.pasteLastTranscript)
|
||||
}
|
||||
return true // Intercept the key event
|
||||
}
|
||||
|
||||
defer { token.cancel() }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
} onCancel: {
|
||||
token.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureSelectedModelReadiness() -> Effect<Action> {
|
||||
.run { send in
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
let selectedModel = hexSettings.selectedModel
|
||||
guard !selectedModel.isEmpty else {
|
||||
await send(.modelStatusEvaluated(false))
|
||||
return
|
||||
}
|
||||
let isReady = await transcription.isModelDownloaded(selectedModel)
|
||||
$modelBootstrapState.withLock { state in
|
||||
state.modelIdentifier = selectedModel
|
||||
if state.modelDisplayName?.isEmpty ?? true {
|
||||
state.modelDisplayName = selectedModel
|
||||
}
|
||||
state.isModelReady = isReady
|
||||
if isReady {
|
||||
state.lastError = nil
|
||||
state.progress = 1
|
||||
} else {
|
||||
state.progress = 0
|
||||
}
|
||||
}
|
||||
await send(.modelStatusEvaluated(isReady))
|
||||
}
|
||||
}
|
||||
|
||||
private func startPermissionMonitoring() -> Effect<Action> {
|
||||
.run { send in
|
||||
// Initial check on app launch
|
||||
await send(.checkPermissions)
|
||||
|
||||
// Monitor app activation events
|
||||
for await activation in permissions.observeAppActivation() {
|
||||
if case .didBecomeActive = activation {
|
||||
await send(.appActivated)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct AppView: View {
|
||||
@Bindable var store: StoreOf<AppFeature>
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.automatic
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $store.activeTab) {
|
||||
Button {
|
||||
store.send(.setActiveTab(.settings))
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.settings)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.remappings))
|
||||
} label: {
|
||||
Label("Transforms", systemImage: "text.badge.plus")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.remappings)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.history))
|
||||
} label: {
|
||||
Label("History", systemImage: "clock")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.history)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.about))
|
||||
} label: {
|
||||
Label("About", systemImage: "info.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.about)
|
||||
}
|
||||
} detail: {
|
||||
switch store.state.activeTab {
|
||||
case .settings:
|
||||
SettingsView(
|
||||
store: store.scope(state: \.settings, action: \.settings),
|
||||
microphonePermission: store.microphonePermission,
|
||||
accessibilityPermission: store.accessibilityPermission,
|
||||
inputMonitoringPermission: store.inputMonitoringPermission
|
||||
)
|
||||
.navigationTitle("Settings")
|
||||
case .remappings:
|
||||
WordRemappingsView(store: store.scope(state: \.settings, action: \.settings))
|
||||
.navigationTitle("Transforms")
|
||||
case .history:
|
||||
HistoryView(store: store.scope(state: \.history, action: \.history))
|
||||
.navigationTitle("History")
|
||||
case .about:
|
||||
AboutView(store: store.scope(state: \.settings, action: \.settings))
|
||||
.navigationTitle("About")
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
private let historyLogger = HexLog.history
|
||||
|
||||
// MARK: - Date Extensions
|
||||
|
||||
extension Date {
|
||||
func relativeFormatted() -> String {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
if calendar.isDateInToday(self) {
|
||||
return "Today"
|
||||
} else if calendar.isDateInYesterday(self) {
|
||||
return "Yesterday"
|
||||
} else if let daysAgo = calendar.dateComponents([.day], from: self, to: now).day, daysAgo < 7 {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE" // Day of week
|
||||
return formatter.string(from: self)
|
||||
} else {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
extension SharedReaderKey
|
||||
where Self == FileStorageKey<TranscriptionHistory>.Default
|
||||
{
|
||||
static var transcriptionHistory: Self {
|
||||
Self[
|
||||
.fileStorage(.transcriptionHistoryURL),
|
||||
default: .init()
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage Migration
|
||||
|
||||
extension URL {
|
||||
static var transcriptionHistoryURL: URL {
|
||||
get {
|
||||
URL.hexMigratedFileURL(named: "transcription_history.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPlayerController: NSObject, AVAudioPlayerDelegate {
|
||||
private var player: AVAudioPlayer?
|
||||
private let (playbackFinishedStream, playbackFinishedContinuation) = AsyncStream<Void>.makeStream()
|
||||
|
||||
func play(url: URL) throws {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
self.player = player
|
||||
player.play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player = nil
|
||||
finishPlayback()
|
||||
}
|
||||
|
||||
func waitForPlaybackToFinish() async {
|
||||
for await _ in playbackFinishedStream {}
|
||||
}
|
||||
|
||||
// AVAudioPlayerDelegate method
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
guard self.player === player else { return }
|
||||
self.player = nil
|
||||
finishPlayback()
|
||||
}
|
||||
|
||||
private func finishPlayback() {
|
||||
playbackFinishedContinuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History Feature
|
||||
|
||||
@Reducer
|
||||
struct HistoryFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
var playingTranscriptID: UUID?
|
||||
var playbackID: UUID?
|
||||
var audioPlayerController: AudioPlayerController?
|
||||
|
||||
mutating func stopAudioPlayback() {
|
||||
audioPlayerController?.stop()
|
||||
audioPlayerController = nil
|
||||
playingTranscriptID = nil
|
||||
playbackID = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case playTranscript(UUID)
|
||||
case stopPlayback
|
||||
case copyToClipboard(String)
|
||||
case deleteTranscript(UUID)
|
||||
case deleteAllTranscripts
|
||||
case confirmDeleteAll
|
||||
case playbackFinished(UUID)
|
||||
case navigateToSettings
|
||||
}
|
||||
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect<Action> {
|
||||
.run { [transcriptPersistence] _ in
|
||||
for transcript in transcripts {
|
||||
try? await transcriptPersistence.deleteAudio(transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case let .playTranscript(id):
|
||||
if state.playingTranscriptID == id {
|
||||
// Stop playback if tapping the same transcript
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
}
|
||||
|
||||
// Stop any existing playback
|
||||
state.stopAudioPlayback()
|
||||
|
||||
// Find the transcript and play its audio
|
||||
guard let transcript = state.transcriptionHistory.history.first(where: { $0.id == id }) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
do {
|
||||
let controller = AudioPlayerController()
|
||||
try controller.play(url: transcript.audioPath)
|
||||
let playbackID = UUID()
|
||||
|
||||
state.audioPlayerController = controller
|
||||
state.playingTranscriptID = id
|
||||
state.playbackID = playbackID
|
||||
|
||||
return .run { send in
|
||||
await controller.waitForPlaybackToFinish()
|
||||
await send(.playbackFinished(playbackID))
|
||||
}
|
||||
} catch {
|
||||
historyLogger.error("Failed to play audio: \(error.localizedDescription)")
|
||||
return .none
|
||||
}
|
||||
|
||||
case .stopPlayback:
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
|
||||
case let .playbackFinished(playbackID):
|
||||
guard state.playbackID == playbackID else { return .none }
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
|
||||
case let .copyToClipboard(text):
|
||||
return .run { [pasteboard] _ in
|
||||
await pasteboard.copy(text)
|
||||
}
|
||||
|
||||
case let .deleteTranscript(id):
|
||||
guard let index = state.transcriptionHistory.history.firstIndex(where: { $0.id == id }) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
let transcript = state.transcriptionHistory.history[index]
|
||||
|
||||
if state.playingTranscriptID == id {
|
||||
state.stopAudioPlayback()
|
||||
}
|
||||
|
||||
_ = state.$transcriptionHistory.withLock { history in
|
||||
history.history.remove(at: index)
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: [transcript])
|
||||
|
||||
case .deleteAllTranscripts:
|
||||
return .send(.confirmDeleteAll)
|
||||
|
||||
case .confirmDeleteAll:
|
||||
let transcripts = state.transcriptionHistory.history
|
||||
state.stopAudioPlayback()
|
||||
|
||||
state.$transcriptionHistory.withLock { history in
|
||||
history.history.removeAll()
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: transcripts)
|
||||
|
||||
case .navigateToSettings:
|
||||
// This will be handled by the parent reducer
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptView: View {
|
||||
let transcript: Transcript
|
||||
let isPlaying: Bool
|
||||
let onPlay: () -> Void
|
||||
let onCopy: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(transcript.text)
|
||||
.font(.body)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.trailing, 40) // Space for buttons
|
||||
.padding(12)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
// App icon and name
|
||||
if let bundleID = transcript.sourceAppBundleID,
|
||||
let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
|
||||
.resizable()
|
||||
.frame(width: 14, height: 14)
|
||||
if let appName = transcript.sourceAppName {
|
||||
Text(appName)
|
||||
}
|
||||
Text("•")
|
||||
}
|
||||
|
||||
Image(systemName: "clock")
|
||||
Text(transcript.timestamp.relativeFormatted())
|
||||
Text("•")
|
||||
Text(transcript.timestamp.formatted(date: .omitted, time: .shortened))
|
||||
Text("•")
|
||||
Text(String(format: "%.1fs", transcript.duration))
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
onCopy()
|
||||
showCopyAnimation()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: showCopied ? "checkmark" : "doc.on.doc.fill")
|
||||
if showCopied {
|
||||
Text("Copied").font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(showCopied ? .green : .secondary)
|
||||
.help("Copy to clipboard")
|
||||
|
||||
Button(action: onPlay) {
|
||||
Image(systemName: isPlaying ? "stop.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isPlaying ? .blue : .secondary)
|
||||
.help(isPlaying ? "Stop playback" : "Play audio")
|
||||
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Delete transcript")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
.frame(height: 20)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.onDisappear {
|
||||
// Clean up any running task when view disappears
|
||||
copyTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@State private var showCopied = false
|
||||
@State private var copyTask: Task<Void, Error>?
|
||||
|
||||
private func showCopyAnimation() {
|
||||
copyTask?.cancel()
|
||||
|
||||
copyTask = Task {
|
||||
withAnimation {
|
||||
showCopied = true
|
||||
}
|
||||
|
||||
try await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
withAnimation {
|
||||
showCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TranscriptView(
|
||||
transcript: Transcript(timestamp: Date(), text: "Hello, world!", audioPath: URL(fileURLWithPath: "/Users/langton/Downloads/test.m4a"), duration: 1.0),
|
||||
isPlaying: false,
|
||||
onPlay: {},
|
||||
onCopy: {},
|
||||
onDelete: {}
|
||||
)
|
||||
}
|
||||
|
||||
struct HistoryView: View {
|
||||
@ObserveInjection var inject
|
||||
let store: StoreOf<HistoryFeature>
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !hexSettings.saveTranscriptionHistory {
|
||||
ContentUnavailableView {
|
||||
Label("History Disabled", systemImage: "clock.arrow.circlepath")
|
||||
} description: {
|
||||
Text("Transcription history is currently disabled.")
|
||||
} actions: {
|
||||
Button("Enable in Settings") {
|
||||
store.send(.navigateToSettings)
|
||||
}
|
||||
}
|
||||
} else if store.transcriptionHistory.history.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Transcriptions", systemImage: "text.bubble")
|
||||
} description: {
|
||||
Text("Your transcription history will appear here.")
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(store.transcriptionHistory.history) { transcript in
|
||||
TranscriptView(
|
||||
transcript: transcript,
|
||||
isPlaying: store.playingTranscriptID == transcript.id,
|
||||
onPlay: { store.send(.playTranscript(transcript.id)) },
|
||||
onCopy: { store.send(.copyToClipboard(transcript.text)) },
|
||||
onDelete: { store.send(.deleteTranscript(transcript.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.toolbar {
|
||||
Button(role: .destructive, action: { showingDeleteConfirmation = true }) {
|
||||
Label("Delete All", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.alert("Delete All Transcripts", isPresented: $showingDeleteConfirmation) {
|
||||
Button("Delete All", role: .destructive) {
|
||||
store.send(.confirmDeleteAll)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete all transcripts? This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct WordRemappingsView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
@FocusState private var isScratchpadFocused: Bool
|
||||
@State private var activeSection: ModificationSection = .removals
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
previewSection
|
||||
wordRulesSection
|
||||
outputFormattingSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
.onDisappear {
|
||||
store.send(.setRemappingScratchpadFocused(false))
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Preview")
|
||||
.font(.headline)
|
||||
|
||||
GroupBox {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Input")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Type or speak a sample…", text: $store.remappingScratchpadText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($isScratchpadFocused)
|
||||
.onChange(of: isScratchpadFocused) { _, newValue in
|
||||
store.send(.setRemappingScratchpadFocused(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 27)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Output")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(previewText.isEmpty ? "—" : previewText)
|
||||
.frame(maxWidth: .infinity, minHeight: 22, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var wordRulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Word rules")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Rule type", selection: $activeSection) {
|
||||
ForEach(ModificationSection.allCases) { section in
|
||||
Text(section.title).tag(section)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 360)
|
||||
|
||||
switch activeSection {
|
||||
case .removals:
|
||||
removalsSection
|
||||
case .remappings:
|
||||
remappingsSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var removalsSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Remove matching words from every transcript.")
|
||||
.settingsCaption()
|
||||
Spacer()
|
||||
Toggle(
|
||||
"Enabled",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.wordRemovalsEnabled },
|
||||
set: { store.send(.setWordRemovalsEnabled($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Divider()
|
||||
removalsColumnHeaders
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(store.hexSettings.wordRemovals.enumerated()), id: \.element.id) { index, removal in
|
||||
RemovalRow(removal: removalBinding(for: removal)) {
|
||||
store.send(.removeWordRemoval(removal.id))
|
||||
}
|
||||
if index < store.hexSettings.wordRemovals.count - 1 {
|
||||
Divider().padding(.leading, Layout.dividerLeadingPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.addWordRemoval)
|
||||
} label: {
|
||||
Label("Add pattern", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var remappingsSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Replace matching words in every transcript.")
|
||||
.settingsCaption()
|
||||
Divider()
|
||||
remappingsColumnHeaders
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(store.hexSettings.wordRemappings.enumerated()), id: \.element.id) { index, remapping in
|
||||
RemappingRow(remapping: remappingBinding(for: remapping)) {
|
||||
store.send(.removeWordRemapping(remapping.id))
|
||||
}
|
||||
if index < store.hexSettings.wordRemappings.count - 1 {
|
||||
Divider().padding(.leading, Layout.dividerLeadingPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.addWordRemapping)
|
||||
} label: {
|
||||
Label("Add replacement", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var outputFormattingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Output formatting")
|
||||
.font(.headline)
|
||||
|
||||
GroupBox {
|
||||
HStack(spacing: 20) {
|
||||
Toggle(
|
||||
"Lowercase output",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.lowercaseTranscripts },
|
||||
set: { store.send(.setLowercaseTranscripts($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Divider()
|
||||
.frame(height: 22)
|
||||
|
||||
Toggle(
|
||||
"Remove punctuation",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.removePunctuation },
|
||||
set: { store.send(.setRemovePunctuation($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var removalsColumnHeaders: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("On")
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
Text("Pattern")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer().frame(width: Layout.deleteColumnWidth)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
}
|
||||
|
||||
private var remappingsColumnHeaders: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("On")
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
Text("Match")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: Layout.arrowColumnWidth)
|
||||
Text("Replace")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer().frame(width: Layout.deleteColumnWidth)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
}
|
||||
|
||||
private func removalBinding(for removal: WordRemoval) -> Binding<WordRemoval> {
|
||||
return Binding(
|
||||
get: {
|
||||
store.hexSettings.wordRemovals.first { $0.id == removal.id } ?? removal
|
||||
},
|
||||
set: { store.send(.updateWordRemoval($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
private func remappingBinding(for remapping: WordRemapping) -> Binding<WordRemapping> {
|
||||
return Binding(
|
||||
get: {
|
||||
store.hexSettings.wordRemappings.first { $0.id == remapping.id } ?? remapping
|
||||
},
|
||||
set: { store.send(.updateWordRemapping($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
private var previewText: String {
|
||||
var output = store.remappingScratchpadText
|
||||
if store.hexSettings.wordRemovalsEnabled {
|
||||
output = WordRemovalApplier.apply(output, removals: store.hexSettings.wordRemovals)
|
||||
}
|
||||
output = WordRemappingApplier.apply(output, remappings: store.hexSettings.wordRemappings)
|
||||
output = TranscriptFormattingApplier.apply(
|
||||
output,
|
||||
lowercase: store.hexSettings.lowercaseTranscripts,
|
||||
removePunctuation: store.hexSettings.removePunctuation
|
||||
)
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemovalRow: View {
|
||||
@Binding var removal: WordRemoval
|
||||
var onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: $removal.isEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
.accessibilityLabel("Enable \(removal.pattern.isEmpty ? "removal" : removal.pattern)")
|
||||
|
||||
TextField("Regex Pattern", text: $removal.pattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.frame(width: Layout.deleteColumnWidth)
|
||||
.help("Delete removal")
|
||||
}
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemappingRow: View {
|
||||
@Binding var remapping: WordRemapping
|
||||
var onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: $remapping.isEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
.accessibilityLabel("Enable \(remapping.match.isEmpty ? "replacement" : remapping.match)")
|
||||
|
||||
TextField("Match", text: $remapping.match)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: Layout.arrowColumnWidth)
|
||||
|
||||
TextField("Replace", text: $remapping.replacement)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.frame(width: Layout.deleteColumnWidth)
|
||||
.help("Delete replacement")
|
||||
}
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ModificationSection: String, CaseIterable, Identifiable {
|
||||
case removals
|
||||
case remappings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .removals:
|
||||
return "Remove words"
|
||||
case .remappings:
|
||||
return "Replace words"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Layout {
|
||||
static let toggleColumnWidth: CGFloat = 24
|
||||
static let deleteColumnWidth: CGFloat = 24
|
||||
static let arrowColumnWidth: CGFloat = 16
|
||||
static let rowHorizontalPadding: CGFloat = 10
|
||||
static let dividerLeadingPadding: CGFloat = toggleColumnWidth + rowHorizontalPadding + 8
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
struct AboutView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
@State var viewModel = CheckForUpdatesViewModel.shared
|
||||
@State private var showingChangelog = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
Label("Version", systemImage: "info.circle")
|
||||
Spacer()
|
||||
Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown")
|
||||
Button("Check for Updates") {
|
||||
viewModel.checkForUpdates()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!viewModel.canCheckForUpdates)
|
||||
}
|
||||
HStack {
|
||||
Label("Changelog", systemImage: "doc.text")
|
||||
Spacer()
|
||||
Button("Show Changelog") {
|
||||
showingChangelog.toggle()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.sheet(isPresented: $showingChangelog, onDismiss: {
|
||||
showingChangelog = false
|
||||
}) {
|
||||
ChangelogView()
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Label("Hex is open source", systemImage: "apple.terminal.on.rectangle")
|
||||
Spacer()
|
||||
Link("Visit our GitHub", destination: URL(string: "https://github.com/kitlangton/Hex/")!)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Label("Support the developer", systemImage: "heart")
|
||||
Spacer()
|
||||
Link("Become a Sponsor", destination: URL(string: "https://github.com/sponsors/kitlangton")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
import Inject
|
||||
import MarkdownUI
|
||||
|
||||
struct ChangelogView: View {
|
||||
@ObserveInjection var inject
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Changelog")
|
||||
.font(.title)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
if let changelogPath = Bundle.main.path(forResource: "changelog", ofType: "md"),
|
||||
let changelogContent = try? String(
|
||||
contentsOfFile: changelogPath, encoding: .utf8)
|
||||
{
|
||||
Markdown(changelogContent)
|
||||
} else {
|
||||
Text("Changelog could not be loaded.")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Label {
|
||||
Toggle("Open on Login",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.openOnLogin },
|
||||
set: { store.send(.toggleOpenOnLogin($0)) }
|
||||
))
|
||||
} icon: {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Show Dock Icon",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.showDockIcon },
|
||||
set: { store.send(.toggleShowDockIcon($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "dock.rectangle")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Use clipboard to insert",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.useClipboardPaste },
|
||||
set: { store.send(.setUseClipboardPaste($0)) }
|
||||
)
|
||||
)
|
||||
Text("Use clipboard to insert text. Fast but may not restore all clipboard content.\nTurn off to use simulated keypresses. Slower, but doesn't need to restore clipboard")
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.doc.fill")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Copy to clipboard",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.copyToClipboard },
|
||||
set: { store.send(.setCopyToClipboard($0)) }
|
||||
)
|
||||
)
|
||||
Text("Copy transcription text to clipboard in addition to pasting it")
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Prevent System Sleep while Recording",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.preventSystemSleep },
|
||||
set: { store.send(.togglePreventSystemSleep($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "zzz")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Super Fast Mode",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.superFastModeEnabled },
|
||||
set: { store.send(.toggleSuperFastMode($0)) }
|
||||
)
|
||||
)
|
||||
Text("Keep the microphone warm and prepend a short in-memory buffer for near-instant capture. macOS will keep showing the microphone indicator while this mode is armed.")
|
||||
} icon: {
|
||||
Image(systemName: "bolt.circle")
|
||||
}
|
||||
|
||||
Label {
|
||||
HStack(alignment: .center) {
|
||||
Text("Audio Behavior while Recording")
|
||||
Spacer()
|
||||
Picker("", selection: Binding(
|
||||
get: { store.hexSettings.recordingAudioBehavior },
|
||||
set: { store.send(.setRecordingAudioBehavior($0)) }
|
||||
)) {
|
||||
Label("Pause Media", systemImage: "pause")
|
||||
.tag(RecordingAudioBehavior.pauseMedia)
|
||||
Label("Mute Volume", systemImage: "speaker.slash")
|
||||
.tag(RecordingAudioBehavior.mute)
|
||||
Label("Do Nothing", systemImage: "hand.raised.slash")
|
||||
.tag(RecordingAudioBehavior.doNothing)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "speaker.wave.2")
|
||||
}
|
||||
} header: {
|
||||
Text("General")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import HexCore
|
||||
|
||||
struct HistorySectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Label {
|
||||
Toggle("Save Transcription History", isOn: Binding(
|
||||
get: { store.hexSettings.saveTranscriptionHistory },
|
||||
set: { store.send(.toggleSaveTranscriptionHistory($0)) }
|
||||
))
|
||||
Text("Save transcriptions and audio recordings for later access")
|
||||
.settingsCaption()
|
||||
} icon: {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
}
|
||||
|
||||
if store.hexSettings.saveTranscriptionHistory {
|
||||
Label {
|
||||
HStack {
|
||||
Text("Maximum History Entries")
|
||||
Spacer()
|
||||
Picker("", selection: Binding(
|
||||
get: { store.hexSettings.maxHistoryEntries ?? 0 },
|
||||
set: { newValue in
|
||||
store.send(.setMaxHistoryEntries(newValue == 0 ? nil : newValue))
|
||||
}
|
||||
)) {
|
||||
Text("Unlimited").tag(0)
|
||||
Text("50").tag(50)
|
||||
Text("100").tag(100)
|
||||
Text("200").tag(200)
|
||||
Text("500").tag(500)
|
||||
Text("1000").tag(1000)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 120)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "number.square")
|
||||
}
|
||||
|
||||
if store.hexSettings.maxHistoryEntries != nil {
|
||||
Text("Oldest entries will be automatically deleted when limit is reached")
|
||||
.settingsCaption()
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
|
||||
PasteLastTranscriptHotkeyRow(store: store)
|
||||
}
|
||||
} header: {
|
||||
Text("History")
|
||||
} footer: {
|
||||
if !store.hexSettings.saveTranscriptionHistory {
|
||||
Text("When disabled, transcriptions will not be saved and audio files will be deleted immediately after transcription.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PasteLastTranscriptHotkeyRow: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
let pasteHotkey = store.hexSettings.pasteLastTranscriptHotkey
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Paste Last Transcript")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Assign a shortcut (modifier + key) to instantly paste your last transcription.")
|
||||
.settingsCaption()
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
}
|
||||
|
||||
let key = store.isSettingPasteLastTranscriptHotkey ? nil : pasteHotkey?.key
|
||||
let modifiers = store.isSettingPasteLastTranscriptHotkey ? store.currentPasteLastModifiers : (pasteHotkey?.modifiers ?? .init(modifiers: []))
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
HotKeyView(modifiers: modifiers, key: key, isActive: store.isSettingPasteLastTranscriptHotkey)
|
||||
|
||||
if !store.isSettingPasteLastTranscriptHotkey, pasteHotkey == nil {
|
||||
Text("Not set")
|
||||
.settingsCaption()
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
store.send(.startSettingPasteLastTranscriptHotkey)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if store.isSettingPasteLastTranscriptHotkey {
|
||||
Text("Use at least one modifier (⌘, ⌥, ⇧, ⌃) plus a key.")
|
||||
.settingsCaption()
|
||||
} else if pasteHotkey != nil {
|
||||
Button {
|
||||
store.send(.clearPasteLastTranscriptHotkey)
|
||||
} label: {
|
||||
Label("Clear shortcut", systemImage: "xmark.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct HotKeySectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section("Hot Key") {
|
||||
let hotKey = store.hexSettings.hotkey
|
||||
let key = store.isSettingHotKey ? nil : hotKey.key
|
||||
let modifiers = store.isSettingHotKey ? store.currentModifiers : hotKey.modifiers
|
||||
|
||||
VStack(spacing: 12) {
|
||||
// Hot key view
|
||||
HStack {
|
||||
Spacer()
|
||||
HotKeyView(modifiers: modifiers, key: key, isActive: store.isSettingHotKey)
|
||||
.animation(.spring(), value: key)
|
||||
.animation(.spring(), value: modifiers)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
store.send(.startSettingHotKey)
|
||||
}
|
||||
|
||||
if !store.isSettingHotKey,
|
||||
hotKey.key == nil,
|
||||
!hotKey.modifiers.isEmpty {
|
||||
ModifierSideControls(
|
||||
modifiers: hotKey.modifiers,
|
||||
onSelect: { kind, side in
|
||||
store.send(.setModifierSide(kind, side))
|
||||
}
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Enable double-tap lock",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.doubleTapLockEnabled },
|
||||
set: { store.send(.setDoubleTapLockEnabled($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "hand.tap")
|
||||
}
|
||||
|
||||
// Double-tap only mode applies to key+modifier combinations.
|
||||
if hotKey.key != nil {
|
||||
Label {
|
||||
Toggle(
|
||||
"Use double-tap only",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.useDoubleTapOnly },
|
||||
set: { store.send(.setUseDoubleTapOnly($0)) }
|
||||
)
|
||||
)
|
||||
.disabled(!store.hexSettings.doubleTapLockEnabled)
|
||||
} icon: {
|
||||
Image(systemName: "hand.tap.fill")
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum key time (for modifier-only shortcuts)
|
||||
if store.hexSettings.hotkey.key == nil {
|
||||
Label {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { store.hexSettings.minimumKeyTime },
|
||||
set: { store.send(.setMinimumKeyTime($0)) }
|
||||
),
|
||||
in: 0.0 ... 2.0,
|
||||
step: 0.1
|
||||
) {
|
||||
Text("Ignore below \(store.hexSettings.minimumKeyTime, specifier: "%.1f")s")
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "clock")
|
||||
}
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModifierSideControls: View {
|
||||
@ObserveInjection var inject
|
||||
var modifiers: Modifiers
|
||||
var onSelect: (Modifier.Kind, Modifier.Side) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(modifiers.kinds, id: \.self) { kind in
|
||||
if kind.supportsSideSelection {
|
||||
let binding = Binding<Modifier.Side>(
|
||||
get: { modifiers.side(for: kind) ?? .either },
|
||||
set: { onSelect(kind, $0) }
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(kind.symbol) \(kind.displayName)")
|
||||
.settingsCaption()
|
||||
|
||||
Picker("Modifier side", selection: binding) {
|
||||
ForEach(Modifier.Side.allCases, id: \.self) { side in
|
||||
Text(side.displayName)
|
||||
.tag(side)
|
||||
.disabled(!kind.supportsSideSelection && side != .either)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// HotKeyView.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/30/25.
|
||||
//
|
||||
|
||||
import HexCore
|
||||
import Inject
|
||||
import Sauce
|
||||
import SwiftUI
|
||||
|
||||
// This view shows the actual "keys" in a more modern, subtle style.
|
||||
struct HotKeyView: View {
|
||||
@ObserveInjection var inject
|
||||
var modifiers: Modifiers
|
||||
var key: Key?
|
||||
var isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
if modifiers.isHyperkey {
|
||||
// Show Black Four Pointed Star for hyperkey
|
||||
KeyView(text: "✦")
|
||||
.transition(.blurReplace)
|
||||
} else {
|
||||
ForEach(modifiers.sorted) { modifier in
|
||||
KeyView(text: modifier.stringValue)
|
||||
.transition(.blurReplace)
|
||||
}
|
||||
}
|
||||
|
||||
if let key {
|
||||
KeyView(text: key.toString)
|
||||
}
|
||||
|
||||
if modifiers.isEmpty && key == nil {
|
||||
Text("")
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.frame(width: 48, height: 48)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isActive && key == nil && modifiers.isEmpty {
|
||||
Text("Enter a key combination")
|
||||
.foregroundColor(.secondary)
|
||||
.transition(.blurReplace)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.blue.opacity(isActive ? 0.1 : 0))
|
||||
.stroke(Color.blue.opacity(isActive ? 0.2 : 0), lineWidth: 1)
|
||||
)
|
||||
|
||||
.animation(.bouncy(duration: 0.3), value: key)
|
||||
.animation(.bouncy(duration: 0.3), value: modifiers)
|
||||
.animation(.bouncy(duration: 0.3), value: isActive)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyView: View {
|
||||
@ObserveInjection var inject
|
||||
var text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
Color(white: 0.2)
|
||||
.shadow(.inner(color: .white.opacity(0.3), radius: 1, y: 1))
|
||||
.shadow(.inner(color: .white.opacity(0.1), radius: 5, y: 8))
|
||||
.shadow(.inner(color: .black.opacity(0.3), radius: 1, y: -3))
|
||||
)
|
||||
)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HotKeyView(
|
||||
modifiers: .init(modifiers: [.command, .shift]),
|
||||
key: .a,
|
||||
isActive: true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
import Inject
|
||||
#if canImport(ComposableArchitecture)
|
||||
import ComposableArchitecture
|
||||
#endif
|
||||
|
||||
struct LanguageSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Picker(
|
||||
"Output Language",
|
||||
selection: Binding(
|
||||
get: { store.hexSettings.outputLanguage },
|
||||
set: { store.send(.setOutputLanguage($0)) }
|
||||
)
|
||||
) {
|
||||
ForEach(store.languages, id: \.id) { language in
|
||||
Text(language.name).tag(language.code as String?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
} icon: {
|
||||
Image(systemName: "globe")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct MicrophoneSelectionSectionView: View {
|
||||
private static let systemDefaultTag = "__system_default__"
|
||||
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
private var selectedDeviceTag: Binding<String> {
|
||||
Binding(
|
||||
get: { store.hexSettings.selectedMicrophoneID ?? Self.systemDefaultTag },
|
||||
set: { newValue in
|
||||
store.send(.setSelectedMicrophoneID(newValue == Self.systemDefaultTag ? nil : newValue))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var missingSelectedDeviceID: String? {
|
||||
guard let selectedID = store.hexSettings.selectedMicrophoneID,
|
||||
!store.availableInputDevices.contains(where: { $0.id == selectedID })
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return selectedID
|
||||
}
|
||||
|
||||
private var pickerIdentity: String {
|
||||
[
|
||||
store.defaultInputDeviceName ?? "",
|
||||
store.hexSettings.selectedMicrophoneID ?? Self.systemDefaultTag,
|
||||
store.availableInputDevices.map { "\($0.id):\($0.name)" }.joined(separator: "|")
|
||||
].joined(separator: "||")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
// Input device picker
|
||||
HStack {
|
||||
Label {
|
||||
let systemLabel: String = {
|
||||
if let name = store.defaultInputDeviceName, !name.isEmpty {
|
||||
return "System Default (\(name))"
|
||||
}
|
||||
return "System Default"
|
||||
}()
|
||||
Picker("Input Device", selection: selectedDeviceTag) {
|
||||
Text(systemLabel).tag(Self.systemDefaultTag)
|
||||
ForEach(store.availableInputDevices) { device in
|
||||
Text(device.name).tag(device.id)
|
||||
}
|
||||
if let missingSelectedDeviceID {
|
||||
Text("Unavailable Device").tag(missingSelectedDeviceID)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.id(pickerIdentity)
|
||||
} icon: {
|
||||
Image(systemName: "mic.circle")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
store.send(.loadAvailableInputDevices)
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Refresh available input devices")
|
||||
}
|
||||
|
||||
// Show fallback note for selected device not connected
|
||||
if missingSelectedDeviceID != nil {
|
||||
Text("Selected device not connected. System default will be used.")
|
||||
.settingsCaption()
|
||||
}
|
||||
} header: {
|
||||
Text("Microphone Selection")
|
||||
} footer: {
|
||||
Text("Override the system default microphone with a specific input device. This setting will persist across sessions.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
// MARK: – ModelDownloadFeature.swift
|
||||
|
||||
// A full‐featured TCA reducer for managing on‑device ML models.
|
||||
// Dependencies: ComposableArchitecture, IdentifiedCollections, Dependencies
|
||||
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import IdentifiedCollections
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public struct ModelInfo: Equatable, Identifiable {
|
||||
public let name: String
|
||||
public var isDownloaded: Bool
|
||||
|
||||
public var id: String { name }
|
||||
public init(name: String, isDownloaded: Bool) {
|
||||
self.name = name
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
}
|
||||
|
||||
public struct CuratedModelInfo: Equatable, Identifiable, Codable {
|
||||
public let displayName: String
|
||||
public let internalName: String
|
||||
public let size: String
|
||||
public let accuracyStars: Int
|
||||
public let speedStars: Int
|
||||
public let storageSize: String
|
||||
public var isDownloaded: Bool
|
||||
public var id: String { internalName }
|
||||
|
||||
public var badge: String? {
|
||||
switch parakeetModel {
|
||||
case .englishV2:
|
||||
return "BEST FOR ENGLISH"
|
||||
case .multilingualV3:
|
||||
return "BEST FOR MULTILINGUAL"
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var parakeetModel: ParakeetModel? {
|
||||
ParakeetModel(rawValue: internalName)
|
||||
}
|
||||
|
||||
var isParakeet: Bool {
|
||||
parakeetModel != nil
|
||||
}
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
internalName: String,
|
||||
size: String,
|
||||
accuracyStars: Int,
|
||||
speedStars: Int,
|
||||
storageSize: String,
|
||||
isDownloaded: Bool
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.internalName = internalName
|
||||
self.size = size
|
||||
self.accuracyStars = accuracyStars
|
||||
self.speedStars = speedStars
|
||||
self.storageSize = storageSize
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
|
||||
// Codable (isDownloaded is set at runtime)
|
||||
private enum CodingKeys: String, CodingKey { case displayName, internalName, size, accuracyStars, speedStars, storageSize }
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
displayName = try c.decode(String.self, forKey: .displayName)
|
||||
internalName = try c.decode(String.self, forKey: .internalName)
|
||||
size = try c.decode(String.self, forKey: .size)
|
||||
accuracyStars = try c.decode(Int.self, forKey: .accuracyStars)
|
||||
speedStars = try c.decode(Int.self, forKey: .speedStars)
|
||||
storageSize = try c.decode(String.self, forKey: .storageSize)
|
||||
isDownloaded = false
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience helper for loading the bundled models.json once.
|
||||
private enum CuratedModelLoader {
|
||||
private static let bundledModels: [CuratedModelInfo] = {
|
||||
guard let url = Bundle.main.url(forResource: "models", withExtension: "json") ??
|
||||
Bundle.main.url(forResource: "models", withExtension: "json", subdirectory: "Data")
|
||||
else {
|
||||
assertionFailure("models.json not found in bundle")
|
||||
return []
|
||||
}
|
||||
do { return try JSONDecoder().decode([CuratedModelInfo].self, from: Data(contentsOf: url)) }
|
||||
catch { assertionFailure("Failed to decode models.json - \(error)"); return [] }
|
||||
}()
|
||||
|
||||
static func load() -> [CuratedModelInfo] {
|
||||
bundledModels
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Domain
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Reducer
|
||||
public struct ModelDownloadFeature {
|
||||
@ObservableState
|
||||
public struct State: Equatable {
|
||||
// Shared user settings
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
|
||||
// Remote data
|
||||
public var availableModels: IdentifiedArrayOf<ModelInfo> = []
|
||||
public var curatedModels = IdentifiedArrayOf(uniqueElements: CuratedModelLoader.load())
|
||||
public var recommendedModel: String = ""
|
||||
|
||||
// UI state
|
||||
public var isLoadingModels = false
|
||||
public var isDownloading = false
|
||||
public var downloadProgress: Double = 0
|
||||
public var downloadError: String?
|
||||
public var downloadingModelName: String?
|
||||
|
||||
// Track which model generated a progress update to handle switching models
|
||||
public var activeDownloadID: UUID?
|
||||
|
||||
// Convenience computed vars
|
||||
var selectedModel: String { hexSettings.selectedModel }
|
||||
|
||||
/// The downloaded model matching the current selection, pattern-aware so
|
||||
/// legacy or glob-style selections (e.g. "distil*large-v3") still resolve.
|
||||
private var downloadedModelMatchingSelection: ModelInfo? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
return availableModels.first { model in
|
||||
model.isDownloaded && ModelPatternMatcher.namesMatch(model.name, selectedModel)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedModelNameForDisplay: String? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
if let downloaded = downloadedModelMatchingSelection {
|
||||
return downloaded.name
|
||||
}
|
||||
if modelBootstrapState.isModelReady,
|
||||
let identifier = modelBootstrapState.modelIdentifier,
|
||||
ModelPatternMatcher.namesMatch(identifier, selectedModel)
|
||||
{
|
||||
return selectedModel
|
||||
}
|
||||
return hexSettings.hasCompletedModelBootstrap ? selectedModel : nil
|
||||
}
|
||||
|
||||
var selectedModelIsDownloaded: Bool {
|
||||
downloadedModelMatchingSelection != nil
|
||||
}
|
||||
|
||||
var anyModelDownloaded: Bool {
|
||||
availableModels.contains(where: { $0.isDownloaded })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
public enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
// Requests
|
||||
case fetchModels
|
||||
case selectModel(String)
|
||||
case downloadModel(String)
|
||||
// Effects
|
||||
case modelsLoaded(recommended: String, available: [ModelInfo])
|
||||
case modelsLoadFailed
|
||||
case downloadProgress(id: UUID, progress: Double)
|
||||
case downloadCompleted(id: UUID, result: Result<String, Error>)
|
||||
case cancelDownload
|
||||
|
||||
case deleteModel(String)
|
||||
case modelDeleted(String)
|
||||
case modelDeletionFailed(Error)
|
||||
case openModelLocation(String)
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: Reducer
|
||||
|
||||
public var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
Reduce(reduce)
|
||||
}
|
||||
|
||||
// MARK: - Helpers (pattern matching)
|
||||
|
||||
private func resolvePattern(_ pattern: String, from available: [ModelInfo]) -> String? {
|
||||
ModelPatternMatcher.resolvePattern(pattern, from: available.map { ($0.name, $0.isDownloaded) })
|
||||
}
|
||||
|
||||
private func curatedDisplayName(for model: String, curated: IdentifiedArrayOf<CuratedModelInfo>) -> String {
|
||||
if let match = curated.first(where: { ModelPatternMatcher.matches($0.internalName, model) }) {
|
||||
return match.displayName
|
||||
}
|
||||
return model
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
|
||||
private func updateBootstrapState(_ state: inout State) {
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = ""
|
||||
bootstrap.modelDisplayName = ""
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.progress = 0
|
||||
bootstrap.lastError = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
let isDownloaded = state.selectedModelIsDownloaded
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = model
|
||||
bootstrap.modelDisplayName = displayName
|
||||
bootstrap.isModelReady = isDownloaded
|
||||
if isDownloaded {
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reduce(state: inout State, action: Action) -> Effect<Action> {
|
||||
switch action {
|
||||
// MARK: – UI bindings
|
||||
|
||||
case .binding:
|
||||
return .none
|
||||
|
||||
case let .selectModel(model):
|
||||
// If the curated item is a glob (e.g., "distil*large-v3"),
|
||||
// resolve it to a concrete available model so both tabs stay in sync
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
// MARK: – Fetch Models
|
||||
|
||||
case .fetchModels:
|
||||
guard !state.isLoadingModels else { return .none }
|
||||
state.isLoadingModels = true
|
||||
return .run { send in
|
||||
do {
|
||||
async let recommendedSupportTask = transcription.getRecommendedModels()
|
||||
async let availableNamesTask = transcription.getAvailableModels()
|
||||
let recommendedSupport = try await recommendedSupportTask
|
||||
let names = try await availableNamesTask
|
||||
let recommended = recommendedSupport.default
|
||||
let infos = try await withThrowingTaskGroup(of: ModelInfo.self) { group -> [ModelInfo] in
|
||||
for name in names {
|
||||
group.addTask {
|
||||
ModelInfo(
|
||||
name: name,
|
||||
isDownloaded: await transcription.isModelDownloaded(name)
|
||||
)
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: []) { $0.append($1) }
|
||||
}
|
||||
await send(.modelsLoaded(recommended: recommended, available: infos))
|
||||
} catch {
|
||||
await send(.modelsLoadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
case .modelsLoadFailed:
|
||||
state.isLoadingModels = false
|
||||
return .none
|
||||
|
||||
case let .modelsLoaded(recommended, available):
|
||||
state.isLoadingModels = false
|
||||
// Ensure our curated Parakeet options are visible even if WhisperKit doesn't list them
|
||||
var availablePlus = available
|
||||
for model in ParakeetModel.allCases.reversed() {
|
||||
if !availablePlus.contains(where: { $0.name == model.identifier }) {
|
||||
availablePlus.insert(ModelInfo(name: model.identifier, isDownloaded: false), at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
if availablePlus.contains(where: { $0.name == state.preferredParakeetIdentifier }) {
|
||||
state.recommendedModel = state.preferredParakeetIdentifier
|
||||
} else {
|
||||
state.recommendedModel = recommended
|
||||
}
|
||||
state.availableModels = IdentifiedArrayOf(uniqueElements: availablePlus)
|
||||
|
||||
// If the selected model is a pattern, resolve it now to the first available match
|
||||
if state.hexSettings.selectedModel.contains("*") || state.hexSettings.selectedModel.contains("?") {
|
||||
if let resolved = resolvePattern(state.hexSettings.selectedModel, from: available) {
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
}
|
||||
}
|
||||
|
||||
// Merge curated + download status with pattern support
|
||||
var curated = CuratedModelLoader.load()
|
||||
for idx in curated.indices {
|
||||
let internalName = curated[idx].internalName
|
||||
if let match = available.first(where: { ModelPatternMatcher.matches(internalName, $0.name) }) {
|
||||
curated[idx].isDownloaded = match.isDownloaded
|
||||
} else {
|
||||
curated[idx].isDownloaded = false
|
||||
}
|
||||
}
|
||||
state.curatedModels = IdentifiedArrayOf(uniqueElements: curated)
|
||||
// If the selection isn't installed but another model is, switch to the
|
||||
// installed one so transcription keeps working. Never clear the user's
|
||||
// selection outright: availability scans can produce false negatives
|
||||
// (e.g. after a dependency changes its cache layout), and wiping the
|
||||
// setting turns a transient glitch into a permanent silent failure.
|
||||
if !state.selectedModelIsDownloaded,
|
||||
let installedModel = state.curatedModels.first(where: \.isDownloaded)
|
||||
{
|
||||
let fallback = resolvePattern(installedModel.internalName, from: Array(state.availableModels)) ?? installedModel.internalName
|
||||
if fallback != state.selectedModel {
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
if !state.anyModelDownloaded && !state.hexSettings.hasCompletedModelBootstrap {
|
||||
let preferred = state.recommendedModel.isEmpty ? state.hexSettings.selectedModel : state.recommendedModel
|
||||
if !preferred.isEmpty {
|
||||
state.$hexSettings.withLock { $0.selectedModel = preferred }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
}
|
||||
return .none
|
||||
|
||||
// MARK: – Download
|
||||
|
||||
case let .downloadModel(requestedModel):
|
||||
guard !requestedModel.isEmpty, !state.isDownloading else { return .none }
|
||||
// Resolve glob/legacy selections to a concrete model name up front so
|
||||
// the completion handler updates the matching rows and writes a
|
||||
// concrete name back into settings.
|
||||
let model = resolvePattern(requestedModel, from: Array(state.availableModels)) ?? requestedModel
|
||||
state.downloadError = nil
|
||||
state.isDownloading = true
|
||||
state.downloadProgress = 0
|
||||
state.downloadingModelName = model
|
||||
state.activeDownloadID = UUID()
|
||||
let downloadID = state.activeDownloadID!
|
||||
if !state.anyModelDownloaded {
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
state.$modelBootstrapState.withLock {
|
||||
$0.modelIdentifier = model
|
||||
$0.modelDisplayName = displayName
|
||||
$0.isModelReady = false
|
||||
$0.progress = 0
|
||||
$0.lastError = nil
|
||||
}
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.downloadModel(model) { progress in
|
||||
let fractionCompleted = progress.fractionCompleted
|
||||
Task {
|
||||
await send(.downloadProgress(id: downloadID, progress: fractionCompleted))
|
||||
}
|
||||
}
|
||||
await send(.downloadCompleted(id: downloadID, result: .success(model)))
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
await send(.downloadCompleted(id: downloadID, result: .failure(error)))
|
||||
}
|
||||
}
|
||||
.cancellable(id: downloadID)
|
||||
|
||||
case let .downloadProgress(id, progress):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
guard state.downloadProgress != progress else { return .none }
|
||||
state.downloadProgress = progress
|
||||
if !state.modelBootstrapState.isModelReady {
|
||||
state.$modelBootstrapState.withLock { $0.progress = progress }
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .downloadCompleted(id, result):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
var failureMessage: String?
|
||||
switch result {
|
||||
case let .success(name):
|
||||
state.availableModels[id: name]?.isDownloaded = true
|
||||
if let idx = state.curatedModels.firstIndex(where: { $0.internalName == name }) {
|
||||
state.curatedModels[idx].isDownloaded = true
|
||||
}
|
||||
state.$hexSettings.withLock { settings in
|
||||
settings.selectedModel = name
|
||||
settings.hasCompletedModelBootstrap = true
|
||||
}
|
||||
state.downloadError = nil
|
||||
case let .failure(err):
|
||||
let ns = err as NSError
|
||||
var message = ns.localizedDescription
|
||||
if let url = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL,
|
||||
let host = url.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
} else if let str = ns.userInfo[NSURLErrorFailingURLStringErrorKey] as? String,
|
||||
let u = URL(string: str), let host = u.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
}
|
||||
state.downloadError = message
|
||||
failureMessage = message
|
||||
}
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
if let failureMessage {
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.lastError = failureMessage
|
||||
bootstrap.progress = 0
|
||||
} else {
|
||||
bootstrap.isModelReady = true
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case .cancelDownload:
|
||||
guard let id = state.activeDownloadID else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
state.$modelBootstrapState.withLock { $0.progress = 0 }
|
||||
updateBootstrapState(&state)
|
||||
return .cancel(id: id)
|
||||
|
||||
case let .deleteModel(model):
|
||||
guard !model.isEmpty else { return .none }
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
if ModelPatternMatcher.namesMatch(model, state.selectedModel) {
|
||||
state.$modelBootstrapState.withLock { $0.isModelReady = false }
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.deleteModel(resolved)
|
||||
await send(.modelDeleted(resolved))
|
||||
} catch {
|
||||
await send(.modelDeletionFailed(error))
|
||||
}
|
||||
}
|
||||
|
||||
case let .modelDeleted(model):
|
||||
state.availableModels[id: model]?.isDownloaded = false
|
||||
for index in state.curatedModels.indices
|
||||
where ModelPatternMatcher.matches(state.curatedModels[index].internalName, model) {
|
||||
state.curatedModels[index].isDownloaded = false
|
||||
}
|
||||
if ModelPatternMatcher.namesMatch(state.selectedModel, model) {
|
||||
let fallback = state.availableModels.first { $0.isDownloaded }?.name ?? ""
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
return .send(.fetchModels)
|
||||
|
||||
case let .modelDeletionFailed(error):
|
||||
state.downloadError = error.localizedDescription
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case let .openModelLocation(model):
|
||||
return openModelLocationEffect(for: model)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func openModelLocationEffect(for model: String) -> Effect<Action> {
|
||||
// Parakeet caches live under FluidAudio's directory, not the WhisperKit
|
||||
// models folder. Route "Show in Finder" to the matching root so users
|
||||
// don't end up staring at an empty WhisperKit folder thinking the
|
||||
// Parakeet download silently failed.
|
||||
let usesParakeetRoot = ParakeetModel(rawValue: model) != nil
|
||||
return .run { _ in
|
||||
let base = try usesParakeetRoot
|
||||
? URL.hexParakeetModelsDirectory
|
||||
: URL.hexModelsDirectory
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: base.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ModelDownloadFeature.State {
|
||||
var preferredParakeetIdentifier: String {
|
||||
(prefersEnglishParakeet ? ParakeetModel.englishV2 : ParakeetModel.multilingualV3).identifier
|
||||
}
|
||||
|
||||
private var prefersEnglishParakeet: Bool {
|
||||
guard let language = hexSettings.outputLanguage?.lowercased(), !language.isEmpty else {
|
||||
return true
|
||||
}
|
||||
return language.hasPrefix("en")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
private func modelNamesMatch(_ lhs: String, _ rhs: String) -> Bool {
|
||||
ModelPatternMatcher.namesMatch(lhs, rhs)
|
||||
}
|
||||
|
||||
public struct ModelDownloadView: View {
|
||||
@ObserveInjection var inject
|
||||
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@State private var isModelLibraryPresented = false
|
||||
var shouldFlash: Bool = false
|
||||
|
||||
public init(store: StoreOf<ModelDownloadFeature>, shouldFlash: Bool = false) {
|
||||
self.store = store
|
||||
self.shouldFlash = shouldFlash
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !store.modelBootstrapState.isModelReady,
|
||||
let message = store.modelBootstrapState.lastError,
|
||||
!message.isEmpty
|
||||
{
|
||||
AutoDownloadBannerView(
|
||||
title: "Download failed",
|
||||
subtitle: message,
|
||||
progress: nil,
|
||||
style: .error
|
||||
)
|
||||
}
|
||||
if selectedModelName == nil, downloadingModel == nil {
|
||||
NoModelChooser(
|
||||
models: parakeetModels,
|
||||
suggestedModel: suggestedParakeetIdentifier,
|
||||
isLoading: store.isLoadingModels,
|
||||
isFlashing: shouldFlash,
|
||||
onDownload: { store.send(.downloadModel($0.internalName)) },
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onRetry: { store.send(.fetchModels) }
|
||||
)
|
||||
} else {
|
||||
CurrentModelSummary(
|
||||
model: selectedModel,
|
||||
selectedModelName: selectedModelName,
|
||||
isInstalled: store.selectedModelIsDownloaded,
|
||||
isDownloadingAnything: store.isDownloading,
|
||||
downloadingModel: downloadingModel,
|
||||
downloadProgress: store.downloadProgress,
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onDownload: {
|
||||
if let name = selectedModelName {
|
||||
store.send(.downloadModel(name))
|
||||
}
|
||||
},
|
||||
onCancelDownload: { store.send(.cancelDownload) }
|
||||
)
|
||||
}
|
||||
if let err = store.downloadError,
|
||||
err != store.modelBootstrapState.lastError
|
||||
{
|
||||
Text("Model Error: \(err)")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.sheet(isPresented: $isModelLibraryPresented) {
|
||||
ModelLibrarySheet(store: store)
|
||||
}
|
||||
.task {
|
||||
if store.availableModels.isEmpty {
|
||||
store.send(.fetchModels)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var selectedModel: CuratedModelInfo? {
|
||||
guard let selectedModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, selectedModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelName: String? {
|
||||
store.selectedModelNameForDisplay
|
||||
}
|
||||
|
||||
private var downloadingModel: CuratedModelInfo? {
|
||||
guard let downloadingModelName = store.downloadingModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, downloadingModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var parakeetModels: [CuratedModelInfo] {
|
||||
let models = store.curatedModels.filter(\.isParakeet)
|
||||
return models.filter { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } +
|
||||
models.filter { !modelNamesMatch($0.internalName, suggestedParakeetIdentifier) }
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelChooser: View {
|
||||
let models: [CuratedModelInfo]
|
||||
let suggestedModel: String
|
||||
let isLoading: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: (CuratedModelInfo) -> Void
|
||||
let onBrowse: () -> Void
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Choose a transcription model")
|
||||
.font(.body.weight(.medium))
|
||||
Text("Download a local model to start transcribing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("View All Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if models.isEmpty {
|
||||
if isLoading {
|
||||
Text("Loading model choices…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack {
|
||||
Text("Model choices couldn’t be loaded.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry", action: onRetry)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(models) { model in
|
||||
NoModelCard(
|
||||
model: model,
|
||||
isRecommended: matches(model),
|
||||
isFlashing: isFlashing && matches(model),
|
||||
onDownload: { onDownload(model) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private func matches(_ model: CuratedModelInfo) -> Bool {
|
||||
modelNamesMatch(model.internalName, suggestedModel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelCard: View {
|
||||
let model: CuratedModelInfo
|
||||
let isRecommended: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onDownload) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer(minLength: 8)
|
||||
if isRecommended {
|
||||
Text("Suggested")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(model.size)
|
||||
Text("·")
|
||||
Text(model.storageSize)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(NSColor.windowBackgroundColor).opacity(0.45), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
if isRecommended { return Color.accentColor.opacity(0.45) }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CurrentModelSummary: View {
|
||||
let model: CuratedModelInfo?
|
||||
let selectedModelName: String?
|
||||
let isInstalled: Bool
|
||||
let isDownloadingAnything: Bool
|
||||
let downloadingModel: CuratedModelInfo?
|
||||
let downloadProgress: Double
|
||||
let onBrowse: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.accentColor)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.secondary)
|
||||
if let activeDownloadStatus {
|
||||
Text(activeDownloadStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if isDownloadingAnything {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isDownloadingAnything {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
if needsDownload {
|
||||
Button("Download", action: onDownload)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button("Browse Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// The selection references a model that isn't on disk (e.g. the scan came
|
||||
/// back empty after an update). Offer a direct download instead of
|
||||
/// pretending it's installed.
|
||||
private var needsDownload: Bool {
|
||||
selectedModelName != nil && !isInstalled && !isDownloadingAnything
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
if let model { return model.displayName }
|
||||
if let selectedModelName {
|
||||
return selectedModelName
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
if let downloadingModel { return "Downloading \(downloadingModel.displayName)…" }
|
||||
return "Choose a transcription model"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if needsDownload { return "Not downloaded — transcription won't work until you download it." }
|
||||
if let model { return "\(model.size) · \(model.storageSize)" }
|
||||
if selectedModelName != nil { return "Installed local model" }
|
||||
if let downloadingModel { return "\(downloadingModel.storageSize) will be stored locally on this Mac." }
|
||||
return "Download a local model to start transcribing."
|
||||
}
|
||||
|
||||
private var activeDownloadStatus: String? {
|
||||
guard selectedModelName != nil, let downloadingModel else { return nil }
|
||||
return "Downloading \(downloadingModel.displayName)…"
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
if needsDownload { return "exclamationmark.triangle.fill" }
|
||||
if selectedModelName == nil { return "arrow.down.circle.fill" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct ModelLibrarySheet: View {
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var pendingDelete: CuratedModelInfo?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Model Library")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Select an installed model to use it, or download another model for local transcription.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if let recommendedLibraryModel {
|
||||
modelSection(title: "Recommended", models: [recommendedLibraryModel], showsBadges: false)
|
||||
}
|
||||
if !otherLibraryModels.isEmpty {
|
||||
modelSection(title: "Other Models", models: otherLibraryModels, showsBadges: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label("Models run locally on your Mac. Downloads are stored on this device.", systemImage: "lock.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(minWidth: 680, minHeight: 420)
|
||||
.confirmationDialog(
|
||||
"Remove \(pendingDelete?.displayName ?? "model")?",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let pendingDelete {
|
||||
Button("Remove Download", role: .destructive) {
|
||||
store.send(.deleteModel(pendingDelete.internalName))
|
||||
self.pendingDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
if let pendingDelete {
|
||||
Text("This frees \(pendingDelete.storageSize) on this Mac. You can download it again anytime.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ model: CuratedModelInfo) {
|
||||
guard model.isDownloaded, !store.isDownloading else { return }
|
||||
store.send(.selectModel(model.internalName))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func modelSection(title: String, models: [CuratedModelInfo], showsBadges: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(models) { model in
|
||||
ModelLibraryRow(
|
||||
model: model,
|
||||
isSelected: model.isDownloaded && isSelected(model),
|
||||
isDownloading: isDownloading(model),
|
||||
// Only the active row receives live progress so the other
|
||||
// rows don't re-render on every tick.
|
||||
downloadProgress: isDownloading(model) ? store.downloadProgress : 0,
|
||||
isDisabled: store.isDownloading && !isDownloading(model),
|
||||
showsBadge: showsBadges && !model.isDownloaded,
|
||||
onSelect: { select(model) },
|
||||
onDownload: { store.send(.downloadModel(model.internalName)) },
|
||||
onCancelDownload: { store.send(.cancelDownload) },
|
||||
onShowInFinder: { store.send(.openModelLocation(model.internalName)) },
|
||||
onDelete: { pendingDelete = model }
|
||||
)
|
||||
if model.id != models.last?.id {
|
||||
Divider().padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
private var recommendedLibraryModel: CuratedModelInfo? {
|
||||
store.curatedModels.first { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } ?? store.curatedModels.first(where: \.isParakeet)
|
||||
}
|
||||
|
||||
private var otherLibraryModels: [CuratedModelInfo] {
|
||||
guard let recommendedLibraryModel else { return Array(store.curatedModels) }
|
||||
return store.curatedModels.filter { $0.id != recommendedLibraryModel.id }
|
||||
}
|
||||
|
||||
private func isSelected(_ model: CuratedModelInfo) -> Bool {
|
||||
let selected = store.hexSettings.selectedModel
|
||||
return modelNamesMatch(model.internalName, selected)
|
||||
}
|
||||
|
||||
private func isDownloading(_ model: CuratedModelInfo) -> Bool {
|
||||
store.isDownloading && store.downloadingModelName == model.internalName
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelLibraryRow: View {
|
||||
let model: CuratedModelInfo
|
||||
let isSelected: Bool
|
||||
let isDownloading: Bool
|
||||
let downloadProgress: Double
|
||||
let isDisabled: Bool
|
||||
let showsBadge: Bool
|
||||
let onSelect: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
let onShowInFinder: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: leadingIcon)
|
||||
.foregroundStyle(leadingColor)
|
||||
.font(.body)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 7) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
if showsBadge, let badge = model.badge {
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(model.size)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 5) {
|
||||
Text("Accuracy").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.accuracyStars)
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
Text("Speed").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.speedStars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
Text(model.storageSize)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
trailingControls
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(isDisabled)
|
||||
.opacity(isDisabled ? 0.55 : 1)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.06) : Color.clear)
|
||||
.contextMenu {
|
||||
if isDownloading {
|
||||
Button("Cancel Download", role: .destructive, action: onCancelDownload)
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
}
|
||||
if model.isDownloaded {
|
||||
managementMenuItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared by the trailing ⋯ menu and the right-click context menu.
|
||||
@ViewBuilder
|
||||
private var managementMenuItems: some View {
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
Divider()
|
||||
Button("Remove Download…", role: .destructive, action: onDelete)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControls: some View {
|
||||
if isDownloading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if model.isDownloaded {
|
||||
Menu {
|
||||
managementMenuItems
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.help("Show in Finder or remove this download")
|
||||
} else {
|
||||
Button("Download", action: onDownload)
|
||||
.controlSize(.small)
|
||||
.help("Download \(model.storageSize) and switch to this model")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String? {
|
||||
if isDownloading { return "Downloading…" }
|
||||
if isSelected { return "In Use" }
|
||||
if model.isDownloaded { return "Installed" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var leadingIcon: String {
|
||||
if isDownloading { return "arrow.down.circle.fill" }
|
||||
if isSelected { return "checkmark.circle.fill" }
|
||||
if model.isDownloaded { return "circle" }
|
||||
return "arrow.down.circle"
|
||||
}
|
||||
|
||||
private var leadingColor: Color {
|
||||
if isDownloading || isSelected { return .accentColor }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct StarRatingView: View {
|
||||
@ObserveInjection var inject
|
||||
let filled: Int
|
||||
let max: Int
|
||||
|
||||
init(_ filled: Int, max: Int = 5) {
|
||||
self.filled = filled
|
||||
self.max = max
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0 ..< max, id: \.self) { i in
|
||||
Image(systemName: i < filled ? "circle.fill" : "circle")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(i < filled ? .blue : .gray.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct ModelSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let shouldFlash: Bool
|
||||
|
||||
var body: some View {
|
||||
Section("Transcription Model") {
|
||||
ModelDownloadView(
|
||||
store: store.scope(state: \.modelDownload, action: \.modelDownload),
|
||||
shouldFlash: shouldFlash
|
||||
)
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct PermissionsSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let microphonePermission: PermissionStatus
|
||||
let accessibilityPermission: PermissionStatus
|
||||
let inputMonitoringPermission: PermissionStatus
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
// Microphone
|
||||
permissionCard(
|
||||
title: "Microphone",
|
||||
icon: "mic.fill",
|
||||
status: microphonePermission,
|
||||
action: { store.send(.requestMicrophone) }
|
||||
)
|
||||
|
||||
// Accessibility + Keyboard
|
||||
permissionCard(
|
||||
title: "Accessibility",
|
||||
icon: "accessibility",
|
||||
status: combinedAccessibilityStatus,
|
||||
action: {
|
||||
store.send(.requestAccessibility)
|
||||
store.send(.requestInputMonitoring)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if store.hotkeyPermissionState.inputMonitoring != .granted {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label {
|
||||
Text("Input Monitoring is required so Hex can listen for your hotkey.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.requestInputMonitoring)
|
||||
} label: {
|
||||
Text("Open Input Monitoring Settings")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
} header: {
|
||||
Text("Permissions")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func permissionCard(
|
||||
title: String,
|
||||
icon: String,
|
||||
status: PermissionStatus,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 16)
|
||||
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
switch status {
|
||||
case .granted:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.body)
|
||||
case .denied, .notDetermined:
|
||||
Button("Grant") {
|
||||
action()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var combinedAccessibilityStatus: PermissionStatus {
|
||||
if accessibilityPermission == .granted && inputMonitoringPermission == .granted {
|
||||
return .granted
|
||||
}
|
||||
if accessibilityPermission == .denied || inputMonitoringPermission == .denied {
|
||||
return .denied
|
||||
}
|
||||
return .notDetermined
|
||||
}
|
||||
}
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import CoreAudio
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import IdentifiedCollections
|
||||
import Sauce
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
private let settingsLogger = HexLog.settings
|
||||
private typealias SettingsAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer<AudioObjectPropertyAddress>) -> Void
|
||||
|
||||
private enum HotKeyCaptureTarget {
|
||||
case recording
|
||||
case pasteLastTranscript
|
||||
}
|
||||
|
||||
extension SharedReaderKey
|
||||
where Self == InMemoryKey<Bool>.Default
|
||||
{
|
||||
static var isSettingHotKey: Self {
|
||||
Self[.inMemory("isSettingHotKey"), default: false]
|
||||
}
|
||||
|
||||
static var isSettingPasteLastTranscriptHotkey: Self {
|
||||
Self[.inMemory("isSettingPasteLastTranscriptHotkey"), default: false]
|
||||
}
|
||||
|
||||
static var isRemappingScratchpadFocused: Self {
|
||||
Self[.inMemory("isRemappingScratchpadFocused"), default: false]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Feature
|
||||
|
||||
@Reducer
|
||||
struct SettingsFeature {
|
||||
@ObservableState
|
||||
struct State {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.isSettingHotKey) var isSettingHotKey: Bool = false
|
||||
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool = false
|
||||
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
@Shared(.hotkeyPermissionState) var hotkeyPermissionState: HotkeyPermissionState
|
||||
|
||||
var languages: IdentifiedArrayOf<Language> = []
|
||||
var currentModifiers: Modifiers = .init(modifiers: [])
|
||||
var currentPasteLastModifiers: Modifiers = .init(modifiers: [])
|
||||
var remappingScratchpadText: String = ""
|
||||
|
||||
// Available microphones
|
||||
var availableInputDevices: [AudioInputDevice] = []
|
||||
var defaultInputDeviceName: String?
|
||||
|
||||
// Model Management
|
||||
var modelDownload = ModelDownloadFeature.State()
|
||||
var shouldFlashModelSection = false
|
||||
|
||||
}
|
||||
|
||||
enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
|
||||
// Existing
|
||||
case task
|
||||
case startSettingHotKey
|
||||
case startSettingPasteLastTranscriptHotkey
|
||||
case clearPasteLastTranscriptHotkey
|
||||
case keyEvent(KeyEvent)
|
||||
case toggleOpenOnLogin(Bool)
|
||||
case toggleShowDockIcon(Bool)
|
||||
case togglePreventSystemSleep(Bool)
|
||||
case setRecordingAudioBehavior(RecordingAudioBehavior)
|
||||
case toggleSuperFastMode(Bool)
|
||||
case setUseClipboardPaste(Bool)
|
||||
case setCopyToClipboard(Bool)
|
||||
case setDoubleTapLockEnabled(Bool)
|
||||
case setUseDoubleTapOnly(Bool)
|
||||
case setMinimumKeyTime(Double)
|
||||
case setOutputLanguage(String?)
|
||||
case setSelectedMicrophoneID(String?)
|
||||
case setSoundEffectsEnabled(Bool)
|
||||
case setSoundEffectsVolume(Double)
|
||||
|
||||
// Permission delegation (forwarded to AppFeature)
|
||||
case requestMicrophone
|
||||
case requestAccessibility
|
||||
case requestInputMonitoring
|
||||
|
||||
// Microphone selection
|
||||
case loadAvailableInputDevices
|
||||
case availableInputDevicesLoaded([AudioInputDevice], String?)
|
||||
|
||||
// Model Management
|
||||
case modelDownload(ModelDownloadFeature.Action)
|
||||
|
||||
// History Management
|
||||
case toggleSaveTranscriptionHistory(Bool)
|
||||
case setMaxHistoryEntries(Int?)
|
||||
|
||||
// Modifier configuration
|
||||
case setModifierSide(Modifier.Kind, Modifier.Side)
|
||||
|
||||
// Word remappings
|
||||
case setWordRemovalsEnabled(Bool)
|
||||
case addWordRemoval
|
||||
case updateWordRemoval(WordRemoval)
|
||||
case removeWordRemoval(UUID)
|
||||
case addWordRemapping
|
||||
case updateWordRemapping(WordRemapping)
|
||||
case removeWordRemapping(UUID)
|
||||
case setRemappingScratchpadFocused(Bool)
|
||||
case setLowercaseTranscripts(Bool)
|
||||
case setRemovePunctuation(Bool)
|
||||
}
|
||||
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.recording) var recording
|
||||
@Dependency(\.soundEffects) var soundEffects
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect<Action> {
|
||||
.run { [transcriptPersistence] _ in
|
||||
for transcript in transcripts {
|
||||
try? await transcriptPersistence.deleteAudio(transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCapture(_ target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$isSettingHotKey.withLock { $0 = true }
|
||||
state.currentModifiers = .init(modifiers: [])
|
||||
case .pasteLastTranscript:
|
||||
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = true }
|
||||
state.currentPasteLastModifiers = .init(modifiers: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func endCapture(_ target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$isSettingHotKey.withLock { $0 = false }
|
||||
state.currentModifiers = .init(modifiers: [])
|
||||
case .pasteLastTranscript:
|
||||
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = false }
|
||||
state.currentPasteLastModifiers = .init(modifiers: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func captureModifiers(for target: HotKeyCaptureTarget, state: State) -> Modifiers {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.currentModifiers
|
||||
case .pasteLastTranscript:
|
||||
state.currentPasteLastModifiers
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCaptureModifiers(_ modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.currentModifiers = modifiers
|
||||
case .pasteLastTranscript:
|
||||
state.currentPasteLastModifiers = modifiers
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCapturedHotKey(key: Key?, modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$hexSettings.withLock {
|
||||
$0.hotkey.key = key
|
||||
$0.hotkey.modifiers = modifiers.erasingSides()
|
||||
}
|
||||
case .pasteLastTranscript:
|
||||
guard let key else { return }
|
||||
state.$hexSettings.withLock {
|
||||
$0.pasteLastTranscriptHotkey = HotKey(key: key, modifiers: modifiers.erasingSides())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCapture(_ keyEvent: KeyEvent, for target: HotKeyCaptureTarget, state: inout State) -> Effect<Action> {
|
||||
if keyEvent.key == .escape {
|
||||
endCapture(target, state: &state)
|
||||
return .none
|
||||
}
|
||||
|
||||
let updatedModifiers = keyEvent.modifiers.union(captureModifiers(for: target, state: state))
|
||||
updateCaptureModifiers(updatedModifiers, for: target, state: &state)
|
||||
|
||||
if target == .pasteLastTranscript, keyEvent.key != nil, updatedModifiers.isEmpty {
|
||||
return .none
|
||||
}
|
||||
|
||||
if let key = keyEvent.key {
|
||||
applyCapturedHotKey(key: key, modifiers: updatedModifiers, for: target, state: &state)
|
||||
endCapture(target, state: &state)
|
||||
return .none
|
||||
}
|
||||
|
||||
if target == .recording, keyEvent.modifiers.isEmpty {
|
||||
applyCapturedHotKey(key: nil, modifiers: updatedModifiers, for: target, state: &state)
|
||||
endCapture(target, state: &state)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
|
||||
Scope(state: \.modelDownload, action: \.modelDownload) {
|
||||
ModelDownloadFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .binding:
|
||||
let didNormalizeDoubleTapOnly = !state.hexSettings.doubleTapLockEnabled && state.hexSettings.useDoubleTapOnly
|
||||
if didNormalizeDoubleTapOnly {
|
||||
state.$hexSettings.withLock {
|
||||
$0.useDoubleTapOnly = false
|
||||
}
|
||||
}
|
||||
|
||||
return .none
|
||||
|
||||
case .task:
|
||||
if let url = Bundle.main.url(forResource: "languages", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let languages = try? JSONDecoder().decode([Language].self, from: data)
|
||||
{
|
||||
state.languages = IdentifiedArray(uniqueElements: languages)
|
||||
} else {
|
||||
settingsLogger.error("Failed to load languages JSON from bundle")
|
||||
}
|
||||
|
||||
// Listen for key events and load microphones (existing + new)
|
||||
return .run { send in
|
||||
func audioPropertyAddress(
|
||||
_ selector: AudioObjectPropertySelector,
|
||||
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
|
||||
element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain
|
||||
) -> AudioObjectPropertyAddress {
|
||||
AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: scope,
|
||||
mElement: element
|
||||
)
|
||||
}
|
||||
|
||||
await send(.modelDownload(.fetchModels))
|
||||
await send(.loadAvailableInputDevices)
|
||||
|
||||
// Listen for device connection/disconnection notifications
|
||||
// Using a simpler debounced approach with a single task
|
||||
var deviceUpdateTask: Task<Void, Never>?
|
||||
var audioHardwareObservers: [(AudioObjectPropertySelector, SettingsAudioPropertyListenerBlock)] = []
|
||||
|
||||
// Helper function to debounce device updates
|
||||
func debounceDeviceUpdate() {
|
||||
deviceUpdateTask?.cancel()
|
||||
deviceUpdateTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 500ms
|
||||
if !Task.isCancelled {
|
||||
await send(.loadAvailableInputDevices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installAudioHardwareObserver(_ selector: AudioObjectPropertySelector) {
|
||||
let listener: SettingsAudioPropertyListenerBlock = { _, _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
var address = audioPropertyAddress(selector)
|
||||
let status = AudioObjectAddPropertyListenerBlock(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
DispatchQueue.main,
|
||||
listener
|
||||
)
|
||||
|
||||
if status == noErr {
|
||||
audioHardwareObservers.append((selector, listener))
|
||||
} else {
|
||||
settingsLogger.error("Failed to observe audio hardware selector \(selector): \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
let deviceConnectionObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasConnected"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let deviceDisconnectionObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasDisconnected"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let appDidBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didWakeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
installAudioHardwareObserver(kAudioHardwarePropertyDefaultInputDevice)
|
||||
installAudioHardwareObserver(kAudioHardwarePropertyDevices)
|
||||
|
||||
// Be sure to clean up resources when the task is finished
|
||||
defer {
|
||||
deviceUpdateTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(deviceConnectionObserver)
|
||||
NotificationCenter.default.removeObserver(deviceDisconnectionObserver)
|
||||
NotificationCenter.default.removeObserver(appDidBecomeActiveObserver)
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
|
||||
|
||||
for (selector, listener) in audioHardwareObservers {
|
||||
var address = audioPropertyAddress(selector)
|
||||
let status = AudioObjectRemovePropertyListenerBlock(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
DispatchQueue.main,
|
||||
listener
|
||||
)
|
||||
if status != noErr {
|
||||
settingsLogger.error("Failed to remove audio hardware observer for selector \(selector): \(status)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for try await keyEvent in await keyEventMonitor.listenForKeyPress() {
|
||||
await send(.keyEvent(keyEvent))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .startSettingHotKey:
|
||||
beginCapture(.recording, state: &state)
|
||||
return .none
|
||||
|
||||
case .addWordRemoval:
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemovals.append(.init(pattern: ""))
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .updateWordRemoval(removal):
|
||||
state.$hexSettings.withLock {
|
||||
guard let index = $0.wordRemovals.firstIndex(where: { $0.id == removal.id }) else { return }
|
||||
$0.wordRemovals[index] = removal
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .removeWordRemoval(id):
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemovals.removeAll { $0.id == id }
|
||||
}
|
||||
return .none
|
||||
|
||||
case .addWordRemapping:
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemappings.append(.init(match: "", replacement: ""))
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .updateWordRemapping(remapping):
|
||||
state.$hexSettings.withLock {
|
||||
guard let index = $0.wordRemappings.firstIndex(where: { $0.id == remapping.id }) else { return }
|
||||
$0.wordRemappings[index] = remapping
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .removeWordRemapping(id):
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemappings.removeAll { $0.id == id }
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setRemappingScratchpadFocused(isFocused):
|
||||
state.$isRemappingScratchpadFocused.withLock { $0 = isFocused }
|
||||
return .none
|
||||
|
||||
case let .setLowercaseTranscripts(enabled):
|
||||
state.$hexSettings.withLock { $0.lowercaseTranscripts = enabled }
|
||||
return .none
|
||||
|
||||
case let .setRemovePunctuation(enabled):
|
||||
state.$hexSettings.withLock { $0.removePunctuation = enabled }
|
||||
return .none
|
||||
|
||||
case .startSettingPasteLastTranscriptHotkey:
|
||||
beginCapture(.pasteLastTranscript, state: &state)
|
||||
return .none
|
||||
|
||||
case .clearPasteLastTranscriptHotkey:
|
||||
state.$hexSettings.withLock { $0.pasteLastTranscriptHotkey = nil }
|
||||
return .none
|
||||
|
||||
case let .keyEvent(keyEvent):
|
||||
if state.isSettingPasteLastTranscriptHotkey {
|
||||
return handleCapture(keyEvent, for: .pasteLastTranscript, state: &state)
|
||||
}
|
||||
|
||||
guard state.isSettingHotKey else { return .none }
|
||||
return handleCapture(keyEvent, for: .recording, state: &state)
|
||||
|
||||
case let .toggleOpenOnLogin(enabled):
|
||||
state.$hexSettings.withLock { $0.openOnLogin = enabled }
|
||||
return .run { _ in
|
||||
if enabled {
|
||||
try? SMAppService.mainApp.register()
|
||||
} else {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
case let .toggleShowDockIcon(enabled):
|
||||
state.$hexSettings.withLock { $0.showDockIcon = enabled }
|
||||
return .run { _ in
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .updateAppMode, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
case let .togglePreventSystemSleep(enabled):
|
||||
state.$hexSettings.withLock { $0.preventSystemSleep = enabled }
|
||||
return .none
|
||||
|
||||
case let .setUseClipboardPaste(enabled):
|
||||
state.$hexSettings.withLock { $0.useClipboardPaste = enabled }
|
||||
return .none
|
||||
|
||||
case let .setCopyToClipboard(enabled):
|
||||
state.$hexSettings.withLock { $0.copyToClipboard = enabled }
|
||||
return .none
|
||||
|
||||
case let .setRecordingAudioBehavior(behavior):
|
||||
state.$hexSettings.withLock { $0.recordingAudioBehavior = behavior }
|
||||
return .none
|
||||
|
||||
case let .toggleSuperFastMode(enabled):
|
||||
state.$hexSettings.withLock { $0.superFastModeEnabled = enabled }
|
||||
return .run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
|
||||
case let .setDoubleTapLockEnabled(enabled):
|
||||
state.$hexSettings.withLock {
|
||||
$0.doubleTapLockEnabled = enabled
|
||||
if !enabled {
|
||||
$0.useDoubleTapOnly = false
|
||||
}
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setUseDoubleTapOnly(enabled):
|
||||
state.$hexSettings.withLock {
|
||||
$0.useDoubleTapOnly = enabled && $0.doubleTapLockEnabled
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setMinimumKeyTime(value):
|
||||
state.$hexSettings.withLock { $0.minimumKeyTime = value }
|
||||
return .none
|
||||
|
||||
case let .setOutputLanguage(language):
|
||||
state.$hexSettings.withLock { $0.outputLanguage = language }
|
||||
return .none
|
||||
|
||||
case let .setSelectedMicrophoneID(deviceID):
|
||||
state.$hexSettings.withLock { $0.selectedMicrophoneID = deviceID }
|
||||
return .run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
|
||||
case let .setSoundEffectsEnabled(enabled):
|
||||
state.$hexSettings.withLock { $0.soundEffectsEnabled = enabled }
|
||||
return .run { _ in
|
||||
await soundEffects.setEnabled(enabled)
|
||||
}
|
||||
|
||||
case let .setSoundEffectsVolume(volume):
|
||||
state.$hexSettings.withLock { $0.soundEffectsVolume = volume }
|
||||
return .none
|
||||
|
||||
// Permission requests
|
||||
case .requestMicrophone:
|
||||
settingsLogger.info("User requested microphone permission from settings")
|
||||
return .none
|
||||
|
||||
case .requestAccessibility:
|
||||
settingsLogger.info("User requested accessibility permission from settings")
|
||||
return .none
|
||||
|
||||
case .requestInputMonitoring:
|
||||
settingsLogger.info("User requested input monitoring permission from settings")
|
||||
return .none
|
||||
|
||||
// Model Management
|
||||
case .modelDownload:
|
||||
return .none
|
||||
|
||||
// Microphone device selection
|
||||
case .loadAvailableInputDevices:
|
||||
return .run { send in
|
||||
let devices = await recording.getAvailableInputDevices()
|
||||
let defaultName = await recording.getDefaultInputDeviceName()
|
||||
await send(.availableInputDevicesLoaded(devices, defaultName))
|
||||
}
|
||||
|
||||
case let .availableInputDevicesLoaded(devices, defaultName):
|
||||
if let selectedMicrophoneID = state.hexSettings.selectedMicrophoneID,
|
||||
let device = devices.first(where: { $0.legacyID == selectedMicrophoneID }) {
|
||||
state.availableInputDevices = devices
|
||||
state.defaultInputDeviceName = defaultName
|
||||
return .send(.setSelectedMicrophoneID(device.id))
|
||||
}
|
||||
state.availableInputDevices = devices
|
||||
state.defaultInputDeviceName = defaultName
|
||||
return .none
|
||||
|
||||
case let .toggleSaveTranscriptionHistory(enabled):
|
||||
state.$hexSettings.withLock { $0.saveTranscriptionHistory = enabled }
|
||||
|
||||
// If disabling history, delete all existing entries
|
||||
if !enabled {
|
||||
let transcripts = state.transcriptionHistory.history
|
||||
|
||||
// Clear the history
|
||||
state.$transcriptionHistory.withLock { history in
|
||||
history.history.removeAll()
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: transcripts)
|
||||
}
|
||||
|
||||
return .none
|
||||
|
||||
case let .setMaxHistoryEntries(maxHistoryEntries):
|
||||
state.$hexSettings.withLock { $0.maxHistoryEntries = maxHistoryEntries }
|
||||
return .none
|
||||
|
||||
case let .setModifierSide(kind, side):
|
||||
guard state.hexSettings.hotkey.key == nil else { return .none }
|
||||
state.$hexSettings.withLock {
|
||||
$0.hotkey.modifiers = $0.hotkey.modifiers.setting(kind: kind, to: side)
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setWordRemovalsEnabled(enabled):
|
||||
state.$hexSettings.withLock { $0.wordRemovalsEnabled = enabled }
|
||||
return .none
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let microphonePermission: PermissionStatus
|
||||
let accessibilityPermission: PermissionStatus
|
||||
let inputMonitoringPermission: PermissionStatus
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if microphonePermission != .granted
|
||||
|| accessibilityPermission != .granted
|
||||
|| inputMonitoringPermission != .granted {
|
||||
PermissionsSectionView(
|
||||
store: store,
|
||||
microphonePermission: microphonePermission,
|
||||
accessibilityPermission: accessibilityPermission,
|
||||
inputMonitoringPermission: inputMonitoringPermission
|
||||
)
|
||||
}
|
||||
|
||||
ModelSectionView(store: store, shouldFlash: store.shouldFlashModelSection)
|
||||
// Only show language picker for WhisperKit models (not Parakeet)
|
||||
if ParakeetModel(rawValue: store.hexSettings.selectedModel) == nil {
|
||||
LanguageSectionView(store: store)
|
||||
}
|
||||
|
||||
HotKeySectionView(store: store)
|
||||
|
||||
if microphonePermission == .granted {
|
||||
MicrophoneSelectionSectionView(store: store)
|
||||
}
|
||||
|
||||
SoundSectionView(store: store)
|
||||
GeneralSectionView(store: store)
|
||||
HistorySectionView(store: store)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
await store.send(.task).finish()
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Styles
|
||||
|
||||
extension Text {
|
||||
/// Applies caption font with secondary color, commonly used for helper/description text in settings.
|
||||
func settingsCaption() -> some View {
|
||||
self.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct SoundSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
let sliderBinding = Binding<Double>(
|
||||
get: { volumePercentage(for: store.hexSettings.soundEffectsVolume) },
|
||||
set: { store.send(.setSoundEffectsVolume(actualVolume(fromPercentage: $0))) }
|
||||
)
|
||||
|
||||
return Section {
|
||||
Label {
|
||||
Toggle(
|
||||
"Sound Effects",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.soundEffectsEnabled },
|
||||
set: { store.send(.setSoundEffectsEnabled($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Volume")
|
||||
Spacer()
|
||||
Text(formattedVolume(for: store.hexSettings.soundEffectsVolume))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(value: sliderBinding, in: 0...1)
|
||||
.disabled(!store.hexSettings.soundEffectsEnabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Sound")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedVolume(for actualVolume: Double) -> String {
|
||||
let percent = volumePercentage(for: actualVolume)
|
||||
return "\(Int(round(percent * 100)))%"
|
||||
}
|
||||
|
||||
private func volumePercentage(for actualVolume: Double) -> Double {
|
||||
guard HexSettings.baseSoundEffectsVolume > 0 else { return 0 }
|
||||
let ratio = actualVolume / HexSettings.baseSoundEffectsVolume
|
||||
return max(0, min(1, ratio))
|
||||
}
|
||||
|
||||
private func actualVolume(fromPercentage percentage: Double) -> Double {
|
||||
let clampedPercentage = max(0, min(1, percentage))
|
||||
return clampedPercentage * HexSettings.baseSoundEffectsVolume
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//
|
||||
// TranscriptionFeature.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import ComposableArchitecture
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import WhisperKit
|
||||
|
||||
private let transcriptionFeatureLogger = HexLog.transcription
|
||||
|
||||
@Reducer
|
||||
struct TranscriptionFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var isRecording: Bool = false
|
||||
var isTranscribing: Bool = false
|
||||
var isPrewarming: Bool = false
|
||||
var error: String?
|
||||
var recordingStartTime: Date?
|
||||
var meter: Meter = .init(averagePower: 0, peakPower: 0)
|
||||
var sourceAppBundleID: String?
|
||||
var sourceAppName: String?
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case task
|
||||
case audioLevelUpdated(Meter)
|
||||
|
||||
// Hotkey actions
|
||||
case hotKeyPressed
|
||||
case hotKeyReleased
|
||||
|
||||
// Recording flow
|
||||
case startRecording
|
||||
case stopRecording
|
||||
|
||||
// Cancel/discard flow
|
||||
case cancel // Explicit cancellation with sound
|
||||
case discard // Silent discard (too short/accidental)
|
||||
|
||||
// Transcription result flow
|
||||
case transcriptionResult(String, URL, TimeInterval)
|
||||
case transcriptionError(Error, URL?)
|
||||
|
||||
// Model availability
|
||||
case modelMissing
|
||||
}
|
||||
|
||||
enum CancelID {
|
||||
case metering
|
||||
case recordingStart
|
||||
case recordingCleanup
|
||||
case transcription
|
||||
}
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.recording) var recording
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.soundEffects) var soundEffect
|
||||
@Dependency(\.sleepManagement) var sleepManagement
|
||||
@Dependency(\.date.now) var now
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
// MARK: - Lifecycle / Setup
|
||||
|
||||
case .task:
|
||||
// Starts two concurrent effects:
|
||||
// 1) Observing audio meter
|
||||
// 2) Monitoring hot key events
|
||||
// 3) Priming the recorder for instant startup
|
||||
return .merge(
|
||||
startMeteringEffect(),
|
||||
startHotKeyMonitoringEffect(),
|
||||
warmUpRecorderEffect()
|
||||
)
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
case let .audioLevelUpdated(meter):
|
||||
state.meter = meter
|
||||
return .none
|
||||
|
||||
// MARK: - HotKey Flow
|
||||
|
||||
case .hotKeyPressed:
|
||||
// If we're transcribing, send a cancel first. Otherwise start recording immediately.
|
||||
// We'll decide later (on release) whether to keep or discard the recording.
|
||||
return handleHotKeyPressed(isTranscribing: state.isTranscribing)
|
||||
|
||||
case .hotKeyReleased:
|
||||
// If we're currently recording, then stop. Otherwise, just cancel
|
||||
// the delayed "startRecording" effect if we never actually started.
|
||||
return handleHotKeyReleased(isRecording: state.isRecording)
|
||||
|
||||
// MARK: - Recording Flow
|
||||
|
||||
case .startRecording:
|
||||
return handleStartRecording(&state)
|
||||
|
||||
case .stopRecording:
|
||||
return handleStopRecording(&state)
|
||||
|
||||
// MARK: - Transcription Results
|
||||
|
||||
case let .transcriptionResult(result, audioURL, duration):
|
||||
return handleTranscriptionResult(&state, result: result, audioURL: audioURL, duration: duration)
|
||||
|
||||
case let .transcriptionError(error, audioURL):
|
||||
return handleTranscriptionError(&state, error: error, audioURL: audioURL)
|
||||
|
||||
case .modelMissing:
|
||||
return .none
|
||||
|
||||
// MARK: - Cancel/Discard Flow
|
||||
|
||||
case .cancel:
|
||||
// Only cancel if we're in the middle of recording, transcribing, or post-processing
|
||||
guard state.isRecording || state.isTranscribing else {
|
||||
return .none
|
||||
}
|
||||
return handleCancel(&state)
|
||||
|
||||
case .discard:
|
||||
// Silent discard for quick/accidental recordings
|
||||
guard state.isRecording else {
|
||||
return .none
|
||||
}
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Effects: Metering & HotKey
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
/// Effect to begin observing the audio meter.
|
||||
func startMeteringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
for await meter in await recording.observeAudioLevel() {
|
||||
await send(.audioLevelUpdated(meter))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.metering, cancelInFlight: true)
|
||||
}
|
||||
|
||||
/// Effect to start monitoring hotkey events through the `keyEventMonitor`.
|
||||
func startHotKeyMonitoringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option]))
|
||||
@Shared(.isSettingHotKey) var isSettingHotKey: Bool
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
// Handle incoming input events (keyboard and mouse)
|
||||
let token = keyEventMonitor.handleInputEvent { inputEvent in
|
||||
// Skip if the user is currently setting a hotkey
|
||||
if isSettingHotKey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Always keep hotKeyProcessor in sync with current user hotkey preference
|
||||
hotKeyProcessor.hotkey = hexSettings.hotkey
|
||||
let useDoubleTapOnly = hexSettings.doubleTapLockEnabled && hexSettings.useDoubleTapOnly
|
||||
hotKeyProcessor.doubleTapLockEnabled = hexSettings.doubleTapLockEnabled
|
||||
hotKeyProcessor.useDoubleTapOnly = useDoubleTapOnly
|
||||
hotKeyProcessor.minimumKeyTime = hexSettings.minimumKeyTime
|
||||
|
||||
switch inputEvent {
|
||||
case .keyboard(let keyEvent):
|
||||
// If Escape is pressed with no modifiers while idle, let's treat that as `cancel`.
|
||||
if keyEvent.key == .escape, keyEvent.modifiers.isEmpty,
|
||||
hotKeyProcessor.state == .idle
|
||||
{
|
||||
Task { await send(.cancel) }
|
||||
return false
|
||||
}
|
||||
|
||||
// Process the key event
|
||||
switch hotKeyProcessor.process(keyEvent: keyEvent) {
|
||||
case .startRecording:
|
||||
Task { await send(.hotKeyPressed) }
|
||||
// If the hotkey is purely modifiers, return false to keep it from interfering with normal usage
|
||||
// But if useDoubleTapOnly is true, always intercept the key
|
||||
return useDoubleTapOnly || keyEvent.key != nil
|
||||
|
||||
case .stopRecording:
|
||||
Task { await send(.hotKeyReleased) }
|
||||
return false // or `true` if you want to intercept
|
||||
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return true
|
||||
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept - let the key chord reach other apps
|
||||
|
||||
case .none:
|
||||
// If we detect repeated same chord, maybe intercept.
|
||||
if let pressedKey = keyEvent.key,
|
||||
pressedKey == hotKeyProcessor.hotkey.key,
|
||||
keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case .mouseClick:
|
||||
// Process mouse click - for modifier-only hotkeys, this may cancel/discard
|
||||
switch hotKeyProcessor.processMouseClick() {
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return false // Don't intercept the click itself
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept the click itself
|
||||
case .startRecording, .stopRecording, .none:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defer { token.cancel() }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
} onCancel: {
|
||||
token.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func warmUpRecorderEffect() -> Effect<Action> {
|
||||
.run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HotKey Press/Release Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleHotKeyPressed(isTranscribing: Bool) -> Effect<Action> {
|
||||
// If already transcribing, cancel first. Otherwise start recording immediately.
|
||||
guard isTranscribing else { return .send(.startRecording) }
|
||||
return .concatenate(
|
||||
.send(.cancel),
|
||||
.send(.startRecording)
|
||||
)
|
||||
}
|
||||
|
||||
func handleHotKeyReleased(isRecording: Bool) -> Effect<Action> {
|
||||
// Always stop recording when hotkey is released
|
||||
return isRecording ? .send(.stopRecording) : .none
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recording Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleStartRecording(_ state: inout State) -> Effect<Action> {
|
||||
guard state.modelBootstrapState.isModelReady else {
|
||||
return .merge(
|
||||
.send(.modelMissing),
|
||||
.run { _ in soundEffect.play(.cancel) }
|
||||
)
|
||||
}
|
||||
state.isRecording = true
|
||||
let startTime = now
|
||||
state.recordingStartTime = startTime
|
||||
|
||||
// Capture the active application
|
||||
if let activeApp = NSWorkspace.shared.frontmostApplication {
|
||||
state.sourceAppBundleID = activeApp.bundleIdentifier
|
||||
state.sourceAppName = activeApp.localizedName
|
||||
}
|
||||
transcriptionFeatureLogger.notice("Recording started at \(startTime.ISO8601Format())")
|
||||
|
||||
// Prevent system sleep during recording
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingCleanup),
|
||||
.run { [sleepManagement, preventSleep = state.hexSettings.preventSystemSleep] _ in
|
||||
// Play sound immediately for instant feedback
|
||||
soundEffect.play(.startRecording)
|
||||
|
||||
if preventSleep {
|
||||
await sleepManagement.preventSleep(reason: "Hex Voice Recording")
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
if preventSleep {
|
||||
await sleepManagement.allowSleep()
|
||||
}
|
||||
return
|
||||
}
|
||||
await recording.startRecording()
|
||||
}
|
||||
.cancellable(id: CancelID.recordingStart, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleStopRecording(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
|
||||
let stopTime = now
|
||||
let startTime = state.recordingStartTime
|
||||
let duration = startTime.map { stopTime.timeIntervalSince($0) } ?? 0
|
||||
|
||||
let decision = RecordingDecisionEngine.decide(
|
||||
.init(
|
||||
hotkey: state.hexSettings.hotkey,
|
||||
minimumKeyTime: state.hexSettings.minimumKeyTime,
|
||||
recordingStartTime: state.recordingStartTime,
|
||||
currentTime: stopTime
|
||||
)
|
||||
)
|
||||
|
||||
let startStamp = startTime?.ISO8601Format() ?? "nil"
|
||||
let stopStamp = stopTime.ISO8601Format()
|
||||
let minimumKeyTime = state.hexSettings.minimumKeyTime
|
||||
let hotkeyHasKey = state.hexSettings.hotkey.key != nil
|
||||
transcriptionFeatureLogger.notice(
|
||||
"Recording stopped duration=\(String(format: "%.3f", duration))s start=\(startStamp) stop=\(stopStamp) decision=\(String(describing: decision)) minimumKeyTime=\(String(format: "%.2f", minimumKeyTime)) hotkeyHasKey=\(hotkeyHasKey)"
|
||||
)
|
||||
|
||||
guard decision == .proceedToTranscription else {
|
||||
// If the user recorded for less than minimumKeyTime and the hotkey is modifier-only,
|
||||
// discard the audio to avoid accidental triggers.
|
||||
transcriptionFeatureLogger.notice("Discarding short recording per decision \(String(describing: decision))")
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
// Defense-in-depth: handleStartRecording already blocks recording when the
|
||||
// bootstrap state says no model is ready, but settings can change while a
|
||||
// recording is in flight (or the in-memory bootstrap default can race a
|
||||
// cold launch). Never hand an empty model name to the transcriber: it
|
||||
// silently produces nothing (or junk like "[BLANK_AUDIO]").
|
||||
transcriptionFeatureLogger.error("Recording stopped with no transcription model selected; discarding audio")
|
||||
return .merge(
|
||||
handleDiscard(&state),
|
||||
.send(.modelMissing)
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise, proceed to transcription
|
||||
state.isTranscribing = true
|
||||
state.error = nil
|
||||
let language = state.hexSettings.outputLanguage
|
||||
|
||||
state.isPrewarming = true
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] send in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
|
||||
var audioURL: URL?
|
||||
defer {
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
do {
|
||||
let stopResult = await recording.stopRecording()
|
||||
let capturedURL: URL
|
||||
switch stopResult {
|
||||
case let .captured(url):
|
||||
capturedURL = url
|
||||
case .ignored(.staleSession):
|
||||
transcriptionFeatureLogger.notice("Ignoring transcription stop superseded by a newer recording session")
|
||||
return
|
||||
case .ignored(.noActiveRecording):
|
||||
transcriptionFeatureLogger.error("Recording stopped without captured audio")
|
||||
await send(.transcriptionError(RecordingFailure.noCapturedAudio, nil))
|
||||
return
|
||||
case let .failed(error):
|
||||
transcriptionFeatureLogger.error("Recording stop failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
return
|
||||
}
|
||||
audioURL = capturedURL
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.stopRecording)
|
||||
|
||||
// Create transcription options with the selected language
|
||||
// Note: cap concurrency to avoid audio I/O overloads on some Macs
|
||||
let decodeOptions = DecodingOptions(
|
||||
language: language,
|
||||
detectLanguage: language == nil, // Only auto-detect if no language specified
|
||||
chunkingStrategy: .vad,
|
||||
)
|
||||
|
||||
let result = try await transcription.transcribe(capturedURL, model, decodeOptions) { _ in }
|
||||
|
||||
transcriptionFeatureLogger.notice("Transcribed audio from \(capturedURL.lastPathComponent) to text length \(result.count)")
|
||||
audioURL = nil
|
||||
await send(.transcriptionResult(result, capturedURL, duration))
|
||||
} catch {
|
||||
transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transcription Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleTranscriptionResult(
|
||||
_ state: inout State,
|
||||
result: String,
|
||||
audioURL: URL,
|
||||
duration: TimeInterval
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Check for force quit command (emergency escape hatch)
|
||||
if ForceQuitCommandDetector.matches(result) {
|
||||
transcriptionFeatureLogger.fault("Force quit voice command recognized; terminating Hex.")
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
await MainActor.run {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If empty text, nothing else to do
|
||||
guard !result.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
transcriptionFeatureLogger.info("Raw transcription: '\(result, privacy: .private)'")
|
||||
let remappings = state.hexSettings.wordRemappings
|
||||
let removalsEnabled = state.hexSettings.wordRemovalsEnabled
|
||||
let removals = state.hexSettings.wordRemovals
|
||||
let modifiedResult: String
|
||||
if state.isRemappingScratchpadFocused {
|
||||
modifiedResult = result
|
||||
transcriptionFeatureLogger.info("Scratchpad focused; skipping word modifications")
|
||||
} else {
|
||||
var output = result
|
||||
if removalsEnabled {
|
||||
let removedResult = WordRemovalApplier.apply(output, removals: removals)
|
||||
if removedResult != output {
|
||||
let enabledRemovalCount = removals.filter(\.isEnabled).count
|
||||
transcriptionFeatureLogger.info("Applied \(enabledRemovalCount) word removal(s)")
|
||||
}
|
||||
output = removedResult
|
||||
}
|
||||
let remappedResult = WordRemappingApplier.apply(output, remappings: remappings)
|
||||
if remappedResult != output {
|
||||
transcriptionFeatureLogger.info("Applied \(remappings.count) word remapping(s)")
|
||||
}
|
||||
let formattedResult = TranscriptFormattingApplier.apply(
|
||||
remappedResult,
|
||||
lowercase: state.hexSettings.lowercaseTranscripts,
|
||||
removePunctuation: state.hexSettings.removePunctuation
|
||||
)
|
||||
if formattedResult != remappedResult {
|
||||
transcriptionFeatureLogger.info("Applied paste formatting")
|
||||
}
|
||||
modifiedResult = formattedResult
|
||||
}
|
||||
|
||||
guard !modifiedResult.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
let sourceAppBundleID = state.sourceAppBundleID
|
||||
let sourceAppName = state.sourceAppName
|
||||
let transcriptionHistory = state.$transcriptionHistory
|
||||
|
||||
return .run { send in
|
||||
do {
|
||||
try await finalizeRecordingAndStoreTranscript(
|
||||
result: modifiedResult,
|
||||
duration: duration,
|
||||
sourceAppBundleID: sourceAppBundleID,
|
||||
sourceAppName: sourceAppName,
|
||||
audioURL: audioURL,
|
||||
transcriptionHistory: transcriptionHistory
|
||||
)
|
||||
} catch {
|
||||
await send(.transcriptionError(error, audioURL))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
}
|
||||
|
||||
func handleTranscriptionError(
|
||||
_ state: inout State,
|
||||
error: Error,
|
||||
audioURL: URL?
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
state.error = error.localizedDescription
|
||||
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
/// Move file to permanent location, create a transcript record, paste text, and play sound.
|
||||
func finalizeRecordingAndStoreTranscript(
|
||||
result: String,
|
||||
duration: TimeInterval,
|
||||
sourceAppBundleID: String?,
|
||||
sourceAppName: String?,
|
||||
audioURL: URL,
|
||||
transcriptionHistory: Shared<TranscriptionHistory>
|
||||
) async throws {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
if hexSettings.saveTranscriptionHistory {
|
||||
let transcript = try await transcriptPersistence.save(
|
||||
result,
|
||||
audioURL,
|
||||
duration,
|
||||
sourceAppBundleID,
|
||||
sourceAppName
|
||||
)
|
||||
|
||||
transcriptionHistory.withLock { history in
|
||||
history.history.insert(transcript, at: 0)
|
||||
|
||||
if let maxEntries = hexSettings.maxHistoryEntries, maxEntries > 0 {
|
||||
while history.history.count > maxEntries {
|
||||
if let removedTranscript = history.history.popLast() {
|
||||
Task {
|
||||
try? await transcriptPersistence.deleteAudio(removedTranscript)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
await pasteboard.paste(result)
|
||||
soundEffect.play(.pasteTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cancel/Discard Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleCancel(_ state: inout State) -> Effect<Action> {
|
||||
let wasRecording = state.isRecording
|
||||
state.isTranscribing = false
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.transcription),
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
guard wasRecording else {
|
||||
soundEffect.play(.cancel)
|
||||
return
|
||||
}
|
||||
// Stop the recording to release microphone access
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.cancel)
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleDiscard(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Silently discard - no sound effect
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct TranscriptionView: View {
|
||||
@Bindable var store: StoreOf<TranscriptionFeature>
|
||||
@ObserveInjection var inject
|
||||
|
||||
var status: TranscriptionIndicatorView.Status {
|
||||
if store.isTranscribing {
|
||||
return .transcribing
|
||||
} else if store.isRecording {
|
||||
return .recording
|
||||
} else if store.isPrewarming {
|
||||
return .prewarming
|
||||
} else {
|
||||
return .hidden
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TranscriptionIndicatorView(
|
||||
status: status,
|
||||
meter: store.meter
|
||||
)
|
||||
.task {
|
||||
await store.send(.task).finish()
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Force Quit Command
|
||||
|
||||
private enum ForceQuitCommandDetector {
|
||||
static func matches(_ text: String) -> Bool {
|
||||
let normalized = normalize(text)
|
||||
return normalized == "force quit hex now" || normalized == "force quit hex"
|
||||
}
|
||||
|
||||
private static func normalize(_ text: String) -> String {
|
||||
text
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// HexCapsuleView.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/25/25.
|
||||
|
||||
import AppKit
|
||||
import Inject
|
||||
import Pow
|
||||
import SwiftUI
|
||||
|
||||
struct TranscriptionIndicatorView: View {
|
||||
@ObserveInjection var inject
|
||||
|
||||
enum Status {
|
||||
case hidden
|
||||
case optionKeyPressed
|
||||
case recording
|
||||
case transcribing
|
||||
case prewarming
|
||||
}
|
||||
|
||||
var status: Status
|
||||
var meter: Meter
|
||||
|
||||
let transcribeBaseColor: Color = .blue
|
||||
private var backgroundColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.black
|
||||
case .recording:
|
||||
return mixedColor(mixedNSColor(.red, with: .black, by: 0.5), with: .red, by: meter.averagePower * 3)
|
||||
case .transcribing: return mixedColor(.blue, with: .black, by: 0.5)
|
||||
case .prewarming: return mixedColor(.blue, with: .black, by: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private var strokeColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.black
|
||||
case .recording: return mixedColor(.red, with: .white, by: 0.1).opacity(0.6)
|
||||
case .transcribing: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
||||
case .prewarming: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
private func mixedColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> Color {
|
||||
Color(nsColor: mixedNSColor(color, with: otherColor, by: fraction))
|
||||
}
|
||||
|
||||
private func mixedNSColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> NSColor {
|
||||
let clampedFraction = min(max(fraction, 0), 1)
|
||||
return color.blended(withFraction: clampedFraction, of: otherColor) ?? color
|
||||
}
|
||||
|
||||
private var innerShadowColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.clear
|
||||
case .recording: return Color.red
|
||||
case .transcribing: return transcribeBaseColor
|
||||
case .prewarming: return transcribeBaseColor
|
||||
}
|
||||
}
|
||||
|
||||
private let cornerRadius: CGFloat = 8
|
||||
private let baseWidth: CGFloat = 16
|
||||
private let expandedWidth: CGFloat = 56
|
||||
|
||||
var isHidden: Bool {
|
||||
status == .hidden
|
||||
}
|
||||
|
||||
@State var transcribeEffect = 0
|
||||
|
||||
var body: some View {
|
||||
let averagePower = min(1, meter.averagePower * 3)
|
||||
let peakPower = min(1, meter.peakPower * 3)
|
||||
ZStack {
|
||||
Capsule()
|
||||
.fill(backgroundColor.shadow(.inner(color: innerShadowColor, radius: 4)))
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(strokeColor, lineWidth: 1)
|
||||
.blendMode(.screen)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.red.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 1) : 0))
|
||||
.blur(radius: 2)
|
||||
.blendMode(.screen)
|
||||
.padding(6)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.white.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 0.5) : 0))
|
||||
.blur(radius: 1)
|
||||
.blendMode(.screen)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(7)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
GeometryReader { proxy in
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.red.opacity(status == .recording ? (peakPower < 0.1 ? (peakPower / 0.1) * 0.5 : 0.5) : 0))
|
||||
.frame(width: max(proxy.size.width * (peakPower + 0.6), 0), height: proxy.size.height, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.blur(radius: 4)
|
||||
.blendMode(.screen)
|
||||
}.padding(6)
|
||||
}
|
||||
.cornerRadius(cornerRadius)
|
||||
.shadow(
|
||||
color: status == .recording ? .red.opacity(averagePower) : .red.opacity(0),
|
||||
radius: 4
|
||||
)
|
||||
.shadow(
|
||||
color: status == .recording ? .red.opacity(averagePower * 0.5) : .red.opacity(0),
|
||||
radius: 8
|
||||
)
|
||||
.animation(.interactiveSpring(), value: meter)
|
||||
.frame(
|
||||
width: status == .recording ? expandedWidth : baseWidth,
|
||||
height: baseWidth
|
||||
)
|
||||
.opacity(status == .hidden ? 0 : 1)
|
||||
.scaleEffect(status == .hidden ? 0.0 : 1)
|
||||
.blur(radius: status == .hidden ? 4 : 0)
|
||||
.animation(.bouncy(duration: 0.3), value: status)
|
||||
.changeEffect(.glow(color: .red.opacity(0.5), radius: 8), value: status)
|
||||
.changeEffect(.shine(angle: .degrees(0), duration: 0.6), value: transcribeEffect)
|
||||
.compositingGroup()
|
||||
.task(id: status == .transcribing) {
|
||||
while status == .transcribing, !Task.isCancelled {
|
||||
transcribeEffect += 1
|
||||
try? await Task.sleep(for: .seconds(0.25))
|
||||
}
|
||||
}
|
||||
|
||||
// Show tooltip when prewarming
|
||||
if status == .prewarming {
|
||||
VStack(spacing: 4) {
|
||||
Text("Model prewarming...")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.black.opacity(0.8))
|
||||
)
|
||||
}
|
||||
.offset(y: -24)
|
||||
.transition(.opacity)
|
||||
.zIndex(2)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("HEX") {
|
||||
VStack(spacing: 8) {
|
||||
TranscriptionIndicatorView(status: .hidden, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .optionKeyPressed, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .recording, meter: .init(averagePower: 0.5, peakPower: 0.5))
|
||||
TranscriptionIndicatorView(status: .transcribing, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .prewarming, meter: .init(averagePower: 0, peakPower: 0))
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AudioHardwarePowerHint</key>
|
||||
<string>None</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.8.4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>91</string>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>Hex needs accessibility access to monitor keyboard events for hotkey detection.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSInputMonitoringUsageDescription</key>
|
||||
<string>Hex needs Input Monitoring permission so your global hotkey works in every app.</string>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<true/>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://hex-updates.s3.amazonaws.com/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>mIek27lttJe8cIBqVZFhh6reRKjpTx1h9ZY9OKWPtuM=</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+233
@@ -0,0 +1,233 @@
|
||||
[
|
||||
{
|
||||
"name": "Auto"
|
||||
},
|
||||
{
|
||||
"code": "af",
|
||||
"name": "Afrikaans"
|
||||
},
|
||||
{
|
||||
"code": "ar",
|
||||
"name": "Arabic"
|
||||
},
|
||||
{
|
||||
"code": "hy",
|
||||
"name": "Armenian"
|
||||
},
|
||||
{
|
||||
"code": "az",
|
||||
"name": "Azerbaijani"
|
||||
},
|
||||
{
|
||||
"code": "be",
|
||||
"name": "Belarusian"
|
||||
},
|
||||
{
|
||||
"code": "bs",
|
||||
"name": "Bosnian"
|
||||
},
|
||||
{
|
||||
"code": "bg",
|
||||
"name": "Bulgarian"
|
||||
},
|
||||
{
|
||||
"code": "ca",
|
||||
"name": "Catalan"
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"name": "Chinese"
|
||||
},
|
||||
{
|
||||
"code": "hr",
|
||||
"name": "Croatian"
|
||||
},
|
||||
{
|
||||
"code": "cs",
|
||||
"name": "Czech"
|
||||
},
|
||||
{
|
||||
"code": "da",
|
||||
"name": "Danish"
|
||||
},
|
||||
{
|
||||
"code": "nl",
|
||||
"name": "Dutch"
|
||||
},
|
||||
{
|
||||
"code": "en",
|
||||
"name": "English"
|
||||
},
|
||||
{
|
||||
"code": "et",
|
||||
"name": "Estonian"
|
||||
},
|
||||
{
|
||||
"code": "fi",
|
||||
"name": "Finnish"
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"name": "French"
|
||||
},
|
||||
{
|
||||
"code": "gl",
|
||||
"name": "Galician"
|
||||
},
|
||||
{
|
||||
"code": "de",
|
||||
"name": "German"
|
||||
},
|
||||
{
|
||||
"code": "el",
|
||||
"name": "Greek"
|
||||
},
|
||||
{
|
||||
"code": "he",
|
||||
"name": "Hebrew"
|
||||
},
|
||||
{
|
||||
"code": "hi",
|
||||
"name": "Hindi"
|
||||
},
|
||||
{
|
||||
"code": "hu",
|
||||
"name": "Hungarian"
|
||||
},
|
||||
{
|
||||
"code": "is",
|
||||
"name": "Icelandic"
|
||||
},
|
||||
{
|
||||
"code": "id",
|
||||
"name": "Indonesian"
|
||||
},
|
||||
{
|
||||
"code": "it",
|
||||
"name": "Italian"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"name": "Japanese"
|
||||
},
|
||||
{
|
||||
"code": "kn",
|
||||
"name": "Kannada"
|
||||
},
|
||||
{
|
||||
"code": "kk",
|
||||
"name": "Kazakh"
|
||||
},
|
||||
{
|
||||
"code": "ko",
|
||||
"name": "Korean"
|
||||
},
|
||||
{
|
||||
"code": "lv",
|
||||
"name": "Latvian"
|
||||
},
|
||||
{
|
||||
"code": "lt",
|
||||
"name": "Lithuanian"
|
||||
},
|
||||
{
|
||||
"code": "mk",
|
||||
"name": "Macedonian"
|
||||
},
|
||||
{
|
||||
"code": "ms",
|
||||
"name": "Malay"
|
||||
},
|
||||
{
|
||||
"code": "mr",
|
||||
"name": "Marathi"
|
||||
},
|
||||
{
|
||||
"code": "mi",
|
||||
"name": "Maori"
|
||||
},
|
||||
{
|
||||
"code": "ne",
|
||||
"name": "Nepali"
|
||||
},
|
||||
{
|
||||
"code": "no",
|
||||
"name": "Norwegian"
|
||||
},
|
||||
{
|
||||
"code": "fa",
|
||||
"name": "Persian"
|
||||
},
|
||||
{
|
||||
"code": "pl",
|
||||
"name": "Polish"
|
||||
},
|
||||
{
|
||||
"code": "pt",
|
||||
"name": "Portuguese"
|
||||
},
|
||||
{
|
||||
"code": "ro",
|
||||
"name": "Romanian"
|
||||
},
|
||||
{
|
||||
"code": "ru",
|
||||
"name": "Russian"
|
||||
},
|
||||
{
|
||||
"code": "sr",
|
||||
"name": "Serbian"
|
||||
},
|
||||
{
|
||||
"code": "sk",
|
||||
"name": "Slovak"
|
||||
},
|
||||
{
|
||||
"code": "sl",
|
||||
"name": "Slovenian"
|
||||
},
|
||||
{
|
||||
"code": "es",
|
||||
"name": "Spanish"
|
||||
},
|
||||
{
|
||||
"code": "sw",
|
||||
"name": "Swahili"
|
||||
},
|
||||
{
|
||||
"code": "sv",
|
||||
"name": "Swedish"
|
||||
},
|
||||
{
|
||||
"code": "tl",
|
||||
"name": "Tagalog"
|
||||
},
|
||||
{
|
||||
"code": "ta",
|
||||
"name": "Tamil"
|
||||
},
|
||||
{
|
||||
"code": "th",
|
||||
"name": "Thai"
|
||||
},
|
||||
{
|
||||
"code": "tr",
|
||||
"name": "Turkish"
|
||||
},
|
||||
{
|
||||
"code": "uk",
|
||||
"name": "Ukrainian"
|
||||
},
|
||||
{
|
||||
"code": "ur",
|
||||
"name": "Urdu"
|
||||
},
|
||||
{
|
||||
"code": "vi",
|
||||
"name": "Vietnamese"
|
||||
},
|
||||
{
|
||||
"code": "cy",
|
||||
"name": "Welsh"
|
||||
}
|
||||
]
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"displayName": "Parakeet TDT v2",
|
||||
"internalName": "parakeet-tdt-0.6b-v2-coreml",
|
||||
"size": "English",
|
||||
"accuracyStars": 5,
|
||||
"speedStars": 5,
|
||||
"storageSize": "650MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Parakeet TDT v3",
|
||||
"internalName": "parakeet-tdt-0.6b-v3-coreml",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 5,
|
||||
"speedStars": 5,
|
||||
"storageSize": "650MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Tiny",
|
||||
"internalName": "openai_whisper-tiny",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 2,
|
||||
"speedStars": 4,
|
||||
"storageSize": "73MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Base",
|
||||
"internalName": "openai_whisper-base",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 3,
|
||||
"speedStars": 3,
|
||||
"storageSize": "140MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Large v3 Turbo",
|
||||
"internalName": "openai_whisper-large-v3-v20240930_turbo_632MB",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 4,
|
||||
"speedStars": 4,
|
||||
"storageSize": "632MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Large v3",
|
||||
"internalName": "openai_whisper-large-v3-v20240930",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 5,
|
||||
"speedStars": 2,
|
||||
"storageSize": "1.5GB"
|
||||
}
|
||||
]
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8739929: Overhaul audio engine lifecycle: recover recordings across device/route changes instead of silently capturing nothing (#251, #252, #218, #226), rebuild the warm capture engine immediately when devices change while idle instead of leaving a stale engine running (#209), suspend the warm microphone while the screen is locked or asleep and rearm on wake, stop the sound-effects engine when idle (#209), and fix a crash on quit caused by racy audio teardown (#245)
|
||||
- 2c5d8ae: Add Whisper Large v3 Turbo (632MB) to the model library: near large-v3 accuracy at several times the speed (#185). Whisper models are now labeled by their real sizes (Tiny, Base) instead of Small/Medium.
|
||||
- 9c597cb: Prevent Hex from crashing on first launch before hotkey permissions are granted (#254)
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 224822d: Fix hotkeys dying after sleep or when macOS permission checks go stale: key events arriving at the tap now self-heal the Input Monitoring state instead of being dropped, the tap survives stale permission denials, and it is recreated on wake from sleep (#250)
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- eae358b: Never silently clear the selected model when a scan misfires, and route hotkey presses to model setup instead of transcribing with no model
|
||||
- eae358b: Redesign the model library: visible Download buttons, one-click model switching, and a menu to remove downloaded models or show them in Finder
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd971a3: Reuse existing Parakeet downloads after upgrading FluidAudio
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 276da69: Add lowercase and punctuation-removal paste transforms (#253)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 10e75b9: Simplify output transforms with clearer word rules, preview, and formatting controls.
|
||||
- 8e6e041: Improve update and changelog controls in Settings.
|
||||
- 71878b7: Defer Fast Mode capture rebuilds until audio route changes settle
|
||||
- db31b0a: Move transcription model selection into a focused model library with automatic downloads on selection.
|
||||
- c00a91d: Refresh microphones reliably, harden recording cleanup, and polish the settings overlay behavior.
|
||||
- db31b0a: Handle interrupted recording stops explicitly and ignore stale Fast Mode callbacks.
|
||||
- 2b5fb15: Recognize downloaded Parakeet models immediately after restarting Hex.
|
||||
- 8e6e041: Preserve literal dollar signs in word-remapping replacements.
|
||||
|
||||
## 0.7.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f4cecbb: Show in Finder reveals Parakeet caches at the correct path (#205)
|
||||
- f4cecbb: Stay in the menu bar when launched at login (#222)
|
||||
|
||||
## 0.7.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b5a19b4: Fix word remapping deletion crashes (#207)
|
||||
- b5a19b4: Enable Super Fast Mode by default for new users
|
||||
- b5a19b4: Restore macOS Sonoma compatibility (#215)
|
||||
|
||||
## 0.7.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5a4af9b: Fix silent recordings from multichannel input devices during calls (#204)
|
||||
- b78f049: Stop priming the sound-effects audio engine when sound effects are disabled so Hex avoids unnecessary background audio activity and sleep assertions (#200).
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7340d1e: Restore double-tap lock audio capture (#193)
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 55249a6: Keep the ends of recordings from getting clipped in super fast mode.
|
||||
- d9e40cc: Use the capture engine for normal recordings to reduce startup drift
|
||||
- d9e40cc: Keep the microphone picker visible and refresh it when audio devices change
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ed69836: Suppress startup windows when Hex launches as a hidden login item (#146)
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c5d5162: Add Super Fast mode to keep the mic warm and prepend a short in-memory buffer
|
||||
|
||||
## 0.6.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c018c40: Add setting to disable double-tap lock for hands-free recording
|
||||
- 7af7cd9: Update dependencies: TCA 1.23, Sparkle 2.8, swift-dependencies 1.11
|
||||
|
||||
## 0.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 74893ab: Support escape sequences (\n, \t, \\) in word remappings for newlines, tabs, and literal backslashes (#140)
|
||||
|
||||
## 0.6.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e2000d8: Fix Icon Composer app icon not displaying (#148)
|
||||
- 75bc323: Update macOS Tahoe app icon (#145)
|
||||
|
||||
## 0.6.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc99650: Prepare release metadata for 0.6.6
|
||||
|
||||
## 0.6.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3b6c966: Improve transcript modifications layout and remove log export settings
|
||||
- 3b6c966: Add opt-in regex word removals for transcripts (#121)
|
||||
|
||||
## 0.6.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 140c205: Fix Sparkle auto-update for sandboxed app by adding required XPC entitlements and SUEnableInstallerLauncherService. Users on 0.6.3 will need to manually download this update.
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c00f79e: Reduce code duplication: add ModelPatternMatcher, FileManager helpers, settingsCaption style, notification constants, and Core Audio helper
|
||||
- 658a755: Fix silent recordings caused by device-level microphone mute - automatically detects and fixes muted input devices before recording
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b4c54ce: Fix microphone priming and media pause races
|
||||
- 5217d3f: Add word remappings and remove LLM UI (#000)
|
||||
- 4d38708: Add persistent MCP config editing for Claude Code modes
|
||||
- bbd0b80: Show system default mic name in picker
|
||||
- bbd0b80: Fix Parakeet polling cleanup and organize paste flow
|
||||
- 3413d68: Rename Transformations tab to Modes
|
||||
- 4d38708: Fix microphone freezing and speech cutoff when using custom microphone. Only switch input device when actually needed, re-prime recorder after device changes, and add cleanup on app termination.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7e325ad: Fix Sequoia hotkey deadlock by removing Input Monitoring guard that prevented CGEventTap creation. Tap creation triggers permission prompt naturally. Re-add 'force quit Hex now' voice escape hatch from v0.5.8 (#122 #124)
|
||||
- 7e325ad: Add missing-model callout and focus settings when transcription starts without a model
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3bf2fb0: Fix voice prefix matching with punctuation - now strips punctuation (.,;:!?) when matching prefixes
|
||||
|
||||
## 0.5.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 083513c: Add comprehensive documentation to HotKeyProcessor and extract magic numbers into named constants (HexCoreConstants)
|
||||
|
||||
## 0.5.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 471310c: Fix Input Monitoring permission enforcement for hotkey reliability
|
||||
|
||||
## 0.5.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1deda2a: Route Advanced → Export Logs through the new swift-log diagnostics file so Sequoia permission bugs (#122 #124) can be diagnosed locally without relying on macOS unified logs.
|
||||
|
||||
## 0.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3560bdb: Keep hotkeys alive on Sequoia and add voice force-quit plus Advanced log export (#122 #124)
|
||||
|
||||
## 0.5.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6c2f1bd: Add comprehensive permissions logging for improved debugging and log export support
|
||||
|
||||
## 0.5.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 03b81c7: Let the hotkey tap start even when Input Monitoring is missing so Sequoia users get prompts again, while keeping the accessibility watchdog (#122 #124). Add a spoken “force quit Hex now” escape hatch in case permissions clobber input.
|
||||
|
||||
## 0.5.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 539b0a4: Pad sub-1.5s Parakeet recordings so FluidAudio accepts them
|
||||
|
||||
## 0.5.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a1eb1d0: Restore hotkeys when Input Monitoring permission is missing (#122, #124)
|
||||
- 1ee452a: Add non-interactive changeset creation for AI agents
|
||||
- 68475f5: Fix clipboard restore timing for slow apps – increased delay from 100ms to 500ms to prevent paste failures in apps that read clipboard asynchronously
|
||||
|
||||
## 0.5.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0045f28: Fix recording chime latency by switching to AVAudioEngine with pre-loaded buffers
|
||||
- 7f6c5db: Actually request macOS Input Monitoring permission when installing the key event tap so Sequoia users can record hotkeys again (#122, #124).
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix hotkey monitoring on macOS Sequoia 15.7.1 by properly handling Input Monitoring permissions (#122, #124)
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle update delivery by regenerating appcast with correct bundle versions and updating release tooling to prevent duplicate CFBundleVersion issues
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle update delivery by regenerating appcast with correct bundle versions and updating release tooling to prevent duplicate CFBundleVersion issues
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle appcast generation by cleaning duplicate bundle versions and updating release pipeline to preserve last 3 DMGs for delta generation
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 049592c: Add support for multiple Parakeet model variants: choose between English-only (v2) or multilingual (v3) based on your transcription needs.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- aca9ad5: Fix microphone access retained when recording canceled with ESC (#117)
|
||||
- 049592c: Polish paste-last-transcript hotkey UI with improved layout and clearer instructions.
|
||||
- 049592c: Improve hotkey reliability with accessibility trust monitoring and automatic recovery from tap disabled events (#89, #81, #87).
|
||||
- 049592c: Improve media pausing reliability by using MediaRemote API instead of simulated keyboard events.
|
||||
- 049592c: Fix menu bar rendering issue where items appeared as single embedded view instead of separate clickable menu items.
|
||||
- 1b9bd52: Optimize recorder startup by keeping AVAudioRecorder primed between sessions, eliminating ~500ms latency for successive recordings
|
||||
- 55fb4f8: Add a sound effects volume slider beneath the toggle so users can fine-tune feedback relative to the existing 20% baseline, keeping 100% at the legacy loudness (#000).
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e50478d: Add Parakeet TDT v3 plus the first-run model bootstrap, faster recording pipeline, and solid Fn/modifier hotkeys so the next release captures all of the recent feature work (#71, #97, #113, #89, #81, #87).
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ea42b5b: Move `HexSettings` + `RecordingAudioBehavior` into HexCore and add fixtures/tests so we can migrate historic settings blobs safely before shipping new media-ducking options.
|
||||
- e50478d: Adopt Changesets for SemVer + changelog management, wire release.ts to fail without pending fragments, and sync the aggregated release notes into the bundled changelog + GitHub releases.
|
||||
- 2fbbe7a: Wait for NSPasteboard changeCount to advance before pasting so panel apps always receive the latest transcript (#69, #42).
|
||||
|
||||
All notable changes to Hex are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added NVIDIA Parakeet TDT v3 support with a redesigned model manager so you can swap between Parakeet and curated Whisper variants without juggling files (#71).
|
||||
- Added first-run model bootstrap: Hex now automatically downloads the recommended model, shows progress/cancel controls, and prevents transcription from starting until a model is ready (#97).
|
||||
- Added a global hotkey to paste the last transcript plus contextual actions to cancel or delete model downloads directly from Settings, making recovery workflows faster.
|
||||
|
||||
### Improved
|
||||
|
||||
- Model downloads now surface the failing host/domain in their error message so DNS or network issues are easier to debug (#112).
|
||||
- Recording starts ~200–700 ms faster: start sounds play immediately, media pausing runs off the main actor, and transcription errors skip the extra cancel chime for less audio clutter (#113).
|
||||
- The transcription overlay tracks the active window so UI hints stay anchored to whichever app currently has focus.
|
||||
- HexSettings now lives inside HexCore with fixture-based migration tests, giving us a single source of truth for future settings changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Printable-key hotkeys (for example `⌘+'`) can now trigger short recordings just like modifier-only chords, so quick phrases aren’t discarded anymore (#113).
|
||||
- Fn and other modifier-only hotkeys respect left/right side selection, ignore phantom arrow events, and stop firing when combined with other keys, resolving long-standing regressions (#89, #81, #87).
|
||||
- Paste reliability: Hex now waits for the clipboard write to commit before firing ⌘V, so panel apps like Alfred, Raycast, and IntelliBar always receive the latest transcript instead of the previous clipboard contents (#69, #42).
|
||||
|
||||
## 1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bump version for stable release
|
||||
|
||||
## 0.1.33
|
||||
|
||||
### Added
|
||||
|
||||
- Add copy to clipboard option
|
||||
- Add support for complete keyboard shortcuts
|
||||
- Add indication for model prewarming
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix issue with Hex showing in Mission Control and Cmd+Tab
|
||||
- Improve paste behavior when text input fails
|
||||
- Rework audio pausing logic to make it more reliable
|
||||
|
||||
## 0.1.26
|
||||
|
||||
### Added
|
||||
|
||||
- Add changelog
|
||||
- Add option to set minimum record time
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct AutoDownloadBannerView: View {
|
||||
@ObserveInjection var inject
|
||||
enum Style {
|
||||
case info
|
||||
case error
|
||||
}
|
||||
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var progress: Double?
|
||||
var style: Style = .info
|
||||
|
||||
private var normalizedProgress: Double? {
|
||||
progress.map { min(max($0, 0), 1) }
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
switch style {
|
||||
case .info:
|
||||
return .accentColor
|
||||
case .error:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let normalizedProgress {
|
||||
ProgressView(value: normalizedProgress)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(.thinMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(accentColor.opacity(style == .error ? 0.4 : 0.25), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.12), radius: 8, x: 0, y: 4)
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch style {
|
||||
case .info:
|
||||
return "arrow.down.circle.fill"
|
||||
case .error:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 12) {
|
||||
AutoDownloadBannerView(
|
||||
title: "Preparing Parakeet",
|
||||
subtitle: "42% downloaded",
|
||||
progress: 0.42
|
||||
)
|
||||
|
||||
AutoDownloadBannerView(
|
||||
title: "Model download failed",
|
||||
subtitle: "Check your connection and retry",
|
||||
progress: nil,
|
||||
style: .error
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// InvisibleWindow.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// This allows us to render SwiftUI views anywhere on the screen, without dealing with the awkward
|
||||
/// rendering issues that come with normal MacOS windows. Essentially, we create one giant invisible
|
||||
/// window that covers the entire screen, and render our SwiftUI views into it.
|
||||
///
|
||||
/// I'm pretty sure this is what CleanShot X and other apps do to render their floating widgets.
|
||||
/// But if there's a better way to do this, I'd love to know!
|
||||
class InvisibleWindow: NSPanel {
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
private var currentScreen: NSScreen?
|
||||
private var mouseMonitor: Any?
|
||||
|
||||
init() {
|
||||
let screen = NSScreen.main ?? NSScreen.screens[0]
|
||||
let styleMask: NSWindow.StyleMask = [.fullSizeContentView, .borderless, .utilityWindow, .nonactivatingPanel]
|
||||
|
||||
super.init(contentRect: screen.frame,
|
||||
styleMask: styleMask,
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
|
||||
level = .statusBar
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
hasShadow = false
|
||||
ignoresMouseEvents = true
|
||||
hidesOnDeactivate = false // Prevent hiding when app loses focus
|
||||
canHide = false
|
||||
collectionBehavior = [.fullScreenAuxiliary, .canJoinAllSpaces, .stationary, .ignoresCycle]
|
||||
|
||||
// Set initial frame
|
||||
updateToScreenWithMouse()
|
||||
|
||||
// Start observing screen changes
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(screenDidChange),
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Also observe screen parameters for resolution changes
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(screenDidChange),
|
||||
name: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Monitor mouse movements to detect screen boundary crossings
|
||||
mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { [weak self] _ in
|
||||
self?.checkForScreenChange()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
if let monitor = mouseMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateToScreenWithMouse() {
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
guard let screenWithMouse = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else { return }
|
||||
currentScreen = screenWithMouse
|
||||
setFrame(screenWithMouse.frame, display: true)
|
||||
}
|
||||
|
||||
private func checkForScreenChange() {
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
guard let newScreen = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else { return }
|
||||
|
||||
// Only update if screen actually changed
|
||||
if newScreen !== currentScreen {
|
||||
currentScreen = newScreen
|
||||
setFrame(newScreen.frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func screenDidChange(_: Notification) {
|
||||
updateToScreenWithMouse()
|
||||
}
|
||||
}
|
||||
|
||||
extension InvisibleWindow: NSWindowDelegate {
|
||||
static func fromView<V: View>(_ view: V) -> InvisibleWindow {
|
||||
let window = InvisibleWindow()
|
||||
window.contentView = NSHostingView(rootView: view)
|
||||
window.delegate = window
|
||||
return window
|
||||
}
|
||||
}
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"originHash" : "31930225da43237330c66527a658f3d42dc4c7f482922f5d83ef0030c5e414b3",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/combine-schedulers",
|
||||
"state" : {
|
||||
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sauce",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Clipy/Sauce",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "9c0de6c233f29d892e86dda68c2dd791aa10670c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||
"state" : {
|
||||
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
|
||||
"version" : "1.0.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-dependencies",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-dependencies",
|
||||
"state" : {
|
||||
"revision" : "c79f72b3e67a1eb64f66f76704c22ed6a5c1ed84",
|
||||
"version" : "1.11.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log",
|
||||
"state" : {
|
||||
"revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
|
||||
"version" : "1.10.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax",
|
||||
"state" : {
|
||||
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
|
||||
"version" : "602.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad",
|
||||
"version" : "1.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "HexCore",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "HexCore", targets: ["HexCore"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/Clipy/Sauce", branch: "master"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.11.0"),
|
||||
.package(url: "https://github.com/apple/swift-log", from: "1.9.1"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "HexCore",
|
||||
dependencies: [
|
||||
"Sauce",
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
],
|
||||
path: "Sources/HexCore",
|
||||
linkerSettings: [
|
||||
.linkedFramework("IOKit")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "HexCoreTests",
|
||||
dependencies: ["HexCore"],
|
||||
path: "Tests/HexCoreTests",
|
||||
resources: [
|
||||
.copy("Fixtures")
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
/// Central repository for timing thresholds and magic numbers used throughout HexCore.
|
||||
///
|
||||
/// These values have been carefully tuned based on user testing and OS behavior.
|
||||
/// Changing these values may affect hotkey responsiveness and conflict with system shortcuts.
|
||||
public enum HexCoreConstants {
|
||||
|
||||
// MARK: - Hotkey Timing Thresholds
|
||||
|
||||
/// Maximum time between two hotkey taps to be considered a double-tap.
|
||||
///
|
||||
/// **Value:** 0.3 seconds
|
||||
///
|
||||
/// **Rationale:** This feels responsive for intentional double-taps while being
|
||||
/// long enough to avoid accidental triggers. Tested to align with standard
|
||||
/// UI double-click timing expectations.
|
||||
///
|
||||
/// **Used in:**
|
||||
/// - `HotKeyProcessor`: Double-tap lock detection
|
||||
/// - Tests: Verifying double-tap vs two separate taps
|
||||
public static let doubleTapWindow: TimeInterval = 0.3
|
||||
|
||||
/// Minimum duration for modifier-only hotkeys to avoid conflicts with OS shortcuts.
|
||||
///
|
||||
/// **Value:** 0.3 seconds
|
||||
///
|
||||
/// **Rationale:** macOS uses modifier keys for many shortcuts:
|
||||
/// - Option+click = duplicate in Finder
|
||||
/// - Cmd+click = open in new tab
|
||||
/// - etc.
|
||||
///
|
||||
/// A 0.3s minimum prevents accidental transcription when users perform these
|
||||
/// system actions. This value is enforced regardless of user's `minimumKeyTime` setting
|
||||
/// (though user can set higher if desired).
|
||||
///
|
||||
/// **Used in:**
|
||||
/// - `RecordingDecisionEngine`: Discard short modifier-only recordings
|
||||
/// - `HotKeyProcessor`: Mouse click cancellation threshold
|
||||
public static let modifierOnlyMinimumDuration: TimeInterval = 0.3
|
||||
|
||||
/// Time window for canceling press-and-hold on different key press.
|
||||
///
|
||||
/// **Value:** 1.0 second
|
||||
///
|
||||
/// **Rationale:** For key+modifier hotkeys (e.g., Cmd+A), if user presses a different
|
||||
/// key within 1 second, it's likely accidental (fat-finger, muscle memory for different shortcut).
|
||||
/// After 1 second, we assume the user wants to type while recording.
|
||||
///
|
||||
/// Does NOT apply to modifier-only hotkeys (they use `modifierOnlyMinimumDuration` instead).
|
||||
///
|
||||
/// **Used in:**
|
||||
/// - `HotKeyProcessor`: Accidental key press detection for key+modifier hotkeys
|
||||
public static let pressAndHoldCancelWindow: TimeInterval = 1.0
|
||||
|
||||
// MARK: - Default Settings
|
||||
|
||||
/// Default minimum time a key must be held to register as valid press.
|
||||
///
|
||||
/// **Value:** 0.2 seconds
|
||||
///
|
||||
/// **Rationale:** Prevents very quick accidental taps while still feeling responsive.
|
||||
/// User-configurable in Settings. Modifier-only hotkeys override this with
|
||||
/// `modifierOnlyMinimumDuration` if higher.
|
||||
///
|
||||
/// **Used in:**
|
||||
/// - `HexSettings`: Default value for user preference
|
||||
/// - `HotKeyProcessor`: Validation for printable-key hotkeys
|
||||
public static let defaultMinimumKeyTime: TimeInterval = 0.2
|
||||
|
||||
/// Base volume for sound effects (before user multiplier applied).
|
||||
///
|
||||
/// **Value:** 0.2 (20%)
|
||||
///
|
||||
/// **Rationale:** Quiet enough to not be jarring, loud enough to provide clear feedback.
|
||||
/// User can adjust via soundEffectsVolume multiplier.
|
||||
///
|
||||
/// **Used in:**
|
||||
/// - `HexSettings`: Default sound effects volume
|
||||
/// - Sound effect playback: Base volume before scaling
|
||||
public static let baseSoundEffectsVolume: Double = 0.2
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import os.log
|
||||
|
||||
/// Shared helper for creating consistent os.Logger instances across the Hex app and HexCore.
|
||||
public enum HexLog {
|
||||
public static let subsystem = "com.kitlangton.Hex"
|
||||
|
||||
public enum Category: String {
|
||||
case app = "App"
|
||||
case caches = "Caches"
|
||||
case transcription = "Transcription"
|
||||
case models = "Models"
|
||||
case recording = "Recording"
|
||||
case media = "Media"
|
||||
case pasteboard = "Pasteboard"
|
||||
case sound = "SoundEffect"
|
||||
case hotKey = "HotKey"
|
||||
case keyEvent = "KeyEvent"
|
||||
case parakeet = "Parakeet"
|
||||
case history = "History"
|
||||
case settings = "Settings"
|
||||
case permissions = "Permissions"
|
||||
}
|
||||
|
||||
public static func logger(_ category: Category) -> os.Logger {
|
||||
os.Logger(subsystem: subsystem, category: category.rawValue)
|
||||
}
|
||||
|
||||
public static let app = logger(.app)
|
||||
public static let caches = logger(.caches)
|
||||
public static let transcription = logger(.transcription)
|
||||
public static let models = logger(.models)
|
||||
public static let recording = logger(.recording)
|
||||
public static let media = logger(.media)
|
||||
public static let pasteboard = logger(.pasteboard)
|
||||
public static let sound = logger(.sound)
|
||||
public static let hotKey = logger(.hotKey)
|
||||
public static let keyEvent = logger(.keyEvent)
|
||||
public static let parakeet = logger(.parakeet)
|
||||
public static let history = logger(.history)
|
||||
public static let settings = logger(.settings)
|
||||
public static let permissions = logger(.permissions)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//
|
||||
// HotKeyProcessor.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/28/25.
|
||||
//
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let hotKeyLogger = HexLog.hotKey
|
||||
|
||||
/// A state machine that processes keyboard events to detect hotkey activations.
|
||||
///
|
||||
/// Implements two complementary recording modes:
|
||||
/// 1. **Press-and-Hold**: Start recording when hotkey is pressed, stop when released
|
||||
/// 2. **Double-Tap Lock**: Quick double-tap locks recording until hotkey is pressed again
|
||||
///
|
||||
/// # Architecture
|
||||
///
|
||||
/// The processor maintains three possible states:
|
||||
/// - `.idle`: Waiting for hotkey activation
|
||||
/// - `.pressAndHold(startTime)`: Recording active, will stop when hotkey released
|
||||
/// - `.doubleTapLock`: Recording locked, requires explicit hotkey press to stop
|
||||
///
|
||||
/// # Double-Tap Detection
|
||||
///
|
||||
/// A "tap" is a quick press-and-release sequence. The processor tracks release times:
|
||||
/// - First tap: Press hotkey → release → record release time
|
||||
/// - Second tap: If pressed within `doubleTapThreshold` (0.3s), enters `.doubleTapLock`
|
||||
/// - The lock persists until the user presses the hotkey again or presses ESC
|
||||
///
|
||||
/// # Press-and-Hold Behavior
|
||||
///
|
||||
/// Standard recording mode:
|
||||
/// - Hotkey pressed → `.startRecording` output, enter `.pressAndHold` state
|
||||
/// - Hotkey released → `.stopRecording` output, return to `.idle`
|
||||
/// - Different key pressed within threshold → cancel (accidental activation prevention)
|
||||
/// - Different key pressed after threshold → ignored (intentional simultaneous input)
|
||||
///
|
||||
/// # Modifier-Only Hotkey Specifics
|
||||
///
|
||||
/// For hotkeys with no key component (e.g., Option-only):
|
||||
/// - "Press" = all required modifiers held, no key pressed
|
||||
/// - "Release" = any required modifier released
|
||||
/// - Uses higher minimum duration (0.3s) to prevent conflicts with OS shortcuts
|
||||
/// - Mouse clicks within threshold → silent discard (prevents Option+click conflicts)
|
||||
/// - After threshold, only ESC cancels (mouse clicks ignored)
|
||||
///
|
||||
/// # Dirty State & Backsliding Prevention
|
||||
///
|
||||
/// After cancellation or with extra modifiers, processor enters "dirty" state:
|
||||
/// - All input ignored until full release (key:nil, modifiers:[])
|
||||
/// - Prevents accidental re-triggering during complex key combinations
|
||||
/// - User cannot "backslide" into hotkey by releasing extra modifiers
|
||||
///
|
||||
/// # ESC Key Handling
|
||||
///
|
||||
/// Pressing ESC always cancels active recordings:
|
||||
/// - Returns `.cancel` output (plays cancel sound)
|
||||
/// - Enters dirty state to prevent immediate re-triggering
|
||||
/// - Works in both `.pressAndHold` and `.doubleTapLock` states
|
||||
///
|
||||
/// # Example Interaction Flow
|
||||
///
|
||||
/// ```
|
||||
/// // Simple press-and-hold (Cmd+A hotkey)
|
||||
/// Event: Cmd+A pressed → Output: .startRecording, State: .pressAndHold
|
||||
/// Event: Cmd released → Output: .stopRecording, State: .idle
|
||||
///
|
||||
/// // Double-tap lock (Option hotkey)
|
||||
/// Event: Option pressed → Output: .startRecording, State: .pressAndHold
|
||||
/// Event: Option released → Output: .stopRecording, State: .idle
|
||||
/// Event: Option pressed → Output: .startRecording, State: .pressAndHold
|
||||
/// Event: Option released → Output: nil, State: .doubleTapLock (locked!)
|
||||
/// Event: Option pressed → Output: .stopRecording, State: .idle
|
||||
///
|
||||
/// // Accidental trigger prevention
|
||||
/// Event: Cmd+A pressed → Output: .startRecording, State: .pressAndHold
|
||||
/// Event: Cmd+B pressed (0.1s) → Output: .stopRecording, State: .idle (different key)
|
||||
/// ```
|
||||
///
|
||||
/// # Related Components
|
||||
///
|
||||
/// - `RecordingDecisionEngine`: Determines if recording duration meets minimum thresholds
|
||||
/// - `KeyEvent`: Input events from keyboard monitoring
|
||||
/// - `HotKey`: Configuration of which key/modifiers to detect
|
||||
///
|
||||
public struct HotKeyProcessor {
|
||||
@Dependency(\.date.now) var now
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// The hotkey combination to detect (key + modifiers)
|
||||
public var hotkey: HotKey
|
||||
|
||||
/// If true, only double-tap activates recording (press-and-hold disabled)
|
||||
/// Only applies to key+modifier hotkeys; modifier-only always allows press-and-hold
|
||||
public var useDoubleTapOnly: Bool = false
|
||||
|
||||
/// If false, the quick double-tap lock gesture is disabled.
|
||||
/// Press-and-hold still works normally.
|
||||
public var doubleTapLockEnabled: Bool = true
|
||||
|
||||
/// Minimum duration before very quick taps are considered valid
|
||||
/// For modifier-only hotkeys, this is overridden to 0.3s minimum
|
||||
public var minimumKeyTime: TimeInterval = 0.15
|
||||
|
||||
// MARK: - State
|
||||
|
||||
/// Current state of the processor
|
||||
public private(set) var state: State = .idle
|
||||
|
||||
/// Timestamp of the most recent hotkey release (for double-tap detection)
|
||||
private var lastTapAt: Date?
|
||||
|
||||
/// When true, all input is ignored until full keyboard release
|
||||
/// Prevents accidental re-triggering after cancellation or during complex key combos
|
||||
private var isDirty: Bool = false
|
||||
|
||||
// MARK: - Timing Thresholds
|
||||
|
||||
/// Maximum time between two taps to be considered a double-tap (0.3 seconds)
|
||||
/// Chosen to feel responsive while avoiding accidental double-taps
|
||||
public static let doubleTapThreshold: TimeInterval = HexCoreConstants.doubleTapWindow
|
||||
|
||||
/// Time window for canceling press-and-hold on different key press (1 second)
|
||||
/// For key+modifier hotkeys: different key within 1s = accidental, after 1s = intentional
|
||||
public static let pressAndHoldCancelThreshold: TimeInterval = HexCoreConstants.pressAndHoldCancelWindow
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new hotkey processor
|
||||
/// - Parameters:
|
||||
/// - hotkey: The key combination to detect
|
||||
/// - useDoubleTapOnly: If true, disables press-and-hold for key+modifier hotkeys
|
||||
/// - doubleTapLockEnabled: If false, disables double-tap lock behavior
|
||||
/// - minimumKeyTime: Minimum duration for valid key press (overridden to modifierOnlyMinimumDuration for modifier-only)
|
||||
public init(
|
||||
hotkey: HotKey,
|
||||
useDoubleTapOnly: Bool = false,
|
||||
doubleTapLockEnabled: Bool = true,
|
||||
minimumKeyTime: TimeInterval = HexCoreConstants.defaultMinimumKeyTime
|
||||
) {
|
||||
self.hotkey = hotkey
|
||||
self.useDoubleTapOnly = useDoubleTapOnly
|
||||
self.doubleTapLockEnabled = doubleTapLockEnabled
|
||||
self.minimumKeyTime = minimumKeyTime
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Returns true if recording is currently active (press-and-hold or double-tap locked)
|
||||
public var isMatched: Bool {
|
||||
switch state {
|
||||
case .idle:
|
||||
return false
|
||||
case .pressAndHold, .doubleTapLock:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a keyboard event and returns an action to take, if any.
|
||||
///
|
||||
/// - Parameter keyEvent: The keyboard event containing key and modifier state
|
||||
/// - Returns: An output action (.startRecording, .stopRecording, .cancel, .discard) or nil if no action needed
|
||||
///
|
||||
/// # Event Processing Order
|
||||
/// 1. ESC key → immediate cancellation
|
||||
/// 2. Dirty state check → ignore input until full release
|
||||
/// 3. Matching chord → handle as hotkey press
|
||||
/// 4. Non-matching chord → handle as release or different key
|
||||
public mutating func process(keyEvent: KeyEvent) -> Output? {
|
||||
// 1) ESC => immediate cancel
|
||||
if keyEvent.key == .escape {
|
||||
let currentState = state
|
||||
hotKeyLogger.notice("ESC pressed while state=\(String(describing: currentState))")
|
||||
}
|
||||
if keyEvent.key == .escape, state != .idle {
|
||||
isDirty = true
|
||||
resetToIdle()
|
||||
return .cancel
|
||||
}
|
||||
|
||||
// 2) If dirty, ignore until full release (nil, [])
|
||||
if isDirty {
|
||||
if chordIsFullyReleased(keyEvent) {
|
||||
isDirty = false
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Matching chord => handle as "press"
|
||||
if chordMatchesHotkey(keyEvent) {
|
||||
return handleMatchingChord()
|
||||
} else {
|
||||
// Potentially become dirty if chord has extra mods or different key
|
||||
if chordIsDirty(keyEvent) {
|
||||
isDirty = true
|
||||
}
|
||||
return handleNonmatchingChord(keyEvent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a mouse click event to prevent accidental recordings.
|
||||
///
|
||||
/// For modifier-only hotkeys, mouse clicks can interfere with recording:
|
||||
/// - Option+click = duplicate items in Finder
|
||||
/// - Cmd+click = open in new tab
|
||||
/// - etc.
|
||||
///
|
||||
/// This method discards recordings that haven't passed the minimum threshold yet.
|
||||
///
|
||||
/// - Returns: `.discard` if recording canceled, nil if click ignored
|
||||
///
|
||||
/// # Behavior
|
||||
/// - Modifier-only hotkeys: Discard if within threshold, ignore after threshold
|
||||
/// - Key+modifier hotkeys: Always ignore (no conflict with mouse clicks)
|
||||
/// - Double-tap lock: Always ignore (intentional recording, only ESC cancels)
|
||||
public mutating func processMouseClick() -> Output? {
|
||||
// Only cancel if:
|
||||
// 1. The hotkey is modifier-only (no key component)
|
||||
// 2. We're currently in an active recording state (pressAndHold or doubleTapLock)
|
||||
guard hotkey.key == nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .idle:
|
||||
return nil
|
||||
case let .pressAndHold(startTime):
|
||||
// Mouse click during modifier-only recording
|
||||
let elapsed = now.timeIntervalSince(startTime)
|
||||
// For modifier-only hotkeys, use the same threshold as RecordingDecisionEngine
|
||||
// (max of minimumKeyTime and 0.3s) to be consistent
|
||||
let effectiveMinimum = max(minimumKeyTime, RecordingDecisionEngine.modifierOnlyMinimumDuration)
|
||||
|
||||
// Only discard if within threshold - after threshold, ignore clicks (only ESC cancels)
|
||||
if elapsed < effectiveMinimum {
|
||||
isDirty = true
|
||||
resetToIdle()
|
||||
return .discard
|
||||
} else {
|
||||
// After threshold, ignore mouse clicks - let recording continue
|
||||
return nil
|
||||
}
|
||||
case .doubleTapLock:
|
||||
// Mouse click during double-tap lock => ignore (only ESC cancels locked recordings)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State & Output
|
||||
|
||||
public extension HotKeyProcessor {
|
||||
/// Represents the current state of hotkey detection
|
||||
enum State: Equatable {
|
||||
/// Idle, waiting for hotkey activation
|
||||
case idle
|
||||
|
||||
/// Press-and-hold recording active
|
||||
/// - Parameter startTime: When the hotkey was first pressed (for duration calculation)
|
||||
case pressAndHold(startTime: Date)
|
||||
|
||||
/// Double-tap lock active - recording continues until explicit stop
|
||||
case doubleTapLock
|
||||
}
|
||||
|
||||
/// Actions to take in response to keyboard events
|
||||
enum Output: Equatable {
|
||||
/// Begin a new recording session
|
||||
case startRecording
|
||||
|
||||
/// Stop the current recording and process audio
|
||||
case stopRecording
|
||||
|
||||
/// Explicit user cancellation via ESC key
|
||||
/// Plays cancel sound to provide feedback
|
||||
case cancel
|
||||
|
||||
/// Silent discard of accidental/short activation
|
||||
/// Used for very quick taps or mouse click conflicts
|
||||
case discard
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core Logic
|
||||
|
||||
extension HotKeyProcessor {
|
||||
private var isDoubleTapOnlyEnabledForCurrentHotkey: Bool {
|
||||
useDoubleTapOnly && doubleTapLockEnabled && hotkey.key != nil
|
||||
}
|
||||
|
||||
/// Handles keyboard events that match the configured hotkey.
|
||||
///
|
||||
/// # State Transitions
|
||||
/// - `.idle` → `.pressAndHold`: Start new recording (unless useDoubleTapOnly mode)
|
||||
/// - `.pressAndHold` → no change: Already recording, ignore
|
||||
/// - `.doubleTapLock` → `.idle`: User pressed hotkey to stop locked recording
|
||||
///
|
||||
/// # Double-Tap Only Mode
|
||||
/// For key+modifier hotkeys with useDoubleTapOnly enabled:
|
||||
/// - First press: Record timestamp but don't start recording
|
||||
/// - Wait for quick release and second press to actually start
|
||||
///
|
||||
/// - Returns: `.startRecording` when entering press-and-hold, `.stopRecording` when exiting lock
|
||||
private mutating func handleMatchingChord() -> Output? {
|
||||
switch state {
|
||||
case .idle:
|
||||
// If doubleTapOnly mode is enabled and the hotkey has a key component,
|
||||
// we want to delay starting recording until we see the double-tap
|
||||
if isDoubleTapOnlyEnabledForCurrentHotkey {
|
||||
// Record the timestamp but don't start recording
|
||||
lastTapAt = now
|
||||
return nil
|
||||
} else {
|
||||
// Normal press => .pressAndHold => .startRecording
|
||||
state = .pressAndHold(startTime: now)
|
||||
return .startRecording
|
||||
}
|
||||
|
||||
case .pressAndHold:
|
||||
// Already matched, no new output
|
||||
return nil
|
||||
|
||||
case .doubleTapLock:
|
||||
// Pressing hotkey again while locked => stop
|
||||
resetToIdle()
|
||||
return .stopRecording
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles keyboard events that don't match the configured hotkey.
|
||||
///
|
||||
/// This method detects:
|
||||
/// 1. **Hotkey release**: User lifted the hotkey (transition to idle or double-tap lock)
|
||||
/// 2. **Different key press**: User pressed a different key while holding hotkey (potential cancel)
|
||||
/// 3. **Extra modifiers**: User added modifiers beyond hotkey requirements (potential cancel)
|
||||
///
|
||||
/// # Cancel Behavior
|
||||
/// Different keys/modifiers are handled based on timing and hotkey type:
|
||||
///
|
||||
/// **Modifier-only hotkeys:**
|
||||
/// - Within threshold (0.3s): Discard silently (accidental trigger, e.g., Option+click)
|
||||
/// - After threshold: Ignore completely, keep recording (only ESC cancels)
|
||||
///
|
||||
/// **Key+modifier hotkeys:**
|
||||
/// - Within 1s: Stop recording (likely accidental)
|
||||
/// - After 1s: Ignore, keep recording (intentional simultaneous input)
|
||||
///
|
||||
/// - Parameter e: The non-matching keyboard event
|
||||
/// - Returns: Recording control output or nil
|
||||
private mutating func handleNonmatchingChord(_ e: KeyEvent) -> Output? {
|
||||
switch state {
|
||||
case .idle:
|
||||
// Handle double-tap detection for key+modifier combinations
|
||||
if isDoubleTapOnlyEnabledForCurrentHotkey &&
|
||||
chordIsFullyReleased(e) &&
|
||||
lastTapAt != nil {
|
||||
// If we've seen a tap recently, and now we see a full release, and we're in idle state
|
||||
// Check if the time between taps is within the threshold
|
||||
if let prevTapTime = lastTapAt,
|
||||
now.timeIntervalSince(prevTapTime) < Self.doubleTapThreshold {
|
||||
// This is the second tap - activate recording in double-tap lock mode
|
||||
state = .doubleTapLock
|
||||
return .startRecording
|
||||
}
|
||||
|
||||
// Reset the tap timer as we've fully released
|
||||
lastTapAt = nil
|
||||
}
|
||||
return nil
|
||||
|
||||
case let .pressAndHold(startTime):
|
||||
// If user truly "released" the chord => either normal stop or doubleTapLock
|
||||
if isReleaseForActiveHotkey(e) {
|
||||
// Check if this release is close to the prior release => double-tap lock
|
||||
if doubleTapLockEnabled,
|
||||
let prevReleaseTime = lastTapAt,
|
||||
now.timeIntervalSince(prevReleaseTime) < Self.doubleTapThreshold
|
||||
{
|
||||
// => Switch to doubleTapLock, remain matched, no new output
|
||||
state = .doubleTapLock
|
||||
return nil
|
||||
} else {
|
||||
// Normal stop => idle => record the release time
|
||||
state = .idle
|
||||
lastTapAt = doubleTapLockEnabled ? now : nil
|
||||
return .stopRecording
|
||||
}
|
||||
} else {
|
||||
// User pressed a different key/modifier while holding hotkey
|
||||
let elapsed = now.timeIntervalSince(startTime)
|
||||
|
||||
// Modifier-only hotkeys: Only discard within threshold, ignore after
|
||||
if hotkey.key == nil {
|
||||
let effectiveMinimum = max(minimumKeyTime, RecordingDecisionEngine.modifierOnlyMinimumDuration)
|
||||
|
||||
if elapsed < effectiveMinimum {
|
||||
// Within threshold => discard silently (accidental trigger)
|
||||
isDirty = true
|
||||
resetToIdle()
|
||||
return .discard
|
||||
} else {
|
||||
// After threshold => ignore extra modifiers/keys, keep recording (only ESC cancels)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// Printable-key hotkeys: Use old behavior with 1s threshold
|
||||
if elapsed < Self.pressAndHoldCancelThreshold {
|
||||
// Within 1s threshold => treat as accidental
|
||||
isDirty = true
|
||||
resetToIdle()
|
||||
// If very quick (< minimumKeyTime), discard silently. Otherwise stop with sound.
|
||||
return elapsed < minimumKeyTime ? .discard : .stopRecording
|
||||
} else {
|
||||
// After 1s => remain matched
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleTapLock:
|
||||
// For key+modifier combinations in doubleTapLock mode, require full key release to stop
|
||||
if isDoubleTapOnlyEnabledForCurrentHotkey && chordIsFullyReleased(e) {
|
||||
resetToIdle()
|
||||
return .stopRecording
|
||||
}
|
||||
// Otherwise, if locked, ignore everything except chord == hotkey => stop
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Checks if the given keyboard event exactly matches the configured hotkey.
|
||||
///
|
||||
/// # Matching Rules
|
||||
/// - **Key+modifier hotkey**: Both key and modifiers must match exactly
|
||||
/// - **Modifier-only hotkey**: Modifiers match exactly and no key is pressed
|
||||
///
|
||||
/// - Parameter e: The keyboard event to check
|
||||
/// - Returns: True if event matches hotkey configuration
|
||||
private func chordMatchesHotkey(_ e: KeyEvent) -> Bool {
|
||||
if hotkey.key != nil {
|
||||
return e.key == hotkey.key && e.modifiers.matchesExactly(hotkey.modifiers)
|
||||
} else {
|
||||
return e.key == nil && e.modifiers.matchesExactly(hotkey.modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if keyboard event contains extra keys/modifiers that should trigger dirty state.
|
||||
///
|
||||
/// "Dirty" means the user is doing something unrelated to our hotkey, so we should
|
||||
/// ignore all input until they fully release the keyboard.
|
||||
///
|
||||
/// # Dirty Conditions
|
||||
/// - **Modifier-only hotkey**: Any key press OR extra modifiers beyond requirements
|
||||
/// - **Key+modifier hotkey**: Different key OR modifiers not subset of requirements
|
||||
///
|
||||
/// - Parameter e: The keyboard event to check
|
||||
/// - Returns: True if event should trigger dirty state
|
||||
private func chordIsDirty(_ e: KeyEvent) -> Bool {
|
||||
if hotkey.key == nil {
|
||||
// Any key press while watching pure-modifier hotkey is "dirty"
|
||||
// Also dirty if there are extra modifiers beyond what the hotkey requires
|
||||
return e.key != nil || !e.modifiers.isSubset(of: hotkey.modifiers)
|
||||
}
|
||||
let isSubset = e.modifiers.isSubset(of: hotkey.modifiers)
|
||||
let isWrongKey = (e.key != nil && e.key != hotkey.key)
|
||||
return !isSubset || isWrongKey
|
||||
}
|
||||
|
||||
/// Checks if all keys and modifiers have been released.
|
||||
///
|
||||
/// Used to clear dirty state - once user fully releases keyboard,
|
||||
/// we can start accepting hotkey input again.
|
||||
///
|
||||
/// - Parameter e: The keyboard event to check
|
||||
/// - Returns: True if no keys or modifiers are pressed
|
||||
private func chordIsFullyReleased(_ e: KeyEvent) -> Bool {
|
||||
e.key == nil && e.modifiers.isEmpty
|
||||
}
|
||||
|
||||
/// Detects if user has released the active hotkey.
|
||||
///
|
||||
/// Release detection differs based on hotkey type:
|
||||
///
|
||||
/// # Key+Modifier Hotkey (e.g., Cmd+A)
|
||||
/// "Release" = key is lifted, modifiers may still be held
|
||||
/// - Allows partial modifier release before key release
|
||||
/// - User can lift Cmd slightly early without affecting detection
|
||||
///
|
||||
/// # Modifier-Only Hotkey (e.g., Option)
|
||||
/// "Release" = required modifiers no longer pressed
|
||||
/// - Detects when user lifts the specific modifier(s)
|
||||
/// - Key must be nil (no key component in hotkey)
|
||||
///
|
||||
/// - Parameter e: The keyboard event to check
|
||||
/// - Returns: True if hotkey has been released
|
||||
private func isReleaseForActiveHotkey(_ e: KeyEvent) -> Bool {
|
||||
if hotkey.key != nil {
|
||||
let requiredModifiers = hotkey.modifiers
|
||||
let keyReleased = e.key == nil
|
||||
let modifiersAreSubset = e.modifiers.isSubset(of: requiredModifiers)
|
||||
|
||||
if keyReleased {
|
||||
// Treat as release even if some modifiers were lifted first,
|
||||
// as long as no new modifiers are introduced.
|
||||
return modifiersAreSubset
|
||||
}
|
||||
|
||||
return false
|
||||
} else {
|
||||
// For modifier-only hotkeys, we check:
|
||||
// 1. Key is nil
|
||||
// 2. Required hotkey modifiers are no longer pressed
|
||||
// This detects when user has released the specific modifiers in the hotkey
|
||||
return e.key == nil && !hotkey.modifiers.isSubset(of: e.modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets processor to idle state, clearing active recording state.
|
||||
///
|
||||
/// Preserves `isDirty` flag if caller has set it, allowing dirty state
|
||||
/// to persist across state transitions for proper input blocking.
|
||||
///
|
||||
/// Clears:
|
||||
/// - `state` → `.idle`
|
||||
/// - `lastTapAt` → nil (double-tap timing reset)
|
||||
private mutating func resetToIdle() {
|
||||
state = .idle
|
||||
lastTapAt = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// ModelPatternMatcher.swift
|
||||
// HexCore
|
||||
//
|
||||
// Shared utility for matching model names using glob patterns (fnmatch).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Utilities for matching model names against glob patterns.
|
||||
public enum ModelPatternMatcher {
|
||||
/// Returns `true` if `text` matches `pattern` (supports `*` and `?` wildcards).
|
||||
public static func matches(_ pattern: String, _ text: String) -> Bool {
|
||||
if pattern.contains("*") || pattern.contains("?") {
|
||||
return fnmatch(pattern, text, 0) == 0
|
||||
}
|
||||
return pattern == text
|
||||
}
|
||||
|
||||
/// Returns `true` if either name matches the other as a pattern.
|
||||
/// Use when comparing a stored selection to a model name and either side
|
||||
/// may be a glob (e.g. "distil*large-v3") or a concrete identifier.
|
||||
public static func namesMatch(_ lhs: String, _ rhs: String) -> Bool {
|
||||
matches(lhs, rhs) || matches(rhs, lhs)
|
||||
}
|
||||
|
||||
/// Given a list of model names and download status, resolve a glob pattern to a concrete name.
|
||||
/// Preference: downloaded > non-turbo > any match.
|
||||
/// Returns `nil` if no match found.
|
||||
public static func resolvePattern(
|
||||
_ pattern: String,
|
||||
from models: [(name: String, isDownloaded: Bool)]
|
||||
) -> String? {
|
||||
// No glob characters: return as-is
|
||||
guard pattern.contains("*") || pattern.contains("?") else {
|
||||
return pattern
|
||||
}
|
||||
|
||||
// Find all matches
|
||||
let matched = models.filter { fnmatch(pattern, $0.name, 0) == 0 }
|
||||
guard !matched.isEmpty else { return nil }
|
||||
|
||||
// Prefer already-downloaded matches
|
||||
let downloaded = matched.filter { $0.isDownloaded }
|
||||
if !downloaded.isEmpty {
|
||||
// Prefer non-turbo if both exist
|
||||
if let nonTurbo = downloaded.first(where: { !$0.name.localizedCaseInsensitiveContains("turbo") }) {
|
||||
return nonTurbo.name
|
||||
}
|
||||
return downloaded.first!.name
|
||||
}
|
||||
|
||||
// If none downloaded yet, prefer non-turbo first
|
||||
if let nonTurbo = matched.first(where: { !$0.name.localizedCaseInsensitiveContains("turbo") }) {
|
||||
return nonTurbo.name
|
||||
}
|
||||
return matched.first!.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
/// Determines whether a recording should be kept or discarded based on duration and hotkey type.
|
||||
///
|
||||
/// This engine enforces minimum recording durations to prevent accidental activations
|
||||
/// and conflicts with system shortcuts.
|
||||
public struct RecordingDecisionEngine {
|
||||
/// Minimum duration for modifier-only hotkeys to avoid OS shortcut conflicts.
|
||||
///
|
||||
/// This is applied regardless of user's minimumKeyTime setting.
|
||||
/// See `HexCoreConstants.modifierOnlyMinimumDuration` for rationale.
|
||||
public static let modifierOnlyMinimumDuration: TimeInterval = HexCoreConstants.modifierOnlyMinimumDuration
|
||||
|
||||
/// Context information needed to make a recording decision.
|
||||
public struct Context: Equatable {
|
||||
/// The hotkey configuration that triggered this recording
|
||||
public var hotkey: HotKey
|
||||
|
||||
/// User's configured minimum key time preference
|
||||
public var minimumKeyTime: TimeInterval
|
||||
|
||||
/// When recording started (nil if no recording)
|
||||
public var recordingStartTime: Date?
|
||||
|
||||
/// Current timestamp
|
||||
public var currentTime: Date
|
||||
|
||||
public init(
|
||||
hotkey: HotKey,
|
||||
minimumKeyTime: TimeInterval,
|
||||
recordingStartTime: Date?,
|
||||
currentTime: Date
|
||||
) {
|
||||
self.hotkey = hotkey
|
||||
self.minimumKeyTime = minimumKeyTime
|
||||
self.recordingStartTime = recordingStartTime
|
||||
self.currentTime = currentTime
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision outcome for a recording.
|
||||
public enum Decision: Equatable {
|
||||
/// Recording was too short or accidental - discard silently
|
||||
case discardShortRecording
|
||||
|
||||
/// Recording meets minimum requirements - proceed with transcription
|
||||
case proceedToTranscription
|
||||
}
|
||||
|
||||
/// Determines whether to keep or discard a recording based on duration and hotkey type.
|
||||
///
|
||||
/// # Decision Logic
|
||||
///
|
||||
/// **Modifier-only hotkeys** (e.g., Option):
|
||||
/// - Must meet `max(minimumKeyTime, modifierOnlyMinimumDuration)`
|
||||
/// - Always enforces 0.3s minimum to prevent OS shortcut conflicts
|
||||
///
|
||||
/// **Key+modifier hotkeys** (e.g., Cmd+A):
|
||||
/// - Always proceeds to transcription (duration checked elsewhere)
|
||||
/// - User's minimumKeyTime preference applies
|
||||
///
|
||||
/// - Parameter context: Recording context with timing and configuration
|
||||
/// - Returns: Decision to discard or proceed
|
||||
public static func decide(_ context: Context) -> Decision {
|
||||
let elapsed = context.recordingStartTime.map { context.currentTime.timeIntervalSince($0) } ?? 0
|
||||
let includesPrintableKey = context.hotkey.key != nil
|
||||
|
||||
// For modifier-only hotkeys, use the higher of minimumKeyTime or modifierOnlyMinimumDuration
|
||||
// to prevent conflicts with system shortcuts
|
||||
let effectiveMinimum = includesPrintableKey
|
||||
? context.minimumKeyTime
|
||||
: max(context.minimumKeyTime, modifierOnlyMinimumDuration)
|
||||
|
||||
let durationIsLongEnough = elapsed >= effectiveMinimum
|
||||
return (durationIsLongEnough || includesPrintableKey) ? .proceedToTranscription : .discardShortRecording
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// App lifecycle activation events
|
||||
public enum AppActivation: Equatable, Sendable {
|
||||
/// The app became the active application
|
||||
case didBecomeActive
|
||||
|
||||
/// The app will resign active status
|
||||
case willResignActive
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
@preconcurrency import AppKit
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import IOKit
|
||||
import IOKit.hidsystem
|
||||
|
||||
private let logger = HexLog.permissions
|
||||
|
||||
extension PermissionClient: DependencyKey {
|
||||
public static var liveValue: Self {
|
||||
let live = PermissionClientLive()
|
||||
return Self(
|
||||
microphoneStatus: { await live.microphoneStatus() },
|
||||
accessibilityStatus: { live.accessibilityStatus() },
|
||||
inputMonitoringStatus: { live.inputMonitoringStatus() },
|
||||
requestMicrophone: { await live.requestMicrophone() },
|
||||
requestAccessibility: { await live.requestAccessibility() },
|
||||
requestInputMonitoring: { await live.requestInputMonitoring() },
|
||||
openMicrophoneSettings: { await live.openMicrophoneSettings() },
|
||||
openAccessibilitySettings: { await live.openAccessibilitySettings() },
|
||||
openInputMonitoringSettings: { await live.openInputMonitoringSettings() },
|
||||
observeAppActivation: { live.observeAppActivation() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Live implementation of the PermissionClient.
|
||||
///
|
||||
/// This actor manages permission checking, requesting, and app activation monitoring.
|
||||
/// It uses NotificationCenter to observe app lifecycle events and provides an AsyncStream
|
||||
/// for reactive permission updates.
|
||||
actor PermissionClientLive {
|
||||
private let (activationStream, activationContinuation) = AsyncStream<AppActivation>.makeStream()
|
||||
private nonisolated(unsafe) var observations: [Any] = []
|
||||
|
||||
init() {
|
||||
logger.debug("Initializing PermissionClient, setting up app activation observers")
|
||||
// Subscribe to app activation notifications
|
||||
let didBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
logger.debug("App became active")
|
||||
Task {
|
||||
self?.activationContinuation.yield(.didBecomeActive)
|
||||
}
|
||||
}
|
||||
|
||||
let willResignActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
logger.debug("App will resign active")
|
||||
Task {
|
||||
self?.activationContinuation.yield(.willResignActive)
|
||||
}
|
||||
}
|
||||
|
||||
observations = [didBecomeActiveObserver, willResignActiveObserver]
|
||||
}
|
||||
|
||||
deinit {
|
||||
observations.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
}
|
||||
|
||||
// MARK: - Microphone Permissions
|
||||
|
||||
func microphoneStatus() async -> PermissionStatus {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
let result: PermissionStatus
|
||||
switch status {
|
||||
case .authorized:
|
||||
result = .granted
|
||||
case .denied, .restricted:
|
||||
result = .denied
|
||||
case .notDetermined:
|
||||
result = .notDetermined
|
||||
@unknown default:
|
||||
result = .denied
|
||||
}
|
||||
logger.info("Microphone status: \(String(describing: result))")
|
||||
return result
|
||||
}
|
||||
|
||||
func requestMicrophone() async -> Bool {
|
||||
logger.info("Requesting microphone permission...")
|
||||
let granted = await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
logger.info("Microphone permission granted: \(granted)")
|
||||
return granted
|
||||
}
|
||||
|
||||
func openMicrophoneSettings() async {
|
||||
logger.info("Opening microphone settings in System Preferences...")
|
||||
await MainActor.run {
|
||||
_ = NSWorkspace.shared.open(
|
||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessibility Permissions
|
||||
|
||||
nonisolated func accessibilityStatus() -> PermissionStatus {
|
||||
// Check without prompting (kAXTrustedCheckOptionPrompt: false)
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] as CFDictionary
|
||||
let result = AXIsProcessTrustedWithOptions(options) ? PermissionStatus.granted : .denied
|
||||
logger.info("Accessibility status: \(String(describing: result))")
|
||||
return result
|
||||
}
|
||||
|
||||
nonisolated func inputMonitoringStatus() -> PermissionStatus {
|
||||
let access = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)
|
||||
let result = mapIOHIDAccess(access)
|
||||
logger.info("Input monitoring status: \(String(describing: result)) (IOHIDAccess: \(String(describing: access)))")
|
||||
return result
|
||||
}
|
||||
|
||||
func requestAccessibility() async {
|
||||
logger.info("Requesting accessibility permission...")
|
||||
// First, trigger the system prompt (on main actor for safety)
|
||||
await MainActor.run {
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
// Also open System Settings (the prompt alone is insufficient on modern macOS)
|
||||
await openAccessibilitySettings()
|
||||
}
|
||||
|
||||
func requestInputMonitoring() async -> Bool {
|
||||
logger.info("Requesting input monitoring permission...")
|
||||
let granted = await MainActor.run {
|
||||
if CGPreflightListenEventAccess() {
|
||||
return true
|
||||
}
|
||||
return CGRequestListenEventAccess()
|
||||
}
|
||||
|
||||
if !granted {
|
||||
logger.info("Input monitoring not granted, opening Settings...")
|
||||
await openInputMonitoringSettings()
|
||||
} else {
|
||||
logger.info("Input monitoring permission granted: \(granted)")
|
||||
}
|
||||
|
||||
return granted
|
||||
}
|
||||
|
||||
func openAccessibilitySettings() async {
|
||||
logger.info("Opening accessibility settings in System Preferences...")
|
||||
await MainActor.run {
|
||||
_ = NSWorkspace.shared.open(
|
||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func openInputMonitoringSettings() async {
|
||||
logger.info("Opening input monitoring settings in System Preferences...")
|
||||
await MainActor.run {
|
||||
_ = NSWorkspace.shared.open(
|
||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reactive Monitoring
|
||||
|
||||
nonisolated func observeAppActivation() -> AsyncStream<AppActivation> {
|
||||
activationStream
|
||||
}
|
||||
|
||||
private nonisolated func mapIOHIDAccess(_ access: IOHIDAccessType) -> PermissionStatus {
|
||||
switch access {
|
||||
case kIOHIDAccessTypeGranted:
|
||||
return .granted
|
||||
case kIOHIDAccessTypeDenied:
|
||||
return .denied
|
||||
default:
|
||||
return .notDetermined
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
/// A client for managing system permissions (microphone, accessibility) in a composable way.
|
||||
///
|
||||
/// This client provides a unified interface for checking permission status, requesting permissions,
|
||||
/// and monitoring app activation events to reactively update permission state.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```swift
|
||||
/// @Dependency(\.permissions) var permissions
|
||||
///
|
||||
/// // Check current status
|
||||
/// let micStatus = await permissions.microphoneStatus()
|
||||
///
|
||||
/// // Request permission
|
||||
/// let granted = await permissions.requestMicrophone()
|
||||
///
|
||||
/// // Monitor app activation for reactive updates
|
||||
/// for await activation in permissions.observeAppActivation() {
|
||||
/// if case .didBecomeActive = activation {
|
||||
/// // Re-check permissions
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@DependencyClient
|
||||
public struct PermissionClient: Sendable {
|
||||
/// Check the current microphone permission status.
|
||||
///
|
||||
/// This is async to accommodate iOS 18+ where AVCaptureDevice authorization checks
|
||||
/// may perform I/O operations. On macOS this is typically instant.
|
||||
public var microphoneStatus: @Sendable () async -> PermissionStatus = { .notDetermined }
|
||||
|
||||
/// Check the current accessibility permission status (synchronous).
|
||||
///
|
||||
/// Uses `AXIsProcessTrusted` which is a fast, synchronous check.
|
||||
public var accessibilityStatus: @Sendable () -> PermissionStatus = { .notDetermined }
|
||||
|
||||
/// Check the current input monitoring permission status (synchronous).
|
||||
///
|
||||
/// Uses `IOHIDCheckAccess` to determine whether we can listen for global keyboard events.
|
||||
public var inputMonitoringStatus: @Sendable () -> PermissionStatus = { .notDetermined }
|
||||
|
||||
/// Request microphone permission from the user.
|
||||
///
|
||||
/// If permission is `.notDetermined`, this will show the system permission dialog.
|
||||
/// If already granted or denied, this will return the current status.
|
||||
///
|
||||
/// - Returns: `true` if permission was granted, `false` otherwise
|
||||
public var requestMicrophone: @Sendable () async -> Bool = { false }
|
||||
|
||||
/// Request accessibility permission from the user.
|
||||
///
|
||||
/// This triggers the system permission prompt and opens System Settings to the
|
||||
/// Accessibility privacy panel. The user must manually enable the app in Settings.
|
||||
public var requestAccessibility: @Sendable () async -> Void = {}
|
||||
|
||||
/// Request input monitoring permission from the user.
|
||||
///
|
||||
/// Triggers the consent dialog introduced in macOS Sequoia when listening for keyboard events.
|
||||
public var requestInputMonitoring: @Sendable () async -> Bool = { false }
|
||||
|
||||
/// Open System Settings to the microphone privacy panel.
|
||||
///
|
||||
/// Useful when permission is denied and the user needs to manually change it.
|
||||
public var openMicrophoneSettings: @Sendable () async -> Void = {}
|
||||
|
||||
/// Open System Settings to the accessibility privacy panel.
|
||||
///
|
||||
/// Useful when permission is denied and the user needs to manually change it.
|
||||
public var openAccessibilitySettings: @Sendable () async -> Void = {}
|
||||
|
||||
/// Open System Settings to the Input Monitoring privacy panel.
|
||||
public var openInputMonitoringSettings: @Sendable () async -> Void = {}
|
||||
|
||||
/// Observe app activation events.
|
||||
///
|
||||
/// Returns an `AsyncStream` that yields `AppActivation` events when the app
|
||||
/// becomes active or resigns active status. Use this to reactively re-check
|
||||
/// permissions when the app comes to the foreground.
|
||||
///
|
||||
/// - Note: On macOS, the app is killed when permissions change in System Settings,
|
||||
/// so continuous polling is unnecessary. Checking on app activation is sufficient.
|
||||
// Note: An empty-closure AsyncStream never yields and never finishes, matching
|
||||
// ConcurrencyExtras' `.never` without relying on that (undeclared) dependency.
|
||||
public var observeAppActivation: @Sendable () -> AsyncStream<AppActivation> = { AsyncStream { _ in } }
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
/// Access the permission client dependency.
|
||||
public var permissions: PermissionClient {
|
||||
get { self[PermissionClient.self] }
|
||||
set { self[PermissionClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/// Represents the authorization status for a system permission
|
||||
public enum PermissionStatus: Equatable, Codable, Sendable {
|
||||
/// Permission has not been requested yet
|
||||
case notDetermined
|
||||
|
||||
/// Permission has been granted by the user
|
||||
case granted
|
||||
|
||||
/// Permission has been denied or restricted by the user
|
||||
case denied
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import Foundation
|
||||
|
||||
public enum RecordingAudioBehavior: String, Codable, CaseIterable, Equatable, Sendable {
|
||||
case pauseMedia
|
||||
case mute
|
||||
case doNothing
|
||||
}
|
||||
|
||||
/// User-configurable settings saved to disk.
|
||||
public struct HexSettings: Codable, Equatable, Sendable {
|
||||
public static let defaultPasteLastTranscriptHotkey = HotKey(key: .v, modifiers: [.option, .shift])
|
||||
public static let baseSoundEffectsVolume: Double = HexCoreConstants.baseSoundEffectsVolume
|
||||
public static let defaultWordRemovals: [WordRemoval] = [
|
||||
.init(pattern: "uh+"),
|
||||
.init(pattern: "um+"),
|
||||
.init(pattern: "er+"),
|
||||
.init(pattern: "hm+")
|
||||
]
|
||||
|
||||
public static var defaultPasteLastTranscriptHotkeyDescription: String {
|
||||
let modifiers = defaultPasteLastTranscriptHotkey.modifiers.sorted.map { $0.stringValue }.joined()
|
||||
let key = defaultPasteLastTranscriptHotkey.key?.toString ?? ""
|
||||
return modifiers + key
|
||||
}
|
||||
|
||||
public var soundEffectsEnabled: Bool
|
||||
public var soundEffectsVolume: Double
|
||||
public var hotkey: HotKey
|
||||
public var openOnLogin: Bool
|
||||
public var showDockIcon: Bool
|
||||
public var selectedModel: String
|
||||
public var useClipboardPaste: Bool
|
||||
public var preventSystemSleep: Bool
|
||||
public var recordingAudioBehavior: RecordingAudioBehavior
|
||||
public var minimumKeyTime: Double
|
||||
public var copyToClipboard: Bool
|
||||
public var superFastModeEnabled: Bool
|
||||
public var useDoubleTapOnly: Bool
|
||||
public var doubleTapLockEnabled: Bool
|
||||
public var outputLanguage: String?
|
||||
public var selectedMicrophoneID: String?
|
||||
public var saveTranscriptionHistory: Bool
|
||||
public var maxHistoryEntries: Int?
|
||||
public var pasteLastTranscriptHotkey: HotKey?
|
||||
public var hasCompletedModelBootstrap: Bool
|
||||
public var hasCompletedStorageMigration: Bool
|
||||
public var wordRemovalsEnabled: Bool
|
||||
public var wordRemovals: [WordRemoval]
|
||||
public var wordRemappings: [WordRemapping]
|
||||
public var lowercaseTranscripts: Bool
|
||||
public var removePunctuation: Bool
|
||||
|
||||
private mutating func normalizeDoubleTapSettings() {
|
||||
if !doubleTapLockEnabled {
|
||||
useDoubleTapOnly = false
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
soundEffectsEnabled: Bool = true,
|
||||
soundEffectsVolume: Double = HexSettings.baseSoundEffectsVolume,
|
||||
hotkey: HotKey = .init(key: nil, modifiers: [.option]),
|
||||
openOnLogin: Bool = false,
|
||||
showDockIcon: Bool = true,
|
||||
selectedModel: String = ParakeetModel.multilingualV3.identifier,
|
||||
useClipboardPaste: Bool = true,
|
||||
preventSystemSleep: Bool = true,
|
||||
recordingAudioBehavior: RecordingAudioBehavior = .doNothing,
|
||||
minimumKeyTime: Double = HexCoreConstants.defaultMinimumKeyTime,
|
||||
copyToClipboard: Bool = false,
|
||||
superFastModeEnabled: Bool = true,
|
||||
useDoubleTapOnly: Bool = false,
|
||||
doubleTapLockEnabled: Bool = true,
|
||||
outputLanguage: String? = nil,
|
||||
selectedMicrophoneID: String? = nil,
|
||||
saveTranscriptionHistory: Bool = true,
|
||||
maxHistoryEntries: Int? = nil,
|
||||
pasteLastTranscriptHotkey: HotKey? = HexSettings.defaultPasteLastTranscriptHotkey,
|
||||
hasCompletedModelBootstrap: Bool = false,
|
||||
hasCompletedStorageMigration: Bool = false,
|
||||
wordRemovalsEnabled: Bool = false,
|
||||
wordRemovals: [WordRemoval] = HexSettings.defaultWordRemovals,
|
||||
wordRemappings: [WordRemapping] = [],
|
||||
lowercaseTranscripts: Bool = false,
|
||||
removePunctuation: Bool = false
|
||||
) {
|
||||
self.soundEffectsEnabled = soundEffectsEnabled
|
||||
self.soundEffectsVolume = soundEffectsVolume
|
||||
self.hotkey = hotkey
|
||||
self.openOnLogin = openOnLogin
|
||||
self.showDockIcon = showDockIcon
|
||||
self.selectedModel = selectedModel
|
||||
self.useClipboardPaste = useClipboardPaste
|
||||
self.preventSystemSleep = preventSystemSleep
|
||||
self.recordingAudioBehavior = recordingAudioBehavior
|
||||
self.minimumKeyTime = minimumKeyTime
|
||||
self.copyToClipboard = copyToClipboard
|
||||
self.superFastModeEnabled = superFastModeEnabled
|
||||
self.useDoubleTapOnly = useDoubleTapOnly
|
||||
self.doubleTapLockEnabled = doubleTapLockEnabled
|
||||
self.outputLanguage = outputLanguage
|
||||
self.selectedMicrophoneID = selectedMicrophoneID
|
||||
self.saveTranscriptionHistory = saveTranscriptionHistory
|
||||
self.maxHistoryEntries = maxHistoryEntries
|
||||
self.pasteLastTranscriptHotkey = pasteLastTranscriptHotkey
|
||||
self.hasCompletedModelBootstrap = hasCompletedModelBootstrap
|
||||
self.hasCompletedStorageMigration = hasCompletedStorageMigration
|
||||
self.wordRemovalsEnabled = wordRemovalsEnabled
|
||||
self.wordRemovals = wordRemovals
|
||||
self.wordRemappings = wordRemappings
|
||||
self.lowercaseTranscripts = lowercaseTranscripts
|
||||
self.removePunctuation = removePunctuation
|
||||
normalizeDoubleTapSettings()
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
self.init()
|
||||
let container = try decoder.container(keyedBy: HexSettingKey.self)
|
||||
for field in HexSettingsSchema.fields {
|
||||
try field.decode(into: &self, from: container)
|
||||
}
|
||||
normalizeDoubleTapSettings()
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: HexSettingKey.self)
|
||||
for field in HexSettingsSchema.fields {
|
||||
try field.encode(self, into: &container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema
|
||||
|
||||
private enum HexSettingKey: String, CodingKey, CaseIterable {
|
||||
case soundEffectsEnabled
|
||||
case soundEffectsVolume
|
||||
case hotkey
|
||||
case openOnLogin
|
||||
case showDockIcon
|
||||
case selectedModel
|
||||
case useClipboardPaste
|
||||
case preventSystemSleep
|
||||
case recordingAudioBehavior
|
||||
case pauseMediaOnRecord // Legacy
|
||||
case minimumKeyTime
|
||||
case copyToClipboard
|
||||
case superFastModeEnabled
|
||||
case useDoubleTapOnly
|
||||
case doubleTapLockEnabled
|
||||
case outputLanguage
|
||||
case selectedMicrophoneID
|
||||
case saveTranscriptionHistory
|
||||
case maxHistoryEntries
|
||||
case pasteLastTranscriptHotkey
|
||||
case hasCompletedModelBootstrap
|
||||
case hasCompletedStorageMigration
|
||||
case wordRemovalsEnabled
|
||||
case wordRemovals
|
||||
case wordRemappings
|
||||
case lowercaseTranscripts
|
||||
case removePunctuation
|
||||
}
|
||||
|
||||
private struct SettingsField<Value: Codable & Sendable> {
|
||||
let key: HexSettingKey
|
||||
let keyPath: WritableKeyPath<HexSettings, Value>
|
||||
let defaultValue: Value
|
||||
let decodeStrategy: (KeyedDecodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Value
|
||||
let encodeStrategy: (inout KeyedEncodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Void
|
||||
|
||||
init(
|
||||
_ key: HexSettingKey,
|
||||
keyPath: WritableKeyPath<HexSettings, Value>,
|
||||
default defaultValue: Value,
|
||||
decode: ((KeyedDecodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Value)? = nil,
|
||||
encode: ((inout KeyedEncodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Void)? = nil
|
||||
) {
|
||||
self.key = key
|
||||
self.keyPath = keyPath
|
||||
self.defaultValue = defaultValue
|
||||
self.decodeStrategy = decode ?? { container, key, defaultValue in
|
||||
try container.decodeIfPresent(Value.self, forKey: key) ?? defaultValue
|
||||
}
|
||||
self.encodeStrategy = encode ?? { container, key, value in
|
||||
try container.encode(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func eraseToAny() -> AnySettingsField {
|
||||
AnySettingsField(
|
||||
key: key,
|
||||
decode: { container, settings in
|
||||
let value = try decodeStrategy(container, key, defaultValue)
|
||||
settings[keyPath: keyPath] = value
|
||||
},
|
||||
encode: { settings, container in
|
||||
let value = settings[keyPath: keyPath]
|
||||
try encodeStrategy(&container, key, value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnySettingsField {
|
||||
let key: HexSettingKey
|
||||
let decode: (KeyedDecodingContainer<HexSettingKey>, inout HexSettings) throws -> Void
|
||||
let encode: (HexSettings, inout KeyedEncodingContainer<HexSettingKey>) throws -> Void
|
||||
|
||||
func decode(into settings: inout HexSettings, from container: KeyedDecodingContainer<HexSettingKey>) throws {
|
||||
try decode(container, &settings)
|
||||
}
|
||||
|
||||
func encode(_ settings: HexSettings, into container: inout KeyedEncodingContainer<HexSettingKey>) throws {
|
||||
try encode(settings, &container)
|
||||
}
|
||||
}
|
||||
|
||||
private enum HexSettingsSchema {
|
||||
static let defaults = HexSettings()
|
||||
|
||||
nonisolated(unsafe) static let fields: [AnySettingsField] = [
|
||||
SettingsField(.soundEffectsEnabled, keyPath: \.soundEffectsEnabled, default: defaults.soundEffectsEnabled).eraseToAny(),
|
||||
SettingsField(.soundEffectsVolume, keyPath: \.soundEffectsVolume, default: defaults.soundEffectsVolume).eraseToAny(),
|
||||
SettingsField(.hotkey, keyPath: \.hotkey, default: defaults.hotkey).eraseToAny(),
|
||||
SettingsField(.openOnLogin, keyPath: \.openOnLogin, default: defaults.openOnLogin).eraseToAny(),
|
||||
SettingsField(.showDockIcon, keyPath: \.showDockIcon, default: defaults.showDockIcon).eraseToAny(),
|
||||
SettingsField(.selectedModel, keyPath: \.selectedModel, default: defaults.selectedModel).eraseToAny(),
|
||||
SettingsField(.useClipboardPaste, keyPath: \.useClipboardPaste, default: defaults.useClipboardPaste).eraseToAny(),
|
||||
SettingsField(.preventSystemSleep, keyPath: \.preventSystemSleep, default: defaults.preventSystemSleep).eraseToAny(),
|
||||
SettingsField(
|
||||
.recordingAudioBehavior,
|
||||
keyPath: \.recordingAudioBehavior,
|
||||
default: defaults.recordingAudioBehavior,
|
||||
decode: { container, key, defaultValue in
|
||||
if let value = try container.decodeIfPresent(RecordingAudioBehavior.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let legacyPause = try container.decodeIfPresent(Bool.self, forKey: .pauseMediaOnRecord) {
|
||||
return legacyPause ? .pauseMedia : .doNothing
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
).eraseToAny(),
|
||||
SettingsField(.minimumKeyTime, keyPath: \.minimumKeyTime, default: defaults.minimumKeyTime).eraseToAny(),
|
||||
SettingsField(.copyToClipboard, keyPath: \.copyToClipboard, default: defaults.copyToClipboard).eraseToAny(),
|
||||
SettingsField(.superFastModeEnabled, keyPath: \.superFastModeEnabled, default: defaults.superFastModeEnabled).eraseToAny(),
|
||||
SettingsField(.useDoubleTapOnly, keyPath: \.useDoubleTapOnly, default: defaults.useDoubleTapOnly).eraseToAny(),
|
||||
SettingsField(.doubleTapLockEnabled, keyPath: \.doubleTapLockEnabled, default: defaults.doubleTapLockEnabled).eraseToAny(),
|
||||
SettingsField(
|
||||
.outputLanguage,
|
||||
keyPath: \.outputLanguage,
|
||||
default: defaults.outputLanguage,
|
||||
encode: { container, key, value in
|
||||
try container.encodeIfPresent(value, forKey: key)
|
||||
}
|
||||
).eraseToAny(),
|
||||
SettingsField(
|
||||
.selectedMicrophoneID,
|
||||
keyPath: \.selectedMicrophoneID,
|
||||
default: defaults.selectedMicrophoneID,
|
||||
encode: { container, key, value in
|
||||
try container.encodeIfPresent(value, forKey: key)
|
||||
}
|
||||
).eraseToAny(),
|
||||
SettingsField(.saveTranscriptionHistory, keyPath: \.saveTranscriptionHistory, default: defaults.saveTranscriptionHistory).eraseToAny(),
|
||||
SettingsField(
|
||||
.maxHistoryEntries,
|
||||
keyPath: \.maxHistoryEntries,
|
||||
default: defaults.maxHistoryEntries,
|
||||
encode: { container, key, value in
|
||||
try container.encodeIfPresent(value, forKey: key)
|
||||
}
|
||||
).eraseToAny(),
|
||||
SettingsField(
|
||||
.pasteLastTranscriptHotkey,
|
||||
keyPath: \.pasteLastTranscriptHotkey,
|
||||
default: defaults.pasteLastTranscriptHotkey,
|
||||
encode: { container, key, value in
|
||||
try container.encodeIfPresent(value, forKey: key)
|
||||
}
|
||||
).eraseToAny(),
|
||||
SettingsField(.hasCompletedModelBootstrap, keyPath: \.hasCompletedModelBootstrap, default: defaults.hasCompletedModelBootstrap).eraseToAny(),
|
||||
SettingsField(.hasCompletedStorageMigration, keyPath: \.hasCompletedStorageMigration, default: defaults.hasCompletedStorageMigration).eraseToAny(),
|
||||
SettingsField(.wordRemovalsEnabled, keyPath: \.wordRemovalsEnabled, default: defaults.wordRemovalsEnabled).eraseToAny(),
|
||||
SettingsField(
|
||||
.wordRemovals,
|
||||
keyPath: \.wordRemovals,
|
||||
default: defaults.wordRemovals
|
||||
).eraseToAny(),
|
||||
SettingsField(
|
||||
.wordRemappings,
|
||||
keyPath: \.wordRemappings,
|
||||
default: defaults.wordRemappings
|
||||
).eraseToAny(),
|
||||
SettingsField(.lowercaseTranscripts, keyPath: \.lowercaseTranscripts, default: defaults.lowercaseTranscripts).eraseToAny(),
|
||||
SettingsField(.removePunctuation, keyPath: \.removePunctuation, default: defaults.removePunctuation).eraseToAny()
|
||||
]
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
import Dependencies
|
||||
import IOKit.pwr_mgt
|
||||
|
||||
extension SleepManagementClient: DependencyKey {
|
||||
public static var liveValue: Self {
|
||||
let live = SleepManagementClientLive()
|
||||
return Self(
|
||||
preventSleep: { reason in
|
||||
await live.preventSleep(reason: reason)
|
||||
},
|
||||
allowSleep: {
|
||||
await live.allowSleep()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Live implementation of SleepManagementClient that manages assertion lifecycle.
|
||||
actor SleepManagementClientLive {
|
||||
private var currentAssertionID: IOPMAssertionID?
|
||||
|
||||
func preventSleep(reason: String) {
|
||||
// Release any existing assertion first
|
||||
if let existingID = currentAssertionID {
|
||||
IOPMAssertionRelease(existingID)
|
||||
currentAssertionID = nil
|
||||
}
|
||||
|
||||
// Create new assertion
|
||||
let reasonForActivity = reason as CFString
|
||||
var assertionID: IOPMAssertionID = 0
|
||||
let success = IOPMAssertionCreateWithName(
|
||||
kIOPMAssertionTypeNoDisplaySleep as CFString,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reasonForActivity,
|
||||
&assertionID
|
||||
)
|
||||
|
||||
if success == kIOReturnSuccess {
|
||||
currentAssertionID = assertionID
|
||||
}
|
||||
}
|
||||
|
||||
func allowSleep() {
|
||||
if let assertionID = currentAssertionID {
|
||||
IOPMAssertionRelease(assertionID)
|
||||
currentAssertionID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
|
||||
/// Client for managing system sleep prevention during critical operations.
|
||||
///
|
||||
/// On macOS, this uses IOKit power assertions to prevent the display from sleeping
|
||||
/// while operations like voice recording are in progress.
|
||||
///
|
||||
/// The client manages assertion lifecycle internally - calling `preventSleep` multiple times
|
||||
/// will automatically release any previous assertion before creating a new one.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```swift
|
||||
/// @Dependency(\.sleepManagement) var sleepManagement
|
||||
///
|
||||
/// // Prevent sleep during recording
|
||||
/// await sleepManagement.preventSleep(reason: "Voice Recording")
|
||||
/// // Recording in progress...
|
||||
/// await sleepManagement.allowSleep()
|
||||
/// ```
|
||||
@DependencyClient
|
||||
public struct SleepManagementClient: Sendable {
|
||||
/// Prevent the system from sleeping.
|
||||
///
|
||||
/// If sleep is already being prevented, this will release the old assertion
|
||||
/// and create a new one with the updated reason.
|
||||
///
|
||||
/// - Parameter reason: A human-readable string describing why sleep is being prevented
|
||||
public var preventSleep: @Sendable (_ reason: String) async -> Void = { _ in }
|
||||
|
||||
/// Allow the system to sleep again by releasing any active assertion.
|
||||
///
|
||||
/// Safe to call even if no assertion is active.
|
||||
public var allowSleep: @Sendable () async -> Void = {}
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
/// Access the sleep management client dependency.
|
||||
public var sleepManagement: SleepManagementClient {
|
||||
get { self[SleepManagementClient.self] }
|
||||
set { self[SleepManagementClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
public extension URL {
|
||||
static var hexApplicationSupport: URL {
|
||||
get throws {
|
||||
let fm = FileManager.default
|
||||
let appSupport = try fm.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let hexDirectory = appSupport.appendingPathComponent("com.kitlangton.Hex", isDirectory: true)
|
||||
try fm.createDirectory(at: hexDirectory, withIntermediateDirectories: true)
|
||||
return hexDirectory
|
||||
}
|
||||
}
|
||||
|
||||
static var legacyDocumentsDirectory: URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
static func hexMigratedFileURL(named fileName: String) -> URL {
|
||||
let newURL = (try? hexApplicationSupport.appending(component: fileName))
|
||||
?? documentsDirectory.appending(component: fileName)
|
||||
let legacyURL = legacyDocumentsDirectory.appending(component: fileName)
|
||||
FileManager.default.migrateIfNeeded(from: legacyURL, to: newURL)
|
||||
return newURL
|
||||
}
|
||||
|
||||
static var hexModelsDirectory: URL {
|
||||
get throws {
|
||||
let modelsDirectory = try hexApplicationSupport.appendingPathComponent("models", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
|
||||
return modelsDirectory
|
||||
}
|
||||
}
|
||||
|
||||
/// Where FluidAudio (Parakeet) keeps its on-disk model caches.
|
||||
///
|
||||
/// FluidAudio writes to `<Application Support>/FluidAudio/Models/<variant>` in
|
||||
/// the sandboxed container, regardless of `XDG_CACHE_HOME`. We surface that
|
||||
/// location so "Show in Finder" can reveal Parakeet caches instead of the
|
||||
/// WhisperKit-only models directory.
|
||||
static var hexParakeetModelsDirectory: URL {
|
||||
get throws {
|
||||
let fm = FileManager.default
|
||||
let appSupport = try fm.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = appSupport.appendingPathComponent("FluidAudio/Models", isDirectory: true)
|
||||
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension FileManager {
|
||||
func migrateIfNeeded(from legacy: URL, to new: URL) {
|
||||
guard fileExists(atPath: legacy.path), !fileExists(atPath: new.path) else { return }
|
||||
try? copyItem(at: legacy, to: new)
|
||||
}
|
||||
|
||||
func removeItemIfExists(at url: URL) {
|
||||
guard fileExists(atPath: url.path) else { return }
|
||||
try? removeItem(at: url)
|
||||
}
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
public struct TranscriptPersistenceClient: Sendable {
|
||||
public var save: @Sendable (
|
||||
_ result: String,
|
||||
_ audioURL: URL,
|
||||
_ duration: TimeInterval,
|
||||
_ sourceAppBundleID: String?,
|
||||
_ sourceAppName: String?
|
||||
) async throws -> Transcript
|
||||
|
||||
public var deleteAudio: @Sendable (_ transcript: Transcript) async throws -> Void
|
||||
}
|
||||
|
||||
extension TranscriptPersistenceClient: DependencyKey {
|
||||
public static let liveValue: TranscriptPersistenceClient = {
|
||||
return TranscriptPersistenceClient(
|
||||
save: { result, audioURL, duration, sourceAppBundleID, sourceAppName in
|
||||
let fm = FileManager.default
|
||||
let recordingsFolder = try URL.hexApplicationSupport.appendingPathComponent("Recordings", isDirectory: true)
|
||||
try fm.createDirectory(at: recordingsFolder, withIntermediateDirectories: true)
|
||||
|
||||
let filename = "\(Date().timeIntervalSince1970).wav"
|
||||
let finalURL = recordingsFolder.appendingPathComponent(filename)
|
||||
try fm.moveItem(at: audioURL, to: finalURL)
|
||||
|
||||
return Transcript(
|
||||
timestamp: Date(),
|
||||
text: result,
|
||||
audioPath: finalURL,
|
||||
duration: duration,
|
||||
sourceAppBundleID: sourceAppBundleID,
|
||||
sourceAppName: sourceAppName
|
||||
)
|
||||
},
|
||||
deleteAudio: { transcript in
|
||||
FileManager.default.removeItemIfExists(at: transcript.audioPath)
|
||||
}
|
||||
)
|
||||
}()
|
||||
|
||||
public static let testValue = TranscriptPersistenceClient(
|
||||
save: { _, _, _, _, _ in
|
||||
Transcript(timestamp: Date(), text: "", audioPath: URL(fileURLWithPath: "/"), duration: 0)
|
||||
},
|
||||
deleteAudio: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
public extension DependencyValues {
|
||||
var transcriptPersistence: TranscriptPersistenceClient {
|
||||
get { self[TranscriptPersistenceClient.self] }
|
||||
set { self[TranscriptPersistenceClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"soundEffectsEnabled": false,
|
||||
"hotkey": {
|
||||
"key": null,
|
||||
"modifiers": {
|
||||
"modifiers": [
|
||||
"option"
|
||||
]
|
||||
}
|
||||
},
|
||||
"openOnLogin": true,
|
||||
"showDockIcon": false,
|
||||
"selectedModel": "whisper-large-v3",
|
||||
"useClipboardPaste": false,
|
||||
"preventSystemSleep": true,
|
||||
"pauseMediaOnRecord": true,
|
||||
"minimumKeyTime": 0.25,
|
||||
"copyToClipboard": true,
|
||||
"useDoubleTapOnly": true,
|
||||
"outputLanguage": "en",
|
||||
"selectedMicrophoneID": "builtin:mic",
|
||||
"saveTranscriptionHistory": false,
|
||||
"maxHistoryEntries": 10,
|
||||
"pasteLastTranscriptHotkey": {
|
||||
"key": "v",
|
||||
"modifiers": {
|
||||
"modifiers": [
|
||||
"option",
|
||||
"shift"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hasCompletedModelBootstrap": true,
|
||||
"hasCompletedStorageMigration": true
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import XCTest
|
||||
@testable import HexCore
|
||||
|
||||
final class HexSettingsMigrationTests: XCTestCase {
|
||||
func testV1FixtureMigratesToCurrentDefaults() throws {
|
||||
let data = try loadFixture(named: "v1")
|
||||
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.recordingAudioBehavior, .pauseMedia, "Legacy pauseMediaOnRecord bool should map to pauseMedia behavior")
|
||||
XCTAssertEqual(decoded.soundEffectsEnabled, false)
|
||||
XCTAssertEqual(decoded.soundEffectsVolume, HexSettings.baseSoundEffectsVolume)
|
||||
XCTAssertEqual(decoded.openOnLogin, true)
|
||||
XCTAssertEqual(decoded.showDockIcon, false)
|
||||
XCTAssertEqual(decoded.selectedModel, "whisper-large-v3")
|
||||
XCTAssertEqual(decoded.useClipboardPaste, false)
|
||||
XCTAssertEqual(decoded.preventSystemSleep, true)
|
||||
XCTAssertEqual(decoded.minimumKeyTime, 0.25)
|
||||
XCTAssertEqual(decoded.copyToClipboard, true)
|
||||
XCTAssertTrue(decoded.superFastModeEnabled)
|
||||
XCTAssertEqual(decoded.useDoubleTapOnly, true)
|
||||
XCTAssertEqual(decoded.doubleTapLockEnabled, true)
|
||||
XCTAssertEqual(decoded.outputLanguage, "en")
|
||||
XCTAssertEqual(decoded.selectedMicrophoneID, "builtin:mic")
|
||||
XCTAssertEqual(decoded.saveTranscriptionHistory, false)
|
||||
XCTAssertEqual(decoded.maxHistoryEntries, 10)
|
||||
XCTAssertEqual(decoded.hasCompletedModelBootstrap, true)
|
||||
XCTAssertEqual(decoded.hasCompletedStorageMigration, true)
|
||||
XCTAssertFalse(decoded.lowercaseTranscripts)
|
||||
XCTAssertFalse(decoded.removePunctuation)
|
||||
}
|
||||
|
||||
func testEncodeDecodeRoundTripPreservesDefaults() throws {
|
||||
let settings = HexSettings()
|
||||
let data = try JSONEncoder().encode(settings)
|
||||
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
|
||||
XCTAssertEqual(decoded, settings)
|
||||
}
|
||||
|
||||
func testNewSettingsEnableSuperFastModeByDefault() {
|
||||
XCTAssertTrue(HexSettings().superFastModeEnabled)
|
||||
}
|
||||
|
||||
func testInitNormalizesDoubleTapOnlyWhenLockDisabled() {
|
||||
let settings = HexSettings(useDoubleTapOnly: true, doubleTapLockEnabled: false)
|
||||
|
||||
XCTAssertFalse(settings.useDoubleTapOnly)
|
||||
XCTAssertFalse(settings.doubleTapLockEnabled)
|
||||
}
|
||||
|
||||
func testDecodeNormalizesDoubleTapOnlyWhenLockDisabled() throws {
|
||||
let payload = "{\"useDoubleTapOnly\":true,\"doubleTapLockEnabled\":false}"
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
XCTFail("Failed to encode JSON payload")
|
||||
return
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
|
||||
|
||||
XCTAssertFalse(decoded.useDoubleTapOnly)
|
||||
XCTAssertFalse(decoded.doubleTapLockEnabled)
|
||||
}
|
||||
|
||||
func testEncodeDecodeRoundTripPreservesNormalizedDoubleTapValues() throws {
|
||||
let settings = HexSettings(useDoubleTapOnly: true, doubleTapLockEnabled: false)
|
||||
let data = try JSONEncoder().encode(settings)
|
||||
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
|
||||
|
||||
XCTAssertFalse(settings.useDoubleTapOnly)
|
||||
XCTAssertFalse(decoded.useDoubleTapOnly)
|
||||
XCTAssertEqual(decoded, settings)
|
||||
}
|
||||
|
||||
private func loadFixture(named name: String) throws -> Data {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: name,
|
||||
withExtension: "json",
|
||||
subdirectory: "Fixtures/HexSettings"
|
||||
) else {
|
||||
XCTFail("Missing fixture \(name).json")
|
||||
throw NSError(domain: "Fixture", code: 0)
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user