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 生效)"