feat: tip IAP, commercial GitHub Pages, and App Store screenshots

Add optional StoreKit support tip (features stay free), redesign the
landing page with zh/en + light/dark and product shots, and ship
iPhone/iPad/Mac App Store screenshot sets.
This commit is contained in:
Rocky
2026-07-12 11:00:57 +08:00
parent 951e9514d7
commit 166019b384
51 changed files with 1938 additions and 518 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

+7
View File
@@ -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. Its 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
+97
View File
@@ -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
}
}
+1 -1
View File
@@ -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)
+12
View File
@@ -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)
}
}
+3
View File
@@ -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
-1
View File
@@ -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";
@@ -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" = "本机";
+3 -2
View File
@@ -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)
+7
View File
@@ -261,6 +261,10 @@ struct MacOnboardingView: View {
@ViewBuilder
private var heroIcon: some View {
if model.step == .welcome {
ZStack {
SonicParticleField()
.frame(width: 300, height: 300)
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
@@ -268,6 +272,9 @@ struct MacOnboardingView: View {
.frame(width: 128, height: 128)
.foregroundStyle(colorScheme == .dark ? Color.white : palette.accent)
.accessibilityLabel("OSGKeyboard")
.allowsHitTesting(false)
}
.frame(width: 300, height: 300)
} else {
ZStack {
Circle()
+12
View File
@@ -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 {
@@ -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)
}
}
@@ -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<Date> {
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)
@@ -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..<ringCount {
let count = particlesPerRing[ring]
for index in 0..<count {
let angle = (Double(index) / Double(count)) * (.pi * 2.0)
+ Double(ring) * 0.11
let phase = Double(ring) * 0.13 + Double(index) * 0.017
let size = CGFloat(1.6 + (Double(index + ring) * 0.07).truncatingRemainder(dividingBy: 1.9))
seeds.append(SonicParticleSeed(ring: ring, angle: angle, phase: phase, size: size))
}
}
return seeds
}()
}
@@ -0,0 +1,153 @@
// SupportDeveloperSection.swift
// OSGKeyboard · Shared
//
// Optional voluntary tip block for Settings. Does not gate features.
import SwiftUI
/// iOS Settings card for the consumable support tip.
public struct SupportDeveloperSection: View {
@ObservedObject private var tipManager: TipPurchaseManager
@Environment(\.themePalette) private var palette
private let language: AppUILanguage
@State private var showThankYouAlert = false
@State private var showErrorAlert = false
@State private var errorMessage = ""
public init(
language: AppUILanguage,
tipManager: TipPurchaseManager = .shared
) {
self.language = language
self.tipManager = tipManager
}
public var body: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
Text(SharedL10n.string("tip.title", language: language))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: Spacing.sm) {
SupportDeveloperTipBody(language: language)
if tipManager.supportCount > 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)
}
}
@@ -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"
}
@@ -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<Void, Never>?
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<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified:
throw TipPurchaseError.failedVerification
}
}
}
private enum TipPurchaseError: Error {
case failedVerification
}
+16 -1
View File
@@ -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. Its 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";
+16 -1
View File
@@ -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" = "好";
+39
View File
@@ -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)
}
}
+12
View File
@@ -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
```
+122
View File
@@ -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 |
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="36 367 952 291" fill="none" aria-hidden="true">
<g fill="currentColor">
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z"/>
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z"/>
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z"/>
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z"/>
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+850 -478
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -44,8 +44,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "0.5.3"
CURRENT_PROJECT_VERSION: "22"
MARKETING_VERSION: "0.5.4"
CURRENT_PROJECT_VERSION: "23"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -211,6 +211,7 @@ targets:
embed: true
- sdk: Speech.framework
- sdk: ActivityKit.framework
- sdk: StoreKit.framework
# =========================================================
# Keyboard Extension
@@ -349,6 +350,7 @@ targets:
- sdk: Speech.framework
- sdk: AVFoundation.framework
- sdk: Charts.framework
- sdk: StoreKit.framework
# =========================================================
# 单元测试
@@ -485,6 +487,7 @@ targets:
- sdk: Speech.framework
- sdk: AVFoundation.framework
- sdk: Charts.framework
- sdk: StoreKit.framework
schemes:
OSGKeyboard:
@@ -495,6 +498,7 @@ schemes:
OSGKeyboardLiveActivity: all
run:
config: Debug
storeKitConfiguration: OSGKeyboard.storekit
test:
config: Debug
targets: