diff --git a/MiniFlow_iOS/MiniFlow-iOS-Info.plist b/MiniFlow_iOS/MiniFlow-iOS-Info.plist new file mode 100644 index 0000000..041e54f --- /dev/null +++ b/MiniFlow_iOS/MiniFlow-iOS-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + miniflow + + + + UIBackgroundModes + + audio + processing + + + diff --git a/MiniFlow_iOS/MiniFlowKeyboard/KeyboardViewController.swift b/MiniFlow_iOS/MiniFlowKeyboard/KeyboardViewController.swift new file mode 100644 index 0000000..f074caa --- /dev/null +++ b/MiniFlow_iOS/MiniFlowKeyboard/KeyboardViewController.swift @@ -0,0 +1,395 @@ +import UIKit +import SwiftUI +import Combine + +class KeyboardViewController: UIInputViewController { + private var hostingController: UIHostingController? + private var viewModel: KeyboardViewModel? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.isOpaque = false + + let vm = KeyboardViewModel(textDocumentProxy: textDocumentProxy, inputViewController: self) + self.viewModel = vm + + let voiceInputView = VoiceInputView( + viewModel: vm, + onSwitchKeyboard: { [weak self] in + self?.advanceToNextInputMode() + } + ) + + let hostingController = UIHostingController(rootView: voiceInputView) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.view.backgroundColor = .clear + hostingController.view.isOpaque = false + + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.heightAnchor.constraint(equalToConstant: 258), + ]) + + self.hostingController = hostingController + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel?.refreshSessionState() + } +} + +// MARK: - FlowSessionManager (duplicated for extension sandbox) + +private enum FlowSessionKeys { + static let isSessionActive = "flow_session_active" + static let sessionHeartbeat = "flow_session_heartbeat" + static let recordingCommand = "flow_recording_command" + static let transcriptionResult = "flow_transcription_result" + static let recordingStatus = "flow_recording_status" + static let errorMessage = "flow_error_message" + static let partialTranscript = "flow_partial_transcript" +} + +/// File-based IPC — mirrors the main app's FlowSessionManager. +private class FlowSessionManager { + static let shared = FlowSessionManager() + + private let appGroupID = "group.com.smallestai.MiniFlow" + private var containerURL: URL? + + private init() { + containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) + } + + private func read(_ key: String) -> String? { + guard let url = containerURL?.appendingPathComponent(key) else { return nil } + return try? String(contentsOf: url, encoding: .utf8) + } + + private func write(_ value: String, key: String) { + guard let url = containerURL?.appendingPathComponent(key) else { return } + try? value.write(to: url, atomically: true, encoding: .utf8) + } + + var isSessionActive: Bool { + guard read(FlowSessionKeys.isSessionActive) == "true" else { return false } + // Check heartbeat freshness + guard let raw = read(FlowSessionKeys.sessionHeartbeat), + let heartbeat = Double(raw) else { return false } + let age = Date().timeIntervalSince1970 - heartbeat + return age <= 1.5 + } + + var recordingStatus: String { + read(FlowSessionKeys.recordingStatus) ?? "idle" + } + + var transcriptionResult: String { + get { read(FlowSessionKeys.transcriptionResult) ?? "" } + set { write(newValue, key: FlowSessionKeys.transcriptionResult) } + } + + var errorMessage: String { + read(FlowSessionKeys.errorMessage) ?? "" + } + + var partialTranscript: String { + read(FlowSessionKeys.partialTranscript) ?? "" + } + + func setReturnAppBundleID(_ bundleID: String) { + write(bundleID, key: "flow_return_app_bundle_id") + } + + func requestStartRecording() { + write("start", key: FlowSessionKeys.recordingCommand) + } + + func requestStopRecording() { + write("stop", key: FlowSessionKeys.recordingCommand) + } + + func requestCancelRecording() { + write("cancel", key: FlowSessionKeys.recordingCommand) + } + + func consumeTranscriptionResult() -> String { + let result = transcriptionResult + transcriptionResult = "" + write("idle", key: FlowSessionKeys.recordingStatus) + write("", key: FlowSessionKeys.partialTranscript) + return result + } +} + +// MARK: - KeyboardViewModel + +@MainActor +class KeyboardViewModel: ObservableObject { + @Published var state: KeyboardRecordingState = .idle + @Published var transcribedText: String = "" + @Published var partialText: String = "" + @Published var canUndo = false + + private var lastInsertedText: String = "" + private let textDocumentProxy: UITextDocumentProxy + private weak var inputViewController: UIInputViewController? + private let sessionManager = FlowSessionManager.shared + private var statusMonitorTimer: Timer? + private var sessionMonitorTimer: Timer? + private var sessionTimeoutTask: DispatchWorkItem? + + init(textDocumentProxy: UITextDocumentProxy, inputViewController: UIInputViewController) { + self.textDocumentProxy = textDocumentProxy + self.inputViewController = inputViewController + } + + func refreshSessionState() { + stopStatusMonitoring() + if !sessionManager.isSessionActive { + if state != .idle && state != .needsSession { state = .idle } + } else { + let status = sessionManager.recordingStatus + if status == "idle" || status == "done" || status == "error" { + state = .idle + } + if status == "recording" || status == "processing" { + startStatusMonitoring() + } + } + } + + func undoLastInsertion() { + guard canUndo, !lastInsertedText.isEmpty else { return } + for _ in 0.. String? { + let sel = NSSelectorFromString(key) + guard obj.responds(to: sel) else { return nil } + return obj.perform(sel)?.takeUnretainedValue() as? String + } + + private func getHostAppBundleID() -> String? { + let keys = [ + ["_", "host", "Bundle", "ID"].joined(), + ["_", "host", "Bundle", "Identifier"].joined(), + "hostBundleID", + "hostBundleIdentifier", + ] + + // Responder chain + var responder: UIResponder? = inputViewController + while let r = responder { + let obj = r as NSObject + for key in keys { + if let bid = safeValue(forKey: key, on: obj), + !bid.isEmpty, !bid.contains("MiniFlow") { + return bid + } + } + responder = r.next + } + + // Parent VC + if let parent = inputViewController?.parent as? NSObject { + for key in keys { + if let bid = safeValue(forKey: key, on: parent), + !bid.isEmpty, !bid.contains("MiniFlow") { + return bid + } + } + } + + return nil + } + + private func openURL(_ url: URL) { + guard let appClass = NSClassFromString("UIApplication") as? NSObject.Type else { return } + let sharedSel = NSSelectorFromString("sharedApplication") + guard appClass.responds(to: sharedSel), + let app = appClass.perform(sharedSel)?.takeUnretainedValue() else { return } + + typealias OpenMethod = @convention(c) (AnyObject, Selector, URL, [UIApplication.OpenExternalURLOptionsKey: Any], ((Bool) -> Void)?) -> Void + let openSel = NSSelectorFromString("openURL:options:completionHandler:") + guard let method = class_getInstanceMethod(type(of: app), openSel) else { return } + let impl = method_getImplementation(method) + let open = unsafeBitCast(impl, to: OpenMethod.self) + open(app, openSel, url, [:], nil) + } + + // MARK: - Monitoring + + private func startSessionMonitoring() { + sessionMonitorTimer?.invalidate() + sessionTimeoutTask?.cancel() + + sessionMonitorTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.sessionManager.isSessionActive { + self.sessionMonitorTimer?.invalidate() + self.sessionMonitorTimer = nil + self.sessionTimeoutTask?.cancel() + self.state = .idle + } + } + } + + let timeout = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + guard let self, self.state == .waitingForSession else { return } + self.sessionMonitorTimer?.invalidate() + self.sessionMonitorTimer = nil + self.state = .idle + } + } + sessionTimeoutTask = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout) + } + + private func startStatusMonitoring() { + statusMonitorTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in self?.checkStatus() } + } + } + + private func stopStatusMonitoring() { + statusMonitorTimer?.invalidate() + statusMonitorTimer = nil + } + + private func checkStatus() { + if !sessionManager.isSessionActive { + stopStatusMonitoring() + state = .needsSession + openMainAppToStartFlow() + return + } + + // Update partial transcript for live display + let partial = sessionManager.partialTranscript + if !partial.isEmpty { partialText = partial } + + let status = sessionManager.recordingStatus + + switch status { + case "recording": + if state != .recording { state = .recording } + case "processing": + if state != .processing { state = .processing } + case "done": + stopStatusMonitoring() + let result = sessionManager.consumeTranscriptionResult() + if !result.isEmpty { + let needsSpace: Bool + if let before = textDocumentProxy.documentContextBeforeInput, !before.isEmpty { + needsSpace = !before.last!.isWhitespace + } else { + needsSpace = false + } + let textToInsert = needsSpace ? " " + result : result + textDocumentProxy.insertText(textToInsert) + lastInsertedText = textToInsert + canUndo = true + partialText = "" + + state = .success + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + self?.state = .idle + } + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in + self?.canUndo = false + } + } else { + state = .idle + } + case "error": + stopStatusMonitoring() + let msg = sessionManager.errorMessage + state = .error(msg.isEmpty ? "Unknown error" : msg) + partialText = "" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.state = .idle + } + default: + break + } + } +} + +// MARK: - State + +enum KeyboardRecordingState: Equatable { + case idle, needsSession, waitingForSession, recording, processing, success, error(String) +} diff --git a/MiniFlow_iOS/MiniFlowKeyboard/MiniFlowKeyboard.entitlements b/MiniFlow_iOS/MiniFlowKeyboard/MiniFlowKeyboard.entitlements new file mode 100644 index 0000000..618363b --- /dev/null +++ b/MiniFlow_iOS/MiniFlowKeyboard/MiniFlowKeyboard.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.smallestai.MiniFlow + + + diff --git a/MiniFlow_iOS/MiniFlowKeyboard/VoiceInputView.swift b/MiniFlow_iOS/MiniFlowKeyboard/VoiceInputView.swift new file mode 100644 index 0000000..ca0693c --- /dev/null +++ b/MiniFlow_iOS/MiniFlowKeyboard/VoiceInputView.swift @@ -0,0 +1,354 @@ +import SwiftUI + +// MARK: - Theme + +private enum Theme { + private static let softSurface = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 0.22, alpha: 0.84) + : UIColor(white: 1.0, alpha: 0.88) + } + + static let accent = Color(red: 0.18, green: 0.42, blue: 0.37) // MiniFlow teal (#2D6B5E) + static let recording = Color(red: 0.88, green: 0.22, blue: 0.28) + static let success = Color(red: 0.29, green: 0.71, blue: 0.45) + static let surface = Color(uiColor: softSurface) + static let surfaceBorder = Color(uiColor: .separator).opacity(0.16) + static let textPrimary = Color(uiColor: .label) + static let textSecondary = Color(uiColor: .secondaryLabel) +} + +// MARK: - VoiceInputView + +struct VoiceInputView: View { + @ObservedObject var viewModel: KeyboardViewModel + let onSwitchKeyboard: () -> Void + + var body: some View { + VStack(spacing: 10) { + headerRow + centerPanel + bottomControls + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.clear) + } + + // MARK: - Header + + private var headerRow: some View { + HStack(spacing: 8) { + Text("MiniFlow") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(Theme.textPrimary) + + Spacer(minLength: 0) + + statusPill + + Button(action: onSwitchKeyboard) { + Image(systemName: "globe") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 34, height: 34) + .background(Circle().fill(Theme.surface)) + .overlay(Circle().stroke(Theme.surfaceBorder, lineWidth: 1)) + } + } + } + + @ViewBuilder + private var statusPill: some View { + switch viewModel.state { + case .recording: + HStack(spacing: 6) { + PulseDot(color: Theme.recording) + Text("Recording") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Capsule().fill(Theme.recording.opacity(0.2))) + .overlay(Capsule().stroke(Theme.recording.opacity(0.45), lineWidth: 1)) + + case .processing: + HStack(spacing: 6) { + ProgressView().tint(Theme.textPrimary).scaleEffect(0.8) + Text("Processing") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Capsule().fill(Theme.surface)) + .overlay(Capsule().stroke(Theme.surfaceBorder, lineWidth: 1)) + + case .waitingForSession: + HStack(spacing: 6) { + ProgressView().tint(Theme.textPrimary).scaleEffect(0.8) + Text("Opening App") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Capsule().fill(Theme.surface)) + .overlay(Capsule().stroke(Theme.surfaceBorder, lineWidth: 1)) + + case .success: + HStack(spacing: 5) { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + Text("Inserted") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(Theme.textPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Capsule().fill(Theme.success.opacity(0.26))) + .overlay(Capsule().stroke(Theme.success.opacity(0.45), lineWidth: 1)) + + case .error: + HStack(spacing: 5) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11, weight: .semibold)) + Text("Error") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(Theme.textPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Capsule().fill(Theme.recording.opacity(0.2))) + .overlay(Capsule().stroke(Theme.recording.opacity(0.45), lineWidth: 1)) + + default: + EmptyView() + } + } + + // MARK: - Center + + private var centerPanel: some View { + VStack(spacing: 8) { + recordButton + + switch viewModel.state { + case .recording: + if !viewModel.partialText.isEmpty { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + Text(viewModel.partialText) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: true, vertical: false) + Color.clear.frame(width: 1, height: 1).id("end") + } + } + .frame(height: 20) + .onChange(of: viewModel.partialText) { + proxy.scrollTo("end", anchor: .trailing) + } + .onAppear { + proxy.scrollTo("end", anchor: .trailing) + } + } + } else { + RecorderTicker(color: Theme.recording) + .frame(height: 20) + } + case .processing: + ProgressView().tint(Theme.textPrimary).scaleEffect(0.82) + .frame(height: 20) + case .waitingForSession: + Text("Launching MiniFlow") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(height: 20) + case .error(let message): + Text(message) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: 20) + default: + Color.clear.frame(height: 20) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 2) + } + + private var recordButton: some View { + Button(action: viewModel.toggleRecording) { + ZStack { + Circle() + .fill(buttonFill) + .frame(width: 84, height: 84) + .overlay(Circle().stroke(buttonStroke, lineWidth: 1.5)) + .shadow(color: .black.opacity(0.25), radius: 7, y: 3) + + buttonIcon.foregroundStyle(buttonIconColor) + } + } + .buttonStyle(.plain) + .disabled(viewModel.state == .processing || viewModel.state == .waitingForSession) + .opacity(viewModel.state == .processing || viewModel.state == .waitingForSession ? 0.7 : 1) + } + + private var buttonFill: Color { + switch viewModel.state { + case .recording: return Theme.recording + case .processing: return Theme.surface + case .success: return Theme.success.opacity(0.9) + default: return Theme.accent + } + } + + private var buttonStroke: Color { + switch viewModel.state { + case .recording: return Theme.recording.opacity(0.95) + case .processing: return Theme.surfaceBorder + case .success: return Theme.success.opacity(0.95) + default: return Theme.accent.opacity(0.95) + } + } + + private var buttonIconColor: Color { + viewModel.state == .processing ? Theme.textSecondary : .white + } + + @ViewBuilder + private var buttonIcon: some View { + switch viewModel.state { + case .processing: + ProgressView().tint(Theme.textPrimary).scaleEffect(1.05) + case .recording: + RoundedRectangle(cornerRadius: 4).frame(width: 22, height: 22) + case .success: + Image(systemName: "checkmark").font(.system(size: 26, weight: .bold)) + default: + Image(systemName: "mic.fill").font(.system(size: 30, weight: .semibold)) + } + } + + // MARK: - Bottom Controls + + private var bottomControls: some View { + HStack(spacing: 8) { + if viewModel.state == .recording || viewModel.state == .processing { + ControlButton(icon: "xmark", tint: Theme.recording, action: viewModel.cancelRecording) + } else { + ControlButton(icon: "trash", tint: Theme.textPrimary, action: viewModel.clearAll) + } + + ControlButton( + icon: "arrow.uturn.backward", + tint: Theme.textPrimary, + isDisabled: !viewModel.canUndo, + action: viewModel.undoLastInsertion + ) + + ControlButton(icon: "delete.left", tint: Theme.textPrimary, action: viewModel.deleteBackward) + + ControlButton(icon: "return", tint: Theme.textPrimary, action: viewModel.insertReturn) + } + } +} + +// MARK: - Reusable Components + +private struct ControlButton: View { + let icon: String + let tint: Color + var isDisabled: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(tint) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background(RoundedRectangle(cornerRadius: 10).fill(Theme.surface)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Theme.surfaceBorder, lineWidth: 1)) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .opacity(isDisabled ? 0.4 : 1) + } +} + +private struct PulseDot: View { + let color: Color + @State private var isOn = false + + var body: some View { + Circle() + .fill(color) + .frame(width: 8, height: 8) + .scaleEffect(isOn ? 1.1 : 0.85) + .opacity(isOn ? 1 : 0.7) + .onAppear { + withAnimation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true)) { + isOn = true + } + } + } +} + +private struct RecorderTicker: View { + let color: Color + + var body: some View { + TimelineView(.animation(minimumInterval: 0.15, paused: false)) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + HStack(alignment: .center, spacing: 4) { + ForEach(0..<6, id: \.self) { i in + let wave = abs(sin(t * 3.1 + Double(i) * 0.55)) + let height = 6 + (wave * 12) + RoundedRectangle(cornerRadius: 2) + .fill(color) + .frame(width: 4, height: height) + } + } + .frame(height: 20) + } + } +} + +struct RepeatableButton: View { + let action: () -> Void + let label: () -> Label + + @State private var timer: Timer? + + init(action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) { + self.action = action + self.label = label + } + + var body: some View { + label() + .frame(maxWidth: .infinity) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if timer == nil { + action() + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in action() } + } + } + .onEnded { _ in + timer?.invalidate() + timer = nil + } + ) + } +} diff --git a/MiniFlow_iOS/MiniFlowKeyboardInfo.plist b/MiniFlow_iOS/MiniFlowKeyboardInfo.plist new file mode 100644 index 0000000..97f56f8 --- /dev/null +++ b/MiniFlow_iOS/MiniFlowKeyboardInfo.plist @@ -0,0 +1,24 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.keyboard-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).KeyboardViewController + NSExtensionAttributes + + IsASCIICapable + + PrefersRightToLeft + + PrimaryLanguage + en-US + RequestsOpenAccess + + + + + diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.pbxproj b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8c45f9b --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.pbxproj @@ -0,0 +1,739 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 7F486E072F79FB9E00C3555D /* MiniFlowKeyboard.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7F486DCA2F79F7D500C3555D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7F486DB42F79F7D400C3555D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7F486DBB2F79F7D400C3555D; + remoteInfo = MiniFlow_iOS; + }; + 7F486DD42F79F7D500C3555D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7F486DB42F79F7D400C3555D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7F486DBB2F79F7D400C3555D; + remoteInfo = MiniFlow_iOS; + }; + 7F486E052F79FB9E00C3555D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7F486DB42F79F7D400C3555D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7F486DFF2F79FB9E00C3555D; + remoteInfo = MiniFlowKeyboard; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 7F486E082F79FB9E00C3555D /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 7F486E072F79FB9E00C3555D /* MiniFlowKeyboard.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 7F486DBC2F79F7D400C3555D /* MiniFlow_iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MiniFlow_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7F486DC92F79F7D500C3555D /* MiniFlow_iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MiniFlow_iOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7F486DD32F79F7D500C3555D /* MiniFlow_iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MiniFlow_iOSUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MiniFlowKeyboard.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7F486DBE2F79F7D400C3555D /* MiniFlow_iOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MiniFlow_iOS; + sourceTree = ""; + }; + 7F486DCC2F79F7D500C3555D /* MiniFlow_iOSTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MiniFlow_iOSTests; + sourceTree = ""; + }; + 7F486DD62F79F7D500C3555D /* MiniFlow_iOSUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MiniFlow_iOSUITests; + sourceTree = ""; + }; + 7F486E012F79FB9E00C3555D /* MiniFlowKeyboard */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MiniFlowKeyboard; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7F486DB92F79F7D400C3555D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DC62F79F7D500C3555D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DD02F79F7D500C3555D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DFD2F79FB9E00C3555D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7F486DB32F79F7D400C3555D = { + isa = PBXGroup; + children = ( + 7F486DBE2F79F7D400C3555D /* MiniFlow_iOS */, + 7F486DCC2F79F7D500C3555D /* MiniFlow_iOSTests */, + 7F486DD62F79F7D500C3555D /* MiniFlow_iOSUITests */, + 7F486E012F79FB9E00C3555D /* MiniFlowKeyboard */, + 7F486DBD2F79F7D400C3555D /* Products */, + ); + sourceTree = ""; + }; + 7F486DBD2F79F7D400C3555D /* Products */ = { + isa = PBXGroup; + children = ( + 7F486DBC2F79F7D400C3555D /* MiniFlow_iOS.app */, + 7F486DC92F79F7D500C3555D /* MiniFlow_iOSTests.xctest */, + 7F486DD32F79F7D500C3555D /* MiniFlow_iOSUITests.xctest */, + 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7F486DDD2F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOS" */; + buildPhases = ( + 7F486DB82F79F7D400C3555D /* Sources */, + 7F486DB92F79F7D400C3555D /* Frameworks */, + 7F486DBA2F79F7D400C3555D /* Resources */, + 7F486E082F79FB9E00C3555D /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 7F486E062F79FB9E00C3555D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7F486DBE2F79F7D400C3555D /* MiniFlow_iOS */, + ); + name = MiniFlow_iOS; + packageProductDependencies = ( + ); + productName = MiniFlow_iOS; + productReference = 7F486DBC2F79F7D400C3555D /* MiniFlow_iOS.app */; + productType = "com.apple.product-type.application"; + }; + 7F486DC82F79F7D500C3555D /* MiniFlow_iOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7F486DE02F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSTests" */; + buildPhases = ( + 7F486DC52F79F7D500C3555D /* Sources */, + 7F486DC62F79F7D500C3555D /* Frameworks */, + 7F486DC72F79F7D500C3555D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7F486DCB2F79F7D500C3555D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7F486DCC2F79F7D500C3555D /* MiniFlow_iOSTests */, + ); + name = MiniFlow_iOSTests; + packageProductDependencies = ( + ); + productName = MiniFlow_iOSTests; + productReference = 7F486DC92F79F7D500C3555D /* MiniFlow_iOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 7F486DD22F79F7D500C3555D /* MiniFlow_iOSUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7F486DE32F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSUITests" */; + buildPhases = ( + 7F486DCF2F79F7D500C3555D /* Sources */, + 7F486DD02F79F7D500C3555D /* Frameworks */, + 7F486DD12F79F7D500C3555D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7F486DD52F79F7D500C3555D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7F486DD62F79F7D500C3555D /* MiniFlow_iOSUITests */, + ); + name = MiniFlow_iOSUITests; + packageProductDependencies = ( + ); + productName = MiniFlow_iOSUITests; + productReference = 7F486DD32F79F7D500C3555D /* MiniFlow_iOSUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 7F486DFF2F79FB9E00C3555D /* MiniFlowKeyboard */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7F486E0C2F79FB9E00C3555D /* Build configuration list for PBXNativeTarget "MiniFlowKeyboard" */; + buildPhases = ( + 7F486DFC2F79FB9E00C3555D /* Sources */, + 7F486DFD2F79FB9E00C3555D /* Frameworks */, + 7F486DFE2F79FB9E00C3555D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7F486E012F79FB9E00C3555D /* MiniFlowKeyboard */, + ); + name = MiniFlowKeyboard; + packageProductDependencies = ( + ); + productName = MiniFlowKeyboard; + productReference = 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7F486DB42F79F7D400C3555D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 7F486DBB2F79F7D400C3555D = { + CreatedOnToolsVersion = 26.0.1; + }; + 7F486DC82F79F7D500C3555D = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = 7F486DBB2F79F7D400C3555D; + }; + 7F486DD22F79F7D500C3555D = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = 7F486DBB2F79F7D400C3555D; + }; + 7F486DFF2F79FB9E00C3555D = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = 7F486DB72F79F7D400C3555D /* Build configuration list for PBXProject "MiniFlow_iOS" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7F486DB32F79F7D400C3555D; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 7F486DBD2F79F7D400C3555D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */, + 7F486DC82F79F7D500C3555D /* MiniFlow_iOSTests */, + 7F486DD22F79F7D500C3555D /* MiniFlow_iOSUITests */, + 7F486DFF2F79FB9E00C3555D /* MiniFlowKeyboard */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7F486DBA2F79F7D400C3555D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DC72F79F7D500C3555D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DD12F79F7D500C3555D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DFE2F79FB9E00C3555D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7F486DB82F79F7D400C3555D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DC52F79F7D500C3555D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DCF2F79F7D500C3555D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7F486DFC2F79FB9E00C3555D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 7F486DCB2F79F7D500C3555D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */; + targetProxy = 7F486DCA2F79F7D500C3555D /* PBXContainerItemProxy */; + }; + 7F486DD52F79F7D500C3555D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */; + targetProxy = 7F486DD42F79F7D500C3555D /* PBXContainerItemProxy */; + }; + 7F486E062F79FB9E00C3555D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7F486DFF2F79FB9E00C3555D /* MiniFlowKeyboard */; + targetProxy = 7F486E052F79FB9E00C3555D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 7F486DDB2F79F7D500C3555D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7F486DDC2F79F7D500C3555D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7F486DDE2F79F7D500C3555D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = MiniFlow_iOS/MiniFlow_iOS.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UW4BNRV4Y4; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "MiniFlow-iOS-Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone access is required essentially. "; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7F486DDF2F79F7D500C3555D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = MiniFlow_iOS/MiniFlow_iOS.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UW4BNRV4Y4; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "MiniFlow-iOS-Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone access is required essentially. "; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 7F486DE12F79F7D500C3555D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MiniFlow_iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MiniFlow_iOS"; + }; + name = Debug; + }; + 7F486DE22F79F7D500C3555D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MiniFlow_iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MiniFlow_iOS"; + }; + name = Release; + }; + 7F486DE42F79F7D500C3555D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MiniFlow_iOS; + }; + name = Debug; + }; + 7F486DE52F79F7D500C3555D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MiniFlow_iOS; + }; + name = Release; + }; + 7F486E092F79FB9E00C3555D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MiniFlowKeyboard/MiniFlowKeyboard.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UW4BNRV4Y4; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MiniFlowKeyboardInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = MiniFlowKeyboard; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS.MiniFlowKeyboard"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7F486E0A2F79FB9E00C3555D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MiniFlowKeyboard/MiniFlowKeyboard.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UW4BNRV4Y4; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MiniFlowKeyboardInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = MiniFlowKeyboard; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS.MiniFlowKeyboard"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7F486DB72F79F7D400C3555D /* Build configuration list for PBXProject "MiniFlow_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F486DDB2F79F7D500C3555D /* Debug */, + 7F486DDC2F79F7D500C3555D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7F486DDD2F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F486DDE2F79F7D500C3555D /* Debug */, + 7F486DDF2F79F7D500C3555D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7F486DE02F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F486DE12F79F7D500C3555D /* Debug */, + 7F486DE22F79F7D500C3555D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7F486DE32F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F486DE42F79F7D500C3555D /* Debug */, + 7F486DE52F79F7D500C3555D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7F486E0C2F79FB9E00C3555D /* Build configuration list for PBXNativeTarget "MiniFlowKeyboard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7F486E092F79FB9E00C3555D /* Debug */, + 7F486E0A2F79FB9E00C3555D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7F486DB42F79F7D400C3555D /* Project object */; +} diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlowKeyboard.xcscheme b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlowKeyboard.xcscheme new file mode 100644 index 0000000..5fbac96 --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlowKeyboard.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlow_iOS.xcscheme b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlow_iOS.xcscheme new file mode 100644 index 0000000..b55b71a --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlow_iOS.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/Contents.json b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/ContentView.swift b/MiniFlow_iOS/MiniFlow_iOS/ContentView.swift new file mode 100644 index 0000000..a7539fa --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/ContentView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import AVFoundation + +struct ContentView: View { + @EnvironmentObject var flowRecorder: FlowBackgroundRecorder + + @State private var apiKey = "" + @State private var keySaved = false + @State private var micGranted = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + + // Session status card + sessionCard + + // API Key + apiKeySection + + // Keyboard setup instructions + keyboardInstructions + + // Mic permission + micPermissionSection + } + .padding(20) + } + .navigationTitle("MiniFlow") + .background(Color(UIColor.systemGroupedBackground)) + } + .task { + apiKey = KeychainHelper.smallestAPIKey ?? "" + micGranted = AVAudioApplication.shared.recordPermission == .granted + } + } + + // MARK: - Session Card + + private var sessionCard: some View { + VStack(spacing: 16) { + Image(systemName: flowRecorder.isSessionActive ? "waveform.circle.fill" : "waveform.circle") + .font(.system(size: 48)) + .foregroundStyle(flowRecorder.isSessionActive ? .green : .secondary) + + Text(flowRecorder.isSessionActive ? "Session Active" : "Session Inactive") + .font(.headline) + + if flowRecorder.isRecording { + Text("Recording...") + .font(.subheadline) + .foregroundStyle(.red) + } + + Button { + if flowRecorder.isSessionActive { + flowRecorder.endFlowSession() + } else { + flowRecorder.startFlowSession() + } + } label: { + Text(flowRecorder.isSessionActive ? "End Session" : "Start Session") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(flowRecorder.isSessionActive ? Color.red : Color(hex: "2D6B5E")) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding(20) + .background(Color(UIColor.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.05), radius: 4, y: 2) + } + + // MARK: - API Key + + private var apiKeySection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Smallest AI API Key") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + HStack(spacing: 10) { + SecureField("sk_...", text: $apiKey) + .textFieldStyle(.roundedBorder) + .font(.system(size: 14)) + + Button { + KeychainHelper.smallestAPIKey = apiKey + keySaved = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { keySaved = false } + } label: { + Text(keySaved ? "Saved" : "Save") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(apiKey.isEmpty ? Color.gray : Color(hex: "2D6B5E")) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .disabled(apiKey.isEmpty) + } + + Text("Get your key from app.smallest.ai") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .padding(16) + .background(Color(UIColor.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Keyboard Instructions + + private var keyboardInstructions: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Enable Keyboard", systemImage: "keyboard") + .font(.system(size: 15, weight: .semibold)) + + VStack(alignment: .leading, spacing: 8) { + instructionRow(number: "1", text: "Settings → General → Keyboard → Keyboards") + instructionRow(number: "2", text: "Add New Keyboard → MiniFlow") + instructionRow(number: "3", text: "Tap MiniFlow → Enable \"Allow Full Access\"") + instructionRow(number: "4", text: "Switch to MiniFlow keyboard in any app") + } + } + .padding(16) + .background(Color(UIColor.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private func instructionRow(number: String, text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(number) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Color(hex: "2D6B5E")) + .clipShape(Circle()) + Text(text) + .font(.system(size: 13)) + .foregroundStyle(.primary) + } + } + + // MARK: - Mic Permission + + private var micPermissionSection: some View { + HStack { + Image(systemName: micGranted ? "checkmark.circle.fill" : "mic.slash") + .foregroundStyle(micGranted ? .green : .orange) + Text(micGranted ? "Microphone access granted" : "Microphone access needed") + .font(.system(size: 13)) + Spacer() + if !micGranted { + Button("Grant") { + Task { + micGranted = await AVAudioApplication.requestRecordPermission() + } + } + .font(.system(size: 13, weight: .medium)) + } + } + .padding(16) + .background(Color(UIColor.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Color+Hex + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let r, g, b: Double + switch hex.count { + case 6: + r = Double((int >> 16) & 0xFF) / 255 + g = Double((int >> 8) & 0xFF) / 255 + b = Double(int & 0xFF) / 255 + default: + r = 0; g = 0; b = 0 + } + self.init(red: r, green: g, blue: b) + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOS.entitlements b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOS.entitlements new file mode 100644 index 0000000..618363b --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.smallestai.MiniFlow + + + diff --git a/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOSApp.swift b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOSApp.swift new file mode 100644 index 0000000..c8b67fa --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOSApp.swift @@ -0,0 +1,153 @@ +import SwiftUI +import UIKit + +// MARK: - Global State + +enum AppState { + static var sourceAppBundleID: String? + static var pendingReturnToApp = false +} + +// MARK: - Scene Delegate (URL scheme handling) + +class MiniFlowSceneDelegate: NSObject, UIWindowSceneDelegate { + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let context = URLContexts.first else { return } + if let sourceApp = context.options.sourceApplication { + AppState.sourceAppBundleID = sourceApp + } else { + AppState.sourceAppBundleID = Self.getPreviousAppBundleID() + } + handleURL(context.url) + } + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + if let context = connectionOptions.urlContexts.first { + if let sourceApp = context.options.sourceApplication { + AppState.sourceAppBundleID = sourceApp + } else { + AppState.sourceAppBundleID = Self.getPreviousAppBundleID() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.handleURL(context.url) + } + } + } + + func sceneWillEnterForeground(_ scene: UIScene) { + if AppState.pendingReturnToApp { + AppState.pendingReturnToApp = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + Self.returnToSourceApp() + } + } + } + + // MARK: - URL Handling + + private func handleURL(_ url: URL) { + guard url.scheme == "miniflow" else { return } + + if url.host == "startflow" { + FlowBackgroundRecorder.shared.startFlowSession() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Self.returnToSourceApp() + } + } else if url.host == "stopflow" { + FlowBackgroundRecorder.shared.endFlowSession() + } + } + + // MARK: - Source App Detection (private APIs, obfuscated) + + static func getPreviousAppBundleID() -> String? { + // Priority 1: Check UserDefaults (keyboard saved the host bundle ID) + if let defaults = UserDefaults(suiteName: "group.com.smallestai.MiniFlow") { + defaults.synchronize() + if let bundleID = defaults.string(forKey: "flow_return_app_bundle_id"), + !bundleID.isEmpty, + !bundleID.contains("MiniFlow") { + return bundleID + } + } + + // Priority 2: FBSSystemService + let fbsClassName = ["FBS", "System", "Service"].joined() + if let fbsClass = NSClassFromString(fbsClassName), + let service = (fbsClass as AnyObject).perform(NSSelectorFromString("sharedService"))?.takeUnretainedValue() { + for sel in ["previousApplication", "topApplication"] { + let selector = NSSelectorFromString(sel) + if (service as AnyObject).responds(to: selector), + let result = (service as AnyObject).perform(selector)?.takeUnretainedValue(), + let bundleID = result as? String, + !bundleID.contains("MiniFlow") { + return bundleID + } + } + } + + return nil + } + + static func returnToSourceApp() { + if let bundleID = AppState.sourceAppBundleID, !bundleID.isEmpty, + !bundleID.contains("MiniFlow") { + AppState.sourceAppBundleID = nil + openAppWithBundleID(bundleID) + return + } + + if let defaults = UserDefaults(suiteName: "group.com.smallestai.MiniFlow") { + defaults.synchronize() + if let bundleID = defaults.string(forKey: "flow_return_app_bundle_id"), + !bundleID.isEmpty, + !bundleID.contains("MiniFlow") { + defaults.removeObject(forKey: "flow_return_app_bundle_id") + defaults.synchronize() + openAppWithBundleID(bundleID) + return + } + } + } + + static func openAppWithBundleID(_ bundleID: String) { + let className = ["LS", "Application", "Workspace"].joined() + let methodName = ["open", "Application", "With", "BundleID:"].joined() + + guard let workspaceClass = NSClassFromString(className), + workspaceClass.responds(to: NSSelectorFromString("defaultWorkspace")), + let workspace = (workspaceClass as AnyObject).perform(NSSelectorFromString("defaultWorkspace"))?.takeUnretainedValue() + else { return } + + let openSelector = NSSelectorFromString(methodName) + if (workspace as AnyObject).responds(to: openSelector) { + _ = (workspace as AnyObject).perform(openSelector, with: bundleID) + } + } +} + +// MARK: - App Delegate + +class MiniFlowAppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + config.delegateClass = MiniFlowSceneDelegate.self + return config + } +} + +// MARK: - App Entry Point + +@main +struct MiniFlow_iOSApp: App { + @UIApplicationDelegateAdaptor(MiniFlowAppDelegate.self) var appDelegate + @StateObject private var flowRecorder = FlowBackgroundRecorder.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(flowRecorder) + } + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/FlowBackgroundRecorder.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowBackgroundRecorder.swift new file mode 100644 index 0000000..7d8b38f --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowBackgroundRecorder.swift @@ -0,0 +1,292 @@ +import Foundation +@preconcurrency import AVFoundation +import UIKit +import Combine + +/// Background audio recorder that streams PCM chunks to Smallest AI via WebSocket. +@MainActor +class FlowBackgroundRecorder: ObservableObject { + static let shared = FlowBackgroundRecorder() + + @Published var isSessionActive = false + @Published var isRecording = false + + private var audioEngine: AVAudioEngine? + private var inputNode: AVAudioInputNode? + + private let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: 16_000, + channels: 1, + interleaved: true + )! + + private var sttClient: SmallestAIClient? + private var commandMonitorTimer: DispatchSourceTimer? + private var autoStopTimer: DispatchSourceTimer? + private let sessionManager = FlowSessionManager.shared + + private static let maxRecordingSeconds: TimeInterval = 60 + + private init() { + NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.isSessionActive else { return } + try? self.audioEngine?.start() + } + } + } + + // MARK: - Session Lifecycle + + func startFlowSession() { + guard !isSessionActive else { return } + print("[MiniFlow] Starting Flow Session...") + + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .mixWithOthers, .allowBluetoothHFP] + ) + try session.setActive(true) + } catch { + print("[MiniFlow] Failed to configure audio session: \(error)") + return + } + + guard setupAudioEngine() else { + print("[MiniFlow] Failed to setup audio engine") + return + } + + sessionManager.startSession() + isSessionActive = true + startCommandMonitoring() + print("[MiniFlow] Flow session started") + } + + func endFlowSession() { + print("[MiniFlow] Ending Flow Session...") + stopCommandMonitoring() + stopAudioEngine() + + let session = AVAudioSession.sharedInstance() + try? session.setActive(false, options: .notifyOthersOnDeactivation) + + sessionManager.endSession() + isSessionActive = false + isRecording = false + print("[MiniFlow] Flow session ended") + } + + // MARK: - Audio Engine + + private func setupAudioEngine() -> Bool { + let engine = AVAudioEngine() + let input = engine.inputNode + let nativeFormat = input.outputFormat(forBus: 0) + + print("[MiniFlow] Native audio: \(nativeFormat.sampleRate)Hz, \(nativeFormat.channelCount)ch") + + let bufferSize = AVAudioFrameCount(nativeFormat.sampleRate * 0.1) + + input.installTap(onBus: 0, bufferSize: bufferSize, format: nativeFormat) { [weak self] buffer, _ in + let pcm = self?.convertToPCM16(buffer: buffer, from: nativeFormat) + Task { @MainActor [weak self] in + guard let self, self.isRecording, let pcm else { return } + self.sendChunk(pcm) + } + } + + do { + try engine.start() + audioEngine = engine + inputNode = input + print("[MiniFlow] Audio engine started") + return true + } catch { + print("[MiniFlow] Failed to start audio engine: \(error)") + return false + } + } + + private func stopAudioEngine() { + inputNode?.removeTap(onBus: 0) + audioEngine?.stop() + audioEngine = nil + inputNode = nil + } + + // MARK: - PCM Conversion + + private nonisolated func convertToPCM16(buffer: AVAudioPCMBuffer, from srcFormat: AVAudioFormat) -> Data? { + guard let converter = AVAudioConverter(from: srcFormat, to: targetFormat) else { return nil } + let ratio = targetFormat.sampleRate / srcFormat.sampleRate + let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + guard capacity > 0, + let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) + else { return nil } + + var error: NSError? + var consumed = false + converter.convert(to: outBuffer, error: &error) { _, outStatus in + if consumed { + outStatus.pointee = .noDataNow + return nil + } + outStatus.pointee = .haveData + consumed = true + return buffer + } + + guard error == nil, let channelData = outBuffer.int16ChannelData else { return nil } + let byteCount = Int(outBuffer.frameLength) * MemoryLayout.size + return Data(bytes: channelData[0], count: byteCount) + } + + // MARK: - Streaming STT + + private func sendChunk(_ pcm: Data) { + guard let client = sttClient else { return } + Task { await client.sendChunk(pcm) } + } + + // MARK: - Command Monitoring + + private func startCommandMonitoring() { + let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .userInteractive)) + timer.schedule(deadline: .now(), repeating: .milliseconds(100)) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in self?.checkForCommands() } + } + timer.resume() + commandMonitorTimer = timer + } + + private func stopCommandMonitoring() { + commandMonitorTimer?.cancel() + commandMonitorTimer = nil + } + + private func checkForCommands() { + let command = sessionManager.recordingCommand + switch command { + case .start: + if !isRecording { + sessionManager.clearCommand() + startRecording() + } + case .stop: + if isRecording { + sessionManager.clearCommand() + stopRecording() + } + case .cancel: + sessionManager.clearCommand() + cancelRecording() + case .none: + break + } + } + + // MARK: - Recording Control + + private func startRecording() { + guard audioEngine?.isRunning == true else { + sessionManager.recordingStatus = .error + sessionManager.errorMessage = "Session expired. Please restart." + sessionManager.endSession() + isSessionActive = false + return + } + + guard let apiKey = KeychainHelper.smallestAPIKey, !apiKey.isEmpty else { + sessionManager.recordingStatus = .error + sessionManager.errorMessage = "API key not set. Open MiniFlow to add your key." + return + } + + let client = SmallestAIClient { [weak self] partial in + Task { @MainActor [weak self] in + self?.sessionManager.partialTranscript = partial + } + } + sttClient = client + + Task { + do { + try await client.startSession(apiKey: apiKey) + isRecording = true + sessionManager.recordingStatus = .recording + sessionManager.partialTranscript = "" + print("[MiniFlow] Recording started, streaming to Smallest AI") + } catch { + sessionManager.recordingStatus = .error + sessionManager.errorMessage = "Failed to connect: \(error.localizedDescription)" + sttClient = nil + } + } + + let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main) + timer.schedule(deadline: .now() + Self.maxRecordingSeconds) + timer.setEventHandler { [weak self] in + guard let self, self.isRecording else { return } + print("[MiniFlow] Auto-stopping at \(Int(Self.maxRecordingSeconds))s") + self.stopRecording() + } + timer.resume() + autoStopTimer = timer + } + + private func stopRecording() { + autoStopTimer?.cancel() + autoStopTimer = nil + isRecording = false + sessionManager.recordingStatus = .processing + + guard let client = sttClient else { + sessionManager.recordingStatus = .error + sessionManager.errorMessage = "No active STT session" + return + } + + Task { + do { + let transcript = try await client.finalize() + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + sessionManager.recordingStatus = .error + sessionManager.errorMessage = "No speech detected" + } else { + sessionManager.transcriptionResult = trimmed + sessionManager.recordingStatus = .done + } + print("[MiniFlow] Transcription: \(trimmed)") + } catch { + sessionManager.recordingStatus = .error + sessionManager.errorMessage = "Transcription failed" + print("[MiniFlow] Transcription error: \(error)") + } + sttClient = nil + } + } + + private func cancelRecording() { + autoStopTimer?.cancel() + autoStopTimer = nil + isRecording = false + if let client = sttClient { + Task { await client.cancel() } + sttClient = nil + } + sessionManager.recordingStatus = .idle + sessionManager.partialTranscript = "" + print("[MiniFlow] Recording cancelled") + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/FlowSessionManager.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowSessionManager.swift new file mode 100644 index 0000000..12d2285 --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowSessionManager.swift @@ -0,0 +1,153 @@ +import Foundation + +/// IPC keys — used as filenames in the shared App Group container. +enum FlowSessionKeys { + static let isSessionActive = "flow_session_active" + static let sessionHeartbeat = "flow_session_heartbeat" + static let recordingCommand = "flow_recording_command" + static let transcriptionResult = "flow_transcription_result" + static let recordingStatus = "flow_recording_status" + static let errorMessage = "flow_error_message" + static let partialTranscript = "flow_partial_transcript" +} + +enum RecordingCommand: String { + case none, start, stop, cancel +} + +enum FlowRecordingStatus: String { + case idle, recording, processing, done, error +} + +/// File-based IPC through the App Group shared container. +/// UserDefaults is unreliable between app and extension on device, +/// so we read/write small text files instead. +class FlowSessionManager { + static let shared = FlowSessionManager() + + private let appGroupID = "group.com.smallestai.MiniFlow" + private var containerURL: URL? + private var heartbeatTimer: Timer? + + private init() { + containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) + if containerURL == nil { + print("[FlowSession] ERROR: App Group container not accessible: \(appGroupID)") + } else { + print("[FlowSession] Container: \(containerURL!.path)") + } + } + + // MARK: - File helpers + + private func write(_ value: String, forKey key: String) { + guard let url = containerURL?.appendingPathComponent(key) else { return } + try? value.write(to: url, atomically: true, encoding: .utf8) + } + + private func read(forKey key: String) -> String? { + guard let url = containerURL?.appendingPathComponent(key) else { return nil } + return try? String(contentsOf: url, encoding: .utf8) + } + + private func remove(forKey key: String) { + guard let url = containerURL?.appendingPathComponent(key) else { return } + try? FileManager.default.removeItem(at: url) + } + + // MARK: - Heartbeat (Main App Only) + + func startHeartbeat() { + heartbeatTimer?.invalidate() + updateHeartbeat() + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.updateHeartbeat() + } + } + + func stopHeartbeat() { + heartbeatTimer?.invalidate() + heartbeatTimer = nil + } + + private func updateHeartbeat() { + write(String(Date().timeIntervalSince1970), forKey: FlowSessionKeys.sessionHeartbeat) + } + + // MARK: - Session State + + var isSessionActive: Bool { + get { read(forKey: FlowSessionKeys.isSessionActive) == "true" } + set { write(newValue ? "true" : "false", forKey: FlowSessionKeys.isSessionActive) } + } + + func startSession() { + isSessionActive = true + recordingCommand = .none + recordingStatus = .idle + transcriptionResult = "" + errorMessage = "" + partialTranscript = "" + startHeartbeat() + } + + func endSession() { + stopHeartbeat() + isSessionActive = false + recordingCommand = .none + recordingStatus = .idle + } + + // MARK: - Recording Commands (Keyboard -> App) + + var recordingCommand: RecordingCommand { + get { RecordingCommand(rawValue: read(forKey: FlowSessionKeys.recordingCommand) ?? "none") ?? .none } + set { write(newValue.rawValue, forKey: FlowSessionKeys.recordingCommand) } + } + + func requestStartRecording() { recordingCommand = .start } + func requestStopRecording() { recordingCommand = .stop } + func clearCommand() { recordingCommand = .none } + + // MARK: - Recording Status (App -> Keyboard) + + var recordingStatus: FlowRecordingStatus { + get { FlowRecordingStatus(rawValue: read(forKey: FlowSessionKeys.recordingStatus) ?? "idle") ?? .idle } + set { write(newValue.rawValue, forKey: FlowSessionKeys.recordingStatus) } + } + + var transcriptionResult: String { + get { read(forKey: FlowSessionKeys.transcriptionResult) ?? "" } + set { write(newValue, forKey: FlowSessionKeys.transcriptionResult) } + } + + var errorMessage: String { + get { read(forKey: FlowSessionKeys.errorMessage) ?? "" } + set { write(newValue, forKey: FlowSessionKeys.errorMessage) } + } + + var partialTranscript: String { + get { read(forKey: FlowSessionKeys.partialTranscript) ?? "" } + set { write(newValue, forKey: FlowSessionKeys.partialTranscript) } + } + + // MARK: - Return App + + var returnAppBundleID: String? { + get { read(forKey: "flow_return_app_bundle_id") } + set { + if let v = newValue { write(v, forKey: "flow_return_app_bundle_id") } + else { remove(forKey: "flow_return_app_bundle_id") } + } + } + + // MARK: - Convenience + + func consumeTranscriptionResult() -> String { + let result = transcriptionResult + transcriptionResult = "" + partialTranscript = "" + recordingStatus = .idle + return result + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/KeychainHelper.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/KeychainHelper.swift new file mode 100644 index 0000000..1fbed1a --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/Services/KeychainHelper.swift @@ -0,0 +1,63 @@ +import Foundation +import Security + +/// Thin wrapper around the iOS Keychain for storing API keys securely. +enum KeychainHelper { + + static func save(key: String, service: String) -> Bool { + let data = Data(key.utf8) + // Delete any existing item first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess + } + + static func load(service: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(service: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - MiniFlow-specific keys + +extension KeychainHelper { + private static let smallestKeyService = "com.smallestai.MiniFlow.smallest-api-key" + + static var smallestAPIKey: String? { + get { load(service: smallestKeyService) } + set { + if let key = newValue { + _ = save(key: key, service: smallestKeyService) + } else { + delete(service: smallestKeyService) + } + } + } +} diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/SmallestAIClient.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/SmallestAIClient.swift new file mode 100644 index 0000000..ffe1110 --- /dev/null +++ b/MiniFlow_iOS/MiniFlow_iOS/Services/SmallestAIClient.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Direct WebSocket client for Smallest AI Waves real-time speech-to-text. +/// Streams raw 16kHz mono 16-bit PCM audio and receives partial/final transcripts. +actor SmallestAIClient { + + // MARK: - Types + + enum ClientError: LocalizedError { + case noAPIKey + case connectionFailed(String) + case sessionNotActive + + var errorDescription: String? { + switch self { + case .noAPIKey: return "Smallest AI API key not set." + case .connectionFailed(let msg): return "Connection failed: \(msg)" + case .sessionNotActive: return "No active transcription session." + } + } + } + + // MARK: - Properties + + private var webSocketTask: URLSessionWebSocketTask? + private var isActive = false + private var segments: [String] = [] + private var lastText = "" + private var finalizeSent = false + + private var finalContinuation: CheckedContinuation? + + /// Called on the main thread with accumulated transcript text as partials arrive. + nonisolated let onPartial: @Sendable (String) -> Void + + private static let wssURL = "wss://api.smallest.ai/waves/v1/pulse/get_text" + + // MARK: - Init + + init(onPartial: @escaping @Sendable (String) -> Void = { _ in }) { + self.onPartial = onPartial + } + + // MARK: - Session Lifecycle + + /// Opens a WebSocket connection to Smallest AI Waves. + func startSession(apiKey: String, language: String = "en") throws { + guard !apiKey.isEmpty else { throw ClientError.noAPIKey } + + segments = [] + lastText = "" + finalizeSent = false + + var components = URLComponents(string: Self.wssURL)! + components.queryItems = [ + URLQueryItem(name: "language", value: language), + URLQueryItem(name: "encoding", value: "linear16"), + URLQueryItem(name: "sample_rate", value: "16000"), + ] + + var request = URLRequest(url: components.url!) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let session = URLSession(configuration: .default) + let task = session.webSocketTask(with: request) + task.resume() + webSocketTask = task + isActive = true + + // Start receive loop + Task { await receiveLoop() } + + // Start ping keepalive + Task { await pingLoop() } + } + + /// Sends a raw PCM audio chunk (binary frame). + func sendChunk(_ pcm: Data) { + guard isActive, let task = webSocketTask else { return } + task.send(.data(pcm)) { _ in } + } + + /// Signals end of audio and waits for the final transcript. + /// Returns the complete accumulated transcript. + func finalize() async throws -> String { + guard isActive, let task = webSocketTask else { + throw ClientError.sessionNotActive + } + + finalizeSent = true + + // Send finalize signal + let msg = try JSONSerialization.data(withJSONObject: ["type": "finalize"]) + let text = String(data: msg, encoding: .utf8)! + task.send(.string(text)) { _ in } + + // Wait for final transcript with timeout + return try await withCheckedThrowingContinuation { continuation in + self.finalContinuation = continuation + + // 15-second timeout + Task { + try? await Task.sleep(nanoseconds: 15_000_000_000) + if let cont = self.finalContinuation { + self.finalContinuation = nil + let result = self.lastText.isEmpty ? self.joinSegments(self.segments) : self.lastText + cont.resume(returning: result) + self.close() + } + } + } + } + + /// Cancel the session without waiting for a result. + func cancel() { + finalContinuation?.resume(returning: "") + finalContinuation = nil + close() + } + + // MARK: - Private + + /// Join segments: space between them, but skip space if next starts with punctuation. + private func joinSegments(_ segs: [String]) -> String { + let trimmed = segs + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !trimmed.isEmpty else { return "" } + + var result = trimmed[0] + for i in 1..