Skip to content

Commit 64fe14c

Browse files
committed
feat: implement Google Analytics integration for launcher usage and settings tracking
1 parent dd996b0 commit 64fe14c

6 files changed

Lines changed: 147 additions & 4 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
GO_GA_MEASUREMENT_ID=G-XXXXXXXXXXXX
2+
GO_GA_API_SECRET=XXXXXXXXXXXXXXXXXX

Platform/MacOS/Launcher/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ build/
22
outputs/
33
.DS_Store
44
launcher.log
5+
.env
6+
!.env.example
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Foundation
2+
3+
enum Analytics {
4+
private static let measurementId =
5+
(Bundle.main.object(forInfoDictionaryKey: "GAMeasurementId") as? String) ?? ""
6+
private static let apiSecret =
7+
(Bundle.main.object(forInfoDictionaryKey: "GAApiSecret") as? String) ?? ""
8+
private static let collectEndpoint = "https://www.google-analytics.com/mp/collect"
9+
10+
private static let optOutKey = "GA_ANALYTICS_OPT_OUT"
11+
private static let clientIdKey = "GA_CLIENT_ID"
12+
13+
static var isOptedOut: Bool {
14+
get { UserDefaults.standard.bool(forKey: optOutKey) }
15+
set { UserDefaults.standard.set(newValue, forKey: optOutKey) }
16+
}
17+
18+
private static let clientId: String = {
19+
if let existing = UserDefaults.standard.string(forKey: clientIdKey) {
20+
return existing
21+
}
22+
let generated = UUID().uuidString
23+
UserDefaults.standard.set(generated, forKey: clientIdKey)
24+
return generated
25+
}()
26+
27+
private static let launcherVersion: String =
28+
(Bundle.main.object(forInfoDictionaryKey: "GOLauncherVersion") as? String) ?? "unknown"
29+
30+
static func log(_ name: String, _ params: [String: Any] = [:]) {
31+
guard !isOptedOut, measurementId.hasPrefix("G-"), !apiSecret.isEmpty else { return }
32+
33+
var enriched = params
34+
enriched["engagement_time_msec"] = 1
35+
enriched["launcher_version"] = launcherVersion
36+
37+
let payload: [String: Any] = [
38+
"client_id": clientId,
39+
"events": [["name": name, "params": enriched]]
40+
]
41+
42+
guard
43+
let body = try? JSONSerialization.data(withJSONObject: payload),
44+
let url = URL(string: "\(collectEndpoint)?measurement_id=\(measurementId)&api_secret=\(apiSecret)")
45+
else { return }
46+
47+
var request = URLRequest(url: url)
48+
request.httpMethod = "POST"
49+
request.httpBody = body
50+
URLSession.shared.dataTask(with: request).resume()
51+
}
52+
53+
static func logOpen(uiLanguage: String) {
54+
log("launcher_open", [
55+
"ui_language": uiLanguage,
56+
"os_version": ProcessInfo.processInfo.operatingSystemVersionString
57+
])
58+
}
59+
60+
static func logSettingsSnapshot(_ vm: LauncherViewModel) {
61+
log("settings_snapshot", [
62+
"windowed_edge_scroll": vm.isWindowedEdgeScrollEnabled.gaFlag,
63+
"hotkey_labels": vm.showHotkeyLabels.gaFlag,
64+
"game_language": vm.gameLanguage,
65+
"ui_language": vm.selectedLanguage,
66+
"limit_framerate": vm.limitFramerate.gaFlag,
67+
"fps_limit": Int(vm.fpsLimit),
68+
"stats_overlay": vm.statsOverlay.gaFlag,
69+
"alternative_endpoint": vm.useAlternativeEndpoint.gaFlag,
70+
"verbose_logging": vm.verboseLogging.gaFlag,
71+
"camera_move_speed": vm.cameraMoveSpeed
72+
])
73+
}
74+
75+
static func logSettingChanged(_ key: String, value: Any) {
76+
log("setting_changed_\(key)", ["setting_value": "\(value)"])
77+
}
78+
79+
static func logTabSelected(_ tab: String) {
80+
log("tab_selected", ["tab": tab])
81+
}
82+
83+
static func logGameLaunched() {
84+
log("game_launched")
85+
}
86+
87+
static func logPatchStarted(source: String) {
88+
log("patch_started", ["source": source])
89+
}
90+
}
91+
92+
private extension Bool {
93+
var gaFlag: Int { self ? 1 : 0 }
94+
}

Platform/MacOS/Launcher/Sources/LauncherViewModel.swift

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,20 @@ class LauncherViewModel: ObservableObject {
2424
"ScreenEdgeScrollEnabledInWindowedApp": valStr,
2525
"CursorCaptureEnabledInWindowedGame": isWindowedEdgeScrollEnabled ? "yes" : "no"
2626
])
27+
reportSettingChange("windowed_edge_scroll", isWindowedEdgeScrollEnabled)
2728
}
2829
}
2930
@Published var showHotkeyLabels: Bool = SettingsDefaults.showHotkeyLabels {
3031
didSet {
3132
OptionsIniHelper.writeValue(value: showHotkeyLabels ? "yes" : "no", forKey: "ShowHotKeyLabels")
33+
reportSettingChange("hotkey_labels", showHotkeyLabels)
3234
}
3335
}
3436
@Published var gameLanguage: String = SettingsDefaults.gameLanguage {
3537
didSet {
3638
guard !isInitializing else { return }
3739
OptionsIniHelper.writeValue(value: gameLanguage, forKey: "Language")
40+
reportSettingChange("game_language", gameLanguage)
3841
}
3942
}
4043

