// Shuo — hold a key, speak in Chinese, English or a mix of both; native English is typed // wherever your cursor is. Nothing but English ever comes out. // // Single-file AppKit menu-bar app for macOS 13+ (Apple silicon and Intel). // Backend: https://en.sinogenomics.com (project p1136 「出地道英文」) — the same account, // the same free quota and the same "no Chinese ever" gate as the website. // // Build: see build.sh (swiftc only, no Xcode project needed). import AppKit import ApplicationServices import AVFoundation import ServiceManagement #if LOCAL_ASR import whisper // whisper.cpp framework, embedded by build.sh when frameworks/whisper.framework exists #endif let APP_VERSION = "0.4.0" let SITE_URL = "https://shuo.sinogenomics.com" // MARK: - Settings enum Hotkey: String, CaseIterable { case rightOption, rightCommand, fn var title: String { switch self { case .rightOption: return "Right ⌥ Option" case .rightCommand: return "Right ⌘ Command" case .fn: return "fn / 🌐 Globe" } } var hint: String { switch self { case .rightOption: return "Hold the right ⌥ Option key and speak" case .rightCommand: return "Hold the right ⌘ Command key and speak" case .fn: return "Hold the fn / 🌐 key and speak" } } var keyCode: UInt16 { switch self { case .rightOption: return 61 case .rightCommand: return 54 case .fn: return 63 } } var flag: NSEvent.ModifierFlags { switch self { case .rightOption: return .option case .rightCommand: return .command case .fn: return .function } } } struct Settings: Codable { var server: String = "https://en.sinogenomics.com" var token: String = "" // signed uid from the server (login token or anonymous trial token) var username: String = "" // empty = not signed in var hotkey: String = Hotkey.rightOption.rawValue var restoreClipboard: Bool = true var showTips: Bool = true var localASR: Bool = true // transcribe on this Mac (whisper.cpp) when the model is installed var showPanel: Bool = true // after each result, show the English in a panel; hover a word for its card init() {} // Tolerant decoding: a missing key keeps its default instead of failing the whole file. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) server = (try? c.decodeIfPresent(String.self, forKey: .server)) ?? server token = (try? c.decodeIfPresent(String.self, forKey: .token)) ?? token username = (try? c.decodeIfPresent(String.self, forKey: .username)) ?? username hotkey = (try? c.decodeIfPresent(String.self, forKey: .hotkey)) ?? hotkey restoreClipboard = (try? c.decodeIfPresent(Bool.self, forKey: .restoreClipboard)) ?? restoreClipboard showTips = (try? c.decodeIfPresent(Bool.self, forKey: .showTips)) ?? showTips localASR = (try? c.decodeIfPresent(Bool.self, forKey: .localASR)) ?? localASR showPanel = (try? c.decodeIfPresent(Bool.self, forKey: .showPanel)) ?? showPanel if server.isEmpty { server = "https://en.sinogenomics.com" } } var hotkeyValue: Hotkey { Hotkey(rawValue: hotkey) ?? .rightOption } var serverURL: URL { URL(string: server) ?? URL(string: "https://en.sinogenomics.com")! } static var dir: URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let dir = base.appendingPathComponent("Shuo", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) return dir } static var fileURL: URL { dir.appendingPathComponent("settings.json") } static func load() -> Settings { if let d = try? Data(contentsOf: fileURL), let s = try? JSONDecoder().decode(Settings.self, from: d) { return s } return Settings() } func save() { guard let d = try? JSONEncoder().encode(self) else { return } try? d.write(to: Settings.fileURL, options: .atomic) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: Settings.fileURL.path) } } // MARK: - Errors (all user-facing text is English) enum ShuoError: Error { case noMic case network(String) case badResponse case asrFailed case tooShort case rateLimited case busy case quota(loggedIn: Bool) case needLogin case rewriteFailed(rephrase: Bool) case loginFailed(String) case localASRFailed(String) var message: String { switch self { case .noMic: return "No microphone is available. Check System Settings › Privacy & Security › Microphone." case .network(let s): return "Can't reach the server (\(s)). Check your connection." case .badResponse: return "The server sent an unexpected reply. Please try again." case .asrFailed: return "I couldn't understand the audio. Please try again." case .tooShort: return "I didn't catch that. Hold the key a little longer and speak." case .rateLimited: return "Too many requests right now. Wait a moment and try again." case .busy: return "The service is busy at the moment. Please try again shortly." case .quota(let loggedIn): return loggedIn ? "You've used today's free rewrites. Upgrade at en.sinogenomics.com to continue." : "Your free trial is used up. Sign in from the menu for 5 free rewrites a day." case .needLogin: return "Please sign in from the menu bar icon." case .rewriteFailed(let rephrase): return rephrase ? "Couldn't produce clean English for that one. Please say it differently." : "The rewrite didn't finish — it wasn't counted. Try again, and say it the way you'd write it to someone (a message, not a quote or a poem)." case .loginFailed(let s): return s case .localASRFailed(let s): return "On-device transcription failed (\(s))." } } } // MARK: - Recorder (16 kHz, mono, 16-bit PCM → WAV) final class Recorder { private var engine: AVAudioEngine? private var converter: AVAudioConverter? private let outFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true)! private var pcm = Data() private let lock = NSLock() private(set) var isRecording = false var onLevel: ((Float) -> Void)? func start() throws { lock.lock(); pcm = Data(); lock.unlock() let eng = AVAudioEngine() let input = eng.inputNode let inFormat = input.outputFormat(forBus: 0) guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else { throw ShuoError.noMic } guard let conv = AVAudioConverter(from: inFormat, to: outFormat) else { throw ShuoError.noMic } converter = conv input.installTap(onBus: 0, bufferSize: 4096, format: inFormat) { [weak self] buffer, _ in self?.consume(buffer) } eng.prepare() do { try eng.start() } catch { input.removeTap(onBus: 0) throw ShuoError.noMic } engine = eng isRecording = true } private func consume(_ buffer: AVAudioPCMBuffer) { guard let conv = converter else { return } let ratio = outFormat.sampleRate / buffer.format.sampleRate let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64 guard let out = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: capacity) else { return } var served = false var convError: NSError? let status = conv.convert(to: out, error: &convError) { _, outStatus in if served { outStatus.pointee = .noDataNow return nil } served = true outStatus.pointee = .haveData return buffer } if status == .error { return } let n = Int(out.frameLength) guard n > 0, let ch = out.int16ChannelData else { return } let bytes = Data(bytes: ch[0], count: n * 2) var sum: Float = 0 for i in 0.. Data { if let eng = engine { eng.inputNode.removeTap(onBus: 0) eng.stop() } engine = nil isRecording = false lock.lock(); let raw = pcm; pcm = Data(); lock.unlock() return Recorder.wav(raw) } var seconds: Double { lock.lock(); defer { lock.unlock() } return Double(pcm.count) / 32000.0 } /// Strips the 44-byte header written by `wav(_:)`. static func pcm(fromWav wav: Data) -> Data { return wav.count > 44 ? wav.subdata(in: 44.. Data { var d = Data() func u32(_ v: UInt32) { var x = v.littleEndian; d.append(Data(bytes: &x, count: 4)) } func u16(_ v: UInt16) { var x = v.littleEndian; d.append(Data(bytes: &x, count: 2)) } d.append(contentsOf: Array("RIFF".utf8)); u32(UInt32(36 + pcm.count)); d.append(contentsOf: Array("WAVE".utf8)) d.append(contentsOf: Array("fmt ".utf8)); u32(16); u16(1); u16(1); u32(16000); u32(32000); u16(2); u16(16) d.append(contentsOf: Array("data".utf8)); u32(UInt32(pcm.count)); d.append(pcm) return d } } // MARK: - On-device speech recognition (whisper.cpp, Metal on Apple silicon) enum LocalEngine: String { case senseVoice = "SenseVoice" // sherpa-onnx, the same model family the server uses; best for mixed Chinese/English case whisper = "Whisper" // whisper.cpp large-v3-turbo, kept as a fallback } final class LocalASR { static let modelName = "ggml-large-v3-turbo-q5_0.bin" static var modelsDir: URL { Settings.dir.appendingPathComponent("models", isDirectory: true) } static var modelURL: URL { modelsDir.appendingPathComponent(modelName) } static var svModelURL: URL { modelsDir.appendingPathComponent("sensevoice", isDirectory: true).appendingPathComponent("model.int8.onnx") } static var svTokensURL: URL { modelsDir.appendingPathComponent("sensevoice", isDirectory: true).appendingPathComponent("tokens.txt") } /// Which on-device engine this build can run with the files present on disk (SenseVoice preferred). static var engine: LocalEngine? { let fm = FileManager.default #if LOCAL_SV if fm.fileExists(atPath: svModelURL.path) && fm.fileExists(atPath: svTokensURL.path) { return .senseVoice } #endif #if LOCAL_ASR if fm.fileExists(atPath: modelURL.path) { return .whisper } #endif return nil } static var available: Bool { engine != nil } private let queue = DispatchQueue(label: "shuo.local-asr", qos: .userInitiated) private var ctx: OpaquePointer? private(set) var loading = false #if LOCAL_SV private var sv: OpaquePointer? // const SherpaOnnxOfflineRecognizer * private var svStrings: [UnsafeMutablePointer] = [] #endif // Biases whisper towards mixed Mandarin/English speech and simplified characters. private let prompt = "以下是普通话和English夹杂的口语记录。比如:我们需要先confirm一下价格,然后再place order,你看OK不?" // Things whisper says when it hears silence or noise. private let hallucinations = ["amara", "字幕", "subtitles", "thank you for watching", "thanks for watching", "点赞", "订阅", "请不吝", "明镜与点点"] /// Loads the model in the background so the first real request is fast. func warmUp() { guard let engine = LocalASR.engine, !loading else { return } loading = true queue.async { [weak self] in switch engine { case .senseVoice: #if LOCAL_SV _ = self?.loadSV() #else break #endif case .whisper: #if LOCAL_ASR _ = self?.load() #else break #endif } DispatchQueue.main.async { self?.loading = false } } } #if LOCAL_SV private func loadSV() -> OpaquePointer? { if let r = sv { return r } func keep(_ s: String) -> UnsafePointer? { let p = strdup(s) if let p = p { svStrings.append(p) } return UnsafePointer(p) } var config = SherpaOnnxOfflineRecognizerConfig() config.feat_config.sample_rate = 16000 config.feat_config.feature_dim = 80 config.model_config.sense_voice.model = keep(LocalASR.svModelURL.path) config.model_config.sense_voice.language = keep("auto") config.model_config.sense_voice.use_itn = 1 config.model_config.tokens = keep(LocalASR.svTokensURL.path) config.model_config.num_threads = Int32(min(4, max(2, ProcessInfo.processInfo.activeProcessorCount))) config.model_config.debug = 0 config.model_config.provider = keep("cpu") config.decoding_method = keep("greedy_search") guard let r = SherpaOnnxCreateOfflineRecognizer(&config) else { return nil } sv = r return r } private func transcribeSV(samples: [Float]) -> Result { guard let r = loadSV() else { return .failure(.localASRFailed("SenseVoice model could not be loaded")) } guard let stream = SherpaOnnxCreateOfflineStream(r) else { return .failure(.localASRFailed("stream")) } defer { SherpaOnnxDestroyOfflineStream(stream) } samples.withUnsafeBufferPointer { buf in SherpaOnnxAcceptWaveformOffline(stream, 16000, buf.baseAddress, Int32(samples.count)) } SherpaOnnxDecodeOfflineStream(r, stream) guard let res = SherpaOnnxGetOfflineStreamResult(stream) else { return .failure(.localASRFailed("no result")) } defer { SherpaOnnxDestroyOfflineRecognizerResult(res) } var text = "" if let t = res.pointee.text { text = String(cString: t) } return .success(text.trimmingCharacters(in: .whitespacesAndNewlines)) } #endif #if LOCAL_ASR private static var logSilenced = false private func load() -> OpaquePointer? { if let c = ctx { return c } if !LocalASR.logSilenced { whisper_log_set({ _, _, _ in }, nil) LocalASR.logSilenced = true } var cp = whisper_context_default_params() cp.use_gpu = true cp.flash_attn = true guard let c = whisper_init_from_file_with_params(LocalASR.modelURL.path, cp) else { return nil } ctx = c return c } #endif /// `pcm` is 16 kHz mono 16-bit little-endian samples (no WAV header). Calls back on the main thread. private static func floatSamples(_ pcm: Data) -> [Float] { let n = pcm.count / 2 var samples = [Float](repeating: 0, count: n) pcm.withUnsafeBytes { raw in let p = raw.bindMemory(to: Int16.self) for i in 0..) -> Void) { let lower = text.lowercased() if text.count < 2 || hallucinations.contains(where: { lower.contains($0) }) { return DispatchQueue.main.async { done(.failure(.tooShort)) } } DispatchQueue.main.async { done(.success(text)) } } func transcribe(pcm: Data, _ done: @escaping (Result) -> Void) { guard let engine = LocalASR.engine else { return DispatchQueue.main.async { done(.failure(.localASRFailed("not installed"))) } } if engine == .senseVoice { #if LOCAL_SV queue.async { [weak self] in guard let self = self else { return } switch self.transcribeSV(samples: LocalASR.floatSamples(pcm)) { case .success(let text): self.finishText(text, done) case .failure(let e): DispatchQueue.main.async { done(.failure(e)) } } } #endif return } #if LOCAL_ASR queue.async { [weak self] in guard let self = self else { return } guard let c = self.load() else { return DispatchQueue.main.async { done(.failure(.localASRFailed("model could not be loaded"))) } } let samples = LocalASR.floatSamples(pcm) let n = samples.count var params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY) params.n_threads = Int32(min(8, max(2, ProcessInfo.processInfo.activeProcessorCount))) params.print_progress = false params.print_realtime = false params.print_timestamps = false params.print_special = false params.no_timestamps = true params.translate = false params.single_segment = false params.suppress_blank = true params.suppress_nst = true params.no_speech_thold = 0.6 let langC = strdup("auto") let promptC = strdup(self.prompt) defer { free(langC); free(promptC) } params.language = UnsafePointer(langC) params.detect_language = false params.initial_prompt = UnsafePointer(promptC) let rc = samples.withUnsafeBufferPointer { buf in whisper_full(c, params, buf.baseAddress, Int32(n)) } if rc != 0 { return DispatchQueue.main.async { done(.failure(.localASRFailed("code \(rc)"))) } } var text = "" let segs = whisper_full_n_segments(c) if segs > 0 { for i in 0.. Void private let onEnd: (Int, Data, Error?) -> Void private var ended = false init(onEvent: @escaping (String, [String: Any]) -> Void, onEnd: @escaping (Int, Data, Error?) -> Void) { self.onEvent = onEvent self.onEnd = onEnd super.init() let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 120 session = URLSession(configuration: cfg, delegate: self, delegateQueue: nil) } func start(_ req: URLRequest) { session.dataTask(with: req).resume() } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 completionHandler(.allow) } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { buffer.append(data) if statusCode == 200 { drain() } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if ended { return } ended = true if statusCode == 200 { drain() if !buffer.isEmpty { let rest = buffer buffer = Data() handle(rest) } } onEnd(statusCode, buffer, error) session.finishTasksAndInvalidate() } private func drain() { let sep = Data([0x0A, 0x0A]) while let r = buffer.range(of: sep) { let chunk = buffer.subdata(in: 0.. URLRequest { let base = settings.server.hasSuffix("/") ? String(settings.server.dropLast()) : settings.server var r = URLRequest(url: URL(string: base + path) ?? settings.serverURL) r.httpMethod = method r.setValue("Shuo/\(APP_VERSION) (macOS)", forHTTPHeaderField: "User-Agent") r.setValue("application/json", forHTTPHeaderField: "Accept") if !settings.token.isEmpty { r.setValue(settings.token, forHTTPHeaderField: "X-IME-UID") } return r } private func json(_ data: Data?) -> [String: Any]? { guard let d = data, let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { return nil } return o } /// Picks the `ime_uid` cookie the server just set (anonymous trial identity or login) so we can /// send it back as a header from now on. private func captureToken() { guard let cookies = HTTPCookieStorage.shared.cookies(for: settings.serverURL) else { return } for c in cookies where c.name == "ime_uid" { let v = c.value.removingPercentEncoding ?? c.value if !v.isEmpty && v != settings.token { settings.token = v settings.save() } } } func clearIdentity() { settings.token = "" settings.username = "" settings.save() if let cookies = HTTPCookieStorage.shared.cookies(for: settings.serverURL) { for c in cookies { HTTPCookieStorage.shared.deleteCookie(c) } } } // GET /api/user/status — also creates an anonymous trial identity when we have none. func status(_ done: @escaping (Result) -> Void) { let req = request("/api/user/status") session.dataTask(with: req) { data, resp, err in DispatchQueue.main.async { if let e = err { return done(.failure(.network(e.localizedDescription))) } let http = resp as? HTTPURLResponse self.captureToken() guard http?.statusCode == 200, let o = self.json(data) else { return done(.failure(.badResponse)) } let st = UserStatus(username: o["username"] as? String, isVip: (o["isVip"] as? Bool) ?? false, remaining: (o["remaining"] as? Int) ?? 0, quotaKind: (o["quotaKind"] as? String) ?? "trial") if let u = st.username, !u.isEmpty, self.settings.username != u { self.settings.username = u self.settings.save() } done(.success(st)) } }.resume() } // POST /api/user/login func login(username: String, password: String, _ done: @escaping (Result) -> Void) { var req = request("/api/user/login", method: "POST") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["username": username, "password": password]) session.dataTask(with: req) { data, resp, err in DispatchQueue.main.async { if let e = err { return done(.failure(.network(e.localizedDescription))) } let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 let o = self.json(data) if code == 429 { return done(.failure(.loginFailed("Too many attempts. Please wait a while and try again."))) } guard code == 200, let ok = o?["ok"] as? Bool, ok, let token = o?["token"] as? String, !token.isEmpty else { return done(.failure(.loginFailed("Wrong username or password."))) } self.settings.token = token self.settings.username = (o?["username"] as? String) ?? username self.settings.save() let s = (o?["status"] as? [String: Any]) ?? [:] done(.success(UserStatus(username: self.settings.username, isVip: (s["isVip"] as? Bool) ?? false, remaining: (s["remaining"] as? Int) ?? 0, quotaKind: (s["quotaKind"] as? String) ?? "daily"))) } }.resume() } // POST /api/transcribe — raw WAV body → { ok, text } func transcribe(wav: Data, _ done: @escaping (Result) -> Void) { var req = request("/api/transcribe", method: "POST") req.setValue("audio/wav", forHTTPHeaderField: "Content-Type") req.httpBody = wav session.dataTask(with: req) { data, resp, err in if let e = err { return done(.failure(.network(e.localizedDescription))) } let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 let o = self.json(data) if code == 429 { return done(.failure(.rateLimited)) } if code == 400 && self.settings.token.isEmpty { return done(.failure(.needLogin)) } guard code == 200, let ok = o?["ok"] as? Bool, ok else { return done(.failure(.asrFailed)) } let text = ((o?["text"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if text.count < 2 { return done(.failure(.tooShort)) } done(.success(text)) }.resume() } // POST /api/word/shuo — English-only word card for a word in a sentence this server produced func wordCard(word: String, sentence: String, sig: String, _ done: @escaping (Result) -> Void) { var req = request("/api/word/shuo", method: "POST") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["w": word, "sentence": sentence, "sig": sig]) session.dataTask(with: req) { data, resp, err in DispatchQueue.main.async { if let e = err { return done(.failure(.network(e.localizedDescription))) } let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 let o = self.json(data) if code == 429 { return done(.failure(.rateLimited)) } if code == 503 { return done(.failure(.busy)) } guard code == 200, let ok = o?["ok"] as? Bool, ok, let o = o else { return done(.failure(.badResponse)) } let strs: (Any?) -> [String] = { v in (v as? [String]) ?? [] } done(.success(WordCard(word: (o["word"] as? String) ?? word, ipaUK: o["ipaUK"] as? String, ipaUS: o["ipaUS"] as? String, ipaApprox: (o["ipaApprox"] as? Bool) ?? false, meaning: (o["meaning"] as? String) ?? "", pronunciation: (o["pronunciation"] as? String) ?? "", synonyms: strs(o["synonyms"]), antonyms: strs(o["antonyms"]), examples: strs(o["examples"]), etymology: (o["etymology"] as? String) ?? ""))) } }.resume() } // POST /api/rewrite — SSE: delta {english} … done {english, alternative, notes} | error {message} func rewrite(text: String, onDelta: @escaping (String) -> Void, _ done: @escaping (Result) -> Void) { var req = request("/api/rewrite", method: "POST") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("text/event-stream", forHTTPHeaderField: "Accept") // faithful: render exactly what was said (message, question, quote, poem…) — never comment, explain or refuse. req.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text, "faithful": true]) var finished = false var result: RewriteResult? var failure: ShuoError? let reader = SSEReader(onEvent: { event, obj in switch event { case "delta": if let en = obj["english"] as? String { onDelta(en) } case "done": let en = (obj["english"] as? String) ?? "" let alt = (obj["alternative"] as? String) ?? "" let notes = (obj["notes"] as? [String]) ?? [] let sig = (obj["sig"] as? String) ?? "" if !en.isEmpty { result = RewriteResult(english: en, alternative: alt, notes: notes, sig: sig) } case "error": let msg = (obj["message"] as? String) ?? "" failure = .rewriteFailed(rephrase: msg.contains("闸门") || msg.contains("拦下")) default: break } }, onEnd: { code, body, err in DispatchQueue.main.async { if finished { return } finished = true self.activeSSE = nil if code == 200 { if let r = result { return done(.success(r)) } return done(.failure(failure ?? .rewriteFailed(rephrase: false))) } if let e = err, code == 0 { return done(.failure(.network(e.localizedDescription))) } let o = self.json(body) let ecode = (o?["code"] as? String) ?? "" switch code { case 402: return done(.failure(.quota(loggedIn: !self.settings.username.isEmpty))) case 429: return done(.failure(.rateLimited)) case 503: return done(.failure(ecode == "BUSY" ? ShuoError.busy : ShuoError.rewriteFailed(rephrase: false))) default: return done(.failure(.rewriteFailed(rephrase: false))) } } }) activeSSE = reader reader.start(req) } } // MARK: - HUD (floating, never takes focus) final class HUD { private let panel: NSPanel private let label = NSTextField(labelWithString: "") private var generation = 0 init() { panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 420, height: 56), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) panel.level = .statusBar panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true panel.ignoresMouseEvents = true panel.hidesOnDeactivate = false panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] let effect = NSVisualEffectView(frame: panel.contentView!.bounds) effect.material = .hudWindow effect.blendingMode = .behindWindow effect.state = .active effect.wantsLayer = true effect.layer?.cornerRadius = 14 effect.layer?.masksToBounds = true effect.autoresizingMask = [.width, .height] panel.contentView?.addSubview(effect) label.font = NSFont.systemFont(ofSize: 15, weight: .medium) label.textColor = .labelColor label.alignment = .center label.lineBreakMode = .byWordWrapping label.maximumNumberOfLines = 4 label.usesSingleLineMode = false label.cell?.wraps = true label.cell?.isScrollable = false label.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 18), label.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -18), label.topAnchor.constraint(equalTo: effect.topAnchor, constant: 14), label.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -14), ]) } /// Shows text and keeps it until the next call (or `hide`). func show(_ text: String) { generation += 1 render(text) } /// Shows text, then hides it after `seconds` unless something else was shown in between. func flash(_ text: String, seconds: Double) { generation += 1 let g = generation render(text) DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in guard let self = self, self.generation == g else { return } self.panel.orderOut(nil) } } func hide() { generation += 1 panel.orderOut(nil) } private func render(_ text: String) { label.stringValue = text let maxWidth: CGFloat = 520 label.preferredMaxLayoutWidth = maxWidth - 36 let size = label.intrinsicContentSize let w = min(maxWidth, max(220, size.width + 36)) let h = max(48, size.height + 28) let screen = NSScreen.main ?? NSScreen.screens.first let vf = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) let x = vf.midX - w / 2 let y = vf.minY + 72 panel.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true) panel.orderFrontRegardless() } } // MARK: - Result panel: the English, with a word card on hover /// Non-editable text view that reports which word the mouse is over (and where it is, in view coordinates). final class HoverTextView: NSTextView { var onHoverWord: ((String, NSRect)?) -> Void = { _ in } private var tracking: NSTrackingArea? override func updateTrackingAreas() { super.updateTrackingAreas() if let t = tracking { removeTrackingArea(t) } let t = NSTrackingArea(rect: bounds, options: [.mouseMoved, .mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self, userInfo: nil) addTrackingArea(t) tracking = t } override func mouseMoved(with event: NSEvent) { let p = convert(event.locationInWindow, from: nil) let ns = string as NSString let n = ns.length guard n > 0 else { return onHoverWord(nil) } var ci = characterIndexForInsertion(at: p) func isWordChar(_ i: Int) -> Bool { guard i >= 0 && i < n else { return false } let c = ns.character(at: i) if c == 39 || c == 0x2019 || c == 45 { return true } // ' ’ - guard let u = Unicode.Scalar(c) else { return false } return CharacterSet.letters.contains(u) } if !isWordChar(ci) { ci -= 1 } guard isWordChar(ci) else { return onHoverWord(nil) } var a = ci, b = ci while isWordChar(a - 1) { a -= 1 } while isWordChar(b + 1) { b += 1 } var range = NSRange(location: a, length: b - a + 1) var word = ns.substring(with: range) // trim stray apostrophes / hyphens at the edges while let f = word.first, "'’-".contains(f) { word.removeFirst(); range.location += 1; range.length -= 1 } while let l = word.last, "'’-".contains(l) { word.removeLast(); range.length -= 1 } guard range.length > 0, word.range(of: "^[A-Za-z][A-Za-z'’-]{0,39}$", options: .regularExpression) != nil, let win = window else { return onHoverWord(nil) } let screenRect = firstRect(forCharacterRange: range, actualRange: nil) let rect = convert(win.convertFromScreen(screenRect), from: nil) guard rect.insetBy(dx: -3, dy: -3).contains(p) else { return onHoverWord(nil) } onHoverWord((word, rect)) } override func mouseExited(with event: NSEvent) { onHoverWord(nil) } override var acceptsFirstResponder: Bool { false } } final class NoKeyPanel: NSPanel { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } } final class ResultPanel { private let panel: NoKeyPanel private let textView = HoverTextView(frame: NSRect(x: 0, y: 0, width: 500, height: 40)) private let caption = NSTextField(labelWithString: "Hover a word for its card") private let popover = NSPopover() private let cardLabel = NSTextField(labelWithString: "") private var textHeight: NSLayoutConstraint! private var sentence = "" private var sig = "" private var hoverWord: String? private var hoverRect = NSRect.zero private var lookupTimer: Timer? private var watchTimer: Timer? private var hideTimer: Timer? private var cache: [String: WordCard] = [:] var fetch: ((String, String, String, @escaping (Result) -> Void) -> Void)? init() { panel = NoKeyPanel(contentRect: NSRect(x: 0, y: 0, width: 560, height: 90), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) panel.level = .floating panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true panel.hidesOnDeactivate = false panel.becomesKeyOnlyIfNeeded = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] let effect = NSVisualEffectView(frame: panel.contentView!.bounds) effect.material = .popover effect.blendingMode = .behindWindow effect.state = .active effect.wantsLayer = true effect.layer?.cornerRadius = 14 effect.layer?.masksToBounds = true effect.autoresizingMask = [.width, .height] panel.contentView?.addSubview(effect) textView.isEditable = false textView.isSelectable = false textView.drawsBackground = false textView.font = NSFont.systemFont(ofSize: 16) textView.textColor = .labelColor textView.textContainerInset = NSSize(width: 4, height: 4) textView.isVerticallyResizable = false textView.isHorizontallyResizable = false textView.textContainer?.lineFragmentPadding = 0 textView.textContainer?.widthTracksTextView = true textView.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(textView) textHeight = textView.heightAnchor.constraint(equalToConstant: 40) textHeight.isActive = true caption.font = NSFont.systemFont(ofSize: 11) caption.textColor = .secondaryLabelColor caption.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(caption) let close = NSButton(title: "✕", target: self, action: #selector(closeClicked)) close.bezelStyle = .inline close.isBordered = false close.font = NSFont.systemFont(ofSize: 12) close.contentTintColor = .secondaryLabelColor close.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(close) NSLayoutConstraint.activate([ textView.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 16), textView.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -16), textView.topAnchor.constraint(equalTo: effect.topAnchor, constant: 12), caption.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 18), caption.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6), caption.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -8), close.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -10), close.centerYAnchor.constraint(equalTo: caption.centerYAnchor), ]) cardLabel.font = NSFont.systemFont(ofSize: 13) cardLabel.lineBreakMode = .byWordWrapping cardLabel.maximumNumberOfLines = 0 cardLabel.usesSingleLineMode = false cardLabel.cell?.wraps = true cardLabel.cell?.isScrollable = false cardLabel.preferredMaxLayoutWidth = 380 cardLabel.translatesAutoresizingMaskIntoConstraints = false let cardView = NSView(frame: NSRect(x: 0, y: 0, width: 412, height: 100)) cardView.addSubview(cardLabel) NSLayoutConstraint.activate([ cardLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), cardLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), cardLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 12), cardLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -12), ]) let vc = NSViewController() vc.view = cardView popover.contentViewController = vc popover.behavior = .applicationDefined popover.animates = false textView.onHoverWord = { [weak self] hit in self?.hovered(hit) } } @objc private func closeClicked() { hide() } var isVisible: Bool { panel.isVisible } func show(english: String, sig: String) { sentence = english self.sig = sig hoverWord = nil popover.performClose(nil) textView.string = english let width: CGFloat = 560 let textWidth = width - 32 - 8 let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 16)] let measured = (english as NSString).boundingRect(with: NSSize(width: textWidth, height: 2000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: attrs) let textHeight = ceil(measured.height) + 8 self.textHeight.constant = textHeight let h = textHeight + 12 + 6 + 16 + 8 let screen = NSScreen.main ?? NSScreen.screens.first let vf = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) panel.setFrame(NSRect(x: vf.midX - width / 2, y: vf.minY + 140, width: width, height: h), display: true) caption.stringValue = sig.isEmpty ? "Copied. (Word cards need a fresh result.)" : "Hover a word for its pronunciation, meaning, examples and origin" panel.orderFrontRegardless() textView.updateTrackingAreas() scheduleHide(after: 40) } func hide() { lookupTimer?.invalidate(); lookupTimer = nil watchTimer?.invalidate(); watchTimer = nil hideTimer?.invalidate(); hideTimer = nil popover.performClose(nil) panel.orderOut(nil) } private func scheduleHide(after s: Double) { hideTimer?.invalidate() hideTimer = Timer.scheduledTimer(withTimeInterval: s, repeats: false) { [weak self] _ in guard let self = self else { return } if self.popover.isShown { self.scheduleHide(after: 20) } else { self.hide() } } } private func hovered(_ hit: (String, NSRect)?) { guard let hit = hit else { hoverWord = nil return } let (word, rect) = hit scheduleHide(after: 40) if word == hoverWord { hoverRect = rect; return } hoverWord = word hoverRect = rect lookupTimer?.invalidate() lookupTimer = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: false) { [weak self] _ in guard let self = self, self.hoverWord == word else { return } self.openCard(for: word, at: rect) } } private func openCard(for word: String, at rect: NSRect) { guard !sig.isEmpty else { return } let key = word.lowercased() if let c = cache[key] { render(c); present(at: rect); return } cardLabel.attributedStringValue = NSAttributedString(string: "Looking up “\(word)”…", attributes: [.font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.secondaryLabelColor]) present(at: rect) fetch?(word, sentence, sig) { [weak self] r in guard let self = self, self.hoverWord?.lowercased() == key else { return } switch r { case .success(let card): self.cache[key] = card self.render(card) case .failure(let e): self.cardLabel.attributedStringValue = NSAttributedString(string: "No card right now — \(e.message)", attributes: [.font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.secondaryLabelColor]) } if self.popover.isShown { self.present(at: self.hoverRect) } } } private func present(at rect: NSRect) { popover.contentSize = NSSize(width: 412, height: max(60, cardLabel.intrinsicContentSize.height + 24)) if popover.isShown { popover.positioningRect = rect } else { popover.show(relativeTo: rect, of: textView, preferredEdge: .maxY) startWatching() } } /// Keeps the card open while the mouse is on the word or inside the card; closes it otherwise. private func startWatching() { watchTimer?.invalidate() watchTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { [weak self] t in guard let self = self, self.popover.isShown else { t.invalidate(); return } let mouse = NSEvent.mouseLocation let wordScreen = self.panel.convertToScreen(self.textView.convert(self.hoverRect, to: nil)).insetBy(dx: -6, dy: -8) let overWord = self.hoverWord != nil && wordScreen.contains(mouse) let overCard = self.popover.contentViewController?.view.window?.frame.insetBy(dx: -4, dy: -4).contains(mouse) ?? false if !overWord && !overCard { self.popover.performClose(nil) self.hoverWord = nil t.invalidate() } } } private func render(_ c: WordCard) { let out = NSMutableAttributedString() let body = NSFont.systemFont(ofSize: 13) let bold = NSFont.boldSystemFont(ofSize: 13) let dim = NSColor.secondaryLabelColor let ink = NSColor.labelColor let add: (String, NSFont, NSColor) -> Void = { t, f, color in out.append(NSAttributedString(string: t, attributes: [.font: f, .foregroundColor: color])) } add(c.word, NSFont.boldSystemFont(ofSize: 17), ink) var ipa: [String] = [] if let uk = c.ipaUK, !uk.isEmpty { ipa.append(uk + " UK") } if let us = c.ipaUS, !us.isEmpty { ipa.append(us + (c.ipaApprox ? " (approx.)" : " US")) } if !ipa.isEmpty { add(" " + ipa.joined(separator: " · "), NSFont.systemFont(ofSize: 14), dim) } if !c.meaning.isEmpty { add("\n\n", body, ink); add("Meaning ", bold, ink); add(c.meaning, body, ink) } if !c.pronunciation.isEmpty { add("\n\n", body, ink); add("Say it ", bold, ink); add(c.pronunciation, body, ink) } if !c.synonyms.isEmpty || !c.antonyms.isEmpty { add("\n\n", body, ink) if !c.synonyms.isEmpty { add("Synonyms ", bold, ink); add(c.synonyms.joined(separator: ", "), body, ink) } if !c.antonyms.isEmpty { add(c.synonyms.isEmpty ? "" : " ", body, ink); add("Antonyms ", bold, ink); add(c.antonyms.joined(separator: ", "), body, ink) } } if !c.examples.isEmpty { add("\n\n", body, ink); add("Examples", bold, ink) for e in c.examples { add("\n• " + e, body, ink) } } if !c.etymology.isEmpty { add("\n\n", body, ink); add("Origin ", bold, ink); add(c.etymology, body, dim) } cardLabel.attributedStringValue = out } } // MARK: - Typing the result into the focused app enum Inserter { static var accessibilityTrusted: Bool { AXIsProcessTrusted() } static func promptAccessibility() { let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String _ = AXIsProcessTrustedWithOptions([key: true] as CFDictionary) } /// Puts `text` on the clipboard and presses ⌘V in the frontmost app. Returns false when we are /// not allowed to press keys (no Accessibility permission); the text is still on the clipboard. static func insert(_ text: String, restoreClipboard: Bool) -> Bool { let pb = NSPasteboard.general let old = pb.string(forType: .string) pb.clearContents() pb.setString(text, forType: .string) let ourChange = pb.changeCount guard accessibilityTrusted else { return false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { pasteKeystroke() if restoreClipboard, let o = old { DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { if pb.changeCount == ourChange { // nobody else touched it meanwhile pb.clearContents() pb.setString(o, forType: .string) } } } } return true } private static func pasteKeystroke() { let src = CGEventSource(stateID: .combinedSessionState) guard let down = CGEvent(keyboardEventSource: src, virtualKey: 9, keyDown: true), let up = CGEvent(keyboardEventSource: src, virtualKey: 9, keyDown: false) else { return } down.flags = .maskCommand up.flags = .maskCommand down.post(tap: .cghidEventTap) up.post(tap: .cghidEventTap) } } // MARK: - App final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let api = API(settings: Settings.load()) private var settings: Settings { get { api.settings } set { api.settings = newValue } } private let recorder = Recorder() private let localASR = LocalASR() private let hud = HUD() private let resultPanel = ResultPanel() private var statusItem: NSStatusItem! private var statusLine: NSMenuItem! private var hintLine: NSMenuItem! private var signInItem: NSMenuItem! private var launchItem: NSMenuItem! private var restoreItem: NSMenuItem! private var tipsItem: NSMenuItem! private var localItem: NSMenuItem! private var panelItem: NSMenuItem! private var hotkeyItems: [Hotkey: NSMenuItem] = [:] private var flagsMonitor: Any? private var keyMonitor: Any? private var trustTimer: Timer? private var pressedAt: Date? private var maxTimer: Timer? private var busy = false private var lastLevelAt = Date.distantPast private var lastResult: RewriteResult? // MARK: lifecycle func applicationDidFinishLaunching(_ notification: Notification) { buildMenu() installMonitors() recorder.onLevel = { [weak self] rms in DispatchQueue.main.async { self?.showLevel(rms) } } resultPanel.fetch = { [weak self] word, sentence, sig, done in self?.api.wordCard(word: word, sentence: sentence, sig: sig, done) } requestPermissions(interactive: false) if settings.localASR { localASR.warmUp() } if Inserter.accessibilityTrusted { hud.flash("👋 Shuo is ready in the menu bar (waveform icon, top right). " + settings.hotkeyValue.hint + ".", seconds: 8) } if !Inserter.accessibilityTrusted { hud.flash("Shuo needs Accessibility (System Settings › Privacy & Security › Accessibility) to hear the push-to-talk key and to type for you.", seconds: 9) // Global key monitors installed before trust was granted may never fire: watch for the grant and re-arm. trustTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] t in guard let self = self else { t.invalidate(); return } if Inserter.accessibilityTrusted { t.invalidate() self.trustTimer = nil self.installMonitors() self.hud.flash("✅ Accessibility granted. " + self.settings.hotkeyValue.hint + ".", seconds: 4) } } } fetchStatus() } private func fetchStatus() { api.status { [weak self] r in if case .success(let s) = r { self?.lastStatus = s } self?.refreshStatusLine() } } // Double-clicking Shuo.app while it is already running: there is no window, so say where we are. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { showStatusDialog() return false } /// The window people expect when they double-click the app: status, the hotkey, and the account buttons. private func showStatusDialog() { NSApp.activate(ignoringOtherApps: true) let a = NSAlert() a.messageText = "Shuo is running in the menu bar (〰 icon, top right)" var info = settings.hotkeyValue.hint + ". Release to finish; press any key to cancel.\n\n" if let st = lastStatus { if st.isVip { info += "Account: \(settings.username) · member, unlimited." } else if settings.username.isEmpty { info += "Not signed in · \(st.remaining) free trial rewrite\(st.remaining == 1 ? "" : "s") left. Sign in for 5 free a day; members are unlimited." } else { info += "Account: \(settings.username) · \(st.remaining) free rewrites left today." } } else { info += settings.username.isEmpty ? "Not signed in." : "Account: \(settings.username)." } if !Inserter.accessibilityTrusted { info += "\n\n⚠️ Accessibility is off, so Shuo can only copy the English for you to paste. System Settings › Privacy & Security › Accessibility → turn on Shuo (remove any old Shuo entry first)." } a.informativeText = info a.addButton(withTitle: settings.username.isEmpty ? "Sign in…" : "Sign out") a.addButton(withTitle: "Open en.sinogenomics.com") a.addButton(withTitle: "OK") let r = a.runModal() if r == .alertFirstButtonReturn { signInOrOut() } else if r == .alertSecondButtonReturn { openSite() } } /// Out of free rewrites: offer the way forward right there, instead of only a HUD line. private func quotaDialog(loggedIn: Bool) { NSApp.activate(ignoringOtherApps: true) let a = NSAlert() a.messageText = loggedIn ? "Today's free rewrites are used up" : "Your free trial is used up" a.informativeText = loggedIn ? "Free accounts get 5 rewrites a day (resets at midnight Beijing time). Members are unlimited: upgrade on en.sinogenomics.com, then keep using Shuo — nothing to re-enter." : "Create a free account on en.sinogenomics.com (it takes a minute), then sign in here for 5 free rewrites a day. Members are unlimited." a.addButton(withTitle: loggedIn ? "Open en.sinogenomics.com" : "Sign in…") a.addButton(withTitle: loggedIn ? "Later" : "Create an account…") if !loggedIn { a.addButton(withTitle: "Later") } let r = a.runModal() if loggedIn { if r == .alertFirstButtonReturn { openSite() } } else { if r == .alertFirstButtonReturn { signInOrOut() } else if r == .alertSecondButtonReturn { openSite() } } } func applicationWillTerminate(_ notification: Notification) { if let m = flagsMonitor { NSEvent.removeMonitor(m) } if let m = keyMonitor { NSEvent.removeMonitor(m) } } // MARK: menu private func buildMenu() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) setIcon(recording: false) let menu = NSMenu() menu.delegate = self let title = NSMenuItem(title: "Shuo — speak, get native English", action: nil, keyEquivalent: "") title.isEnabled = false menu.addItem(title) statusLine = NSMenuItem(title: "Checking account…", action: nil, keyEquivalent: "") statusLine.isEnabled = false menu.addItem(statusLine) menu.addItem(.separator()) hintLine = NSMenuItem(title: settings.hotkeyValue.hint, action: nil, keyEquivalent: "") hintLine.isEnabled = false menu.addItem(hintLine) let howto = NSMenuItem(title: "Release to finish · press any key to cancel", action: nil, keyEquivalent: "") howto.isEnabled = false menu.addItem(howto) let hk = NSMenu() for k in Hotkey.allCases { let it = NSMenuItem(title: k.title, action: #selector(pickHotkey(_:)), keyEquivalent: "") it.representedObject = k.rawValue it.target = self hk.addItem(it) hotkeyItems[k] = it } let hkItem = NSMenuItem(title: "Push-to-talk key", action: nil, keyEquivalent: "") hkItem.submenu = hk menu.addItem(hkItem) let last = NSMenuItem(title: "Copy last result again", action: #selector(copyLast), keyEquivalent: "") last.target = self menu.addItem(last) menu.addItem(.separator()) restoreItem = NSMenuItem(title: "Restore clipboard after typing", action: #selector(toggleRestore), keyEquivalent: "") restoreItem.target = self menu.addItem(restoreItem) tipsItem = NSMenuItem(title: "Show a learning tip after each result", action: #selector(toggleTips), keyEquivalent: "") tipsItem.target = self menu.addItem(tipsItem) panelItem = NSMenuItem(title: "Show result panel with word cards", action: #selector(togglePanel), keyEquivalent: "") panelItem.target = self menu.addItem(panelItem) localItem = NSMenuItem(title: "Transcribe on this Mac (offline)", action: #selector(toggleLocal), keyEquivalent: "") localItem.target = self menu.addItem(localItem) launchItem = NSMenuItem(title: "Launch at login", action: #selector(toggleLaunch), keyEquivalent: "") launchItem.target = self menu.addItem(launchItem) menu.addItem(.separator()) signInItem = NSMenuItem(title: "Sign in…", action: #selector(signInOrOut), keyEquivalent: "") signInItem.target = self menu.addItem(signInItem) let perm = NSMenuItem(title: "Check permissions…", action: #selector(checkPermissions), keyEquivalent: "") perm.target = self menu.addItem(perm) let web = NSMenuItem(title: "Open en.sinogenomics.com", action: #selector(openSite), keyEquivalent: "") web.target = self menu.addItem(web) let help = NSMenuItem(title: "Help & updates (shuo.sinogenomics.com)", action: #selector(openHelp), keyEquivalent: "") help.target = self menu.addItem(help) menu.addItem(.separator()) let quit = NSMenuItem(title: "Quit Shuo", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") menu.addItem(quit) statusItem.menu = menu syncMenuState() } func menuWillOpen(_ menu: NSMenu) { syncMenuState() fetchStatus() } private func syncMenuState() { for (k, it) in hotkeyItems { it.state = (k == settings.hotkeyValue) ? .on : .off } hintLine.title = settings.hotkeyValue.hint restoreItem.state = settings.restoreClipboard ? .on : .off tipsItem.state = settings.showTips ? .on : .off panelItem.state = settings.showPanel ? .on : .off if let eng = LocalASR.engine { localItem.title = "Transcribe on this Mac (offline, \(eng.rawValue))" localItem.isEnabled = true localItem.state = settings.localASR ? .on : .off } else { localItem.title = "Offline transcription not installed — re-run the installer" localItem.isEnabled = false localItem.state = .off } signInItem.title = settings.username.isEmpty ? "Sign in…" : "Sign out (\(settings.username))" if #available(macOS 13.0, *) { launchItem.isHidden = false launchItem.state = SMAppService.mainApp.status == .enabled ? .on : .off } else { launchItem.isHidden = true } refreshStatusLine() } private var lastStatus: UserStatus? private func refreshStatusLine() { guard let s = lastStatus else { statusLine.title = settings.username.isEmpty ? "Not signed in · free trial" : "Signed in as \(settings.username)" return } if s.isVip { statusLine.title = "Signed in as \(settings.username) · member, unlimited" } else if settings.username.isEmpty { statusLine.title = "Not signed in · \(s.remaining) free trial rewrite\(s.remaining == 1 ? "" : "s") left" } else { statusLine.title = "Signed in as \(settings.username) · \(s.remaining) free left today" } } private func setIcon(recording: Bool) { guard let button = statusItem.button else { return } let name = recording ? "record.circle" : "waveform" if let img = NSImage(systemSymbolName: name, accessibilityDescription: "Shuo") { img.isTemplate = true button.image = img } else { button.title = recording ? "●" : "Shuo" } button.contentTintColor = recording ? NSColor.systemRed : nil } // MARK: menu actions @objc private func pickHotkey(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, let k = Hotkey(rawValue: raw) else { return } settings.hotkey = k.rawValue settings.save() syncMenuState() } @objc private func toggleRestore() { settings.restoreClipboard.toggle() settings.save() syncMenuState() } @objc private func toggleTips() { settings.showTips.toggle() settings.save() syncMenuState() } @objc private func togglePanel() { settings.showPanel.toggle() settings.save() if !settings.showPanel { resultPanel.hide() } syncMenuState() } @objc private func toggleLocal() { settings.localASR.toggle() settings.save() if settings.localASR { localASR.warmUp() } syncMenuState() } @objc private func toggleLaunch() { if #available(macOS 13.0, *) { let svc = SMAppService.mainApp do { if svc.status == .enabled { try svc.unregister() } else { try svc.register() } } catch { alert("Couldn't change the login item", "\(error.localizedDescription)\n\nTip: move Shuo.app to /Applications and try again.") } syncMenuState() } } @objc private func copyLast() { guard let r = lastResult else { hud.flash("Nothing to copy yet — hold the key and speak first.", seconds: 3); return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(r.english, forType: .string) hud.flash("📋 Copied: " + r.english, seconds: 3) } @objc private func openSite() { NSWorkspace.shared.open(settings.serverURL) } @objc private func openHelp() { if let u = URL(string: SITE_URL) { NSWorkspace.shared.open(u) } } @objc private func checkPermissions() { requestPermissions(interactive: true) } @objc private func signInOrOut() { if !settings.username.isEmpty { api.clearIdentity() lastStatus = nil fetchStatus() syncMenuState() return } NSApp.activate(ignoringOtherApps: true) let alert = NSAlert() alert.messageText = "Sign in to en.sinogenomics.com" alert.informativeText = "Use the same account as the website. Signed-in users get 5 free rewrites a day; members are unlimited." alert.addButton(withTitle: "Sign in") alert.addButton(withTitle: "Create an account…") alert.addButton(withTitle: "Cancel") let user = NSTextField(frame: NSRect(x: 0, y: 32, width: 280, height: 24)) user.placeholderString = "Username" let pass = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) pass.placeholderString = "Password" let box = NSView(frame: NSRect(x: 0, y: 0, width: 280, height: 56)) box.addSubview(user) box.addSubview(pass) alert.accessoryView = box alert.window.initialFirstResponder = user let r = alert.runModal() if r == .alertSecondButtonReturn { openSite(); return } guard r == .alertFirstButtonReturn else { return } let u = user.stringValue.trimmingCharacters(in: .whitespaces) let p = pass.stringValue guard !u.isEmpty, !p.isEmpty else { return } hud.show("Signing in…") api.login(username: u, password: p) { [weak self] res in DispatchQueue.main.async { guard let self = self else { return } switch res { case .success(let s): self.lastStatus = s self.hud.flash("✅ Signed in as \(s.username ?? u)", seconds: 2.5) case .failure(let e): self.hud.hide() self.alert("Sign-in failed", e.message) } self.syncMenuState() } } } private func alert(_ title: String, _ text: String) { NSApp.activate(ignoringOtherApps: true) let a = NSAlert() a.messageText = title a.informativeText = text a.addButton(withTitle: "OK") a.runModal() } // MARK: permissions private func requestPermissions(interactive: Bool) { let mic = AVCaptureDevice.authorizationStatus(for: .audio) if mic == .notDetermined { AVCaptureDevice.requestAccess(for: .audio) { _ in } } else if mic != .authorized, interactive { if let u = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") { NSWorkspace.shared.open(u) } } if !Inserter.accessibilityTrusted { Inserter.promptAccessibility() if interactive, let u = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { NSWorkspace.shared.open(u) } } else if interactive { let micText = mic == .authorized ? "allowed" : "not allowed" alert("All set", "Microphone: \(micText).\nAccessibility (to type for you): allowed.\n\n\(settings.hotkeyValue.hint).") } } // MARK: hotkey private func installMonitors() { if let m = flagsMonitor { NSEvent.removeMonitor(m) } if let m = keyMonitor { NSEvent.removeMonitor(m) } flagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] ev in self?.handleFlags(ev) } keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] ev in self?.handleKeyDown(ev) } } private func handleFlags(_ ev: NSEvent) { let hk = settings.hotkeyValue guard ev.keyCode == hk.keyCode else { return } let down = ev.modifierFlags.contains(hk.flag) if down { beginRecording() } else { endRecording(cancelled: false) } } private func handleKeyDown(_ ev: NSEvent) { // Any key pressed while we are listening means the user wanted a shortcut (⌥E, ⌘C…) or Esc. if recorder.isRecording { endRecording(cancelled: true) } } private func beginRecording() { guard !recorder.isRecording, !busy else { return } if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { requestPermissions(interactive: false) hud.flash("🎙 Please allow the microphone for Shuo, then try again.", seconds: 4) return } do { try recorder.start() } catch { hud.flash(ShuoError.noMic.message, seconds: 4) return } pressedAt = Date() setIcon(recording: true) resultPanel.hide() hud.show("🎙 Listening…") maxTimer?.invalidate() maxTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: false) { [weak self] _ in self?.endRecording(cancelled: false) } } private func endRecording(cancelled: Bool) { guard recorder.isRecording else { return } maxTimer?.invalidate() maxTimer = nil let wav = recorder.stop() setIcon(recording: false) let held = Date().timeIntervalSince(pressedAt ?? Date()) if cancelled { hud.hide() return } if held < 0.35 || wav.count < 44 + 16000 { // a tap, or under half a second of audio hud.hide() return } process(wav: wav) } private func showLevel(_ rms: Float) { guard recorder.isRecording else { return } let now = Date() guard now.timeIntervalSince(lastLevelAt) > 0.12 else { return } lastLevelAt = now let bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] let level = min(7, Int(rms * 40)) let meter = (0..<8).map { $0 <= level ? bars[$0] : "·" }.joined() let secs = Int(recorder.seconds) hud.show("🎙 Listening \(meter) \(secs)s — release to finish") } // MARK: pipeline: audio → text → native English → typed private func process(wav: Data) { busy = true if settings.localASR && LocalASR.available { hud.show(localASR.loading ? "⏳ Loading the speech model (first time)…" : "⏳ Transcribing on this Mac (\(LocalASR.engine?.rawValue ?? "offline"))…") localASR.transcribe(pcm: Recorder.pcm(fromWav: wav)) { [weak self] res in guard let self = self else { return } switch res { case .success(let text): self.rewrite(text) case .failure(let e): if case .tooShort = e { self.busy = false self.hud.flash("⚠️ " + e.message, seconds: 4) } else { // Model missing or crashed mid-way: the server still works. self.transcribeOnServer(wav: wav, note: "on-device failed, ") } } } } else { transcribeOnServer(wav: wav, note: "") } } private func transcribeOnServer(wav: Data, note: String) { hud.show("⏳ Transcribing (\(note)server)…") api.transcribe(wav: wav) { [weak self] res in DispatchQueue.main.async { guard let self = self else { return } switch res { case .failure(let e): self.busy = false self.hud.flash("⚠️ " + e.message, seconds: 4.5) case .success(let text): self.rewrite(text) } } } } private func rewrite(_ text: String) { hud.show("✍️ Writing native English…") api.rewrite(text: text, onDelta: { [weak self] en in DispatchQueue.main.async { self?.hud.show("✍️ " + AppDelegate.tail(en, 160)) } }) { [weak self] r in DispatchQueue.main.async { self?.finish(r) } } } private func finish(_ r: Result) { busy = false switch r { case .failure(let e): hud.flash("⚠️ " + e.message, seconds: 5) if case .quota(let loggedIn) = e { quotaDialog(loggedIn: loggedIn) } case .success(let result): lastResult = result let typed = Inserter.insert(result.english, restoreClipboard: settings.restoreClipboard) if settings.showPanel { resultPanel.show(english: result.english, sig: result.sig) } if typed { hud.flash("✅ " + result.english, seconds: 3.5) } else { hud.flash("📋 Copied — press ⌘V to paste. Grant Accessibility in System Settings so Shuo can type for you.", seconds: 6) Inserter.promptAccessibility() } if settings.showTips, let tip = result.notes.first, !tip.isEmpty { let delay = typed ? 3.6 : 6.1 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self, !self.busy, !self.recorder.isRecording else { return } self.hud.flash("💡 " + tip, seconds: 7) } } fetchStatus() } } private static func tail(_ s: String, _ n: Int) -> String { if s.count <= n { return s } return "…" + String(s.suffix(n)) } } // MARK: - main let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.accessory) app.run()