feat: add localization support (zh-Hans + en) with instant language switching

This commit is contained in:
lijingjun 2026-05-30 15:24:32 +08:00
parent 5f2ed6667b
commit a18d8b5d5c
7 changed files with 1869 additions and 63 deletions

File diff suppressed because it is too large Load diff

View file

@ -159,6 +159,8 @@ struct HeartbeatApp: App {
var body: some Scene {
WindowGroup {
MainTabView()
.environment(LanguageManager.shared)
.id(LanguageManager.shared.language)
.onAppear {
Task {
let fileManager = FileManager.default

View file

@ -24,57 +24,57 @@ enum AppFeature: String, CaseIterable, Identifiable {
var title: String {
switch self {
case .home:
return "Apps"
return "Apps".localized
case .scripts:
return "Scripts"
return "Scripts".localized
case .tools:
return "Tools"
return "Tools".localized
case .news:
return "News"
return "News".localized
case .console:
return "Console"
return "Console".localized
case .deviceInfo:
return "Device Info"
return "Device Info".localized
case .profiles:
return "App Expiry"
return "App Expiry".localized
case .processes:
return "Processes"
return "Processes".localized
case .location:
return "Location"
return "Location".localized
case .settings:
return "Settings"
return "Settings".localized
}
}
var detail: String {
switch self {
case .home:
return "Manage installed apps"
return "Manage installed apps".localized
case .scripts:
return "Manage and run JS scripts"
return "Manage and run JS scripts".localized
case .tools:
return "Access additional tools"
return "Access additional tools".localized
case .news:
return "Latest StikDebug updates"
return "Latest StikDebug updates".localized
case .console:
return "Live device logs"
return "Live device logs".localized
case .deviceInfo:
return "View detailed device metadata"
return "View detailed device metadata".localized
case .profiles:
return "Check app expiration dates"
return "Check app expiration dates".localized
case .processes:
return "Inspect running apps"
return "Inspect running apps".localized
case .location:
return "Simulate GPS location"
return "Simulate GPS location".localized
case .settings:
return "Configure StikDebug"
return "Configure StikDebug".localized
}
}
var toolTitle: String {
switch self {
case .location:
return "Location Simulation"
return "Location Simulation".localized
default:
return title
}

View file

@ -0,0 +1,18 @@
import SwiftUI
@Observable
class LanguageManager {
static let shared = LanguageManager()
var language: String {
didSet {
UserDefaults.standard.set([language], forKey: "AppleLanguages")
UserDefaults.standard.synchronize()
}
}
init() {
let saved = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first ?? "zh-Hans"
self.language = saved
}
}

View file

@ -220,7 +220,7 @@ struct HomeView: View {
ConsoleLogsView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
Button("Close".localized) {
isShowingConsole = false
}
}
@ -233,7 +233,7 @@ struct HomeView: View {
RunJSView(model: jsModel)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { scriptViewShow = false }
Button("Done".localized) { scriptViewShow = false }
}
}
.navigationTitle(selectedScript)

View file

@ -133,8 +133,8 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
var title: String {
switch self {
case .debuggable: return "JIT"
case .launch: return "Other"
case .debuggable: return "JIT".localized
case .launch: return "Other".localized
}
}
}
@ -186,7 +186,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
}
if showDoneButton {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.fontWeight(.semibold)
Button("Done".localized) { dismiss() }.fontWeight(.semibold)
}
} else {
ToolbarItem(placement: .navigationBarTrailing) {
@ -580,7 +580,7 @@ struct AppButton: View {
.contextMenu {
Button(action: toggleFavorite) {
Label(
favoriteApps.contains(bundleID) ? "Remove Favorite" : "Add to Favorites",
favoriteApps.contains(bundleID) ? "Remove Favorite".localized : "Add to Favorites".localized,
systemImage: favoriteApps.contains(bundleID) ? "star.slash" : "star"
)
.disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4)
@ -588,17 +588,17 @@ struct AppButton: View {
Button {
copyBundleID()
} label: {
Label("Copy Bundle ID", systemImage: "doc.on.doc")
Label("Copy Bundle ID".localized, systemImage: "doc.on.doc")
}
if enableAdvancedOptions {
Button { showScriptPicker = true } label: {
Label("Assign Script", systemImage: "chevron.left.slash.chevron.right")
Label("Assign Script".localized, systemImage: "chevron.left.slash.chevron.right")
}
if assignedScriptName != nil {
Button {
resetScriptAssignment()
} label: {
Label("Reset Script", systemImage: "arrow.uturn.left")
Label("Reset Script".localized, systemImage: "arrow.uturn.left")
}
}
}
@ -607,14 +607,14 @@ struct AppButton: View {
Button {
toggleFavorite()
} label: {
Label(favoriteApps.contains(bundleID) ? "Unfavorite" : "Favorite", systemImage: "star")
Label(favoriteApps.contains(bundleID) ? "Unfavorite".localized : "Favorite".localized, systemImage: "star")
}
.tint(.yellow)
Button {
copyBundleID()
} label: {
Label("Copy ID", systemImage: "doc.on.doc")
Label("Copy ID".localized, systemImage: "doc.on.doc")
}
}
.sheet(isPresented: $showScriptPicker) {

View file

@ -18,6 +18,7 @@ struct SettingsView: View {
@AppStorage("keepAliveAudio") private var keepAliveAudio = true
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
@AppStorage("customTargetIP") private var customTargetIP = ""
@Environment(LanguageManager.self) private var langManager
@State private var isShowingPairingFilePicker = false
@State private var isImportingFile = false
@ -54,15 +55,26 @@ struct SettingsView: View {
Section {
Link(destination: SettingsLinks.githubStars) {
Label("Star on GitHub", systemImage: "star")
Label("Star on GitHub".localized, systemImage: "star")
}
}
Section("Pairing File") {
Section("Language".localized) {
Picker("Language".localized, selection: Binding(
get: { langManager.language },
set: { langManager.language = $0 }
)) {
Text("简体中文").tag("zh-Hans")
Text("English").tag("en")
}
.pickerStyle(.segmented)
}
Section("Pairing File".localized) {
Button {
isShowingPairingFilePicker = true
} label: {
Label("Import Pairing File", systemImage: "doc.badge.plus")
Label("Import Pairing File".localized, systemImage: "doc.badge.plus")
}
.disabled(isImportingFile)
@ -70,7 +82,7 @@ struct SettingsView: View {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Importing pairing file…")
Text("Importing pairing file…".localized)
.font(.caption)
.foregroundStyle(.secondary)
}
@ -87,8 +99,8 @@ struct SettingsView: View {
Section {
Toggle(isOn: $keepAliveAudio) {
VStack(alignment: .leading, spacing: 2) {
Text("Silent Audio")
Text("Plays inaudible audio so iOS keeps the app running.")
Text("Silent Audio".localized)
Text("Plays inaudible audio so iOS keeps the app running.".localized)
.font(.caption).foregroundStyle(.secondary)
}
}
@ -99,8 +111,8 @@ struct SettingsView: View {
Toggle(isOn: $keepAliveLocation) {
VStack(alignment: .leading, spacing: 2) {
Text("Background Location")
Text("Uses low-accuracy location to stay alive when an activity needs it.")
Text("Background Location".localized)
Text("Uses low-accuracy location to stay alive when an activity needs it.".localized)
.font(.caption).foregroundStyle(.secondary)
}
}
@ -109,22 +121,22 @@ struct SettingsView: View {
}
} header: {
Text("Background Keep-Alive")
Text("Background Keep-Alive".localized)
}
Section("Behavior") {
Section("Behavior".localized) {
Toggle(isOn: $overrideTXMDetection) {
VStack(alignment: .leading, spacing: 2) {
Text("Always Run Scripts")
Text("Treats device as TXM-capable to bypass hardware checks.")
Text("Always Run Scripts".localized)
Text("Treats device as TXM-capable to bypass hardware checks.".localized)
.font(.caption).foregroundStyle(.secondary)
}
}
}
Section("Advanced") {
Section("Advanced".localized) {
HStack {
Text("Target Device IP")
Text("Target Device IP".localized)
Spacer()
TextField("10.7.0.1", text: $customTargetIP)
.multilineTextAlignment(.trailing)
@ -132,10 +144,10 @@ struct SettingsView: View {
.submitLabel(.done)
}
Button { openAppFolder() } label: {
Label("App Folder", systemImage: "folder")
Label("App Folder".localized, systemImage: "folder")
}.foregroundStyle(.primary)
Button { showDDIConfirmation = true } label: {
Label("Redownload DDI", systemImage: "arrow.down.circle")
Label("Redownload DDI".localized, systemImage: "arrow.down.circle")
}.foregroundStyle(.primary).disabled(isRedownloadingDDI)
if isRedownloadingDDI {
VStack(alignment: .leading, spacing: 4) {
@ -147,15 +159,15 @@ struct SettingsView: View {
}
}
Section("Help") {
Section("Help".localized) {
Link(destination: SettingsLinks.pairingFileGuide) {
Label("Pairing File Guide", systemImage: "questionmark.circle")
Label("Pairing File Guide".localized, systemImage: "questionmark.circle")
}
Link(destination: SettingsLinks.localDevVPN) {
Label("Download LocalDevVPN", systemImage: "arrow.down.circle")
Label("Download LocalDevVPN".localized, systemImage: "arrow.down.circle")
}
Link(destination: SettingsLinks.discord) {
Label("Discord Support", systemImage: "bubble.left.and.bubble.right")
Label("Discord Support".localized, systemImage: "bubble.left.and.bubble.right")
}
}
@ -166,7 +178,7 @@ struct SettingsView: View {
.listRowBackground(Color.clear)
}
}
.navigationTitle("Settings")
.navigationTitle("Settings".localized)
}
.fileImporter(
isPresented: $isShowingPairingFilePicker,
@ -184,27 +196,27 @@ struct SettingsView: View {
do {
try PairingFileStore.importFromPicker(url, fileManager: fileManager)
isImportingFile = false
pairingImportMessage = ("Imported successfully", false)
pairingImportMessage = ("Imported successfully".localized, false)
startTunnelInBackground()
schedulePairingStatusDismiss()
} catch {
isImportingFile = false
pairingImportMessage = ("Import failed: \(error.localizedDescription)", true)
pairingImportMessage = (String(format: "Import failed: %@".localized, error.localizedDescription), true)
schedulePairingStatusDismiss()
}
case .failure(let error):
isImportingFile = false
pairingImportMessage = ("Import failed: \(error.localizedDescription)", true)
pairingImportMessage = (String(format: "Import failed: %@".localized, error.localizedDescription), true)
schedulePairingStatusDismiss()
}
}
.confirmationDialog("Redownload DDI Files?", isPresented: $showDDIConfirmation, titleVisibility: .visible) {
Button("Redownload", role: .destructive) {
.confirmationDialog("Redownload DDI Files?".localized, isPresented: $showDDIConfirmation, titleVisibility: .visible) {
Button("Redownload".localized, role: .destructive) {
redownloadDDIPressed()
}
Button("Cancel", role: .cancel) { }
Button("Cancel".localized, role: .cancel) { }
} message: {
Text("Existing DDI files will be removed before downloading fresh copies.")
Text("Existing DDI files will be removed before downloading fresh copies.".localized)
}
}
@ -235,7 +247,7 @@ struct SettingsView: View {
await MainActor.run {
isRedownloadingDDI = true
ddiDownloadProgress = 0
ddiStatusMessage = "Preparing download…"
ddiStatusMessage = "Preparing download…".localized
ddiResultMessage = nil
}
do {
@ -247,12 +259,12 @@ struct SettingsView: View {
}
await MainActor.run {
isRedownloadingDDI = false
ddiResultMessage = ("DDI files refreshed successfully.", false)
ddiResultMessage = ("DDI files refreshed successfully.".localized, false)
}
} catch {
await MainActor.run {
isRedownloadingDDI = false
ddiResultMessage = ("Failed to redownload DDI files: \(error.localizedDescription)", true)
ddiResultMessage = (String(format: "Failed to redownload DDI files: %@".localized, error.localizedDescription), true)
}
}
}