From bec36befa2ff40e5033f03ac51d7c0dc023ab3b8 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:22:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20comprehensive=20rewrite=20=E2=80=94=20p?= =?UTF-8?q?ush-to-talk=20pipeline,=20Typeless=20UI,=20Chinese?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a major rewrite of OpenLessKeyboard, renamed to OSGKeyboard and rebuilt end-to-end. 59 files changed (+3205/-1550). Architecture ------------ - Rename project, targets, directories from OpenLess* to OSGKeyboard* (OpenLess / OpenLessKeyboard / OpenLessShared / OpenLessTests). - AudioCaptureService rewritten as @unchecked Sendable class with OSAllocatedUnfairLock instead of an actor, so it survives Swift 6 strict-concurrency checks while still serialising engine + converter state correctly. - Single design system (Palette / Spacing / Radius / TypeStyle / Motion) lifted into OSGKeyboardShared so the host app and the keyboard extension stay in lock-step. Push-to-talk — first-principles fix ----------------------------------- - App Group + audio-input entitlements were stripped by Xcode's Automatic Signing. They are now declared in project.yml so 'xcodegen generate' re-emits them every time. iOS Developer Account is untouched; only the App Group capability was added. - State machine uses a real stored `phase` (was a derived shim that locked out every press after the first because recordStream was never nilled after the pipeline finished). - Microphone permission is requested inside pressBegan (async Task) so the press flow optimistically enters .recording; permission denial surfaces a short error and returns to idle. - Replaced LongPressGesture(0.15s) with a DragGesture + TapGesture pair separated by pressArmed, so a single tap no longer fires both onPressBegan and onTap simultaneously. - Real RMS / peak level meter from the AVAudioEngine tap (was a pseudo-random walk); the visible waveform is now driven by actual audio. - SFSpeechRecognizer(locale:) with selectable ASR locales (auto / zh-Hans / zh-Hant / en-US / ja-JP / ko-KR) for first-class Chinese / English / Japanese / Korean dictation, with on-device recognition when supported. - AVAudioSession now deactivates on stop so other apps' audio routing is restored. Keyboard UI — Typeless-inspired layout --------------------------------------- - Hero area is 280 pt with a 96 pt record disc, breathing outer ring, and a 12-bar waveform driven by the real RMS. - inputView.allowsSelfSizing + a heightAnchor constraint so iOS no longer crops the keyboard under the Spotlight bar / home indicator. - Top bar: mode chip (Off / 转写 / 润色) + locale chip (Auto / 简体 / 繁體 / EN / 日 / 한) + status badge + ⚙. - Bottom bar: globe / delete / 空格 / return — all 40 pt and balanced. - RecordButton onPressEnded is now safe to fire from a quick press; pressArmed prevents double-firing. LLM / Polishing --------------- - LLMClient: stopped leaking the server response body in errors (server body is now logged at debug, never surfaced to UI); added a dedicated .rateLimited case for 429. - PolishingService timeout 8s → 12s to accommodate slower domestic LLM providers. - AppGroupStore.defaultSystemPrompt is now provider-aware (Chinese for zhipu/moonshot/qwen/deepseek, English otherwise). Onboarding & Settings --------------------- - Re-themed OnboardingView / HomeView / SettingsView on the new design system. - ProviderPickerSection now shows 6 providers (OpenAI, DeepSeek, Qwen DashScope, 智谱 GLM, 月之暗面 Moonshot, Custom) with blurb + selected accent. - PickerRow for Mode and ASR locale; System Prompt editor with reset-to-default. - API settings page "Get an API key" used SwiftUI Link, which has a hit-test bug on iOS 18 that ate gestures from adjacent TextFields (manifested as "typing jumps to a website"). It is now an explicit Button + contentShape + .submitLabel(.done) on the fields. Polish & tests -------------- - LLMClientTests: 4 unit tests passing (ProviderConfig persistence + OpenAI request/response + HTTP error + missing key); test App Group renamed to the correct identifier. - ProviderConfig.apply now captures the previous provider id *before* mutating, so switching providers actually resets the system prompt to the new default. Build ----- - Swift 6 strict concurrency, iOS 18.0 deployment target. - Tested on Xcode 26 + iPhone 17 Pro simulator. A real device on iOS 27 beta aborts with __abort_with_payload (dispatch library ABI mismatch); use an iOS 18 real device or the iOS 26 simulator for now. 🤖 Generated with Claude Code --- .swiftlint.yml | 8 +- CONTRIBUTING.md | 12 +- .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 2 +- .../AppIcon.appiconset/Group 24.png | Bin 0 -> 24338 bytes .../BackgroundColor.colorset/Contents.json | 20 + .../Assets.xcassets/Contents.json | 0 {OpenLess => OSGKeyboard}/Info.plist | 2 +- OSGKeyboard/OSGKeyboard.entitlements | 12 + OSGKeyboard/OSGKeyboardApp.swift | 31 ++ .../PrivacyInfo.xcprivacy | 0 OSGKeyboard/Views/APISettingsCard.swift | 124 ++++++ OSGKeyboard/Views/HomeView.swift | 157 +++++++ OSGKeyboard/Views/KeyboardPreviewSheet.swift | 99 +++++ OSGKeyboard/Views/KeyboardPreviewStub.swift | 235 ++++++++++ OSGKeyboard/Views/OnboardingView.swift | 257 +++++++++++ OSGKeyboard/Views/ProviderPickerSection.swift | 74 ++++ OSGKeyboard/Views/SettingsView.swift | 227 ++++++++++ .../Info.plist | 0 OSGKeyboardExt/KeyboardViewController.swift | 392 +++++++++++++++++ .../OSGKeyboardExt.entitlements | 7 +- .../PrivacyInfo.xcprivacy | 0 OSGKeyboardExt/Services/ASRService.swift | 151 +++++++ .../Services/AudioCaptureService.swift | 315 ++++++++++++++ .../Services/PolishingService.swift | 2 +- OSGKeyboardExt/Views/KeyboardRootView.swift | 402 ++++++++++++++++++ OSGKeyboardExt/Views/RecordButton.swift | 187 ++++++++ OSGKeyboardExt/Views/WaveformView.swift | 54 +++ OSGKeyboardShared/Constants/AppGroup.swift | 32 ++ OSGKeyboardShared/DesignSystem/Theme.swift | 156 +++++++ .../Info.plist | 0 .../Models/AudioBufferSnapshot.swift | 34 ++ .../Models/LLMProvider.swift | 36 +- .../Models/LLMRequest.swift | 0 .../Models/ProviderConfig.swift | 52 ++- .../Services/AppGroupStore.swift | 106 +++++ .../Services/LLMClient.swift | 30 +- .../Info.plist | 0 .../LLMClientTests.swift | 5 +- .../AppIcon.appiconset/icon-1024.png | Bin 13557 -> 0 bytes OpenLess/OpenLess.entitlements | 5 - OpenLess/OpenLessApp.swift | 23 - OpenLess/Views/APISettingsCard.swift | 102 ----- OpenLess/Views/HomeView.swift | 86 ---- OpenLess/Views/OnboardingView.swift | 177 -------- OpenLess/Views/ProviderPickerSection.swift | 36 -- OpenLess/Views/SettingsView.swift | 50 --- OpenLess/Views/Theme.swift | 30 -- OpenLessKeyboard/KeyboardViewController.swift | 285 ------------- OpenLessKeyboard/Services/ASRService.swift | 165 ------- .../Services/AudioCaptureService.swift | 173 -------- OpenLessKeyboard/Views/KeyboardRootView.swift | 121 ------ OpenLessKeyboard/Views/RecordButton.swift | 116 ----- OpenLessKeyboard/Views/WaveformView.swift | 37 -- OpenLessShared/Constants/AppGroup.swift | 21 - OpenLessShared/Services/AppGroupStore.swift | 45 -- README.md | 14 +- README.zh.md | 14 +- project.yml | 34 +- 59 files changed, 3205 insertions(+), 1550 deletions(-) rename {OpenLess => OSGKeyboard}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {OpenLess => OSGKeyboard}/Assets.xcassets/AppIcon.appiconset/Contents.json (83%) create mode 100644 OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png create mode 100644 OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json rename {OpenLess => OSGKeyboard}/Assets.xcassets/Contents.json (100%) rename {OpenLess => OSGKeyboard}/Info.plist (97%) create mode 100644 OSGKeyboard/OSGKeyboard.entitlements create mode 100644 OSGKeyboard/OSGKeyboardApp.swift rename {OpenLess => OSGKeyboard}/PrivacyInfo.xcprivacy (100%) create mode 100644 OSGKeyboard/Views/APISettingsCard.swift create mode 100644 OSGKeyboard/Views/HomeView.swift create mode 100644 OSGKeyboard/Views/KeyboardPreviewSheet.swift create mode 100644 OSGKeyboard/Views/KeyboardPreviewStub.swift create mode 100644 OSGKeyboard/Views/OnboardingView.swift create mode 100644 OSGKeyboard/Views/ProviderPickerSection.swift create mode 100644 OSGKeyboard/Views/SettingsView.swift rename {OpenLessKeyboard => OSGKeyboardExt}/Info.plist (100%) create mode 100644 OSGKeyboardExt/KeyboardViewController.swift rename OpenLessKeyboard/OpenLessKeyboard.entitlements => OSGKeyboardExt/OSGKeyboardExt.entitlements (56%) rename {OpenLessKeyboard => OSGKeyboardExt}/PrivacyInfo.xcprivacy (100%) create mode 100644 OSGKeyboardExt/Services/ASRService.swift create mode 100644 OSGKeyboardExt/Services/AudioCaptureService.swift rename {OpenLessKeyboard => OSGKeyboardExt}/Services/PolishingService.swift (98%) create mode 100644 OSGKeyboardExt/Views/KeyboardRootView.swift create mode 100644 OSGKeyboardExt/Views/RecordButton.swift create mode 100644 OSGKeyboardExt/Views/WaveformView.swift create mode 100644 OSGKeyboardShared/Constants/AppGroup.swift create mode 100644 OSGKeyboardShared/DesignSystem/Theme.swift rename {OpenLessShared => OSGKeyboardShared}/Info.plist (100%) create mode 100644 OSGKeyboardShared/Models/AudioBufferSnapshot.swift rename {OpenLessShared => OSGKeyboardShared}/Models/LLMProvider.swift (60%) rename {OpenLessShared => OSGKeyboardShared}/Models/LLMRequest.swift (100%) rename {OpenLessShared => OSGKeyboardShared}/Models/ProviderConfig.swift (56%) create mode 100644 OSGKeyboardShared/Services/AppGroupStore.swift rename {OpenLessShared => OSGKeyboardShared}/Services/LLMClient.swift (75%) rename {OpenLessTests => OSGKeyboardTests}/Info.plist (100%) rename {OpenLessTests => OSGKeyboardTests}/LLMClientTests.swift (96%) delete mode 100644 OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png delete mode 100644 OpenLess/OpenLess.entitlements delete mode 100644 OpenLess/OpenLessApp.swift delete mode 100644 OpenLess/Views/APISettingsCard.swift delete mode 100644 OpenLess/Views/HomeView.swift delete mode 100644 OpenLess/Views/OnboardingView.swift delete mode 100644 OpenLess/Views/ProviderPickerSection.swift delete mode 100644 OpenLess/Views/SettingsView.swift delete mode 100644 OpenLess/Views/Theme.swift delete mode 100644 OpenLessKeyboard/KeyboardViewController.swift delete mode 100644 OpenLessKeyboard/Services/ASRService.swift delete mode 100644 OpenLessKeyboard/Services/AudioCaptureService.swift delete mode 100644 OpenLessKeyboard/Views/KeyboardRootView.swift delete mode 100644 OpenLessKeyboard/Views/RecordButton.swift delete mode 100644 OpenLessKeyboard/Views/WaveformView.swift delete mode 100644 OpenLessShared/Constants/AppGroup.swift delete mode 100644 OpenLessShared/Services/AppGroupStore.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index c10a62d..757abeb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -21,10 +21,10 @@ opt_in_rules: - redundant_optional_initialization included: - - OpenLess - - OpenLessKeyboard - - OpenLessShared - - OpenLessTests + - OSGKeyboard + - OSGKeyboardExt + - OSGKeyboardShared + - OSGKeyboardTests excluded: - build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1d1db1..d2a7a9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Open an issue using the **Feature request** template. Briefly describe: xcodegen generate ``` 3. **Code style.** SwiftLint config lives in `.swiftlint.yml` — keep it green. We use Swift 6 strict concurrency, no `Sendable` shims where avoidable. -4. **Tests.** Add XCTest coverage in `OpenLessTests/` for any non-trivial logic. +4. **Tests.** Add XCTest coverage in `OSGKeyboardTests/` for any non-trivial logic. 5. **Build & test before pushing:** ```bash xcodebuild -project OSGKeyboard.xcodeproj \ @@ -46,17 +46,17 @@ Open an issue using the **Feature request** template. Briefly describe: ## Project structure ``` -OpenLess/ Main iOS app target -OpenLessKeyboard/ Custom Keyboard Extension target -OpenLessShared/ Framework shared by app + extension -OpenLessTests/ XCTest unit tests +OSGKeyboard/ Main iOS app target +OSGKeyboardExt/ Custom Keyboard Extension target +OSGKeyboardShared/ Framework shared by app + extension +OSGKeyboardTests/ XCTest unit tests project.yml XcodeGen project definition (source of truth) .github/workflows/ CI ``` ## Adding a new LLM provider -The simplest contribution: add a preset to `OpenLessShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint. +The simplest contribution: add a preset to `OSGKeyboardShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint. ## Coding conventions diff --git a/OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json b/OSGKeyboard/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json rename to OSGKeyboard/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 83% rename from OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json rename to OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json index 27a4f38..838144a 100644 --- a/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "icon-1024.png", + "filename" : "Group 24.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png new file mode 100644 index 0000000000000000000000000000000000000000..0e6ff46ac1c0840ab758a889473f1bde05f121f1 GIT binary patch literal 24338 zcmeEu`8(9_|L;9x-z7^?NSh=?C}Kv@Mo9K;kWkhv*=GhV%3BE8mx^pzvSuAZ*_VXK zmhAh!3^Q}?>HRt1^S!QfuIu~*-}ynWdCk2(@8|w}+`Z7&yw1kV&kO)yyLm%Z2LM|5 zNDCN|@Shc2)eiv72X3mW=z1kC4lB7I`4%TJhSadnw{;4B)O07)@-zC@#S5{E2(A~% zZ7J#K?*nFMqmJsZGpQ+@5LeFsBxbMjK< zazl_RBT(9alMpDR<+=-(fp*jP@85TKcf+t7?}vwnKjc4MV*>swu*GuC-qQH^m2wWC zYx#isneEFpNx_l5d-}h;qs8Y{;3zdrSdewaVY1_Oxq zC_mu+;K9uz?dOcZF{XXP7IT(}z(1}q&NDo0cL|XiDyZzYQ$`@2Q!G~R<^b-RrJ*~c zr+^g`sd5xm>mW>O_|y61^@lk5xl~E`!%-Uq%pmXPX8v zy{6?q4St;SRgXD%8G&!OiM)LP%(;07XM7g|!jVUXCWN~cm_W45BnRMZ4n(uj;+f;t z7(kQ#g9q?yIxT*U`c?F)=Sw{mAQ}mKrMeRXTTX(g`nY5OdX0>YiK}5aar&q$z)-hu zCVzev^8P#kOq5ntRaI90+R!VG0xR@` zaHNWkulTY9fc*3^4&al!4{R_a#!@!+0oZu`Dm8}|&-wQ`=~b#a61_7P#PKF3Mvnr( znXUs-?m%jl1BCOdBWCIG9H@P%C#ggRaN};FI^qmH{yB#bc6-=!YYz$1#0S#x6G;4f zEC8R~A3a)qj@o}ao0F+`1%dvC0>C*+S5aFGA?#ak&|`oAX!Yv#4#gM6G$#*$(y}s1 z35hlm4R8rn9Srah1jQr(I3=GZ)%ml7K{%jj>gXc6nhlIBvw&I=Vuj4-6vP;rw09c= zMJ=hnm9?u3q|OQfJlAIa(M$sbx(CjfK5s-nqXk_0PQz1}y{*{g+QX;)YH@LKeci1W z{?4-tw;Esb#C5}xuJ#QEfVu7<4}3>H@Z0(Q{O`zFGb*bCzj*)y8Gx=}O=p&+hrnfw z1B&~c6a9=eOZXg|(uPD@#^H8WEPD*@sBR3vV3*5&AMZp9sOve`AI;ho0I2j$J*W_Y7!(A$6ad_B?8%^WX#S4d z@xYtj&KWdm?(0HobzO4AqR{Fz_;oS@%;d=tj(-eApf@=Hy1zt@rdR@GQr|y+Y=iHk z`#VGRDpf9SW4`1J@&M)imHH2o2mo%mJ$lq#%H^3g%d(U4CM5K{hQBXGz-7OkmHqvy*&OP7@pI=&tXnE~!PrkB0CZfS0(o{dfS(;? z!ySI_eio!6zz#mJ&{v)H6We|a0n@z z8b9nl%LqWKrnZ(5+ql-S2`>O{79-b`J zb@4b+0E)XB8`Ve&h=**{(WtblO()9J{NM*<@-~45uEIsH-I*ar5?tIq2*5~YM>~+E z!_#vp&;k6L%FmyFj*R3@+x8v+i9|RhM%7w0@T6au8;;TlogcXMEOGnIs*zfx1ugJ6j z&u?mKDqh1_Ui2v0S+zZjbVOs{IAHQK6T}p`-G^SO@Z$<%gvd21;ESd`;cogRs71$CvMlm zp>i6W>0U8|^2bo5fo#Yq^AR54PN4zl)})k_Z+;6?Abh*m3N}1$b!*#19{Xhr1HjCa z*zcq%9^g!Y$EO*5X4sh#yrMvA!;(R53glJF1L9q$RTSofW|EIghZN4rO zntvN*+E?I5_F4Wms?3{3`nZ7|Xx_!Dj=)}4&Sz&wj{ptoE;8UQ2&*YnQ3(&1r^WAW zFZbh)!tIog82%(20~e|D`;3M9EA>~OV1?7?$qUkp!PaP$S}v7+e<9#oQbBb!sRiXN z`!8MPYbfMB1M&-AVb~$JzYGCI9CVrF%kS*BzDOB?UjS(xx&)?JlH^(N_q)=1i%9L7 zEP)=^N9^dPSZ*>=zaTMRJ#g4fcXW3Cwanq{unEw4G~aiQYB^3=?^2bqJ$yF@wXf6P#frdh=qpr@ z^hPlJ1DK3zK7Y0&^24#?4gb#!;gjj<=^ygpi_90)W?4{pz9V5brUUSs@G&ZlPpHB_ z_$OWmpl(YWA0Pihb$S2u^6xS^smtN|zh?Y*gjT8!$3T*5A9P02!*T5XQsV#L@xM*| zKaKzULHrLj{)Za>5$gX-NdMow(Uki^68|js<404h5`SnO`MaMoZg=he)@Io#)^}I7 z>yp8BqRYi2tn>yr54i+0tpDT3`lI&|U9^bQPJMm-Ywx@5F7U%&De}Ji1RaE`f}*1D z?%JfEa!TiP(ZIrFYmBX}ZP9>(a*8cMmq?&Noj$@{%x|sWWEA=WoeGY zrp9aN)_qz$DrF|X0>H??McV}m_q(EfziJTq9qs#db(Ja@mU;hH0;6jP#FhiAJ zC*NVV)OupBvLn#~w@*cul$3nrwj&zP$t1LE;#@X0xMp&qqod(#_^II~IY*vEd`kIY zt%j4E*pr`YpYaF*UAzOS^r`#z`>F&PM(~ms) zIdMULB6p`G5_J8u&{t4WlD-snAxV>ioxSa3*QEhJ%Ga;YA_VK>LDCzgq5l5+||b&t;ng@A#TGn(^&6ohc~P<;zjK zvrn3>-z|kjoeT&HqIqAbf<)ierI~E`z_h)+J*;e(&}?1!>C>a>j-wvvGuGI_wzLtE zkBjm*KxSg))pTx#;s%$CLqC5Ol$42lV12+7=`-1q{D+@1cLM0zNyj-jI+jc4ppafjtAFlEDl{!=fJ)d4ugZ&Xf!8w+9a_!Id7w#v<)cYwU+3?^zP>=54KdFu! z*t=z*uOA(WrU+uqiq=cs>K2j$e#QQcg!j)ihcN@Ak* zBz8;95k>Sd;%YK?C@w72vEAvMVrc|oy-ks%?AFYb+yyhUW=S*NwW7n-3-j?pqXtmk zmKGO{mU4oD_A3QvPNSpYN3-_E8$;gc?#s8#1vOj0o($l}zB`2GnG+`!IgFORr1%)^ zq-dOi0+;+=_U$>dyoD0$G20-bYXo8Hh6Ub06{cL2rKI>$+~vk#t+lVstrUYE_y%yKL2v`=cki$A?t zvpbc*CuyFTm{@FknBwzC=*J3Y=b^FmgWzS(Z~8|@%o|=z4j1^LZ1(o{-U_?TO=GCq zn@;glhJ3rw^@kAaux*{r6C@Tu*DIIhx$Cz~H2EYk>g@+<2(^PQv50kriwV%>=I1** zIq83Dmrr2zBJc06u09G=50Gy$Pjj8_NNPQIYf|CjMVEhs%m2Cd{(6|8o-Q#Xd}UNL zgygwAlACE7+isJBB|hk%7kg-<Cq%4B&6e}KQdl~YgADj4HUbL>>~x2m6Yh+nN3j|3JVKMLE(~r)GN)CS_diFJLH~O zcU#-*9g-TL$@1b6CuisU{QQ_bM=ecFt}0p8?3u|qXazhyw`XQD88;LvC^EjjzLQujqP29~WuJ}AjZMEupBQLt=H}*xD?M{= zAYHMRNc6|=84QIF$L8}1n)`j65B&YBHy&J(XE!%!mzPa@{fs0^^(Tmv4-CMl| zwQyY!Lwf#n!VlksZaeqwrQsCPg00@fK2#W#I=2JBNiI%9@UoYbU|1{hS$4n|IN#Ki z&5K+WXlusCYH5!hd-B`mb9s6Br%!kKa1lXVFY2Brwew@>PB836HA$bk}%af4{moyG_wRK8|FMlQ?5yV)AQbL?VX`-6W}A{xq;w zi_y~2ak2aT^^+3k4=*?BMyyk5tmHfOK=3N?%Hi5W-e%Rc_VDn4qW`f*xcbWje)i{t zokq#NzP?+#PS5{-ZUwkTS8kkMIh`|fwfgcvs}3;`pW)4l7-PGYxPwwu{PpWs(J8&G zj~7KJN8U=`xNYPXa4Ue{+JtF&xYFPjtF}nmhlV6l#eR7%vRw_?>k zoR<3W1uh*&YMBGNA}8K8yufK~Wk>r8$7bxx(&BMq_T!X=g@xIk3@6S|(X;fKti;@? z#QY6s{WIsG@GmbfQ&0K68IR-Lz6Qc~f{iP-7j}dCEldbyzLF`#hB|kiKNM#C_!5Wo z4sY}ZF>vs9?!sMFR#x7k*xb)x9;x(nu3PIss8s9z9YW=PBTz@=9|TuK?>(zEwP4B3 z%Og+Y$kT!H>>lPRQLon_^C|Bw$TV8{Minl}C$uY*Htss}EQacoZi|vktlZq(R?8Tm z*f9Clw-(|arY7Ao+LpQflP3Lg zU+#2SyWIuiZYK`AJW{Pz^TLzPG};}&{Sb(~`Mk30yr*^`xg^~X=% z32NNKpIi0ln3|gE>(l=|>eQY8o)+jJ#;h8+T z(~KECvl%Ja_#LzNSHJ#Q{qrr1C4cQwcTOcQD;9F|K#{F6oq~1Uw+ebf$H32@h7-BM zP&{pIPfwy&Cy=!+7yVYo>Y${BkRH>iaEO0?W;d037V@yrWiM?n1s7*$Edg}@xkEc8 z%&7$1ibL3AJEnaPlJ`OQgrpB*kjW0b6MFNulW$s$@E=i6*RGGZ< z$ExQEKgHPY-n~Ci{2Q_PFDT~5)@Ow4&L^t^QNM)P&S}El@NzN=XHGoXEf%6n|Lxm1 zMMcFmO98l~v5hcQZe`)7gfGVFE@3GzC|DEgGmxKgYt5%;|5+t7;J=i;xDfM1NB^_* zia|OXvjK%U@CUc@RTs$f3kye%9@TrK*Kl^|&$M|`u$nRX@#Du_17rQ`K>IVXpCR!B zv2=8c*a=rk{XJFo-oYGb7Es4`bdZ-F&NT7yU&ED>(n+>=65pz4IfT zK>H&Rb?MUXqBG83Azb6rfXd-i&H4HHFhQl3y-MhsMxNG6+`9T<k_ zTwLnw-Z0xpJ+<@lwvbFCcf!#q#N3^aMERpl^UTgn;fRND_oGl3jgIDzzn)3_a&LcS z#l_Y(F&)K^?*gcY+Q@ZSY3AzmY_+GuhNT}OL;lb4yhpQ{P^~O*X0fqwZ(;n zO!67C0M>#}pCYz@j9G_XdtY_jY*VWoE+a30(?b$Kuf@^9p#eu;SZM3E&^_%;4|RUE zUxvjj^N>l3vcHeJyKeVp_~!wKt%>HyT|YsJ2{dZCJHw*(GwqvwbiF7&NL1!Fos#R{ zkhO5ul3O!XxQEc@PwbV zu=HB8U%zj8DNS-x5>-s1%{i*N$z<~H-Aq#RYy?>->f@M*+5$4{YNoXPH_(4#CU8lCKMIE3@ysJJXr06oRo8WTls* zF>UDu4-|!NUs6`?DETs!TT;VLOQqG0qT0w&W9^Ebo*q*ilOui8gd?e}`MJ&HBC+VO za1msR`J|a^3T)8RZt`fsJntjITAXX+nVcuZs(B{Qvg*PK0uP;?J5LGmLwCbfmI->f z?yJS&T#%0-?RGk+jco$N7M7M^65$&5^Hq)weFl70!jraxU4qc*UG2<;;f&XD?Y#S( zj@aVsBRmA?$){IsqWVvzKmiR33c8igV0GDh?L@yZ=Ct5UE;}ffV~RVFrdPOy-(jme zL%49kK)rTgAWjZtJUKQdjJ@j8VnQh^Ep?UMXxS}ReO&0*DLAvp2FjPXUom`mx>m~I zet)>YwA>h3xQK@Y=U!PDDB5BAazP-#^`sD6b#c49Lc7O~^IkvEm>;Z-oPiF_*=j-2 zshypRSQ*H_nl2Ar+}vag)bVgiQD0xXeOk-Ckf86A#@}(ojjRaGUlW7F{bU9obsBV@ z;?wuwK@mg+>`WC`Nmk8QK>pjAN-+)P)rRf|FG?lW7;gR~3GQ`ADMmZPv~th5rmz1i zpvvRCre!kzO>4Vk-YYU+Idk9d}LIf4`Zc`Ey|4#VBjn6zsFAP8?ahy*KHTT2_7_(*q$~ zbL?kQAbYc{EOhh-_Q+$bL-zA0Pla+E`}#h5$>A~djryAKN< z)8S3o>Xx|OTRS>5Cn|nhNKp%gM4xyb)VBBI$2M=n*e@w5g^r&2xy%@ z=Xt@HdHcnjI#_faIy3Xh^llI|u1ZTB9kORKcb|6*jE;s$vil4%M%hP(C2BpN@JZGB zo8Y;XpMmG4!|%k`CI!AyP*#40k^g6uwddUX?wO_G$_Sy-uIqf0V(j_`2EK7yZs%qd zw<9~6OZv*HtA|TFMoMXcEaFA5Gws~G)7VZj!=03+7QG`I(u>o^-#wR3n{gBR6g=(J}ur`=+=I^T zS|sVlUiq61rJmUU(|mgS&VT#iLUt%MUD#Dpoq&Kq?!zPEHZYB)EX2i)IkIu@K<#T~ zCMfT*ni=O)5;abpGorP-2pY<0Gnr=MswxA{R>$%;P|p6GzS?=@$Pv{Kq&!JvgBHc) zdxq_}qOVCIW0wo8Z-f`_4A-vqOfPcdNHC^3MK>F_Q#`txtGrMcOB{_5-Y^gIL^c@6 zB+qXAgu;NKwcEOwY6DM;uq8y!?#S-D(o{745MD)uyztNE|cHhluw-jix~eW3yp>c{bQj;&F~R z{3XAscgcSgvM=pUH|jx!ICT*l*p(i!b)fv<8^7+ zXQn{}4ppmySh==Zjqn$v{vN-hhJRCae#0kucBKM7!-QzId6`BYM zFsV5x?E1D-CW%%}0(qgNB)<>9ECNI}499B4!^W#20456bFl{0Z%unV^{2ohvwU$0l z;&47lVC=fVr=5#{8NK6E_n??PM85WLcjsRJB$vX#Pw>8Dgf3*~;5cr}0K-g^`b}=m zxOJtTn|*b4DsA83@<=9o5X%rj@i9i;F!D2Ufk`9&ybOF(9=MMnnXLt7jX{c@4O zdF3KhjsxpGrDaIPZh>(%r^OJIeh#-R9WZ$+9Me>oIk+ek|D7{Tc7@TMAC_S_c8L0kc66Cb8!ZXsO!k@g#`@?hqXxAR_~=Y z=iU{W@jYC24cAQgpFe+M-RfYrvgh^V_&N*DOOl7Y&!$n!1Q6q6DVN~C-Yzd+bJ9)f z5X>dYMg547y=6TrlO}A^E5u6Qj}uQgpKlur=N=zrGKSlIt~w%FIPh7{{YQf*U6tQ% z;ateEuiu1lL)pC2dC&s)Jp-JVviNqHPuyrQ!1(jHd3ib_QrCD>cgQJnL;b0GA_+mq z1)Q^ay$^q&yp5InXQPRn0`rQ+mioxS_l{=hXa+z+X&YLLIW;fU!pz{%PTa0v-g)ry zLA?<=;@An-HRSgL-nH9y7qV?FOV=NDKT9y&~8%{YwM#_Uj`>WVikMIR>4v1$GpUn1b<0PKM!oq`V5r7laq6BE zuS<2)`R^8d+HIy!-Rju2IgzeF~zMm24`uL*52Yl;2nF6X~D?2tW(z40Fq0MZ96i0i)xJ z*34|WrdH%zRan)s`K5i_41FH80dw%s!pWMlx|_8b55TkEbp*+?+V5f`bT;Jx0w_SY z|C`2QKT7gYxls28O7-OtaaoS7*idpr*%{$NVy=6cmL5X*1LYe%2;{WUtwM%r(#`|o*=~g7A2UejzFq{u^r5<$FCj~8}$9KLcdpHg2`YjBD z{!QRgg#TV50~*@2s=QXeP{)<|Ie%mpO4fNHnJk7?t7p55zshnMDCawD|)`Rx#eZN&9Hp*|w)bL{T$kFm2>*D4I~DiRSH zz6NC+pI?KO$dvtwubnf@_$ra>w{uzoEpj#tQ_30*-akM)DYh^*nK!lRIDTO(06FN!sonN+977@YXG_u$ zGX{e2@edHvXBZn*1!;Bbql1asNlg<++$O#Tif^c>O|5$eIaasRg2+H^jftOG$7mWf zC=AgEZZ2<5Z8iZcY~x`y=yN=!)LXWQp|&0Q@{*!r$gUw-I?GS~s zoMJq859?Ml)G`BIF&jO?yGXP;JLmvUvl}na>$*F*m`AXw*Uno#)9Sr5N)Ix1CswlF z4dhQi_4T3!DJs6!drS}WY*<-4tBE8NZ3C35wX^8AH>08ZGktWC<;fQ--mZr~X;P)} zx776Mqd$;hEy=L5rtgX9a*o(^VL;+x8yOfO`T_5_65buwLiHclh z?Vwm5JX~Y&+jED2)5Iet-3k5jQ3hM#!)%1JS_ZX{XPj(fu!oUB0D!SHqY9+NpH! zDw{?g4G#nkq=pvv|9$vxFdx^Rcvjg0HonYYh$Q|Xdl*#YOn_8z{F{r4iuPMb4Ra(~ z5gv*aKg|(g#t;{JluK5Hfs^NC`X4lctr(oaSR4?!a6-i9aPBJGr9NV62*g4m&?m(f zzlF?7a_Ito`IOFcQ^f-=7ago^swRL7OqKqz#t$G38(La!wSD6s4ZoMm@le~$k*_?g z>4tN;EsW=Vw?5A1jOEJ7%R>X3M`?papIKB{fD%Bni9xUE_^=2N7m;3+SBLbyAbW14 z_6+(V(ol7x{oVHqf^ibM^aa7O-|l83+7TZy>y)Wa2W_WUzrg9x!JJ=LT(`235=bFFme{b2780#mSi z_UD(m$|o6sZ4kQGO0Y~XWtVf8lEgP%<4e(Cz)FC;SCGsK+pXX0BspjpYgp;aJ^me zk?w)wyFp13$~H{+)gNKDFX+kNR|4mwzd`JU39<7jckyqF@P9_2 zYzjQyQ|(Y}0ooeY*EJYkA=n-nJm3J_2QNL&c4B4huB7omsGL8)TRvB!yhlvcIe%{& zR#x@)XVz%YZBLC6P%GH7hzR`(3En@zi%5-SKf2=!9oAb=hlzaB)zm@F+Wh=1VtNZ%(Xea?%j4nK8vC8!j1-28_Ut#9e#r3Pdw)Z5g>ODqt*B? z3rz6fV2QwK{~umqQQ^Wvd3JB}_o3Hy?Dq+6QDK|0A4N#caWNXGj_sP6(ZQ9}A~98g zo?hEE?3Qx=Kqoqb4BQ`dqqjTtLAnb`OILh)&Uac~H*Mf)%^FQDjCsm2h6sGspv;*= zlpq){1>uGB<&GetUR=Iw2vdq8F&zrjDf0Aer6za|gNODA*DH*=mQsfrE{o(~Sq!IO zm^XC~7M9I>4UkF)W!iFlQw_L)RZHNnGUo)oC#F_5EKpnXIJ0`cZxV={RBlSwyMvO3 zCCi{Ht7|?UEdJ57_@>I&jv!pUEj-IsN9v$AXE*@ms_b!tkM|me^jO`R?nCS4FsFFw zQq~Moj0r@Xeb&K-_lht>lx$M0t%#(5+?!6i=H{0UE1&n|K>*hm4zF^ZVhwtc*5;fo zJeSNx{&*T$eV|{Yd1g9T+Q)Y{(5sqbg6ooO-zOzdS0x;kJMafNa%yeFT1ZOu;csKi zgju7qMKIr`wwzx-4D^7MB;#a-+jK{0%8ScS%2Xp**zN7@^)O5uSFL~KyX8tsgNC8^ zc#XCj`5OV|dN9sR+}XxawxMU_bLs zA!{M1r0v_0BlPD_Mcyp>Mkl^=_1m9Mlghyd?jBA|N-A;5?aYRbOTE2P4l{_zWrRT& zj~UpwDd5EGeDLmJR(hIWVC-6i0GJb+)!%%pdfxF9jElbOk+P9!DN8W=bn8K8V|;QBFT`@$q>m0HQ>s zPxlj$oaNb))AgYuzCJn0-S)~LRti5~vd#t!)CBkJ%Q^D+$`Pp%HiTF;;7o%TPoc^K_->#E|ZmGuR&<{vnIbgCk<`a=mT!M3VAEfVK53(R<4gw+1nm-IVC8!;uHer&e;r{*~-y=$^;W)&+{3rW5!`zRE~-7X|PU?EQA@ z<`hn)*3;((t+%gL0RbF@21wh`* z1?BGx49-2t$s-SG&=XHoCPF#5s0DPm;7TMX4#A5U3msgs=@L+5U;kTF$1u6ynGiQr z5+LQUfnz>aIso6VCdVSDcqaD6W!4L>Ycx6sFI|W?n-4*)8}M;fNw_k9OisjjgJ6ey zI=r>>B9(ujf<)>0pOla9oIWqZTY(?*h-e*y#hK9jO||63w=9h2e7hR3w)2sA>k1pf zLM~wz8E7RZCugOsU+?tx5Ia+Vl|nZq@`d`1Wf%V`n0Mh zMIKA;On2y^gl>PXvsjtwejg)=>2uRFz31uKFQocM;Rod)HA02368Tea>MUF!wO|^| zCy#khQhK_-G!1hdG*#fCjCPS)09(GIS0gJs(Mxw8S9u#~r`Ptp3I_kdR6?Nt&g zu1O-b*%~6}>^}?I5t6Vx>n>b~~vf6g3qguh@4`%CnW$qq?vW_f>p}IO&M`p={+Rm(__L zQ~U8qw2P{+YREE^(>@84uc)B#5?TjHd23+zi|KHLGwu2a2cu*A?j{|+zP`SxsD4QD zhUrB6`0n~NTnGZz6T)!&_uws{qnrcBdY&2h6C?^LQd6wxnNK?>N%3x2f0&=O?;-_8 zK`_j>k31dI>%3bOYhz!_F0qK~q*BKmty8YCTBi<1`lLC)r1aFiP087QK5)go-(uo} z=uCRkECq&TeS%|NK0brVY$08m5qD5ex;nZcBJH@OhOOk85Y}31AAX>zrFA?^{GUX= zfaDh`=j?Y8zZSif4j12@s2YRy2$ns#b@hYhk`_O>vS%RxW0RN z&D?!?Ln(f*ekY|1#z&HC~L}9tdjB$yY;3s*3wBQEUlqzSJva5=kPi7vCeJ;FWb%Q z*0(tahY#+*g!%c{u*T;?GZj08MYF@R5%qpjhg56EIeml++wJLtr%M9_6EKFf!gQ;$D%0`f zBGYo>uyf5d(oTsoB*z#8#+(S`MP|LF@5On&F|LsIA#DYP*>I*dOn= z$2lkuwmw#UMdH;!(?-du&)nXxUks=a9i`(oF3w$FGK7;ZH!iO^KiqTv@Ck6f^Q9S* zgzPm-fu!u{rH!NJs@1@Q6v2=MX2#)vh%j3VmCpH;3vIKXV6wJdxd+}^ir5Z;<(wu; zYS%fq-XN2wyxf#h;Rh)-FC|$jE|OBZ2UyJF$QD?5iu3 zdMIy|sJdTGkc;aA`}g~+jt-;*awkM0sbXEF<9=>kz3x#aF4_pI6ccI5mYNxjr+-4# zv!y|KzC{aZnC3#NbN^v2{Q||2hLcsXkxLlZy1Go7Ga$U&pMcH=Obh5t#Q%_Ko6Uqp z(VgU|()O7WQ2^^4J3Tb6r0dV+4zWKUP7)LChY8tslE5Ky?USS$%+c z4vun-?)UWjE5|@R2GxTI$|1Jr7zCZI`okKXFGQez-0)$xGXqV%LRB?2P96BIR@vh} z_u|KN_6IO<7cU{&puymi|M0Y1@6uA9rDo@Z&xf+ow`H(IHu2yH>lEuuAhlphNM8+^ zGK)F82rE-NPU?25c~5Kl-q209D7ID4J*S4GB*RJjdvKnM7a^Jgg*BbstQCPUKZsWn zLHW73aE>18p(!W(%gf6z*vSuQ={FqY`Aa!bIhz~bM(>0y+L!)3t)nbf4O ze?G;J`K;RFo^nEThIM1uW1szCxA6P+V*=EvnR7m=2i|@8eQ;+3^h!g&>ZxKS8X}iU^GeEPeC0^mx=wN&UR~vdMjI90zEW;O>q)IDr#UrOZjNfI#21#7^tRrtKr2J=K;fvS@(E*ONdq@{1IiF2x(| zu8v&H#}*bB8=YOWZsoN@YYDX?G`o6#F$+u&Sp1!%JW<7#n(b?S;YlWw^BRD0mxHsZ z&&lCn8ol=(0@i61i?t02rU{5Ej8HTmleL3Bo>ZziDV$L8ok+O`6BezqOjfaX6Ck=x z)Zn!Db7P96#~H|+u%}zDKnwUG?7ZoGZRMJ`!TuFS2wo^ye}3yeN0ez~AVj1M$8&$| zfzWygJdkS3YJ>s=J-D zL2ssb^7;O9mxVN>LSH=Ug=w^3BXJQ56*n_auI25VVHZ^kAlw*0dU(QMXk()JV142> z?o9Ib)>Z}CH4Gw@!YSIR!ZZa1NZeC6!gYD364ih_!pDmRM&Vyx_QqsWzOcIS-$^Ad ze`8N^Vt@<`_YlCkk}$gebs&`+-e-s|BT~_N8$DW^G~$`){!zONt`>}q5Df;=K!dYy z@L!=2R!vj*r^Mf(H*xOnQDvmGB#70zLUs&1_GHz|VK%&qdFV``lZ8xRIxI7LhXgIu za!PWD#8~xG;9;&$cb=_CbN?5xB~RE5wbV2h^53{KV(XLAx4y>-j~;S|G>GvFi!&WH^kG##RbRQ$Cl_Q&0e*J>b9;RLXh4_TmxjM)!4C9WTemRO5LddSPf_pn0znBAa*^ zhD7Z-kFwIkpXLbePyp$k?M_X7uA-0%=~UO zGq4QUe!}-tLt~kz@@^)1{*FVbvqBOU`mjosVMU*z>6$Q`WXYz?@w9?xmz*#BiyWOY z8=t?wDMaK0Uyz4uixCDeh(LL*2rnmH*~Da69focMiP%qA8XZowf(TN&UYyy>`=CD2 zfZIfV$$nGF=kR|z##yz4Yzh$${x6i)TZ=sv_aBt)0_|N=f0iM=R9iE=sR9)UQr%A} zk9-wFgWmZ)6IPXQ`3fTh%RvpSSAWv};t}#VA3=0Qej0i#&5@_cQ$O-oevvMaPdY*D z6crUGhS4U;*GXl>eo^-$!EwT9{+?W82mRyL8+U)t){cyxG0t;&6n<20oI^tPsV!~L z9N|(U|L33*(vw|9x6ot;d_irw1$1Mfejt(mOlN=T3CO0%!AzkLZvRmLs%F5Uw5Z4p zN@$~A_Z2w6RYs_&Ac9Mj^g8&0U>G%{miO`F1S#~Hci_v#1&(9zro;YL8DX*KMT+we z3gF~z0)qiucRnwkPCKvF#RE|&R1mB75!w)JvuNV_^!n~Hc3W|o=$HtZ0)pycT@0pg z0!B?1^kmGUQ~_g1wNS&k_oJ{r34eN7&9O8yGXu5JJn6E^b8(#`7v<7M;e9EH2w$t& zleve(TAEw6m#I&rOPOt!SUt%Hq(j=+>D3Wtr4}mT3rX+Z^FG*T zVP{$_q_YnUH%Fm8;Z>2p_~*eI%aI2m?)%Rni!M+$4zdBuPY;5|@Tu(Z0syo! zjUy}(Hz({0l8p*hB^mdR>h5Hww82|^&>$W^uGk=@)g^MBWX(0&6ypRKSrSG4ZXUEW zfUBjwO);YaTrYbS+}P?7>*9%QwubqIh{k zQ%Avt8+XAtK#3K9HPZQ zFB7O#=%uJF)n@Wwb+cM{0&@)P*9TSxOPkA$aHY4=J)01q^?d-5e@;#k-`TmZz)LRh zK3LgOKRtBB;1!k-4F-BgRCSS8KQx)Y(xPUocKvF~%4}^ZC*gl|ax059@Hid6UW=&( zBSZ;MFE13DV$Ri^K4G|RKR7<@fA|t1AG(Wh#k?u~GHn97`j$55DIUn$9BNm(hQVk( zBExfB#@i_6fK+9>D`%2`N{1HujLf$>Dd#Ij={T2X-%J>Rvv%LPOo={Tbo?t_yx%UUswjrbbN!=(+?}lMf%H zFo``fcmDTI-H{`Qrqa*aKZ5tx%Cz>_?<=WWB#t*Z5k0wV>annYX zaEEsz4Bv3|Z%dyd-_>u)!>d2@esRlmh=nTu5W*P0Lr8{|URUSCkP~KRn#|tEe&mNf zrJ3LK=t(+6oGT5kV52P7^(vOpQXxs%cT1x~E75X9Ed8|KbyCAY@Gc75%7_}0eH9iK zoecB2&G0^w(Xet{WMrhn?GGQFZ*W8j>|CWP%Z&})sW4q170w^DRbQo?O=p=#NmX3IF0jG!}efGlV9B7DsHx68#U|Gmd*9ylsH z!v88k%S0{$>Ptx&2~cl>t+q!VouYaSiAr5LULPQqJHLk5;%*LaLOtqjKVn^YB7Al` z^!WTT#>6BivyFKQX{4bxkEMoyMO<%3zv-Kv+F$iDRT~!iK`UM>C8@@{oP+(K82I23 zvi4o}D?}W0l!p~z(ziQ|;TJ?E&ynYD1^n|prBK&Q=!x=IGQ7Ea^q1ZPS~M5>0zSG>mK+%J8k@?l|=KIi`GjnF<%=!JF{sZ1puU^%yTes>}-TTSpe9`Be8EA;W1cX(#Jm%yW z_I7baeQ=ezI`XC`)mxn*j&c7eJ1Wwz;Q2$$q?c47f^%TS#~56kdBbNBu^8d+kL?o- zQjilR;e`st-eV61jxF1y!Yzk19{3_AJmZ9J!Nv4B{lS70y4X*!ZV#G{h!UcM!IM&k zK)k-$CKfGGx5IOXBu)!7+9x^E2b1Qia=nzxlVKS}BdM#p+?+_Z*RivjDvV_zeEN^xw~-mv6r=fe@XAXo4m!7^Nar==0_Ram{vx6yL}qu?t|sgF zm#>p+SMwBVg3^+pl>x9thKAQP0@kPU>DW4FzN`V0Ts^hIX8~&-w^ARC+=H6HE+EA4 zO{(|j4DrGiZAL@FyDxR>cVck}qf|)1;x}6AjP$dwb+dcxo|XHp%KHXE$>=PY^t!pZ zEsa~dYobA?WjepWvVQb-;ki=pZuA9hPe<4@zLj%y4r{81vlYop z;|8sMc1Rff(HCb3l6DfL4j%uuU+FrRcq|{#ew@S!9E*!q+y2P65`ID{tty?t*0vir zZnUupd~Q%DK77_F!}@MYdRzF>qh{?z2zL&IN%@1}M6`P+0@bG=keINr;T|^CvLSwr z|4p{}T$v3w(tQqMgqlWH|2`nOD|8YbNLW`y?C#yXRJLcs=VzK{sc;VX{n6Nbu1)#s z(5XwlTi9h^&qVj$HQI}qdF=AhA@J}*+`uyXc1O*(Q|Y!51yqPEY(ZG+>#T;WR^{4r ziA`UB`763Lq6{%3q9=3(p2uv+%Q+|RW8d+>Bfh_XrXmXSUqrp**Al#}-995Y9^o^2 z*RL0{b^P?GmX;Pcf>#8{PMX@0-ea|9CbKn;Rf((Yx&li?dsW|PyPUtix%j#sPu3u^ zPNp#Dql2Rv8`F2(V(pRV7w6pnL$rA9y1Iy|MWVX>@v$N%DNlZ}4h@DFe+19FyFXIC z#ecc$>i1FB8{Wb}6+Ibsw6)#M;M5h!MTu{M%kUXnd+X(=@f|VNLVwCuSXZGK ze~wNkl4dph*?Oq9IHq%di+#*YU&c(PmgTVUZ|?smk$owxu;e!%RN-9u(Bs|iRSry? z8GL8NE8di#+omn{EZA4=wsm6Q_X;>w!Hi7X7>srBrf*p4?0YA(l)kKLl49<;6i^e6 z-FaaJ{);#MMk9es$iKtF{CncWZ;AgC59c>U`tQ0?P*zyjIBErbv-$^Y(f$MultpO`A%YG6*yx=FXzzoL*}hv1 z$XrEVzX*IeQ-pmMW0e0h3ZTZ@T*$!XB2ZHulyV}4n&8TmuT=qqx)7CFL!}TOBji2$ zV>ko;gY<#<35DE|OujtD`d%G{kEKDOUJA)k9*S7xE$X0ODJ}vIi#xSEPlH%+P%;BI zF5`TZeF+5`U+WyaZ^O`I4S|@Jo~%Fd#Rj@1M;itV$UTD(G_<&%fno!gN?;fntICP= zoPBx=()4Do)f9C5N8^V0e!J^Wk||MpX}F_4+T(EZ4bP(XNoj{iceDYL9&Q8#71Xfl zwaO${TG+y%E5jY15FR`%+zx$4H){K&hTzBWL}jS}*&WL5qh@)~M+b66KS)-QUKK#W zo|IgnmLnqeu@66q2g| zJc{Q7Feg;4w0&E4tMKrM9onV-dPI9v&jab^z2iXaa3^OPgGM^8$M!c#X+ z7`kkv$gZc=RPS;4e&>pXv7T`>Q_r_u8NIh+WCM*41yav(G=@>>>I2SY^5h%p%L+0y z)g$smbp+>4UB2((Rp-$H5BRGJL%eMTABv(v!x>H8+La;~A9dqjqUcWMa>my`>I@m` zPT{uB!g-x+xRy0rp1Xu$9JvBSvNhL2-F8%w{JVF9%%MeVGJhWBn=Zm~Dc#^}e9t(Q zRI6f}81LEK)ko!TBg_Smk@(2$3y(@%NIq>0tUUc>?3KU6GaY@h9&a(S0-9&b^6m`WaDstFB4}TIZts_op%{ zqpOhkFrCIh{li9y4El{;Gh0D7-8{&AG%p0Q2&DeRj z+Vu6IP~Yusm7l%(s*;zdff^Y!HQ4UneXL@B9K`gAzP|q0=#$1G`{o?furnkuK>4Wg zDKZX3!6GlWUZY$NkV*N7G+hhUz|F$KqN&Y{%O%Vm>gq(m2cXz{tVEQTZAH|w;#Ekc z-dM^*@IO2ExDdiLNw5@22dInqP?54Jxg5@yC~i{nZ6yW&vj_#dG!8p#lTaab`g-^` z-|RdzKxHn=2|Wn2YU~t#VS4K1epj-Z8}sgXIFjsY@EJ2ogL_i~qMshtdE-*!)ia3_ zH>*V?n5jXG7dwT>%dd42-I=q6GiMgb6PiXb#>lbW4b`=XTKe^fIY~V)?}6@{6=t=C zI1;9|x(X3zrEM7R(X-G({@SbXGvLG zsULK^1S3pQ*Uuui!kbT&sE<@C6;?oCrn%`&{D1?5N$0iiSu66gCJ9FENj_{`Se%rM zWelagkXDydEHD9!X{GEexk&}p4HHMni&rQ!(+E1%;=mY`fxqGOK^BKmp zAt?On#5JlundtiA#N(atnRGgy@zn{~ZxL3mA!_4mOlKwA$W7 zM#omUQWsCX&uGn!%T4^=05n?+P}DT1c>`3UF&Nb7jiQJ`na;j8)gi6t@eGn(_rcU& z$@4Wy*CCrATk)zFHbXs$GJ0>sC}t?`h%x#lXtp2N!={_uCP>*OBR-5oVLcun9u7OG z^~Ad_Et)n5nFDX)*asVP!q(ewt< z`pU=og@s@nKdtR&kA|xo|H%>AsE=Onqx52PzYBq-FwRq2DI}Mq<|Jh!NXp&a-H_=O zmKqG6hlhs^oBg)VUDUj72~m5^A;c@EBZTdiIghN~p{YVT!|{6#Q8b(7@!|PbPYubapWM`up>+MXKRp6C#RDK$q9;HvYlcBx zmVAVYRN<^NfEIZ;_8B>^7k-CEs5d+!TOdAdVTZvvz}5ACTKYT%^X{y}I4R~iQ0gnZ z^_QYm8y`$vIJNuEd3&VF-gcz82eUJ8YM1F(wWD|i)w`8lN`oxzP{hiv3ww2m8LfAN zZ-W~2RY?7I2dbY*^su+eV?Ptq)4c2e2O$(QhHO})e z>2ZG1VW`46wG9SW_Oqv6ZhVWd}9D+zA%zs);crqJQ>I6rGk8ye^11 z>Cg4Ve6O`Huw;gR9{rh@GwDr&n?>zM#;9{$=}H;hv@cssfU5I1SA_u?$91dP$2y-Y z7B3~Eo9-Ow1Yz9}+75-pFC$B0q$c2@(%L3|&;PPTg&3yb%7 literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json b/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json new file mode 100644 index 0000000..e88d30b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.043", + "green" : "0.039", + "red" : "0.039" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OpenLess/Assets.xcassets/Contents.json b/OSGKeyboard/Assets.xcassets/Contents.json similarity index 100% rename from OpenLess/Assets.xcassets/Contents.json rename to OSGKeyboard/Assets.xcassets/Contents.json diff --git a/OpenLess/Info.plist b/OSGKeyboard/Info.plist similarity index 97% rename from OpenLess/Info.plist rename to OSGKeyboard/Info.plist index 6e78134..ddfb8b4 100644 --- a/OpenLess/Info.plist +++ b/OSGKeyboard/Info.plist @@ -46,7 +46,7 @@ UILaunchScreen UIColorName - + BackgroundColor UISupportedInterfaceOrientations diff --git a/OSGKeyboard/OSGKeyboard.entitlements b/OSGKeyboard/OSGKeyboard.entitlements new file mode 100644 index 0000000..2160d46 --- /dev/null +++ b/OSGKeyboard/OSGKeyboard.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.application-groups + + group.com.osgkeyboard.shared + + com.apple.security.device.audio-input + + + diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift new file mode 100644 index 0000000..3d4b5b1 --- /dev/null +++ b/OSGKeyboard/OSGKeyboardApp.swift @@ -0,0 +1,31 @@ +// OSGKeyboardApp.swift +// OSGKeyboard · Main App +// +// DEBUG VERSION 2: restore real flow but instrument every step. + +import SwiftUI +import OSGKeyboardShared + +@main +struct OSGKeyboardApp: App { + @StateObject private var config = ProviderConfig.shared + + init() { + print("🔥 [OSGKeyboardApp] init()") + } + + var body: some Scene { + WindowGroup { + Group { + if config.isConfigured { + HomeView() + .onAppear { print("🔥 [OSGKeyboardApp] → HomeView appeared") } + } else { + OnboardingView(config: config) + .onAppear { print("🔥 [OSGKeyboardApp] → OnboardingView appeared") } + } + } + .onAppear { print("🔥 [OSGKeyboardApp] body appeared") } + } + } +} diff --git a/OpenLess/PrivacyInfo.xcprivacy b/OSGKeyboard/PrivacyInfo.xcprivacy similarity index 100% rename from OpenLess/PrivacyInfo.xcprivacy rename to OSGKeyboard/PrivacyInfo.xcprivacy diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift new file mode 100644 index 0000000..b75ca27 --- /dev/null +++ b/OSGKeyboard/Views/APISettingsCard.swift @@ -0,0 +1,124 @@ +// APISettingsCard.swift +// OSGKeyboard · Main App +// +// Editable fields for the three OpenAI-compatible config values: +// Base URL, API Key, Model. + +import SwiftUI +import OSGKeyboardShared + +struct APISettingsCard: View { + @ObservedObject var config: ProviderConfig + @State private var showKey: Bool = false + + var body: some View { + VStack(spacing: 0) { + field( + title: "Base URL", + placeholder: "https://api.openai.com/v1", + text: $config.baseURL, + keyboard: .URL, + autocap: false + ) + Divider().background(Palette.divider) + keyField + Divider().background(Palette.divider) + field( + title: "Model", + placeholder: "gpt-4o-mini", + text: $config.model, + keyboard: .default, + autocap: false + ) + if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { + Divider().background(Palette.divider) + // Use a Button + UIApplication.open instead of SwiftUI + // `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that + // makes its tappable area eat gestures from the adjacent + // TextField, which manifests as "typing jumps to a website". + Button { + UIApplication.shared.open(url) + } label: { + HStack { + Image(systemName: "key.fill") + .foregroundStyle(Palette.accent) + Text("Get an API key") + .foregroundStyle(Palette.textPrimary) + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + // MARK: - Key + + private var keyField: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("API Key") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + Spacer() + Button(action: { showKey.toggle() }) { + Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") + .foregroundStyle(Palette.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(showKey ? "Hide key" : "Show key")) + } + Group { + if showKey { + TextField("sk-…", text: $config.apiKey) + } else { + SecureField("sk-…", text: $config.apiKey) + } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + } + + // MARK: - Generic field + + @ViewBuilder + private func field( + title: String, + placeholder: String, + text: Binding, + keyboard: UIKeyboardType, + autocap: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + TextField(placeholder, text: text) + .keyboardType(keyboard) + .autocorrectionDisabled(true) + .textInputAutocapitalization(autocap ? .sentences : .never) + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .submitLabel(.done) + .onSubmit { /* no-op: prevent the keyboard from "submitting" + and dismissing the sheet on iOS 18 */ } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + } +} diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift new file mode 100644 index 0000000..2af9f1b --- /dev/null +++ b/OSGKeyboard/Views/HomeView.swift @@ -0,0 +1,157 @@ +// HomeView.swift +// OSGKeyboard · Main App +// +// Post-onboarding home. Two jobs: (1) tell the user we're ready, and +// (2) give a clear path to the next setup step if anything is missing. + +import SwiftUI +import OSGKeyboardShared +import OSGKeyboardExt + +struct HomeView: View { + @ObservedObject var config = ProviderConfig.shared + @State private var showSettings = false + @State private var showKeyboardPreview = false + + var body: some View { + ZStack { + Palette.background.ignoresSafeArea() + VStack(spacing: 0) { + statusHeader + .padding(.top, Spacing.xl) + Spacer() + heroButton + Spacer() + actionStack + .padding(.horizontal, Spacing.md) + .padding(.bottom, Spacing.lg) + } + } + .sheet(isPresented: $showSettings) { + SettingsView() + } + .sheet(isPresented: $showKeyboardPreview) { + KeyboardPreviewSheet() + } + .preferredColorScheme(.dark) + } + + // MARK: - Header + + private var statusHeader: some View { + VStack(spacing: Spacing.xs) { + HStack(spacing: 6) { + Circle() + .fill(config.isConfigured ? Palette.success : Palette.warning) + .frame(width: 8, height: 8) + Text(config.isConfigured ? "Ready" : "Setup incomplete") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + Text("OSGKeyboard") + .font(TypeStyle.largeTitle) + .foregroundStyle(Palette.textPrimary) + Text(providerLine) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + .multilineTextAlignment(.center) + } + } + + private var providerLine: String { + let p = LLMProvider.provider(id: config.providerId) + let model = config.model.isEmpty ? "—" : config.model + return "\(p.name) · \(model)" + } + + // MARK: - Hero + + private var heroButton: some View { + Button { + showSettings = true + } label: { + ZStack { + Circle() + .fill(Palette.accentMuted) + .frame(width: 220, height: 220) + .blur(radius: 40) + Circle() + .fill(LinearGradient( + colors: [Palette.surfaceElevated, Palette.surface], + startPoint: .top, + endPoint: .bottom + )) + .frame(width: 160, height: 160) + .overlay( + Circle().stroke(Palette.accent.opacity(0.35), lineWidth: 1.5) + ) + .shadow(color: Palette.accent.opacity(0.18), radius: 24, y: 8) + VStack(spacing: 6) { + Image(systemName: "waveform") + .font(.system(size: 36, weight: .light)) + .foregroundStyle(Palette.accent) + Text("Tap to configure") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Open OSGKeyboard settings")) + } + + // MARK: - Actions + + private var actionStack: some View { + VStack(spacing: Spacing.xs) { + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Label("启用键盘 · Enable in iOS Settings", systemImage: "keyboard") + .primaryButton() + } + .buttonStyle(.plain) + + Button { + showSettings = true + } label: { + Label("编辑 API 配置 · Edit API Configuration", systemImage: "slider.horizontal.3") + .secondaryButton() + } + .buttonStyle(.plain) + + #if DEBUG + Button { + showKeyboardPreview = true + } label: { + Label("键盘预览 · Keyboard Preview (Debug)", systemImage: "eye") + .secondaryButton() + } + .buttonStyle(.plain) + #endif + + HStack(spacing: Spacing.xs) { + Button { + if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") { + UIApplication.shared.open(url) + } + } label: { + Text("GitHub") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + .padding(.horizontal, Spacing.sm) + .padding(.vertical, 6) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + Spacer() + Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")") + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textTertiary) + } + } + } +} diff --git a/OSGKeyboard/Views/KeyboardPreviewSheet.swift b/OSGKeyboard/Views/KeyboardPreviewSheet.swift new file mode 100644 index 0000000..8590544 --- /dev/null +++ b/OSGKeyboard/Views/KeyboardPreviewSheet.swift @@ -0,0 +1,99 @@ +// KeyboardPreviewSheet.swift +// OSGKeyboard · Main App +// +// Renders a stand-in keyboard layout inside the main app so the user +// can preview what the real keyboard extension looks like without +// enabling the keyboard in iOS Settings. Tap the disc to cycle +// through idle / recording / processing so all visual states are +// inspectable. + +import SwiftUI +import OSGKeyboardShared + +struct KeyboardPreviewSheet: View { + @Environment(\.dismiss) private var dismiss + + // We mirror only the fields the stand-in actually needs, instead of + // crossing the OSGKeyboardExt target boundary to construct a + // `KeyboardViewController.State` (whose initialiser is internal). + @State private var phase: StubPhase = .idle + @State private var level: Double = 0 + @State private var transcript: String = "" + + private enum StubPhase { case idle, recording, processing } + + var body: some View { + ZStack { + Palette.background.ignoresSafeArea() + VStack(spacing: 0) { + VStack(spacing: Spacing.md) { + Text("Keyboard Preview") + .font(TypeStyle.title2) + .foregroundStyle(Palette.textPrimary) + Text("Tap the disc to cycle states. The real keyboard uses the same layout.") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.lg) + mockTextField.padding(.horizontal, Spacing.md) + } + .padding(.top, Spacing.lg) + Spacer(minLength: 0) + keyboardBlock + } + } + .preferredColorScheme(.dark) + } + + private var mockTextField: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(Palette.textSecondary) + Text("Type here…") + .foregroundStyle(Palette.textTertiary) + Spacer() + } + .padding(Spacing.md) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + private var keyboardBlock: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Palette.divider) + .frame(height: 0.5) + KeyboardPreviewStub( + phase: stubPhase, + level: level, + transcript: transcript + ) + .environment(\.colorScheme, .dark) + Rectangle() + .fill(Color.black) + .frame(height: 34) + .overlay(alignment: .center) { + Capsule() + .fill(Color.white.opacity(0.4)) + .frame(width: 134, height: 5) + } + } + } + + private var stubPhase: KeyboardPreviewStub.Phase { + switch phase { + case .idle: return .idle + case .recording: return .recording + case .processing: return .processing + } + } +} + +#if DEBUG +#Preview { + KeyboardPreviewSheet() +} +#endif diff --git a/OSGKeyboard/Views/KeyboardPreviewStub.swift b/OSGKeyboard/Views/KeyboardPreviewStub.swift new file mode 100644 index 0000000..600a02c --- /dev/null +++ b/OSGKeyboard/Views/KeyboardPreviewStub.swift @@ -0,0 +1,235 @@ +// KeyboardPreviewStub.swift +// OSGKeyboard · Main App +// +// Stand-in for the keyboard extension's SwiftUI tree. iOS does not allow +// the host app to import symbols from its own keyboard extension target, +// so we ship a minimal mirror here. The actual production layout lives +// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up +// when the user enables the keyboard in iOS Settings. + +import SwiftUI +import OSGKeyboardShared + +struct KeyboardPreviewStub: View { + + enum Phase { case idle, recording, processing } + + let phase: Phase + let level: Double + let transcript: String + + var body: some View { + ZStack(alignment: .top) { + Palette.background + VStack(spacing: 0) { + topBar.frame(height: 32) + centreArea.frame(maxWidth: .infinity, maxHeight: .infinity) + bottomBar.frame(height: 56) + } + .padding(.top, 4) + .padding(.bottom, 6) + } + .frame(height: 280) + .preferredColorScheme(.dark) + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: Spacing.xs) { + modeChip + localeChip + Spacer(minLength: 0) + statusBadge + Button(action: {}) { + Image(systemName: "gearshape.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Palette.textSecondary) + .frame(width: 28, height: 28) + .background(Palette.surface, in: Circle()) + .overlay(Circle().stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, Spacing.md) + } + + private var modeChip: some View { + HStack(spacing: 4) { + Image(systemName: "wand.and.stars") + Text("润色") + Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + + private var localeChip: some View { + HStack(spacing: 4) { + Image(systemName: "globe") + Text("简体") + Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + + private var statusBadge: some View { + Group { + switch phase { + case .idle: + EmptyView() + case .recording: + HStack(spacing: 4) { + Circle().fill(Palette.recordRed).frame(width: 6, height: 6) + Text("REC").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.xs).padding(.vertical, 3) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + case .processing: + HStack(spacing: 4) { + Circle().fill(Palette.accent).frame(width: 6, height: 6) + Text("···").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.xs).padding(.vertical, 3) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + } + } + + // MARK: - Centre area + + private var centreArea: some View { + VStack(spacing: Spacing.xxs) { + transcriptLine.frame(height: 22) + recordDisc.frame(width: 140, height: 140) + } + .frame(maxWidth: .infinity) + } + + private var transcriptLine: some View { + Group { + switch phase { + case .idle: + Text("按住说话 · Hold to talk") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textTertiary) + case .recording: + Text(transcript.isEmpty ? " " : transcript) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textPrimary) + .lineLimit(1) + .truncationMode(.head) + .frame(maxWidth: .infinity) + case .processing: + HStack(spacing: 6) { + ProgressView().controlSize(.mini).tint(Palette.accent) + Text("润色中 · Polishing") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + } + } + .padding(.horizontal, Spacing.md) + } + + private var recordDisc: some View { + ZStack { + if phase == .recording { + Circle() + .stroke(Palette.recordRed.opacity(0.35), lineWidth: 2) + .frame(width: 110, height: 110) + .opacity(0.6) + Circle() + .fill(RadialGradient(colors: [Palette.recordRed.opacity(0.55), .clear], center: .center, startRadius: 30, endRadius: 70)) + .frame(width: 160, height: 160) + .blur(radius: 12) + .opacity(0.4 + level * 0.6) + } + Circle() + .fill(discGradient) + .frame(width: 96, height: 96) + .overlay(Circle().stroke(Color.white.opacity(0.16), lineWidth: 1)) + .shadow(color: .black.opacity(0.4), radius: 10, y: 6) + Group { + switch phase { + case .idle: + Image(systemName: "mic.fill") + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(.white) + case .recording: + HStack(spacing: 3) { + ForEach(0..<12, id: \.self) { i in + Capsule() + .fill(Palette.recordRed) + .frame(width: 2, height: 8 + CGFloat(level * 30) * (i.isMultiple(of: 2) ? 1 : 0.6)) + } + } + .frame(width: 60, height: 32) + case .processing: + ProgressView().tint(.white).scaleEffect(1.1) + } + } + } + } + + private var discGradient: LinearGradient { + switch phase { + case .recording: + return LinearGradient(colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)], startPoint: .top, endPoint: .bottom) + case .processing: + return LinearGradient(colors: [Palette.surfaceElevated, Palette.surface], startPoint: .top, endPoint: .bottom) + case .idle: + return LinearGradient(colors: [Color(white: 0.22), Color(white: 0.10)], startPoint: .top, endPoint: .bottom) + } + } + + // MARK: - Bottom bar + + private var bottomBar: some View { + HStack(spacing: Spacing.xxs) { + iconButton("globe") + iconButton("delete.left") + Spacer(minLength: 0) + Button(action: {}) { + Text("空格") + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .frame(maxWidth: .infinity, minHeight: 42) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + Spacer(minLength: 0) + iconButton("return") + } + .padding(.horizontal, Spacing.sm) + } + + private func iconButton(_ systemName: String) -> some View { + Button(action: {}) { + Image(systemName: systemName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Palette.textPrimary) + .frame(width: 40, height: 40) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + } +} + +#if DEBUG +#Preview { + KeyboardPreviewStub(phase: .idle, level: 0, transcript: "") + .preferredColorScheme(.dark) +} +#endif diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift new file mode 100644 index 0000000..368c0ef --- /dev/null +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -0,0 +1,257 @@ +// OnboardingView.swift +// OSGKeyboard · Main App +// +// Three-step onboarding presented as a horizontal pager: +// +// 1) Welcome — what the app does, in one sentence +// 2) Enable — Settings → General → Keyboards → Add → Allow Full Access +// 3) Setup — pick a provider, paste a key +// +// Visual style: one large accent surface, generous whitespace, single CTA +// at the bottom. No tipsy animations, no cheerful illustrations — every +// pixel is doing one job. + +import SwiftUI +import OSGKeyboardShared + +struct OnboardingView: View { + @ObservedObject var config: ProviderConfig + @State private var page: Int = 0 + + var body: some View { + ZStack { + Palette.background.ignoresSafeArea() + VStack(spacing: 0) { + TabView(selection: $page) { + WelcomePage().tag(0) + EnableKeyboardPage().tag(1) + APISetupPage(config: config).tag(2) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + + pageDots + .padding(.bottom, Spacing.md) + + bottomBar + .padding(.horizontal, Spacing.md) + .padding(.bottom, Spacing.lg) + } + } + .preferredColorScheme(.dark) + } + + private var pageDots: some View { + HStack(spacing: 6) { + ForEach(0..<3, id: \.self) { i in + Capsule() + .fill(i == page ? Palette.accent : Color.white.opacity(0.18)) + .frame(width: i == page ? 18 : 6, height: 6) + .animation(Motion.quick, value: page) + } + } + } + + @ViewBuilder + private var bottomBar: some View { + HStack(spacing: Spacing.sm) { + if page > 0 { + Button { withAnimation(Motion.soft) { page -= 1 } } label: { + Text("Back") + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.dividerStrong, lineWidth: 0.5) + ) + .foregroundStyle(Palette.textPrimary) + } + .buttonStyle(.plain) + } + + Button { + withAnimation(Motion.soft) { + if page < 2 { page += 1 } + } + } label: { + Text(page == 2 ? (config.isConfigured ? "Done" : "Continue") : "Next") + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background( + (page == 2 && !config.isConfigured) ? Palette.surfaceElevated : Palette.accent, + in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + ) + .foregroundStyle( + (page == 2 && !config.isConfigured) ? Palette.textSecondary : Palette.textOnAccent + ) + } + .buttonStyle(.plain) + .disabled(page == 2 && !config.isConfigured) + } + } +} + +// MARK: - Page 1: Welcome + +private struct WelcomePage: View { + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + ZStack { + Circle() + .fill(Palette.accentMuted) + .frame(width: 180, height: 180) + .blur(radius: 30) + Image(systemName: "mic.circle.fill") + .font(.system(size: 96, weight: .light)) + .foregroundStyle(Palette.accent) + } + VStack(spacing: Spacing.sm) { + Text("OSGKeyboard") + .font(TypeStyle.title) + .foregroundStyle(Palette.textPrimary) + Text("按住说话,松开即得润色文字。") + .font(TypeStyle.body) + .foregroundStyle(Palette.textSecondary) + .multilineTextAlignment(.center) + Text("Hold to talk. Release for polished text, in any app.") + .font(TypeStyle.footnote) + .foregroundStyle(Palette.textTertiary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, Spacing.xl) + PrivacyFootnote() + .padding(.top, Spacing.lg) + Spacer() + } + } +} + +private struct PrivacyFootnote: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + footnoteRow(icon: "lock.fill", + title: "Audio stays on device", + body: "Transcribed locally with Apple's speech engine.") + footnoteRow(icon: "wifi", + title: "Only the polished text is sent", + body: "Sent to your chosen LLM to add structure & punctuation.") + footnoteRow(icon: "keyboard", + title: "Works everywhere", + body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.") + } + .padding(Spacing.md) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + .padding(.horizontal, Spacing.md) + } + + private func footnoteRow(icon: String, title: String, body: String) -> some View { + HStack(alignment: .top, spacing: Spacing.xs) { + Image(systemName: icon) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Palette.accent) + .frame(width: 24, height: 24) + .background(Palette.accentMuted, in: Circle()) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textPrimary) + Text(body) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textSecondary) + } + } + } +} + +// MARK: - Page 2: Enable keyboard + +private struct EnableKeyboardPage: View { + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + Image(systemName: "keyboard.fill") + .font(.system(size: 64, weight: .light)) + .foregroundStyle(Palette.accent) + VStack(spacing: Spacing.sm) { + Text("启用 OSGKeyboard") + .font(TypeStyle.title2) + .foregroundStyle(Palette.textPrimary) + Text("Enable OSGKeyboard") + .font(TypeStyle.body) + .foregroundStyle(Palette.textTertiary) + } + VStack(alignment: .leading, spacing: Spacing.sm) { + step(num: 1, text: "Settings → General → Keyboard → Keyboards") + step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard") + step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access”") + step(num: 4, text: "Allow Full Access is required for the microphone and LLM calls.") + } + .cardSurface() + .padding(.horizontal, Spacing.md) + + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square") + .primaryButton() + } + .padding(.horizontal, Spacing.md) + Spacer() + } + } + + private func step(num: Int, text: String) -> some View { + HStack(alignment: .top, spacing: Spacing.xs) { + Text("\(num)") + .font(TypeStyle.caption2) + .frame(width: 22, height: 22) + .background(Palette.accent, in: Circle()) + .foregroundStyle(Palette.textOnAccent) + Text(text) + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +// MARK: - Page 3: API setup + +private struct APISetupPage: View { + @ObservedObject var config: ProviderConfig + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("配置 AI 提供商") + .font(TypeStyle.title2) + .foregroundStyle(Palette.textPrimary) + Text("Configure your AI provider") + .font(TypeStyle.body) + .foregroundStyle(Palette.textTertiary) + Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.") + .font(TypeStyle.footnote) + .foregroundStyle(Palette.textSecondary) + .padding(.top, Spacing.xxs) + } + .padding(.horizontal, Spacing.md) + .padding(.top, Spacing.lg) + + ProviderPickerSection(config: config) + .padding(.horizontal, Spacing.md) + + APISettingsCard(config: config) + .padding(.horizontal, Spacing.md) + } + .padding(.bottom, Spacing.xxxl) + } + } +} diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift new file mode 100644 index 0000000..60a60a2 --- /dev/null +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -0,0 +1,74 @@ +// ProviderPickerSection.swift +// OSGKeyboard · Main App +// +// Provider picker shown inline inside Settings & Onboarding. Each option +// surfaces the provider's name + a short blurb so the user can pick +// confidently without opening a doc. + +import SwiftUI +import OSGKeyboardShared + +struct ProviderPickerSection: View { + @ObservedObject var config: ProviderConfig + + var body: some View { + VStack(spacing: 0) { + ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in + Button { + select(provider) + } label: { + row(provider, selected: provider.id == config.providerId) + } + .buttonStyle(.plain) + if index < LLMProvider.presets.count - 1 { + Divider().background(Palette.divider) + } + } + } + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + private func select(_ provider: LLMProvider) { + withAnimation(Motion.quick) { + config.apply(preset: provider) + } + } + + @ViewBuilder + private func row(_ provider: LLMProvider, selected: Bool) -> some View { + HStack(spacing: Spacing.xs) { + // Provider mark — a coloured dot with first letter + ZStack { + Circle() + .fill(selected ? Palette.accent : Palette.surfaceElevated) + .frame(width: 36, height: 36) + Text(String(provider.name.prefix(1))) + .font(TypeStyle.bodyEmph) + .foregroundStyle(selected ? Palette.textOnAccent : Palette.textPrimary) + } + VStack(alignment: .leading, spacing: 2) { + Text(provider.name) + .font(TypeStyle.bodyEmph) + .foregroundStyle(Palette.textPrimary) + if let blurb = provider.blurb { + Text(blurb) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textTertiary) + } + } + Spacer() + if selected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Palette.accent) + } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .contentShape(Rectangle()) + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift new file mode 100644 index 0000000..3e3c131 --- /dev/null +++ b/OSGKeyboard/Views/SettingsView.swift @@ -0,0 +1,227 @@ +// SettingsView.swift +// OSGKeyboard · Main App +// +// Sheet that hosts the API configuration. Single scrollable column, every +// field earns its space. + +import SwiftUI +import OSGKeyboardShared + +struct SettingsView: View { + @ObservedObject var config = ProviderConfig.shared + @Environment(\.dismiss) private var dismiss + @State private var showResetConfirm = false + + var body: some View { + NavigationStack { + ZStack { + Palette.background.ignoresSafeArea() + ScrollView { + VStack(spacing: Spacing.md) { + providerSection + apiSection + languageSection + promptSection + resetButton + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.md) + } + } + .navigationTitle("设置 · Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .font(TypeStyle.headline) + .foregroundStyle(Palette.accent) + } + } + .preferredColorScheme(.dark) + } + .confirmationDialog( + "Reset all settings?", + isPresented: $showResetConfirm, + titleVisibility: .visible + ) { + Button("Reset", role: .destructive) { + config.reset() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("API key, model, and base URL will be cleared.") + } + } + + // MARK: - Provider + + private var providerSection: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + sectionHeader("Provider · 提供商", subtitle: "Pick the LLM that polishes your dictation.") + ProviderPickerSection(config: config) + } + } + + // MARK: - API + + private var apiSection: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + sectionHeader("API · 接口", subtitle: nil) + APISettingsCard(config: config) + } + } + + // MARK: - Language (ASR + mode) + + private var languageSection: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + sectionHeader("Language · 语言", subtitle: "Choose ASR locale and dictation mode.") + VStack(spacing: 0) { + PickerRow( + title: "Mode", + options: modeOptions, + selection: Binding( + get: { config.modeId }, + set: { config.modeId = $0 } + ) + ) + Divider().background(Palette.divider) + PickerRow( + title: "ASR locale", + options: localeOptions, + selection: Binding( + get: { config.localeId }, + set: { config.localeId = $0 } + ) + ) + } + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + } + + private var modeOptions: [(id: String, label: String)] { + [ + ("off", "Off · 关闭"), + ("transcribe", "Transcribe · 仅转写"), + ("polish", "Polish · 润色") + ] + } + + private var localeOptions: [(id: String, label: String)] { + [ + ("auto", "Auto · 跟随系统"), + ("zh-Hans", "中文(简体)"), + ("zh-Hant", "中文(繁體)"), + ("en-US", "English (US)"), + ("ja-JP", "日本語"), + ("ko-KR", "한국어") + ] + } + + // MARK: - Prompt + + private var promptSection: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack { + sectionHeader("System Prompt · 系统提示", subtitle: nil) + Spacer() + Button("Reset") { config.systemPrompt = config.defaultSystemPrompt } + .font(TypeStyle.caption2) + .foregroundStyle(Palette.accent) + } + VStack(alignment: .leading, spacing: Spacing.xs) { + TextEditor(text: $config.systemPrompt) + .font(TypeStyle.mono) + .scrollContentBackground(.hidden) + .frame(minHeight: 140) + .padding(Spacing.xs) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + .cardSurface() + } + } + + // MARK: - Reset + + private var resetButton: some View { + Button(role: .destructive) { + showResetConfirm = true + } label: { + Text("Reset all settings") + .font(TypeStyle.caption) + .foregroundStyle(Palette.danger) + .frame(maxWidth: .infinity, minHeight: 40) + } + .buttonStyle(.plain) + } + + // MARK: - Header + + private func sectionHeader(_ title: String, subtitle: String?) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textSecondary) + .textCase(.uppercase) + if let subtitle { + Text(subtitle) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textTertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Picker row + +private struct PickerRow: View { + let title: String + let options: [(id: String, label: String)] + @Binding var selection: String + + var body: some View { + HStack { + Text(title) + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + Spacer() + Menu { + ForEach(options, id: \.id) { o in + Button { + selection = o.id + } label: { + if o.id == selection { + Label(o.label, systemImage: "checkmark") + } else { + Text(o.label) + } + } + } + } label: { + HStack(spacing: 4) { + Text(currentLabel) + .font(TypeStyle.body) + .foregroundStyle(Palette.textSecondary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Palette.textTertiary) + } + } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + } + + private var currentLabel: String { + options.first(where: { $0.id == selection })?.label ?? "—" + } +} diff --git a/OpenLessKeyboard/Info.plist b/OSGKeyboardExt/Info.plist similarity index 100% rename from OpenLessKeyboard/Info.plist rename to OSGKeyboardExt/Info.plist diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift new file mode 100644 index 0000000..9c917d2 --- /dev/null +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -0,0 +1,392 @@ +// KeyboardViewController.swift +// OSGKeyboard · Keyboard Extension +// +// Principal class for the Custom Keyboard Extension. Hosts a single +// SwiftUI tree (`KeyboardRootView`) and drives the recording pipeline: +// +// AudioCaptureService ──► ASRService ──► PolishingService ──► insertText +// +// Design notes: +// • The class is `@MainActor` — every UI mutation and `textDocumentProxy` +// call must happen on main, and Swift 6 strict concurrency forces this. +// • State is a single `State` ObservableObject; SwiftUI observes it via +// `@ObservedObject` so we never re-create the hosting root on each tick. +// • `phase` is a real stored property (no derivation) — the previous +// "derive from recordStream" shim locked out every press after the first. +// • Microphone permission is requested *inside* pressBegan, but we still +// start the rest of the press flow optimistically; if permission is +// denied we surface a short error and drop back to idle cleanly. + +import UIKit +import SwiftUI +import AVFoundation +import OSGKeyboardShared + +@objc(KeyboardViewController) +@MainActor +public final class KeyboardViewController: UIInputViewController { + + // MARK: - View model + + @MainActor + public final class State: ObservableObject { + public init() {} + public enum Phase: Equatable { + case idle + case recording + case processing + case error(String) + } + + public enum InputMode: String, CaseIterable, Identifiable { + case off + case transcribe + case polish + + public var id: String { rawValue } + + public var labelKey: String { + switch self { + case .off: return "mode.off" + case .transcribe: return "mode.transcribe" + case .polish: return "mode.polish" + } + } + } + + @Published public var phase: Phase = .idle + @Published public var level: Double = 0 + @Published public var mode: InputMode = .polish + @Published public var localeId: String = "auto" + @Published public var lastTranscript: String = "" + + // Action hooks — injected by the view controller at install time. + var beginRecording: () -> Void = {} + var endRecording: () -> Void = {} + var tapMic: () -> Void = {} // tap on the mic area (advances keyboard) + var openSettings: () -> Void = {} + var setMode: (InputMode) -> Void = { _ in } + var setLocale: (String) -> Void = { _ in } + var insertNewline: () -> Void = {} + var insertSpace: () -> Void = {} + var deleteBackward: () -> Void = {} + + // MARK: - Preview helpers + + #if DEBUG + static var previewIdle: State { + let s = State() + s.phase = .idle + s.level = 0 + s.mode = .polish + s.localeId = "zh-Hans" + s.lastTranscript = "" + return s + } + static var previewRecording: State { + let s = State() + s.phase = .recording + s.level = 0.65 + s.mode = .polish + s.localeId = "zh-Hans" + s.lastTranscript = "你好,我想说一段测试" + return s + } + static var previewProcessing: State { + let s = State() + s.phase = .processing + s.level = 0 + s.mode = .polish + s.localeId = "zh-Hans" + s.lastTranscript = "" + return s + } + #endif + } + + // MARK: - State + + private let state = State() + private let audio = AudioCaptureService() + private let asr: ASRService = ASRServiceFactory.make() + private let polisher = PolishingService() + + private var session: AudioCaptureService.Session? + private var asrTask: Task? + private var levelTask: Task? + private var didRequestMicOnce: Bool = false + + private var hosting: UIHostingController! + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + // iOS 18 keyboard extension MUST opt in to self-sizing, otherwise + // our SwiftUI `frame(height:)` is ignored and the keyboard is + // cropped by the system chrome (Spotlight bar, home indicator). + inputView?.allowsSelfSizing = true + installStateActions() + installSwiftUI() + loadPersistedLocale() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cancelPipeline() + } + + public override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + cancelPipeline() + } + + public override func textDidChange(_ textInput: (any UITextInput)?) { + super.textDidChange(textInput) + // Hook for future per-app mode switching (e.g. password field → .off). + } + + // MARK: - Wiring + + private func installStateActions() { + state.beginRecording = { [weak self] in self?.pressBegan() } + state.endRecording = { [weak self] in self?.pressEnded() } + state.tapMic = { [weak self] in self?.advanceToNextInputMode() } + state.openSettings = { [weak self] in self?.openHostApp() } + state.setMode = { [weak self] m in self?.persistMode(m) } + state.setLocale = { [weak self] l in self?.persistLocale(l) } + state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") } + state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") } + state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() } + } + + private func installSwiftUI() { + let root = KeyboardRootView(state: state) + let host = UIHostingController(rootView: root) + host.view.backgroundColor = .clear + host.view.translatesAutoresizingMaskIntoConstraints = false + addChild(host) + view.addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + // Pin the host view to a fixed height matching KeyboardRootView.totalHeight. + // Without this, iOS lets the system chrome (Spotlight, home + // indicator) bleed into our content. With it, our content area + // is fully reserved and the keyboard feels intentional. + host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight) + ]) + host.didMove(toParent: self) + self.hosting = host + } + + private func loadPersistedLocale() { + let store = AppGroupStore() + let id = store.localeId + state.localeId = id + state.mode = State.InputMode(rawValue: store.modeId) ?? .polish + } + + // MARK: - Press handlers + + private func pressBegan() { + guard state.phase == .idle else { return } + guard state.mode != .off else { return } + // We optimistically enter `.recording`; the capture session will yield + // frames on its own queue, so even if mic permission takes a beat the + // user already feels the press registered. + Task { @MainActor [weak self] in + guard let self else { return } + let granted = await self.requestMicPermission() + guard granted else { + self.state.phase = .error("麦克风被拒绝,请到「设置」中允许") + self.scheduleAutoClearError() + return + } + self.startPipeline() + } + } + + private func pressEnded() { + guard state.phase == .recording else { return } + stopPipeline() + } + + // MARK: - Pipeline + + private func startPipeline() { + let session = audio.start() + self.session = session + state.phase = .recording + state.level = 0 + state.lastTranscript = "" + + let locale = resolveLocale(state.localeId) + let events = asr.transcribe(stream: session.audio, locale: locale) + + asrTask = Task { @MainActor [weak self] in + guard let self else { return } + var lastPartial: String = "" + for await event in events { + switch event { + case .partial(let s): + lastPartial = s + self.state.lastTranscript = s + case .final(let s): + let transcript = s.isEmpty ? lastPartial : s + self.handleFinalTranscript(transcript) + case .error(let m): + self.state.phase = .error("ASR: \(m)") + self.scheduleAutoClearError() + } + } + } + + levelTask = Task { @MainActor [weak self] in + for await level in session.levels { + guard let self else { return } + // Smooth a little extra to feel natural. + self.state.level = Double(self.state.level) * 0.6 + Double(level.meter) * 0.4 + } + } + } + + private func stopPipeline() { + session?.stop() + session = nil + asrTask?.cancel(); asrTask = nil + levelTask?.cancel(); levelTask = nil + } + + private func cancelPipeline() { + stopPipeline() + asr.cancel() + if state.phase == .recording || state.phase == .processing { + state.phase = .idle + } + state.level = 0 + } + + private func handleFinalTranscript(_ transcript: String) { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + state.phase = .idle + return + } + // In `.transcribe` mode, skip the LLM and insert raw. + if state.mode == .transcribe { + textDocumentProxy.insertText(trimmed) + state.lastTranscript = "" + state.phase = .idle + return + } + // `.polish` (default): call the LLM. + state.phase = .processing + Task { @MainActor [weak self] in + guard let self else { return } + do { + let polished = try await self.polisher.polish(trimmed) + self.textDocumentProxy.insertText(polished) + self.state.lastTranscript = "" + self.state.phase = .idle + } catch { + // Fall back to raw transcript on any failure. + self.textDocumentProxy.insertText(trimmed) + self.state.lastTranscript = "" + let msg = (error as? LocalizedError)?.errorDescription + ?? "Polishing failed — inserted raw." + self.state.phase = .error(msg) + self.scheduleAutoClearError() + } + } + } + + // MARK: - Persistence + + private func persistMode(_ m: State.InputMode) { + state.mode = m + AppGroupStore().setModeId(m.rawValue) + } + + private func persistLocale(_ id: String) { + state.localeId = id + AppGroupStore().setLocaleId(id) + } + + // MARK: - Permissions + + private func requestMicPermission() async -> Bool { + if #available(iOS 17.0, *) { + switch AVAudioApplication.shared.recordPermission { + case .granted: return true + case .denied: return false + case .undetermined: + if !didRequestMicOnce { + didRequestMicOnce = true + return await AVAudioApplication.requestRecordPermission() + } + return false + @unknown default: return false + } + } else { + let session = AVAudioSession.sharedInstance() + switch session.recordPermission { + case .granted: return true + case .denied: return false + case .undetermined: + if !didRequestMicOnce { + didRequestMicOnce = true + return await withCheckedContinuation { cont in + session.requestRecordPermission { cont.resume(returning: $0) } + } + } + return false + @unknown default: return false + } + } + } + + // MARK: - Open host app + + private func openHostApp() { + let urlString = "osgkeyboard://settings" + if let url = URL(string: urlString) { + var responder: UIResponder? = self + while let r = responder { + if let app = r as? UIApplication { + app.open(url) + return + } + responder = r.next + } + } + if let url = URL(string: UIApplication.openSettingsURLString) { + var responder: UIResponder? = self + while let r = responder { + if let app = r as? UIApplication { + app.open(url); return + } + responder = r.next + } + } + } + + // MARK: - Helpers + + private func resolveLocale(_ id: String) -> Locale { + if id == "auto" { return .current } + return Locale(identifier: id) + } + + private func scheduleAutoClearError() { + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 2_400_000_000) + guard let self else { return } + if case .error = self.state.phase { + self.state.phase = .idle + } + } + } +} diff --git a/OpenLessKeyboard/OpenLessKeyboard.entitlements b/OSGKeyboardExt/OSGKeyboardExt.entitlements similarity index 56% rename from OpenLessKeyboard/OpenLessKeyboard.entitlements rename to OSGKeyboardExt/OSGKeyboardExt.entitlements index 0c67376..9d4ffbc 100644 --- a/OpenLessKeyboard/OpenLessKeyboard.entitlements +++ b/OSGKeyboardExt/OSGKeyboardExt.entitlements @@ -1,5 +1,10 @@ - + + com.apple.security.application-groups + + group.com.osgkeyboard.shared + + diff --git a/OpenLessKeyboard/PrivacyInfo.xcprivacy b/OSGKeyboardExt/PrivacyInfo.xcprivacy similarity index 100% rename from OpenLessKeyboard/PrivacyInfo.xcprivacy rename to OSGKeyboardExt/PrivacyInfo.xcprivacy diff --git a/OSGKeyboardExt/Services/ASRService.swift b/OSGKeyboardExt/Services/ASRService.swift new file mode 100644 index 0000000..7515e6d --- /dev/null +++ b/OSGKeyboardExt/Services/ASRService.swift @@ -0,0 +1,151 @@ +// ASRService.swift +// OSGKeyboard · Keyboard Extension +// +// Speech-to-text abstraction over Apple's `SFSpeechRecognizer`. +// Honours a user-selected locale (auto / zh-CN / en-US / ja-JP …) so +// dictation is first-class for non-English languages. + +import Foundation +import AVFoundation +import Speech +import os.lock +import OSGKeyboardShared + +// MARK: - Sendable conformance + +// `AVAudioPCMBuffer` and `SFSpeechRecognitionTask` are not Sendable. We +// only ever access them serially — the PCM buffer is built and consumed +// inside a single Task, and the recogniser task is cancelled but never +// shared concurrently — so an unchecked conformance is sound here. +extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {} +extension SFSpeechRecognitionTask: @unchecked @retroactive Sendable {} + +// MARK: - Protocol + +public protocol ASRService: Sendable { + /// Start a transcription session. The returned stream emits `.partial` + /// updates and exactly one `.final` (or `.error`) before finishing. + func transcribe( + stream: AsyncStream, + locale: Locale + ) -> AsyncStream + + /// Cancel any in-flight recognition and tear down its tasks. + func cancel() +} + +public enum ASREvent: Sendable, Equatable { + case partial(String) + case final(String) + case error(String) +} + +// MARK: - Factory + +public enum ASRServiceFactory { + public static func make() -> ASRService { + AppleSpeechASR() + } +} + +// MARK: - Apple Speech implementation + +final class AppleSpeechASR: ASRService, @unchecked Sendable { + + private let lock = OSAllocatedUnfairLock() + private var recognizerTask: SFSpeechRecognitionTask? + private var feedTask: Task? + + func transcribe( + stream: AsyncStream, + locale: Locale + ) -> AsyncStream { + AsyncStream { continuation in + let recognizer = SFSpeechRecognizer(locale: locale) + ?? SFSpeechRecognizer(locale: .current) + guard let recognizer, recognizer.isAvailable else { + continuation.yield(.error("Speech recognizer unavailable for \(locale.identifier)")) + continuation.finish() + return + } + recognizer.defaultTaskHint = .dictation + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition + + let task = recognizer.recognitionTask(with: request) { result, error in + if let error { + let nsErr = error as NSError + // Codes 203 / 1110 = "no speech detected" — a normal exit. + if nsErr.code == 203 || nsErr.code == 1110 { + continuation.yield(.final("")) + } else { + continuation.yield(.error(error.localizedDescription)) + } + continuation.finish() + return + } + guard let result else { return } + if result.isFinal { + continuation.yield(.final(result.bestTranscription.formattedString)) + continuation.finish() + } else { + continuation.yield(.partial(result.bestTranscription.formattedString)) + } + } + + self.lock.withLock { self.recognizerTask = task } + + // Feed audio: for each snapshot, build a 16 kHz mono Float32 + // PCM buffer and immediately `request.append(pcm)`. The PCM + // buffer never leaves this task, so it doesn't need to be + // Sendable. + let feedFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + self.feedTask = Task { [request] in + for await snap in stream { + if Task.isCancelled { break } + guard !snap.samples.isEmpty, + let pcm = AVAudioPCMBuffer( + pcmFormat: feedFormat, + frameCapacity: AVAudioFrameCount(snap.samples.count) + ) + else { continue } + pcm.frameLength = AVAudioFrameCount(snap.samples.count) + if let dst = pcm.floatChannelData?[0] { + snap.samples.withUnsafeBufferPointer { src in + if let base = src.baseAddress { + memcpy(dst, base, snap.samples.count * MemoryLayout.size) + } + } + } + request.append(pcm) + } + if !Task.isCancelled { + request.endAudio() + } + } + + continuation.onTermination = { @Sendable [weak self] _ in + self?.cancel() + } + } + } + + func cancel() { + let (recTask, feedT) = lock.withLock { () -> (SFSpeechRecognitionTask?, Task?) in + let r = self.recognizerTask + let f = self.feedTask + self.recognizerTask = nil + self.feedTask = nil + return (r, f) + } + recTask?.cancel() + feedT?.cancel() + } +} diff --git a/OSGKeyboardExt/Services/AudioCaptureService.swift b/OSGKeyboardExt/Services/AudioCaptureService.swift new file mode 100644 index 0000000..3d5ef0a --- /dev/null +++ b/OSGKeyboardExt/Services/AudioCaptureService.swift @@ -0,0 +1,315 @@ +// AudioCaptureService.swift +// OSGKeyboard · Keyboard Extension +// +// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine. +// Designed for use inside an iOS Custom Keyboard Extension: +// • Uses `.record` (not `.playAndRecord`) — keyboards cannot play. +// • Exposes a Sendable `Session` with two streams: +// - `audio`: 16 kHz mono Float32 frames for ASR. +// - `levels`: RMS + peak dBFS for the animated waveform. +// • All mutable state is guarded by a lock; class is `@unchecked Sendable` +// for use with Swift 6 strict concurrency. + +import Foundation +import AVFoundation +import os.lock +import OSGKeyboardShared + +// MARK: - Sendable conformance +// +// `AVAudioEngine` is not Sendable, but the iOS audio APIs hand us +// closures that need to capture it. We never mutate the engine +// concurrently — capture / conversion are serialised on the actor, and +// the tap closure only reads pointers into it. So an unchecked +// retroactive Sendable conformance is sound here. +// `AVAudioConverter` and `AVAudioFormat` are already Sendable in newer +// SDKs; we don't need to redeclare. +extension AVAudioEngine: @unchecked @retroactive Sendable {} + +public final class AudioCaptureService: @unchecked Sendable { + + // MARK: - Errors + + public enum CaptureError: LocalizedError, Sendable { + case sessionConfigFailed(String) + case engineStartFailed(String) + case noInputNode + case alreadyRunning + + public var errorDescription: String? { + switch self { + case .sessionConfigFailed(let s): return "Audio session config failed: \(s)" + case .engineStartFailed(let s): return "Audio engine failed to start: \(s)" + case .noInputNode: return "No microphone input available." + case .alreadyRunning: return "Audio capture is already running." + } + } + } + + // MARK: - Level payload (Sendable) + + public struct Level: Sendable, Equatable { + /// Root-mean-square, 0...1 (linear). + public let rms: Float + /// Peak amplitude, 0...1 (linear). + public let peak: Float + public let timestamp: TimeInterval + + /// Convenience: -20 dBFS … 0 dBFS mapped to 0…1 for UI meters. + public var meter: Float { + // Clamp floor at -50 dB so silence still shows a tiny bar. + let db = 20 * log10(max(rms, 1e-7)) + let clamped = max(-50, min(0, db)) + return Float((clamped + 50) / 50) + } + } + + // MARK: - Session (one capture run) + + /// A single capture run. Two streams + a stop handle. + public final class Session: @unchecked Sendable { + public let audio: AsyncStream + public let levels: AsyncStream + private let onStop: @Sendable () -> Void + + fileprivate init( + audio: AsyncStream, + levels: AsyncStream, + onStop: @escaping @Sendable () -> Void + ) { + self.audio = audio + self.levels = levels + self.onStop = onStop + } + + public func stop() { onStop() } + } + + // MARK: - State + + private let lock = OSAllocatedUnfairLock() + private var engine: AVAudioEngine? + private var converter: AVAudioConverter? + private var audioContinuation: AsyncStream.Continuation? + private var levelContinuation: AsyncStream.Continuation? + private var isRunning: Bool = false + + public init() {} + + deinit { stopInternal() } + + // MARK: - Public API + + /// Start capture. Returns a `Session` whose streams yield audio + level data. + /// The session ends when `Session.stop()` is called or the extension is torn down. + @discardableResult + public func start() -> Session { + let (audioStream, audioCont) = AsyncStream.makeStream() + let (levelStream, levelCont) = AsyncStream.makeStream() + + // Fail fast if already running. + let alreadyRunning: Bool = lock.withLock { isRunning } + if alreadyRunning { + audioCont.finish() + levelCont.finish() + return Session(audio: audioStream, levels: levelStream, onStop: {}) + } + + do { + try configureSession() + try bootstrap(audioCont: audioCont, levelCont: levelCont) + lock.withLock { + isRunning = true + audioContinuation = audioCont + levelContinuation = levelCont + } + } catch { + // Tear down whatever we partially created. + audioCont.finish() + levelCont.finish() + teardownEngine() + return Session(audio: audioStream, levels: levelStream, onStop: {}) + } + + return Session( + audio: audioStream, + levels: levelStream, + onStop: { [weak self] in self?.stop() } + ) + } + + public func stop() { + stopInternal() + } + + // MARK: - Setup + + private func configureSession() throws { + #if canImport(UIKit) + let session = AVAudioSession.sharedInstance() + do { + // `.record` — keyboards cannot play audio, so .playAndRecord is wrong. + // `.measurement` mode disables system AGC/echo cancellation for cleaner ASR input. + // `.duckOthers` is harmless in record-only. + try session.setCategory(.record, mode: .measurement, options: [.duckOthers]) + try session.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + throw CaptureError.sessionConfigFailed(error.localizedDescription) + } + #endif + } + + private func bootstrap( + audioCont: AsyncStream.Continuation, + levelCont: AsyncStream.Continuation + ) throws { + let engine = AVAudioEngine() + let input = engine.inputNode + let hardwareFormat = input.outputFormat(forBus: 0) + guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else { + throw CaptureError.noInputNode + } + + let target = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + + guard let converter = AVAudioConverter(from: hardwareFormat, to: target) else { + throw CaptureError.engineStartFailed("converter init failed") + } + + // Persistent buffer for level computation: we re-use Float arrays to + // avoid per-tap allocations. + let levelScratch = LevelScratch() + + input.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, when in + // We capture self weakly only to keep the AudioCaptureService + // alive while the tap is installed; the tap itself only + // touches the local `audioCont` / `levelCont` continuations. + guard self != nil else { return } + // 1) Compute level from raw hardware buffer (preserves true amplitude). + let (rms, peak) = levelScratch.measure(buffer: buffer) + // `AVAudioTime` carries both `sampleTime` (frames on the device + // clock) and `hostTime` (mach absolute time). We only need a + // monotonically increasing source for the timestamp; sample + // time / sample rate is good enough and is independent of the + // host clock. + let ts: Double + if when.sampleTime > 0, hardwareFormat.sampleRate > 0 { + ts = Double(when.sampleTime) / hardwareFormat.sampleRate + } else { + ts = Date().timeIntervalSinceReferenceDate + } + levelCont.yield(Level(rms: rms, peak: peak, timestamp: ts)) + + // 2) Convert to 16 kHz mono Float32 for ASR. + let outFrames = AVAudioFrameCount( + Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate + ) + guard outFrames > 0, + let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) + else { return } + + var error: NSError? + let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in + outStatus.pointee = .haveData + return buffer + } + if status == .haveData, error == nil { + let snap = AudioBufferSnapshot(buffer: outBuffer) + audioCont.yield(snap) + } + } + + do { + try engine.start() + } catch { + input.removeTap(onBus: 0) + throw CaptureError.engineStartFailed(error.localizedDescription) + } + + lock.withLock { + self.engine = engine + self.converter = converter + } + } + + // MARK: - Teardown + + private func stopInternal() { + let (wasRunning, engine, audioCont, levelCont) = lock.withLock { () -> (Bool, AVAudioEngine?, AsyncStream.Continuation?, AsyncStream.Continuation?) in + let was = isRunning + isRunning = false + let eng = self.engine + let ac = audioContinuation + let lc = levelContinuation + self.engine = nil + self.converter = nil + self.audioContinuation = nil + self.levelContinuation = nil + return (was, eng, ac, lc) + } + guard wasRunning else { return } + engine?.inputNode.removeTap(onBus: 0) + engine?.stop() + #if canImport(UIKit) + // Deactivate the session so other apps' audio routing is restored. + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + #endif + audioCont?.finish() + levelCont?.finish() + } + + private func teardownEngine() { + let (engine, audioCont, levelCont) = lock.withLock { () -> (AVAudioEngine?, AsyncStream.Continuation?, AsyncStream.Continuation?) in + let e = self.engine + let a = audioContinuation + let l = levelContinuation + self.engine = nil + self.converter = nil + self.audioContinuation = nil + self.levelContinuation = nil + self.isRunning = false + return (e, a, l) + } + engine?.inputNode.removeTap(onBus: 0) + engine?.stop() + audioCont?.finish() + levelCont?.finish() + } +} + +// MARK: - Level scratch (lock-free, single-writer / single-reader per tap) + +/// Lock-free per-tap scratch for RMS + peak measurement. The AVAudio tap is +/// always invoked serially per input node, so we don't need a lock here. +private final class LevelScratch: @unchecked Sendable { + private var last: (rms: Float, peak: Float) = (0, 0) + + func measure(buffer: AVAudioPCMBuffer) -> (rms: Float, peak: Float) { + guard let ch = buffer.floatChannelData?[0] else { return last } + let n = Int(buffer.frameLength) + guard n > 0 else { return last } + + // Decay smoothing — keeps the meter lively but not jittery. + var sumSq: Float = 0 + var peak: Float = 0 + for i in 0.. peak { peak = a } + } + let rms = sqrtf(sumSq / Float(n)) + + // Exponential moving average for visual smoothness. + let alpha: Float = 0.35 + let smoothedRms = alpha * rms + (1 - alpha) * last.rms + let smoothedPeak = max(alpha * peak, (1 - alpha) * last.peak) + last = (smoothedRms, smoothedPeak) + return (smoothedRms, smoothedPeak) + } +} diff --git a/OpenLessKeyboard/Services/PolishingService.swift b/OSGKeyboardExt/Services/PolishingService.swift similarity index 98% rename from OpenLessKeyboard/Services/PolishingService.swift rename to OSGKeyboardExt/Services/PolishingService.swift index 165c24b..70c5561 100644 --- a/OpenLessKeyboard/Services/PolishingService.swift +++ b/OSGKeyboardExt/Services/PolishingService.swift @@ -18,7 +18,7 @@ public actor PolishingService { private let store: AppGroupStore private let timeout: TimeInterval - public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 8) { + public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 12) { self.store = store self.timeout = timeout } diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift new file mode 100644 index 0000000..78ab68f --- /dev/null +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -0,0 +1,402 @@ +// KeyboardRootView.swift +// OSGKeyboard · Keyboard Extension +// +// Typeless-inspired keyboard surface. The keyboard is laid out in three +// vertical bands, but the entire height is reserved for us — we set +// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's +// frame is honoured, and we add safe-area insets at the top and bottom so +// the system Spotlight / home-indicator chrome never clips our controls. +// +// ┌───────────────────────────────────────────┐ +// │ [polish] [中] ● ⚙ │ ← top: 32 pt (incl. safe top) +// │ (transcript preview) │ ← 24 pt +// │ │ +// │ ◯ ▲▲▲▲▲ │ ← centre: 96 pt disc + +// │ │ breathing ring +// │ │ +// ├───────────────────────────────────────────┤ +// │ 🌐 ⌫ [ space ] ↩ │ ← bottom: 60 pt (incl. safe bottom) +// └───────────────────────────────────────────┘ + +import SwiftUI +import OSGKeyboardShared + +public struct KeyboardRootView: View { + + @ObservedObject var state: State + + public init(state: KeyboardViewController.State) { + self.state = state + } + + /// Total keyboard height. We set the same value as a height-anchor + /// constraint in the view controller so the host UIInputView picks + /// it up. + static let totalHeight: CGFloat = 280 + + public var body: some View { + ZStack(alignment: .top) { + background + + VStack(spacing: 0) { + topBar + .frame(height: 32) + + centreArea + .frame(maxWidth: .infinity, maxHeight: .infinity) + + bottomBar + .frame(height: 56) + } + .padding(.top, 4) + .padding(.bottom, 6) + } + .frame(height: Self.totalHeight) + .preferredColorScheme(.dark) + } + + // MARK: - Background + + /// Solid dark fill plus a hairline highlight at the top edge, so the + /// keyboard reads as a physical surface rather than a floating card. + private var background: some View { + ZStack { + Palette.background + VStack(spacing: 0) { + Rectangle() + .fill( + LinearGradient( + colors: [Color.white.opacity(0.05), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(height: 1) + Spacer(minLength: 0) + } + } + .overlay(alignment: .top) { + Rectangle() + .fill(Palette.divider) + .frame(height: 0.5) + } + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: Spacing.xs) { + ModeChip(mode: state.mode) { newMode in + state.setMode(newMode) + } + LocaleChip(localeId: state.localeId) { newId in + state.setLocale(newId) + } + Spacer(minLength: 0) + StatusBadge(phase: state.phase) + Button(action: state.openSettings) { + Image(systemName: "gearshape.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Palette.textSecondary) + .frame(width: 28, height: 28) + .background(Palette.surface, in: Circle()) + .overlay(Circle().stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Open OSGKeyboard settings")) + } + .padding(.horizontal, Spacing.md) + } + + // MARK: - Centre area + + private var centreArea: some View { + ZStack { + VStack(spacing: Spacing.xxs) { + TranscriptLine(phase: state.phase, transcript: state.lastTranscript) + .frame(height: 22) + RecordButton( + phase: buttonPhase, + level: state.level, + onPressBegan: state.beginRecording, + onPressEnded: state.endRecording, + onTap: state.tapMic + ) + .frame(width: 140, height: 140) + } + } + .frame(maxWidth: .infinity) + } + + // MARK: - Bottom bar + + private var bottomBar: some View { + HStack(spacing: Spacing.xxs) { + ToolbarIconButton(systemName: "globe", label: "nextKeyboard") { + state.tapMic() + } + ToolbarIconButton(systemName: "delete.left", label: "delete") { + state.deleteBackward() + } + Spacer(minLength: 0) + Button(action: state.insertSpace) { + Text("空格") + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .frame(maxWidth: .infinity, minHeight: 42) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Space")) + Spacer(minLength: 0) + ToolbarIconButton(systemName: "return", label: "newline") { + state.insertNewline() + } + } + .padding(.horizontal, Spacing.sm) + } + + private var buttonPhase: RecordButton.Phase { + switch state.phase { + case .idle: return .idle + case .recording: return .recording + case .processing: return .processing + case .error: return .error + } + } +} + +// MARK: - State alias + +extension KeyboardRootView { + typealias State = KeyboardViewController.State +} + +// MARK: - SwiftUI Preview + +#if DEBUG +#Preview("Keyboard · Idle") { + KeyboardRootView(state: KeyboardViewController.State.previewIdle) + .frame(width: 390, height: 280) + .preferredColorScheme(.dark) +} + +#Preview("Keyboard · Recording") { + KeyboardRootView(state: KeyboardViewController.State.previewRecording) + .frame(width: 390, height: 280) + .preferredColorScheme(.dark) +} + +#Preview("Keyboard · Processing") { + KeyboardRootView(state: KeyboardViewController.State.previewProcessing) + .frame(width: 390, height: 280) + .preferredColorScheme(.dark) +} +#endif + +// MARK: - Transcript line + +private struct TranscriptLine: View { + let phase: KeyboardViewController.State.Phase + let transcript: String + + var body: some View { + ZStack { + switch phase { + case .idle: + Text("按住说话 · Hold to talk") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textTertiary) + case .recording: + Text(transcript.isEmpty ? " " : transcript) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textPrimary) + .lineLimit(1) + .truncationMode(.head) + .frame(maxWidth: .infinity) + case .processing: + HStack(spacing: 6) { + ProgressView().controlSize(.mini).tint(Palette.accent) + Text("润色中 · Polishing") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + case .error(let msg): + Text(msg) + .font(TypeStyle.caption) + .foregroundStyle(Palette.warning) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, Spacing.md) + } +} + +// MARK: - Toolbar icon button + +private struct ToolbarIconButton: View { + let systemName: String + let label: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Palette.textPrimary) + .frame(width: 40, height: 40) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(label)) + } +} + +// MARK: - Status badge + +private struct StatusBadge: View { + let phase: KeyboardViewController.State.Phase + + var body: some View { + Group { + switch phase { + case .idle: + EmptyView() + case .recording: + dot(color: Palette.recordRed, label: "REC") + case .processing: + dot(color: Palette.accent, label: "···") + case .error: + dot(color: Palette.warning, label: "!") + } + } + } + + private func dot(color: Color, label: String) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(label) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.xs) + .padding(.vertical, 3) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } +} + +// MARK: - Mode chip + +private struct ModeChip: View { + let mode: KeyboardViewController.State.InputMode + let onChange: (KeyboardViewController.State.InputMode) -> Void + + var body: some View { + Menu { + ForEach(KeyboardViewController.State.InputMode.allCases) { m in + Button { + onChange(m) + } label: { + if m == mode { + Label(label(for: m), systemImage: "checkmark") + } else { + Text(label(for: m)) + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: icon(for: mode)) + Text(label(for: mode)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(mode == .off ? Palette.textTertiary : Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2) + .padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + .menuStyle(.button) + } + + private func label(for m: KeyboardViewController.State.InputMode) -> String { + switch m { + case .off: return "Off" + case .transcribe: return "转写" + case .polish: return "润色" + } + } + + private func icon(for m: KeyboardViewController.State.InputMode) -> String { + switch m { + case .off: return "mic.slash.fill" + case .transcribe: return "text.bubble.fill" + case .polish: return "wand.and.stars" + } + } +} + +// MARK: - Locale chip + +private struct LocaleChip: View { + let localeId: String + let onChange: (String) -> Void + + private let options: [(id: String, label: String)] = [ + ("auto", "Auto"), + ("zh-Hans", "简体"), + ("zh-Hant", "繁體"), + ("en-US", "EN"), + ("ja-JP", "日"), + ("ko-KR", "한") + ] + + var body: some View { + Menu { + ForEach(options, id: \.id) { o in + Button { + onChange(o.id) + } label: { + if o.id == localeId { + Label(o.label, systemImage: "checkmark") + } else { + Text(o.label) + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: "globe") + Text(currentLabel) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2) + .padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + .menuStyle(.button) + } + + private var currentLabel: String { + options.first(where: { $0.id == localeId })?.label ?? "Auto" + } +} diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift new file mode 100644 index 0000000..2e57659 --- /dev/null +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -0,0 +1,187 @@ +// RecordButton.swift +// OSGKeyboard · Keyboard Extension +// +// The hero control. 120 pt primary disc with a soft inner gradient, a +// breathing outer ring while recording, and a centred waveform that maps +// directly to the real audio RMS. Idle / recording / processing are three +// distinct visual states — no flicker, no surprise transitions. + +import SwiftUI +import OSGKeyboardShared + +struct RecordButton: View { + enum Phase: Equatable { + case idle + case recording + case processing + case error + } + + let phase: Phase + let level: Double // 0...1 + let onPressBegan: () -> Void + let onPressEnded: () -> Void + let onTap: () -> Void + + @GestureState private var isPressed: Bool = false + @State private var breath: Bool = false + + init( + phase: Phase, + level: Double, + onPressBegan: @escaping () -> Void, + onPressEnded: @escaping () -> Void, + onTap: @escaping () -> Void + ) { + self.phase = phase + self.level = level + self.onPressBegan = onPressBegan + self.onPressEnded = onPressEnded + self.onTap = onTap + } + + var body: some View { + ZStack { + // Outer breathing ring (recording only) + Circle() + .stroke(Palette.recordRed.opacity(0.35), lineWidth: 2) + .frame(width: 150, height: 150) + .scaleEffect(breath ? 1.18 : 0.95) + .opacity(phase == .recording ? 1 : 0) + .animation(Motion.breath, value: breath) + + // Halo: soft red glow that intensifies with input level + Circle() + .fill( + RadialGradient( + colors: [Palette.recordRed.opacity(0.55), .clear], + center: .center, + startRadius: 50, + endRadius: 100 + ) + ) + .frame(width: 200, height: 200) + .opacity(phase == .recording ? 0.4 + level * 0.6 : 0) + .blur(radius: 18) + .animation(Motion.soft, value: phase) + .animation(Motion.soft, value: level) + + // Secondary outer ring (always present, dimmer when idle) + Circle() + .stroke( + Color.white.opacity(phase == .idle ? 0.08 : 0.12), + lineWidth: 0.5 + ) + .frame(width: 140, height: 140) + + // Main disc with gradient + soft inner highlight + ZStack { + Circle() + .fill(discGradient) + Circle() + .stroke(Color.white.opacity(0.16), lineWidth: 1) + .blendMode(.overlay) + + // Centre content — switches by phase + Group { + switch phase { + case .idle: + Image(systemName: "mic.fill") + .font(.system(size: 38, weight: .medium)) + .foregroundStyle(Palette.textPrimary) + case .recording: + WaveformView(level: level, active: true) + .frame(width: 80, height: 44) + .transition(.opacity) + case .processing: + ProgressView() + .progressViewStyle(.circular) + .tint(Palette.textPrimary) + .scaleEffect(1.2) + case .error: + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 30, weight: .medium)) + .foregroundStyle(Palette.warning) + } + } + } + .frame(width: 120, height: 120) + .scaleEffect(isPressed ? 0.94 : 1.0) + .shadow(color: .black.opacity(0.45), radius: 14, y: 8) + .animation(Motion.quick, value: isPressed) + .animation(Motion.soft, value: phase) + } + .contentShape(Circle()) + // Press-to-talk: act on the FIRST touch-down, not after a 150 ms + // minimum duration. That's what Typeless feels like, and it's what + // makes the keyboard feel responsive. A tap (very short press) is + // interpreted as "toggle" for the secondary action (onTap), not + // "record" — the recording only fires if the press lasts long + // enough to read as intentional. This avoids the previous bug + // where every single tap fired both onPressBegan AND onTap. + .gesture( + LongPressGesture(minimumDuration: 0.18) + .sequenced(before: DragGesture(minimumDistance: 0)) + .updating($isPressed) { value, state, _ in + switch value { + case .second(true, _): state = true + default: state = false + } + } + .onChanged { value in + if case .second(true, _) = value, !pressArmed { + pressArmed = true + onPressBegan() + } + } + .onEnded { _ in + if pressArmed { pressArmed = false; onPressEnded() } + } + ) + .simultaneousGesture( + // Pure tap: only fires when the user lifts before the long-press + // threshold. This becomes the "secondary action" (e.g. cycle + // mode). It is paired with, not conflicting with, the long-press. + TapGesture(count: 1) + .onEnded { + if !pressArmed { onTap() } + } + ) + .onAppear { breath = (phase == .recording) } + .onChange(of: phase) { _, new in + breath = (new == .recording) + } + .accessibilityLabel(Text("Push to talk")) + } + + @State private var pressArmed: Bool = false + + private var discGradient: LinearGradient { + switch phase { + case .recording: + return LinearGradient( + colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)], + startPoint: .top, + endPoint: .bottom + ) + case .processing: + return LinearGradient( + colors: [Palette.surfaceElevated, Palette.surface], + startPoint: .top, + endPoint: .bottom + ) + case .error: + return LinearGradient( + colors: [Palette.warning.opacity(0.85), Palette.warning.opacity(0.55)], + startPoint: .top, + endPoint: .bottom + ) + case .idle: + return LinearGradient( + colors: [Color(white: 0.22), Color(white: 0.10)], + startPoint: .top, + endPoint: .bottom + ) + } + } +} diff --git a/OSGKeyboardExt/Views/WaveformView.swift b/OSGKeyboardExt/Views/WaveformView.swift new file mode 100644 index 0000000..c2d8a35 --- /dev/null +++ b/OSGKeyboardExt/Views/WaveformView.swift @@ -0,0 +1,54 @@ +// WaveformView.swift +// OSGKeyboard · Keyboard Extension +// +// Symmetric, real-time driven waveform. 18 bars centred around a vertical +// axis. The dominant bar is driven by the current RMS; surrounding bars +// decay on a small position-based curve so the visual feels like a +// horizontal speaker cone, not random noise. + +import SwiftUI +import OSGKeyboardShared + +struct WaveformView: View { + let level: Double // 0...1, smoothed RMS + let barCount: Int + let color: Color + let active: Bool // when false, bars collapse to a thin resting line + + init( + level: Double, + barCount: Int = 18, + color: Color = Palette.recordRed, + active: Bool = true + ) { + self.level = max(0, min(1, level)) + self.barCount = barCount + self.color = color + self.active = active + } + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + HStack(alignment: .center, spacing: 3) { + ForEach(0.. CGFloat { + guard active else { return 4 } + let centre = Double(barCount - 1) / 2.0 + let distance = abs(Double(index) - centre) / max(centre, 1) + // Per-bar small wobble so the line is alive but tied to level. + let phase = sin(time * 4.0 + Double(index) * 0.45) + let wobble = 0.18 * phase + let magnitude = max(0, min(1, Double(level) + wobble)) + let profile = 1.0 - pow(distance, 1.4) * 0.85 + return CGFloat(max(6, 32 * magnitude * profile)) + } +} diff --git a/OSGKeyboardShared/Constants/AppGroup.swift b/OSGKeyboardShared/Constants/AppGroup.swift new file mode 100644 index 0000000..abd8583 --- /dev/null +++ b/OSGKeyboardShared/Constants/AppGroup.swift @@ -0,0 +1,32 @@ +// AppGroup.swift +// OSGKeyboard · Shared +// +// App Group identifier shared between main app and keyboard extension. +// UserDefaults(suiteName:) and file containers use this. + +import Foundation + +public enum AppGroup { + /// App Group container identifier (must match entitlements in both targets) + public static let identifier = "group.com.osgkeyboard.shared" + + /// Shared UserDefaults instance for cross-process config. + /// + /// Falls back to `.standard` if the App Group isn't available (e.g. + /// the user hasn't created the App Group in the Apple Developer + /// portal, or Xcode hasn't downloaded a matching provisioning profile). + /// In that mode, the keyboard extension will *not* see config written + /// by the main app — but the main app itself stays usable so the user + /// can fix the signing situation without the app crashing. + public static var defaults: UserDefaults { + if let d = UserDefaults(suiteName: identifier) { + return d + } + #if DEBUG + print("⚠️ App Group \(identifier) unavailable — falling back to .standard. " + + "Add the App Group in your Apple Developer account and Xcode " + + "Signing & Capabilities, then re-run.") + #endif + return .standard + } +} diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift new file mode 100644 index 0000000..d26fd1b --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -0,0 +1,156 @@ +// Theme.swift +// OSGKeyboard · Design System +// +// Single source of truth for colour, spacing, corner radius, typography. +// Inspired by Dieter Rams ("less but better") and Apple Human Interface: +// every surface has a single purpose, every token earns its place, and +// the visual hierarchy is carried by *whitespace + one accent*, never by +// extra colour. + +import SwiftUI + +// MARK: - Palette + +public enum Palette { + // Backgrounds + public static let background = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B + public static let surface = Color(red: 0.094, green: 0.094, blue: 0.106) // #18181B + public static let surfaceElevated = Color(red: 0.153, green: 0.153, blue: 0.169) // #27272A + public static let surfaceMuted = Color(red: 0.071, green: 0.071, blue: 0.082) // #121215 + + // Accents + public static let accent = Color(red: 0.353, green: 0.784, blue: 0.980) // #5AC8FA + public static let accentMuted = accent.opacity(0.18) + public static let accentGlow = accent.opacity(0.42) + + // Semantic + public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A + public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69 + public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18 + + // Text + public static let textPrimary = Color.white + public static let textSecondary = Color(white: 0.7) + public static let textTertiary = Color(white: 0.50) + public static let textOnAccent = Color.black + + // Lines + public static let divider = Color.white.opacity(0.06) + public static let dividerStrong = Color.white.opacity(0.10) + + // Recording state + public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30 +} + +// MARK: - Spacing scale (4 pt grid) + +public enum Spacing { + public static let xxs: CGFloat = 4 + public static let xs: CGFloat = 8 + public static let sm: CGFloat = 12 + public static let md: CGFloat = 16 + public static let lg: CGFloat = 20 + public static let xl: CGFloat = 24 + public static let xxl: CGFloat = 32 + public static let xxxl: CGFloat = 40 + public static let hero: CGFloat = 48 +} + +// MARK: - Corner radius scale + +public enum Radius { + public static let small: CGFloat = 8 + public static let medium: CGFloat = 12 + public static let large: CGFloat = 16 + public static let xl: CGFloat = 20 + public static let xxl: CGFloat = 24 + public static let pill: CGFloat = 999 +} + +// MARK: - Typography + +public enum TypeStyle { + public static let caption2 = Font.system(size: 11, weight: .medium) + public static let caption = Font.system(size: 12, weight: .medium) + public static let footnote = Font.system(size: 13, weight: .regular) + public static let body = Font.system(size: 15, weight: .regular) + public static let bodyEmph = Font.system(size: 15, weight: .medium) + public static let headline = Font.system(size: 17, weight: .semibold) + public static let title3 = Font.system(size: 20, weight: .semibold) + public static let title2 = Font.system(size: 22, weight: .bold) + public static let title = Font.system(size: 28, weight: .bold) + public static let largeTitle = Font.system(size: 34, weight: .bold) + public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced) + public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced) +} + +// MARK: - Animation + +public enum Motion { + public static let quick = Animation.spring(response: 0.25, dampingFraction: 0.85) + public static let soft = Animation.spring(response: 0.40, dampingFraction: 0.80) + public static let deliberate = Animation.spring(response: 0.55, dampingFraction: 0.78) + public static let breath = Animation.easeInOut(duration: 1.6).repeatForever(autoreverses: true) + public static let instant = Animation.linear(duration: 0.12) +} + +// MARK: - Reusable view modifiers + +public extension View { + /// Standard card surface used in the main app. + func cardSurface(padding: CGFloat = Spacing.md) -> some View { + self + .padding(padding) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + /// Muted pill (used for tags, locale indicators, etc.). + func pillChip(foreground: Color = Palette.textSecondary) -> some View { + self + .padding(.horizontal, Spacing.xs) + .padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .foregroundStyle(foreground) + } + + /// Primary CTA button. + func primaryButton() -> some View { + self + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background(Palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .foregroundStyle(Palette.textOnAccent) + } + + /// Secondary CTA button. + func secondaryButton() -> some View { + self + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.dividerStrong, lineWidth: 0.5) + ) + .foregroundStyle(Palette.textPrimary) + } + + /// Legacy alias for older call sites. + func cardStyle() -> some View { cardSurface() } +} + +// MARK: - Backwards compat (legacy callers in old code) + +public enum Theme { + public static let background = Palette.background + public static let card = Palette.surface + public static let accent = Palette.accent + public static let danger = Palette.danger + public static let textPrimary = Palette.textPrimary + public static let textSecondary = Palette.textSecondary + public static let divider = Palette.divider +} diff --git a/OpenLessShared/Info.plist b/OSGKeyboardShared/Info.plist similarity index 100% rename from OpenLessShared/Info.plist rename to OSGKeyboardShared/Info.plist diff --git a/OSGKeyboardShared/Models/AudioBufferSnapshot.swift b/OSGKeyboardShared/Models/AudioBufferSnapshot.swift new file mode 100644 index 0000000..5c7f4f6 --- /dev/null +++ b/OSGKeyboardShared/Models/AudioBufferSnapshot.swift @@ -0,0 +1,34 @@ +// AudioBufferSnapshot.swift +// OSGKeyboard · Shared +// +// Sendable wrapper around a Float32 audio buffer's raw samples. +// The snapshot is the only thing that crosses actor / concurrency +// boundaries; the recognizer re-creates an `AVAudioPCMBuffer` on its +// own side and consumes it locally (never yielding it back out). + +import Foundation +import AVFoundation + +public struct AudioBufferSnapshot: Sendable { + public let samples: [Float] + public let sampleRate: Double + + public init(samples: [Float], sampleRate: Double) { + self.samples = samples + self.sampleRate = sampleRate + } + + /// Construct from an `AVAudioPCMBuffer` by copying out the channel data. + public init(buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData else { + self.samples = [] + self.sampleRate = buffer.format.sampleRate + return + } + let n = Int(buffer.frameLength) + var copy = [Float](repeating: 0, count: n) + memcpy(©, channelData[0], n * MemoryLayout.size) + self.samples = copy + self.sampleRate = buffer.format.sampleRate + } +} diff --git a/OpenLessShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift similarity index 60% rename from OpenLessShared/Models/LLMProvider.swift rename to OSGKeyboardShared/Models/LLMProvider.swift index cc87663..361cd1e 100644 --- a/OpenLessShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -12,19 +12,23 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { public let defaultBaseURL: String public let defaultModel: String public let apiKeyURL: URL? + /// Optional short blurb shown under the provider name in the picker. + public let blurb: String? public init( id: String, name: String, defaultBaseURL: String, defaultModel: String, - apiKeyURL: URL? = nil + apiKeyURL: URL? = nil, + blurb: String? = nil ) { self.id = id self.name = name self.defaultBaseURL = defaultBaseURL self.defaultModel = defaultModel self.apiKeyURL = apiKeyURL + self.blurb = blurb } public static let presets: [LLMProvider] = [ @@ -33,27 +37,47 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { name: "OpenAI", defaultBaseURL: "https://api.openai.com/v1", defaultModel: "gpt-4o-mini", - apiKeyURL: URL(string: "https://platform.openai.com/api-keys") + apiKeyURL: URL(string: "https://platform.openai.com/api-keys"), + blurb: "GPT-4o mini · 多语言" ), .init( id: "deepseek", name: "DeepSeek", defaultBaseURL: "https://api.deepseek.com/v1", defaultModel: "deepseek-chat", - apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys") + apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"), + blurb: "deepseek-chat · 中文友好" ), .init( id: "qwen", - name: "Qwen (DashScope, OpenAI-compatible)", + name: "Qwen (DashScope)", defaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", defaultModel: "qwen-plus", - apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey") + apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"), + blurb: "通义千问 · OpenAI 兼容" + ), + .init( + id: "zhipu", + name: "智谱 GLM", + defaultBaseURL: "https://open.bigmodel.cn/api/paas/v4", + defaultModel: "glm-4-flash", + apiKeyURL: URL(string: "https://bigmodel.cn/usercenter/apikeys"), + blurb: "GLM-4-Flash · 中文优化" + ), + .init( + id: "moonshot", + name: "月之暗面 Moonshot", + defaultBaseURL: "https://api.moonshot.cn/v1", + defaultModel: "moonshot-v1-8k", + apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"), + blurb: "Kimi · 长上下文" ), .init( id: "custom", name: "Custom (OpenAI-compatible)", defaultBaseURL: "", - defaultModel: "" + defaultModel: "", + blurb: "自建 / 任意 OpenAI 兼容端点" ) ] diff --git a/OpenLessShared/Models/LLMRequest.swift b/OSGKeyboardShared/Models/LLMRequest.swift similarity index 100% rename from OpenLessShared/Models/LLMRequest.swift rename to OSGKeyboardShared/Models/LLMRequest.swift diff --git a/OpenLessShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift similarity index 56% rename from OpenLessShared/Models/ProviderConfig.swift rename to OSGKeyboardShared/Models/ProviderConfig.swift index 5156f46..3457ca2 100644 --- a/OpenLessShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -10,60 +10,69 @@ import Combine public final class ProviderConfig: ObservableObject, @unchecked Sendable { public static let shared = ProviderConfig() - // Storage keys private enum Key { static let providerId = "config.providerId" static let baseURL = "config.baseURL" static let apiKey = "config.apiKey" static let model = "config.model" static let systemPrompt = "config.systemPrompt" + static let modeId = "config.modeId" + static let localeId = "config.localeId" } @Published public var providerId: String { didSet { defaults.set(providerId, forKey: Key.providerId) } } - @Published public var baseURL: String { didSet { defaults.set(baseURL, forKey: Key.baseURL) } } - @Published public var apiKey: String { didSet { defaults.set(apiKey, forKey: Key.apiKey) } } - @Published public var model: String { didSet { defaults.set(model, forKey: Key.model) } } - @Published public var systemPrompt: String { didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) } } + @Published public var modeId: String { + didSet { defaults.set(modeId, forKey: Key.modeId) } + } + @Published public var localeId: String { + didSet { defaults.set(localeId, forKey: Key.localeId) } + } public var isConfigured: Bool { !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty } - public let defaultSystemPrompt = """ - You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text: - 1) Preserve the user's original intent and meaning; do not invent facts. - 2) Add proper punctuation, capitalization, and paragraph breaks. - 3) When the user enumerates items ("first ... second ... third"), output a markdown list. - 4) Keep the output concise — do not exceed 1.5x the spoken length. - 5) Output in the same language as the input. - """ + /// The system prompt the user *sees* in the editor — fall back to the + /// provider-aware default from `AppGroupStore` when nothing is set. + public var defaultSystemPrompt: String { + AppGroupStore.defaultSystemPrompt(for: providerId) + } private let defaults: UserDefaults public init(defaults: UserDefaults = AppGroup.defaults) { self.defaults = defaults - self.providerId = defaults.string(forKey: Key.providerId) ?? "openai" - self.baseURL = defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: "openai").defaultBaseURL + let pid = defaults.string(forKey: Key.providerId) ?? "openai" + let preset = LLMProvider.provider(id: pid) + self.providerId = pid + self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL self.apiKey = defaults.string(forKey: Key.apiKey) ?? "" - self.model = defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: "openai").defaultModel - self.systemPrompt = defaults.string(forKey: Key.systemPrompt) ?? defaultSystemPrompt + self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel + self.systemPrompt = defaults.string(forKey: Key.systemPrompt) + ?? AppGroupStore.defaultSystemPrompt(for: pid) + self.modeId = defaults.string(forKey: Key.modeId) ?? "polish" + self.localeId = defaults.string(forKey: Key.localeId) ?? "auto" } public func apply(preset: LLMProvider) { + // Capture the *previous* provider id BEFORE we mutate, so the + // system-prompt reset check below can compare against the actual + // prior default. + let oldId = providerId providerId = preset.id if !preset.defaultBaseURL.isEmpty { baseURL = preset.defaultBaseURL @@ -71,6 +80,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { if !preset.defaultModel.isEmpty { model = preset.defaultModel } + // When switching providers, reset the system prompt to the new + // provider's default — otherwise the user is left editing a + // Chinese prompt on a US-English model. + if systemPrompt.isEmpty + || systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) { + systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id) + } } public func reset() { @@ -79,6 +95,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { baseURL = preset.defaultBaseURL apiKey = "" model = preset.defaultModel - systemPrompt = defaultSystemPrompt + systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") } } diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift new file mode 100644 index 0000000..86c031a --- /dev/null +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -0,0 +1,106 @@ +// AppGroupStore.swift +// OSGKeyboard · Shared +// +// Convenience wrapper around App Group UserDefaults for non-Published reads. +// Used by the keyboard extension (no SwiftUI) to read config without +// instantiating an ObservableObject. + +import Foundation + +public struct AppGroupStore: @unchecked Sendable { + public let defaults: UserDefaults + + public init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + // MARK: - Keys + + private enum Key { + static let providerId = "config.providerId" + static let baseURL = "config.baseURL" + static let apiKey = "config.apiKey" + static let model = "config.model" + static let systemPrompt = "config.systemPrompt" + static let modeId = "config.modeId" + static let localeId = "config.localeId" + } + + // MARK: - Reads + + public var providerId: String { + defaults.string(forKey: Key.providerId) ?? "openai" + } + + public var baseURL: String { + defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL + } + + public var apiKey: String { + defaults.string(forKey: Key.apiKey) ?? "" + } + + public var model: String { + defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel + } + + public var systemPrompt: String { + defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId) + } + + public var modeId: String { + defaults.string(forKey: Key.modeId) ?? "polish" + } + + public var localeId: String { + defaults.string(forKey: Key.localeId) ?? "auto" + } + + // MARK: - Writes + + public func setModeId(_ id: String) { + defaults.set(id, forKey: Key.modeId) + } + + public func setLocaleId(_ id: String) { + defaults.set(id, forKey: Key.localeId) + } + + // MARK: - Client + + public func makeClient() -> LLMClient { + OpenAICompatibleClient( + baseURL: baseURL, + apiKey: apiKey, + model: model + ) + } + + // MARK: - Defaults + + /// Per-provider default system prompt. We bias the prompt by the + /// provider's *primary* language so Chinese LLMs naturally return + /// Chinese for Chinese input, and English LLMs stay terse. + public static func defaultSystemPrompt(for providerId: String) -> String { + switch providerId { + case "zhipu", "moonshot", "qwen", "deepseek": + return """ + 你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字: + 1) 保留原意,不编造事实;保持输入语言。 + 2) 添加恰当的标点、大小写、段落。 + 3) 当用户枚举"第一…第二…第三…"时,使用 markdown 列表。 + 4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。 + 5) 只输出润色后的正文,不要解释、不要加引号。 + """ + default: + return """ + You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text: + 1) Preserve the user's original intent and meaning; do not invent facts. + 2) Add proper punctuation, capitalization, and paragraph breaks. + 3) When the user enumerates items ("first ... second ... third"), output a markdown list. + 4) Keep the output concise — do not exceed 1.5x the spoken length. Drop filler words (um, uh, like). + 5) Output in the same language as the input. No quotes, no explanation, no preamble. + """ + } + } +} diff --git a/OpenLessShared/Services/LLMClient.swift b/OSGKeyboardShared/Services/LLMClient.swift similarity index 75% rename from OpenLessShared/Services/LLMClient.swift rename to OSGKeyboardShared/Services/LLMClient.swift index 0531427..650a746 100644 --- a/OpenLessShared/Services/LLMClient.swift +++ b/OSGKeyboardShared/Services/LLMClient.swift @@ -9,20 +9,21 @@ import Foundation public enum LLMError: Error, LocalizedError, Sendable { case invalidURL case noAPIKey - case http(status: Int, body: String) + case http(status: Int) case decoding(String) - case transport(underlying: String) + case transport(String) case cancelled + case rateLimited public var errorDescription: String? { switch self { - case .invalidURL: return "Invalid API URL." - case .noAPIKey: return "API key is missing." - case .http(let s, let body): - return "API returned HTTP \(s): \(body.prefix(200))" - case .decoding(let s): return "Failed to decode response: \(s)" - case .transport(let s): return "Network error: \(s)" - case .cancelled: return "Request was cancelled." + case .invalidURL: return "API 地址无效。请在设置中检查 Base URL。" + case .noAPIKey: return "未填写 API Key。" + case .http(let s): return "API 返回 HTTP \(s)。请稍后重试或联系服务方。" + case .decoding: return "解析 API 响应失败。" + case .transport: return "网络错误,请检查连接后重试。" + case .rateLimited: return "API 调用过于频繁,请稍候再试。" + case .cancelled: return "请求已取消。" } } } @@ -81,11 +82,16 @@ public struct OpenAICompatibleClient: LLMClient { do { let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { - throw LLMError.transport(underlying: "non-HTTP response") + throw LLMError.transport("non-HTTP response") } if !(200..<300).contains(http.statusCode) { + #if DEBUG + // Log full body for debugging — never expose to UI. let body = String(data: data, encoding: .utf8) ?? "" - throw LLMError.http(status: http.statusCode, body: body) + print("⚠️ LLM HTTP \(http.statusCode): \(body.prefix(500))") + #endif + if http.statusCode == 429 { throw LLMError.rateLimited } + throw LLMError.http(status: http.statusCode) } do { let decoded = try JSONDecoder().decode(LLMResponse.self, from: data) @@ -100,7 +106,7 @@ public struct OpenAICompatibleClient: LLMClient { } catch let urlError as URLError where urlError.code == .cancelled { throw LLMError.cancelled } catch { - throw LLMError.transport(underlying: String(describing: error)) + throw LLMError.transport(String(describing: error)) } } } diff --git a/OpenLessTests/Info.plist b/OSGKeyboardTests/Info.plist similarity index 100% rename from OpenLessTests/Info.plist rename to OSGKeyboardTests/Info.plist diff --git a/OpenLessTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift similarity index 96% rename from OpenLessTests/LLMClientTests.swift rename to OSGKeyboardTests/LLMClientTests.swift index 0ca10d4..6ead26e 100644 --- a/OpenLessTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -12,8 +12,9 @@ final class LLMClientTests: XCTestCase { // MARK: - ProviderConfig persistence func testProviderConfigPersistsAcrossInstances() { - let defaults = UserDefaults(suiteName: "group.com.osgkeyboard.ios.tests")! - defaults.removePersistentDomain(forName: "group.com.osgkeyboard.ios.tests") + let suiteName = "group.com.osgkeyboard.shared.tests" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) let config1 = ProviderConfig(defaults: defaults) config1.baseURL = "https://example.com/v1" diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png deleted file mode 100644 index 5cfd8eb8d0ff161a73e70da1ca8cb77f6bbc821f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13557 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
0;<48|JQSESvk$=O_UI?`gSkYI zo~;j_m$RaUO*y4%@ezsMBoF(xG5&>-s@zF^4!=HdR^_9aJNEw8oc?Z%HbT|-_zd$c zRyI{*V{uke{iQK2=Tg;hG$_4$dR>Re(857ykWGa^8BL`LJAraoK5Z!Opp-6l1jG#c z?{!Kp!f7y^{{j;(8H%reg$6b>m4A2BEIY^xVWhnr{@y6}#H->HhK|Sm(7~SZwo&If zVKkUMUIJ?x`b-00u z?emOxe&B()4xY|7c0;qqwpt=9?YOJscWK|9se}1BL!zd-<+QN&ghzB_@Kh+kVl&D((e7KNuBwq~7O8-1^QT@fUU@9oZ9+Fvt zmjnLVmc^-Xb&JFgcReE)et`iNwHg4uIr(H&W6Z%W(<2POQ~#o|R9fHA7?GY;`(}LK zzmg3=$1QmjArQal?mg2#1w-79ON}+&jk3@bpb~&RY0u9|N~7aN+45;ihEnr%Y3&I^ z+ua$Gm7)!Xvj<>04mrdBiy0u`R|~%sxes0&JTV;-EDv_jM{8Qp&*7M^TJOdgT5vvo z()!xO$WPib0^&wo3fTWdEHwDwQZ2m;7fC}u@chWvDMr;<{WaCrHJROiS)Qb2&hl4L zx5yc96PKAt}B<;PaZU$OwrZPl-^$TQo zc;HA%Uxd%OoD#usrjIoB68}T3w($3{>IY!h-|?3J_nV=eUyqx<1w;caD1Co#ceTNu zIM0tOx-5BQqsnlE;Fe|xX>HIwDEYz@q@O)HfnSjM6Gmr`y!Bm#h;e(qMD$wn1cOmx z$6|irJ^!K^((D+eExt`UxKgJ4P^WH}hNH*iCo5^OyI#jjL+zEd26P>oyE2KZ3ndufFTb>mMcDtjAaT=dB6>emwklOh(3mDOd(edzMj2IwnF4YngUC#Em4QMuz4rBY)ZmB$k^JLHP(fE(I^Z~F zkVD!-Agus|a4=u44E?2FdvgAXr!j=QrtX|;88q1vn)iF}7rcfF zy`yb+W5RtGyzK}Ue1{4g{MZgDx$Y`7^@G4Cb5N1A89>jN(KidLi;@Sh?K`6y$is^f zGXQAzRU~(B(I9lR8zzBayzp-5+h3-&mBPN^MUA5o4GL4e$J>v|=%OKM{TaIq1o8(q z_$+uaAQQ}xfuh2}<_~dyLp#bQ;h;e&0nF!@WMVq0!y+mw=ri8wWKPQ+= zX(`Nk$mQ8O(L8v4NVc36G|G>W#ed!7y`aDdE{hP(A`6qvvTt>pyHKOf2r}fxh6H|# z@TU&16_dAXZeFx#5 zi6>K>-5s&Iq0rO;Ho|qok4wva;>lsU=s2?&6v$o2CJT2+?8YOjmzRND{7WRcAa**c zgmK$Vyl#pKq5<${YC-<%FvD+>I3S#%qO zZ7i;}LuOWMDee^>N!uKxi5YbMGGnMGmL8jkT;-Th6DvJ7=^t;qVW=(XCqsD8f)zn? z#ofUolN@hLu;>8-qr7m+#ZpcdH})kzkb0Vfa1XY^m>^CTBg z!#9KC0}+0GgKVWA?7N?!sMkiqGnnco_}bIc{jh`XGxA+v7B^z8qz7@u7D>#o60X^Y zSzU@>3e>v9li&4{hg*5?y-<&k?S90jA5b?x6L-xRv)5SZ#}9HgMYm<J}0#` zCll3`;0SpV_+>o*2lKZOR|a1$CSbl?I0#OXY2l=-v%Rj}|YTX4NXa z_ECnCss}3d(8skP7`xf(*kS}fCVR1oGOD{2ir_DnliH_0 zb_6eR-HzY`APniTq#|edrAja&bRf|@G9Ibw%gfP`$ZADS27sRc31NvGGSC@o44DxC zjlv)uE(=Wb*9cV(6Mjn*jPSvoxF+Slqv!n@$Z$37b|SY2W;=FAWI4tIU^9D7jyx=p zM;!Blk;r~0|F*(qK9qn@603ElP;(vzG3qg--kKfv)e;HsuSyy$S43uB7yn3y@zXa3 s{0Zs(0a3YF4tX3Oro)bP@|2i#@@r9!oEAJ2WQ10)uv(t*JOAYW0iYN>yZ`_I diff --git a/OpenLess/OpenLess.entitlements b/OpenLess/OpenLess.entitlements deleted file mode 100644 index 0c67376..0000000 --- a/OpenLess/OpenLess.entitlements +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/OpenLess/OpenLessApp.swift b/OpenLess/OpenLessApp.swift deleted file mode 100644 index 97066b8..0000000 --- a/OpenLess/OpenLessApp.swift +++ /dev/null @@ -1,23 +0,0 @@ -// OpenLessApp.swift -// OSGKeyboard · Main App -// -// App entry point. Switches between Onboarding and Home based on -// whether the user has configured their API key yet. - -import SwiftUI -import OSGKeyboardShared - -@main -struct OSGKeyboardApp: App { - @StateObject private var config = ProviderConfig.shared - - var body: some Scene { - WindowGroup { - if config.isConfigured { - HomeView() - } else { - OnboardingView() - } - } - } -} diff --git a/OpenLess/Views/APISettingsCard.swift b/OpenLess/Views/APISettingsCard.swift deleted file mode 100644 index b82b124..0000000 --- a/OpenLess/Views/APISettingsCard.swift +++ /dev/null @@ -1,102 +0,0 @@ -// APISettingsCard.swift -// OSGKeyboard · Main App -// -// Editable fields for the four OpenAI-compatible config values: -// Base URL, API Key, Model, System Prompt. - -import SwiftUI -import OSGKeyboardShared - -struct APISettingsCard: View { - @ObservedObject var config: ProviderConfig - @State private var showKey: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - field("Base URL", text: $config.baseURL, isSecure: false, - keyboard: .URL, autocap: false) - keyField - field("Model", text: $config.model, isSecure: false, - keyboard: .default, autocap: false) - - VStack(alignment: .leading, spacing: 4) { - Text("System Prompt") - .font(.caption) - .foregroundStyle(Theme.textSecondary) - TextEditor(text: $config.systemPrompt) - .font(.system(size: 12, design: .monospaced)) - .scrollContentBackground(.hidden) - .frame(minHeight: 110) - .padding(8) - .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) - Button("Reset to default") { - config.systemPrompt = config.defaultSystemPrompt - } - .font(.caption2) - .foregroundStyle(Theme.accent) - } - - if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { - Link(destination: url) { - Label("Get an API key", systemImage: "key.fill") - .font(.caption) - } - } - } - .cardStyle() - } - - private var keyField: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("API Key") - .font(.caption) - .foregroundStyle(Theme.textSecondary) - Spacer() - Button(action: { showKey.toggle() }) { - Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") - .foregroundStyle(Theme.textSecondary) - } - .buttonStyle(.plain) - } - Group { - if showKey { - TextField("sk-...", text: $config.apiKey) - } else { - SecureField("sk-...", text: $config.apiKey) - } - } - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .padding(10) - .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) - } - } - - @ViewBuilder - private func field( - _ title: String, - text: Binding, - isSecure: Bool, - keyboard: UIKeyboardType, - autocap: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption) - .foregroundStyle(Theme.textSecondary) - Group { - if isSecure { - SecureField("", text: text) - } else { - TextField("", text: text) - .keyboardType(keyboard) - .autocorrectionDisabled(true) - } - } - .textInputAutocapitalization(autocap ? .sentences : .never) - .padding(10) - .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) - } - } -} diff --git a/OpenLess/Views/HomeView.swift b/OpenLess/Views/HomeView.swift deleted file mode 100644 index 0cc8e97..0000000 --- a/OpenLess/Views/HomeView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// HomeView.swift -// OSGKeyboard · Main App -// -// Minimal home screen shown after onboarding. Two CTAs: enable keyboard -// (opens iOS settings) and edit API config (sheet). - -import SwiftUI -import OSGKeyboardShared - -struct HomeView: View { - @ObservedObject var config = ProviderConfig.shared - @State private var showSettings = false - - var body: some View { - ZStack { - Theme.background.ignoresSafeArea() - VStack(spacing: 22) { - Spacer() - Image(systemName: "mic.circle.fill") - .font(.system(size: 80)) - .foregroundStyle(Theme.accent) - Text("OSGKeyboard is ready") - .font(.title2.weight(.bold)) - .foregroundStyle(Theme.textPrimary) - Text(currentProviderSubtitle) - .font(.subheadline) - .foregroundStyle(Theme.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - - Spacer() - - VStack(spacing: 12) { - primaryButton( - title: "Enable in iOS Settings", - systemImage: "gearshape.fill" - ) { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - primaryButton( - title: "Edit API Configuration", - systemImage: "key.fill", - secondary: true - ) { - showSettings = true - } - } - .padding(.horizontal, 24) - - Spacer().frame(height: 12) - } - } - .sheet(isPresented: $showSettings) { - SettingsView() - } - .preferredColorScheme(.dark) - } - - private var currentProviderSubtitle: String { - let name = LLMProvider.provider(id: config.providerId).name - return "Using \(name) • Model: \(config.model.isEmpty ? "—" : config.model)" - } - - private func primaryButton( - title: String, - systemImage: String, - secondary: Bool = false, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack { - Image(systemName: systemImage) - Text(title).font(.subheadline.weight(.semibold)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background( - secondary ? Theme.card : Theme.accent, - in: RoundedRectangle(cornerRadius: 14, style: .continuous) - ) - .foregroundStyle(secondary ? Theme.textPrimary : .black) - } - } -} diff --git a/OpenLess/Views/OnboardingView.swift b/OpenLess/Views/OnboardingView.swift deleted file mode 100644 index f3be0a6..0000000 --- a/OpenLess/Views/OnboardingView.swift +++ /dev/null @@ -1,177 +0,0 @@ -// OnboardingView.swift -// OSGKeyboard · Main App -// -// Three-page horizontal onboarding: -// 1) Welcome -// 2) Enable keyboard + Allow Full Access -// 3) Pick provider + enter API key - -import SwiftUI -import OSGKeyboardShared - -struct OnboardingView: View { - @ObservedObject var config = ProviderConfig.shared - @State private var page: Int = 0 - - var body: some View { - ZStack { - Theme.background.ignoresSafeArea() - VStack(spacing: 0) { - TabView(selection: $page) { - WelcomePage().tag(0) - EnableKeyboardPage().tag(1) - APISetupPage(config: config) { - // completion — root switches to HomeView - } - .tag(2) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - - pageDots - .padding(.bottom, 12) - - bottomBar - } - } - .preferredColorScheme(.dark) - } - - private var pageDots: some View { - HStack(spacing: 6) { - ForEach(0..<3, id: \.self) { i in - Circle() - .fill(i == page ? Theme.accent : Color.white.opacity(0.2)) - .frame(width: 6, height: 6) - } - } - } - - private var bottomBar: some View { - HStack { - if page > 0 { - Button("Back") { withAnimation { page -= 1 } } - .foregroundStyle(Theme.textSecondary) - } - Spacer() - if page < 2 { - Button { - withAnimation { page += 1 } - } label: { - Text("Next") - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 20).padding(.vertical, 10) - .background(Theme.accent, in: Capsule()) - .foregroundStyle(.black) - } - } else { - Button { - // finalise — ProviderConfig is already bound to App Group - } label: { - Text("Done") - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 24).padding(.vertical, 10) - .background(config.isConfigured ? Theme.accent : Color.gray.opacity(0.4), - in: Capsule()) - .foregroundStyle(.black) - } - .disabled(!config.isConfigured) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 18) - } -} - -private struct WelcomePage: View { - var body: some View { - VStack(spacing: 18) { - Spacer() - Image(systemName: "mic.circle.fill") - .font(.system(size: 80)) - .foregroundStyle(Theme.accent) - Text("OSGKeyboard") - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(Theme.textPrimary) - Text("Hold the mic key, speak, and let AI polish your words into clean text — in every app.") - .multilineTextAlignment(.center) - .font(.system(size: 15)) - .foregroundStyle(Theme.textSecondary) - .padding(.horizontal, 28) - Spacer() - } - } -} - -private struct EnableKeyboardPage: View { - var body: some View { - VStack(spacing: 18) { - Spacer() - Image(systemName: "keyboard.fill") - .font(.system(size: 64)) - .foregroundStyle(Theme.accent) - Text("Enable OSGKeyboard") - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.textPrimary) - - VStack(alignment: .leading, spacing: 10) { - step(num: 1, text: "Open Settings → General → Keyboard → Keyboards") - step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard") - step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access” (needed for mic + LLM)") - } - .padding(.horizontal, 22) - - Button { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } label: { - Label("Open iOS Settings", systemImage: "arrow.up.right.square") - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 18).padding(.vertical, 10) - .background(Theme.accent, in: Capsule()) - .foregroundStyle(.black) - } - .padding(.top, 6) - Spacer() - } - } - - private func step(num: Int, text: String) -> some View { - HStack(alignment: .top, spacing: 10) { - Text("\(num)") - .font(.system(size: 11, weight: .bold)) - .frame(width: 20, height: 20) - .background(Theme.accent, in: Circle()) - .foregroundStyle(.black) - Text(text) - .font(.system(size: 14)) - .foregroundStyle(Theme.textPrimary) - } - } -} - -private struct APISetupPage: View { - @ObservedObject var config: ProviderConfig - let onDone: () -> Void - - var body: some View { - ScrollView { - VStack(spacing: 14) { - Text("Configure your AI provider") - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.textPrimary) - .padding(.top, 18) - Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.") - .font(.caption) - .multilineTextAlignment(.center) - .foregroundStyle(Theme.textSecondary) - .padding(.horizontal, 24) - - ProviderPickerSection(config: config) - APISettingsCard(config: config) - .padding(.horizontal, 16) - } - .padding(.bottom, 40) - } - } -} diff --git a/OpenLess/Views/ProviderPickerSection.swift b/OpenLess/Views/ProviderPickerSection.swift deleted file mode 100644 index 831e57a..0000000 --- a/OpenLess/Views/ProviderPickerSection.swift +++ /dev/null @@ -1,36 +0,0 @@ -// ProviderPickerSection.swift -// OSGKeyboard · Main App -// -// A picker that swaps in the right BaseURL/Model defaults for a chosen -// provider, while letting the user override each field. - -import SwiftUI -import OSGKeyboardShared - -struct ProviderPickerSection: View { - @ObservedObject var config: ProviderConfig - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Provider") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Theme.textSecondary) - Spacer() - } - - Picker("Provider", selection: $config.providerId) { - ForEach(LLMProvider.presets) { p in - Text(p.name).tag(p.id) - } - } - .pickerStyle(.menu) - .tint(Theme.accent) - .onChange(of: config.providerId) { _, newId in - let preset = LLMProvider.provider(id: newId) - config.apply(preset: preset) - } - } - .cardStyle() - } -} diff --git a/OpenLess/Views/SettingsView.swift b/OpenLess/Views/SettingsView.swift deleted file mode 100644 index b26a0e4..0000000 --- a/OpenLess/Views/SettingsView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// SettingsView.swift -// OSGKeyboard · Main App -// -// Reachable from HomeView. Reuses the same Onboarding cards. - -import SwiftUI -import OSGKeyboardShared - -struct SettingsView: View { - @ObservedObject var config = ProviderConfig.shared - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - ZStack { - Theme.background.ignoresSafeArea() - ScrollView { - VStack(spacing: 14) { - ProviderPickerSection(config: config) - APISettingsCard(config: config) - .padding(.horizontal, 16) - - Button { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } label: { - Label("Open iOS Keyboard Settings", systemImage: "keyboard") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Theme.card, in: RoundedRectangle(cornerRadius: 12)) - .foregroundStyle(Theme.textPrimary) - } - .padding(.horizontal, 16) - } - .padding(.vertical, 18) - } - } - .navigationTitle("Settings") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - .foregroundStyle(Theme.accent) - } - } - .preferredColorScheme(.dark) - } - } -} diff --git a/OpenLess/Views/Theme.swift b/OpenLess/Views/Theme.swift deleted file mode 100644 index c96a7b4..0000000 --- a/OpenLess/Views/Theme.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Theme.swift -// OSGKeyboard · Main App -// -// Centralised colours, fonts, and reusable modifiers so the app looks -// coherent. Inspired by Typeless: dark base, soft frosted surfaces, -// generous whitespace, large rounded buttons. - -import SwiftUI - -enum Theme { - static let background = Color(red: 0.07, green: 0.07, blue: 0.08) - static let card = Color(white: 0.12) - static let accent = Color(red: 0.36, green: 0.78, blue: 0.98) // soft cyan - static let danger = Color(red: 0.97, green: 0.42, blue: 0.45) - static let textPrimary = Color.white - static let textSecondary = Color(white: 0.7) - static let divider = Color(white: 0.18) -} - -extension View { - func cardStyle() -> some View { - self - .padding(16) - .background(Theme.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Theme.divider, lineWidth: 0.5) - ) - } -} diff --git a/OpenLessKeyboard/KeyboardViewController.swift b/OpenLessKeyboard/KeyboardViewController.swift deleted file mode 100644 index cea041a..0000000 --- a/OpenLessKeyboard/KeyboardViewController.swift +++ /dev/null @@ -1,285 +0,0 @@ -// KeyboardViewController.swift -// OSGKeyboard · Keyboard Extension -// -// The principal class for the Custom Keyboard Extension. Manages the -// recording pipeline: AudioCapture -> ASR -> LLM polish -> insertText. - -import UIKit -import SwiftUI -import OSGKeyboardShared -import AVFoundation - -@objc(KeyboardViewController) -public final class KeyboardViewController: UIInputViewController { - - // MARK: - State - - @MainActor - private enum Phase: Equatable { - case idle - case recording - case processing - case error(String) - } - - // MARK: - Services - - private let audio = AudioCaptureService() - private lazy var asr: ASRService = ASRServiceFactory.create() - private let polisher = PolishingService() - - // MARK: - Pipeline state - - private var recordStream: AsyncStream? - private var recordContinuation: Task? - private var asrTask: Task? - private var lastTranscript: String = "" - - // MARK: - UI - - private var hosting: UIHostingController! - private var levelTimer: Timer? - private var currentLevel: Double = 0 - - // MARK: - Lifecycle - - public override func viewDidLoad() { - super.viewDidLoad() - installSwiftUI() - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - requestMicPermissionIfNeeded() - } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - cancelPipeline() - } - - public override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - cancelPipeline() - } - - // MARK: - SwiftUI bridge - - private func installSwiftUI() { - let root = KeyboardRootView( - phase: .idle, - level: 0, - onPressBegan: { [weak self] in self?.pressBegan() }, - onPressEnded: { [weak self] in self?.pressEnded() }, - onTap: { [weak self] in self?.handleTap() }, - onOpenSettings:{ [weak self] in self?.openHostApp() } - ) - let host = UIHostingController(rootView: root) - host.view.backgroundColor = .clear - addChild(host) - view.addSubview(host.view) - host.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - host.view.topAnchor.constraint(equalTo: view.topAnchor), - host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - host.didMove(toParent: self) - self.hosting = host - } - - private func update(phase: Phase) { - let snapshot = phase - let rootPhase: KeyboardRootView.Phase = { - switch snapshot { - case .idle: return .idle - case .recording: return .recording - case .processing: return .processing - case .error(let m): return .error(m) - } - }() - hosting.rootView = KeyboardRootView( - phase: rootPhase, - level: currentLevel, - onPressBegan: { [weak self] in self?.pressBegan() }, - onPressEnded: { [weak self] in self?.pressEnded() }, - onTap: { [weak self] in self?.handleTap() }, - onOpenSettings:{ [weak self] in self?.openHostApp() } - ) - } - - // MARK: - Press handlers - - private func pressBegan() { - guard case .idle = currentPhase() else { return } - requestMicPermissionIfNeeded { [weak self] granted in - guard let self else { return } - guard granted else { - Task { @MainActor in self.update(phase: .error("Microphone denied. Enable in Settings.")) } - return - } - self.startPipeline() - } - } - - private func pressEnded() { - guard case .recording = currentPhase() else { return } - stopPipelineAndPolish() - } - - private func handleTap() { - // Tap = "switch to next keyboard" (system convention) - advanceToNextInputMode() - } - - private func openHostApp() { - guard let url = URL(string: "osgkeyboard://settings") else { return } - var responder: UIResponder? = self - while let r = responder { - if let app = r as? UIApplication { - app.open(url) - return - } - responder = r.next - } - // Fallback: open settings page - if let url = URL(string: UIApplication.openSettingsURLString) { - var r: UIResponder? = self - while let r2 = r { - if let app = r2 as? UIApplication { - app.open(url); return - } - r = r2.next - } - } - } - - // MARK: - Pipeline - - private func startPipeline() { - update(phase: .recording) - let stream = audio.start() - recordStream = stream - - // 1) ASR pipeline - let events = asr.transcribe(stream: stream) - asrTask = Task { [weak self] in - guard let self else { return } - for await event in events { - switch event { - case .partial(let s): - // optionally show partial in status bar (keep simple — silent) - _ = s - case .final(let s): - await MainActor.run { self.lastTranscript = s } - case .error(let msg): - await MainActor.run { self.update(phase: .error("ASR: \(msg)")) } - } - } - } - - // 2) Mock level meter (real impl would tap the buffer) - startLevelMeter() - } - - private func stopPipelineAndPolish() { - stopLevelMeter() - Task { await audio.stop() } - asrTask?.cancel() - - let snapshot = lastTranscript - guard !snapshot.isEmpty else { - update(phase: .idle) - return - } - - update(phase: .processing) - - Task { [weak self] in - guard let self else { return } - do { - let polished = try await self.polisher.polish(snapshot) - await MainActor.run { - self.textDocumentProxy.insertText(polished) - self.lastTranscript = "" - self.update(phase: .idle) - } - } catch { - // fallback: insert raw transcript - await MainActor.run { - self.textDocumentProxy.insertText(snapshot) - self.lastTranscript = "" - let msg = (error as? LocalizedError)?.errorDescription ?? "Polishing failed, inserted raw." - self.update(phase: .error(msg)) - // auto-clear error back to idle after 2s - Task { @MainActor in - try? await Task.sleep(nanoseconds: 2_000_000_000) - if case .error = self.currentPhase() { - self.update(phase: .idle) - } - } - } - } - } - } - - private func cancelPipeline() { - asrTask?.cancel() - asrTask = nil - Task { await audio.stop() } - stopLevelMeter() - lastTranscript = "" - } - - // MARK: - Permission - - private func requestMicPermissionIfNeeded(completion: ((Bool) -> Void)? = nil) { - let session = AVAudioSession.sharedInstance() - switch session.recordPermission { - case .granted: - completion?(true) - case .denied: - completion?(false) - case .undetermined: - session.requestRecordPermission { granted in - DispatchQueue.main.async { completion?(granted) } - } - @unknown default: - completion?(false) - } - } - - // MARK: - Level meter (mock — tap could be replaced with real RMS from AVAudioEngine) - - private func startLevelMeter() { - stopLevelMeter() - currentLevel = 0 - levelTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in - guard let self else { return } - // Simple pseudo-level — random walk around 0.5 while "recording" - let delta = Double.random(in: -0.18...0.18) - self.currentLevel = max(0.15, min(0.95, self.currentLevel + delta)) - // refresh UI - Task { @MainActor in - self.update(phase: .recording) - } - } - } - - private func stopLevelMeter() { - levelTimer?.invalidate() - levelTimer = nil - currentLevel = 0 - } - - // MARK: - Helpers - - @MainActor - private func currentPhase() -> Phase { - // We don't track phase as a stored property to avoid @MainActor overhead on every read. - // Instead, derive it from state of services — for simplicity we mirror via update(). - // This shim returns .idle when no record is in flight. - return recordStream == nil ? .idle : .recording - } -} diff --git a/OpenLessKeyboard/Services/ASRService.swift b/OpenLessKeyboard/Services/ASRService.swift deleted file mode 100644 index c938ab1..0000000 --- a/OpenLessKeyboard/Services/ASRService.swift +++ /dev/null @@ -1,165 +0,0 @@ -// ASRService.swift -// OSGKeyboard · Keyboard Extension -// -// Speech-to-text abstraction. iOS 26+ uses SpeechAnalyzer + DictationTranscriber -// (Apple's modern, on-device streaming API). iOS 18 falls back to SFSpeechRecognizer -// with on-device recognition. - -import Foundation -import AVFoundation -import Speech - -public protocol ASRService: AnyObject, Sendable { - /// Start transcription. Returns an async stream of partial + final strings. - /// The last value emitted on `finish()` is the final transcript. - func transcribe(stream: AsyncStream) -> AsyncStream - - /// Cancel any in-flight work. - func cancel() -} - -public enum ASREvent: Sendable { - case partial(String) // incremental, may be discarded - case final(String) // the authoritative transcript - case error(String) -} - -// MARK: - Factory - -public enum ASRServiceFactory { - public static func create() -> ASRService { - if #available(iOS 26, *) { - return SpeechAnalyzerASR() - } else { - return SFSpeechRecognizerASR() - } - } -} - -// MARK: - iOS 26+: SpeechAnalyzer + DictationTranscriber - -@available(iOS 26, *) -final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { - private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current) - private var task: Task? - - func transcribe(stream: AsyncStream) -> AsyncStream { - AsyncStream { continuation in - let recognizer = self.recognizer ?? SFSpeechRecognizer(locale: .current) - guard let recognizer, recognizer.isAvailable else { - continuation.yield(.error("Speech recognizer unavailable")) - continuation.finish() - return - } - recognizer.defaultTaskHint = .dictation - - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - if recognizer.supportsOnDeviceRecognition { - request.requiresOnDeviceRecognition = true - } - - let task = recognizer.recognitionTask(with: request) { result, error in - if let error { - continuation.yield(.error(error.localizedDescription)) - continuation.finish() - return - } - guard let result else { return } - if result.isFinal { - continuation.yield(.final(result.bestTranscription.formattedString)) - continuation.finish() - } else { - continuation.yield(.partial(result.bestTranscription.formattedString)) - } - } - self.task = Task { [request] in - for await snap in stream { - if Task.isCancelled { break } - let pcmStream = AsyncStream { c in - c.yield(snap) - c.finish() - } - for await pcm in pcmStream.toAVAudioBuffers() { - request.append(pcm) - } - } - request.endAudio() - // give recognizer a moment to finalize - try? await Task.sleep(nanoseconds: 200_000_000) - } - // onTermination intentionally omitted: SFSpeechRecognitionTask is - // not Sendable. Cancellation flows through this class's cancel(). - } - } - - func cancel() { - task?.cancel() - task = nil - } -} - -// MARK: - iOS 18 fallback: SFSpeechRecognizer - -final class SFSpeechRecognizerASR: ASRService, @unchecked Sendable { - private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current) - private var task: SFSpeechRecognitionTask? - private var feedTask: Task? - - func transcribe(stream: AsyncStream) -> AsyncStream { - AsyncStream { continuation in - guard let recognizer, recognizer.isAvailable else { - continuation.yield(.error("Speech recognizer unavailable")) - continuation.finish() - return - } - recognizer.defaultTaskHint = .dictation - - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - if recognizer.supportsOnDeviceRecognition { - request.requiresOnDeviceRecognition = true - } - - let recognizerTask = recognizer.recognitionTask(with: request) { result, error in - if let error { - continuation.yield(.error(error.localizedDescription)) - continuation.finish() - return - } - guard let result else { return } - if result.isFinal { - continuation.yield(.final(result.bestTranscription.formattedString)) - continuation.finish() - } else { - continuation.yield(.partial(result.bestTranscription.formattedString)) - } - } - self.task = recognizerTask - - self.feedTask = Task { [request] in - for await snap in stream { - if Task.isCancelled { break } - let pcmStream = AsyncStream { c in - c.yield(snap) - c.finish() - } - for await pcm in pcmStream.toAVAudioBuffers() { - request.append(pcm) - } - } - request.endAudio() - } - // onTermination intentionally omitted: SFSpeechRecognitionTask is - // not Sendable. Cancellation is handled by calling cancel() on - // this class, and the feedTask loop respects Task.isCancelled. - } - } - - func cancel() { - task?.cancel() - feedTask?.cancel() - task = nil - feedTask = nil - } -} diff --git a/OpenLessKeyboard/Services/AudioCaptureService.swift b/OpenLessKeyboard/Services/AudioCaptureService.swift deleted file mode 100644 index f87bb8a..0000000 --- a/OpenLessKeyboard/Services/AudioCaptureService.swift +++ /dev/null @@ -1,173 +0,0 @@ -// AudioCaptureService.swift -// OSGKeyboard · Keyboard Extension -// -// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine. -// Exposes an AsyncStream that ASR services can consume. - -import Foundation -@preconcurrency import AVFoundation - -public actor AudioCaptureService { - - public enum CaptureError: Error { - case sessionConfigFailed(Error) - case engineStartFailed(Error) - case noInputNode - } - - private let engine = AVAudioEngine() - private let converter = AVAudioConverter( - from: AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 48000, - channels: 1, - interleaved: false - )!, - to: AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16_000, - channels: 1, - interleaved: false - )! - ) - - private var continuation: AsyncStream.Continuation? - private var isRunning = false - - public init() {} - - public func start() -> AsyncStream { - AsyncStream { continuation in - self.continuation = continuation - do { - try configureSession() - try attachTap() - try engine.start() - isRunning = true - } catch { - continuation.finish() - self.continuation = nil - isRunning = false - } - } - } - - public func stop() { - guard isRunning else { return } - engine.inputNode.removeTap(onBus: 0) - engine.stop() - continuation?.finish() - continuation = nil - isRunning = false - } - - private func configureSession() throws { - #if canImport(UIKit) - let session = AVAudioSession.sharedInstance() - do { - try session.setCategory( - .playAndRecord, - mode: .measurement, - options: [.duckOthers, .defaultToSpeaker, .allowBluetooth] - ) - try session.setActive(true, options: .notifyOthersOnDeactivation) - } catch { - throw CaptureError.sessionConfigFailed(error) - } - #endif - } - - private func attachTap() throws { - let input = engine.inputNode - let hardwareFormat = input.outputFormat(forBus: 0) - guard hardwareFormat.sampleRate > 0 else { - throw CaptureError.noInputNode - } - let bufferSize: AVAudioFrameCount = 4096 - input.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in - guard let self else { return } - // Downsample to 16 kHz mono Float32 - let target = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16_000, - channels: 1, - interleaved: false - )! - let outFrames = AVAudioFrameCount( - Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate - ) - guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else { - return - } - var error: NSError? - let status = self.converter?.convert(to: outBuffer, error: &error) { _, outStatus in - outStatus.pointee = .haveData - return buffer - } - if status == .haveData, error == nil { - // AVAudioPCMBuffer is not Sendable; copy the raw float data - // into a Sendable wrapper so we can hand it to the actor. - let copy = AudioBufferSnapshot(buffer: outBuffer) - Task { await self.deliver(copy) } - } - } - } - - private func deliver(_ snapshot: AudioBufferSnapshot) { - guard isRunning else { return } - continuation?.yield(snapshot) - } -} - -/// Sendable wrapper around a Float32 audio buffer's raw samples. -/// We re-decode on the consumer side to avoid AVAudioPCMBuffer's non-Sendable -/// type crossing the actor boundary. -public struct AudioBufferSnapshot: Sendable { - public let samples: [Float] - public let sampleRate: Double - - public init(buffer: AVAudioPCMBuffer) { - guard let channelData = buffer.floatChannelData else { - self.samples = [] - self.sampleRate = 16_000 - return - } - let n = Int(buffer.frameLength) - var copy = [Float](repeating: 0, count: n) - memcpy(©, channelData[0], n * MemoryLayout.size) - self.samples = copy - self.sampleRate = buffer.format.sampleRate - } -} - -public extension AsyncStream where Element == AudioBufferSnapshot { - /// Convenience: convert snapshots to AVAudioPCMBuffer 16kHz mono Float32. - func toAVAudioBuffers() -> AsyncStream { - let format = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16_000, - channels: 1, - interleaved: false - )! - let snapshots = self - return AsyncStream { continuation in - Task { - for await snap in snapshots { - guard !snap.samples.isEmpty, - let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(snap.samples.count)) - else { continue } - pcm.frameLength = AVAudioFrameCount(snap.samples.count) - if let dst = pcm.floatChannelData?[0] { - snap.samples.withUnsafeBufferPointer { src in - if let base = src.baseAddress { - memcpy(dst, base, snap.samples.count * MemoryLayout.size) - } - } - } - continuation.yield(pcm) - } - continuation.finish() - } - } - } -} diff --git a/OpenLessKeyboard/Views/KeyboardRootView.swift b/OpenLessKeyboard/Views/KeyboardRootView.swift deleted file mode 100644 index 9fe67d3..0000000 --- a/OpenLessKeyboard/Views/KeyboardRootView.swift +++ /dev/null @@ -1,121 +0,0 @@ -// KeyboardRootView.swift -// OSGKeyboard · Keyboard Extension -// -// The single SwiftUI view that backs the keyboard. Shows status text, -// the record button, waveform (when active), and a settings shortcut. - -import SwiftUI - -public struct KeyboardRootView: View { - public enum Phase: Equatable { - case idle - case recording - case processing - case error(String) - } - - public let phase: Phase - public let level: Double // 0...1, used while recording - public let onPressBegan: () -> Void - public let onPressEnded: () -> Void - public let onTap: () -> Void - public let onOpenSettings: () -> Void - - public init( - phase: Phase, - level: Double, - onPressBegan: @escaping () -> Void, - onPressEnded: @escaping () -> Void, - onTap: @escaping () -> Void, - onOpenSettings: @escaping () -> Void - ) { - self.phase = phase - self.level = level - self.onPressBegan = onPressBegan - self.onPressEnded = onPressEnded - self.onTap = onTap - self.onOpenSettings = onOpenSettings - } - - public var body: some View { - ZStack { - // frosted glass background - Rectangle() - .fill(.ultraThinMaterial) - .ignoresSafeArea() - - HStack { - Spacer() - statusLine - Spacer() - recordButton - Spacer() - settingsButton - } - .padding(.horizontal, 12) - } - .frame(height: 56) - } - - private var statusLine: some View { - VStack(alignment: .leading, spacing: 2) { - switch phase { - case .idle: - Text("Hold to talk") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - case .recording: - HStack(spacing: 6) { - WaveformView(level: level, barCount: 7, color: .red) - Text("Recording…") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.red) - } - case .processing: - HStack(spacing: 6) { - ProgressView().controlSize(.small) - Text("Polishing…") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } - case .error(let msg): - Text(msg) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.orange) - .lineLimit(1) - .truncationMode(.tail) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var recordButton: some View { - RecordButton( - phase: recordState, - onPressBegan: onPressBegan, - onPressEnded: onPressEnded, - onTap: onTap - ) - } - - private var settingsButton: some View { - Button(action: onOpenSettings) { - Image(systemName: "gearshape.fill") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.secondary) - .padding(8) - .background(Color.white.opacity(0.06), in: Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(Text("Open OSGKeyboard settings")) - } - - private var recordState: RecordButton.Phase { - switch phase { - case .idle: return .idle - case .recording: return .recording - case .processing: return .processing - case .error(let s): return .error(s) - } - } -} diff --git a/OpenLessKeyboard/Views/RecordButton.swift b/OpenLessKeyboard/Views/RecordButton.swift deleted file mode 100644 index b110cea..0000000 --- a/OpenLessKeyboard/Views/RecordButton.swift +++ /dev/null @@ -1,116 +0,0 @@ -// RecordButton.swift -// OSGKeyboard · Keyboard Extension -// -// Circular push-to-talk button styled like Typeless. -// Pulses red while recording; ripples outward. - -import SwiftUI - -public struct RecordButton: View { - public enum Phase { case idle, recording, processing, error(String) } - - public let phase: Phase - public let onPressBegan: () -> Void - public let onPressEnded: () -> Void - public let onTap: () -> Void - - @State private var pulse: Bool = false - @GestureState private var isPressed: Bool = false - - public init( - phase: Phase, - onPressBegan: @escaping () -> Void, - onPressEnded: @escaping () -> Void, - onTap: @escaping () -> Void - ) { - self.phase = phase - self.onPressBegan = onPressBegan - self.onPressEnded = onPressEnded - self.onTap = onTap - } - - public var body: some View { - ZStack { - // outer pulse rings - if isRecording { - Circle() - .stroke(Color.red.opacity(0.35), lineWidth: 2) - .frame(width: 110, height: 110) - .scaleEffect(pulse ? 1.3 : 0.95) - .opacity(pulse ? 0 : 1) - .animation(.easeOut(duration: 1.2).repeatForever(autoreverses: false), value: pulse) - } - - // main button - Circle() - .fill(buttonColor) - .frame(width: 78, height: 78) - .overlay( - Circle().stroke(Color.white.opacity(0.12), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.25), radius: 6, y: 2) - .scaleEffect(isPressed ? 0.92 : 1.0) - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isPressed) - - Image(systemName: iconName) - .font(.system(size: 30, weight: .semibold)) - .foregroundStyle(.white) - } - .contentShape(Circle()) - .gesture( - LongPressGesture(minimumDuration: 0.15) - .sequenced(before: DragGesture(minimumDistance: 0)) - .updating($isPressed) { value, state, _ in - switch value { - case .first, .second: state = true - default: state = false - } - } - .onChanged { value in - switch value { - case .first: - if !pulseStarted { onPressBegan(); pulseStarted = true } - case .second(true, _): - // still pressed - break - default: - if pulseStarted { onPressEnded(); pulseStarted = false } - } - } - .onEnded { _ in - if pulseStarted { onPressEnded(); pulseStarted = false } - } - ) - .onTapGesture { onTap() } - .onAppear { pulse = isRecording } - .onChange(of: isRecording) { _, newValue in - pulse = newValue - } - .accessibilityLabel(Text("Push to talk")) - } - - private var isRecording: Bool { - if case .recording = phase { return true } - return false - } - - @State private var pulseStarted: Bool = false - - private var buttonColor: Color { - switch phase { - case .idle: return Color(white: 0.22) - case .recording: return .red - case .processing: return Color(white: 0.32) - case .error: return Color(white: 0.22) - } - } - - private var iconName: String { - switch phase { - case .idle: return "mic.fill" - case .recording: return "stop.fill" - case .processing: return "ellipsis" - case .error: return "exclamationmark.triangle.fill" - } - } -} diff --git a/OpenLessKeyboard/Views/WaveformView.swift b/OpenLessKeyboard/Views/WaveformView.swift deleted file mode 100644 index 527f36b..0000000 --- a/OpenLessKeyboard/Views/WaveformView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// WaveformView.swift -// OSGKeyboard · Keyboard Extension -// -// Simple animated waveform that responds to a 0-1 audio level. - -import SwiftUI - -public struct WaveformView: View { - public let level: Double // 0...1 - public let barCount: Int - public let color: Color - - public init(level: Double, barCount: Int = 5, color: Color = .red) { - self.level = max(0, min(1, level)) - self.barCount = barCount - self.color = color - } - - public var body: some View { - HStack(spacing: 4) { - ForEach(0.. CGFloat { - // Center bars taller; outer shorter — symmetric pattern - let center = Double(barCount - 1) / 2.0 - let distance = abs(Double(index) - center) / max(center, 1) - let base = max(6, 28 * level) - return CGFloat(base * (1.0 - distance * 0.4)) - } -} diff --git a/OpenLessShared/Constants/AppGroup.swift b/OpenLessShared/Constants/AppGroup.swift deleted file mode 100644 index 56476ad..0000000 --- a/OpenLessShared/Constants/AppGroup.swift +++ /dev/null @@ -1,21 +0,0 @@ -// AppGroup.swift -// OSGKeyboard · Shared -// -// App Group identifier shared between main app and keyboard extension. -// UserDefaults(suiteName:) and file containers use this. - -import Foundation - -public enum AppGroup { - /// App Group container identifier (must match entitlements in both targets) - public static let identifier = "group.com.osgkeyboard.ios" - - /// Shared UserDefaults instance for cross-process config - public static var defaults: UserDefaults { - guard let d = UserDefaults(suiteName: identifier) else { - assertionFailure("App Group \(identifier) not configured in entitlements") - return .standard - } - return d - } -} diff --git a/OpenLessShared/Services/AppGroupStore.swift b/OpenLessShared/Services/AppGroupStore.swift deleted file mode 100644 index a581b12..0000000 --- a/OpenLessShared/Services/AppGroupStore.swift +++ /dev/null @@ -1,45 +0,0 @@ -// AppGroupStore.swift -// OSGKeyboard · Shared -// -// Convenience wrapper around App Group UserDefaults for non-Published reads. -// Used by the keyboard extension (no SwiftUI) to read config without -// instantiating an ObservableObject. - -import Foundation - -public struct AppGroupStore: @unchecked Sendable { - public let defaults: UserDefaults - - public init(defaults: UserDefaults = AppGroup.defaults) { - self.defaults = defaults - } - - public var providerId: String { - defaults.string(forKey: "config.providerId") ?? "openai" - } - - public var baseURL: String { - defaults.string(forKey: "config.baseURL") ?? LLMProvider.provider(id: "openai").defaultBaseURL - } - - public var apiKey: String { - defaults.string(forKey: "config.apiKey") ?? "" - } - - public var model: String { - defaults.string(forKey: "config.model") ?? LLMProvider.provider(id: "openai").defaultModel - } - - public var systemPrompt: String { - defaults.string(forKey: "config.systemPrompt") - ?? "You are a voice-input polishing assistant. Rewrite the user's dictation as clean written text. Preserve intent. Add punctuation and structure. Do not invent facts. Output in the same language as the input." - } - - public func makeClient() -> LLMClient { - OpenAICompatibleClient( - baseURL: baseURL, - apiKey: apiKey, - model: model - ) - } -} diff --git a/README.md b/README.md index 33a65ad..9ff1720 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ 3. In any text field, tap 🌐 to switch to **OSGKeyboard**. 4. Press and hold the mic, speak, release. ✨ -> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OpenLess/PrivacyInfo.xcprivacy). +> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy). --- @@ -71,23 +71,23 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ ``` OSGKeyboard/ -├── OpenLess/ # Main iOS app (settings, onboarding) +├── OSGKeyboard/ # Main iOS app (settings, onboarding) │ ├── Views/ # SwiftUI screens │ ├── OSGKeyboardApp.swift # @main entry │ ├── PrivacyInfo.xcprivacy # Required privacy manifest -│ └── OpenLess.entitlements # App Group declaration -├── OpenLessKeyboard/ # Custom Keyboard Extension +│ └── OSGKeyboard.entitlements # App Group declaration +├── OSGKeyboardExt/ # Custom Keyboard Extension │ ├── KeyboardViewController.swift # Principal class │ ├── Services/ │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16 kHz PCM │ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR │ │ └── PolishingService.swift # LLM call with timeout │ └── Views/ # RecordButton, Waveform, KeyboardRootView -├── OpenLessShared/ # Framework shared by app + extension +├── OSGKeyboardShared/ # Framework shared by app + extension │ ├── Models/ # ProviderConfig, LLMRequest, LLMProvider │ ├── Services/ # LLMClient (OpenAI-compatible) │ └── Constants/ # AppGroup identifier -├── OpenLessTests/ # XCTest unit tests +├── OSGKeyboardTests/ # XCTest unit tests ├── project.yml # XcodeGen project definition └── .github/workflows/ci.yml # Lint + build CI ``` @@ -112,7 +112,7 @@ OSGKeyboard/ ## Adding a new LLM provider -Open `OpenLessShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol. +Open `OSGKeyboardShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol. ```swift LLMProvider( diff --git a/README.zh.md b/README.zh.md index 072fe21..04de1ab 100644 --- a/README.zh.md +++ b/README.zh.md @@ -62,7 +62,7 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ 3. 在任意输入框,点 🌐 切换到 **OSGKeyboard**。 4. 长按麦克风键 → 说话 → 松开。✨ -> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OpenLess/PrivacyInfo.xcprivacy)。 +> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy)。 --- @@ -70,23 +70,23 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ ``` OSGKeyboard/ -├── OpenLess/ # 主 iOS App(设置、Onboarding) +├── OSGKeyboard/ # 主 iOS App(设置、Onboarding) │ ├── Views/ # SwiftUI 屏幕 │ ├── OSGKeyboardApp.swift # @main 入口 │ ├── PrivacyInfo.xcprivacy # 隐私清单 -│ └── OpenLess.entitlements # App Group 声明 -├── OpenLessKeyboard/ # 自定义键盘扩展 +│ └── OSGKeyboard.entitlements # App Group 声明 +├── OSGKeyboardExt/ # 自定义键盘扩展 │ ├── KeyboardViewController.swift # 主体类 │ ├── Services/ │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM │ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR │ │ └── PolishingService.swift # LLM 调用(带超时) │ └── Views/ # 录音按钮、波形、键盘主视图 -├── OpenLessShared/ # 主 App + 键盘共享 framework +├── OSGKeyboardShared/ # 主 App + 键盘共享 framework │ ├── Models/ # ProviderConfig、LLMRequest、LLMProvider │ ├── Services/ # LLMClient(OpenAI 兼容) │ └── Constants/ # App Group ID -├── OpenLessTests/ # XCTest 单元测试 +├── OSGKeyboardTests/ # XCTest 单元测试 ├── project.yml # XcodeGen 工程定义 └── .github/workflows/ci.yml # Lint + 编译 CI ``` @@ -111,7 +111,7 @@ OSGKeyboard/ ## 新增 LLM 提供商 -打开 `OpenLessShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。 +打开 `OSGKeyboardShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。 ```swift LLMProvider( diff --git a/project.yml b/project.yml index e130c27..b5e7284 100644 --- a/project.yml +++ b/project.yml @@ -35,19 +35,23 @@ targets: type: application platform: iOS sources: - - path: OpenLess + - path: OSGKeyboard entitlements: - path: OpenLess/OpenLess.entitlements + path: OSGKeyboard/OSGKeyboard.entitlements + properties: + com.apple.security.application-groups: + - group.com.osgkeyboard.shared + com.apple.security.device.audio-input: true resources: - - path: OpenLess/Assets.xcassets + - path: OSGKeyboard/Assets.xcassets info: - path: OpenLess/Info.plist + path: OSGKeyboard/Info.plist properties: CFBundleDisplayName: OSGKeyboard CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" UILaunchScreen: - UIColorName: "" + UIColorName: "BackgroundColor" UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait UIApplicationSceneManifest: @@ -77,11 +81,17 @@ targets: type: app-extension platform: iOS sources: - - path: OpenLessKeyboard + - path: OSGKeyboardExt + settings: + base: + IPHONEOS_DEPLOYMENT_TARGET: "18.0" entitlements: - path: OpenLessKeyboard/OpenLessKeyboard.entitlements + path: OSGKeyboardExt/OSGKeyboardExt.entitlements + properties: + com.apple.security.application-groups: + - group.com.osgkeyboard.shared info: - path: OpenLessKeyboard/Info.plist + path: OSGKeyboardExt/Info.plist properties: CFBundleDisplayName: OSGKeyboard CFBundleShortVersionString: "$(MARKETING_VERSION)" @@ -109,9 +119,9 @@ targets: type: framework platform: iOS sources: - - path: OpenLessShared + - path: OSGKeyboardShared info: - path: OpenLessShared/Info.plist + path: OSGKeyboardShared/Info.plist settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared @@ -129,9 +139,9 @@ targets: type: bundle.unit-test platform: iOS sources: - - path: OpenLessTests + - path: OSGKeyboardTests info: - path: OpenLessTests/Info.plist + path: OSGKeyboardTests/Info.plist dependencies: - target: OSGKeyboard settings: