diff --git a/openless-ios/.gitignore b/openless-ios/.gitignore
new file mode 100644
index 000000000..ed7fa7cd3
--- /dev/null
+++ b/openless-ios/.gitignore
@@ -0,0 +1,19 @@
+# XcodeGen 生成物(project.yml 是唯一真源)
+*.xcodeproj/
+
+# Xcode
+DerivedData/
+build/
+*.xcuserstate
+*.xcscmblueprint
+xcuserdata/
+
+# SwiftPM
+.build/
+.swiftpm/
+
+# 设计参考截图(个人,不入库)
+refs/
+
+# macOS
+.DS_Store
diff --git a/openless-ios/App/Info.plist b/openless-ios/App/Info.plist
new file mode 100644
index 000000000..aaa1c1b44
--- /dev/null
+++ b/openless-ios/App/Info.plist
@@ -0,0 +1,65 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ OpenLess
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+
+ UIRequiresFullScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+
+ NSMicrophoneUsageDescription
+ OpenLess 需要使用麦克风进行语音输入。
+ NSSpeechRecognitionUsageDescription
+ OpenLess 使用语音识别将你的语音转写为文字。
+ UIBackgroundModes
+
+ audio
+
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ top.openless.ios
+ CFBundleURLSchemes
+
+ openless
+
+
+
+
+
diff --git a/openless-ios/App/OpenLess.entitlements b/openless-ios/App/OpenLess.entitlements
new file mode 100644
index 000000000..fbcec971f
--- /dev/null
+++ b/openless-ios/App/OpenLess.entitlements
@@ -0,0 +1,11 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.top.openless.ios
+
+
+
diff --git a/openless-ios/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/openless-ios/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 000000000..1a6abde8f
--- /dev/null
+++ b/openless-ios/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.95",
+ "green" : "0.30",
+ "red" : "0.40"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/openless-ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/openless-ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
new file mode 100644
index 000000000..496bf7a62
Binary files /dev/null and b/openless-ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ
diff --git a/openless-ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/openless-ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..f22e10cde
--- /dev/null
+++ b/openless-ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon-1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/openless-ios/App/Resources/Assets.xcassets/Contents.json b/openless-ios/App/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/openless-ios/App/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/openless-ios/App/Sources/ASR/VolcengineASR.swift b/openless-ios/App/Sources/ASR/VolcengineASR.swift
new file mode 100644
index 000000000..eefe3a5b5
--- /dev/null
+++ b/openless-ios/App/Sources/ASR/VolcengineASR.swift
@@ -0,0 +1,164 @@
+import Foundation
+import OpenLessShared
+
+/// 火山引擎 SAUC bigmodel 流式 ASR 客户端(移植自桌面 asr/volcengine.rs)。
+/// 用 URLSessionWebSocketTask 连接,发首帧 → 流式发 16k/16bit/mono PCM 音频帧 → 末帧收尾,
+/// 接收 FullServerResponse,拼 utterances 文本,partial/final 回调。
+final class VolcengineASR {
+ struct Credentials {
+ let appKey: String
+ let accessToken: String
+ let resourceId: String
+ }
+
+ var onPartial: ((String) -> Void)?
+ var onFinal: ((String) -> Void)?
+ var onError: ((String) -> Void)?
+
+ private let creds: Credentials
+ private var task: URLSessionWebSocketTask?
+ private var session: URLSession?
+ private var nextSeq: Int32 = 1
+ private var pending = Data()
+ private var lastPartial = ""
+ private var finished = false
+
+ private let chunkBytes = 6_400 // 200ms @ 16k/16bit/mono
+ private let endpoint = URL(string: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async")!
+
+ init(creds: Credentials) { self.creds = creds }
+
+ func start() {
+ let connectId = UUID().uuidString
+ var req = URLRequest(url: endpoint)
+ req.setValue(creds.appKey, forHTTPHeaderField: "X-Api-App-Key")
+ req.setValue(creds.accessToken, forHTTPHeaderField: "X-Api-Access-Key")
+ req.setValue(creds.resourceId, forHTTPHeaderField: "X-Api-Resource-Id")
+ req.setValue(connectId, forHTTPHeaderField: "X-Api-Connect-Id")
+
+ let s = URLSession(configuration: .default)
+ session = s
+ let t = s.webSocketTask(with: req)
+ task = t
+ t.resume()
+
+ // 首帧:FullClientRequest + JSON(音频参数 + 请求参数)
+ let payload: [String: Any] = [
+ "user": ["uid": connectId],
+ "audio": ["format": "pcm", "rate": 16_000, "bits": 16, "channel": 1, "codec": "raw"],
+ "request": [
+ "model_name": "bigmodel",
+ "enable_itn": true,
+ "enable_punc": true,
+ "show_utterances": true,
+ ],
+ ]
+ guard let body = try? JSONSerialization.data(withJSONObject: payload) else {
+ onError?("payload 编码失败"); return
+ }
+ send(VolcFrame.build(.fullClientRequest, .positiveSequence, .json, payload: body, sequence: allocSeq()))
+ receiveLoop()
+ }
+
+ /// 喂入 16k/16bit/mono PCM;满 6400 字节就发一帧。
+ func consume(pcm: Data) {
+ pending.append(pcm)
+ while pending.count >= chunkBytes {
+ let chunk = pending.prefix(chunkBytes)
+ pending.removeFirst(chunkBytes)
+ send(VolcFrame.build(.audioOnlyRequest, .positiveSequence, .none, payload: Data(chunk), sequence: allocSeq()))
+ }
+ }
+
+ /// 停止说话:把剩余音频发出,再发负序号末帧收尾,等服务端回 final。
+ func finish() {
+ if !pending.isEmpty {
+ send(VolcFrame.build(.audioOnlyRequest, .positiveSequence, .none, payload: pending, sequence: allocSeq()))
+ pending.removeAll()
+ }
+ let finalSeq = -nextSeq
+ nextSeq += 1
+ send(VolcFrame.build(.audioOnlyRequest, .negativeSequence, .none, payload: Data(), sequence: finalSeq))
+ }
+
+ func cancel() {
+ finished = true
+ task?.cancel(with: .goingAway, reason: nil)
+ task = nil
+ }
+
+ // MARK: - 内部
+
+ private func allocSeq() -> Int32 { let s = nextSeq; nextSeq += 1; return s }
+
+ private func send(_ data: Data) {
+ task?.send(.data(data)) { [weak self] err in
+ if let err { self?.fail("发送失败:\(err.localizedDescription)") }
+ }
+ }
+
+ private func receiveLoop() {
+ task?.receive { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case .failure(let err):
+ // 断连/出错:有 partial 就兜底回 final,不丢已识别文字。
+ if self.finished { return }
+ if !self.lastPartial.isEmpty {
+ self.finished = true
+ self.onFinal?(self.lastPartial)
+ } else {
+ self.fail(err.localizedDescription)
+ }
+ case .success(let msg):
+ if case .data(let data) = msg { self.handle(data) }
+ if !self.finished { self.receiveLoop() }
+ }
+ }
+ }
+
+ private func handle(_ data: Data) {
+ guard let parsed = VolcFrame.parse(data) else { return }
+
+ if parsed.messageType == .errorMessage {
+ let body = String(data: parsed.payload, encoding: .utf8) ?? ""
+ fail("ASR \(parsed.errorCode ?? 0):\(body)")
+ return
+ }
+ guard parsed.messageType == .fullServerResponse else { return }
+ guard let json = try? JSONSerialization.jsonObject(with: parsed.payload) as? [String: Any] else { return }
+
+ let text = Self.extractText(json)
+ if parsed.isFinal {
+ finished = true
+ onFinal?(text.isEmpty ? lastPartial : text)
+ } else if !text.isEmpty {
+ lastPartial = text
+ onPartial?(text)
+ }
+ }
+
+ private func fail(_ msg: String) {
+ guard !finished else { return }
+ finished = true
+ onError?(msg)
+ }
+
+ /// 优先用 utterances 拼接(含全部分段),否则取 result.text。
+ private static func extractText(_ json: [String: Any]) -> String {
+ var result: [String: Any]?
+ if let r = json["result"] as? [String: Any] {
+ result = r
+ } else if let arr = json["result"] as? [[String: Any]], let first = arr.first {
+ result = first
+ } else if json["text"] is String {
+ result = json
+ }
+ guard let result else { return "" }
+ if let utts = result["utterances"] as? [[String: Any]] {
+ let pieces = utts.compactMap { $0["text"] as? String }
+ if !pieces.isEmpty { return pieces.joined() }
+ }
+ return (result["text"] as? String) ?? ""
+ }
+}
diff --git a/openless-ios/App/Sources/DictationView.swift b/openless-ios/App/Sources/DictationView.swift
new file mode 100644
index 000000000..56a1f066b
--- /dev/null
+++ b/openless-ios/App/Sources/DictationView.swift
@@ -0,0 +1,146 @@
+import SwiftUI
+import OpenLessShared
+
+/// Flow 式语音输入界面:大麦克风按钮(轻点开始 / 再次点击完成)、录音波形、实时转写、插入。
+/// 「插入」把文本写入 App Group(键盘切回后自动插入)+ 复制到剪贴板兜底,并给出明确确认。
+struct DictationView: View {
+ @Environment(\.dismiss) private var dismiss
+ @StateObject private var dictator = SpeechDictator()
+ @State private var inserted = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ Divider()
+ if inserted {
+ confirmation
+ } else {
+ transcriptArea
+ Spacer(minLength: 0)
+ controls
+ }
+ }
+ .background(Color(.systemBackground))
+ .onAppear { dictator.requestAuthorization() }
+ .onDisappear { dictator.stop() }
+ }
+
+ private var header: some View {
+ HStack {
+ Button { dismiss() } label: {
+ Image(systemName: "xmark").font(.headline).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text("语音输入").font(.headline)
+ Spacer()
+ Color.clear.frame(width: 22, height: 22)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+ }
+
+ private var transcriptArea: some View {
+ ScrollView {
+ Text(dictator.transcript.isEmpty ? "开口说话,文字会实时出现在这里…" : dictator.transcript)
+ .font(.system(size: 22))
+ .foregroundStyle(dictator.transcript.isEmpty ? .secondary : .primary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(20)
+ }
+ .frame(maxHeight: .infinity)
+ }
+
+ private var controls: some View {
+ VStack(spacing: 14) {
+ if let e = dictator.error {
+ Text(e).font(.footnote).foregroundStyle(.red).padding(.horizontal, 20)
+ }
+ micButton
+ Text(dictator.phase == .recording ? "再次点击以完成" : "轻点一下开始说话")
+ .font(.subheadline).foregroundStyle(.secondary)
+ if !dictator.engineLabel.isEmpty {
+ Text("识别引擎:\(dictator.engineLabel)")
+ .font(.caption2).foregroundStyle(.tertiary)
+ }
+
+ if dictator.phase == .finished && !dictator.transcript.isEmpty {
+ HStack(spacing: 12) {
+ Button("重录") { dictator.reset() }.buttonStyle(.bordered)
+ Button("插入到输入框") { insert() }.buttonStyle(.borderedProminent)
+ }
+ .padding(.top, 2)
+ }
+ }
+ .padding(.bottom, 28)
+ .padding(.top, 8)
+ }
+
+ private var micButton: some View {
+ Button(action: dictator.toggle) {
+ ZStack {
+ Capsule()
+ .fill(Color.black)
+ .frame(width: 220, height: 66)
+ .shadow(color: .black.opacity(0.2), radius: 12, y: 6)
+ if dictator.phase == .recording {
+ WaveBars(level: dictator.level).frame(width: 130, height: 34)
+ } else {
+ Label("语音输入", systemImage: "mic.fill").font(.headline).foregroundStyle(.white)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var confirmation: some View {
+ VStack(spacing: 20) {
+ Spacer()
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 56))
+ .foregroundStyle(.green)
+ Text("已识别").font(.title2.bold())
+ Text(dictator.transcript)
+ .font(.body)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 28)
+ Text("切回刚才的 App,文字会自动插入到光标处\n(也已复制到剪贴板)")
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.tertiary)
+ .padding(.horizontal, 28)
+ Spacer()
+ Button("完成") { dismiss() }
+ .buttonStyle(.borderedProminent)
+ .padding(.bottom, 28)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func insert() {
+ let text = dictator.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { dismiss(); return }
+ Handoff.setPendingInsert(text)
+ UIPasteboard.general.string = text
+ withAnimation { inserted = true }
+ }
+}
+
+/// 录音时的白色波形条,随音量 `level` 起伏。
+struct WaveBars: View {
+ var level: CGFloat
+ private let pattern: [CGFloat] = [0.35, 0.6, 0.9, 1.0, 0.9, 0.6, 0.35]
+
+ var body: some View {
+ HStack(spacing: 5) {
+ ForEach(pattern.indices, id: \.self) { i in
+ Capsule().fill(Color.white).frame(width: 6, height: barHeight(pattern[i]))
+ }
+ }
+ .animation(.easeOut(duration: 0.12), value: level)
+ }
+
+ private func barHeight(_ p: CGFloat) -> CGFloat {
+ 8 + 26 * p * (0.25 + 0.75 * level)
+ }
+}
diff --git a/openless-ios/App/Sources/OpenLessApp.swift b/openless-ios/App/Sources/OpenLessApp.swift
new file mode 100644
index 000000000..5a75485d3
--- /dev/null
+++ b/openless-ios/App/Sources/OpenLessApp.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+import OpenLessShared
+
+/// OpenLess iOS 主 App 入口。
+/// 职责(随阶段填充):麦克风录音 → 语音识别 → 润色 → 经 App Group 回传键盘扩展;
+/// 配置/凭据管理;云端风格包市场。
+@main
+struct OpenLessApp: App {
+ init() {
+ // 启动即把偏好写入 App Group 共享容器(签名后生效);也用于验证共享容器可写。
+ let store = SharedStore.shared
+ try? store.savePreferences(store.loadPreferences())
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ }
+ }
+}
diff --git a/openless-ios/App/Sources/PiP/PiPVoiceController.swift b/openless-ios/App/Sources/PiP/PiPVoiceController.swift
new file mode 100644
index 000000000..4445b9fbb
--- /dev/null
+++ b/openless-ios/App/Sources/PiP/PiPVoiceController.swift
@@ -0,0 +1,148 @@
+import AVKit
+import AVFoundation
+import UIKit
+
+/// PiP(画中画)保活:用 AVSampleBufferDisplayLayer 自定义内容源开一个视频小窗,
+/// 把主 App 保活在后台,从而能在键盘场景下持续使用麦克风(豆包的做法)。
+/// 仅真机可用(模拟器 isPictureInPictureSupported == false)。
+final class PiPVoiceController: NSObject, ObservableObject {
+ @Published var isActive = false
+ let isSupported = AVPictureInPictureController.isPictureInPictureSupported()
+
+ let displayLayer = AVSampleBufferDisplayLayer()
+ private var pip: AVPictureInPictureController?
+ private var displayLink: CADisplayLink?
+ private var pool: CVPixelBufferPool?
+ private let size = CGSize(width: 160, height: 120)
+
+ override init() {
+ super.init()
+ displayLayer.videoGravity = .resizeAspect
+ displayLayer.frame = CGRect(origin: .zero, size: size)
+ makePool()
+ }
+
+ /// 把 displayLayer 挂到可见 view 上(PiP 需从内联视频启动),并创建控制器。
+ func configure(hostView: UIView) {
+ if displayLayer.superlayer == nil {
+ displayLayer.frame = CGRect(origin: .zero, size: size)
+ hostView.layer.addSublayer(displayLayer)
+ }
+ guard isSupported, pip == nil else { return }
+ let source = AVPictureInPictureController.ContentSource(
+ sampleBufferDisplayLayer: displayLayer, playbackDelegate: self)
+ let controller = AVPictureInPictureController(contentSource: source)
+ controller.delegate = self
+ controller.canStartPictureInPictureAutomaticallyFromInline = true
+ pip = controller
+ }
+
+ func start() {
+ guard isSupported, let pip else { return }
+ startFeeding()
+ if !pip.isPictureInPictureActive { pip.startPictureInPicture() }
+ }
+
+ func stop() {
+ pip?.stopPictureInPicture()
+ stopFeeding()
+ }
+
+ // MARK: - 帧供给(让 layer 持续 "playing")
+
+ private func startFeeding() {
+ stopFeeding()
+ let link = CADisplayLink(target: self, selector: #selector(tick))
+ link.preferredFramesPerSecond = 6
+ link.add(to: .main, forMode: .common)
+ displayLink = link
+ }
+
+ private func stopFeeding() {
+ displayLink?.invalidate()
+ displayLink = nil
+ }
+
+ @objc private func tick() { enqueueFrame() }
+
+ private func makePool() {
+ let attrs: [String: Any] = [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
+ kCVPixelBufferWidthKey as String: Int(size.width),
+ kCVPixelBufferHeightKey as String: Int(size.height),
+ kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
+ ]
+ CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attrs as CFDictionary, &pool)
+ }
+
+ private func enqueueFrame() {
+ guard displayLayer.isReadyForMoreMediaData,
+ let pixelBuffer = renderPixelBuffer(),
+ let sample = makeSampleBuffer(from: pixelBuffer) else { return }
+ displayLayer.enqueue(sample)
+ }
+
+ private func renderPixelBuffer() -> CVPixelBuffer? {
+ guard let pool else { return nil }
+ var pb: CVPixelBuffer?
+ CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pb)
+ guard let pixelBuffer = pb else { return nil }
+ CVPixelBufferLockBaseAddress(pixelBuffer, [])
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
+ guard let ctx = CGContext(
+ data: CVPixelBufferGetBaseAddress(pixelBuffer),
+ width: Int(size.width), height: Int(size.height),
+ bitsPerComponent: 8,
+ bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
+ ) else { return nil }
+
+ ctx.setFillColor(UIColor(red: 0.36, green: 0.30, blue: 0.95, alpha: 1).cgColor)
+ ctx.fill(CGRect(origin: .zero, size: size))
+ let str = NSAttributedString(string: "🎤", attributes: [.font: UIFont.systemFont(ofSize: 56)])
+ UIGraphicsPushContext(ctx)
+ let ts = str.size()
+ str.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: (size.height - ts.height) / 2))
+ UIGraphicsPopContext()
+ return pixelBuffer
+ }
+
+ private func makeSampleBuffer(from pixelBuffer: CVPixelBuffer) -> CMSampleBuffer? {
+ var formatDesc: CMVideoFormatDescription?
+ CMVideoFormatDescriptionCreateForImageBuffer(
+ allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDesc)
+ guard let formatDesc else { return nil }
+ var timing = CMSampleTimingInfo(
+ duration: CMTime(value: 1, timescale: 6),
+ presentationTimeStamp: CMClockGetTime(CMClockGetHostTimeClock()),
+ decodeTimeStamp: .invalid)
+ var sample: CMSampleBuffer?
+ CMSampleBufferCreateForImageBuffer(
+ allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer,
+ dataReady: true, makeDataReadyCallback: nil, refcon: nil,
+ formatDescription: formatDesc, sampleTiming: &timing, sampleBufferOut: &sample)
+ return sample
+ }
+
+ private func setActive(_ v: Bool) { DispatchQueue.main.async { self.isActive = v } }
+}
+
+extension PiPVoiceController: AVPictureInPictureControllerDelegate {
+ func pictureInPictureControllerDidStartPictureInPicture(_ c: AVPictureInPictureController) { setActive(true) }
+ func pictureInPictureControllerDidStopPictureInPicture(_ c: AVPictureInPictureController) { setActive(false) }
+ func pictureInPictureController(_ c: AVPictureInPictureController,
+ failedToStartPictureInPictureWithError error: Error) { setActive(false) }
+}
+
+extension PiPVoiceController: AVPictureInPictureSampleBufferPlaybackDelegate {
+ func pictureInPictureController(_ c: AVPictureInPictureController, setPlaying playing: Bool) {}
+ func pictureInPictureControllerTimeRangeForPlayback(_ c: AVPictureInPictureController) -> CMTimeRange {
+ CMTimeRange(start: .negativeInfinity, duration: .positiveInfinity)
+ }
+ func pictureInPictureControllerIsPlaybackPaused(_ c: AVPictureInPictureController) -> Bool { false }
+ func pictureInPictureController(_ c: AVPictureInPictureController,
+ didTransitionToRenderSize newRenderSize: CMVideoDimensions) {}
+ func pictureInPictureController(_ c: AVPictureInPictureController, skipByInterval skipInterval: CMTime,
+ completion completionHandler: @escaping () -> Void) { completionHandler() }
+}
diff --git a/openless-ios/App/Sources/RootView.swift b/openless-ios/App/Sources/RootView.swift
new file mode 100644
index 000000000..5c26c5c2d
--- /dev/null
+++ b/openless-ios/App/Sources/RootView.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+/// 根容器:主界面 = 设置页(含「语音输入」入口)。键盘的麦克风通过 openless://dictate 跳到这里发起语音。
+struct RootView: View {
+ @State private var showDictation = false
+
+ var body: some View {
+ NavigationStack {
+ SettingsView(onDictate: { showDictation = true })
+ }
+ .fullScreenCover(isPresented: $showDictation) {
+ DictationView()
+ }
+ .onOpenURL { url in
+ switch url.host {
+ case "dictate": showDictation = true
+ default: break // settings:本就是主界面
+ }
+ }
+ }
+}
+
+#Preview {
+ RootView()
+}
diff --git a/openless-ios/App/Sources/SettingsModel.swift b/openless-ios/App/Sources/SettingsModel.swift
new file mode 100644
index 000000000..19b0024fd
--- /dev/null
+++ b/openless-ios/App/Sources/SettingsModel.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import OpenLessShared
+
+/// 设置页状态:偏好(App Group JSON)+ 凭据(Keychain)。
+/// 编辑即时自动落盘;同时提供显式 `save()` 供「保存」按钮调用并回报结果。
+@MainActor
+final class SettingsModel: ObservableObject {
+ @Published var prefs: Preferences
+ @Published var creds: CredsRoot
+
+ init() {
+ prefs = SharedStore.shared.loadPreferences()
+ creds = SharedStore.shared.loadCredentials()
+ }
+
+ /// 显式保存:失败会抛错,供 UI 给出明确反馈。
+ func save() throws {
+ try SharedStore.shared.savePreferences(prefs)
+ try SharedStore.shared.saveCredentials(creds)
+ }
+
+ /// 编辑时的尽力自动保存(不打断输入)。
+ private func autosave() { try? save() }
+
+ func pref(_ kp: WritableKeyPath) -> Binding {
+ Binding(
+ get: { self.prefs[keyPath: kp] },
+ set: { self.prefs[keyPath: kp] = $0; self.autosave() }
+ )
+ }
+
+ func asr(_ kp: WritableKeyPath) -> Binding {
+ Binding(
+ get: { self.creds.providers.asr[self.prefs.activeAsrProvider]?[keyPath: kp] ?? "" },
+ set: { v in
+ var e = self.creds.providers.asr[self.prefs.activeAsrProvider] ?? CredsAsrEntry()
+ e[keyPath: kp] = v.isEmpty ? nil : v
+ self.creds.providers.asr[self.prefs.activeAsrProvider] = e
+ self.creds.active.asr = self.prefs.activeAsrProvider
+ self.autosave()
+ }
+ )
+ }
+
+ func llm(_ kp: WritableKeyPath) -> Binding {
+ Binding(
+ get: { self.creds.providers.llm[self.prefs.activeLlmProvider]?[keyPath: kp] ?? "" },
+ set: { v in
+ var e = self.creds.providers.llm[self.prefs.activeLlmProvider] ?? CredsLlmEntry()
+ e[keyPath: kp] = v.isEmpty ? nil : v
+ self.creds.providers.llm[self.prefs.activeLlmProvider] = e
+ self.creds.active.llm = self.prefs.activeLlmProvider
+ self.autosave()
+ }
+ )
+ }
+}
diff --git a/openless-ios/App/Sources/SettingsView.swift b/openless-ios/App/Sources/SettingsView.swift
new file mode 100644
index 000000000..7e1be8b38
--- /dev/null
+++ b/openless-ios/App/Sources/SettingsView.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+import OpenLessShared
+
+/// 主界面 = 设置页(无营销文案,直接给可编辑配置)。Key 写入 Keychain,顶部「保存」回报结果。
+struct SettingsView: View {
+ @StateObject private var model = SettingsModel()
+ @State private var flash: String?
+ var onDictate: () -> Void = {}
+
+ private let asrProviders = ["volcengine", "bailian", "whisper"]
+ private let llmProviders = ["openai", "ark", "gemini"]
+
+ var body: some View {
+ Form {
+ Section {
+ Button(action: onDictate) {
+ HStack {
+ Image(systemName: "mic.fill")
+ Text("语音输入").fontWeight(.semibold)
+ Spacer()
+ Image(systemName: "chevron.right").font(.footnote).foregroundStyle(.tertiary)
+ }
+ }
+ Button(action: openSystemKeyboardSettings) {
+ HStack {
+ Image(systemName: "keyboard")
+ Text("启用 OpenLess 键盘")
+ Spacer()
+ Image(systemName: "chevron.right").font(.footnote).foregroundStyle(.tertiary)
+ }
+ }
+ }
+
+ Section("润色") {
+ Picker("默认模式", selection: model.pref(\.defaultMode)) {
+ ForEach(PolishMode.allCases, id: \.self) { Text($0.displayName).tag($0) }
+ }
+ }
+
+ Section("语音识别 (ASR)") {
+ Picker("服务", selection: model.pref(\.activeAsrProvider)) {
+ ForEach(asrProviders, id: \.self) { Text(asrName($0)).tag($0) }
+ }
+ asrFields
+ }
+
+ Section("大模型润色 (LLM)") {
+ Picker("服务", selection: model.pref(\.activeLlmProvider)) {
+ ForEach(llmProviders, id: \.self) { Text(llmName($0)).tag($0) }
+ }
+ llmFields
+ }
+
+ Section("语音保持") {
+ Picker("免跳转保持", selection: model.pref(\.voiceHoldMinutes)) {
+ Text("每次跳转").tag(0)
+ Text("5 分钟").tag(5)
+ Text("30 分钟").tag(30)
+ Text("一直保持").tag(-1)
+ }
+ }
+ }
+ .navigationTitle("OpenLess")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("保存", action: saveNow).bold()
+ }
+ }
+ .overlay(alignment: .top) { flashBanner }
+ }
+
+ @ViewBuilder private var asrFields: some View {
+ switch model.prefs.activeAsrProvider {
+ case "volcengine":
+ field("App Key", model.asr(\.appKey))
+ secure("Access Token", model.asr(\.accessKey))
+ field("Resource ID", model.asr(\.resourceId))
+ case "bailian":
+ secure("API Key", model.asr(\.apiKey))
+ field("词汇表 ID", model.asr(\.vocabularyId))
+ default:
+ secure("API Key", model.asr(\.apiKey))
+ field("Base URL", model.asr(\.baseURL))
+ field("模型", model.asr(\.model))
+ }
+ }
+
+ @ViewBuilder private var llmFields: some View {
+ secure("API Key", model.llm(\.apiKey))
+ field("Base URL", model.llm(\.baseURL))
+ field("模型", model.llm(\.model))
+ }
+
+ // MARK: - 行控件
+
+ private func field(_ label: String, _ binding: Binding) -> some View {
+ HStack {
+ Text(label)
+ Spacer()
+ TextField("未设置", text: binding)
+ .multilineTextAlignment(.trailing)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private func secure(_ label: String, _ binding: Binding) -> some View {
+ HStack {
+ Text(label)
+ Spacer()
+ SecureField("未设置", text: binding).multilineTextAlignment(.trailing)
+ }
+ }
+
+ @ViewBuilder private var flashBanner: some View {
+ if let flash {
+ Text(flash)
+ .font(.subheadline.weight(.semibold))
+ .padding(.horizontal, 16).padding(.vertical, 10)
+ .background(.thinMaterial, in: Capsule())
+ .padding(.top, 8)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+
+ // MARK: - 动作
+
+ private func saveNow() {
+ do {
+ try model.save()
+ showFlash("已保存")
+ } catch {
+ showFlash("保存失败")
+ }
+ }
+
+ private func showFlash(_ text: String) {
+ withAnimation { flash = text }
+ Task {
+ try? await Task.sleep(nanoseconds: 1_200_000_000)
+ withAnimation { flash = nil }
+ }
+ }
+
+ private func openSystemKeyboardSettings() {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ }
+
+ private func asrName(_ id: String) -> String {
+ ["volcengine": "火山引擎", "bailian": "阿里百炼", "whisper": "Whisper / OpenAI 兼容"][id] ?? id
+ }
+
+ private func llmName(_ id: String) -> String {
+ ["openai": "OpenAI 兼容", "ark": "火山方舟", "gemini": "Gemini"][id] ?? id
+ }
+}
diff --git a/openless-ios/App/Sources/SpeechDictator.swift b/openless-ios/App/Sources/SpeechDictator.swift
new file mode 100644
index 000000000..51442f515
--- /dev/null
+++ b/openless-ios/App/Sources/SpeechDictator.swift
@@ -0,0 +1,225 @@
+import Foundation
+import Speech
+import AVFoundation
+import OpenLessShared
+
+/// 语音转写:按配置选择引擎——火山引擎(Volcengine 流式 ASR,复刻桌面)配了 Key 就用它,
+/// 否则退回 Apple 系统识别。麦克风采集统一走 AVAudioEngine;火山路径把音频重采样成 16k/16bit/mono。
+final class SpeechDictator: ObservableObject {
+ enum Phase { case idle, recording, finished }
+
+ @Published var phase: Phase = .idle
+ @Published var transcript: String = ""
+ @Published var level: CGFloat = 0
+ @Published var error: String?
+ @Published var engineLabel: String = ""
+
+ private let appleRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN"))
+ private let engine = AVAudioEngine()
+ private var request: SFSpeechAudioBufferRecognitionRequest?
+ private var task: SFSpeechRecognitionTask?
+
+ private var volc: VolcengineASR?
+ private var converter: AVAudioConverter?
+ private let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16_000,
+ channels: 1, interleaved: true)!
+ private var usingVolc = false
+ private var finalTimeout: DispatchWorkItem?
+
+ func requestAuthorization() {
+ SFSpeechRecognizer.requestAuthorization { _ in }
+ AVAudioApplication.requestRecordPermission { _ in }
+ }
+
+ func toggle() {
+ switch phase {
+ case .recording: stop()
+ default: start()
+ }
+ }
+
+ func start() {
+ transcript = ""
+ error = nil
+ let prefs = SharedStore.shared.loadPreferences()
+ let creds = SharedStore.shared.loadCredentials()
+
+ if prefs.activeAsrProvider == "volcengine",
+ let e = creds.providers.asr["volcengine"],
+ let appKey = e.appKey, !appKey.isEmpty,
+ let token = e.accessKey, !token.isEmpty {
+ let resource = (e.resourceId?.isEmpty == false) ? e.resourceId! : "volc.seedasr.sauc.duration"
+ usingVolc = true
+ engineLabel = "火山引擎"
+ startVolc(.init(appKey: appKey, accessToken: token, resourceId: resource))
+ } else {
+ usingVolc = false
+ engineLabel = (prefs.activeAsrProvider == "volcengine") ? "火山引擎(缺 Key→系统识别)" : "系统识别"
+ startApple()
+ }
+ }
+
+ func reset() {
+ transcript = ""
+ error = nil
+ phase = .idle
+ level = 0
+ }
+
+ // MARK: - Apple 系统识别
+
+ private func startApple() {
+ guard let recognizer = appleRecognizer, recognizer.isAvailable else {
+ setError("语音识别暂不可用(检查网络或语言支持)"); return
+ }
+ SFSpeechRecognizer.requestAuthorization { [weak self] status in
+ guard let self else { return }
+ guard status == .authorized else { self.setError("未授权语音识别"); return }
+ AVAudioApplication.requestRecordPermission { granted in
+ guard granted else { self.setError("未授权麦克风"); return }
+ DispatchQueue.main.async { self.beginAppleAudio(recognizer) }
+ }
+ }
+ }
+
+ private func beginAppleAudio(_ recognizer: SFSpeechRecognizer) {
+ do {
+ try activateSession()
+ let req = SFSpeechAudioBufferRecognitionRequest()
+ req.shouldReportPartialResults = true
+ request = req
+ let input = engine.inputNode
+ let format = input.outputFormat(forBus: 0)
+ input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
+ self?.request?.append(buffer)
+ self?.updateLevel(buffer)
+ }
+ engine.prepare()
+ try engine.start()
+ phase = .recording
+ task = recognizer.recognitionTask(with: req) { [weak self] result, err in
+ guard let self else { return }
+ if let result {
+ let text = result.bestTranscription.formattedString
+ DispatchQueue.main.async { self.transcript = text }
+ }
+ if err != nil || (result?.isFinal ?? false) { self.teardown(finished: true) }
+ }
+ } catch {
+ setError(error.localizedDescription); teardown(finished: false)
+ }
+ }
+
+ // MARK: - 火山引擎流式 ASR
+
+ private func startVolc(_ creds: VolcengineASR.Credentials) {
+ AVAudioApplication.requestRecordPermission { [weak self] granted in
+ guard let self else { return }
+ guard granted else { self.setError("未授权麦克风"); return }
+ DispatchQueue.main.async { self.beginVolcAudio(creds) }
+ }
+ }
+
+ private func beginVolcAudio(_ creds: VolcengineASR.Credentials) {
+ do {
+ try activateSession()
+ let v = VolcengineASR(creds: creds)
+ v.onPartial = { [weak self] t in DispatchQueue.main.async { self?.transcript = t } }
+ v.onFinal = { [weak self] t in
+ DispatchQueue.main.async { self?.transcript = t; self?.teardown(finished: true) }
+ }
+ v.onError = { [weak self] m in
+ DispatchQueue.main.async { self?.setError(m); self?.teardown(finished: false) }
+ }
+ volc = v
+ v.start()
+
+ let input = engine.inputNode
+ let inFormat = input.outputFormat(forBus: 0)
+ converter = AVAudioConverter(from: inFormat, to: targetFormat)
+ input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { [weak self] buffer, _ in
+ guard let self else { return }
+ self.updateLevel(buffer)
+ if let data = self.convertToPCM16(buffer) { self.volc?.consume(pcm: data) }
+ }
+ engine.prepare()
+ try engine.start()
+ phase = .recording
+ } catch {
+ setError(error.localizedDescription); teardown(finished: false)
+ }
+ }
+
+ /// 把任意输入格式重采样为 16k/16bit/mono 的 PCM 字节。
+ private func convertToPCM16(_ buffer: AVAudioPCMBuffer) -> Data? {
+ guard let converter else { return nil }
+ let ratio = targetFormat.sampleRate / buffer.format.sampleRate
+ let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1024
+ guard let out = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return nil }
+ var fed = false
+ var convErr: NSError?
+ let status = converter.convert(to: out, error: &convErr) { _, outStatus in
+ if fed { outStatus.pointee = .noDataNow; return nil }
+ fed = true
+ outStatus.pointee = .haveData
+ return buffer
+ }
+ guard status != .error, convErr == nil, out.frameLength > 0,
+ let ch = out.int16ChannelData else { return nil }
+ return Data(bytes: ch[0], count: Int(out.frameLength) * 2)
+ }
+
+ // MARK: - 停止 / 公共
+
+ func stop() {
+ if engine.isRunning { engine.stop() }
+ engine.inputNode.removeTap(onBus: 0)
+ if usingVolc {
+ volc?.finish() // 发末帧,等 onFinal 回最终结果
+ let work = DispatchWorkItem { [weak self] in
+ guard let self, self.phase == .recording else { return }
+ self.teardown(finished: true) // 12s 兜底:用已识别 transcript 收尾
+ }
+ finalTimeout = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + 12, execute: work)
+ } else {
+ request?.endAudio()
+ DispatchQueue.main.async { if self.phase == .recording { self.phase = .finished } }
+ }
+ }
+
+ private func activateSession() throws {
+ let s = AVAudioSession.sharedInstance()
+ try s.setCategory(.record, mode: .measurement, options: .duckOthers)
+ try s.setActive(true, options: .notifyOthersOnDeactivation)
+ }
+
+ private func teardown(finished: Bool) {
+ finalTimeout?.cancel(); finalTimeout = nil
+ if engine.isRunning { engine.stop() }
+ engine.inputNode.removeTap(onBus: 0)
+ request = nil
+ task = nil
+ volc?.cancel(); volc = nil
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+ DispatchQueue.main.async {
+ self.level = 0
+ if finished { self.phase = .finished }
+ }
+ }
+
+ private func updateLevel(_ buffer: AVAudioPCMBuffer) {
+ guard let ch = buffer.floatChannelData?[0] else { return }
+ let n = Int(buffer.frameLength)
+ guard n > 0 else { return }
+ var sum: Float = 0
+ for i in 0..
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ OpenLess 键盘
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionAttributes
+
+
+ IsASCIICapable
+
+ PrefersRightToLeft
+
+ PrimaryLanguage
+ zh-Hans
+
+ RequestsOpenAccess
+
+
+ NSExtensionPointIdentifier
+ com.apple.keyboard-service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).KeyboardViewController
+
+
+
diff --git a/openless-ios/Keyboard/OpenLessKeyboard.entitlements b/openless-ios/Keyboard/OpenLessKeyboard.entitlements
new file mode 100644
index 000000000..2484e0752
--- /dev/null
+++ b/openless-ios/Keyboard/OpenLessKeyboard.entitlements
@@ -0,0 +1,11 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.top.openless.ios
+
+
+
diff --git a/openless-ios/Keyboard/Sources/KeyboardViewController.swift b/openless-ios/Keyboard/Sources/KeyboardViewController.swift
new file mode 100644
index 000000000..77c1d6559
--- /dev/null
+++ b/openless-ios/Keyboard/Sources/KeyboardViewController.swift
@@ -0,0 +1,183 @@
+import UIKit
+
+/// OpenLess 键盘 = Flow 式语音面板(不做打字键盘)。
+///
+/// 约束:键盘扩展无法录音。麦克风键 → 跳主 App(openless://dictate)录音+识别,
+/// 结果写入 App Group;返回本输入框时本类从共享容器取出并用 textDocumentProxy 插入。
+///
+/// 注意:跳转(openURL)与读 App Group 都**需要「允许完全访问」**;未开时给出提示。
+final class KeyboardViewController: UIInputViewController {
+
+ private let appGroup = "group.top.openless.ios"
+ private let pendingKey = "pendingInsertText" // 与 Shared/Handoff.swift 保持一致
+ private let hintLabel = UILabel()
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ buildPanel()
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ updateHint()
+ insertPendingIfAny()
+ }
+
+ override func textDidChange(_ textInput: UITextInput?) {
+ super.textDidChange(textInput)
+ insertPendingIfAny()
+ }
+
+ /// 取出主 App 识别好的文本并插入。
+ private func insertPendingIfAny() {
+ guard let d = UserDefaults(suiteName: appGroup),
+ let text = d.string(forKey: pendingKey), !text.isEmpty else { return }
+ d.removeObject(forKey: pendingKey)
+ textDocumentProxy.insertText(text)
+ }
+
+ /// 根据是否拥有完全访问更新提示(未开则跳转/回插都用不了)。
+ private func updateHint() {
+ if hasFullAccess {
+ hintLabel.text = "轻点一下,跳转说话"
+ hintLabel.textColor = .secondaryLabel
+ } else {
+ hintLabel.text = "请到 设置→通用→键盘→OpenLess 开启「允许完全访问」"
+ hintLabel.textColor = .systemRed
+ }
+ }
+
+ // MARK: - 布局
+
+ private func buildPanel() {
+ let h = view.heightAnchor.constraint(equalToConstant: 196)
+ h.priority = UILayoutPriority(999)
+ h.isActive = true
+
+ let toolbar = makeToolbar()
+
+ let mic = makeMicButton()
+ hintLabel.font = .systemFont(ofSize: 14)
+ hintLabel.textColor = .secondaryLabel
+ hintLabel.textAlignment = .center
+ hintLabel.numberOfLines = 2
+ hintLabel.text = "轻点一下,跳转说话"
+
+ let center = UIStackView(arrangedSubviews: [mic, hintLabel])
+ center.axis = .vertical
+ center.spacing = 12
+ center.alignment = .center
+
+ let root = UIStackView(arrangedSubviews: [toolbar, center])
+ root.axis = .vertical
+ root.spacing = 18
+ root.alignment = .fill
+ root.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(root)
+ NSLayoutConstraint.activate([
+ root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 14),
+ root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14),
+ root.topAnchor.constraint(equalTo: view.topAnchor, constant: 12),
+ center.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 8),
+ ])
+ }
+
+ // MARK: - 顶部工具条:品牌 + 切键盘 / 删除 / 设置
+
+ private func makeToolbar() -> UIView {
+ let brand = UILabel()
+ brand.text = "OpenLess"
+ brand.font = .systemFont(ofSize: 13, weight: .semibold)
+ brand.textColor = .secondaryLabel
+
+ let spacerView = UIView()
+ spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+
+ var items: [UIView] = [brand, spacerView]
+ if needsInputModeSwitchKey {
+ let globe = iconButton("globe")
+ globe.addTarget(self, action: #selector(handleInputModeList(from:with:)), for: .allTouchEvents)
+ items.append(globe)
+ }
+ let del = iconButton("delete.left")
+ del.addTarget(self, action: #selector(onDelete), for: .touchUpInside)
+ items.append(del)
+
+ let gear = iconButton("gearshape")
+ gear.addTarget(self, action: #selector(onSettings), for: .touchUpInside)
+ items.append(gear)
+
+ let bar = UIStackView(arrangedSubviews: items)
+ bar.axis = .horizontal
+ bar.spacing = 18
+ bar.alignment = .center
+ bar.heightAnchor.constraint(equalToConstant: 30).isActive = true
+ return bar
+ }
+
+ /// 无背景的纯图标按钮(不再额外套白底方块),加大点击热区。
+ private func iconButton(_ systemName: String) -> UIButton {
+ let b = UIButton(type: .system)
+ b.setImage(UIImage(systemName: systemName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular)), for: .normal)
+ b.tintColor = .label
+ b.widthAnchor.constraint(equalToConstant: 40).isActive = true
+ b.heightAnchor.constraint(equalToConstant: 30).isActive = true
+ return b
+ }
+
+ // MARK: - 大麦克风按钮
+
+ private func makeMicButton() -> UIButton {
+ var cfg = UIButton.Configuration.filled()
+ cfg.baseBackgroundColor = .label
+ cfg.baseForegroundColor = .systemBackground
+ cfg.title = "语音输入"
+ cfg.image = UIImage(systemName: "mic.fill")
+ cfg.imagePadding = 8
+ cfg.cornerStyle = .capsule
+ cfg.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 28, bottom: 12, trailing: 28)
+ let b = UIButton(configuration: cfg)
+ b.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
+ b.addTarget(self, action: #selector(onMic), for: .touchUpInside)
+ return b
+ }
+
+ // MARK: - 动作
+
+ @objc private func onMic() {
+ guard hasFullAccess else { updateHint(); return }
+ openHostApp("openless://dictate")
+ }
+
+ @objc private func onSettings() {
+ guard hasFullAccess else { updateHint(); return }
+ openHostApp("openless://settings")
+ }
+
+ @objc private func onDelete() { textDocumentProxy.deleteBackward() }
+
+ /// 键盘扩展打开容器 App:沿响应链找到 UIApplication 调现代 open;老选择器作兜底。
+ /// 需要「允许完全访问」。
+ private func openHostApp(_ urlString: String) {
+ guard let url = URL(string: urlString) else { return }
+ // 首选:找到 UIApplication 实例调 open(_:options:completionHandler:)
+ var responder: UIResponder? = self
+ while let r = responder {
+ if let app = r as? UIApplication {
+ app.open(url, options: [:], completionHandler: nil)
+ return
+ }
+ responder = r.next
+ }
+ // 兜底:老的 openURL: 选择器(部分系统版本仍可用)
+ let selector = NSSelectorFromString("openURL:")
+ responder = self
+ while let r = responder {
+ if r.responds(to: selector) {
+ r.perform(selector, with: url)
+ return
+ }
+ responder = r.next
+ }
+ }
+}
diff --git a/openless-ios/README.md b/openless-ios/README.md
new file mode 100644
index 000000000..1c801150e
--- /dev/null
+++ b/openless-ios/README.md
@@ -0,0 +1,52 @@
+# OpenLess for iOS
+
+OpenLess 的 iOS 端:一个以**语音输入**为核心的第三方输入法。按住语音键说话,云端 ASR 转写 + LLM 润色后直接落到光标处;并能同步使用云端**风格包市场**。
+
+> 与桌面端(`../openless-all/`)独立工程,仅共用同一套数据模型约定与同一个风格包市场后端。
+
+## 关键架构约束(务必先读)
+
+- **iOS 键盘扩展无法访问麦克风**(苹果硬限制,开 Full Access 也不行)。因此语音录制必须在**主 App** 内完成。
+- 语音闭环:键盘语音键 → `openless://` 跳主 App(或 Phase 5 的后台常驻会话免跳转)→ 录音 → ASR → 润色 → 结果写入 **App Group** 共享容器 → 键盘用 `textDocumentProxy` 插入。
+- 三个 target:
+ - `OpenLess`(主 App,SwiftUI)
+ - `OpenLessKeyboard`(键盘扩展,app-extension)
+ - `OpenLessShared`(共享框架:数据模型 / 存储 / ASR&润色客户端)
+- App Group:`group.top.openless.ios`
+
+## 前置依赖
+
+- **完整 Xcode.app**(不是 Command Line Tools)+ iOS 模拟器运行时。
+- [XcodeGen](https://github.com/yonyz/XcodeGen):`brew install xcodegen`
+
+## 构建与运行(模拟器优先)
+
+```bash
+cd 1-app/openless-ios
+xcodegen generate # project.yml → OpenLess.xcodeproj
+open OpenLess.xcodeproj # 用 Xcode 打开,选 iPhone 模拟器运行
+
+# 或命令行编译(模拟器,无需签名)
+xcodebuild -scheme OpenLess \
+ -destination 'platform=iOS Simulator,name=iPhone 15' \
+ -derivedDataPath build build
+```
+
+启用键盘:模拟器里 **设置 → 通用 → 键盘 → 键盘 → 添加新键盘 → OpenLess 键盘**,并开启「允许完全访问」。
+
+## 工程约定
+
+- 只提交 `project.yml` 与源码;`OpenLess.xcodeproj` 由 XcodeGen 生成,不入库(见 `.gitignore`)。
+- 数据模型对齐桌面 `src-tauri/src/types.rs`、`persistence.rs`,JSON 一律 camelCase。
+- 凭据存 Keychain,结构沿用桌面 `CredsRoot`。
+
+## 进度(分阶段交付,每阶段可在模拟器跑起来)
+
+- [x] **Phase 0** 工程骨架(3 target 可生成,最小可用键盘 + 首页引导)
+- [ ] **Phase 1** 共享数据模型 + 存储(Keychain 凭据、round-trip 单测)
+- [ ] **Phase 2** 云端 ASR(Volcengine/Bailian/Whisper)+ 润色客户端
+- [ ] **Phase 3** 语音闭环(跳转模式)
+- [ ] **Phase 4** 完整键盘 + 拼音引擎
+- [ ] **Phase 5** 免跳转后台会话 + 悬浮控件
+- [ ] **Phase 6** 云端风格包市场(拉取 / 安装)
+- [ ] **Phase 7** 设置 / 配置录入 / 收尾打磨
diff --git a/openless-ios/Shared/Info.plist b/openless-ios/Shared/Info.plist
new file mode 100644
index 000000000..c0701c6d7
--- /dev/null
+++ b/openless-ios/Shared/Info.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+
+
diff --git a/openless-ios/Shared/Sources/ASR/VolcFrame.swift b/openless-ios/Shared/Sources/ASR/VolcFrame.swift
new file mode 100644
index 000000000..9471d27d4
--- /dev/null
+++ b/openless-ios/Shared/Sources/ASR/VolcFrame.swift
@@ -0,0 +1,108 @@
+import Foundation
+
+/// 火山引擎大模型流式 ASR 二进制帧编解码(移植自桌面 asr/frame.rs)。
+/// 帧结构:4 字节 header + 可选大端 i32 sequence + 4 字节大端 u32 payload size + payload。
+/// 固定 no-compression。
+
+public enum VolcMessageType: UInt8 {
+ case fullClientRequest = 0b0001
+ case audioOnlyRequest = 0b0010
+ case fullServerResponse = 0b1001
+ case errorMessage = 0b1111
+}
+
+public enum VolcFlags: UInt8 {
+ case none = 0b0000
+ case positiveSequence = 0b0001
+ case lastPacket = 0b0010
+ case negativeSequence = 0b0011
+}
+
+public enum VolcSerialization: UInt8 {
+ case none = 0b0000
+ case json = 0b0001
+}
+
+public struct VolcParsedFrame {
+ public var messageType: VolcMessageType?
+ public var flags: UInt8
+ public var sequence: Int32?
+ public var errorCode: UInt32?
+ public var payload: Data
+
+ /// 流结束信号只信帧头 flags(lastPacket / negativeSequence / 负序号)。
+ public var isFinal: Bool {
+ flags == VolcFlags.lastPacket.rawValue
+ || flags == VolcFlags.negativeSequence.rawValue
+ || (sequence ?? 0) < 0
+ }
+}
+
+public enum VolcFrame {
+ static let headerByte0: UInt8 = 0x11 // header_size=1*4=4, version=1
+ static let compressionNone: UInt8 = 0b0000
+
+ public static func build(_ type: VolcMessageType,
+ _ flags: VolcFlags,
+ _ serialization: VolcSerialization,
+ payload: Data,
+ sequence: Int32?) -> Data {
+ var frame = Data()
+ frame.append(headerByte0)
+ frame.append((type.rawValue << 4) | flags.rawValue)
+ frame.append((serialization.rawValue << 4) | compressionNone)
+ frame.append(0x00)
+
+ if (flags == .positiveSequence || flags == .negativeSequence), let seq = sequence {
+ var be = seq.bigEndian
+ withUnsafeBytes(of: &be) { frame.append(contentsOf: $0) }
+ }
+
+ var size = UInt32(payload.count).bigEndian
+ withUnsafeBytes(of: &size) { frame.append(contentsOf: $0) }
+ frame.append(payload)
+ return frame
+ }
+
+ public static func parse(_ data: Data) -> VolcParsedFrame? {
+ let bytes = [UInt8](data)
+ guard bytes.count >= 8 else { return nil }
+
+ let headerSize = Int(bytes[0] & 0x0F) * 4
+ guard headerSize >= 4, bytes.count >= headerSize + 4 else { return nil }
+
+ let mt = VolcMessageType(rawValue: (bytes[1] >> 4) & 0x0F)
+ let flags = bytes[1] & 0x0F
+ let compression = bytes[2] & 0x0F
+ guard compression == compressionNone else { return nil }
+
+ var offset = headerSize
+ var sequence: Int32?
+ if flags == VolcFlags.positiveSequence.rawValue || flags == VolcFlags.negativeSequence.rawValue {
+ guard let v = readU32(bytes, offset) else { return nil }
+ sequence = Int32(bitPattern: v)
+ offset += 4
+ }
+
+ if mt == .errorMessage {
+ guard let code = readU32(bytes, offset), let msz = readU32(bytes, offset + 4) else { return nil }
+ offset += 8
+ let size = Int(msz)
+ guard bytes.count >= offset + size else { return nil }
+ return VolcParsedFrame(messageType: mt, flags: flags, sequence: sequence,
+ errorCode: code, payload: Data(bytes[offset..= offset + size else { return nil }
+ return VolcParsedFrame(messageType: mt, flags: flags, sequence: sequence,
+ errorCode: nil, payload: Data(bytes[offset.. UInt32? {
+ guard b.count >= o + 4 else { return nil }
+ return (UInt32(b[o]) << 24) | (UInt32(b[o + 1]) << 16) | (UInt32(b[o + 2]) << 8) | UInt32(b[o + 3])
+ }
+}
diff --git a/openless-ios/Shared/Sources/Models/Credentials.swift b/openless-ios/Shared/Sources/Models/Credentials.swift
new file mode 100644
index 000000000..47ac97638
--- /dev/null
+++ b/openless-ios/Shared/Sources/Models/Credentials.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+/// 凭据模型,结构沿用桌面 keyring 的 `CredsRoot`(persistence.rs:538-651),JSON 采用 camelCase。
+/// iOS 端整体以一条 JSON 存入 Keychain(见 KeychainStore,Phase 1)。
+
+public struct CredsAsrEntry: Codable, Sendable {
+ public var apiKey: String?
+ public var baseURL: String?
+ public var model: String?
+ public var appKey: String? // 火山引擎 App Key
+ public var accessKey: String? // 火山引擎 Access Token
+ public var resourceId: String? // 火山引擎 Resource ID
+ public var vocabularyId: String? // Bailian 词汇表 ID
+
+ public init(apiKey: String? = nil, baseURL: String? = nil, model: String? = nil,
+ appKey: String? = nil, accessKey: String? = nil,
+ resourceId: String? = nil, vocabularyId: String? = nil) {
+ self.apiKey = apiKey; self.baseURL = baseURL; self.model = model
+ self.appKey = appKey; self.accessKey = accessKey
+ self.resourceId = resourceId; self.vocabularyId = vocabularyId
+ }
+}
+
+public struct CredsLlmEntry: Codable, Sendable {
+ public var displayName: String?
+ public var apiKey: String?
+ public var baseURL: String?
+ public var model: String?
+ public var temperature: Double?
+ public var extraHeaders: [String: String]?
+
+ public init(displayName: String? = nil, apiKey: String? = nil, baseURL: String? = nil,
+ model: String? = nil, temperature: Double? = nil,
+ extraHeaders: [String: String]? = nil) {
+ self.displayName = displayName; self.apiKey = apiKey; self.baseURL = baseURL
+ self.model = model; self.temperature = temperature; self.extraHeaders = extraHeaders
+ }
+}
+
+public struct CredsActive: Codable, Sendable {
+ public var asr: String
+ public var llm: String
+ public init(asr: String = "", llm: String = "") { self.asr = asr; self.llm = llm }
+}
+
+public struct CredsProviders: Codable, Sendable {
+ public var asr: [String: CredsAsrEntry]
+ public var llm: [String: CredsLlmEntry]
+ public init(asr: [String: CredsAsrEntry] = [:], llm: [String: CredsLlmEntry] = [:]) {
+ self.asr = asr; self.llm = llm
+ }
+}
+
+public struct CredsRoot: Codable, Sendable {
+ public var version: Int
+ public var active: CredsActive
+ public var providers: CredsProviders
+
+ public init(version: Int = 1, active: CredsActive = .init(), providers: CredsProviders = .init()) {
+ self.version = version
+ self.active = active
+ self.providers = providers
+ }
+}
diff --git a/openless-ios/Shared/Sources/Models/PolishMode.swift b/openless-ios/Shared/Sources/Models/PolishMode.swift
new file mode 100644
index 000000000..02756349c
--- /dev/null
+++ b/openless-ios/Shared/Sources/Models/PolishMode.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// 润色模式,对齐桌面 `PolishMode`(types.rs)。
+/// raw=不润色 / light=轻度 / structured=结构化 / formal=正式。
+public enum PolishMode: String, Codable, CaseIterable, Sendable {
+ case raw
+ case light
+ case structured
+ case formal
+
+ public var displayName: String {
+ switch self {
+ case .raw: return "原文"
+ case .light: return "轻度润色"
+ case .structured: return "结构化"
+ case .formal: return "正式"
+ }
+ }
+}
diff --git a/openless-ios/Shared/Sources/Models/Preferences.swift b/openless-ios/Shared/Sources/Models/Preferences.swift
new file mode 100644
index 000000000..b77da438e
--- /dev/null
+++ b/openless-ios/Shared/Sources/Models/Preferences.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+/// 用户偏好的 iOS 子集,对齐桌面 `UserPreferences`(types.rs:524-752)中与移动端相关的字段。
+/// 桌面端的热键/插入方式/Less Computer 等桌面特有项不移植。JSON 采用 camelCase。
+public struct Preferences: Codable, Sendable {
+ // 润色与风格
+ public var defaultMode: PolishMode
+ public var activeStylePackId: String
+
+ // Provider 选择
+ public var activeAsrProvider: String // volcengine | bailian | whisper
+ public var activeLlmProvider: String // openai | ark | gemini
+
+ // 语言
+ public var workingLanguages: [String] // 例:["zh", "en"]
+ public var outputLanguagePreference: String // auto | zhCn | zhTw | en | ja | ko
+ public var chineseScriptPreference: String // auto | simplified | traditional
+
+ // 行为
+ public var llmThinkingEnabled: Bool
+ public var streamingInsert: Bool
+
+ // 市场
+ public var marketplaceBaseUrl: String // 空 = 用默认 https://apic.openless.top
+
+ // 免跳转保持时长(分钟):0=每次跳转;>0=保持 N 分钟;-1=一直保持
+ public var voiceHoldMinutes: Int
+
+ public init(
+ defaultMode: PolishMode = .light,
+ activeStylePackId: String = "builtin.light",
+ activeAsrProvider: String = "volcengine",
+ activeLlmProvider: String = "openai",
+ workingLanguages: [String] = ["zh", "en"],
+ outputLanguagePreference: String = "auto",
+ chineseScriptPreference: String = "auto",
+ llmThinkingEnabled: Bool = false,
+ streamingInsert: Bool = true,
+ marketplaceBaseUrl: String = "",
+ voiceHoldMinutes: Int = 30
+ ) {
+ self.defaultMode = defaultMode
+ self.activeStylePackId = activeStylePackId
+ self.activeAsrProvider = activeAsrProvider
+ self.activeLlmProvider = activeLlmProvider
+ self.workingLanguages = workingLanguages
+ self.outputLanguagePreference = outputLanguagePreference
+ self.chineseScriptPreference = chineseScriptPreference
+ self.llmThinkingEnabled = llmThinkingEnabled
+ self.streamingInsert = streamingInsert
+ self.marketplaceBaseUrl = marketplaceBaseUrl
+ self.voiceHoldMinutes = voiceHoldMinutes
+ }
+
+ public static let `default` = Preferences()
+
+ /// 解码时容忍缺字段(老版本/部分写)。
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ let d = Preferences.default
+ defaultMode = try c.decodeIfPresent(PolishMode.self, forKey: .defaultMode) ?? d.defaultMode
+ activeStylePackId = try c.decodeIfPresent(String.self, forKey: .activeStylePackId) ?? d.activeStylePackId
+ activeAsrProvider = try c.decodeIfPresent(String.self, forKey: .activeAsrProvider) ?? d.activeAsrProvider
+ activeLlmProvider = try c.decodeIfPresent(String.self, forKey: .activeLlmProvider) ?? d.activeLlmProvider
+ workingLanguages = try c.decodeIfPresent([String].self, forKey: .workingLanguages) ?? d.workingLanguages
+ outputLanguagePreference = try c.decodeIfPresent(String.self, forKey: .outputLanguagePreference) ?? d.outputLanguagePreference
+ chineseScriptPreference = try c.decodeIfPresent(String.self, forKey: .chineseScriptPreference) ?? d.chineseScriptPreference
+ llmThinkingEnabled = try c.decodeIfPresent(Bool.self, forKey: .llmThinkingEnabled) ?? d.llmThinkingEnabled
+ streamingInsert = try c.decodeIfPresent(Bool.self, forKey: .streamingInsert) ?? d.streamingInsert
+ marketplaceBaseUrl = try c.decodeIfPresent(String.self, forKey: .marketplaceBaseUrl) ?? d.marketplaceBaseUrl
+ voiceHoldMinutes = try c.decodeIfPresent(Int.self, forKey: .voiceHoldMinutes) ?? d.voiceHoldMinutes
+ }
+}
diff --git a/openless-ios/Shared/Sources/Models/StylePack.swift b/openless-ios/Shared/Sources/Models/StylePack.swift
new file mode 100644
index 000000000..9af55f36b
--- /dev/null
+++ b/openless-ios/Shared/Sources/Models/StylePack.swift
@@ -0,0 +1,105 @@
+import Foundation
+
+/// 风格包来源类型,对齐桌面 `StylePackKind`。
+public enum StylePackKind: String, Codable, Sendable {
+ case builtin
+ case imported
+}
+
+/// 风格包示例(标题 + 输入 → 输出)。
+public struct StylePackExample: Codable, Hashable, Sendable {
+ public var title: String
+ public var input: String
+ public var output: String
+
+ public init(title: String, input: String, output: String) {
+ self.title = title
+ self.input = input
+ self.output = output
+ }
+}
+
+/// 风格包,字段对齐桌面 `StylePack`(types.rs:275-298),JSON 采用 camelCase。
+/// iOS 本地 `style-packs.json` 与云端市场下载的 ZIP 清单共用此结构。
+public struct StylePack: Codable, Identifiable, Hashable, Sendable {
+ public var id: String
+ public var name: String
+ public var description: String
+ public var author: String?
+ public var version: String
+ public var kind: StylePackKind
+ public var baseMode: PolishMode
+ public var prompt: String
+ public var examples: [StylePackExample]
+ public var tags: [String]
+ public var iconPath: String?
+ public var createdAt: String?
+ public var updatedAt: String?
+ public var enabled: Bool
+ public var active: Bool
+ public var recommendedModel: String?
+ public var compatibleAppVersion: String?
+ public var originPackId: String?
+ public var originAuthorLogin: String?
+
+ public init(
+ id: String,
+ name: String,
+ description: String = "",
+ author: String? = nil,
+ version: String = "1.0.0",
+ kind: StylePackKind = .imported,
+ baseMode: PolishMode = .light,
+ prompt: String = "",
+ examples: [StylePackExample] = [],
+ tags: [String] = [],
+ iconPath: String? = nil,
+ createdAt: String? = nil,
+ updatedAt: String? = nil,
+ enabled: Bool = true,
+ active: Bool = false,
+ recommendedModel: String? = nil,
+ compatibleAppVersion: String? = nil,
+ originPackId: String? = nil,
+ originAuthorLogin: String? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.description = description
+ self.author = author
+ self.version = version
+ self.kind = kind
+ self.baseMode = baseMode
+ self.prompt = prompt
+ self.examples = examples
+ self.tags = tags
+ self.iconPath = iconPath
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.enabled = enabled
+ self.active = active
+ self.recommendedModel = recommendedModel
+ self.compatibleAppVersion = compatibleAppVersion
+ self.originPackId = originPackId
+ self.originAuthorLogin = originAuthorLogin
+ }
+}
+
+public extension StylePack {
+ /// 四个内置风格包,对齐桌面 builtin.{raw,light,structured,formal}。
+ static func builtins() -> [StylePack] {
+ PolishMode.allCases.map { mode in
+ StylePack(
+ id: "builtin.\(mode.rawValue)",
+ name: mode.displayName,
+ description: "内置风格:\(mode.displayName)",
+ author: "OpenLess",
+ version: "1.0.0",
+ kind: .builtin,
+ baseMode: mode,
+ enabled: true,
+ active: mode == .light
+ )
+ }
+ }
+}
diff --git a/openless-ios/Shared/Sources/Storage/AppGroup.swift b/openless-ios/Shared/Sources/Storage/AppGroup.swift
new file mode 100644
index 000000000..36d27a10a
--- /dev/null
+++ b/openless-ios/Shared/Sources/Storage/AppGroup.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+/// App Group:主 App ↔ 键盘扩展 的共享身份与容器。
+public enum AppGroup {
+ /// 必须与两端 entitlements 的 application-groups 一致。
+ public static let identifier = "group.top.openless.ios"
+
+ /// 共享容器根目录;若未配置(如纯单测环境)退化到 caches,避免崩溃。
+ public static var containerURL: URL {
+ if let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) {
+ return url
+ }
+ return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
+ }
+
+ /// 共享 UserDefaults(轻量标志位 / 跳转信令)。
+ public static var defaults: UserDefaults {
+ UserDefaults(suiteName: identifier) ?? .standard
+ }
+}
diff --git a/openless-ios/Shared/Sources/Storage/Handoff.swift b/openless-ios/Shared/Sources/Storage/Handoff.swift
new file mode 100644
index 000000000..7fe1278af
--- /dev/null
+++ b/openless-ios/Shared/Sources/Storage/Handoff.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// 主 App ↔ 键盘扩展 的「识别结果」交接:主 App 识别完写入共享容器,键盘下次激活时取出并插入。
+/// 仅用共享 UserDefaults(键盘侧用同名 suite + key 读取,无需链接本框架)。
+public enum Handoff {
+ public static let pendingInsertKey = "pendingInsertText"
+
+ public static func setPendingInsert(_ text: String) {
+ AppGroup.defaults.set(text, forKey: pendingInsertKey)
+ }
+
+ /// 取出并清空待插入文本。
+ public static func takePendingInsert() -> String? {
+ let d = AppGroup.defaults
+ guard let t = d.string(forKey: pendingInsertKey), !t.isEmpty else { return nil }
+ d.removeObject(forKey: pendingInsertKey)
+ return t
+ }
+}
diff --git a/openless-ios/Shared/Sources/Storage/KeychainStore.swift b/openless-ios/Shared/Sources/Storage/KeychainStore.swift
new file mode 100644
index 000000000..18b985c6c
--- /dev/null
+++ b/openless-ios/Shared/Sources/Storage/KeychainStore.swift
@@ -0,0 +1,62 @@
+import Foundation
+import Security
+
+/// 凭据存储:把整个 `CredsRoot` 以一条 JSON 存入 iOS Keychain(generic password)。
+/// 结构沿用桌面 keyring 的 CredsRoot;ASR/LLM 的 API Key 等敏感数据只走这里,不落 App Group 明文。
+public final class KeychainStore {
+ public static let shared = KeychainStore()
+
+ private let service = "top.openless.ios.credentials"
+ private let account = "credentials.v1"
+
+ public init() {}
+
+ public func loadCreds() -> CredsRoot {
+ guard let data = read() else { return CredsRoot() }
+ return (try? JSONDecoder().decode(CredsRoot.self, from: data)) ?? CredsRoot()
+ }
+
+ public func saveCreds(_ creds: CredsRoot) throws {
+ let data = try JSONEncoder().encode(creds)
+ try write(data)
+ }
+
+ // MARK: - SecItem 封装
+
+ private func baseQuery() -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ }
+
+ private func read() -> Data? {
+ var q = baseQuery()
+ q[kSecReturnData as String] = true
+ q[kSecMatchLimit as String] = kSecMatchLimitOne
+ var out: AnyObject?
+ let status = SecItemCopyMatching(q as CFDictionary, &out)
+ guard status == errSecSuccess else { return nil }
+ return out as? Data
+ }
+
+ private func write(_ data: Data) throws {
+ let q = baseQuery()
+ let update: [String: Any] = [kSecValueData as String: data]
+ let status = SecItemUpdate(q as CFDictionary, update as CFDictionary)
+ if status == errSecItemNotFound {
+ var add = q
+ add[kSecValueData as String] = data
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+ let addStatus = SecItemAdd(add as CFDictionary, nil)
+ guard addStatus == errSecSuccess else { throw KeychainError.status(addStatus) }
+ } else if status != errSecSuccess {
+ throw KeychainError.status(status)
+ }
+ }
+}
+
+public enum KeychainError: Error {
+ case status(OSStatus)
+}
diff --git a/openless-ios/Shared/Sources/Storage/SharedStore.swift b/openless-ios/Shared/Sources/Storage/SharedStore.swift
new file mode 100644
index 000000000..67776f6c2
--- /dev/null
+++ b/openless-ios/Shared/Sources/Storage/SharedStore.swift
@@ -0,0 +1,66 @@
+import Foundation
+
+/// App Group 容器内的 JSON 持久化(偏好、风格包等),主 App 与键盘扩展共读。
+/// 凭据不走这里——敏感数据存 Keychain(Phase 1 的 KeychainStore)。
+///
+/// 线程安全:所有读写在内部串行队列上序列化;`encoder`/`decoder` 不跨线程共享。
+public final class SharedStore: @unchecked Sendable {
+ public static let shared = SharedStore()
+
+ private let dir: URL
+ private let encoder: JSONEncoder
+ private let decoder: JSONDecoder
+ private let queue = DispatchQueue(label: "top.openless.ios.sharedstore")
+
+ private init(subdirectory: String = "OpenLess") {
+ dir = AppGroup.containerURL.appendingPathComponent(subdirectory, isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ decoder = JSONDecoder()
+ }
+
+ private func url(_ name: String) -> URL { dir.appendingPathComponent(name) }
+
+ public func load(_ type: T.Type, from name: String) -> T? {
+ queue.sync {
+ guard let data = try? Data(contentsOf: url(name)) else { return nil }
+ return try? decoder.decode(T.self, from: data)
+ }
+ }
+
+ public func save(_ value: T, to name: String) throws {
+ try queue.sync {
+ let data = try encoder.encode(value)
+ try data.write(to: url(name), options: [.atomic])
+ }
+ }
+
+ // MARK: - 便捷访问
+
+ public func loadPreferences() -> Preferences {
+ load(Preferences.self, from: "preferences.json") ?? .default
+ }
+
+ public func savePreferences(_ prefs: Preferences) throws {
+ try save(prefs, to: "preferences.json")
+ }
+
+ public func loadStylePacks() -> [StylePack] {
+ load([StylePack].self, from: "style-packs.json") ?? StylePack.builtins()
+ }
+
+ public func saveStylePacks(_ packs: [StylePack]) throws {
+ try save(packs, to: "style-packs.json")
+ }
+
+ // 凭据:模拟器/未签名阶段存沙盒 JSON(Keychain 需签名后的 access group,未签名会 -34018 失败)。
+ // 真机签名后改走 Keychain(见 KeychainStore)。
+ public func loadCredentials() -> CredsRoot {
+ load(CredsRoot.self, from: "credentials.json") ?? CredsRoot()
+ }
+
+ public func saveCredentials(_ creds: CredsRoot) throws {
+ try save(creds, to: "credentials.json")
+ }
+}
diff --git a/openless-ios/Shared/Sources/Voice/VoiceBridge.swift b/openless-ios/Shared/Sources/Voice/VoiceBridge.swift
new file mode 100644
index 000000000..df318ebb0
--- /dev/null
+++ b/openless-ios/Shared/Sources/Voice/VoiceBridge.swift
@@ -0,0 +1,60 @@
+import Foundation
+
+/// 免跳转语音的进程间桥:键盘扩展 ↔ 主 App。
+/// - Darwin 通知:跨进程信号(start/stop/partial/final)。
+/// - App Group UserDefaults:传文本(partial/final) + 主 App 是否在后台保活(hostAlive)。
+///
+/// 用法:主 App(被 PiP 保活)observe(startNote) 开始录音、把 partial/final 写进 App Group 并 post 通知;
+/// 键盘 post(startNote) 触发、observe(finalNote) 后取 final 插入。
+
+private var voiceObservers: [String: () -> Void] = [:]
+
+private let voiceCallback: CFNotificationCallback = { _, _, name, _, _ in
+ guard let raw = name?.rawValue as String? else { return }
+ let handler = voiceObservers[raw]
+ DispatchQueue.main.async { handler?() }
+}
+
+public enum VoiceBridge {
+ public static let appGroup = "group.top.openless.ios"
+
+ // Darwin 通知名
+ public static let startNote = "top.openless.voice.start"
+ public static let stopNote = "top.openless.voice.stop"
+ public static let partialNote = "top.openless.voice.partial"
+ public static let finalNote = "top.openless.voice.final"
+
+ // App Group keys
+ public static let partialKey = "voice.partial"
+ public static let finalKey = "voice.final"
+ public static let hostAliveKey = "voice.hostAlive"
+
+ public static var defaults: UserDefaults { UserDefaults(suiteName: appGroup) ?? .standard }
+
+ public static func post(_ name: String) {
+ CFNotificationCenterPostNotification(
+ CFNotificationCenterGetDarwinNotifyCenter(),
+ CFNotificationName(name as CFString), nil, nil, true)
+ }
+
+ public static func observe(_ name: String, _ handler: @escaping () -> Void) {
+ voiceObservers[name] = handler
+ CFNotificationCenterAddObserver(
+ CFNotificationCenterGetDarwinNotifyCenter(),
+ nil, voiceCallback, name as CFString, nil, .deliverImmediately)
+ }
+
+ // 保活标志
+ public static func setHostAlive(_ v: Bool) { defaults.set(v, forKey: hostAliveKey) }
+ public static func isHostAlive() -> Bool { defaults.bool(forKey: hostAliveKey) }
+
+ // partial / final 文本
+ public static func setPartial(_ t: String) { defaults.set(t, forKey: partialKey) }
+ public static func partial() -> String { defaults.string(forKey: partialKey) ?? "" }
+ public static func setFinal(_ t: String) { defaults.set(t, forKey: finalKey) }
+ public static func takeFinal() -> String? {
+ guard let t = defaults.string(forKey: finalKey), !t.isEmpty else { return nil }
+ defaults.removeObject(forKey: finalKey)
+ return t
+ }
+}
diff --git a/openless-ios/project.yml b/openless-ios/project.yml
new file mode 100644
index 000000000..60f217aec
--- /dev/null
+++ b/openless-ios/project.yml
@@ -0,0 +1,76 @@
+# OpenLess for iOS — XcodeGen 工程定义
+# 源码只提交本文件 + Swift 源;.xcodeproj 由 `xcodegen generate` 生成,不入库。
+#
+# 生成: xcodegen generate
+# 打开: open OpenLess.xcodeproj
+# 编译(模拟器): xcodebuild -scheme OpenLess -destination 'generic/platform=iOS Simulator' build
+
+name: OpenLess
+options:
+ bundleIdPrefix: top.openless.ios
+ deploymentTarget:
+ iOS: "17.0"
+ createIntermediateGroups: true
+ groupSortPosition: top
+ generateEmptyDirectories: true
+
+settings:
+ base:
+ MARKETING_VERSION: "0.1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ SWIFT_VERSION: "5.9"
+ # 签名:各开发者在本地填自己的 Team(真机需要);模拟器无需。
+ DEVELOPMENT_TEAM: ""
+ CODE_SIGN_STYLE: Automatic
+ TARGETED_DEVICE_FAMILY: "1" # iPhone(输入法主要面向手机)
+ IPHONEOS_DEPLOYMENT_TARGET: "17.0"
+
+targets:
+ # ── 共享框架:数据模型 / ASR&润色客户端 / App Group 存储 ──
+ OpenLessShared:
+ type: framework
+ platform: iOS
+ sources:
+ - Shared/Sources
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: top.openless.ios.shared
+ INFOPLIST_FILE: Shared/Info.plist
+ DEFINES_MODULE: "YES"
+ SKIP_INSTALL: "YES"
+
+ # ── 主 App(SwiftUI):麦克风/录音/ASR/润色/配置/市场 ──
+ OpenLess:
+ type: application
+ platform: iOS
+ sources:
+ - App/Sources
+ - App/Resources
+ dependencies:
+ - target: OpenLessShared
+ embed: true
+ - target: OpenLessKeyboard
+ embed: true
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: top.openless.ios
+ INFOPLIST_FILE: App/Info.plist
+ CODE_SIGN_ENTITLEMENTS: App/OpenLess.entitlements
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
+ ENABLE_PREVIEWS: "YES"
+
+ # ── 键盘扩展(app-extension):复刻键盘 + 语音按钮,不能碰麦克风 ──
+ # 暂不依赖共享框架 / App Group:键盘当前自包含,去掉未用到的依赖可避免真机上扩展加载失败。
+ # 语音闭环(Phase 3)需要 App Group 时再加回,届时需配套的签名/容器权限。
+ OpenLessKeyboard:
+ type: app-extension
+ platform: iOS
+ sources:
+ - Keyboard/Sources
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: top.openless.ios.keyboard
+ INFOPLIST_FILE: Keyboard/Info.plist
+ # App Group:键盘从共享容器读取主 App 识别好的文本并插入(不依赖共享框架,仅用 UserDefaults suite)
+ CODE_SIGN_ENTITLEMENTS: Keyboard/OpenLessKeyboard.entitlements
diff --git a/openless-ios/scripts/run-sim.sh b/openless-ios/scripts/run-sim.sh
new file mode 100755
index 000000000..79a165a18
--- /dev/null
+++ b/openless-ios/scripts/run-sim.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# OpenLess iOS —— 一键:生成工程 → adhoc 签名构建 → 装到模拟器 → 启动
+#
+# adhoc 签名(CODE_SIGN_IDENTITY=-)是关键:让 App Group / 后台音频等 entitlements 在模拟器生效
+# (未签名构建会清空 entitlements,导致键盘↔主App 的共享容器不通)。
+#
+# 用法: scripts/run-sim.sh ["iPhone 17 Pro"]
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+export PATH="/opt/homebrew/bin:$PATH"
+export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
+
+DEVICE="${1:-iPhone 17 Pro}"
+APP="build/Build/Products/Debug-iphonesimulator/OpenLess.app"
+BUNDLE_ID="top.openless.ios"
+
+echo "▸ 生成工程"
+xcodegen generate >/dev/null
+
+echo "▸ adhoc 签名构建($DEVICE)"
+xcodebuild -project OpenLess.xcodeproj -scheme OpenLess \
+ -destination "platform=iOS Simulator,name=$DEVICE" \
+ -derivedDataPath build \
+ CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual \
+ CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=NO PROVISIONING_PROFILE_SPECIFIER="" \
+ build 2>&1 | grep -iE "error:| BUILD (SUCCEEDED|FAILED)" || true
+
+echo "▸ 启动模拟器并安装"
+xcrun simctl boot "$DEVICE" 2>/dev/null || true
+open -a Simulator
+xcrun simctl bootstatus "$DEVICE" -b >/dev/null 2>&1 || true
+xcrun simctl terminate "$DEVICE" "$BUNDLE_ID" 2>/dev/null || true
+xcrun simctl install "$DEVICE" "$APP"
+xcrun simctl launch "$DEVICE" "$BUNDLE_ID"
+echo "✓ 完成:$BUNDLE_ID 已在「$DEVICE」启动(adhoc 签名,App Group 生效)"