diff --git a/AppStoreScreenshots/Mac/01_dashboard.png b/AppStoreScreenshots/Mac/01_dashboard.png new file mode 100644 index 0000000..e583bb0 Binary files /dev/null and b/AppStoreScreenshots/Mac/01_dashboard.png differ diff --git a/AppStoreScreenshots/Mac/02_history.png b/AppStoreScreenshots/Mac/02_history.png new file mode 100644 index 0000000..3aaa118 Binary files /dev/null and b/AppStoreScreenshots/Mac/02_history.png differ diff --git a/AppStoreScreenshots/Mac/03_dictionary.png b/AppStoreScreenshots/Mac/03_dictionary.png new file mode 100644 index 0000000..f5e7dd0 Binary files /dev/null and b/AppStoreScreenshots/Mac/03_dictionary.png differ diff --git a/AppStoreScreenshots/Mac/04_settings.png b/AppStoreScreenshots/Mac/04_settings.png new file mode 100644 index 0000000..c64ccd0 Binary files /dev/null and b/AppStoreScreenshots/Mac/04_settings.png differ diff --git a/AppStoreScreenshots/Mac/05_recording.png b/AppStoreScreenshots/Mac/05_recording.png new file mode 100644 index 0000000..b067e4f Binary files /dev/null and b/AppStoreScreenshots/Mac/05_recording.png differ diff --git a/AppStoreScreenshots/iPad13/01_home.png b/AppStoreScreenshots/iPad13/01_home.png new file mode 100644 index 0000000..462e554 Binary files /dev/null and b/AppStoreScreenshots/iPad13/01_home.png differ diff --git a/AppStoreScreenshots/iPad13/02_history.png b/AppStoreScreenshots/iPad13/02_history.png new file mode 100644 index 0000000..40bed42 Binary files /dev/null and b/AppStoreScreenshots/iPad13/02_history.png differ diff --git a/AppStoreScreenshots/iPad13/03_dictionary.png b/AppStoreScreenshots/iPad13/03_dictionary.png new file mode 100644 index 0000000..2ccb0bd Binary files /dev/null and b/AppStoreScreenshots/iPad13/03_dictionary.png differ diff --git a/AppStoreScreenshots/iPad13/04_settings.png b/AppStoreScreenshots/iPad13/04_settings.png new file mode 100644 index 0000000..c65fc8e Binary files /dev/null and b/AppStoreScreenshots/iPad13/04_settings.png differ diff --git a/AppStoreScreenshots/iPad13/05_keyboard_idle.png b/AppStoreScreenshots/iPad13/05_keyboard_idle.png new file mode 100644 index 0000000..0d2cce9 Binary files /dev/null and b/AppStoreScreenshots/iPad13/05_keyboard_idle.png differ diff --git a/AppStoreScreenshots/iPad13/06_keyboard_recording.png b/AppStoreScreenshots/iPad13/06_keyboard_recording.png new file mode 100644 index 0000000..d87f7be Binary files /dev/null and b/AppStoreScreenshots/iPad13/06_keyboard_recording.png differ diff --git a/AppStoreScreenshots/iPhone69/01_home.png b/AppStoreScreenshots/iPhone69/01_home.png new file mode 100644 index 0000000..ee20f1d Binary files /dev/null and b/AppStoreScreenshots/iPhone69/01_home.png differ diff --git a/AppStoreScreenshots/iPhone69/02_history.png b/AppStoreScreenshots/iPhone69/02_history.png new file mode 100644 index 0000000..83ea576 Binary files /dev/null and b/AppStoreScreenshots/iPhone69/02_history.png differ diff --git a/AppStoreScreenshots/iPhone69/03_dictionary.png b/AppStoreScreenshots/iPhone69/03_dictionary.png new file mode 100644 index 0000000..71e47e7 Binary files /dev/null and b/AppStoreScreenshots/iPhone69/03_dictionary.png differ diff --git a/AppStoreScreenshots/iPhone69/04_settings.png b/AppStoreScreenshots/iPhone69/04_settings.png new file mode 100644 index 0000000..e9102e3 Binary files /dev/null and b/AppStoreScreenshots/iPhone69/04_settings.png differ diff --git a/AppStoreScreenshots/iPhone69/05_keyboard_idle.png b/AppStoreScreenshots/iPhone69/05_keyboard_idle.png new file mode 100644 index 0000000..48a921c Binary files /dev/null and b/AppStoreScreenshots/iPhone69/05_keyboard_idle.png differ diff --git a/AppStoreScreenshots/iPhone69/06_keyboard_recording.png b/AppStoreScreenshots/iPhone69/06_keyboard_recording.png new file mode 100644 index 0000000..0c07195 Binary files /dev/null and b/AppStoreScreenshots/iPhone69/06_keyboard_recording.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 025397b..6975739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Voluntary support tip**: Settings (top of the page) includes an optional ¥28 Consumable in-app tip (StoreKit 2). All features stay free — no paywall or unlock. / **自愿打赏**:设置页顶部新增可选 ¥28 消耗型应用内打赏(StoreKit 2)。全功能仍免费,无付费墙或功能解锁。 + +### Changed +- **Unified welcome slogan**: iPad Home now reuses the iOS onboarding brand line, and macOS onboarding shows the same “Speak it. It’s typed.” welcome slogan. / **统一欢迎口号**:iPad 首页复用 iOS 引导页品牌句,macOS 引导页也显示同一句「开口即文字。」欢迎口号。 +- **GitHub Pages landing**: redesign as a commercial product page with zh/en, light/dark, brand mark, scroll motion, and App Store screenshots; emphasizes free, cross-platform, open source, privacy, and BYOK. / **GitHub Pages 落地页**:改版为商业产品页,支持中英与日夜模式、品牌标、滚动动效与 App Store 截图;突出免费、跨端、开源、隐私与 BYOK。 + ## [0.5.3] - 2026-07-11 ### Added diff --git a/OSGKeyboard.storekit b/OSGKeyboard.storekit new file mode 100644 index 0000000..54b43af --- /dev/null +++ b/OSGKeyboard.storekit @@ -0,0 +1,97 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "A1B2C3D4", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "28", + "familyShareable" : false, + "internalID" : "6789921326", + "localizations" : [ + { + "description" : "Support me keep vibe coding~ Voluntary tip — does not unlock any features.", + "displayName" : "Buy me a cup of coffee", + "locale" : "en_US" + }, + { + "description" : "支持一下老刘的 Vibe Coding 工作。自愿打赏,不解锁任何功能。", + "displayName" : "给老刘买杯冰美式", + "locale" : "zh_Hans" + } + ], + "productID" : "ByRockyACoffee", + "referenceName" : "给老刘买杯咖啡", + "type" : "Consumable" + } + ], + "settings" : { + "_failTransactionsEnabled" : false, + "_locale" : "zh_CN", + "_storefront" : "CHN", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index 2f54229..4d71323 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -174,7 +174,7 @@ struct HomeView: View { private var wideHeroHeader: some View { VStack(alignment: .leading, spacing: Spacing.xs) { - Text("home.wide.tagline") + Text("onboarding.welcome.tagline") .font(.system(size: 30, weight: .semibold)) .foregroundStyle(palette.textPrimary) .lineLimit(2) diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index b49cb34..01e7d61 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -370,6 +370,17 @@ private struct WelcomePage: View { @State private var logoAppeared = false var body: some View { + ZStack { + SonicParticleField(focalPoint: UnitPoint(x: 0.5, y: 0.42)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(true) + + welcomeContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var welcomeContent: some View { VStack(spacing: 0) { Spacer() @@ -398,6 +409,7 @@ private struct WelcomePage: View { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) } } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 5902b1b..185fa3a 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -39,6 +39,9 @@ struct SettingsView: View { palette.background.ignoresSafeArea() ScrollView { VStack(spacing: Spacing.md) { + if presentation == .tab { + SupportDeveloperSection(language: config.uiLanguage) + } languageAndPolishSection dictionaryAndPolishSection flowSessionSection diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 73f3d22..8a93cd6 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -335,7 +335,6 @@ "home.flow.startShort" = "Start"; "home.preview.label" = "Try typing"; "home.preview.placeholder" = "Tap to type and test…"; -"home.wide.tagline" = "Voice dictation, anywhere."; "home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate."; "home.wide.mode.cloud" = "Cloud"; "home.wide.mode.local" = "On-device"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 3fef6c6..c57d74f 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -334,7 +334,6 @@ "home.flow.startShort" = "开启"; "home.preview.label" = "输入测试"; "home.preview.placeholder" = "点这里试试键盘"; -"home.wide.tagline" = "随处语音听写"; "home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。"; "home.wide.mode.cloud" = "云端"; "home.wide.mode.local" = "本机"; diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift index d83d9ee..117871d 100644 --- a/OSGKeyboardMac/DashboardView.swift +++ b/OSGKeyboardMac/DashboardView.swift @@ -38,8 +38,9 @@ struct DashboardView: View { .padding(.top, Spacing.sm) // Leftover window height splits evenly above / below the mic - // bar so spacing stays balanced at any window size. - Spacer(minLength: Spacing.xs) + // bar so spacing stays balanced at any window size. The top + // gap keeps a larger floor so the mic never hugs the canvas. + Spacer(minLength: 30) BottomDictationBar(viewModel: viewModel) .padding(.horizontal, MacMetrics.pageHorizontalInset) diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift index 354f1d0..a942253 100644 --- a/OSGKeyboardMac/MacOnboardingView.swift +++ b/OSGKeyboardMac/MacOnboardingView.swift @@ -261,13 +261,20 @@ struct MacOnboardingView: View { @ViewBuilder private var heroIcon: some View { if model.step == .welcome { - Image("OSGBrandMark") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 128, height: 128) - .foregroundStyle(colorScheme == .dark ? Color.white : palette.accent) - .accessibilityLabel("OSGKeyboard") + ZStack { + SonicParticleField() + .frame(width: 300, height: 300) + + Image("OSGBrandMark") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 128, height: 128) + .foregroundStyle(colorScheme == .dark ? Color.white : palette.accent) + .accessibilityLabel("OSGKeyboard") + .allowsHitTesting(false) + } + .frame(width: 300, height: 300) } else { ZStack { Circle() diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift index 5c0609a..c7c7da6 100644 --- a/OSGKeyboardMac/MacSettingsView.swift +++ b/OSGKeyboardMac/MacSettingsView.swift @@ -39,6 +39,7 @@ struct MacSettingsView: View { ScrollView { LazyVStack(alignment: .leading, spacing: Spacing.xl) { + supportSection generalSection recognitionSection if viewModel.config.engineMode == "cloud" { @@ -298,6 +299,17 @@ struct MacSettingsView: View { } } + // MARK: - Support developer + + private var supportSection: some View { + MacSettingsSection( + title: MacL10n.string("tip.title", language: lang), + footer: MacL10n.string("tip.consumableNotice", language: lang) + ) { + MacSupportDeveloperTipRows(language: lang) + } + } + // MARK: - Legal private var legalSection: some View { diff --git a/OSGKeyboardMac/MacSupportDeveloperTipRows.swift b/OSGKeyboardMac/MacSupportDeveloperTipRows.swift new file mode 100644 index 0000000..deddd52 --- /dev/null +++ b/OSGKeyboardMac/MacSupportDeveloperTipRows.swift @@ -0,0 +1,89 @@ +// MacSupportDeveloperTipRows.swift +// OSGKeyboard · Mac +// +// Optional consumable tip rows for macOS Settings. + +import SwiftUI + +struct MacSupportDeveloperTipRows: View { + @ObservedObject private var tipManager = TipPurchaseManager.shared + @Environment(\.themePalette) private var palette + + let language: AppUILanguage + + @State private var showThankYouAlert = false + @State private var showErrorAlert = false + @State private var errorMessage = "" + + var body: some View { + VStack(alignment: .leading, spacing: MacMetrics.settingsRowGap) { + SupportDeveloperTipBody(language: language) + .padding(.horizontal, MacMetrics.settingsCardInset) + + if tipManager.supportCount > 0 { + Text( + SharedL10n.format( + "tip.thankYou.past", + language: language, + tipManager.supportCount + ) + ) + .font(TypeStyle.caption) + .foregroundStyle(palette.accent) + .padding(.horizontal, MacMetrics.settingsCardInset) + } + + MacSettingsToolButton(title: tipButtonTitle, disabled: isPurchaseInFlight) { + Task { await tipManager.purchase() } + } + .padding(.horizontal, MacMetrics.settingsCardInset) + } + .onChange(of: tipManager.purchaseState) { _, newValue in + switch newValue { + case .succeeded: + showThankYouAlert = true + case .failed(let message): + errorMessage = message + showErrorAlert = true + default: + break + } + } + .alert( + SharedL10n.string("tip.thankYou.title", language: language), + isPresented: $showThankYouAlert + ) { + Button(SharedL10n.string("tip.alert.dismiss", language: language)) { + tipManager.acknowledgePurchaseState() + } + } message: { + Text(SharedL10n.string("tip.thankYou.message", language: language)) + } + .alert( + SharedL10n.string("tip.error.title", language: language), + isPresented: $showErrorAlert + ) { + Button(SharedL10n.string("tip.alert.dismiss", language: language)) { + tipManager.acknowledgePurchaseState() + } + } message: { + Text(errorMessage) + } + } + + private var isPurchaseInFlight: Bool { + switch tipManager.purchaseState { + case .loading, .purchasing: + return true + default: + return false + } + } + + private var tipButtonTitle: String { + if let product = tipManager.product { + return SharedL10n.format("tip.button", language: language, product.displayPrice) + } + return SharedL10n.string("tip.buttonFallback", language: language) + } +} diff --git a/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift b/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift index 6b48845..0b1ed00 100644 --- a/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift +++ b/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift @@ -48,6 +48,21 @@ public struct SevenDayUsageChart: View { Locale(identifier: language.resolvedLanguageCode()) } + /// The real day for each bar; used to decide which axis marks get a label. + private var pointDates: Set { + Set(points.map(\.date)) + } + + /// Axis tick positions: the 7 real days plus one trailing boundary (last + 1 + /// day) so `centered: true` has a span to center the final day's label within. + private var axisDates: [Date] { + let dates = points.map(\.date) + guard let last = dates.last, + let boundary = Calendar.current.date(byAdding: .day, value: 1, to: last) + else { return dates } + return dates + [boundary] + } + public var body: some View { Group { if embedsInCard { @@ -121,8 +136,18 @@ public struct SevenDayUsageChart: View { .chartYScale(domain: 0...max(1, Int(ceil(Double(maxValue) * 1.15)))) .chartYAxis(.hidden) .chartXAxis { - AxisMarks(values: points.map(\.date)) { _ in - AxisValueLabel(format: .dateTime.weekday(.narrow)) + // Center each weekday label under its bar. Date `BarMark`s draw the + // bar centered within its day band, but axis labels default to the + // day's leading tick, so `centered: true` re-centers them on the bar. + // + // `centered` positions a label between its tick and the *next* tick, + // so the final day (Saturday) would be dropped for lack of a trailing + // tick. We append one boundary tick (last day + 1) to give it a span, + // and only draw labels for the real 7 days. + AxisMarks(values: axisDates) { value in + if let date = value.as(Date.self), pointDates.contains(date) { + AxisValueLabel(format: .dateTime.weekday(.narrow), centered: true) + } } } .environment(\.locale, chartLocale) diff --git a/OSGKeyboardShared/DesignSystem/SonicParticleField.swift b/OSGKeyboardShared/DesignSystem/SonicParticleField.swift new file mode 100644 index 0000000..b97c736 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/SonicParticleField.swift @@ -0,0 +1,288 @@ +// SonicParticleField.swift +// OSGKeyboard · Shared Design System +// +// Ambient radial sonic-wave particles for onboarding welcome screens. +// Pure SwiftUI Canvas + TimelineView — no Metal / SpriteKit dependency. + +import SwiftUI + +// MARK: - Ripple + +private struct SonicRipple: Identifiable { + let id = UUID() + let origin: CGPoint + let bornAt: TimeInterval +} + +// MARK: - Particle seed (immutable layout) + +private struct SonicParticleSeed: Sendable { + let ring: Int + let angle: Double + let phase: Double + let size: CGFloat +} + +// MARK: - View + +/// Radial sonic-wave particle field with touch / pointer interaction. +/// Intended as a decorative backdrop behind onboarding hero content. +public struct SonicParticleField: View { + @Environment(\.themePalette) private var palette + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Optional accent override; defaults to `themePalette.accent`. + public var accent: Color? + /// Focal point for the pulse rings, in unit coordinates of the field bounds. + public var focalPoint: UnitPoint + /// When false, particles animate but ignore pointer input. + public var isInteractive: Bool + + @State private var touchLocation: CGPoint? + @State private var isTouching = false + @State private var ripples: [SonicRipple] = [] + + public init( + accent: Color? = nil, + focalPoint: UnitPoint = .center, + isInteractive: Bool = true + ) { + self.accent = accent + self.focalPoint = focalPoint + self.isInteractive = isInteractive + } + + private var resolvedAccent: Color { + accent ?? palette.accent + } + + public var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 45.0)) { timeline in + GeometryReader { geo in + let size = geo.size + let center = CGPoint( + x: size.width * focalPoint.x, + y: size.height * focalPoint.y + ) + let time = timeline.date.timeIntervalSinceReferenceDate + let motionScale = reduceMotion ? 0.18 : 1.0 + + ZStack { + RadialGradient( + colors: [ + resolvedAccent.opacity(reduceMotion ? 0.05 : 0.10), + resolvedAccent.opacity(0.03), + palette.background.opacity(0) + ], + center: .center, + startRadius: 0, + endRadius: min(size.width, size.height) * 0.52 + ) + + Canvas { context, canvasSize in + drawField( + in: &context, + size: canvasSize, + center: center, + time: time * motionScale, + motionScale: motionScale + ) + } + } + .contentShape(Rectangle()) + .gesture(interactionGesture(in: size, time: time)) + } + } + .accessibilityHidden(true) + .allowsHitTesting(isInteractive) + } + + // MARK: - Drawing + + private func drawField( + in context: inout GraphicsContext, + size: CGSize, + center: CGPoint, + time: TimeInterval, + motionScale: Double + ) { + let maxDimension = max(size.width, size.height) + let ringSpacing = maxDimension * 0.075 + let baseRadius = maxDimension * 0.06 + let influenceRadius = maxDimension * 0.22 + + drawRipples( + in: &context, + time: time, + maxRadius: maxDimension * 0.55 + ) + + for seed in Self.particleSeeds { + let pulse = (time * 0.42 + seed.phase).truncatingRemainder(dividingBy: 1.0) + let wobble = sin(time * 2.4 + seed.angle * 3.0) * 0.018 + let angle = seed.angle + wobble + + let ringRadius = baseRadius + Double(seed.ring) * ringSpacing + let expansion = pulse * ringSpacing * 0.92 + var radius = ringRadius + expansion + + // Soft sonic bulge — particles swell mid-pulse. + let swell = sin(pulse * .pi) * ringSpacing * 0.08 + radius += swell + + var position = polarPoint(center: center, radius: radius, angle: angle) + position = applyTouchInfluence( + to: position, + touch: touchLocation, + influenceRadius: influenceRadius + ) + + var opacity = (1.0 - pulse) * 0.62 + 0.12 + opacity += sin(time * 3.1 + seed.phase * 8.0) * 0.06 + opacity = max(0.08, min(0.78, opacity)) + + if motionScale < 1 { + opacity *= 0.55 + } + + opacity += rippleBoost(at: position, time: time, maxRadius: maxDimension * 0.55) + + let particleColor = resolvedAccent.opacity(opacity) + let rect = CGRect( + x: position.x - seed.size * 0.5, + y: position.y - seed.size * 0.5, + width: seed.size, + height: seed.size + ) + + context.fill( + Path(ellipseIn: rect), + with: .color(particleColor) + ) + } + } + + private func drawRipples( + in context: inout GraphicsContext, + time: TimeInterval, + maxRadius: CGFloat + ) { + for ripple in ripples { + let age = time - ripple.bornAt + guard age > 0, age < 2.4 else { continue } + + let progress = age / 2.4 + let radius = maxRadius * CGFloat(progress) + let opacity = (1.0 - progress) * 0.28 + let lineWidth = max(0.8, 2.4 - progress * 1.4) + + var ringContext = context + ringContext.stroke( + Path(ellipseIn: CGRect( + x: ripple.origin.x - radius, + y: ripple.origin.y - radius, + width: radius * 2, + height: radius * 2 + )), + with: .color(resolvedAccent.opacity(opacity)), + lineWidth: lineWidth + ) + } + } + + private func rippleBoost(at point: CGPoint, time: TimeInterval, maxRadius: CGFloat) -> Double { + var boost = 0.0 + for ripple in ripples { + let age = time - ripple.bornAt + guard age > 0, age < 2.4 else { continue } + + let progress = age / 2.4 + let radius = Double(maxRadius) * progress + let dist = hypot(point.x - ripple.origin.x, point.y - ripple.origin.y) + let band = abs(dist - radius) + if band < 18 { + boost += (1.0 - band / 18.0) * (1.0 - progress) * 0.35 + } + } + return min(boost, 0.4) + } + + private func polarPoint(center: CGPoint, radius: Double, angle: Double) -> CGPoint { + CGPoint( + x: center.x + CGFloat(cos(angle) * radius), + y: center.y + CGFloat(sin(angle) * radius) + ) + } + + private func applyTouchInfluence( + to point: CGPoint, + touch: CGPoint?, + influenceRadius: CGFloat + ) -> CGPoint { + guard let touch else { return point } + + let dx = point.x - touch.x + let dy = point.y - touch.y + let distance = hypot(dx, dy) + guard distance > 0.5, distance < influenceRadius else { return point } + + let normalized = (influenceRadius - distance) / influenceRadius + let strength = CGFloat(normalized * normalized) * (isTouching ? 22 : 10) + return CGPoint( + x: point.x + dx / distance * strength, + y: point.y + dy / distance * strength + ) + } + + // MARK: - Interaction + + private func interactionGesture(in size: CGSize, time: TimeInterval) -> some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + guard isInteractive else { return } + touchLocation = value.location + isTouching = true + registerRipple(at: value.location, time: time) + } + .onEnded { _ in + isTouching = false + touchLocation = nil + } + } + + private func registerRipple(at location: CGPoint, time: TimeInterval) { + let shouldAdd: Bool + if let last = ripples.last { + let moved = hypot(location.x - last.origin.x, location.y - last.origin.y) + let elapsed = time - last.bornAt + shouldAdd = moved > 36 || elapsed > 0.38 + } else { + shouldAdd = true + } + + guard shouldAdd else { return } + + ripples.append(SonicRipple(origin: location, bornAt: time)) + ripples = ripples.filter { time - $0.bornAt < 2.6 } + } + + // MARK: - Particle layout + + private static let particleSeeds: [SonicParticleSeed] = { + var seeds: [SonicParticleSeed] = [] + let ringCount = 7 + let particlesPerRing = [14, 18, 22, 26, 30, 34, 38] + + for ring in 0.. 0 { + Text( + SharedL10n.format( + "tip.thankYou.past", + language: language, + tipManager.supportCount + ) + ) + .font(TypeStyle.caption) + .foregroundStyle(palette.accent) + } + + tipButton + .disabled(isPurchaseInFlight) + + Text(SharedL10n.string("tip.consumableNotice", language: language)) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .onChange(of: tipManager.purchaseState) { _, newValue in + switch newValue { + case .succeeded: + showThankYouAlert = true + case .failed(let message): + errorMessage = message + showErrorAlert = true + default: + break + } + } + .alert( + SharedL10n.string("tip.thankYou.title", language: language), + isPresented: $showThankYouAlert + ) { + Button(SharedL10n.string("tip.alert.dismiss", language: language)) { + tipManager.acknowledgePurchaseState() + } + } message: { + Text(SharedL10n.string("tip.thankYou.message", language: language)) + } + .alert( + SharedL10n.string("tip.error.title", language: language), + isPresented: $showErrorAlert + ) { + Button(SharedL10n.string("tip.alert.dismiss", language: language)) { + tipManager.acknowledgePurchaseState() + } + } message: { + Text(errorMessage) + } + } + + private var isPurchaseInFlight: Bool { + switch tipManager.purchaseState { + case .loading, .purchasing: + return true + default: + return false + } + } + + private var tipButton: some View { + Button { + Task { await tipManager.purchase() } + } label: { + HStack(spacing: Spacing.sm) { + if isPurchaseInFlight { + ProgressView() + .controlSize(.small) + } + Text(tipButtonTitle) + .font(TypeStyle.body.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, Spacing.sm) + .foregroundStyle(.white) + .background(palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(tipButtonTitle) + } + + private var tipButtonTitle: String { + if let product = tipManager.product { + return SharedL10n.format("tip.button", language: language, product.displayPrice) + } + return SharedL10n.string("tip.buttonFallback", language: language) + } +} + +/// Shared copy + tip button for macOS Settings (Mac chrome wraps this). +public struct SupportDeveloperTipBody: View { + @Environment(\.themePalette) private var palette + + private let language: AppUILanguage + + public init(language: AppUILanguage) { + self.language = language + } + + public var body: some View { + Text(SharedL10n.string("tip.body", language: language)) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/OSGKeyboardShared/Services/Tip/TipProduct.swift b/OSGKeyboardShared/Services/Tip/TipProduct.swift new file mode 100644 index 0000000..8477fc5 --- /dev/null +++ b/OSGKeyboardShared/Services/Tip/TipProduct.swift @@ -0,0 +1,17 @@ +// 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" +} diff --git a/OSGKeyboardShared/Services/Tip/TipPurchaseManager.swift b/OSGKeyboardShared/Services/Tip/TipPurchaseManager.swift new file mode 100644 index 0000000..0a1b4a4 --- /dev/null +++ b/OSGKeyboardShared/Services/Tip/TipPurchaseManager.swift @@ -0,0 +1,123 @@ +// TipPurchaseManager.swift +// OSGKeyboard · Shared +// +// Optional ¥30 consumable tip via StoreKit 2. Voluntary support only — +// no feature gates, no App Group sync, no restore (Apple consumable rules). + +import Foundation +import Combine +import StoreKit + +public enum TipPurchaseState: Equatable { + case idle + case loading + case purchasing + case succeeded + case failed(String) +} + +@MainActor +public final class TipPurchaseManager: ObservableObject { + public static let shared = TipPurchaseManager() + + @Published public private(set) var product: Product? + @Published public private(set) var purchaseState: TipPurchaseState = .idle + @Published public private(set) var supportCount: Int + + private var transactionUpdatesTask: Task? + + public init(defaults: UserDefaults = .standard) { + supportCount = defaults.integer(forKey: TipProduct.supportCountDefaultsKey) + transactionUpdatesTask = Task { [weak self] in + await self?.listenForTransactionUpdates() + } + Task { await loadProducts() } + } + + deinit { + transactionUpdatesTask?.cancel() + } + + /// Loads the tip product from the App Store / StoreKit Test configuration. + public func loadProducts() async { + guard purchaseState != .purchasing else { return } + purchaseState = .loading + do { + let products = try await Product.products(for: TipProduct.allProductIDs) + product = products.first(where: { $0.id == TipProduct.supportID }) + purchaseState = product == nil + ? .failed(SharedL10n.string("tip.error.productUnavailable")) + : .idle + } catch { + product = nil + purchaseState = .failed(error.localizedDescription) + } + } + + /// Starts the StoreKit purchase sheet for the support tip. + public func purchase() async { + guard let product else { + purchaseState = .failed(SharedL10n.string("tip.error.productUnavailable")) + return + } + purchaseState = .purchasing + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + let transaction = try Self.verified(verification) + await handleCompletedTip(transaction) + purchaseState = .succeeded + case .userCancelled: + purchaseState = .idle + case .pending: + purchaseState = .failed(SharedL10n.string("tip.error.pending")) + @unknown default: + purchaseState = .failed(SharedL10n.string("tip.error.unknown")) + } + } catch { + purchaseState = .failed(error.localizedDescription) + } + } + + /// Clears transient success / failure state after the UI acknowledges it. + public func acknowledgePurchaseState() { + switch purchaseState { + case .succeeded, .failed: + purchaseState = .idle + default: + break + } + } + + private func listenForTransactionUpdates() async { + for await update in Transaction.updates { + guard let transaction = try? Self.verified(update), + transaction.productID == TipProduct.supportID else { continue } + await handleCompletedTip(transaction) + } + } + + private func handleCompletedTip(_ transaction: Transaction) async { + await transaction.finish() + recordSupportPurchase() + } + + private func recordSupportPurchase() { + supportCount += 1 + UserDefaults.standard.set(supportCount, forKey: TipProduct.supportCountDefaultsKey) + } + + private static func verified(_ result: VerificationResult) throws -> T { + switch result { + case .verified(let value): + return value + case .unverified: + throw TipPurchaseError.failedVerification + } + } +} + +private enum TipPurchaseError: Error { + case failedVerification +} diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index ed6a91d..0b240aa 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -269,7 +269,7 @@ "mac.onboarding.next" = "Next"; "mac.onboarding.finish" = "Finish"; "mac.onboarding.skipForNow" = "Skip for now"; -"mac.onboarding.welcome.title" = "Welcome to OSGKeyboard"; +"mac.onboarding.welcome.title" = "Speak it. It’s typed."; "mac.onboarding.welcome.subtitle" = "Set up dictation in a minute. Everything here can be changed later in Settings."; "mac.onboarding.welcome.privacy" = "Your voice stays local unless you choose a cloud provider."; "mac.onboarding.welcome.hotkey" = "Hold Option to dictate from any app."; @@ -358,3 +358,18 @@ "settings.appLanguage.auto" = "Auto"; "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"; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index 1f7731b..3b892d7 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -269,7 +269,7 @@ "mac.onboarding.next" = "下一步"; "mac.onboarding.finish" = "完成"; "mac.onboarding.skipForNow" = "暂时跳过"; -"mac.onboarding.welcome.title" = "欢迎使用 OSGKeyboard"; +"mac.onboarding.welcome.title" = "开口即文字。"; "mac.onboarding.welcome.subtitle" = "用一分钟完成听写基础配置。这里的选项之后都可以在设置里修改。"; "mac.onboarding.welcome.privacy" = "除非你选择云端服务商,语音会优先留在本机处理。"; "mac.onboarding.welcome.hotkey" = "按住 Option 键即可在任意应用开始听写。"; @@ -358,3 +358,18 @@ "settings.appLanguage.auto" = "自动"; "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" = "好"; diff --git a/OSGKeyboardTests/TipProductTests.swift b/OSGKeyboardTests/TipProductTests.swift new file mode 100644 index 0000000..04215ab --- /dev/null +++ b/OSGKeyboardTests/TipProductTests.swift @@ -0,0 +1,39 @@ +// TipProductTests.swift +// OSGKeyboardTests +// +// Locks tip product identifiers and optional support-count persistence. + +import XCTest +@testable import OSGKeyboardShared + +final class TipProductTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "tip.tests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + super.tearDown() + } + + func testSupportProductIDIsStable() { + XCTAssertEqual(TipProduct.supportID, "ByRockyACoffee") + XCTAssertEqual(TipProduct.allProductIDs, [TipProduct.supportID]) + } + + func testSupportCountDefaultsKeyIsStable() { + XCTAssertEqual(TipProduct.supportCountDefaultsKey, "tipSupportPurchaseCount") + } + + func testSupportCountPersistsInUserDefaults() { + defaults.set(2, forKey: TipProduct.supportCountDefaultsKey) + XCTAssertEqual(defaults.integer(forKey: TipProduct.supportCountDefaultsKey), 2) + } +} diff --git a/docs/APPSTORE_METADATA.md b/docs/APPSTORE_METADATA.md index acb74b3..5eb06cd 100644 --- a/docs/APPSTORE_METADATA.md +++ b/docs/APPSTORE_METADATA.md @@ -38,6 +38,7 @@ | Field | Value | |---|---| | **Price** | Free (0 USD) | +| **In-App Purchases** | Optional voluntary tip — Consumable `ByRockyACoffee` (¥28 China tier; no feature unlock) | | **Availability** | All App Store territories (default) | | **Pre-order** | No | | **Volume purchase** | No | @@ -83,6 +84,12 @@ WHY OSGKEYBOARD touches (voice audio + transcripts, on-device by default, never linked or tracked); we don't run a server. +OPTIONAL SUPPORT + +OSGKeyboard is completely free — every feature is available without +payment. If you'd like to support development, Settings includes an +optional in-app tip (Consumable). It does not unlock anything extra. + BUILT FOR • iOS 26 and later, iPhone and iPad. @@ -271,6 +278,11 @@ to end, please: 5. The privacy policy is at https://hkgood.github.io/OSGKeyboard/privacy/ +6. Optional tip (Consumable IAP ByRockyACoffee): open + Settings → "Support the Developer". All features remain free before + and after purchase; the tip does not unlock anything. Consumable + tips cannot be restored (stated in UI). + Source code: https://github.com/hkgood/OSGKeyboard ``` diff --git a/docs/STOREKIT_TESTING.md b/docs/STOREKIT_TESTING.md new file mode 100644 index 0000000..b239f37 --- /dev/null +++ b/docs/STOREKIT_TESTING.md @@ -0,0 +1,122 @@ +# StoreKit — Voluntary Support Tip + +OSGKeyboard offers a single **Consumable** in-app purchase: + +| Field | Value | +|---|---| +| **Type** | Consumable | +| **Product ID** | `ByRockyACoffee` | +| **Reference name (Connect)** | 给老刘买杯咖啡 | +| **Display name (en)** | Buy me a cup of coffee | +| **Display name (zh-Hans)** | 给老刘买杯冰美式 | +| **Price (China)** | ¥28 (nearest available App Store tier) | +| **Price (US, suggested)** | $3.99 equivalent tier | + +**Important:** this tip does **not** unlock translation, dictionary limits, +long Flow sessions, or any other capability. The app remains fully free. + +Consumable purchases **cannot be restored** (Apple policy). Settings copy +explains this to users. + +--- + +## App Store Connect setup + +1. Open **App Store Connect → OSGKeyboard → In-App Purchases**. +2. Create **Consumable** with Product ID `ByRockyACoffee`. +3. Add localizations (en + zh-Hans) using the strings in + `OSGKeyboardShared/*/Shared.strings` (`tip.*` keys) and + `docs/APPSTORE_METADATA.md`. +4. Set pricing: **China ¥28** (App Store only offers fixed tiers — ¥28 is + the nearest to ¥30); pick equivalent tiers for other territories. +5. Submit the IAP for review **with** the app version that includes the + Settings → Support the Developer entry. + +### Review screenshot (required — fixes「元数据丢失」) + +App Store Connect → IAP **ByRockyACoffee** → **审核信息** → **截屏**: + +1. Run the app (Simulator or device) with Settings open at the top + **支持开发者** card showing the green **打赏 ¥28.00** button. +2. Capture that screen (⌘S in Simulator, or device screenshot). +3. Upload to **截屏 → 选取文件**. +4. Optional **审核备注**: + +``` +Optional voluntary tip only (Consumable IAP ByRockyACoffee). +All features free before and after purchase. Settings tab → top of page. +Consumable — cannot restore (stated in UI). +``` + +Save — status should become **准备提交**. + +--- + +## Local testing (StoreKit Test — no Connect / sandbox account) + +Uses [`OSGKeyboard.storekit`](../OSGKeyboard.storekit). The **OSGKeyboard** +scheme already references it in `project.yml` (`storeKitConfiguration`). + +1. `xcodegen generate && open OSGKeyboard.xcodeproj` +2. **Product → Scheme → Edit Scheme → Run → Options** + - Confirm **StoreKit Configuration** = `OSGKeyboard.storekit` +3. Run **OSGKeyboard** on Simulator (e.g. iPhone 17) or a plugged-in device +4. Open **Settings** tab → top card **支持开发者** +5. Tap **打赏 ¥28.00** → StoreKit Test purchase sheet appears +6. **Buy** → thank-you alert; **Cancel** → no error +7. Repeat buy once (Consumable allows multiple) + +**Debug menu (optional):** Xcode → **Debug → StoreKit → Manage Transactions** +to view / delete test purchases. + +--- + +## Sandbox testing (real App Store sandbox — after Connect IAP is 准备提交) + +1. App Store Connect → **用户和访问** → **沙盒** → create a **Sandbox Tester** +2. On device: **设置 → App Store → 沙盒账户** → sign in (not your real Apple ID) +3. Install via **TestFlight** or **Debug run without** `.storekit`: + - To hit Connect products: Edit Scheme → Run → Options → set StoreKit + Configuration to **None**, then run on device +4. Settings → **支持开发者** → purchase with sandbox account +5. Sandbox charges are free; receipt is real sandbox flow + +--- + +## Submit to App Review (after local + sandbox pass) + +Yes — **wait until testing looks good**, then: + +1. IAP **ByRockyACoffee** status = **准备提交** (pricing + screenshot + localizations) +2. Bump app version in `project.yml` / `CHANGELOG.md` if needed +3. **Archive** → upload build to App Store Connect +4. Open the new **App Store version** page → **App 内购买项目** → **+** → select **ByRockyACoffee** +5. Fill metadata, attach build, submit **version + IAP together** (first IAP rule) + +### Sandbox checklist + +- [ ] Product loads and shows localized price +- [ ] Successful purchase shows thank-you alert +- [ ] User cancel returns to idle (no error spam) +- [ ] Repeat purchase works (Consumable allows multiple) +- [ ] No Restore button for this product (Consumable) +- [ ] Translation, Flow, dictionary, BYOK unchanged after tipping + +### macOS note + +The menu-bar Mac build (`com.osgkeyboard.mac`) ships via Developer ID outside +the Mac App Store today. StoreKit products load only for App Store builds. +The Mac Settings UI is present for parity; tip IAP requires an App Store +distribution if Mac tipping is enabled later. + +--- + +## Files + +| File | Role | +|---|---| +| `OSGKeyboardShared/Services/Tip/TipProduct.swift` | Product ID constants | +| `OSGKeyboardShared/Services/Tip/TipPurchaseManager.swift` | StoreKit 2 purchase flow | +| `OSGKeyboardShared/DesignSystem/SupportDeveloperSection.swift` | iOS Settings UI | +| `OSGKeyboardMac/MacSupportDeveloperTipRows.swift` | macOS Settings UI | +| `OSGKeyboard.storekit` | Local StoreKit Test catalog | diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..f94a62e --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,9 @@ + diff --git a/docs/assets/screenshots/ipad-home.png b/docs/assets/screenshots/ipad-home.png new file mode 100644 index 0000000..ca2200c Binary files /dev/null and b/docs/assets/screenshots/ipad-home.png differ diff --git a/docs/assets/screenshots/iphone-dictionary.png b/docs/assets/screenshots/iphone-dictionary.png new file mode 100644 index 0000000..d9e580a Binary files /dev/null and b/docs/assets/screenshots/iphone-dictionary.png differ diff --git a/docs/assets/screenshots/iphone-home.png b/docs/assets/screenshots/iphone-home.png new file mode 100644 index 0000000..228b580 Binary files /dev/null and b/docs/assets/screenshots/iphone-home.png differ diff --git a/docs/assets/screenshots/iphone-keyboard_idle.png b/docs/assets/screenshots/iphone-keyboard_idle.png new file mode 100644 index 0000000..7bf4d98 Binary files /dev/null and b/docs/assets/screenshots/iphone-keyboard_idle.png differ diff --git a/docs/assets/screenshots/iphone-keyboard_recording.png b/docs/assets/screenshots/iphone-keyboard_recording.png new file mode 100644 index 0000000..ad54859 Binary files /dev/null and b/docs/assets/screenshots/iphone-keyboard_recording.png differ diff --git a/docs/assets/screenshots/mac-dashboard.png b/docs/assets/screenshots/mac-dashboard.png new file mode 100644 index 0000000..19dd69e Binary files /dev/null and b/docs/assets/screenshots/mac-dashboard.png differ diff --git a/docs/assets/screenshots/mac-dictionary.png b/docs/assets/screenshots/mac-dictionary.png new file mode 100644 index 0000000..28fec08 Binary files /dev/null and b/docs/assets/screenshots/mac-dictionary.png differ diff --git a/docs/assets/screenshots/mac-history.png b/docs/assets/screenshots/mac-history.png new file mode 100644 index 0000000..fb46992 Binary files /dev/null and b/docs/assets/screenshots/mac-history.png differ diff --git a/docs/assets/screenshots/mac-recording.png b/docs/assets/screenshots/mac-recording.png new file mode 100644 index 0000000..138a938 Binary files /dev/null and b/docs/assets/screenshots/mac-recording.png differ diff --git a/docs/assets/screenshots/mac-settings.png b/docs/assets/screenshots/mac-settings.png new file mode 100644 index 0000000..fb6ffab Binary files /dev/null and b/docs/assets/screenshots/mac-settings.png differ diff --git a/docs/index.html b/docs/index.html index 543e4e9..ff2fd9c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,112 +1,109 @@ - + - - OSGKeyboard — 开口即文字 · iOS & Mac 语音输入 - - - - - + + + + - - - - - + + - - - - - - - + - - - - - - - - +