merge: bring local uncommitted work onto the cloud-agent branch

Merges the wip/local-snapshot-20260901 safety commit (94 uncommitted files:
in-flight Account/Mac/Tip work, clipboard-semantics improvements, and the
feature-preview recording tooling) into the newer cloud-agent line.

Conflict resolutions:
- docs/*: honour the branch's deliberate removal in eac44da; drop the now
  dangling docs/archive/README.md that only pointed at the deleted files.
- CHANGELOG: both sides describe separate [Unreleased] features; keep both.
- ClipboardSkillSemanticRanker: take the branch's reply routing, which retires
  ranking playfulReply/acceptInvitation/... as separate skills in favour of
  ordinary/formal/playful variants inside one reply action. Keep the local
  link/phone/CJK-density work, which is orthogonal and still referenced.
- OfficialSkillCatalogTests: keep the local "installed exactly once" assertion;
  it matches the test's own comment and does not hardcode the default set.
- ClipboardSkillSemanticRankerTests: keep both sides' disjoint test functions;
  retarget the two link/phone cases at the branch's row, which no longer
  surfaces the retired specialised reply skills.

Verified: xcodebuild build succeeds and the 77 ranker/catalog/analyzer tests
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 12:34:23 -07:00
co-authored by Claude Opus 5
79 changed files with 3459 additions and 1178 deletions
@@ -16,6 +16,8 @@ public struct UsageStatCard: View {
public var accent: Bool
/// Hero metric: wide horizontal layout for the primary word count.
public var prominent: Bool
/// Stretch to the container's height (split layouts aligning a grid with a taller sibling).
public var expands: Bool
public init(
title: String,
@@ -23,7 +25,8 @@ public struct UsageStatCard: View {
caption: String,
systemImage: String? = nil,
accent: Bool = false,
prominent: Bool = false
prominent: Bool = false,
expands: Bool = false
) {
self.title = title
self.value = value
@@ -31,6 +34,7 @@ public struct UsageStatCard: View {
self.systemImage = systemImage
self.accent = accent
self.prominent = prominent
self.expands = expands
}
public var body: some View {
@@ -69,7 +73,11 @@ public struct UsageStatCard: View {
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(
maxWidth: .infinity,
maxHeight: expands ? .infinity : nil,
alignment: .topLeading
)
}
/// Wide "hero bar": icon badge + title/caption left, big number right.
@@ -304,8 +304,12 @@ public actor ClipboardSemanticAnalyzer {
private static let resourceDirectory = "ClipboardSemantics"
private static let manifestName = "clipboard-semantic-models"
private static let maximumSemanticSegments = 8
private static let maximumSegmentCharacters = 500
// Segment length tracks the training corpus, whose records are single
// sentences (zh max 33 characters, en max 138). Feeding a whole multi-
// sentence paste as one blob puts the classifiers far out of distribution
// and dilutes bag-of-words confidence below their routing thresholds.
private static let maximumSemanticSegments = 24
private static let maximumSegmentCharacters = 180
private static let minimumSentimentConfidence = 0.65
private static let minimumSentimentMargin = 0.15
@@ -923,9 +927,6 @@ public actor ClipboardSemanticAnalyzer {
}
private func semanticSegments(in text: String) -> [String] {
if text.count <= Self.maximumSegmentCharacters {
return [text]
}
let tokenizer = NLTokenizer(unit: .sentence)
tokenizer.string = text
var segments: [String] = []
@@ -9,8 +9,31 @@ import Combine
import Foundation
public enum ClipboardSkillSemanticRanker {
/// Calibrated against Latin script. `effectiveLength` scales dense scripts
/// up to the same information density before comparing.
private static let longTextLengthThreshold = 360.0
/// Branch-side raw character gate. Kept alongside the density-aware
/// `longTextLengthThreshold` because the two are used by different
/// call sites: intent routing counts characters, while the summary and
/// list heuristics weight CJK density via `effectiveLength`.
private static let longTextCharacterThreshold = 360
private static let listLineLengthThreshold = 48.0
private static let denseScriptCharacterWeight = 2.25
/// A bare link or phone paste keeps at most a short label ("详情:",
/// "Contact:"). Anything longer means the entity is embedded in a real
/// message, which must keep its own skills.
private static let entityResidualLengthLimit = 12
private static let languageConfidenceThreshold = 0.75
/// Characters permitted in a URL, used to carve the link out of text with no
/// whitespace around it (`详见https://example.com`). Deliberately spelled out
/// in ASCII: `CharacterSet.alphanumerics` also matches CJK ideographs, which
/// would swallow the surrounding message.
private static let urlCharacters = CharacterSet(
charactersIn: "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789"
+ "-._~:/?#[]@!$&'()*+,;=%"
)
public static func ranked(
skills: [AIClipboardSkill],
@@ -87,18 +110,28 @@ public enum ClipboardSkillSemanticRanker {
scores[id, default: 0] += value
}
// A bare link or phone paste has no other intent to serve, so it stays
// exclusive. One embedded in a message must not suppress the skills the
// message itself earns — scoring continues at a weight that still ranks
// the direct action high without outranking a strong message intent.
if let webURL = analysis.singleWebURL {
boost(AIClipboardSkillCatalog.openLinkID, 320)
let isBarePaste = isWebLinkDominant(sourceText, url: webURL)
boost(AIClipboardSkillCatalog.openLinkID, isBarePaste ? 320 : 200)
if webURL.scheme?.lowercased() == "https" {
boost(AIClipboardSkillCatalog.summarizeWebPageID, 310)
boost(AIClipboardSkillCatalog.summarizeWebPageID, isBarePaste ? 310 : 150)
}
if isBarePaste {
return scores
}
return scores
}
if analysis.singlePhoneNumber != nil {
boost(AIClipboardSkillCatalog.callPhoneID, 320)
boost(AIClipboardSkillCatalog.createContactID, 310)
return scores
let isBarePaste = isPhoneNumberDominant(sourceText)
boost(AIClipboardSkillCatalog.callPhoneID, isBarePaste ? 320 : 200)
boost(AIClipboardSkillCatalog.createContactID, isBarePaste ? 310 : 150)
if isBarePaste {
return scores
}
}
if isLanguageMismatch(
@@ -179,7 +212,7 @@ public enum ClipboardSkillSemanticRanker {
boost(AIClipboardSkillCatalog.summarizeID, 45)
}
if sourceText.count >= longTextCharacterThreshold {
if effectiveLength(sourceText) >= longTextLengthThreshold {
boost(AIClipboardSkillCatalog.summarizeID, 145)
boost(AIClipboardSkillCatalog.saveToNotesID, 85)
}
@@ -333,8 +366,71 @@ public enum ClipboardSkillSemanticRanker {
if markedCount * 2 >= lines.count {
return true
}
let averageLength = lines.reduce(0) { $0 + $1.count } / lines.count
return lines.count >= 3 && averageLength <= 48
let averageLength = lines.reduce(0.0) {
$0 + effectiveLength($1)
} / Double(lines.count)
return lines.count >= 3 && averageLength <= listLineLengthThreshold
}
/// CJK characters carry roughly twice the information of a Latin character,
/// so a raw `count` makes every length threshold fire about twice too late
/// in Chinese and too early in English. Weight dense scripts instead.
private static func effectiveLength(_ text: String) -> Double {
text.reduce(into: 0.0) { total, character in
total += isDenseScriptCharacter(character) ? denseScriptCharacterWeight : 1
}
}
private static func isDenseScriptCharacter(_ character: Character) -> Bool {
guard let scalar = character.unicodeScalars.first else { return false }
switch scalar.value {
case 0x3040...0x30FF, // kana
0x3400...0x4DBF, // CJK unified ideographs extension A
0x4E00...0x9FFF, // CJK unified ideographs
0xAC00...0xD7AF, // hangul syllables
0xF900...0xFAFF, // CJK compatibility ideographs
0x20000...0x2FA1F: // CJK unified ideographs extensions B+
return true
default:
return false
}
}
private static func isWebLinkDominant(_ text: String, url: URL) -> Bool {
guard let host = url.host, !host.isEmpty else { return false }
guard let hostRange = text.range(of: host, options: [.caseInsensitive]) else {
return significantCharacterCount(text) <= entityResidualLengthLimit
}
var start = hostRange.lowerBound
while start > text.startIndex {
let previous = text.index(before: start)
guard isURLCharacter(text[previous]) else { break }
start = previous
}
var end = hostRange.upperBound
while end < text.endIndex, isURLCharacter(text[end]) {
end = text.index(after: end)
}
var residual = text
residual.removeSubrange(start..<end)
return significantCharacterCount(residual) <= entityResidualLengthLimit
}
/// Digits belong to the number itself, so only letters count as residual.
private static func isPhoneNumberDominant(_ text: String) -> Bool {
text.reduce(into: 0) { count, character in
if character.isLetter { count += 1 }
} <= entityResidualLengthLimit
}
private static func significantCharacterCount(_ text: String) -> Int {
text.reduce(into: 0) { count, character in
if character.isLetter || character.isNumber { count += 1 }
}
}
private static func isURLCharacter(_ character: Character) -> Bool {
character.unicodeScalars.allSatisfy { urlCharacters.contains($0) }
}
private static func isMarkedListLine(_ line: String) -> Bool {
@@ -23,22 +23,27 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload
private let destinationURL: URL
private let onProgress: @Sendable (LocalASRDownloadProgressUpdate) -> Void
private let maxRetries: Int
private lazy var delegateSession: URLSession = {
let config = URLSessionConfiguration.default
// Big weight files over flaky links: allow long total transfers but
// fail (and retry) a stalled connection that goes quiet for a while.
config.timeoutIntervalForRequest = 90
config.timeoutIntervalForResource = 24 * 60 * 60
config.waitsForConnectivity = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
private var delegateSession: URLSession!
// Every field below is reachable from BOTH the caller's thread
// (`download` / `pause` / `resumeFromPause` / `cancel`) and URLSession's
// delegate queue (`delegateQueue: nil` = a private serial background
// queue), so all of them are guarded by `lock`. That is what makes the
// `@unchecked Sendable` above true rather than aspirational: an unguarded
// check-then-act on `finished` let two threads each resume
// `completionContinuation`, and a checked continuation resumed twice is a
// hard crash, not a recoverable error.
private let lock = NSLock()
private var remoteURL: URL?
private var task: URLSessionDownloadTask?
private var completionContinuation: CheckedContinuation<Void, Error>?
private var isPausing = false
private var finished = false
private var retryCount = 0
/// Set by `cancel()` before it invalidates the session. `startTask` refuses
/// to build a task once this is set: `URLSession.downloadTask` on an
/// invalidated session raises an ObjC exception, which Swift cannot catch.
private var sessionInvalidated = false
init(
destinationURL: URL,
@@ -49,57 +54,145 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload
self.maxRetries = maxRetries
self.onProgress = onProgress
super.init()
let config = URLSessionConfiguration.default
// Big weight files over flaky links: allow long total transfers but
// fail (and retry) a stalled connection that goes quiet for a while.
config.timeoutIntervalForRequest = 90
config.timeoutIntervalForResource = 24 * 60 * 60
config.waitsForConnectivity = true
// Built here rather than in a `lazy var`: lazy initialization is not
// atomic, and this session is first touched from whichever thread wins
// the `startTask` / `cancel` race.
delegateSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
}
/// Atomically takes ownership of the pending completion continuation and
/// marks the download finished. Returns `nil` when another thread already
/// claimed it — the single guarantee that `download(from:)` is resumed
/// exactly once, no matter how cancel / completion / failure interleave.
private func claimCompletion() -> CheckedContinuation<Void, Error>? {
lock.withLock { () -> CheckedContinuation<Void, Error>? in
guard !finished else { return nil }
finished = true
let pending = completionContinuation
completionContinuation = nil
return pending
}
}
/// Runs until the archive is fully written to `destinationURL` (survives pause/resume).
public func download(from remoteURL: URL) async throws {
self.remoteURL = remoteURL
retryCount = 0
// A controller is single-use — `LocalASRModelDownloadClient.makeController`
// builds a fresh one per download. `finished` is deliberately NOT reset
// here: doing so would resurrect a controller already settled by
// `cancel()`, and `startTask` would then hand work to a session that
// `cancel()` had invalidated (an uncatchable ObjC exception).
lock.withLock {
self.remoteURL = remoteURL
retryCount = 0
}
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
completionContinuation = continuation
// `cancel()` can land between the setup above and here; parking a
// continuation nobody will ever resume would hang the caller.
let alreadyCancelled = lock.withLock { () -> Bool in
guard !finished else { return true }
completionContinuation = continuation
return false
}
guard !alreadyCancelled else {
continuation.resume(throwing: CancellationError())
return
}
startTask(resumeData: nil)
}
}
public func pause() async throws -> Data {
guard task != nil, !finished else {
let pending = lock.withLock { () -> URLSessionDownloadTask? in
guard let running = task, !finished, !isPausing else { return nil }
isPausing = true
return running
}
guard let pending else {
throw LocalASRModelManagerError.downloadFailed("No active download to pause.")
}
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
isPausing = true
task?.cancel(byProducingResumeData: { [weak self] data in
guard let self else { return }
self.isPausing = false
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: LocalASRModelManagerError.downloadFailed("Pause failed."))
pending.cancel(byProducingResumeData: { [weak self] data in
guard let self else {
continuation.resume(
throwing: LocalASRModelManagerError.downloadFailed("Pause failed.")
)
return
}
if let data {
// `isPausing` deliberately stays set until `resumeFromPause`
// / `cancel` clears it. Cancelling for resume data also
// fires `didCompleteWithError`, and that callback can land
// either side of this one — clearing the flag here (as this
// used to) let the cancellation be misread as a permanent
// failure, tearing down a download the user only paused.
continuation.resume(returning: data)
return
}
// No resume data: the task is dead and cannot be continued, and
// the `didCompleteWithError` that would normally report it was
// suppressed by `isPausing`. Settle `download(from:)` here so
// it cannot hang waiting for a callback that already passed.
self.lock.withLock { self.isPausing = false }
let failure = LocalASRModelManagerError.downloadFailed("Pause failed.")
self.claimCompletion()?.resume(throwing: failure)
continuation.resume(throwing: failure)
})
}
}
/// Continues a paused download; `download(from:)` must still be awaiting.
public func resumeFromPause(_ resumeData: Data) {
finished = false
// Only a genuinely paused download may continue. `finished` is NOT
// reset: a completed or cancelled download must never be revived, and
// pausing no longer sets it in the first place.
let canResume = lock.withLock { () -> Bool in
guard isPausing, !finished else { return false }
isPausing = false
return true
}
guard canResume else { return }
startTask(resumeData: resumeData)
}
public func cancel() {
finished = true
task?.cancel()
completionContinuation?.resume(throwing: CancellationError())
completionContinuation = nil
let pending = lock.withLock { () -> URLSessionDownloadTask? in
isPausing = false
// Flagged under the same lock `startTask` takes, so the two are
// totally ordered: either the task is built first (and cancelled
// just below), or `startTask` sees this and builds nothing.
sessionInvalidated = true
return task
}
// Claim before cancelling the task: the cancellation then arrives at
// `didCompleteWithError` already settled, so it cannot re-report the
// download as a generic network failure.
claimCompletion()?.resume(throwing: CancellationError())
pending?.cancel()
delegateSession.invalidateAndCancel()
}
private func startTask(resumeData: Data?) {
if let resumeData {
task = delegateSession.downloadTask(withResumeData: resumeData)
} else if let remoteURL {
task = delegateSession.downloadTask(with: remoteURL)
let started = lock.withLock { () -> URLSessionDownloadTask? in
guard !sessionInvalidated else { return nil }
let created: URLSessionDownloadTask?
if let resumeData {
created = delegateSession.downloadTask(withResumeData: resumeData)
} else if let remoteURL {
created = delegateSession.downloadTask(with: remoteURL)
} else {
created = nil
}
task = created
return created
}
task?.resume()
started?.resume()
}
// MARK: - URLSessionDownloadDelegate
@@ -124,48 +217,56 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
guard !finished else { return }
finished = true
// Claim first: a cancelled download must not have its archive moved
// into place, and claiming is what stops the `didCompleteWithError`
// that follows every finished download from resuming us a second time.
guard let pending = claimCompletion() else { return }
do {
let fm = FileManager.default
if fm.fileExists(atPath: destinationURL.path) {
try fm.removeItem(at: destinationURL)
}
try fm.moveItem(at: location, to: destinationURL)
completionContinuation?.resume()
pending.resume()
} catch {
completionContinuation?.resume(
pending.resume(
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
)
}
completionContinuation = nil
session.finishTasksAndInvalidate()
}
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard !finished else { return }
if isPausing { return }
// `isPausing` means the cancellation below is the one *we* asked for in
// `pause()`; the download is not over and must keep its continuation.
let (suppressed, shouldRetry, delay) = lock.withLock { () -> (Bool, Bool, Double) in
let suppressed = finished || isPausing
guard let error, !suppressed, Self.isRetryable(error), retryCount < maxRetries else {
return (suppressed, false, 0)
}
retryCount += 1
return (suppressed, true, Self.backoffSeconds(attempt: retryCount))
}
if suppressed { return }
guard let error else { return }
// Transient network drop: resume from where we stopped (if the server
// handed back resume data) after a short exponential backoff, up to a cap.
if Self.isRetryable(error), retryCount < maxRetries {
retryCount += 1
if shouldRetry {
let resumeData = (error as NSError)
.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
let delay = Self.backoffSeconds(attempt: retryCount)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, !self.finished, !self.isPausing else { return }
guard let self else { return }
let stillActive = self.lock.withLock { !self.finished && !self.isPausing }
guard stillActive else { return }
self.startTask(resumeData: resumeData)
}
return
}
finished = true
completionContinuation?.resume(
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
)
completionContinuation = nil
guard let pending = claimCompletion() else { return }
pending.resume(throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription))
session.finishTasksAndInvalidate()
}
@@ -1,17 +0,0 @@
// TipProduct.swift
// OSGKeyboard · Shared
//
// StoreKit product identifiers for optional voluntary tips.
// Tips are consumable IAP — they do not unlock features.
import Foundation
public enum TipProduct {
/// ¥28 voluntary support tip (Consumable). Must match App Store Connect Product ID.
public static let supportID = "ByRockyACoffee"
public static var allProductIDs: [String] { [supportID] }
/// UserDefaults key for optional UX: how many times the user tipped (not entitlements).
public static let supportCountDefaultsKey = "tipSupportPurchaseCount"
}
+19 -1
View File
@@ -204,6 +204,15 @@ public final class EnglishLexicon: @unchecked Sendable {
guard let group = lookupBigramGroup(prevIndex: prevIndex, header: header, buf: buf) else {
return []
}
// `firstNext` / `nextCount` come straight off the mapped file; a
// group that runs past the next-word table is corrupt, and reading
// it would be an out-of-bounds `loadUnaligned`.
guard group.firstNext >= 0,
group.nextCount >= 0,
group.firstNext + group.nextCount <= header.bigramNextCapacity
else {
return []
}
let count = min(limit, group.nextCount)
var words: [String] = []
words.reserveCapacity(count)
@@ -231,6 +240,11 @@ public final class EnglishLexicon: @unchecked Sendable {
var bigramNextOffset: Int
var fileSize: Int
/// How many 2-byte entries fit between `bigramNextOffset` and EOF. The
/// header carries no length for this table, so reads are bounded
/// against the file tail rather than a declared count.
var bigramNextCapacity: Int { (fileSize - bigramNextOffset) / 2 }
static let magic = "OSGENG01"
static let version = 1
static let headerSize = 64
@@ -260,7 +274,11 @@ public final class EnglishLexicon: @unchecked Sendable {
region(freqRankOffset, unigramCount * 2, in: fileSize),
region(initialOffset, initialCount * 4, in: fileSize),
region(bigramIndexOffset, bigramGroupCount * 8, in: fileSize),
region(stringPoolOffset, stringPoolSize, in: fileSize)
region(stringPoolOffset, stringPoolSize, in: fileSize),
// The next-word table has no length field, so only its
// base can be validated here; per-group reads are bounded
// against `bigramNextCapacity` at the call site.
region(bigramNextOffset, 0, in: fileSize)
else {
return nil
}
@@ -159,6 +159,12 @@ public enum EnglishQWERTYProximity: Sendable {
) -> Int {
let aCount = source.count
let bCount = target.count
// `1...count` traps on an empty operand. Current callers filter empties
// out via the `delta <= 2` gate, but this is a public entry point — an
// empty word has no proximity cost, so fall back to pure insert/delete.
guard aCount > 0, bCount > 0 else {
return max(aCount, bCount) * insDelCost
}
var previous = Array(0...bCount).map { $0 * insDelCost }
var older = previous
for i in 1...aCount {
+59 -17
View File
@@ -143,7 +143,6 @@
"ai.responseLength.medium" = "Medium";
"ai.responseLength.detailed" = "Detailed";
/* v0.3.0: Detected app context labels */
"appContext.code" = "Code";
"appContext.email" = "Email";
@@ -353,6 +352,7 @@
"mac.hint.hold.leftOption" = "Hold left Option (⌥) to dictate";
"mac.hint.hold.eitherOption" = "Hold either Option (⌥) to dictate";
"mac.hint.holdOption" = "Hold Option to dictate";
"mac.hint.holdOption.compact" = "Hold Option";
"mac.settings.qwen3Model" = "Qwen3 model folder";
"mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt.";
"mac.settings.qwen3Browse" = "Choose folder…";
@@ -392,9 +392,12 @@
"mac.onboarding.engine.subtitle" = "Start locally for privacy, or use your own cloud API for provider-based recognition and polish.";
"mac.onboarding.engine.localDesc" = "Private on-device recognition with Sherpa models. Falls back to Apple Speech until a model is installed.";
"mac.onboarding.engine.cloudDesc" = "Use your API key for cloud speech recognition and AI polishing.";
"mac.onboarding.cloud.title" = "Configure API access";
"mac.onboarding.cloud.subtitle" = "Pick a provider and add your key. This step is optional, so you can finish now and fill it later in Settings.";
"mac.onboarding.cloud.title" = "Configure speech recognition";
"mac.onboarding.cloud.subtitle" = "Pick a cloud speech provider and add your key. This step is optional, so you can finish now and fill it later in Settings.";
"mac.onboarding.cloud.skipHint" = "You can leave these blank and finish setup; cloud mode will ask for a key before use.";
"mac.onboarding.polish.title" = "Configure polishing";
"mac.onboarding.polish.subtitle" = "Pick the model provider used for polishing, translation and AI answers. Optional — you can fill it in later in Settings.";
"mac.onboarding.polish.skipHint" = "You can leave these blank and finish setup; polishing will ask for a key before use.";
"mac.onboarding.model.title" = "Prepare local recognition";
"mac.onboarding.model.subtitle" = "Download the basic Sherpa runtime and default model now, or skip and continue with Apple Speech fallback.";
"mac.onboarding.model.download" = "Download Default Model";
@@ -467,17 +470,56 @@
"settings.appLanguage.english" = "English";
"settings.appLanguage.chinese" = "Chinese";
/* Voluntary support tip (Consumable IAP — no feature unlock) */
"tip.title" = "Support the Developer";
"tip.body" = "OSGKeyboard is completely free with every feature unlocked. If you find it useful, you can leave an optional tip — it does not unlock anything extra.";
"tip.button" = "Tip %@";
"tip.buttonFallback" = "Send a Tip";
"tip.consumableNotice" = "Consumable in-app purchase. Tips cannot be restored (Apple policy).";
"tip.thankYou.title" = "Thank you!";
"tip.thankYou.message" = "Your support helps keep OSGKeyboard free and open for everyone.";
"tip.thankYou.past" = "You've supported us %lld time(s). Thank you!";
"tip.error.title" = "Purchase Failed";
"tip.error.productUnavailable" = "The tip product is unavailable right now. Try again later.";
"tip.error.pending" = "Your purchase is pending approval.";
"tip.error.unknown" = "The purchase could not be completed.";
"tip.alert.dismiss" = "OK";
/* Account — Mac account page (Sign in with Apple, credits, invitation link).
The account.* / account.referral.* keys below mirror the iOS app bundle so
the shared AccountSessionCoordinator error keys resolve on macOS too. */
"mac.section.account" = "Account";
"mac.account.restoring" = "Restoring your session…";
"mac.account.signedOut.title" = "Not signed in";
"mac.account.signedOut.body" = "Sign in with Apple to use your credit balance on this Mac and to get your invitation link.";
"mac.account.signedOut.footnote" = "Credits are bought on iPhone or on the web and shared across all your devices.";
"mac.account.signIn.loading" = "Signing in…";
"mac.account.profile.unnamed" = "Apple Account";
"mac.account.profile.accountID" = "Account %@";
"mac.account.signOut" = "Sign Out";
"mac.account.credits.title" = "Credits";
"mac.account.credits.balance" = "Balance";
"mac.account.credits.used" = "Used";
"mac.account.credits.loading" = "Loading balance…";
"mac.account.credits.refresh" = "Refresh";
"mac.account.credits.purchaseNote" = "Purchases are not available on macOS. Buy credits on iPhone or on the web — the balance is shared.";
"mac.account.invite.title" = "Invitation";
"mac.account.invite.link" = "Your invitation link";
"mac.account.invite.copy" = "Copy";
"mac.account.invite.copied" = "Copied";
"mac.account.invite.loading" = "Loading your invitation link…";
"mac.account.invite.retry" = "Retry";
"mac.account.invite.rewarded" = "Rewarded invites";
"mac.account.invite.pending" = "Pending invites";
"mac.account.invite.footnote" = "Both of you get credits once an invited friend qualifies.";
"mac.account.delete.title" = "Delete Account";
"mac.account.delete.body" = "Deleting removes your account and any remaining credits. This cannot be undone.";
"mac.account.delete.action" = "Delete Account…";
"mac.account.delete.confirmBody" = "Confirm with Apple to permanently delete your account and remaining credits.";
"mac.account.delete.cancel" = "Cancel";
"mac.account.error.title" = "Account";
"mac.account.error.dismiss" = "OK";
"account.error.appleAuthorization" = "Apple authorization could not be completed.";
"account.error.appleReauthenticationRequired" = "Sign in with Apple again to keep your account protected.";
"account.error.delete" = "Could not delete the account.";
"account.error.load" = "Could not load account details.";
"account.error.managedGateway" = "Could not prepare the managed service.";
"account.error.redeemReferral" = "The invitation is saved, but could not be redeemed yet.";
"account.error.restore" = "Could not restore your account session.";
"account.error.sessionExpired" = "Your session expired. Sign in again to continue.";
"account.error.signIn" = "Could not sign in. Please try again.";
"account.error.signOut" = "Could not sign out. Please try again.";
"account.error.unavailable" = "Account services are not connected in this build.";
"account.error.updateProfile" = "Could not update your profile.";
"account.referral.error.conflict" = "Your invitation link is temporarily unavailable.";
"account.referral.error.load" = "Couldn’t load your invitation link.";
"account.referral.error.network" = "Check your connection and try again.";
"account.referral.error.notFound" = "Your invitation link isn’t available yet.";
"account.referral.error.session" = "Sign in again to load your invitation link.";
"account.referral.error.unavailable" = "The invitation service is temporarily unavailable.";
+59 -16
View File
@@ -352,6 +352,7 @@
"mac.hint.hold.leftOption" = "长按左 Option(⌥)开始听写";
"mac.hint.hold.eitherOption" = "长按任一 Option(⌥)开始听写";
"mac.hint.holdOption" = "长按 Option 开始听写";
"mac.hint.holdOption.compact" = "按住 Option";
"mac.settings.qwen3Model" = "Qwen3 模型目录";
"mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。";
"mac.settings.qwen3Browse" = "选择文件夹…";
@@ -391,9 +392,12 @@
"mac.onboarding.engine.subtitle" = "默认可用本地识别以保护隐私,也可以配置自己的云端 API。";
"mac.onboarding.engine.localDesc" = "使用 Sherpa 本地模型进行私密转写;模型未安装前会回退到 Apple 语音识别。";
"mac.onboarding.engine.cloudDesc" = "使用你的 API Key 进行云端语音识别和 AI 润色。";
"mac.onboarding.cloud.title" = "配置 API 访问";
"mac.onboarding.cloud.subtitle" = "选择服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。";
"mac.onboarding.cloud.title" = "配置语音识别服务";
"mac.onboarding.cloud.subtitle" = "选择云端语音识别服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。";
"mac.onboarding.cloud.skipHint" = "可以先留空并完成引导;云端模式使用前会提示需要 API Key。";
"mac.onboarding.polish.title" = "配置润色服务";
"mac.onboarding.polish.subtitle" = "选择用于润色、翻译和 AI 问答的大模型服务商。此步骤可跳过,之后可在设置中补齐。";
"mac.onboarding.polish.skipHint" = "可以先留空并完成引导;使用润色前会提示需要 API Key。";
"mac.onboarding.model.title" = "准备本地识别";
"mac.onboarding.model.subtitle" = "现在下载基础 Sherpa runtime 和默认模型,或先跳过并使用 Apple Speech 兜底。";
"mac.onboarding.model.download" = "下载默认模型";
@@ -466,17 +470,56 @@
"settings.appLanguage.english" = "英文";
"settings.appLanguage.chinese" = "中文";
/* 自愿打赏(消耗型 IAP — 不解锁功能) */
"tip.title" = "支持开发者";
"tip.body" = "OSGKeyboard 全功能免费、无任何付费墙。若觉得好用,可以自愿打赏一杯咖啡——不会解锁额外功能。";
"tip.button" = "打赏 %@";
"tip.buttonFallback" = "打赏支持";
"tip.consumableNotice" = "消耗型应用内购买,无法恢复购买(Apple 规则)。";
"tip.thankYou.title" = "感谢支持!";
"tip.thankYou.message" = "你的支持帮助我们继续免费、开源地维护 OSGKeyboard。";
"tip.thankYou.past" = "你已支持 %lld 次,感谢!";
"tip.error.title" = "购买失败";
"tip.error.productUnavailable" = "暂时无法加载打赏商品,请稍后再试。";
"tip.error.pending" = "购买待审批,请稍后查看。";
"tip.error.unknown" = "无法完成购买。";
"tip.alert.dismiss" = "好";
/* Account — Mac account page (Sign in with Apple, credits, invitation link).
The account.* / account.referral.* keys below mirror the iOS app bundle so
the shared AccountSessionCoordinator error keys resolve on macOS too. */
"mac.section.account" = "账户";
"mac.account.restoring" = "正在恢复登录状态…";
"mac.account.signedOut.title" = "尚未登录";
"mac.account.signedOut.body" = "通过 Apple 登录,即可在这台 Mac 上使用积分余额并获取你的邀请链接。";
"mac.account.signedOut.footnote" = "积分在 iPhone 或网页购买,所有设备共用同一余额。";
"mac.account.signIn.loading" = "正在登录…";
"mac.account.profile.unnamed" = "Apple 账户";
"mac.account.profile.accountID" = "账户 %@";
"mac.account.signOut" = "退出登录";
"mac.account.credits.title" = "积分";
"mac.account.credits.balance" = "余额";
"mac.account.credits.used" = "已使用";
"mac.account.credits.loading" = "正在加载余额…";
"mac.account.credits.refresh" = "刷新";
"mac.account.credits.purchaseNote" = "macOS 端不支持购买。请在 iPhone 或网页购买积分,余额通用。";
"mac.account.invite.title" = "邀请";
"mac.account.invite.link" = "你的邀请链接";
"mac.account.invite.copy" = "复制";
"mac.account.invite.copied" = "已复制";
"mac.account.invite.loading" = "正在加载邀请链接…";
"mac.account.invite.retry" = "重试";
"mac.account.invite.rewarded" = "已奖励邀请";
"mac.account.invite.pending" = "待生效邀请";
"mac.account.invite.footnote" = "受邀好友达成条件后,双方都可获得积分。";
"mac.account.delete.title" = "注销账号";
"mac.account.delete.body" = "注销会删除你的账号及剩余积分,且无法撤销。";
"mac.account.delete.action" = "注销账号…";
"mac.account.delete.confirmBody" = "请通过 Apple 确认,以永久删除账号及剩余积分。";
"mac.account.delete.cancel" = "取消";
"mac.account.error.title" = "账户";
"mac.account.error.dismiss" = "好";
"account.error.appleAuthorization" = "未能完成 Apple 授权。";
"account.error.appleReauthenticationRequired" = "为继续保护账号,请重新通过 Apple 登录。";
"account.error.delete" = "无法注销账号。";
"account.error.load" = "无法加载账号详情。";
"account.error.managedGateway" = "无法准备托管服务。";
"account.error.redeemReferral" = "邀请已暂存,但暂时无法兑换。";
"account.error.restore" = "无法恢复账号会话。";
"account.error.sessionExpired" = "登录状态已过期,请重新登录后继续。";
"account.error.signIn" = "登录失败,请重试。";
"account.error.signOut" = "退出失败,请重试。";
"account.error.unavailable" = "当前构建尚未接入账号服务。";
"account.error.updateProfile" = "无法更新个人资料。";
"account.referral.error.conflict" = "邀请链接暂时不可用。";
"account.referral.error.load" = "无法加载邀请链接。";
"account.referral.error.network" = "请检查网络连接后重试。";
"account.referral.error.notFound" = "邀请链接暂不可用。";
"account.referral.error.session" = "请重新登录后加载邀请链接。";
"account.referral.error.unavailable" = "邀请服务暂时不可用。";