chore: adopt source-available license and strengthen privacy disclosures

Replace MIT with a restrictive source-available license and align README/docs copy. Add cloud polish acknowledgment, privacy manifest updates, voice history disclosure, export compliance metadata, and support links for App Store readiness.
This commit is contained in:
Rocky
2026-06-21 16:45:29 +08:00
parent 993784d223
commit 5e5122f172
21 changed files with 429 additions and 64 deletions
+2
View File
@@ -36,6 +36,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppTransportSecurity</key>
+14 -1
View File
@@ -7,7 +7,20 @@
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
+5
View File
@@ -8,4 +8,9 @@ enum LegalLinks {
static var privacyPolicyURL: URL? {
URL(string: "https://hkgood.github.io/OSGKeyboard/privacy/")
}
/// Support / feedback (GitHub Issues).
static var supportURL: URL? {
URL(string: "https://github.com/hkgood/OSGKeyboard/issues")
}
}
@@ -0,0 +1,93 @@
// PrivacyDisclosureViews.swift
// OSGKeyboard · Main App
//
// Reusable privacy / data-flow disclosure blocks for Settings and Onboarding.
import SwiftUI
import OSGKeyboardShared
/// Card-style block for Full Access and similar permission explanations.
struct PrivacyInfoCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: LocalizedStringKey
let bodyText: LocalizedStringKey
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text(bodyText)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
/// Footnote when Cloud polish is active text goes to the user's configured API.
struct CloudPolishDisclosureBanner: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
Image(systemName: "info.circle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.accent)
Text("settings.privacy.cloud.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.accentMuted.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
}
}
/// First-time confirmation before enabling Cloud polish (user-configured third-party API).
struct CloudSharingAcknowledgmentModifier: ViewModifier {
@ObservedObject var config: ProviderConfig
@Binding var isPresented: Bool
let onConfirm: () -> Void
let onCancel: () -> Void
func body(content: Content) -> some View {
content
.alert("settings.privacy.cloud.alert.title", isPresented: $isPresented) {
Button("common.continue") {
config.hasAcknowledgedCloudSharing = true
onConfirm()
}
Button("common.cancel", role: .cancel) {
onCancel()
}
} message: {
Text("settings.privacy.cloud.alert.message")
}
}
}
extension View {
func cloudSharingAcknowledgment(
config: ProviderConfig,
isPresented: Binding<Bool>,
onConfirm: @escaping () -> Void,
onCancel: @escaping () -> Void
) -> some View {
modifier(CloudSharingAcknowledgmentModifier(
config: config,
isPresented: isPresented,
onConfirm: onConfirm,
onCancel: onCancel
))
}
}
+31 -3
View File
@@ -22,6 +22,8 @@ struct EnginePickerSection: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showCloudAcknowledgment = false
@State private var pendingEngineSelection: String?
var body: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
@@ -46,7 +48,17 @@ struct EnginePickerSection: View {
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
if config.engineMode == "cloud" {
CloudPolishDisclosureBanner()
}
}
.cloudSharingAcknowledgment(
config: config,
isPresented: $showCloudAcknowledgment,
onConfirm: { applyPendingEngineSelection() },
onCancel: { pendingEngineSelection = nil }
)
}
private var localSubtitle: String {
@@ -64,10 +76,13 @@ struct EnginePickerSection: View {
) -> some View {
let isSelected = config.engineMode == id
return Button {
withAnimation(.easeInOut(duration: 0.2)) {
config.engineMode = id
if id == "local" { config.modeId = "transcribe" }
guard config.engineMode != id else { return }
if id == "cloud", !config.hasAcknowledgedCloudSharing {
pendingEngineSelection = id
showCloudAcknowledgment = true
return
}
selectEngine(id)
} label: {
HStack(spacing: Spacing.sm) {
engineMark(
@@ -97,6 +112,19 @@ struct EnginePickerSection: View {
.buttonStyle(.plain)
}
private func selectEngine(_ id: String) {
withAnimation(.easeInOut(duration: 0.2)) {
config.engineMode = id
if id == "local" { config.modeId = "transcribe" }
}
}
private func applyPendingEngineSelection() {
guard let id = pendingEngineSelection else { return }
pendingEngineSelection = nil
selectEngine(id)
}
@ViewBuilder
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
ZStack {
+7
View File
@@ -39,6 +39,13 @@ struct HistoryView: View {
}
}
Text("history.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.sm)
ZStack {
palette.background.ignoresSafeArea()
+7
View File
@@ -617,6 +617,13 @@ private struct EnableKeyboardPage: View {
.padding(.horizontal, Spacing.xl)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
PrivacyInfoCard(
title: "settings.privacy.fullAccess.title",
bodyText: "settings.privacy.fullAccess.body"
)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.lg)
Button {
AppPermissions.openSystemSettings()
} label: {
+5
View File
@@ -42,6 +42,7 @@ struct SettingsView: View {
confirmActionTitle: "common.reset"
) {
config.reset()
SpeechHistoryStore.shared.clearAll()
}
if presentation == .sheet {
Button("common.done") { dismiss() }
@@ -301,6 +302,10 @@ struct SettingsView: View {
footerLinkRow(title: "settings.privacy.policy", url: url)
Divider().background(palette.divider)
}
if let url = LegalLinks.supportURL {
footerLinkRow(title: "settings.link.support", url: url)
Divider().background(palette.divider)
}
footerLinkRow(
title: "settings.link.github",
url: URL(string: "https://github.com/hkgood/OSGKeyboard")!
+4
View File
@@ -0,0 +1,4 @@
/* Permission prompts — English */
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.";
+6 -2
View File
@@ -84,7 +84,7 @@
/* Settings */
"settings.title" = "Settings";
"settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, and base URL will be cleared.";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Engine";
"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed.";
@@ -92,7 +92,7 @@
"settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
"settings.engine.cloud.title" = "Cloud polish";
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required.";
"settings.engine.cloud.subtitle" = "On-device ASR + polish via your API. Text is sent to the endpoint you configure.";
"settings.provider.title" = "Provider";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"provider.openai" = "OpenAI";
@@ -123,6 +123,10 @@
"settings.privacy.policy" = "Privacy Policy";
"settings.privacy.fullAccess.title" = "About Full Access";
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
"settings.privacy.cloud.body" = "In Cloud polish mode, transcribed text is sent to the API endpoint you configure (e.g. OpenAI or your own server). OSGKeyboard does not operate servers and does not store transcripts in the cloud.";
"settings.privacy.cloud.alert.title" = "Third-party API";
"settings.privacy.cloud.alert.message" = "Cloud polish sends transcribed text to the third-party API you configure. OSGKeyboard never stores data on our servers. Continue?";
"settings.link.support" = "Help & Feedback";
"settings.link.github" = "GitHub";
/* App group error */
@@ -0,0 +1,4 @@
/* 权限说明 — 简体中文 */
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,并在语音会话运行期间保持后台音频会话。";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 使用设备端语音识别将你的语音转为文字。音频仅在设备上处理,不会上传用于转写。";
@@ -84,7 +84,7 @@
/* Settings */
"settings.title" = "设置";
"settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "API key、model 和 base URL 都会被清空。";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "引擎";
"settings.engine.subtitle" = "本地不用 Key,只转文字;云端会润色,要配 Key。";
@@ -92,7 +92,7 @@
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端润色";
"settings.engine.cloud.subtitle" = "先转文字,再用 AI 润色,要 API Key";
"settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往该第三方服务";
"settings.provider.title" = "提供商";
"settings.provider.subtitle" = "选择 LLM 提供商。";
"provider.openai" = "OpenAI";
@@ -123,6 +123,10 @@
"settings.privacy.policy" = "隐私政策";
"settings.privacy.fullAccess.title" = "关于完全访问";
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
"settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。";
"settings.privacy.cloud.alert.title" = "第三方 API";
"settings.privacy.cloud.alert.message" = "云端润色会把转写文字发到你配置的第三方 API。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
"settings.link.support" = "帮助与反馈";
"settings.link.github" = "GitHub";
/* App group error */