@@ -51,23 +54,35 @@ class LauncherViewModel: ObservableObject {
5154

5255
// settings.json render settings
5356
@Published var limitFramerate: Bool = SettingsDefaults.limitFramerate {
54-
didSet { saveSettings() }
57+
didSet {
58+
saveSettings()
59+
reportSettingChange("limit_framerate", limitFramerate)
60+
}
5561
}
5662
@Published var fpsLimit: Double = SettingsDefaults.fpsLimit {
5763
didSet { saveSettings() }
5864
}
5965
@Published var statsOverlay: Bool = SettingsDefaults.statsOverlay {
60-
didSet { saveSettings() }
66+
didSet {
67+
saveSettings()
68+
reportSettingChange("stats_overlay", statsOverlay)
69+
}
6170
}
6271

6372
// settings.json network settings
6473
@Published var useAlternativeEndpoint: Bool = SettingsDefaults.useAlternativeEndpoint {
65-
didSet { saveSettings() }
74+
didSet {
75+
saveSettings()
76+
reportSettingChange("alternative_endpoint", useAlternativeEndpoint)
77+
}
6678
}
6779

6880
// settings.json debug settings
6981
@Published var verboseLogging: Bool = SettingsDefaults.verboseLogging {
70-
didSet { saveSettings() }
82+
didSet {
83+
saveSettings()
84+
reportSettingChange("verbose_logging", verboseLogging)
85+
}
7186
}
7287

7388
var steamCMD = SteamCMDManager()
@@ -135,13 +150,21 @@ class LauncherViewModel: ObservableObject {
135150

136151
self.isInitializing = false
137152
updateChecker.startPeriodicChecks()
153+
154+
Analytics.logOpen(uiLanguage: selectedLanguage)
155+
Analytics.logSettingsSnapshot(self)
138156
}
139157

140158
func saveCredentials() {
141159
guard !steamUsername.isEmpty, !steamPassword.isEmpty else { return }
142160
KeychainHelper.save(account: steamUsername, password: steamPassword)
143161
}
144162

163+
private func reportSettingChange(_ key: String, _ value: Any) {
164+
guard !isInitializing else { return }
165+
Analytics.logSettingChanged(key, value: value)
166+
}
167+
145168
private func saveSettings() {
146169
guard !isInitializing else { return }
147170
SettingsJsonHelper.writeSettings(
@@ -244,6 +267,7 @@ class LauncherViewModel: ObservableObject {
244267
}
245268

246269
func confirmPatching() {
270+
Analytics.logPatchStarted(source: activeTab.rawValue)
247271
switch activeTab {
248272
case .steam:
249273
assetPatcher.startPatching(rootDir: steamCMD.installDir, zhDir: steamCMD.assetsDir)
@@ -280,6 +304,8 @@ class LauncherViewModel: ObservableObject {
280304
do {
281305
try task.run()
282306

307+
Analytics.logGameLaunched()
308+
283309
DispatchQueue.global().async {
284310
Thread.sleep(forTimeInterval: 0.5)
285311
DispatchQueue.main.async {

Platform/MacOS/Launcher/Sources/MainView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ struct MainView: View {
148148
}
149149

150150
return Button(action: {
151+
if viewModel.activeTab != tab {
152+
Analytics.logTabSelected(tab.rawValue)
153+
}
151154
withAnimation(.easeInOut(duration: 0.2)) {
152155
viewModel.activeTab = tab
153156
}

Platform/MacOS/Launcher/assemble_distribution.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
VERSION="1.4.1"
1818
BUILD="10"
1919

20+
if [ -f ".env" ]; then
21+
set -a
22+
. ./.env
23+
set +a
24+
fi
25+
2026
LAUNCHER_NAME="GeneralsLauncher"
2127
FINAL_APP_NAME="Generals Online"
2228
CMAKE_APP_DIR="../../../build/macos/GeneralsMD/GeneralsOnlineZH.app"
@@ -119,6 +125,16 @@ PLIST_FILE="$CONTENTS_DIR/Info.plist"
119125
/usr/libexec/PlistBuddy -c "Add :GOLauncherBuild string $BUILD" "$PLIST_FILE"
120126
echo " Launcher version: v$VERSION (build $BUILD)"
121127

128+
if [ -n "$GO_GA_MEASUREMENT_ID" ] && [ -n "$GO_GA_API_SECRET" ]; then
129+
/usr/libexec/PlistBuddy -c "Delete :GAMeasurementId" "$PLIST_FILE" 2>/dev/null || true
130+
/usr/libexec/PlistBuddy -c "Add :GAMeasurementId string $GO_GA_MEASUREMENT_ID" "$PLIST_FILE"
131+
/usr/libexec/PlistBuddy -c "Delete :GAApiSecret" "$PLIST_FILE" 2>/dev/null || true
132+
/usr/libexec/PlistBuddy -c "Add :GAApiSecret string $GO_GA_API_SECRET" "$PLIST_FILE"
133+
echo " Analytics: enabled ($GO_GA_MEASUREMENT_ID)"
134+
else
135+
echo " Analytics: disabled (no .env keys)"
136+
fi
137+
122138
echo "📝 [5/7] Copying HTML instructions..."
123139
if [ -f "www/instructions.html" ]; then
124140
cp "www/instructions.html" "$OUTPUTS_DIR/$INSTRUCTIONS_NAME"

0 commit comments

Comments
 (0)