PDF BOM export

This commit is contained in:
Stefan Lange-Hegermann
2025-11-07 11:18:03 +01:00
parent ced06f9eb6
commit b11d627fdb
209 changed files with 2242 additions and 20663 deletions

View File

@@ -6,13 +6,6 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
0DBC1CAB8BE5C690AE39630C /* Pods_Cable.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2B59C6617C5C88811F972C70 /* Pods_Cable.framework */; };
156FA26BC2A070D3E79DBC53 /* Pods_Cable_CableUITestsScreenshot.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 838FF4DAE13C48C1BCC63760 /* Pods_Cable_CableUITestsScreenshot.framework */; };
4472B945421CAB58A81AAF03 /* Pods_Cable_CableUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BCB07623E3B49D249C01C67E /* Pods_Cable_CableUITests.framework */; };
85A2E22A9DF253A619C833B2 /* Pods_CableTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 251C4DF01338D1FECB418EE7 /* Pods_CableTests.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
@@ -38,22 +31,10 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
0A51CE1631634DF868118C1B /* Pods-CableTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CableTests.debug.xcconfig"; path = "Target Support Files/Pods-CableTests/Pods-CableTests.debug.xcconfig"; sourceTree = "<group>"; };
10D59C9B7039F7390CB71DAA /* Pods-CableTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CableTests.release.xcconfig"; path = "Target Support Files/Pods-CableTests/Pods-CableTests.release.xcconfig"; sourceTree = "<group>"; };
251C4DF01338D1FECB418EE7 /* Pods_CableTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_CableTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2B59C6617C5C88811F972C70 /* Pods_Cable.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Cable.framework; sourceTree = BUILT_PRODUCTS_DIR; };
340C908BC5784DC053266DDB /* Pods-Cable-CableUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cable-CableUITests.debug.xcconfig"; path = "Target Support Files/Pods-Cable-CableUITests/Pods-Cable-CableUITests.debug.xcconfig"; sourceTree = "<group>"; };
3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITestsScreenshot.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BCC2E72C0FD00247EC8 /* Cable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cable.app; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
4017B33DF440FA2BC612E06E /* Pods-Cable-CableUITestsScreenshot.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cable-CableUITestsScreenshot.release.xcconfig"; path = "Target Support Files/Pods-Cable-CableUITestsScreenshot/Pods-Cable-CableUITestsScreenshot.release.xcconfig"; sourceTree = "<group>"; };
838FF4DAE13C48C1BCC63760 /* Pods_Cable_CableUITestsScreenshot.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Cable_CableUITestsScreenshot.framework; sourceTree = BUILT_PRODUCTS_DIR; };
83D6CB62ED3959EC1EC8027D /* Pods-Cable-CableUITestsScreenshot.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cable-CableUITestsScreenshot.debug.xcconfig"; path = "Target Support Files/Pods-Cable-CableUITestsScreenshot/Pods-Cable-CableUITestsScreenshot.debug.xcconfig"; sourceTree = "<group>"; };
B5E79A38FD11ED9D9A21BB7E /* Pods-Cable.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cable.debug.xcconfig"; path = "Target Support Files/Pods-Cable/Pods-Cable.debug.xcconfig"; sourceTree = "<group>"; };
BCB07623E3B49D249C01C67E /* Pods_Cable_CableUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Cable_CableUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BED2A9D04FDB84725E0725E9 /* Pods-Cable.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cable.release.xcconfig"; path = "Target Support Files/Pods-Cable/Pods-Cable.release.xcconfig"; sourceTree = "<group>"; };
F4D8F0C9760202BC765B4260 /* Pods-Cable-CableUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cable-CableUITests.release.xcconfig"; path = "Target Support Files/Pods-Cable-CableUITests/Pods-Cable-CableUITests.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -64,11 +45,21 @@
);
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
};
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CableUITestsScreenshotLaunchTests.swift,
);
target = 3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */,
);
path = CableUITestsScreenshot;
sourceTree = "<group>";
};
@@ -97,7 +88,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
156FA26BC2A070D3E79DBC53 /* Pods_Cable_CableUITestsScreenshot.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -105,7 +95,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0DBC1CAB8BE5C690AE39630C /* Pods_Cable.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -113,7 +102,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
85A2E22A9DF253A619C833B2 /* Pods_CableTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -121,7 +109,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4472B945421CAB58A81AAF03 /* Pods_Cable_CableUITests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -137,7 +124,6 @@
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
3E5C0BCD2E72C0FD00247EC8 /* Products */,
57738E9B07763CFA62681EEE /* Pods */,
9D16D1FE8C8B34C13C51D389 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -155,29 +141,10 @@
57738E9B07763CFA62681EEE /* Pods */ = {
isa = PBXGroup;
children = (
B5E79A38FD11ED9D9A21BB7E /* Pods-Cable.debug.xcconfig */,
BED2A9D04FDB84725E0725E9 /* Pods-Cable.release.xcconfig */,
340C908BC5784DC053266DDB /* Pods-Cable-CableUITests.debug.xcconfig */,
F4D8F0C9760202BC765B4260 /* Pods-Cable-CableUITests.release.xcconfig */,
83D6CB62ED3959EC1EC8027D /* Pods-Cable-CableUITestsScreenshot.debug.xcconfig */,
4017B33DF440FA2BC612E06E /* Pods-Cable-CableUITestsScreenshot.release.xcconfig */,
0A51CE1631634DF868118C1B /* Pods-CableTests.debug.xcconfig */,
10D59C9B7039F7390CB71DAA /* Pods-CableTests.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
9D16D1FE8C8B34C13C51D389 /* Frameworks */ = {
isa = PBXGroup;
children = (
2B59C6617C5C88811F972C70 /* Pods_Cable.framework */,
BCB07623E3B49D249C01C67E /* Pods_Cable_CableUITests.framework */,
838FF4DAE13C48C1BCC63760 /* Pods_Cable_CableUITestsScreenshot.framework */,
251C4DF01338D1FECB418EE7 /* Pods_CableTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -185,11 +152,9 @@
isa = PBXNativeTarget;
buildConfigurationList = 3E37F65D2E93FB6F00836187 /* Build configuration list for PBXNativeTarget "CableUITestsScreenshot" */;
buildPhases = (
ECF8C5947A59DAC9118AE4F4 /* [CP] Check Pods Manifest.lock */,
3E37F6512E93FB6F00836187 /* Sources */,
3E37F6522E93FB6F00836187 /* Frameworks */,
3E37F6532E93FB6F00836187 /* Resources */,
611809BC8E1F9DF30E9C4629 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -208,11 +173,9 @@
isa = PBXNativeTarget;
buildConfigurationList = 3E5C0BF02E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "Cable" */;
buildPhases = (
3585B809C20C4D3B1FE82C78 /* [CP] Check Pods Manifest.lock */,
3E5C0BC82E72C0FD00247EC8 /* Sources */,
3E5C0BC92E72C0FD00247EC8 /* Frameworks */,
3E5C0BCA2E72C0FD00247EC8 /* Resources */,
E8C196B44C4F00DA4E300C55 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -230,7 +193,6 @@
isa = PBXNativeTarget;
buildConfigurationList = 3E5C0BF52E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "CableTests" */;
buildPhases = (
3D80694CE29BD68AE168E8DF /* [CP] Check Pods Manifest.lock */,
3E5C0BD92E72C0FE00247EC8 /* Sources */,
3E5C0BDA2E72C0FE00247EC8 /* Frameworks */,
3E5C0BDB2E72C0FE00247EC8 /* Resources */,
@@ -252,11 +214,9 @@
isa = PBXNativeTarget;
buildConfigurationList = 3E5C0BF82E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "CableUITests" */;
buildPhases = (
D1A689F595A65E0530AAACB0 /* [CP] Check Pods Manifest.lock */,
3E5C0BE32E72C0FE00247EC8 /* Sources */,
3E5C0BE42E72C0FE00247EC8 /* Frameworks */,
3E5C0BE52E72C0FE00247EC8 /* Resources */,
3808009BC0D951592701EA88 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -355,160 +315,6 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3585B809C20C4D3B1FE82C78 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Cable-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3808009BC0D951592701EA88 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Cable-CableUITests/Pods-Cable-CableUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Cable-CableUITests/Pods-Cable-CableUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Cable-CableUITests/Pods-Cable-CableUITests-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
3D80694CE29BD68AE168E8DF /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-CableTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
611809BC8E1F9DF30E9C4629 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Cable-CableUITestsScreenshot/Pods-Cable-CableUITestsScreenshot-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Cable-CableUITestsScreenshot/Pods-Cable-CableUITestsScreenshot-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Cable-CableUITestsScreenshot/Pods-Cable-CableUITestsScreenshot-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
D1A689F595A65E0530AAACB0 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Cable-CableUITests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E8C196B44C4F00DA4E300C55 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Cable/Pods-Cable-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Cable/Pods-Cable-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Cable/Pods-Cable-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
ECF8C5947A59DAC9118AE4F4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Cable-CableUITestsScreenshot-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3E37F6512E93FB6F00836187 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -561,7 +367,6 @@
/* Begin XCBuildConfiguration section */
3E37F65E2E93FB6F00836187 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 83D6CB62ED3959EC1EC8027D /* Pods-Cable-CableUITestsScreenshot.debug.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -583,7 +388,6 @@
};
3E37F65F2E93FB6F00836187 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 4017B33DF440FA2BC612E06E /* Pods-Cable-CableUITestsScreenshot.release.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -605,7 +409,6 @@
};
3E5C0BF12E72C0FE00247EC8 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B5E79A38FD11ED9D9A21BB7E /* Pods-Cable.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -642,7 +445,6 @@
};
3E5C0BF22E72C0FE00247EC8 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BED2A9D04FDB84725E0725E9 /* Pods-Cable.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -802,7 +604,6 @@
};
3E5C0BF62E72C0FE00247EC8 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0A51CE1631634DF868118C1B /* Pods-CableTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -822,7 +623,6 @@
};
3E5C0BF72E72C0FE00247EC8 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 10D59C9B7039F7390CB71DAA /* Pods-CableTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -842,7 +642,6 @@
};
3E5C0BF92E72C0FE00247EC8 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 340C908BC5784DC053266DDB /* Pods-Cable-CableUITests.debug.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -860,7 +659,6 @@
};
3E5C0BFA2E72C0FE00247EC8 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F4D8F0C9760202BC765B4260 /* Pods-Cable-CableUITests.release.xcconfig */;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;

View File

@@ -4,7 +4,4 @@
<FileRef
location = "group:Cable.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -7,18 +7,30 @@
import Foundation
import PostHog
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
let POSTHOG_API_KEY = "phc_icZY61N3vdg4Sr3lzz9DNAqCRh6hCorVJbytduWORO9"
let POSTHOG_HOST = "https://eu.i.posthog.com"
let config = PostHogConfig(apiKey: POSTHOG_API_KEY, host: POSTHOG_HOST)
PostHogSDK.shared.setup(config)
AnalyticsTracker.configure()
NSLog("Launched")
return true
}
}
enum AnalyticsTracker {
static func configure() {}
static func log(_ event: String, properties: [String: Any] = [:]) {
#if DEBUG
if properties.isEmpty {
NSLog("Analytics: %@", event)
} else {
let formatted = properties
.map { "\($0.key)=\($0.value)" }
.sorted()
.joined(separator: ", ")
NSLog("Analytics: %@ { %@ }", event, formatted)
}
#endif
}
}

View File

@@ -84,6 +84,29 @@
"bom.navigation.title.system" = "BOM %@";
"bom.size.unknown" = "Size TBD";
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
"bom.empty.message" = "No components saved in this system yet.";
"bom.export.pdf.button" = "Export PDF";
"bom.export.pdf.error.title" = "Export Failed";
"bom.export.pdf.error.empty" = "Add at least one component before exporting.";
"bom.pdf.header.title" = "System Bill of Materials";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Unit System: %@";
"bom.pdf.placeholder.empty" = "No components available.";
"bom.pdf.page.number" = "Page %d";
"bom.category.components.title" = "Components & Chargers";
"bom.category.components.subtitle" = "Primary devices, controllers, and charging gear.";
"bom.category.batteries.title" = "Batteries";
"bom.category.batteries.subtitle" = "House banks and storage.";
"bom.category.cables.title" = "Cables";
"bom.category.cables.subtitle" = "Sized power runs for every circuit.";
"bom.category.fuses.title" = "Fuses";
"bom.category.fuses.subtitle" = "Circuit protection and holders.";
"bom.category.accessories.title" = "Accessories";
"bom.category.accessories.subtitle" = "Fuses, lugs, and supporting hardware.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"cable.pro.privacy.label" = "Privacy";
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
"cable.pro.terms.label" = "Terms";
@@ -277,6 +300,7 @@
"cable.pro.alert.restored.body" = "Your purchases are available again.";
"cable.pro.alert.error.title" = "Purchase Failed";
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
"generic.ok" = "OK";
"cable.pro.trial.badge" = "Includes a %@ free trial";
"cable.pro.subscription.renews" = "Renews %@.";
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";

View File

@@ -37,7 +37,7 @@ struct CableApp: App {
_unitSettings = StateObject(wrappedValue: unitSettings)
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
#if DEBUG
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
#endif
}

View File

@@ -16,6 +16,7 @@ final class SavedCharger {
var remoteIconURLString: String?
var affiliateURLString: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = []
var identifier: String
init(
@@ -32,6 +33,7 @@ final class SavedCharger {
remoteIconURLString: String? = nil,
affiliateURLString: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString
) {
self.id = id
@@ -47,6 +49,7 @@ final class SavedCharger {
self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier
}

View File

@@ -34,31 +34,12 @@ class CableCalculator: ObservableObject {
}
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048 // ft to m
// Simplified calculation: minimum cross-section based on current and voltage drop
let maxVoltageDrop = voltage * 0.05 // 5% voltage drop limit
let resistivity = 0.017 // Copper resistivity at 20°C (Ωmm²/m)
let calculatedMinCrossSection = (2 * current * lengthInMeters * resistivity) / maxVoltageDrop
if unitSystem == .imperial {
// Standard AWG wire sizes
let standardAWG = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
let awgCrossSections = [0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0]
// Find the smallest AWG that meets the requirement
for (index, crossSection) in awgCrossSections.enumerated() {
if crossSection >= calculatedMinCrossSection {
return Double(standardAWG[index])
}
}
return Double(standardAWG.last!) // Largest available
} else {
// Standard metric cable cross-sections in mm²
let standardSizes = [0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0]
// Find the smallest standard size that meets the requirement
return standardSizes.first { $0 >= max(0.75, calculatedMinCrossSection) } ?? standardSizes.last!
}
ElectricalCalculations.recommendedCrossSection(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
func crossSection(for unitSystem: UnitSystem) -> Double {
@@ -66,42 +47,34 @@ class CableCalculator: ObservableObject {
}
func voltageDrop(for unitSystem: UnitSystem) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048
let crossSectionMM2 = unitSystem == .metric ? crossSection(for: unitSystem) : crossSectionFromAWG(crossSection(for: unitSystem))
let resistivity = 0.017
let effectiveCurrent = current // Always use the current property which gets updated
return (2 * effectiveCurrent * lengthInMeters * resistivity) / crossSectionMM2
ElectricalCalculations.voltageDrop(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
(voltageDrop(for: unitSystem) / voltage) * 100
ElectricalCalculations.voltageDropPercentage(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
func powerLoss(for unitSystem: UnitSystem) -> Double {
let effectiveCurrent = current
return effectiveCurrent * voltageDrop(for: unitSystem)
ElectricalCalculations.powerLoss(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
var recommendedFuse: Int {
let targetFuse = current * 1.25 // 125% of load current for safety
// Common fuse values in amperes
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
// Find the smallest standard fuse that's >= target
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
}
// AWG conversion helper for voltage drop calculations
private func crossSectionFromAWG(_ awg: Double) -> Double {
let awgSizes = [20: 0.519, 18: 0.823, 16: 1.31, 14: 2.08, 12: 3.31, 10: 5.26, 8: 8.37, 6: 13.3, 4: 21.2, 2: 33.6, 1: 42.4, 0: 53.5]
// Handle 00, 000, 0000 AWG (represented as negative values)
if awg == 00 { return 67.4 }
if awg == 000 { return 85.0 }
if awg == 0000 { return 107.0 }
return awgSizes[Int(awg)] ?? 0.75
ElectricalCalculations.recommendedFuse(forCurrent: current)
}
}

View File

@@ -0,0 +1,138 @@
//
// ElectricalCalculations.swift
// Cable
//
// Created by GPT on request.
//
import Foundation
struct ElectricalCalculations {
private static let maxVoltageDropFraction = 0.05
private static let copperResistivity = 0.017 // Ωmm²/m
private static let feetToMeters = 0.3048
private static let standardMetricCrossSections: [Double] = [
0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0,
150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0,
]
private static let standardAWG: [Int] = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
private static let awgCrossSections: [Double] = [
0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0,
]
private static let standardFuses: [Int] = [
1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50,
60, 70, 80, 100, 125, 150, 175, 200, 225, 250,
300, 350, 400, 450, 500, 600, 700, 800,
]
static func recommendedCrossSection(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem
) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
let maxVoltageDrop = voltage * maxVoltageDropFraction
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
}
if unitSystem == .imperial {
for (index, crossSection) in awgCrossSections.enumerated() where crossSection >= minimumCrossSection {
return Double(standardAWG[index])
}
return Double(standardAWG.last ?? 0)
} else {
return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) }
?? standardMetricCrossSections.last ?? 0.75
}
}
static func voltageDrop(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = nil
) -> Double {
let selectedCrossSection = crossSection ?? recommendedCrossSection(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
let crossSectionMM2: Double
if unitSystem == .metric {
crossSectionMM2 = selectedCrossSection
} else {
crossSectionMM2 = crossSectionFromAWG(selectedCrossSection)
}
guard crossSectionMM2 > 0 else { return 0 }
return (2 * current * lengthInMeters * copperResistivity) / crossSectionMM2
}
static func voltageDropPercentage(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = nil
) -> Double {
guard voltage != 0 else { return 0 }
let drop = voltageDrop(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem,
crossSection: crossSection
)
return (drop / voltage) * 100
}
static func powerLoss(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = nil
) -> Double {
let drop = voltageDrop(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem,
crossSection: crossSection
)
return current * drop
}
static func recommendedFuse(forCurrent current: Double) -> Int {
let target = Int((current * 1.25).rounded(.up))
return standardFuses.first(where: { $0 >= target }) ?? standardFuses.last ?? target
}
private static func guardAgainstZero(_ divisor: Double, calculation: () -> Double) -> Double {
guard divisor > 0 else { return 0 }
return calculation()
}
private static func crossSectionFromAWG(_ awg: Double) -> Double {
switch awg {
case 00: return 67.4
case 000: return 85.0
case 0000: return 107.0
default:
let index = standardAWG.firstIndex(of: Int(awg)) ?? -1
if index >= 0 && index < awgCrossSections.count {
return awgCrossSections[index]
}
return 0.75
}
}
}

View File

@@ -8,7 +8,6 @@
import SwiftUI
import SwiftData
import PostHog
struct LoadsView: View {
@Environment(\.modelContext) private var modelContext
@@ -64,6 +63,7 @@ struct LoadsView: View {
),
systemImage: "rectangle.3.group"
)
.accessibilityIdentifier("overview-tab")
}
componentsTab
@@ -77,6 +77,7 @@ struct LoadsView: View {
),
systemImage: "square.stack.3d.up"
)
.accessibilityIdentifier("components-tab")
}
Group {
@@ -106,6 +107,7 @@ struct LoadsView: View {
),
systemImage: "battery.100"
)
.accessibilityIdentifier("batteries-tab")
}
.environment(\.editMode, $editMode)
@@ -127,6 +129,7 @@ struct LoadsView: View {
),
systemImage: "bolt.fill"
)
.accessibilityIdentifier("chargers-tab")
}
.environment(\.editMode, $editMode)
}
@@ -215,6 +218,8 @@ struct LoadsView: View {
SystemBillOfMaterialsView(
systemName: system.name,
loads: savedLoads,
batteries: savedBatteries,
chargers: savedChargers,
unitSystem: unitSettings.unitSystem
)
}
@@ -266,7 +271,7 @@ struct LoadsView: View {
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
hasOpenedLoadOnAppear = true
DispatchQueue.main.async {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Load Opened",
properties: [
"mode": loadToOpen.isWattMode ? "watt" : "amp",
@@ -469,7 +474,7 @@ struct LoadsView: View {
}
private func selectLoad(_ load: SavedLoad) {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Load Opened",
properties: [
"mode": load.isWattMode ? "watt" : "amp",
@@ -760,7 +765,7 @@ struct LoadsView: View {
}
private func presentSystemEditor(source: String) {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"System Editor Opened",
properties: [
"source": source,
@@ -771,7 +776,7 @@ struct LoadsView: View {
}
private func openComponentLibrary(source: String) {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Component Library Opened",
properties: [
"source": source,
@@ -782,7 +787,7 @@ struct LoadsView: View {
}
private func openBillOfMaterials() {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Bill Of Materials Opened",
properties: [
"system": system.name
@@ -795,7 +800,7 @@ struct LoadsView: View {
let loadsToDelete = offsets.map { savedLoads[$0] }
withAnimation {
for load in loadsToDelete {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Load Deleted",
properties: [
"name": load.name,
@@ -815,7 +820,7 @@ struct LoadsView: View {
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Load Created",
properties: [
"name": newLoad.name,
@@ -826,7 +831,7 @@ struct LoadsView: View {
}
private func startBatteryConfiguration() {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Battery Editor Opened",
properties: [
"source": "create",
@@ -850,7 +855,7 @@ struct LoadsView: View {
in: modelContext
)
let eventName = isExisting ? "Battery Updated" : "Battery Created"
PostHogSDK.shared.capture(
AnalyticsTracker.log(
eventName,
properties: [
"name": configuration.name,
@@ -860,7 +865,7 @@ struct LoadsView: View {
}
private func editBattery(_ battery: SavedBattery) {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Battery Editor Opened",
properties: [
"source": "edit",
@@ -874,7 +879,7 @@ struct LoadsView: View {
let batteriesToDelete = offsets.map { savedBatteries[$0] }
withAnimation {
for battery in batteriesToDelete {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Battery Deleted",
properties: [
"name": battery.name,
@@ -891,7 +896,7 @@ struct LoadsView: View {
}
private func startChargerConfiguration() {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Charger Editor Opened",
properties: [
"source": "create",
@@ -915,7 +920,7 @@ struct LoadsView: View {
in: modelContext
)
let eventName = isExisting ? "Charger Updated" : "Charger Created"
PostHogSDK.shared.capture(
AnalyticsTracker.log(
eventName,
properties: [
"name": configuration.name,
@@ -925,7 +930,7 @@ struct LoadsView: View {
}
private func editCharger(_ charger: SavedCharger) {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Charger Editor Opened",
properties: [
"source": "edit",
@@ -939,7 +944,7 @@ struct LoadsView: View {
let chargersToDelete = offsets.map { savedChargers[$0] }
withAnimation {
for charger in chargersToDelete {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Charger Deleted",
properties: [
"name": charger.name,
@@ -964,7 +969,7 @@ struct LoadsView: View {
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Library Load Added",
properties: [
"id": item.id,
@@ -986,13 +991,7 @@ struct LoadsView: View {
}
private func recommendedFuse(for load: SavedLoad) -> Int {
let targetFuse = load.current * 1.25 // 125% of load current for safety
// Common fuse values in amperes
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
// Find the smallest standard fuse that's >= target
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
ElectricalCalculations.recommendedFuse(forCurrent: load.current)
}
private enum ComponentTab: Hashable {

View File

@@ -62,6 +62,7 @@ struct OnboardingInfoView: View {
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
.frame(maxWidth: .infinity)
}
.accessibilityIdentifier("create-component-button")
.buttonStyle(.borderedProminent)
.controlSize(.large)
@@ -71,6 +72,7 @@ struct OnboardingInfoView: View {
Label(secondaryTitle, systemImage: secondaryIcon)
.frame(maxWidth: .infinity)
}
.accessibilityIdentifier("select-component-button")
.buttonStyle(.bordered)
.tint(.accentColor)
.controlSize(.large)

View File

@@ -159,8 +159,10 @@ struct SystemOverviewView: View {
goalHours: nil,
progressFraction: bomCompletionFraction,
hasValue: bomItemsCount > 0,
action: onShowBillOfMaterials
action: onShowBillOfMaterials,
accessibilityIdentifier: "system-bom-button"
)
}
.padding(.top, 4)
}
@@ -190,7 +192,8 @@ struct SystemOverviewView: View {
goalHours: Double?,
progressFraction: Double?,
hasValue: Bool,
action: (() -> Void)? = nil
action: (() -> Void)? = nil,
accessibilityIdentifier: String? = nil
) -> some View {
let content = VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 12) {
@@ -240,12 +243,28 @@ struct SystemOverviewView: View {
let paddedContent = content
.padding(.horizontal, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
if let action {
Button(action: action) {
paddedContent
if let accessibilityIdentifier {
Button(action: action) {
paddedContent
}
.buttonStyle(.plain)
.accessibilityIdentifier(accessibilityIdentifier)
.accessibilityLabel(title)
.accessibilityAddTraits(.isButton)
.contentShape(Rectangle())
} else {
Button(action: action) {
paddedContent
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(.isButton)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
paddedContent
}

View File

@@ -16,6 +16,7 @@ class SavedBattery {
var iconName: String = "battery.100"
var colorName: String = "blue"
var system: ElectricalSystem?
var bomCompletedItemIDs: [String] = []
var timestamp: Date
init(
@@ -32,6 +33,7 @@ class SavedBattery {
iconName: String = "battery.100",
colorName: String = "blue",
system: ElectricalSystem? = nil,
bomCompletedItemIDs: [String] = [],
timestamp: Date = Date()
) {
self.id = id
@@ -47,6 +49,7 @@ class SavedBattery {
self.iconName = iconName
self.colorName = colorName
self.system = system
self.bomCompletedItemIDs = bomCompletedItemIDs
self.timestamp = timestamp
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -0,0 +1,17 @@
import Foundation
struct BillOfMaterialsItemSnapshot: Identifiable {
let id: String
let title: String
let detail: String
let iconSystemName: String
let isPrimaryComponent: Bool
let metric: String?
}
struct BillOfMaterialsSectionSnapshot: Identifiable {
let id: String
let title: String
let subtitle: String
let items: [BillOfMaterialsItemSnapshot]
}

View File

@@ -0,0 +1,313 @@
import Foundation
import UIKit
struct SystemBillOfMaterialsPDFExporter {
private let pageRect = CGRect(x: 0, y: 0, width: 595, height: 842) // A4 portrait in points
private let margin: CGFloat = 40
private let primaryTextColor = UIColor.black
private let secondaryTextColor = UIColor.darkGray
private let tertiaryTextColor = UIColor.gray
private let accentColor = UIColor(red: 0.45, green: 0.34, blue: 0.86, alpha: 1)
func export(
systemName: String,
unitSystem: UnitSystem,
sections: [BillOfMaterialsSectionSnapshot]
) throws -> URL {
let format = UIGraphicsPDFRendererFormat()
let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)
var pageIndex = 1
let data = renderer.pdfData { context in
var cursorY = beginPage(
context: context,
pageIndex: pageIndex,
systemName: systemName,
unitSystem: unitSystem,
isFirstPage: true
)
if sections.isEmpty {
cursorY = ensureSpace(
requiredHeight: 60,
cursorY: cursorY,
context: context,
pageIndex: &pageIndex,
systemName: systemName,
unitSystem: unitSystem
)
let emptyMessage = NSLocalizedString(
"bom.pdf.placeholder.empty",
comment: "Message shown in the PDF export when no components are available"
)
drawPlaceholder(in: context.cgContext, text: emptyMessage, at: cursorY)
} else {
for section in sections {
let requiredHeight = sectionHeight(for: section)
cursorY = ensureSpace(
requiredHeight: requiredHeight,
cursorY: cursorY,
context: context,
pageIndex: &pageIndex,
systemName: systemName,
unitSystem: unitSystem
)
cursorY = drawSectionHeader(
title: section.title,
subtitle: section.subtitle,
at: cursorY,
in: context.cgContext
)
for item in section.items {
cursorY = drawItem(item, at: cursorY, in: context.cgContext)
cursorY += 12
}
cursorY += 8
}
}
drawFooter(pageIndex: pageIndex, in: context.cgContext)
}
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("System-BOM-\(UUID().uuidString).pdf")
try data.write(to: url, options: .atomic)
return url
}
private func beginPage(
context: UIGraphicsPDFRendererContext,
pageIndex: Int,
systemName: String,
unitSystem: UnitSystem,
isFirstPage: Bool
) -> CGFloat {
context.beginPage()
let titleFont = UIFont.systemFont(ofSize: isFirstPage ? 26 : 18, weight: .bold)
let subtitleFont = UIFont.systemFont(ofSize: isFirstPage ? 16 : 12, weight: .medium)
let title = isFirstPage
? NSLocalizedString(
"bom.pdf.header.title",
comment: "Primary title shown at the top of the BOM PDF"
)
: systemName
let subtitle: String
if isFirstPage {
let format = NSLocalizedString(
"bom.pdf.header.subtitle",
comment: "Subtitle format combining system name and unit system for the BOM PDF"
)
subtitle = String(
format: format,
locale: Locale.current,
systemName,
unitSystem.displayName
)
} else {
let format = NSLocalizedString(
"bom.pdf.header.inline",
comment: "Subtitle describing the active unit system on subsequent PDF pages"
)
subtitle = String(
format: format,
locale: Locale.current,
unitSystem.displayName
)
}
let availableWidth = pageRect.width - (margin * 2)
let titleRect = CGRect(x: margin, y: margin, width: availableWidth, height: titleFont.lineHeight + 4)
title.draw(in: titleRect, withAttributes: [
.font: titleFont,
.foregroundColor: primaryTextColor
])
let subtitleRect = CGRect(
x: margin,
y: titleRect.maxY + 4,
width: availableWidth,
height: subtitleFont.lineHeight + 2
)
subtitle.draw(in: subtitleRect, withAttributes: [
.font: subtitleFont,
.foregroundColor: secondaryTextColor
])
return subtitleRect.maxY + (isFirstPage ? 24 : 12)
}
private func ensureSpace(
requiredHeight: CGFloat,
cursorY: CGFloat,
context: UIGraphicsPDFRendererContext,
pageIndex: inout Int,
systemName: String,
unitSystem: UnitSystem
) -> CGFloat {
if cursorY + requiredHeight <= pageRect.height - margin {
return cursorY
}
drawFooter(pageIndex: pageIndex, in: context.cgContext)
pageIndex += 1
return beginPage(
context: context,
pageIndex: pageIndex,
systemName: systemName,
unitSystem: unitSystem,
isFirstPage: false
)
}
private var sectionHeaderHeight: CGFloat {
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
return headerFont.lineHeight + subtitleFont.lineHeight + 14
}
private func sectionHeight(for section: BillOfMaterialsSectionSnapshot) -> CGFloat {
let itemsHeight = section.items.reduce(0) { partialResult, item in
partialResult + itemBlockHeight(for: item) + 12
}
return sectionHeaderHeight + itemsHeight + 8
}
private func drawSectionHeader(title: String, subtitle: String, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
var cursorY = yPosition
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
let availableWidth = pageRect.width - (margin * 2)
title.draw(
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: headerFont.lineHeight + 4),
withAttributes: [
.font: headerFont,
.foregroundColor: primaryTextColor
]
)
cursorY += headerFont.lineHeight + 4
subtitle.draw(
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: subtitleFont.lineHeight + 2),
withAttributes: [
.font: subtitleFont,
.foregroundColor: secondaryTextColor
]
)
cursorY += subtitleFont.lineHeight + 10
return cursorY
}
private func itemBlockHeight(for item: BillOfMaterialsItemSnapshot) -> CGFloat {
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
var height: CGFloat = 0
if item.metric != nil {
height += metricFont.lineHeight + 2
}
height += titleFont.lineHeight + 2
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
height += detailFont.lineHeight + 4
}
return height + 4
}
private func drawItem(_ item: BillOfMaterialsItemSnapshot, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: titleFont,
.foregroundColor: primaryTextColor
]
let detailAttributes: [NSAttributedString.Key: Any] = [
.font: detailFont,
.foregroundColor: secondaryTextColor
]
let metricAttributes: [NSAttributedString.Key: Any] = [
.font: metricFont,
.foregroundColor: accentColor
]
let bulletWidth: CGFloat = 6
let spacing: CGFloat = 8
let availableWidth = pageRect.width - (margin * 2) - bulletWidth - spacing
let firstLineHeight = item.metric != nil ? metricFont.lineHeight : titleFont.lineHeight
let bulletRect = CGRect(
x: margin,
y: yPosition + (firstLineHeight / 2) - (bulletWidth / 2),
width: bulletWidth,
height: bulletWidth
)
context.setFillColor(accentColor.cgColor)
context.fillEllipse(in: bulletRect)
var cursorY = yPosition
let textX = margin + bulletWidth + spacing
if let metric = item.metric {
let metricRect = CGRect(x: textX, y: cursorY, width: availableWidth, height: metricFont.lineHeight + 2)
metric.draw(in: metricRect, withAttributes: metricAttributes)
cursorY = metricRect.maxY + 2
}
let titleRect = CGRect(
x: textX,
y: cursorY,
width: availableWidth,
height: titleFont.lineHeight + 2
)
item.title.draw(in: titleRect, withAttributes: titleAttributes)
cursorY = titleRect.maxY + 2
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let detailRect = CGRect(
x: textX,
y: cursorY,
width: availableWidth,
height: detailFont.lineHeight + 4
)
item.detail.draw(in: detailRect, withAttributes: detailAttributes)
cursorY = detailRect.maxY
}
return cursorY
}
private func drawFooter(pageIndex: Int, in context: CGContext) {
let footerFont = UIFont.systemFont(ofSize: 11, weight: .regular)
let attributes: [NSAttributedString.Key: Any] = [
.font: footerFont,
.foregroundColor: tertiaryTextColor
]
let format = NSLocalizedString(
"bom.pdf.page.number",
comment: "Format string for the PDF page number footer"
)
let text = String(format: format, locale: Locale.current, pageIndex)
let size = text.size(withAttributes: attributes)
let origin = CGPoint(
x: (pageRect.width - size.width) / 2,
y: pageRect.height - margin + 10
)
text.draw(at: origin, withAttributes: attributes)
}
private func drawPlaceholder(in context: CGContext, text: String, at yPosition: CGFloat) {
let font = UIFont.systemFont(ofSize: 14, weight: .regular)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: secondaryTextColor
]
text.draw(
in: CGRect(x: margin, y: yPosition, width: pageRect.width - (margin * 2), height: font.lineHeight + 4),
withAttributes: attributes
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import SwiftUI
import PostHog
struct SystemsOnboardingView: View {
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
@@ -94,7 +93,7 @@ struct SystemsOnboardingView: View {
.onAppear(perform: resetState)
.onReceive(timer) { _ in advanceCarousel() }
.task {
PostHogSDK.shared.capture("Launched")
AnalyticsTracker.log("Launched")
}
}
@@ -106,7 +105,7 @@ struct SystemsOnboardingView: View {
private func createSystem() {
isFieldFocused = false
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
PostHogSDK.shared.capture("System Created", properties: ["name": trimmed])
AnalyticsTracker.log("System Created", properties: ["name": trimmed])
guard !trimmed.isEmpty else { return }
onCreate(trimmed)
}

View File

@@ -8,7 +8,6 @@
import SwiftUI
import SwiftData
import PostHog
struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@@ -77,48 +76,16 @@ struct SystemsView: View {
} else {
List {
ForEach(systems) { system in
NavigationLink(destination: LoadsView(system: system)) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.componentColor(named: system.colorName))
.frame(width: 44, height: 44)
Image(systemName: system.iconName)
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
Button {
handleSystemSelection(system)
} label: {
systemRow(for: system)
.contentShape(Rectangle())
}
Text(componentSummary(for: system))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
.simultaneousGesture(
TapGesture().onEnded {
PostHogSDK.shared.capture(
"System Opened",
properties: [
"name": system.name,
"source": "list"
]
)
}
)
.buttonStyle(.plain)
.accessibilityLabel(system.name)
.accessibilityHint(Text("systems.list.row.accessibility.hint", comment: "Accessibility hint for systems list row"))
.accessibilityAddTraits(.isButton)
}
.onDelete(perform: deleteSystems)
}
@@ -137,7 +104,7 @@ struct SystemsView: View {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
PostHogSDK.shared.capture("System Create Navigation")
AnalyticsTracker.log("System Create Navigation")
createNewSystem()
}) {
Image(systemName: "plus")
@@ -175,13 +142,67 @@ struct SystemsView: View {
}
private func openSettings() {
PostHogSDK.shared.capture("Settings Opened")
AnalyticsTracker.log("Settings Opened")
showingSettings = true
}
private func handleSystemSelection(_ system: ElectricalSystem) {
AnalyticsTracker.log(
"System Opened",
properties: [
"name": system.name,
"source": "list"
]
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
source: "list"
)
}
@ViewBuilder
private func systemRow(for system: ElectricalSystem) -> some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.componentColor(named: system.colorName))
.frame(width: 44, height: 44)
Image(systemName: system.iconName)
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(componentSummary(for: system))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundColor(.secondary.opacity(0.6))
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func createNewSystem() {
let system = makeSystem()
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
@@ -198,7 +219,7 @@ struct SystemsView: View {
private func createNewSystem(named name: String) {
let system = makeSystem(preferredName: name)
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
@@ -233,7 +254,7 @@ struct SystemsView: View {
animated: Bool = true,
source: String = "programmatic"
) {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"System Opened",
properties: [
"name": system.name,
@@ -300,7 +321,7 @@ struct SystemsView: View {
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem()
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
@@ -308,7 +329,7 @@ struct SystemsView: View {
]
)
let load = createLoad(from: item, in: system)
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Library Load Added",
properties: [
"id": item.id,
@@ -397,7 +418,7 @@ struct SystemsView: View {
let systemsToDelete = offsets.map { systems[$0] }
withAnimation {
for system in systemsToDelete {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"System Deleted",
properties: [
"name": system.name,
@@ -414,7 +435,7 @@ struct SystemsView: View {
let descriptor = FetchDescriptor<SavedLoad>()
if let loads = try? modelContext.fetch(descriptor) {
for load in loads where load.system == system {
PostHogSDK.shared.capture(
AnalyticsTracker.log(
"Load Deleted",
properties: [
"name": load.name,

View File

@@ -7,27 +7,44 @@ import Foundation
import SwiftData
enum UITestSampleData {
static let argument = "--uitest-sample-data"
static let sampleArgument = "--uitest-sample-data"
static let resetArgument = "--uitest-reset-data"
static func prepareIfNeeded(container: ModelContainer) {
static func handleLaunchArguments(container: ModelContainer) {
#if DEBUG
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
let arguments = ProcessInfo.processInfo.arguments
NSLog("UITestSampleData arguments: %@", arguments.joined(separator: ", "))
guard arguments.contains(sampleArgument) || arguments.contains(resetArgument) else { return }
let context = ModelContext(container)
do {
try clearExistingData(in: context)
try seedSampleData(in: context)
try context.save()
if arguments.contains(resetArgument) {
NSLog("UITestSampleData resetting data store")
try clearExistingData(in: context)
}
if arguments.contains(sampleArgument) {
NSLog("UITestSampleData seeding sample data")
if !arguments.contains(resetArgument) {
try clearExistingData(in: context)
}
try seedSampleData(in: context)
}
if context.hasChanges {
try context.save()
NSLog("UITestSampleData save completed")
}
} catch {
assertionFailure("Failed to seed UI test sample data: \(error)")
assertionFailure("Failed to prepare UI test data: \(error)")
}
#endif
}
}
#if DEBUG
private extension UITestSampleData {
extension UITestSampleData {
static func clearExistingData(in context: ModelContext) throws {
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
let loadDescriptor = FetchDescriptor<SavedLoad>()

View File

@@ -144,6 +144,29 @@
"bom.navigation.title.system" = "Stückliste %@";
"bom.size.unknown" = "Größe offen";
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
"bom.empty.message" = "Dieses System hat noch keine Komponenten.";
"bom.export.pdf.button" = "PDF exportieren";
"bom.export.pdf.error.title" = "Export fehlgeschlagen";
"bom.export.pdf.error.empty" = "Füge vor dem Export mindestens eine Komponente hinzu.";
"bom.pdf.header.title" = "System-Stückliste";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Einheitensystem: %@";
"bom.pdf.placeholder.empty" = "Keine Komponenten verfügbar.";
"bom.pdf.page.number" = "Seite %d";
"bom.category.components.title" = "Komponenten & Ladegeräte";
"bom.category.components.subtitle" = "Hauptverbraucher, Regler und Ladehardware.";
"bom.category.batteries.title" = "Batterien";
"bom.category.batteries.subtitle" = "Hausspeicher und Batteriebänke.";
"bom.category.cables.title" = "Kabel";
"bom.category.cables.subtitle" = "Passende Leitungen für jede Strecke.";
"bom.category.fuses.title" = "Sicherungen";
"bom.category.fuses.subtitle" = "Stromkreisschutz und Halter.";
"bom.category.accessories.title" = "Zubehör";
"bom.category.accessories.subtitle" = "Sicherungen, Kabelschuhe und weiteres Montagematerial.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"cable.pro.privacy.label" = "Datenschutz";
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
"cable.pro.terms.label" = "Nutzungsbedingungen";
@@ -264,7 +287,7 @@
"sample.load.charger.name" = "Werkzeugladegerät";
"sample.load.compressor.name" = "Luftkompressor";
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
"sample.load.lighting.name" = "LED-Streifen";
"sample.system.rv.location" = "12V Wohnstromkreis";
"sample.system.rv.name" = "Abenteuer-Van";
"sample.system.workshop.location" = "Werkzeugecke";
@@ -337,6 +360,7 @@
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
"generic.ok" = "OK";
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
"cable.pro.subscription.renews" = "Verlängert sich %@.";
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";

View File

@@ -14,6 +14,29 @@
"bom.navigation.title.system" = "Lista de materiales %@";
"bom.size.unknown" = "Tamaño por definir";
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
"bom.export.pdf.button" = "Exportar PDF";
"bom.export.pdf.error.title" = "Exportación fallida";
"bom.export.pdf.error.empty" = "Agrega al menos un componente antes de exportar.";
"bom.pdf.header.title" = "Lista de materiales del sistema";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Sistema de unidades: %@";
"bom.pdf.placeholder.empty" = "No hay componentes disponibles.";
"bom.pdf.page.number" = "Página %d";
"bom.category.components.title" = "Componentes y cargadores";
"bom.category.components.subtitle" = "Dispositivos principales, controladores y equipos de carga.";
"bom.category.batteries.title" = "Baterías";
"bom.category.batteries.subtitle" = "Bancos domésticos y almacenamiento.";
"bom.category.cables.title" = "Cables";
"bom.category.cables.subtitle" = "Tendidos dimensionados para cada circuito.";
"bom.category.fuses.title" = "Fusibles";
"bom.category.fuses.subtitle" = "Protección de circuitos y portafusibles.";
"bom.category.accessories.title" = "Accesorios";
"bom.category.accessories.subtitle" = "Fusibles, terminales y piezas de soporte.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"component.fallback.name" = "Componente";
"default.load.library" = "Carga de la biblioteca";
"default.load.name" = "Mi carga";
@@ -323,3 +346,4 @@
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
"generic.ok" = "Aceptar";

View File

@@ -14,6 +14,29 @@
"bom.navigation.title.system" = "Liste de matériel %@";
"bom.size.unknown" = "Taille à déterminer";
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
"bom.empty.message" = "Aucun composant enregistré pour ce système pour linstant.";
"bom.export.pdf.button" = "Exporter en PDF";
"bom.export.pdf.error.title" = "Échec de lexport";
"bom.export.pdf.error.empty" = "Ajoutez au moins un composant avant lexport.";
"bom.pdf.header.title" = "Liste de matériaux du système";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Système dunités : %@";
"bom.pdf.placeholder.empty" = "Aucun composant disponible.";
"bom.pdf.page.number" = "Page %d";
"bom.category.components.title" = "Composants et chargeurs";
"bom.category.components.subtitle" = "Appareils principaux, contrôleurs et équipements de charge.";
"bom.category.batteries.title" = "Batteries";
"bom.category.batteries.subtitle" = "Banques domestiques et stockage.";
"bom.category.cables.title" = "Câbles";
"bom.category.cables.subtitle" = "Liaisons dimensionnées pour chaque circuit.";
"bom.category.fuses.title" = "Fusibles";
"bom.category.fuses.subtitle" = "Protection des circuits et porte-fusibles.";
"bom.category.accessories.title" = "Accessoires";
"bom.category.accessories.subtitle" = "Fusibles, cosses et pièces complémentaires.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"component.fallback.name" = "Composant";
"default.load.library" = "Charge de la bibliothèque";
"default.load.name" = "Ma charge";
@@ -322,4 +345,5 @@
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
"cable.pro.feature.usageBased" = "Calculs basés sur l'utilisation";
"cable.pro.feature.usageBased" = "Calculs basés sur lutilisation";
"generic.ok" = "OK";

View File

@@ -14,6 +14,29 @@
"bom.navigation.title.system" = "Materiaallijst %@";
"bom.size.unknown" = "Afmeting nog onbekend";
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
"bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen.";
"bom.export.pdf.button" = "PDF exporteren";
"bom.export.pdf.error.title" = "Export mislukt";
"bom.export.pdf.error.empty" = "Voeg minimaal één component toe voordat je exporteert.";
"bom.pdf.header.title" = "Stuklijst van het systeem";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Maateenheid: %@";
"bom.pdf.placeholder.empty" = "Geen componenten beschikbaar.";
"bom.pdf.page.number" = "Pagina %d";
"bom.category.components.title" = "Componenten en laders";
"bom.category.components.subtitle" = "Hoofdapparaten, regelaars en laadapparatuur.";
"bom.category.batteries.title" = "Batterijen";
"bom.category.batteries.subtitle" = "Huishoudbanken en opslag.";
"bom.category.cables.title" = "Kabels";
"bom.category.cables.subtitle" = "Op maat gemaakte stroomtrajecten per circuit.";
"bom.category.fuses.title" = "Zekeringen";
"bom.category.fuses.subtitle" = "Circuitbeveiliging en houders.";
"bom.category.accessories.title" = "Accessoires";
"bom.category.accessories.subtitle" = "Zekeringen, kabelschoenen en ondersteunende onderdelen.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"component.fallback.name" = "Component";
"default.load.library" = "Bibliotheeklast";
"default.load.name" = "Mijn last";
@@ -323,3 +346,4 @@
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
"generic.ok" = "OK";

View File

@@ -11,40 +11,75 @@ import Testing
struct CableTests {
@Test func metricWireSizingUsesNearestStandardSize() async throws {
let calculator = CableCalculator()
calculator.voltage = 12
calculator.current = 5
calculator.length = 10 // meters
let crossSection = calculator.recommendedCrossSection(for: .metric)
let crossSection = ElectricalCalculations.recommendedCrossSection(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(crossSection == 4.0)
let voltageDrop = calculator.voltageDrop(for: .metric)
let voltageDrop = ElectricalCalculations.voltageDrop(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(voltageDrop - 0.425) < 0.001)
let dropPercentage = calculator.voltageDropPercentage(for: .metric)
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(dropPercentage - 3.5417) < 0.001)
let powerLoss = calculator.powerLoss(for: .metric)
let powerLoss = ElectricalCalculations.powerLoss(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(powerLoss - 2.125) < 0.001)
}
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
let calculator = CableCalculator()
calculator.voltage = 120
calculator.current = 15
calculator.length = 25 // feet
let awg = calculator.recommendedCrossSection(for: .imperial)
let awg = ElectricalCalculations.recommendedCrossSection(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(awg == 18.0)
let voltageDrop = calculator.voltageDrop(for: .imperial)
let voltageDrop = ElectricalCalculations.voltageDrop(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(abs(voltageDrop - 4.722) < 0.01)
let dropPercentage = calculator.voltageDropPercentage(for: .imperial)
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(abs(dropPercentage - 3.935) < 0.01)
let powerLoss = calculator.powerLoss(for: .imperial)
let powerLoss = ElectricalCalculations.powerLoss(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(abs(powerLoss - 70.83) < 0.05)
}
@Test func recommendedFuseRoundsUpToNearestStandardSize() async throws {
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10)
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80)
}
}

View File

@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import Cable
@@ -15,7 +16,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let german = Locale(identifier: "de_DE")
let german = Foundation.Locale(identifier: "de_DE")
#expect(item.localizedName(for: german) == "Ankerwinde")
}
@@ -31,7 +32,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let french = Locale(identifier: "fr_FR")
let french = Foundation.Locale(identifier: "fr_FR")
#expect(item.localizedName(for: french) == "Anchor Winch")
}
@@ -66,7 +67,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let spanishMexico = Locale(identifier: "es_MX")
let spanishMexico = Foundation.Locale(identifier: "es_MX")
#expect(item.localizedName(for: spanishMexico) == "Molinete")
}
@@ -82,7 +83,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let germanSwitzerland = Locale(identifier: "de_CH")
let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
}
@@ -98,7 +99,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let french = Locale(identifier: "fr_FR")
let french = Foundation.Locale(identifier: "fr_FR")
#expect(item.localizedName(for: french) == "Guindeau")
}
}

View File

@@ -1,32 +1,536 @@
//
// CableUITestsScreenshot.swift
// CableUITestsScreenshot
//
// Created by Stefan Lange-Hegermann on 06.10.25.
//
import XCTest
final class CableUITestsScreenshot: XCTestCase {
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
private enum UIStringKey: String {
case addLoad
case browseLibrary
case library
case overviewTab
case componentsTab
case batteriesTab
case chargersTab
case close
case cancel
case settings
case defaultLoadName
case billOfMaterials
case systemEditorTitle
case systemsTitle
}
private let translations: [UIStringKey: [String: String]] = [
.addLoad: [
"en": "Add Load",
"de": "Verbraucher hinzufügen",
"es": "Añadir carga",
"fr": "Ajouter une charge",
"nl": "Belasting toevoegen",
],
.browseLibrary: [
"en": "Browse Library",
"de": "Bibliothek durchsuchen",
"es": "Explorar biblioteca",
"fr": "Parcourir la bibliothèque",
"nl": "Bibliotheek bekijken",
],
.library: [
"en": "Library",
"de": "Bibliothek",
"es": "Biblioteca",
"fr": "Bibliothèque",
"nl": "Bibliotheek",
],
.overviewTab: [
"en": "Overview",
"de": "Übersicht",
"es": "Resumen",
"fr": "Aperçu",
"nl": "Overzicht",
],
.componentsTab: [
"en": "Components",
"de": "Verbraucher",
"es": "Componentes",
"fr": "Composants",
"nl": "Componenten",
],
.batteriesTab: [
"en": "Batteries",
"de": "Batterien",
"es": "Baterías",
"fr": "Batteries",
"nl": "Batterijen",
],
.chargersTab: [
"en": "Chargers",
"de": "Ladegeräte",
"es": "Cargadores",
"fr": "Chargeurs",
"nl": "Laders",
],
.close: [
"en": "Close",
"de": "Schließen",
"es": "Cerrar",
"fr": "Fermer",
"nl": "Sluiten",
],
.cancel: [
"en": "Cancel",
"de": "Abbrechen",
"es": "Cancelar",
"fr": "Annuler",
"nl": "Annuleren",
],
.settings: [
"en": "Settings",
"de": "Einstellungen",
"es": "Configuración",
"fr": "Réglages",
"nl": "Instellingen",
],
.defaultLoadName: [
"en": "New Load",
"de": "Neuer Verbraucher",
"es": "Carga nueva",
"fr": "Nouvelle charge",
"nl": "Nieuwe last",
],
.billOfMaterials: [
"en": "Bill of Materials",
"de": "Stückliste",
"es": "Lista de materiales",
"fr": "Liste de matériel",
"nl": "Stuklijst",
],
.systemEditorTitle: [
"en": "Edit System",
"de": "System bearbeiten",
"es": "Editar sistema",
"fr": "Modifier le système",
"nl": "Systeem bewerken",
],
.systemsTitle: [
"en": "Systems",
"de": "Systeme",
"es": "Sistemas",
"fr": "Systèmes",
"nl": "Systemen",
],
]
override func setUpWithError() throws {
continueAfterFailure = false
try super.setUpWithError()
ensureDoNotDisturbEnabled()
dismissSystemOverlays()
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
//ensureDoNotDisturbEnabled()
//dismissSystemOverlays()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
dismissSystemOverlays()
//dismissSystemOverlays()
}
@MainActor
func testExample() throws {
func testOnboardingScreenshots() throws {
let app = launchApp(arguments: ["--uitest-reset-data"])
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
takeScreenshot(named: "01-OnboardingSystemsView")
createSystemButton.tap()
let addLoadButton = button(in: app.buttons, for: .addLoad)
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
waitForStability()
takeScreenshot(named: "02-OnboardingSystemView")
browseLibraryButton.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
waitForStability(long: true)
takeScreenshot(named: "04-ComponentSelectorView")
libraryCloseButton.tap()
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 4))
addLoadButton.tap()
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
waitForStability(long: true)
takeScreenshot(named: "03-LoadEditorView")
}
@MainActor
func testSampleDataScreenshots() throws {
let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
dismissSystemOverlays()
let systemsList = resolvedSystemsList(in: app)
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
takeScreenshot(named: "05-SystemsWithSampleData")
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
let systemName = firstSystemCell.staticTexts.firstMatch.label
let systemButton = firstSystemCell.buttons.firstMatch
if systemButton.exists {
systemButton.tap()
} else {
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
var detailVisible = waitForSystemDetail(named: systemName, in: app)
if !detailVisible {
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
detailVisible = waitForSystemDetail(named: systemName, in: app)
}
XCTAssertTrue(detailVisible)
takeScreenshot(named: "06-AdventureVanOverview")
// let overviewTab = app.buttons["overview-tab"]
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
// overviewTab.tap()
waitForStability(long: false)
let bomElement = resolveBillOfMaterialsElement(in: app)
if !bomElement.waitForExistence(timeout: 6) {
bringElementIntoView(bomElement, in: app)
}
XCTAssertTrue(bomElement.exists)
if !bomElement.isHittable {
bringElementIntoView(bomElement, in: app, requireHittable: true)
}
if bomElement.isHittable {
bomElement.tap()
} else {
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
waitForStability(long: true)
takeScreenshot(named: "08-BillOfMaterials")
let closeButton = app.buttons["system-bom-close-button"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
closeButton.tap()
let componentsTab = componentsTabButton(in: app)
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
if componentsTab.isHittable {
componentsTab.tap()
} else {
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
let loadsList = resolvedLoadsList(in: app)
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
takeScreenshot(named: "07-AdventureVanLoads")
waitForStability()
let firstLoad = loadsList.cells.element(boundBy: 0)
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
let loadName = firstLoad.staticTexts.firstMatch.label
firstLoad.tap()
let loadNavButton = app.navigationBars.buttons[loadName]
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
takeScreenshot(named: "09-AdventureVanCalculator")
}
private func launchApp(arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
//dismissSystemOverlays()
return app
}
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
return app.collectionViews.firstMatch
}
let table = app.tables["systems-list"]
if table.waitForExistence(timeout: 6) {
return table
}
XCTAssertTrue(app.tables.firstMatch.waitForExistence(timeout: 2))
return app.tables.firstMatch
}
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["loads-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["loads-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
private func waitForStability(long: Bool = false) {
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5))
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let identifierMatch = app.descendants(matching: .any)
.matching(identifier: "components-tab").firstMatch
if identifierMatch.exists {
return identifierMatch
}
let localizedLabels = [
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
]
for label in localizedLabels {
let button = app.buttons[label]
if button.exists {
return button
}
let tabBarButton = app.tabBars.buttons[label]
if tabBarButton.exists {
return tabBarButton
}
let segmentedButton = app.segmentedControls.buttons[label]
if segmentedButton.exists {
return segmentedButton
}
let segmentedOther = app.segmentedControls.otherElements[label]
if segmentedOther.exists {
return segmentedOther
}
}
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
if fallbackSegmented.exists {
return fallbackSegmented
}
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
if tabBarButton.exists {
return tabBarButton
}
return app.tabBars.descendants(matching: .any).firstMatch
}
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.otherElements["system-overview"].exists {
return true
}
let navBar = app.navigationBars.firstMatch
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
}
return app.otherElements["system-overview"].exists
}
private func bringElementIntoView(
_ element: XCUIElement,
in app: XCUIApplication,
requireHittable: Bool = false,
attempts: Int = 8
) {
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.tables.firstMatch
for _ in 0..<attempts {
if element.exists, (!requireHittable || element.isHittable) {
return
}
if scrollContainer.exists {
scrollContainer.swipeUp()
} else {
app.swipeUp()
}
waitForStability()
_ = element.waitForExistence(timeout: 2)
}
}
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
let identifier = "system-bom-button"
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
if buttonByIdentifier.exists { return buttonByIdentifier }
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
if elementByIdentifier.exists { return elementByIdentifier }
let candidates = candidateStrings(for: .billOfMaterials)
for candidate in candidates {
let button = app.buttons[candidate]
if button.exists {
return button
}
let other = app.otherElements[candidate]
if other.exists {
return other
}
}
return buttonByIdentifier
}
private func dismissNotificationBannersIfNeeded() {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
if banner.waitForExistence(timeout: 1) {
if banner.isHittable {
banner.swipeUp()
} else {
let start = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let end = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: -1.5))
start.press(forDuration: 0.05, thenDragTo: end)
}
waitForStability()
}
}
private func candidateStrings(for key: UIStringKey) -> [String] {
var values = Set<String>()
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
let localized = translations[key]?[languageCode] {
values.insert(localized)
}
if let english = translations[key]?["en"] {
values.insert(english)
}
if let others = translations[key]?.values {
values.formUnion(others)
}
if key == .settings {
values.insert("gearshape")
}
return Array(values)
}
private func button(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let element = query[candidate]
if element.exists {
return element
}
}
let predicate = NSPredicate(
format: "label IN %@ OR identifier IN %@",
NSArray(array: candidates),
NSArray(array: candidates)
)
return query.matching(predicate).firstMatch
}
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
let element = button(in: query, for: key)
return element.exists ? element : nil
}
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let tabSpecific = button(in: app.tabBars.buttons, for: key)
if tabSpecific.exists {
return tabSpecific
}
return button(in: app.buttons, for: key)
}
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let bar = app.navigationBars[candidate]
if bar.exists {
return bar
}
}
return app.navigationBars.element(boundBy: 0)
}
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let button = app.buttons[candidate]
if button.waitForExistence(timeout: 2) {
button.tap()
return
}
}
}
private func openBillOfMaterials(app: XCUIApplication) {
let bomButton = button(in: app.buttons, for: .billOfMaterials)
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
waitForStability(long: true)
}
private func closeBillOfMaterials(app: XCUIApplication) {
tapButtonIfPresent(app: app, key: .close)
}
private func navigateBack(app: XCUIApplication) {
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists {
backButton.tap()
} else {
app.swipeRight()
}
}
private func openSettings(app: XCUIApplication) {
let systemsBar = navigationBar(in: app, key: .systemsTitle)
let settingsButton = button(in: systemsBar.buttons, for: .settings)
if settingsButton.exists {
settingsButton.tap()
} else {
systemsBar.buttons.element(boundBy: 0).tap()
}
}
private func ensureDoNotDisturbEnabled() {
@@ -41,6 +545,8 @@ final class CableUITestsScreenshot: XCTestCase {
focusTile.press(forDuration: 1.0)
} else if focusButton.waitForExistence(timeout: 2) {
focusButton.press(forDuration: 1.0)
} else {
return
}
let dndButton = springboard.buttons["Do Not Disturb"]

View File

@@ -8,6 +8,36 @@
import XCTest
final class CableUITestsScreenshotLaunchTests: XCTestCase {
private func launchApp(arguments: [String] = []) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
return app
}
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["systems-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["loads-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["loads-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func takeScreenshot(name: String,
@@ -30,9 +60,7 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
@MainActor
func testOnboardingLoadsView() throws {
let app = XCUIApplication()
app.launch()
let app = launchApp(arguments: ["--uitest-reset-data"])
takeScreenshot(name: "01-OnboardingSystemsView")
let createSystemButton = app.buttons["create-system-button"]
@@ -40,56 +68,55 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
createSystemButton.tap()
takeScreenshot(name: "02-OnboardingLoadsView")
let componentsTab = app.buttons["components-tab"]
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
componentsTab.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
let selectComponentButton = app.buttons["select-component-button"]
XCTAssertTrue(selectComponentButton.waitForExistence(timeout: 5))
selectComponentButton.tap()
let browseLibraryButton = onboardingSecondaryButton(in: app)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5))
browseLibraryButton.tap()
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 10)
takeScreenshot(name: "04-ComponentSelectorView")
libraryCloseButton.tap()
let createComponentButton = app.buttons["create-component-button"]
let createComponentButton = onboardingPrimaryButton(in: app)
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
createComponentButton.tap()
takeScreenshot(name: "03-LoadEditorView")
}
func testWithSampleData() throws {
let app = XCUIApplication()
app.launchArguments.append("--uitest-sample-data")
app.launch()
let app = launchApp(arguments: ["--uitest-sample-data"])
let systemsCollection = app.collectionViews.firstMatch
let collectionExists = systemsCollection.waitForExistence(timeout: 3)
let systemsList: XCUIElement
if collectionExists {
systemsList = systemsCollection
} else {
let table = app.tables.firstMatch
XCTAssertTrue(table.waitForExistence(timeout: 3))
systemsList = table
}
let systemsList = resolvedSystemsList(in: app)
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
let systemName = firstSystemCell.staticTexts.firstMatch.label
takeScreenshot(name: "05-SystemsWithSampleData")
firstSystemCell.tap()
let loadsCollection = app.collectionViews["loads-list"]
let loadsTable = app.tables["loads-list"]
let loadsElement: XCUIElement
if loadsCollection.waitForExistence(timeout: 3) {
loadsElement = loadsCollection
let rowButton = firstSystemCell.buttons.firstMatch
if rowButton.waitForExistence(timeout: 2) {
rowButton.tap()
} else {
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
loadsElement = loadsTable
firstSystemCell.tap()
}
let navButton = app.navigationBars.buttons[systemName]
if !navButton.waitForExistence(timeout: 3) {
let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
XCTAssertTrue(navButton.waitForExistence(timeout: 3))
}
tapComponentsTab(in: app)
let loadsElement = resolvedLoadsList(in: app)
XCTAssertTrue(loadsElement.waitForExistence(timeout: 6))
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "06-AdventureVanLoads")
@@ -98,9 +125,42 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
// let bomView = app.otherElements["system-bom-view"]
// XCTAssertTrue(bomView.waitForExistence(timeout: 3))
//
// Thread.sleep(forTimeInterval: 1)
// takeScreenshot(name: "07-AdventureVanBillOfMaterials")
}
private func tapComponentsTab(in app: XCUIApplication) {
let button = componentsTabButton(in: app)
XCTAssertTrue(button.waitForExistence(timeout: 3))
button.tap()
}
private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["create-component-button"]
if button.exists { return button }
return app.buttons["onboarding-primary-button"]
}
private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["select-component-button"]
if button.exists { return button }
return app.buttons["onboarding-secondary-button"]
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let idButton = app.buttons["components-tab"]
if idButton.exists {
return idButton
}
let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"]
for label in labels {
let button = app.buttons[label]
if button.exists { return button }
}
return app.tabBars.buttons.element(boundBy: 1)
}
}

View File

@@ -6,7 +6,6 @@ target 'Cable' do
use_frameworks!
# Pods for Cable
pod "PostHog", "~> 3.0"
target 'CableTests' do
inherit! :search_paths
# Pods for testing

View File

@@ -1,16 +0,0 @@
PODS:
- PostHog (3.34.0)
DEPENDENCIES:
- PostHog (~> 3.0)
SPEC REPOS:
trunk:
- PostHog
SPEC CHECKSUMS:
PostHog: bbb7eaecb2f5a286d9da3c833cbb18ae08799655
PODFILE CHECKSUM: b59bd921fb9a91e981795c18b6ff238370434172
COCOAPODS: 1.16.2

16
Pods/Manifest.lock generated
View File

@@ -1,16 +0,0 @@
PODS:
- PostHog (3.34.0)
DEPENDENCIES:
- PostHog (~> 3.0)
SPEC REPOS:
trunk:
- PostHog
SPEC CHECKSUMS:
PostHog: bbb7eaecb2f5a286d9da3c833cbb18ae08799655
PODFILE CHECKSUM: b59bd921fb9a91e981795c18b6ff238370434172
COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6835ABF5E9176D16603D3FAED02C1229"
BuildableName = "Pods_Cable_CableUITests.framework"
BlueprintName = "Pods-Cable-CableUITests"
ReferencedContainer = "container:Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C5E834AAC4A69D5D37D96BAF8B8330"
BuildableName = "Pods_Cable_CableUITestsScreenshot.framework"
BlueprintName = "Pods-Cable-CableUITestsScreenshot"
ReferencedContainer = "container:Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8012054959C338A62834AD9706977FB0"
BuildableName = "Pods_Cable.framework"
BlueprintName = "Pods-Cable"
ReferencedContainer = "container:Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "52F449E691F258410D3E74F5BAFD41CD"
BuildableName = "Pods_CableTests.framework"
BlueprintName = "Pods-CableTests"
ReferencedContainer = "container:Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E326EE08AE4CF9FA8C947B96B6F8AB07"
BuildableName = "PostHog.bundle"
BlueprintName = "PostHog-PostHog"
ReferencedContainer = "container:Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8879D5F28A55518ACFB247594F87F75A"
BuildableName = "PostHog.framework"
BlueprintName = "PostHog"
ReferencedContainer = "container:Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Pods-Cable-CableUITests.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
</dict>
<key>Pods-Cable-CableUITestsScreenshot.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
</dict>
<key>Pods-Cable.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
</dict>
<key>Pods-CableTests.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
</dict>
<key>PostHog-PostHog.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
</dict>
<key>PostHog.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict/>
</dict>
</plist>

21
Pods/PostHog/LICENSE generated
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) [2023] [PostHog]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,199 +0,0 @@
//
// ApplicationLifecyclePublisher.swift
// PostHog
//
// Created by Yiannis Josephides on 16/12/2024.
//
#if os(iOS) || os(tvOS) || os(visionOS)
import UIKit
#elseif os(macOS)
import AppKit
#elseif os(watchOS)
import WatchKit
#endif
typealias AppLifecycleHandler = () -> Void
protocol AppLifecyclePublishing: AnyObject {
/// Registers a callback for the `didBecomeActive` event.
func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
/// Registers a callback for the `didEnterBackground` event.
func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
/// Registers a callback for the `didFinishLaunching` event.
func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
}
/**
A publisher that handles application lifecycle events and allows registering callbacks for them.
This class provides a way to observe application lifecycle events like when the app becomes active,
enters background, or finishes launching. Callbacks can be registered for each event type and will
be automatically unregistered when their registration token is deallocated.
Example usage:
```
let token = ApplicationLifecyclePublisher.shared.onDidBecomeActive {
// App became active logic
}
// Keep `token` in memory to keep the registration active
// When token is deallocated, the callback will be automatically unregistered
```
*/
final class ApplicationLifecyclePublisher: BaseApplicationLifecyclePublisher {
/// Shared instance to allow easy access across the app.
static let shared = ApplicationLifecyclePublisher()
override private init() {
super.init()
let defaultCenter = NotificationCenter.default
#if os(iOS) || os(tvOS)
defaultCenter.addObserver(self,
selector: #selector(appDidFinishLaunching),
name: UIApplication.didFinishLaunchingNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil)
#elseif os(visionOS)
defaultCenter.addObserver(self,
selector: #selector(appDidFinishLaunching),
name: UIApplication.didFinishLaunchingNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(appDidEnterBackground),
name: UIScene.willDeactivateNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(appDidBecomeActive),
name: UIScene.didActivateNotification,
object: nil)
#elseif os(macOS)
defaultCenter.addObserver(self,
selector: #selector(appDidFinishLaunching),
name: NSApplication.didFinishLaunchingNotification,
object: nil)
// macOS does not have didEnterBackgroundNotification, so we use didResignActiveNotification
defaultCenter.addObserver(self,
selector: #selector(appDidEnterBackground),
name: NSApplication.didResignActiveNotification,
object: nil)
defaultCenter.addObserver(self,
selector: #selector(appDidBecomeActive),
name: NSApplication.didBecomeActiveNotification,
object: nil)
#elseif os(watchOS)
if #available(watchOS 7.0, *) {
NotificationCenter.default.addObserver(self,
selector: #selector(appDidBecomeActive),
name: WKApplication.didBecomeActiveNotification,
object: nil)
} else {
NotificationCenter.default.addObserver(self,
selector: #selector(appDidBecomeActive),
name: .init("UIApplicationDidBecomeActiveNotification"),
object: nil)
}
#endif
}
// MARK: - Handlers
@objc private func appDidEnterBackground() {
notifyHandlers(didEnterBackgroundHandlers)
}
@objc private func appDidBecomeActive() {
notifyHandlers(didBecomeActiveHandlers)
}
@objc private func appDidFinishLaunching() {
notifyHandlers(didFinishLaunchingHandlers)
}
private func notifyHandlers(_ handlers: [AppLifecycleHandler]) {
for handler in handlers {
notifyHander(handler)
}
}
private func notifyHander(_ handler: @escaping AppLifecycleHandler) {
if Thread.isMainThread {
handler()
} else {
DispatchQueue.main.async(execute: handler)
}
}
}
class BaseApplicationLifecyclePublisher: AppLifecyclePublishing {
private let registrationLock = NSLock()
private var didBecomeActiveCallbacks: [UUID: AppLifecycleHandler] = [:]
private var didEnterBackgroundCallbacks: [UUID: AppLifecycleHandler] = [:]
private var didFinishLaunchingCallbacks: [UUID: AppLifecycleHandler] = [:]
var didBecomeActiveHandlers: [AppLifecycleHandler] {
registrationLock.withLock { Array(didBecomeActiveCallbacks.values) }
}
var didEnterBackgroundHandlers: [AppLifecycleHandler] {
registrationLock.withLock { Array(didEnterBackgroundCallbacks.values) }
}
var didFinishLaunchingHandlers: [AppLifecycleHandler] {
registrationLock.withLock { Array(didFinishLaunchingCallbacks.values) }
}
/// Registers a callback for the `didBecomeActive` event.
func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
register(handler: callback, on: \.didBecomeActiveCallbacks)
}
/// Registers a callback for the `didEnterBackground` event.
func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
register(handler: callback, on: \.didEnterBackgroundCallbacks)
}
/// Registers a callback for the `didFinishLaunching` event.
func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
register(handler: callback, on: \.didFinishLaunchingCallbacks)
}
func register(
handler callback: @escaping AppLifecycleHandler,
on keyPath: ReferenceWritableKeyPath<BaseApplicationLifecyclePublisher, [UUID: AppLifecycleHandler]>
) -> RegistrationToken {
let id = UUID()
registrationLock.withLock {
self[keyPath: keyPath][id] = callback
}
return RegistrationToken { [weak self] in
// Registration token deallocated here
guard let self else { return }
self.registrationLock.withLock {
self[keyPath: keyPath][id] = nil
}
}
}
}
final class RegistrationToken {
private let onDealloc: () -> Void
init(_ onDealloc: @escaping () -> Void) {
self.onDealloc = onDealloc
}
deinit {
onDealloc()
}
}

View File

@@ -1,214 +0,0 @@
//
// PostHogAppLifeCycleIntegration.swift
// PostHog
//
// Created by Ioannis Josephides on 19/02/2025.
//
import Foundation
/**
Add capability to capture application lifecycle events.
This integration:
- captures an `App Installed` event on the first launch of the app
- captures an `App Updated` event on any subsequent launch with a different version
- captures an `App Opened` event when the app is opened (including the first launch)
- captures an `App Backgrounded` event when the app moves to the background
*/
final class PostHogAppLifeCycleIntegration: PostHogIntegration {
var requiresSwizzling: Bool { false }
private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false
private static var didCaptureAppInstallOrUpdate = false
private weak var postHog: PostHogSDK?
// True if the app is launched for the first time
private var isFreshAppLaunch = true
// Manually maintained flag to determine background status of the app
private var isAppBackgrounded: Bool = true
private var didBecomeActiveToken: RegistrationToken?
private var didEnterBackgroundToken: RegistrationToken?
private var didFinishLaunchingToken: RegistrationToken?
func install(_ postHog: PostHogSDK) throws {
try PostHogAppLifeCycleIntegration.integrationInstalledLock.withLock {
if PostHogAppLifeCycleIntegration.integrationInstalled {
throw InternalPostHogError(description: "App life cycle integration already installed to another PostHogSDK instance.")
}
PostHogAppLifeCycleIntegration.integrationInstalled = true
}
self.postHog = postHog
start()
captureAppInstallOrUpdated()
}
func uninstall(_ postHog: PostHogSDK) {
// uninstall only for integration instance
if self.postHog === postHog || self.postHog == nil {
stop()
self.postHog = nil
PostHogAppLifeCycleIntegration.integrationInstalledLock.withLock {
PostHogAppLifeCycleIntegration.integrationInstalled = false
}
}
}
/**
Start capturing app lifecycles events
*/
func start() {
let publisher = DI.main.appLifecyclePublisher
didFinishLaunchingToken = publisher.onDidFinishLaunching { [weak self] in
self?.captureAppInstallOrUpdated()
}
didBecomeActiveToken = publisher.onDidBecomeActive { [weak self] in
self?.captureAppOpened()
}
didEnterBackgroundToken = publisher.onDidEnterBackground { [weak self] in
self?.captureAppBackgrounded()
}
}
/**
Stop capturing app lifecycle events
*/
func stop() {
didFinishLaunchingToken = nil
didBecomeActiveToken = nil
didEnterBackgroundToken = nil
}
private func captureAppInstallOrUpdated() {
// Check if Application Installed or Application Updated was already checked in the lifecycle of this app
// This can be called multiple times in case of optOut, multiple instances or start/stop integration
guard let postHog, !PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate else { return }
PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate = true
if !postHog.config.captureApplicationLifecycleEvents {
hedgeLog("Skipping Application Installed/Application Updated event - captureApplicationLifecycleEvents is disabled in configuration")
return
}
let bundle = Bundle.main
let versionName = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String
// capture app installed/updated
let userDefaults = UserDefaults.standard
let previousVersion = userDefaults.string(forKey: "PHGVersionKey")
let previousVersionCode = userDefaults.string(forKey: "PHGBuildKeyV2")
var props: [String: Any] = [:]
var event: String
if previousVersionCode == nil {
// installed
event = "Application Installed"
} else {
event = "Application Updated"
// Do not send version updates if its the same
if previousVersionCode == versionCode {
return
}
if previousVersion != nil {
props["previous_version"] = previousVersion
}
props["previous_build"] = previousVersionCode
}
var syncDefaults = false
if versionName != nil {
props["version"] = versionName
userDefaults.setValue(versionName, forKey: "PHGVersionKey")
syncDefaults = true
}
if versionCode != nil {
props["build"] = versionCode
userDefaults.setValue(versionCode, forKey: "PHGBuildKeyV2")
syncDefaults = true
}
if syncDefaults {
userDefaults.synchronize()
}
postHog.capture(event, properties: props)
}
private func captureAppOpened() {
guard let postHog else { return }
guard isAppBackgrounded else {
hedgeLog("Skipping Application Opened event - app already in foreground")
return
}
isAppBackgrounded = false
if !postHog.config.captureApplicationLifecycleEvents {
hedgeLog("Skipping Application Opened event - captureApplicationLifecycleEvents is disabled in configuration")
return
}
var props: [String: Any] = [:]
props["from_background"] = !isFreshAppLaunch
if isFreshAppLaunch {
let bundle = Bundle.main
let versionName = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String
if versionName != nil {
props["version"] = versionName
}
if versionCode != nil {
props["build"] = versionCode
}
isFreshAppLaunch = false
}
postHog.capture("Application Opened", properties: props)
}
private func captureAppBackgrounded() {
guard let postHog else { return }
guard !isAppBackgrounded else {
hedgeLog("Skipping Application Opened event - app already in background")
return
}
isAppBackgrounded = true
if !postHog.config.captureApplicationLifecycleEvents {
hedgeLog("Skipping Application Backgrounded event - captureApplicationLifecycleEvents is disabled in configuration")
return
}
postHog.capture("Application Backgrounded")
}
}
#if TESTING
extension PostHogAppLifeCycleIntegration {
static func clearInstalls() {
PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate = false
integrationInstalledLock.withLock {
integrationInstalled = false
}
}
}
#endif

View File

@@ -1,159 +0,0 @@
//
// ApplicationViewLayoutPublisher.swift
// PostHog
//
// Created by Ioannis Josephides on 19/03/2025.
//
#if os(iOS) || os(tvOS)
import UIKit
typealias ApplicationViewLayoutHandler = () -> Void
protocol ViewLayoutPublishing: AnyObject {
/// Registers a callback for getting notified when a UIView is laid out.
/// Note: callback guaranteed to be called on main thread
func onViewLayout(throttle: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken
}
final class ApplicationViewLayoutPublisher: BaseApplicationViewLayoutPublisher {
static let shared = ApplicationViewLayoutPublisher()
private var hasSwizzled: Bool = false
func start() {
swizzleLayoutSubviews()
}
func stop() {
unswizzleLayoutSubviews()
}
func swizzleLayoutSubviews() {
guard !hasSwizzled else { return }
hasSwizzled = true
swizzle(
forClass: UIView.self,
original: #selector(UIView.layoutSublayers(of:)),
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
)
}
func unswizzleLayoutSubviews() {
guard hasSwizzled else { return }
hasSwizzled = false
// swizzling twice will exchange implementations back to original
swizzle(
forClass: UIView.self,
original: #selector(UIView.layoutSublayers(of:)),
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
)
}
override func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
let id = UUID()
registrationLock.withLock {
self.onViewLayoutCallbacks[id] = ThrottledHandler(handler: callback, interval: interval)
}
// start on first callback registration
if !hasSwizzled {
start()
}
return RegistrationToken { [weak self] in
// Registration token deallocated here
guard let self else { return }
let handlerCount = self.registrationLock.withLock {
self.onViewLayoutCallbacks[id] = nil
return self.onViewLayoutCallbacks.values.count
}
// stop when there are no more callbacks
if handlerCount <= 0 {
self.stop()
}
}
}
// Called from swizzled `UIView.layoutSubviews`
fileprivate func layoutSubviews() {
notifyHandlers()
}
#if TESTING
func simulateLayoutSubviews() {
layoutSubviews()
}
#endif
}
class BaseApplicationViewLayoutPublisher: ViewLayoutPublishing {
fileprivate let registrationLock = NSLock()
var onViewLayoutCallbacks: [UUID: ThrottledHandler] = [:]
final class ThrottledHandler {
static let throttleQueue = DispatchQueue(label: "com.posthog.ThrottledHandler",
target: .global(qos: .utility))
let interval: TimeInterval
let handler: ApplicationViewLayoutHandler
private var lastFired: Date = .distantPast
init(handler: @escaping ApplicationViewLayoutHandler, interval: TimeInterval) {
self.handler = handler
self.interval = interval
}
func throttleHandler() {
let now = now()
let timeSinceLastFired = now.timeIntervalSince(lastFired)
if timeSinceLastFired >= interval {
lastFired = now
// notify on main
DispatchQueue.main.async(execute: handler)
}
}
}
func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
let id = UUID()
registrationLock.withLock {
self.onViewLayoutCallbacks[id] = ThrottledHandler(
handler: callback,
interval: interval
)
}
return RegistrationToken { [weak self] in
// Registration token deallocated here
guard let self else { return }
self.registrationLock.withLock {
self.onViewLayoutCallbacks[id] = nil
}
}
}
func notifyHandlers() {
ThrottledHandler.throttleQueue.async {
// Don't lock on main
let handlers = self.registrationLock.withLock { self.onViewLayoutCallbacks.values }
for handler in handlers {
handler.throttleHandler()
}
}
}
}
extension UIView {
@objc func ph_swizzled_layoutSublayers(of layer: CALayer) {
ph_swizzled_layoutSublayers(of: layer) // call original, not altering execution logic
ApplicationViewLayoutPublisher.shared.layoutSubviews()
}
}
#endif

View File

@@ -1,14 +0,0 @@
//
// AutocaptureEventProcessing.swift
// PostHog
//
// Created by Yiannis Josephides on 30/10/2024.
//
#if os(iOS) || targetEnvironment(macCatalyst)
import Foundation
protocol AutocaptureEventProcessing: AnyObject {
func process(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData)
}
#endif

View File

@@ -1,71 +0,0 @@
//
// ForwardingPickerViewDelegate.swift
// PostHog
//
// Created by Yiannis Josephides on 24/10/2024.
//
#if os(iOS) || targetEnvironment(macCatalyst)
import UIKit
final class ForwardingPickerViewDelegate: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
// this needs to be weak since `actualDelegate` will hold a strong reference to `ForwardingPickerViewDelegate`
weak var actualDelegate: UIPickerViewDelegate?
private var valueChangedCallback: (() -> Void)?
// We respond to the same selectors that the original delegate responds to
override func responds(to aSelector: Selector!) -> Bool {
actualDelegate?.responds(to: aSelector) ?? false
}
init(delegate: UIPickerViewDelegate?, onValueChanged: @escaping () -> Void) {
actualDelegate = delegate
valueChangedCallback = onValueChanged
}
// MARK: - UIPickerViewDataSource
func numberOfComponents(in pickerView: UIPickerView) -> Int {
(actualDelegate as? UIPickerViewDataSource)?.numberOfComponents(in: pickerView) ?? 0
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
(actualDelegate as? UIPickerViewDataSource)?.pickerView(pickerView, numberOfRowsInComponent: component) ?? 0
}
// MARK: - UIPickerViewDelegate
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
valueChangedCallback?()
actualDelegate?.pickerView?(pickerView, didSelectRow: row, inComponent: component)
}
func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
actualDelegate?.pickerView?(pickerView, viewForRow: row, forComponent: component, reusing: view) ?? UIView()
}
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
actualDelegate?.pickerView?(pickerView, widthForComponent: component) ?? .zero
}
func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
actualDelegate?.pickerView?(pickerView, rowHeightForComponent: component) ?? .zero
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
actualDelegate?.pickerView?(pickerView, titleForRow: row, forComponent: component)
}
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
actualDelegate?.pickerView?(pickerView, attributedTitleForRow: row, forComponent: component)
}
}
extension UIPickerViewDelegate {
var ph_forwardingDelegate: UIPickerViewDelegate? {
get { objc_getAssociatedObject(self, &AssociatedKeys.phForwardingDelegate) as? UIPickerViewDelegate }
set { objc_setAssociatedObject(self, &AssociatedKeys.phForwardingDelegate, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}
#endif

View File

@@ -1,606 +0,0 @@
//
// PostHogAutocaptureEventTracker.swift
// PostHog
//
// Created by Yiannis Josephides on 14/10/2024.
//
#if os(iOS) || targetEnvironment(macCatalyst)
import UIKit
class PostHogAutocaptureEventTracker {
struct EventData {
let touchCoordinates: CGPoint?
let value: String?
let screenName: String?
let viewHierarchy: [Element]
// values >0 means that this event will be debounced for `debounceInterval`
let debounceInterval: TimeInterval
}
struct Element {
let text: String
let targetClass: String
let baseClass: String?
let label: String?
var elementsChainEntry: String {
var attributes = [String]()
if !text.isEmpty {
attributes.append("text=\(text.quoted)")
}
if let baseClass, !baseClass.isEmpty {
attributes.append("attr__class=\(baseClass.quoted)")
}
if let label, !label.isEmpty {
attributes.append("attr_id=\(label.quoted)")
}
return attributes.isEmpty ? targetClass : "\(targetClass):\(attributes.joined())"
}
}
enum EventSource {
case notification(name: String)
case actionMethod(description: String)
case gestureRecognizer(description: String)
}
static var eventProcessor: (any AutocaptureEventProcessing)? {
willSet {
if newValue != nil {
swizzle()
} else {
unswizzle()
}
}
}
private static var hasSwizzled: Bool = false
private static func swizzle() {
guard !hasSwizzled else { return }
hasSwizzled = true
swizzleMethods()
registerNotifications()
}
private static func unswizzle() {
guard hasSwizzled else { return }
hasSwizzled = false
swizzleMethods() // swizzling again will exchange implementations back to original
unregisterNotifications()
}
private static func swizzleMethods() {
PostHog.swizzle(
forClass: UIApplication.self,
original: #selector(UIApplication.sendAction),
new: #selector(UIApplication.ph_swizzled_uiapplication_sendAction)
)
PostHog.swizzle(
forClass: UIGestureRecognizer.self,
original: #selector(setter: UIGestureRecognizer.state),
new: #selector(UIGestureRecognizer.ph_swizzled_uigesturerecognizer_state_Setter)
)
PostHog.swizzle(
forClass: UIScrollView.self,
original: #selector(setter: UIScrollView.contentOffset),
new: #selector(UIScrollView.ph_swizzled_setContentOffset_Setter)
)
PostHog.swizzle(
forClass: UIPickerView.self,
original: #selector(setter: UIPickerView.delegate),
new: #selector(UIPickerView.ph_swizzled_setDelegate)
)
}
private static func registerNotifications() {
NotificationCenter.default.addObserver(
PostHogAutocaptureEventTracker.self,
selector: #selector(didEndEditing),
name: UITextField.textDidEndEditingNotification,
object: nil
)
NotificationCenter.default.addObserver(
PostHogAutocaptureEventTracker.self,
selector: #selector(didEndEditing),
name: UITextView.textDidEndEditingNotification,
object: nil
)
}
private static func unregisterNotifications() {
NotificationCenter.default.removeObserver(PostHogAutocaptureEventTracker.self, name: UITextField.textDidEndEditingNotification, object: nil)
NotificationCenter.default.removeObserver(PostHogAutocaptureEventTracker.self, name: UITextView.textDidEndEditingNotification, object: nil)
}
// `UITextField` or `UITextView` did end editing notification
@objc static func didEndEditing(_ notification: NSNotification) {
guard let view = notification.object as? UIView, let eventData = view.eventData else { return }
eventProcessor?.process(source: .notification(name: "change"), event: eventData)
}
}
extension UIApplication {
@objc func ph_swizzled_uiapplication_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool {
defer {
// Currently, the action methods pointing to a SwiftUI target are blocked.
let targetClass = String(describing: object_getClassName(target))
if targetClass.contains("SwiftUI") {
hedgeLog("Action methods on SwiftUI targets are not yet supported.")
} else if let control = sender as? UIControl,
control.ph_shouldTrack(action, for: target),
let eventData = control.eventData,
let eventDescription = control.event(for: action, to: target)?.description(forControl: control)
{
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .actionMethod(description: eventDescription), event: eventData)
}
}
// first, call original method
return ph_swizzled_uiapplication_sendAction(action, to: target, from: sender, for: event)
}
}
extension UIGestureRecognizer {
// swiftlint:disable:next cyclomatic_complexity
@objc func ph_swizzled_uigesturerecognizer_state_Setter(_ state: UIGestureRecognizer.State) {
// first, call original method
ph_swizzled_uigesturerecognizer_state_Setter(state)
guard state == .ended, let view, shouldTrack(view) else { return }
// block scroll and zoom gestures for `UIScrollView`.
if let scrollView = view as? UIScrollView {
if self === scrollView.panGestureRecognizer {
return
}
#if !os(tvOS)
if self === scrollView.pinchGestureRecognizer {
return
}
#endif
}
// block all gestures for `UISwitch` (already captured via `.valueChanged` action)
if String(describing: type(of: view)).starts(with: "UISwitch") {
return
}
// ignore gestures in `UIPickerColumnView`
if String(describing: type(of: view)) == "UIPickerColumnView" {
return
}
let gestureDescription: String?
switch self {
case is UITapGestureRecognizer:
gestureDescription = EventType.kTouch
case is UISwipeGestureRecognizer:
gestureDescription = EventType.kSwipe
case is UIPanGestureRecognizer:
gestureDescription = EventType.kPan
case is UILongPressGestureRecognizer:
gestureDescription = EventType.kLongPress
#if !os(tvOS)
case is UIPinchGestureRecognizer:
gestureDescription = EventType.kPinch
case is UIRotationGestureRecognizer:
gestureDescription = EventType.kRotation
case is UIScreenEdgePanGestureRecognizer:
gestureDescription = EventType.kPan
#endif
default:
gestureDescription = nil
}
guard let gestureDescription else { return }
if let eventData = view.eventData {
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: gestureDescription), event: eventData)
}
}
}
extension UIScrollView {
@objc func ph_swizzled_setContentOffset_Setter(_ newContentOffset: CGPoint) {
// first, call original method
ph_swizzled_setContentOffset_Setter(newContentOffset)
guard shouldTrack(self) else {
return
}
// ignore all keyboard events
if let window, window.isKeyboardWindow {
return
}
// scrollview did not scroll (contentOffset didn't change)
guard contentOffset != newContentOffset else {
return
}
// block scrolls on UIPickerTableView. (captured via a forwarding delegate implementation)
if String(describing: type(of: self)) == "UIPickerTableView" {
return
}
if let eventData {
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: EventType.kScroll), event: eventData)
}
}
}
extension UIPickerView {
@objc func ph_swizzled_setDelegate(_ delegate: (any UIPickerViewDelegate)?) {
guard let delegate else {
// this just removes the delegate
return ph_swizzled_setDelegate(delegate)
}
// if delegate doesn't respond to this selector, then we can't intercept selection changes
guard delegate.responds(to: #selector(UIPickerViewDelegate.pickerView(_:didSelectRow:inComponent:))) else {
return ph_swizzled_setDelegate(delegate)
}
// wrap in a forwarding delegate so we can intercept calls
let forwardingDelegate = ForwardingPickerViewDelegate(delegate: delegate) { [weak self] in
if let data = self?.eventData {
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: EventType.kValueChange), event: data)
}
}
// Need to keep a strong reference to keep this forwarding delegate instance alive
delegate.ph_forwardingDelegate = forwardingDelegate
// call original setter
ph_swizzled_setDelegate(forwardingDelegate)
}
}
extension UIView {
var eventData: PostHogAutocaptureEventTracker.EventData? {
guard shouldTrack(self) else { return nil }
return PostHogAutocaptureEventTracker.EventData(
touchCoordinates: nil,
value: ph_autocaptureText
.map(sanitizeText),
screenName: nearestViewController
.flatMap(UIViewController.ph_topViewController)
.flatMap(UIViewController.getViewControllerName),
viewHierarchy: sequence(first: self, next: \.superview)
.map(\.toElement),
debounceInterval: ph_autocaptureDebounceInterval
)
}
}
private extension UIView {
var toElement: PostHogAutocaptureEventTracker.Element {
PostHogAutocaptureEventTracker.Element(
text: ph_autocaptureText.map(sanitizeText) ?? "",
targetClass: descriptiveTypeName,
baseClass: baseTypeName,
label: postHogLabel
)
}
}
extension UIControl {
func event(for action: Selector, to target: Any?) -> UIControl.Event? {
var events: [UIControl.Event] = [
.valueChanged,
.touchDown,
.touchDownRepeat,
.touchDragInside,
.touchDragOutside,
.touchDragEnter,
.touchDragExit,
.touchUpInside,
.touchUpOutside,
.touchCancel,
.editingDidBegin,
.editingChanged,
.editingDidEnd,
.editingDidEndOnExit,
.primaryActionTriggered,
]
if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *) {
events.append(.menuActionTriggered)
}
// latest event for action
return events.first { event in
self.actions(forTarget: target, forControlEvent: event)?.contains(action.description) ?? false
}
}
}
extension UIControl.Event {
// swiftlint:disable:next cyclomatic_complexity
func description(forControl control: UIControl) -> String? {
if self == .primaryActionTriggered {
if control is UIButton {
return EventType.kTouch // UIButton triggers primaryAction with a touch interaction
} else if control is UISegmentedControl {
return EventType.kValueChange // UISegmentedControl changes its value
} else if control is UITextField {
return EventType.kSubmit // UITextField uses this for submit-like behavior
} else if control is UISwitch {
return EventType.kToggle
} else if control is UIDatePicker {
return EventType.kValueChange
} else if control is UIStepper {
return EventType.kValueChange
} else {
return EventType.kPrimaryAction
}
}
// General event descriptions
if UIControl.Event.allTouchEvents.contains(self) {
return EventType.kTouch
} else if UIControl.Event.allEditingEvents.contains(self) {
return EventType.kChange
} else if self == .valueChanged {
if control is UISwitch {
// toggle better describes a value chagne in a switch control
return EventType.kToggle
}
return EventType.kValueChange
} else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered {
return EventType.kMenuAction
}
return nil
}
}
extension UIViewController {
class func ph_topViewController(base: UIViewController? = UIApplication.getCurrentWindow()?.rootViewController) -> UIViewController? {
if let nav = base as? UINavigationController {
return ph_topViewController(base: nav.visibleViewController)
} else if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
return ph_topViewController(base: selected)
} else if let presented = base?.presentedViewController {
return ph_topViewController(base: presented)
}
return base
}
}
extension UIResponder {
var nearestViewController: UIViewController? {
self as? UIViewController ?? next?.nearestViewController
}
}
private func typeName(of type: AnyClass) -> String {
let typeName = String(describing: type)
if let match = typeName.range(of: "^[^<]+", options: .regularExpression) {
// Extracts everything before the first '<' to deal with generics
return String(typeName[match])
}
return typeName
}
// common base types in UIKit that should not be captured
private let excludedBaseTypes: [AnyClass] = [
NSObject.self,
UIResponder.self,
UIControl.self,
UIView.self,
UIScrollView.self,
]
extension NSObject {
var descriptiveTypeName: String {
typeName(of: type(of: self))
}
var baseTypeName: String? {
guard
let superclass = type(of: self).superclass(),
!excludedBaseTypes.contains(where: { $0 == superclass })
else {
return nil
}
return typeName(of: superclass)
}
}
protocol AutoCapturable {
var ph_autocaptureText: String? { get }
var ph_autocaptureEvents: UIControl.Event { get }
var ph_autocaptureDebounceInterval: TimeInterval { get }
func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool
}
extension UIView: AutoCapturable {
@objc var ph_autocaptureEvents: UIControl.Event { .touchUpInside }
@objc var ph_autocaptureText: String? { nil }
@objc var ph_autocaptureDebounceInterval: TimeInterval { 0 }
@objc func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
false // by default views are not tracked. Can be overridden in subclasses
}
}
extension UIButton {
override var ph_autocaptureText: String? { title(for: .normal) ?? title(for: .selected) }
}
extension UIControl {
@objc override func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool {
guard shouldTrack(self) else { return false }
return actions(forTarget: target, forControlEvent: ph_autocaptureEvents)?.contains(action.description) ?? false
}
}
extension UIScrollView {
override var ph_autocaptureDebounceInterval: TimeInterval { 0.4 }
}
extension UISegmentedControl {
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
override var ph_autocaptureText: String? {
// -1 if no segment is selected
if (0 ..< numberOfSegments) ~= selectedSegmentIndex {
return titleForSegment(at: selectedSegmentIndex)
}
return nil
}
}
extension UIPageControl {
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
}
extension UISearchBar {
override var ph_autocaptureEvents: UIControl.Event { .editingDidEnd }
}
extension UIToolbar {
override var ph_autocaptureEvents: UIControl.Event {
if #available(iOS 14.0, *) { .menuActionTriggered } else { .primaryActionTriggered }
}
}
extension UITextField {
override var ph_autocaptureText: String? { text ?? attributedText?.string ?? placeholder }
override func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
// Just making sure that in the future we don't intercept UIControl.Ecent (even though it's not currently emited)
// Tracked via `UITextField.textDidEndEditingNotification`
false
}
}
extension UITextView {
override var ph_autocaptureText: String? { text ?? attributedText?.string }
override func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
shouldTrack(self)
}
}
extension UIStepper {
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
override var ph_autocaptureText: String? { "\(value)" }
}
extension UISlider {
override var ph_autocaptureDebounceInterval: TimeInterval { 0.3 }
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
override var ph_autocaptureText: String? { "\(value)" }
}
extension UISwitch {
@objc override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
override var ph_autocaptureText: String? { "\(isOn)" }
}
extension UIPickerView {
override var ph_autocaptureText: String? {
(0 ..< numberOfComponents).reduce("") { result, component in
// -1 if no row is selected
let selectedRow = selectedRow(inComponent: component)
let rowCount = numberOfRows(inComponent: component)
if (0 ..< rowCount) ~= selectedRow {
if let title = delegate?.pickerView?(self, titleForRow: selectedRow, forComponent: component) {
return result.isEmpty ? title : "\(result) \(title)"
} else if let title = delegate?.pickerView?(self, attributedTitleForRow: selectedRow, forComponent: component) {
return result.isEmpty ? title.string : "\(result) \(title.string)"
}
}
return result
}
}
}
#if !os(tvOS)
extension UIDatePicker {
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
}
#endif
private func shouldTrack(_ view: UIView) -> Bool {
if view.isHidden { return false }
if !view.isUserInteractionEnabled { return false }
if view.isNoCapture() { return false }
if view.window?.isKeyboardWindow == true { return false }
if let textField = view as? UITextField, textField.isSensitiveText() {
return false
}
if let textView = view as? UITextView, textView.isSensitiveText() {
return false
}
// check view hierarchy up
if let superview = view.superview {
return shouldTrack(superview)
}
return true
}
// TODO: Filter out or obfuscate strings that look like sensitive data
// see: https://github.com/PostHog/posthog-js/blob/0cfffcac9bdf1da3fbb9478c1a51170a325bd57f/src/autocapture-utils.ts#L389
private func sanitizeText(_ title: String) -> String {
title
.trimmingCharacters(in: .whitespacesAndNewlines) // trim
.replacingOccurrences( // sequence of spaces, returns and line breaks
of: "[ \\r\\n]+",
with: " ",
options: .regularExpression
)
.replacingOccurrences( // sanitize zero-width unicode characters
of: "[\\u{200B}\\u{200C}\\u{200D}\\u{FEFF}]",
with: "",
options: .regularExpression
)
.limit(to: 255)
}
enum EventType {
static let kValueChange = "value_changed"
static let kSubmit = "submit"
static let kToggle = "toggle"
static let kPrimaryAction = "primary_action"
static let kMenuAction = "menu_action"
static let kChange = "change"
static let kTouch = "touch"
static let kSwipe = "swipe"
static let kPinch = "pinch"
static let kPan = "pan"
static let kScroll = "scroll"
static let kRotation = "rotation"
static let kLongPress = "long_press"
}
// MARK: - Helpers
private extension String {
func limit(to length: Int) -> String {
if count > length {
let index = index(startIndex, offsetBy: length)
return String(self[..<index]) + "..."
}
return self
}
var quoted: String {
"\"\(self)\""
}
}
#endif

View File

@@ -1,147 +0,0 @@
//
// PostHogAutocaptureIntegration.swift
// PostHog
//
// Created by Yiannis Josephides on 22/10/2024.
//
#if os(iOS) || targetEnvironment(macCatalyst)
import UIKit
private let elementsChainDelimiter = ";"
class PostHogAutocaptureIntegration: AutocaptureEventProcessing, PostHogIntegration {
var requiresSwizzling: Bool { true }
private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false
private weak var postHog: PostHogSDK?
private var debounceTimers: [Int: Timer] = [:]
func install(_ postHog: PostHogSDK) throws {
try PostHogAutocaptureIntegration.integrationInstalledLock.withLock {
if PostHogAutocaptureIntegration.integrationInstalled {
throw InternalPostHogError(description: "Autocapture integration already installed to another PostHogSDK instance.")
}
PostHogAutocaptureIntegration.integrationInstalled = true
}
self.postHog = postHog
start()
}
func uninstall(_ postHog: PostHogSDK) {
// uninstall only for integration instance
if self.postHog === postHog || self.postHog == nil {
stop()
self.postHog = nil
PostHogAutocaptureIntegration.integrationInstalledLock.withLock {
PostHogAutocaptureIntegration.integrationInstalled = false
}
}
}
/**
Activates the autocapture integration by routing events from PostHogAutocaptureEventTracker to this instance.
*/
func start() {
PostHogAutocaptureEventTracker.eventProcessor = self
}
/**
Disables the autocapture integration by clearing the PostHogAutocaptureEventTracker routing
*/
func stop() {
if PostHogAutocaptureEventTracker.eventProcessor != nil {
PostHogAutocaptureEventTracker.eventProcessor = nil
debounceTimers.values.forEach { $0.invalidate() }
debounceTimers.removeAll()
}
}
/**
Processes an autocapture event, with optional debounce logic for controls that emit frequent events.
- Parameters:
- source: The source of the event (e.g., gesture recognizer, action method, or notification).
- event: The autocapture event data, containing properties, screen name, and other metadata.
If the event has a `debounceInterval` greater than 0, the event is debounced.
This is useful for UIControls like `UISlider` that emit frequent value changes, ensuring only the last value is captured.
The debounce interval is defined per UIControl by the `ph_autocaptureDebounceInterval` property of `AutoCapturable`
*/
func process(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData) {
guard postHog?.isAutocaptureActive() == true else {
return
}
let eventHash = event.viewHierarchy.map(\.targetClass).hashValue
// debounce frequent UIControl events (e.g., UISlider) to reduce event noise
if event.debounceInterval > 0 {
debounceTimers[eventHash]?.invalidate() // Keep cancelling existing
debounceTimers[eventHash] = Timer.scheduledTimer(withTimeInterval: event.debounceInterval, repeats: false) { [weak self] _ in
self?.handleEventProcessing(source: source, event: event)
self?.debounceTimers.removeValue(forKey: eventHash) // Clean up once fired
}
} else {
handleEventProcessing(source: source, event: event)
}
}
/**
Handles the processing of autocapture events by extracting event details, building properties, and sending them to PostHog.
- Parameters:
- source: The source of the event (action method, gesture, or notification). Values are already mapped to `$event_type` earlier in the chain
- event: The event data including view hierarchy, screen name, and other metadata.
This function extracts event details such as the event type, view hierarchy, and touch coordinates.
It creates a structured payload with relevant properties (e.g., tag_name, elements, element_chain) and sends it to the
associated PostHog instance for further processing.
*/
private func handleEventProcessing(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData) {
guard let postHog else {
return
}
let eventType: String = switch source {
case let .actionMethod(description): description
case let .gestureRecognizer(description): description
case let .notification(name): name
}
var properties: [String: Any] = [:]
if let screenName = event.screenName {
properties["$screen_name"] = screenName
}
let elementsChain = event.viewHierarchy
.map(\.elementsChainEntry)
.joined(separator: elementsChainDelimiter)
if let coordinates = event.touchCoordinates {
properties["$touch_x"] = coordinates.x
properties["$touch_y"] = coordinates.y
}
postHog.autocapture(
eventType: eventType,
elementsChain: elementsChain,
properties: properties
)
}
}
#if TESTING
extension PostHogAutocaptureIntegration {
static func clearInstalls() {
integrationInstalledLock.withLock {
integrationInstalled = false
}
}
}
#endif
#endif

View File

@@ -1,156 +0,0 @@
//
// View+PostHogLabel.swift
// PostHog
//
// Created by Yiannis Josephides on 04/12/2024.
//
#if os(iOS) || targetEnvironment(macCatalyst)
import SwiftUI
public extension View {
/**
Adds a custom label to this view for use with PostHog's auto-capture functionality.
By setting a custom label, you can easily identify and filter interactions with this specific element in your analytics data.
### Usage
```swift
struct ContentView: View {
var body: some View {
Button("Login") {
...
}
.postHogLabel("loginButton")
}
}
```
- Parameter label: A custom label that uniquely identifies the element for analytics purposes.
*/
func postHogLabel(_ label: String?) -> some View {
modifier(PostHogLabelTaggerViewModifier(label: label))
}
}
private struct PostHogLabelTaggerViewModifier: ViewModifier {
let label: String?
func body(content: Content) -> some View {
content
.background(viewTagger)
}
@ViewBuilder
private var viewTagger: some View {
if let label {
PostHogLabelViewTagger(label: label)
}
}
}
private struct PostHogLabelViewTagger: UIViewRepresentable {
let label: String
func makeUIView(context _: Context) -> PostHogLabelTaggerView {
PostHogLabelTaggerView(label: label)
}
func updateUIView(_: PostHogLabelTaggerView, context _: Context) {
// nothing
}
}
private class PostHogLabelTaggerView: UIView {
private let label: String
weak var taggedView: UIView?
init(label: String) {
self.label = label
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
label = ""
super.init(frame: .zero)
}
override func layoutSubviews() {
super.didMoveToWindow()
// try to find a "taggable" cousin view in hierarchy
//
// ### Why cousin view?
//
// Because of SwiftUI-to-UIKit view bridging:
//
// OriginalView (SwiftUI)
// L SwiftUITextFieldRepresentable (ViewRepresentable)
// L UITextField (UIControl) <- we tag here
// L PostHogLabelViewTagger (ViewRepresentable)
// L PostHogLabelTaggerView (UIView) <- we are here
//
if let view = findCousinView(of: PostHogSwiftUITaggable.self) {
taggedView = view
view.postHogLabel = label
} else {
// just tag grandparent view
//
// ### Why grandparent view?
//
// Because of SwiftUI-to-UIKit view bridging:
// OriginalView (SwiftUI) <- we tag here
// L PostHogLabelViewTagger (ViewRepresentable)
// L PostHogLabelTaggerView (UIView) <- we are here
//
taggedView = superview?.superview
superview?.superview?.postHogLabel = label
}
}
override func removeFromSuperview() {
super.removeFromSuperview()
// remove custom label when removed from hierarchy
taggedView?.postHogLabel = nil
taggedView = nil
}
private func findCousinView<T>(of _: T.Type) -> T? {
for sibling in superview?.siblings() ?? [] {
if let match = sibling.child(of: T.self) {
return match
}
}
return nil
}
}
// MARK: - Helpers
private extension UIView {
func siblings() -> [UIView] {
superview?.subviews.reduce(into: []) { result, current in
if current !== self { result.append(current) }
} ?? []
}
func child<T>(of type: T.Type) -> T? {
for child in subviews {
if let curT = child as? T ?? child.child(of: type) {
return curT
}
}
return nil
}
}
protocol PostHogSwiftUITaggable: UIView { /**/ }
extension UIControl: PostHogSwiftUITaggable { /**/ }
extension UIPickerView: PostHogSwiftUITaggable { /**/ }
extension UITextView: PostHogSwiftUITaggable { /**/ }
extension UICollectionView: PostHogSwiftUITaggable { /**/ }
extension UITableView: PostHogSwiftUITaggable { /**/ }
#endif

View File

@@ -1,29 +0,0 @@
//
// UIView+PostHogLabel.swift
// PostHog
//
// Created by Yiannis Josephides on 04/12/2024.
//
#if os(iOS) || targetEnvironment(macCatalyst)
import UIKit
public extension UIView {
/**
Adds a custom label to this view for use with PostHog's auto-capture functionality.
By setting a custom label, you can easily identify and filter interactions with this specific element in your analytics data.
### Usage
```swift
let myView = UIView()
myView.postHogLabel = "customLabel"
```
*/
var postHogLabel: String? {
get { objc_getAssociatedObject(self, &AssociatedKeys.phLabel) as? String }
set { objc_setAssociatedObject(self, &AssociatedKeys.phLabel, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}
#endif

View File

@@ -1,28 +0,0 @@
//
// DI.swift
// PostHog
//
// Created by Yiannis Josephides on 17/12/2024.
//
// swiftlint:disable:next type_name
enum DI {
static var main = Container()
final class Container {
// publishes global app lifecycle events
lazy var appLifecyclePublisher: AppLifecyclePublishing = ApplicationLifecyclePublisher.shared
// publishes global screen view events (UIViewController.viewDidAppear)
lazy var screenViewPublisher: ScreenViewPublishing = ApplicationScreenViewPublisher.shared
#if os(iOS) || os(tvOS)
// publishes global application events (UIApplication.sendEvent)
lazy var applicationEventPublisher: ApplicationEventPublishing = ApplicationEventPublisher.shared
#endif
#if os(iOS)
// publishes global view layout events within a throttle interval (UIView.layoutSubviews)
lazy var viewLayoutPublisher: ViewLayoutPublishing = ApplicationViewLayoutPublisher.shared
#endif
}
}

View File

@@ -1,102 +0,0 @@
//
// PostHogEvent.swift
// PostHog
//
// Created by Manoel Aranda Neto on 13.10.23.
//
import Foundation
@objc(PostHogEvent) public class PostHogEvent: NSObject {
@objc public var event: String
@objc public var distinctId: String
@objc public var properties: [String: Any]
@objc public var timestamp: Date
@objc public private(set) var uuid: UUID
// Only used for Replay
var apiKey: String?
init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), apiKey: String? = nil) {
self.event = event
self.distinctId = distinctId
self.properties = properties ?? [:]
self.timestamp = timestamp
self.uuid = uuid
self.apiKey = apiKey
}
// NOTE: Ideally we would use the NSCoding behaviour but it gets needlessly complex
// given we only need this for sending to the API
static func fromJSON(_ data: Data) -> PostHogEvent? {
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
return nil
}
return fromJSON(json)
}
static func fromJSON(_ json: [String: Any]) -> PostHogEvent? {
guard let event = json["event"] as? String else { return nil }
let timestamp = json["timestamp"] as? String ?? toISO8601String(Date())
let timestampDate = toISO8601Date(timestamp) ?? Date()
var properties = (json["properties"] as? [String: Any]) ?? [:]
// back compatibility with v2
let setProps = json["$set"] as? [String: Any]
if setProps != nil {
properties["$set"] = setProps
}
guard let distinctId = (json["distinct_id"] as? String) ?? (properties["distinct_id"] as? String) else { return nil }
let uuid = ((json["uuid"] as? String) ?? (json["message_id"] as? String)) ?? UUID.v7().uuidString
let uuidObj = UUID(uuidString: uuid) ?? UUID.v7()
let apiKey = json["api_key"] as? String
return PostHogEvent(
event: event,
distinctId: distinctId,
properties: properties,
timestamp: timestampDate,
uuid: uuidObj,
apiKey: apiKey
)
}
func toJSON() -> [String: Any] {
var json: [String: Any] = [
"event": event,
"distinct_id": distinctId,
"properties": properties,
"timestamp": toISO8601String(timestamp),
"uuid": uuid.uuidString,
]
if let apiKey {
json["api_key"] = apiKey
}
return json
}
}
enum PostHogKnownUnsafeEditableEvent: String {
case snapshot = "$snapshot"
case screen = "$screen"
case set = "$set"
case surveyDismissed = "survey dismissed"
case surveySent = "survey sent"
case surveyShown = "survey shown"
case identify = "$identify"
case groupidentify = "$groupidentify"
case createAlias = "$create_alias"
case featureFlagCalled = "$feature_flag_called"
static func contains(_ name: String) -> Bool {
PostHogKnownUnsafeEditableEvent(rawValue: name) != nil
}
}

View File

@@ -1,122 +0,0 @@
#if os(iOS) || TESTING
import Foundation
extension PostHogSurvey {
func toDisplaySurvey() -> PostHogDisplaySurvey {
PostHogDisplaySurvey(
id: id,
name: name,
questions: questions.compactMap { $0.toDisplayQuestion() },
appearance: appearance?.toDisplayAppearance(),
startDate: startDate,
endDate: endDate
)
}
}
extension PostHogSurveyQuestion {
func toDisplayQuestion() -> PostHogDisplaySurveyQuestion? {
switch self {
case let .open(question):
return PostHogDisplayOpenQuestion(
id: question.id,
question: question.question,
questionDescription: question.description,
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
isOptional: question.optional ?? false,
buttonText: question.buttonText
)
case let .link(question):
return PostHogDisplayLinkQuestion(
id: question.id,
question: question.question,
questionDescription: question.description,
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
isOptional: question.optional ?? false,
buttonText: question.buttonText,
link: question.link ?? ""
)
case let .rating(question):
return PostHogDisplayRatingQuestion(
id: question.id,
question: question.question,
questionDescription: question.description,
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
isOptional: question.optional ?? false,
buttonText: question.buttonText,
ratingType: question.display.toDisplayRatingType(),
scaleLowerBound: question.scale.range.lowerBound,
scaleUpperBound: question.scale.range.upperBound,
lowerBoundLabel: question.lowerBoundLabel,
upperBoundLabel: question.upperBoundLabel
)
case let .singleChoice(question), let .multipleChoice(question):
return PostHogDisplayChoiceQuestion(
id: question.id,
question: question.question,
questionDescription: question.description,
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
isOptional: question.optional ?? false,
buttonText: question.buttonText,
choices: question.choices,
hasOpenChoice: question.hasOpenChoice ?? false,
shuffleOptions: question.shuffleOptions ?? false,
isMultipleChoice: isMultipleChoice
)
default:
return nil
}
}
private var isMultipleChoice: Bool {
switch self {
case .multipleChoice: return true
default: return false
}
}
}
extension PostHogSurveyTextContentType {
func toDisplayContentType() -> PostHogDisplaySurveyTextContentType {
if case .html = self {
return .html
}
return .text
}
}
extension PostHogSurveyRatingDisplayType {
func toDisplayRatingType() -> PostHogDisplaySurveyRatingType {
if case .emoji = self {
return .emoji
}
return .number
}
}
extension PostHogSurveyAppearance {
func toDisplayAppearance() -> PostHogDisplaySurveyAppearance {
PostHogDisplaySurveyAppearance(
fontFamily: fontFamily,
backgroundColor: backgroundColor,
borderColor: borderColor,
submitButtonColor: submitButtonColor,
submitButtonText: submitButtonText,
submitButtonTextColor: submitButtonTextColor,
descriptionTextColor: descriptionTextColor,
ratingButtonColor: ratingButtonColor,
ratingButtonActiveColor: ratingButtonActiveColor,
placeholder: placeholder,
displayThankYouMessage: displayThankYouMessage ?? true,
thankYouMessageHeader: thankYouMessageHeader,
thankYouMessageDescription: thankYouMessageDescription,
thankYouMessageDescriptionContentType: thankYouMessageDescriptionContentType?.toDisplayContentType(),
thankYouMessageCloseButtonText: thankYouMessageCloseButtonText
)
}
}
#endif

View File

@@ -1,46 +0,0 @@
//
// PostHogSurvey.swift
// PostHog
//
// Created by Yiannis Josephides on 20/01/2025.
//
import Foundation
/// Represents the main survey object containing metadata, questions, conditions, and appearance settings.
/// see: posthog-js/posthog-surveys-types.ts
struct PostHogSurvey: Decodable, Identifiable {
/// The unique identifier for the survey
let id: String
/// The name of the survey
let name: String
/// Type of the survey (e.g., "popover")
let type: PostHogSurveyType
/// The questions asked in the survey
let questions: [PostHogSurveyQuestion]
/// Multiple feature flag keys. Must all (AND) evaluate to true for the survey to be shown (optional)
let featureFlagKeys: [PostHogSurveyFeatureFlagKeyValue]?
/// Linked feature flag key. Must evaluate to true for the survey to be shown (optional)
let linkedFlagKey: String?
/// Targeting feature flag key. Must evaluate to true for the survey to be shown (optional)
let targetingFlagKey: String?
/// Internal targeting flag key. Must evaluate to true for the survey to be shown (optional)
let internalTargetingFlagKey: String?
/// Conditions for displaying the survey (optional)
let conditions: PostHogSurveyConditions?
/// Appearance settings for the survey (optional)
let appearance: PostHogSurveyAppearance?
/// The iteration number for the survey (optional)
let currentIteration: Int?
/// The start date for the current iteration of the survey (optional)
let currentIterationStartDate: Date?
/// Start date of the survey (optional)
let startDate: Date?
/// End date of the survey (optional)
let endDate: Date?
}
struct PostHogSurveyFeatureFlagKeyValue: Equatable, Decodable {
let key: String
let value: String?
}

View File

@@ -1,38 +0,0 @@
//
// PostHogSurveyAppearance.swift
// PostHog
//
// Created by Ioannis Josephides on 08/04/2025.
//
import Foundation
/// Represents the appearance settings for the survey, such as colors, fonts, and layout
struct PostHogSurveyAppearance: Decodable {
let position: PostHogSurveyAppearancePosition?
let fontFamily: String?
let backgroundColor: String?
let submitButtonColor: String?
let submitButtonText: String?
let submitButtonTextColor: String?
let descriptionTextColor: String?
let ratingButtonColor: String?
let ratingButtonActiveColor: String?
let ratingButtonHoverColor: String?
let whiteLabel: Bool?
let autoDisappear: Bool?
let displayThankYouMessage: Bool?
let thankYouMessageHeader: String?
let thankYouMessageDescription: String?
let thankYouMessageDescriptionContentType: PostHogSurveyTextContentType?
let thankYouMessageCloseButtonText: String?
let borderColor: String?
let placeholder: String?
let shuffleQuestions: Bool?
let surveyPopupDelaySeconds: TimeInterval?
// widget options
let widgetType: PostHogSurveyAppearanceWidgetType?
let widgetSelector: String?
let widgetLabel: String?
let widgetColor: String?
}

View File

@@ -1,47 +0,0 @@
//
// PostHogSurveyConditions.swift
// PostHog
//
// Created by Ioannis Josephides on 08/04/2025.
//
import Foundation
/// Represents conditions for displaying the survey, such as URL or event-based triggers
struct PostHogSurveyConditions: Decodable {
/// Target URL for the survey (optional)
let url: String?
/// The match type for the url condition (optional)
let urlMatchType: PostHogSurveyMatchType?
/// CSS selector for displaying the survey (optional)
let selector: String?
/// Device type based conditions for displaying the survey (optional)
let deviceTypes: [String]?
/// The match type for the device type condition (optional)
let deviceTypesMatchType: PostHogSurveyMatchType?
/// Minimum wait period before showing the survey again (optional)
let seenSurveyWaitPeriodInDays: Int?
/// Event-based conditions for displaying the survey (optional)
let events: PostHogSurveyEventConditions?
/// Action-based conditions for displaying the survey (optional)
let actions: PostHogSurveyActionsConditions?
}
/// Represents event-based conditions for displaying the survey
struct PostHogSurveyEventConditions: Decodable {
let repeatedActivation: Bool?
/// List of events that trigger the survey
let values: [PostHogEventCondition]
}
/// Represents action-based conditions for displaying the survey
struct PostHogSurveyActionsConditions: Decodable {
/// List of events that trigger the survey
let values: [PostHogEventCondition]
}
/// Represents a single event condition used in survey targeting
struct PostHogEventCondition: Decodable, Equatable {
/// Name of the event (e.g., "content loaded")
let name: String
}

View File

@@ -1,280 +0,0 @@
//
// PostHogSurveyEnums.swift
// PostHog
//
// Created by Ioannis Josephides on 08/04/2025.
//
import Foundation
// MARK: - Supporting Types
enum PostHogSurveyType: Decodable, Equatable {
case popover
case api
case widget
case unknown(type: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let typeString = try container.decode(String.self)
switch typeString {
case "popover":
self = .popover
case "api":
self = .api
case "widget":
self = .widget
default:
self = .unknown(type: typeString)
}
}
}
enum PostHogSurveyQuestionType: Decodable, Equatable {
case open
case link
case rating
case multipleChoice
case singleChoice
case unknown(type: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let typeString = try container.decode(String.self)
switch typeString {
case "open":
self = .open
case "link":
self = .link
case "rating":
self = .rating
case "multiple_choice":
self = .multipleChoice
case "single_choice":
self = .singleChoice
default:
self = .unknown(type: typeString)
}
}
}
enum PostHogSurveyTextContentType: Decodable, Equatable {
case html
case text
case unknown(type: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let typeString = try container.decode(String.self)
switch typeString {
case "html":
self = .html
case "text":
self = .text
default:
self = .unknown(type: typeString)
}
}
}
enum PostHogSurveyMatchType: Decodable, Equatable {
case regex
case notRegex
case exact
case isNot
case iContains
case notIContains
case unknown(value: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let valueString = try container.decode(String.self)
switch valueString {
case "regex":
self = .regex
case "not_regex":
self = .notRegex
case "exact":
self = .exact
case "is_not":
self = .isNot
case "icontains":
self = .iContains
case "not_icontains":
self = .notIContains
default:
self = .unknown(value: valueString)
}
}
}
enum PostHogSurveyAppearancePosition: Decodable, Equatable {
case topLeft
case topCenter
case topRight
case middleLeft
case middleCenter
case middleRight
case left
case right
case center
case unknown(position: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let positionString = try container.decode(String.self)
switch positionString {
case "top_left":
self = .topLeft
case "top_center":
self = .topCenter
case "top_right":
self = .topRight
case "middle_left":
self = .middleLeft
case "middle_center":
self = .middleCenter
case "middle_right":
self = .middleRight
case "left":
self = .left
case "right":
self = .right
case "center":
self = .center
default:
self = .unknown(position: positionString)
}
}
}
enum PostHogSurveyAppearanceWidgetType: Decodable, Equatable {
case button
case tab
case selector
case unknown(type: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let typeString = try container.decode(String.self)
switch typeString {
case "button":
self = .button
case "tab":
self = .tab
case "selector":
self = .selector
default:
self = .unknown(type: typeString)
}
}
}
enum PostHogSurveyRatingDisplayType: Decodable, Equatable {
case number
case emoji
case unknown(type: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let typeString = try container.decode(String.self)
switch typeString {
case "number":
self = .number
case "emoji":
self = .emoji
default:
self = .unknown(type: typeString)
}
}
}
enum PostHogSurveyRatingScale: Decodable, Equatable {
case threePoint
case fivePoint
case sevenPoint
case tenPoint
case unknown(scale: Int)
var rawValue: Int {
switch self {
case .threePoint: 3
case .fivePoint: 5
case .sevenPoint: 7
case .tenPoint: 10
case let .unknown(scale): scale
}
}
var range: ClosedRange<Int> {
switch self {
case .threePoint: 1 ... 3
case .fivePoint: 1 ... 5
case .sevenPoint: 1 ... 7
case .tenPoint: 0 ... 10
case let .unknown(scale): 1 ... scale
}
}
init(range: ClosedRange<Int>) {
switch range {
case 1 ... 3: self = .threePoint
case 1 ... 5: self = .fivePoint
case 1 ... 7: self = .sevenPoint
case 0 ... 10: self = .tenPoint
default: self = .unknown(scale: range.upperBound)
}
}
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let scaleInt = try container.decode(Int.self)
switch scaleInt {
case 3:
self = .threePoint
case 5:
self = .fivePoint
case 7:
self = .sevenPoint
case 10:
self = .tenPoint
default:
self = .unknown(scale: scaleInt)
}
}
}
enum PostHogSurveyQuestionBranchingType: Decodable, Equatable {
case nextQuestion
case end
case responseBased
case specificQuestion
case unknown(type: String)
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let typeString = try container.decode(String.self)
switch typeString {
case "next_question":
self = .nextQuestion
case "end":
self = .end
case "response_based":
self = .responseBased
case "specific_question":
self = .specificQuestion
default:
self = .unknown(type: typeString)
}
}
}

View File

@@ -1,247 +0,0 @@
//
// PostHogSurveyQuestion.swift
// PostHog
//
// Created by Ioannis Josephides on 08/04/2025.
//
import Foundation
// MARK: - Question Models
/// Protocol defining common properties for all survey question types
protocol PostHogSurveyQuestionProperties {
/// Question ID, empty if none
var id: String { get }
/// Question text
var question: String { get }
/// Additional description or instructions (optional)
var description: String? { get }
/// Content type of the description (e.g., "text", "html") (optional)
var descriptionContentType: PostHogSurveyTextContentType? { get }
/// Indicates if this question is optional (optional)
var optional: Bool? { get }
/// Text for the main CTA associated with this question (optional)
var buttonText: String? { get }
/// Original index of the question in the survey (optional)
var originalQuestionIndex: Int? { get }
/// Question branching logic if any (optional)
var branching: PostHogSurveyQuestionBranching? { get }
}
/// Represents different types of survey questions with their associated data
enum PostHogSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
case open(PostHogOpenSurveyQuestion)
case link(PostHogLinkSurveyQuestion)
case rating(PostHogRatingSurveyQuestion)
case singleChoice(PostHogMultipleSurveyQuestion)
case multipleChoice(PostHogMultipleSurveyQuestion)
case unknown(type: String)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(PostHogSurveyQuestionType.self, forKey: .type)
switch type {
case .open:
self = try .open(PostHogOpenSurveyQuestion(from: decoder))
case .link:
self = try .link(PostHogLinkSurveyQuestion(from: decoder))
case .rating:
self = try .rating(PostHogRatingSurveyQuestion(from: decoder))
case .singleChoice:
self = try .singleChoice(PostHogMultipleSurveyQuestion(from: decoder))
case .multipleChoice:
self = try .multipleChoice(PostHogMultipleSurveyQuestion(from: decoder))
case let .unknown(type):
self = .unknown(type: type)
}
}
var id: String {
wrappedQuestion?.id ?? ""
}
var question: String {
wrappedQuestion?.question ?? ""
}
var description: String? {
wrappedQuestion?.description
}
var descriptionContentType: PostHogSurveyTextContentType? {
wrappedQuestion?.descriptionContentType
}
var optional: Bool? {
wrappedQuestion?.optional
}
var buttonText: String? {
wrappedQuestion?.buttonText
}
var originalQuestionIndex: Int? {
wrappedQuestion?.originalQuestionIndex
}
var branching: PostHogSurveyQuestionBranching? {
wrappedQuestion?.branching
}
private var wrappedQuestion: PostHogSurveyQuestionProperties? {
switch self {
case let .open(question): question
case let .link(question): question
case let .rating(question): question
case let .singleChoice(question): question
case let .multipleChoice(question): question
case .unknown: nil
}
}
private enum CodingKeys: CodingKey {
case type
}
}
/// Represents a basic open-ended survey question
struct PostHogOpenSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
let id: String
let question: String
let description: String?
let descriptionContentType: PostHogSurveyTextContentType?
let optional: Bool?
let buttonText: String?
let originalQuestionIndex: Int?
let branching: PostHogSurveyQuestionBranching?
}
/// Represents a survey question with an associated link
struct PostHogLinkSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
let id: String
let question: String
let description: String?
let descriptionContentType: PostHogSurveyTextContentType?
let optional: Bool?
let buttonText: String?
let originalQuestionIndex: Int?
let branching: PostHogSurveyQuestionBranching?
/// URL link associated with the question
let link: String?
}
/// Represents a rating-based survey question
struct PostHogRatingSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
let id: String
let question: String
let description: String?
let descriptionContentType: PostHogSurveyTextContentType?
let optional: Bool?
let buttonText: String?
let originalQuestionIndex: Int?
let branching: PostHogSurveyQuestionBranching?
/// Display type for the rating ("number" or "emoji")
let display: PostHogSurveyRatingDisplayType
/// Scale of the rating (3, 5, 7, or 10)
let scale: PostHogSurveyRatingScale
let lowerBoundLabel: String
let upperBoundLabel: String
}
/// Represents a multiple-choice or single-choice survey question
struct PostHogMultipleSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
let id: String
let question: String
let description: String?
let descriptionContentType: PostHogSurveyTextContentType?
let optional: Bool?
let buttonText: String?
let originalQuestionIndex: Int?
let branching: PostHogSurveyQuestionBranching?
/// List of choices for multiple-choice or single-choice questions
let choices: [String]
/// Indicates if there is an open choice option (optional)
let hasOpenChoice: Bool?
/// Indicates if choices should be shuffled or not (optional)
let shuffleOptions: Bool?
}
/// Represents branching logic for a question based on user responses
enum PostHogSurveyQuestionBranching: Decodable {
case next
case end
case responseBased(responseValues: [String: Any])
case specificQuestion(index: Int)
case unknown(type: String)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(PostHogSurveyQuestionBranchingType.self, forKey: .type)
switch type {
case .nextQuestion:
self = .next
case .end:
self = .end
case .responseBased:
do {
let responseValues = try container.decode(JSON.self, forKey: .responseValues)
guard let dict = responseValues.value as? [String: Any] else {
throw DecodingError.typeMismatch(
[String: Any].self,
DecodingError.Context(
codingPath: container.codingPath,
debugDescription: "Expected responseValues to be a dictionary"
)
)
}
self = .responseBased(responseValues: dict)
} catch {
throw DecodingError.dataCorruptedError(
forKey: .responseValues,
in: container,
debugDescription: "responseValues is not a valid JSON object"
)
}
case .specificQuestion:
self = try .specificQuestion(index: container.decode(Int.self, forKey: .index))
case let .unknown(type):
self = .unknown(type: type)
}
}
private enum CodingKeys: CodingKey {
case type, responseValues, index
}
}
/// A helper type for decoding JSON values, which may be nested objects, arrays, strings, numbers, booleans, or nulls.
private struct JSON: Decodable {
let value: Any
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
value = NSNull()
} else if let object = try? container.decode([String: JSON].self) {
value = object.mapValues { $0.value }
} else if let array = try? container.decode([JSON].self) {
value = array.map(\.value)
} else if let string = try? container.decode(String.self) {
value = string
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let number = try? container.decode(Double.self) {
value = NSNumber(value: number)
} else if let number = try? container.decode(Int.self) {
value = NSNumber(value: number)
} else {
throw DecodingError.dataCorruptedError(
in: container, debugDescription: "Invalid JSON value"
)
}
}
}

View File

@@ -1,60 +0,0 @@
//
// PostHog.h
// PostHog
//
// Created by Ben White on 10.01.23.
//
#import <Foundation/Foundation.h>
//! Project version number for PostHog.
FOUNDATION_EXPORT double PostHogVersionNumber;
//! Project version string for PostHog.
FOUNDATION_EXPORT const unsigned char PostHogVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <PostHog/PublicHeader.h>
#import <PostHog/ph_backward_references_enc.h>
#import <PostHog/ph_bit_reader_utils.h>
#import <PostHog/ph_bit_writer_utils.h>
#import <PostHog/ph_color_cache_utils.h>
#import <PostHog/ph_common_dec.h>
#import <PostHog/ph_common_sse2.h>
#import <PostHog/ph_common_sse41.h>
#import <PostHog/ph_cost_enc.h>
#import <PostHog/ph_cpu.h>
#import <PostHog/ph_decode.h>
#import <PostHog/ph_dsp.h>
#import <PostHog/ph_encode.h>
#import <PostHog/ph_endian_inl_utils.h>
#import <PostHog/ph_filters_utils.h>
#import <PostHog/ph_format_constants.h>
#import <PostHog/ph_histogram_enc.h>
#import <PostHog/ph_huffman_encode_utils.h>
#import <PostHog/ph_lossless.h>
#import <PostHog/ph_lossless_common.h>
#import <PostHog/ph_mux.h>
#import <PostHog/ph_muxi.h>
#import <PostHog/ph_mux_types.h>
#import <PostHog/ph_neon.h>
#import <PostHog/ph_palette.h>
#import <PostHog/ph_quant.h>
#import <PostHog/ph_quant_levels_utils.h>
#import <PostHog/ph_random_utils.h>
#import <PostHog/ph_rescaler_utils.h>
#import <PostHog/ph_sharpyuv.h>
#import <PostHog/ph_sharpyuv_cpu.h>
#import <PostHog/ph_sharpyuv_csp.h>
#import <PostHog/ph_sharpyuv_dsp.h>
#import <PostHog/ph_sharpyuv_gamma.h>
#import <PostHog/ph_thread_utils.h>
#import <PostHog/ph_types.h>
#import <PostHog/ph_utils.h>
#import <PostHog/ph_vp8i_enc.h>
#import <PostHog/ph_vp8li_enc.h>
#import <PostHog/ph_vp8_dec.h>
#import <PostHog/ph_vp8i_dec.h>
#import <PostHog/ph_vp8li_dec.h>
#import <PostHog/ph_webpi_dec.h>
#import <PostHog/ph_huffman_utils.h>
#import <PostHog/ph_yuv.h>

View File

@@ -1,337 +0,0 @@
//
// PostHogApi.swift
// PostHog
//
// Created by Ben White on 06.02.23.
//
import Foundation
class PostHogApi {
private let config: PostHogConfig
// default is 60s but we do 10s
private let defaultTimeout: TimeInterval = 10
init(_ config: PostHogConfig) {
self.config = config
}
func sessionConfig() -> URLSessionConfiguration {
let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = [
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "\(postHogSdkName)/\(postHogVersion)",
]
return config
}
private func getURLRequest(_ url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = defaultTimeout
return request
}
private func getEndpointURL(
_ endpoint: String,
queryItems: URLQueryItem...,
relativeTo baseUrl: URL
) -> URL? {
guard var components = URLComponents(
url: baseUrl,
resolvingAgainstBaseURL: true
) else {
return nil
}
let path = "\(components.path)/\(endpoint)"
.replacingOccurrences(of: "/+", with: "/", options: .regularExpression)
components.path = path
components.queryItems = queryItems
return components.url
}
private func getRemoteConfigRequest() -> URLRequest? {
guard let baseUrl: URL = switch config.host.absoluteString {
case "https://us.i.posthog.com":
URL(string: "https://us-assets.i.posthog.com")
case "https://eu.i.posthog.com":
URL(string: "https://eu-assets.i.posthog.com")
default:
config.host
} else {
return nil
}
let url = baseUrl.appendingPathComponent("/array/\(config.apiKey)/config")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = defaultTimeout
return request
}
func batch(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) {
guard let url = getEndpointURL("/batch", relativeTo: config.host) else {
hedgeLog("Malformed batch URL error.")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil))
}
let config = sessionConfig()
var headers = config.httpAdditionalHeaders ?? [:]
headers["Accept-Encoding"] = "gzip"
headers["Content-Encoding"] = "gzip"
config.httpAdditionalHeaders = headers
let request = getURLRequest(url)
let toSend: [String: Any] = [
"api_key": self.config.apiKey,
"batch": events.map { $0.toJSON() },
"sent_at": toISO8601String(Date()),
]
var data: Data?
do {
data = try JSONSerialization.data(withJSONObject: toSend)
} catch {
hedgeLog("Error parsing the batch body: \(error)")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
}
var gzippedPayload: Data?
do {
gzippedPayload = try data!.gzipped()
} catch {
hedgeLog("Error gzipping the batch body: \(error).")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
}
URLSession(configuration: config).uploadTask(with: request, from: gzippedPayload!) { data, response, error in
if error != nil {
hedgeLog("Error calling the batch API: \(String(describing: error)).")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
}
let httpResponse = response as! HTTPURLResponse
if !(200 ... 299 ~= httpResponse.statusCode) {
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
let errorMessage = "Error sending events to batch API: status: \(jsonBody)."
hedgeLog(errorMessage)
} else {
hedgeLog("Events sent successfully.")
}
return completion(PostHogBatchUploadInfo(statusCode: httpResponse.statusCode, error: error))
}.resume()
}
func snapshot(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) {
guard let url = getEndpointURL(config.snapshotEndpoint, relativeTo: config.host) else {
hedgeLog("Malformed snapshot URL error.")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil))
}
for event in events {
event.apiKey = self.config.apiKey
}
let config = sessionConfig()
var headers = config.httpAdditionalHeaders ?? [:]
headers["Accept-Encoding"] = "gzip"
headers["Content-Encoding"] = "gzip"
config.httpAdditionalHeaders = headers
let request = getURLRequest(url)
let toSend = events.map { $0.toJSON() }
var data: Data?
do {
data = try JSONSerialization.data(withJSONObject: toSend)
// remove it only for debugging
// if let newData = data {
// let convertedString = String(data: newData, encoding: .utf8)
// hedgeLog("snapshot body: \(convertedString ?? "")")
// }
} catch {
hedgeLog("Error parsing the snapshot body: \(error)")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
}
var gzippedPayload: Data?
do {
gzippedPayload = try data!.gzipped()
} catch {
hedgeLog("Error gzipping the snapshot body: \(error).")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
}
URLSession(configuration: config).uploadTask(with: request, from: gzippedPayload!) { data, response, error in
if error != nil {
hedgeLog("Error calling the snapshot API: \(String(describing: error)).")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
}
let httpResponse = response as! HTTPURLResponse
if !(200 ... 299 ~= httpResponse.statusCode) {
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
let errorMessage = "Error sending events to snapshot API: status: \(httpResponse.statusCode), body: \(jsonBody)."
hedgeLog(errorMessage)
} else {
hedgeLog("Snapshots sent successfully.")
}
return completion(PostHogBatchUploadInfo(statusCode: httpResponse.statusCode, error: error))
}.resume()
}
func flags(
distinctId: String,
anonymousId: String?,
groups: [String: String],
personProperties: [String: Any],
groupProperties: [String: [String: Any]]? = nil,
completion: @escaping ([String: Any]?, _ error: Error?) -> Void
) {
let url = getEndpointURL(
"/flags",
queryItems: URLQueryItem(name: "v", value: "2"), URLQueryItem(name: "config", value: "true"),
relativeTo: config.host
)
guard let url else {
hedgeLog("Malformed flags URL error.")
return completion(nil, nil)
}
let config = sessionConfig()
let request = getURLRequest(url)
var toSend: [String: Any] = [
"api_key": self.config.apiKey,
"distinct_id": distinctId,
"$groups": groups,
]
if let anonymousId {
toSend["$anon_distinct_id"] = anonymousId
}
if !personProperties.isEmpty {
toSend["person_properties"] = personProperties
}
if let groupProperties, !groupProperties.isEmpty {
toSend["group_properties"] = groupProperties
}
if let evaluationEnvironments = self.config.evaluationEnvironments, !evaluationEnvironments.isEmpty {
toSend["evaluation_environments"] = evaluationEnvironments
}
var data: Data?
do {
data = try JSONSerialization.data(withJSONObject: toSend)
} catch {
hedgeLog("Error parsing the flags body: \(error)")
return completion(nil, error)
}
URLSession(configuration: config).uploadTask(with: request, from: data!) { data, response, error in
if error != nil {
hedgeLog("Error calling the flags API: \(String(describing: error))")
return completion(nil, error)
}
let httpResponse = response as! HTTPURLResponse
if !(200 ... 299 ~= httpResponse.statusCode) {
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
let errorMessage = "Error calling flags API: status: \(httpResponse.statusCode), body: \(jsonBody)."
hedgeLog(errorMessage)
return completion(nil,
InternalPostHogError(description: errorMessage))
} else {
hedgeLog("Flags called successfully.")
}
do {
let jsonData = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any]
completion(jsonData, nil)
} catch {
hedgeLog("Error parsing the flags response: \(error)")
completion(nil, error)
}
}.resume()
}
func remoteConfig(
completion: @escaping ([String: Any]?, _ error: Error?) -> Void
) {
guard let request = getRemoteConfigRequest() else {
hedgeLog("Error calling the remote config API: unable to create request")
return
}
let config = sessionConfig()
let task = URLSession(configuration: config).dataTask(with: request) { data, response, error in
if let error {
hedgeLog("Error calling the remote config API: \(error.localizedDescription)")
return completion(nil, error)
}
let httpResponse = response as! HTTPURLResponse
if !(200 ... 299 ~= httpResponse.statusCode) {
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
let errorMessage = "Error calling the remote config API: status: \(httpResponse.statusCode), body: \(jsonBody)."
hedgeLog(errorMessage)
return completion(nil,
InternalPostHogError(description: errorMessage))
} else {
hedgeLog("Remote config called successfully.")
}
do {
let jsonData = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any]
completion(jsonData, nil)
} catch {
hedgeLog("Error parsing the remote config response: \(error)")
completion(nil, error)
}
}
task.resume()
}
}
extension PostHogApi {
static var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
guard let date = apiDateFormatter.date(from: dateString) else {
throw DecodingError.dataCorruptedError(
in: container, debugDescription: "Invalid date format"
)
}
return date
}
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()
}

View File

@@ -1,13 +0,0 @@
//
// PostHogBatchUploadInfo.swift
// PostHog
//
// Created by Manoel Aranda Neto on 13.10.23.
//
import Foundation
struct PostHogBatchUploadInfo {
let statusCode: Int?
let error: Error?
}

View File

@@ -1,276 +0,0 @@
//
// PostHogConfig.swift
// PostHog
//
// Created by Ben White on 07.02.23.
//
import Foundation
public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
@objc public final class BoxedBeforeSendBlock: NSObject {
@objc public let block: BeforeSendBlock
@objc(block:)
public init(block: @escaping BeforeSendBlock) {
self.block = block
}
}
@objc(PostHogConfig) public class PostHogConfig: NSObject {
enum Defaults {
#if os(tvOS)
static let flushAt: Int = 5
static let maxQueueSize: Int = 100
#else
static let flushAt: Int = 20
static let maxQueueSize: Int = 1000
#endif
static let maxBatchSize: Int = 50
static let flushIntervalSeconds: TimeInterval = 30
}
@objc(PostHogDataMode) public enum PostHogDataMode: Int {
case wifi
case cellular
case any
}
@objc public let host: URL
@objc public let apiKey: String
@objc public var flushAt: Int = Defaults.flushAt
@objc public var maxQueueSize: Int = Defaults.maxQueueSize
@objc public var maxBatchSize: Int = Defaults.maxBatchSize
@objc public var flushIntervalSeconds: TimeInterval = Defaults.flushIntervalSeconds
@objc public var dataMode: PostHogDataMode = .any
@objc public var sendFeatureFlagEvent: Bool = true
@objc public var preloadFeatureFlags: Bool = true
/// Preload PostHog remote config automatically
/// Default: true
///
/// Note: Surveys rely on remote config. Disabling this will also disable Surveys
@objc public var remoteConfig: Bool = true
@objc public var captureApplicationLifecycleEvents: Bool = true
@objc public var captureScreenViews: Bool = true
/// Enable method swizzling for SDK functionality that depends on it
///
/// When disabled, functionality that require swizzling (like autocapture, screen views, session replay, surveys) will not be installed.
///
/// Note: Disabling swizzling will limit session rotation logic to only detect application open and background events.
/// Session rotation will still work, just with reduced granularity for detecting user activity.
///
/// Default: true
@objc public var enableSwizzling: Bool = true
#if os(iOS) || targetEnvironment(macCatalyst)
/// Enable autocapture for iOS
/// Default: false
@objc public var captureElementInteractions: Bool = false
#endif
@objc public var debug: Bool = false
@objc public var optOut: Bool = false
@objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid }
/// Flag to reuse the anonymous Id between `reset()` and next `identify()` calls
///
/// If enabled, the anonymous Id will be reused for all anonymous users on this device,
/// essentially creating a "Guest user Id" as long as this option is enabled.
///
/// Note:
/// Events captured *before* call to *identify()* won't be linked to the identified user
/// Events captured *after* call to *reset()* won't be linked to the identified user
///
/// Defaults to false.
@objc public var reuseAnonymousId: Bool = false
/// Hook that allows to sanitize the event properties
/// The hook is called before the event is cached or sent over the wire
@available(*, deprecated, message: "Use beforeSend instead")
@objc public var propertiesSanitizer: PostHogPropertiesSanitizer?
/// Determines the behavior for processing user profiles.
@objc public var personProfiles: PostHogPersonProfiles = .identifiedOnly
/// Automatically set common device and app properties as person properties for feature flag evaluation.
///
/// When enabled, the SDK will automatically set the following person properties:
/// - $app_version: App version from bundle
/// - $app_build: App build number from bundle
/// - $os_name: Operating system name (iOS, macOS, etc.)
/// - $os_version: Operating system version
/// - $device_type: Device type (Mobile, Tablet, Desktop, etc.)
/// - $locale: User's current locale
///
/// This helps ensure feature flags that rely on these properties work correctly
/// without waiting for server-side processing of identify() calls.
///
/// Default: true
@objc public var setDefaultPersonProperties: Bool = true
/// Evaluation environments for feature flags.
///
/// When configured, only feature flags that have at least one matching evaluation tag
/// will be evaluated. Feature flags with no evaluation tags will always be evaluated
/// for backward compatibility.
///
/// Example usage:
/// ```swift
/// config.evaluationEnvironments = ["production", "web", "checkout"]
/// ```
///
/// This helps ensure feature flags are only evaluated in the appropriate environments
/// for your SDK instance.
///
/// Default: nil (all flags are evaluated)
@objc public var evaluationEnvironments: [String]?
/// The identifier of the App Group that should be used to store shared analytics data.
/// PostHog will try to get the physical location of the App Groups shared container, otherwise fallback to the default location
/// Default: nil
@objc public var appGroupIdentifier: String?
/// Internal
/// Do not modify it, this flag is read and updated by the SDK via feature flags
@objc public var snapshotEndpoint: String = "/s/"
/// or EU Host: 'https://eu.i.posthog.com'
public static let defaultHost: String = "https://us.i.posthog.com"
#if os(iOS)
/// Enable Recording of Session Replays for iOS
/// Default: false
@objc public var sessionReplay: Bool = false
/// Session Replay configuration
@objc public let sessionReplayConfig: PostHogSessionReplayConfig = .init()
#endif
/// Enable mobile surveys
///
/// Default: true
///
/// Note: Event triggers will only work with the instance that first enables surveys.
/// In case of multiple instances, please make sure you are capturing events on the instance that has config.surveys = true
@available(iOS 15.0, *)
@available(watchOS, unavailable, message: "Surveys are only available on iOS 15+")
@available(macOS, unavailable, message: "Surveys are only available on iOS 15+")
@available(tvOS, unavailable, message: "Surveys are only available on iOS 15+")
@available(visionOS, unavailable, message: "Surveys are only available on iOS 15+")
@objc public var surveys: Bool {
get { _surveys }
set { setSurveys(newValue) }
}
@available(iOS 15.0, *)
@available(watchOS, unavailable, message: "Surveys are only available on iOS 15+")
@available(macOS, unavailable, message: "Surveys are only available on iOS 15+")
@available(tvOS, unavailable, message: "Surveys are only available on iOS 15+")
@available(visionOS, unavailable, message: "Surveys are only available on iOS 15+")
@objc public var surveysConfig: PostHogSurveysConfig {
get { _surveysConfig }
set { setSurveysConfig(newValue) }
}
// only internal
var disableReachabilityForTesting: Bool = false
var disableQueueTimerForTesting: Bool = false
// internal
public var storageManager: PostHogStorageManager?
@objc(apiKey:)
public init(
apiKey: String
) {
self.apiKey = apiKey
host = URL(string: PostHogConfig.defaultHost)!
}
@objc(apiKey:host:)
public init(
apiKey: String,
host: String = defaultHost
) {
self.apiKey = apiKey
self.host = URL(string: host) ?? URL(string: PostHogConfig.defaultHost)!
}
/// Returns an array of integrations to be installed based on current configuration
func getIntegrations() -> [PostHogIntegration] {
var integrations: [PostHogIntegration] = []
if captureScreenViews {
integrations.append(PostHogScreenViewIntegration())
}
if captureApplicationLifecycleEvents {
integrations.append(PostHogAppLifeCycleIntegration())
}
#if os(iOS)
if sessionReplay {
integrations.append(PostHogReplayIntegration())
}
if _surveys {
integrations.append(PostHogSurveyIntegration())
}
#endif
#if os(iOS) || targetEnvironment(macCatalyst)
if captureElementInteractions {
integrations.append(PostHogAutocaptureIntegration())
}
#endif
return integrations
}
var _surveys: Bool = true // swiftlint:disable:this identifier_name
private func setSurveys(_ value: Bool) {
// protection against objc API availability warning instead of error
// Unlike swift, which enforces stricter safety rules, objc just displays a warning
if #available(iOS 15.0, *) {
_surveys = value
}
}
var _surveysConfig: PostHogSurveysConfig = .init() // swiftlint:disable:this identifier_name
private func setSurveysConfig(_ value: PostHogSurveysConfig) {
// protection against objc API availability warning instead of error
// Unlike swift, which enforces stricter safety rules, objc just displays a warning
if #available(iOS 15.0, *) {
_surveysConfig = value
}
}
/// Hook that allows to sanitize the event
/// The hook is called before the event is cached or sent over the wire
private var beforeSend: BeforeSendBlock = { $0 }
private static func buildBeforeSendBlock(_ blocks: [BeforeSendBlock]) -> BeforeSendBlock {
{ event in
blocks.reduce(event) { event, block in
event.flatMap(block)
}
}
}
public func setBeforeSend(_ blocks: [BeforeSendBlock]) {
beforeSend = Self.buildBeforeSendBlock(blocks)
}
public func setBeforeSend(_ blocks: BeforeSendBlock...) {
setBeforeSend(blocks)
}
@available(*, unavailable, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead")
@objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) {
setBeforeSend(blocks.map(\.block))
}
func runBeforeSend(_ event: PostHogEvent) -> PostHogEvent? {
beforeSend(event)
}
}

View File

@@ -1,13 +0,0 @@
//
// PostHogConsumerPayload.swift
// PostHog
//
// Created by Manoel Aranda Neto on 13.10.23.
//
import Foundation
struct PostHogConsumerPayload {
let events: [PostHogEvent]
let completion: (Bool) -> Void
}

View File

@@ -1,411 +0,0 @@
//
// PostHogContext.swift
// PostHog
//
// Created by Manoel Aranda Neto on 16.10.23.
//
import Foundation
#if os(iOS) || os(tvOS) || os(visionOS)
import UIKit
#elseif os(macOS)
import AppKit
#elseif os(watchOS)
import WatchKit
#endif
class PostHogContext {
@ReadWriteLock
private var screenSize: CGSize?
#if !os(watchOS)
private let reachability: Reachability?
#endif
private lazy var theStaticContext: [String: Any] = {
// Properties that do not change over the lifecycle of an application
var properties: [String: Any] = [:]
let infoDictionary = Bundle.main.infoDictionary
if let appName = infoDictionary?[kCFBundleNameKey as String] {
properties["$app_name"] = appName
} else if let appName = infoDictionary?["CFBundleDisplayName"] {
properties["$app_name"] = appName
}
if let appVersion = infoDictionary?["CFBundleShortVersionString"] {
properties["$app_version"] = appVersion
}
if let appBuild = infoDictionary?["CFBundleVersion"] {
properties["$app_build"] = appBuild
}
if Bundle.main.bundleIdentifier != nil {
properties["$app_namespace"] = Bundle.main.bundleIdentifier
}
properties["$device_manufacturer"] = "Apple"
properties["$device_model"] = platform()
if let deviceType = PostHogContext.deviceType {
properties["$device_type"] = deviceType
}
properties["$is_emulator"] = PostHogContext.isSimulator
let isIOSAppOnMac = PostHogContext.isIOSAppOnMac
let isMacCatalystApp = PostHogContext.isMacCatalystApp
properties["$is_ios_running_on_mac"] = isIOSAppOnMac
properties["$is_mac_catalyst_app"] = isMacCatalystApp
#if os(iOS) || os(tvOS) || os(visionOS)
let device = UIDevice.current
// use https://github.com/devicekit/DeviceKit
let processInfo = ProcessInfo.processInfo
if isMacCatalystApp || isIOSAppOnMac {
let underlyingOS = device.systemName
let underlyingOSVersion = device.systemVersion
let macOSVersion = processInfo.operatingSystemVersionString
if isMacCatalystApp {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
properties["$os_version"] = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
} else {
let osVersionString = processInfo.operatingSystemVersionString
if let versionRange = osVersionString.range(of: #"\d+\.\d+\.\d+"#, options: .regularExpression) {
properties["$os_version"] = osVersionString[versionRange]
} else {
// fallback to full version string in case formatting changes
properties["$os_version"] = osVersionString
}
}
// device.userInterfaceIdiom reports .pad here, so we use a static value instead
// - For an app deployable on iPad, the idiom type is always .pad (instead of .mac)
//
// Source: https://developer.apple.com/documentation/apple-silicon/adapting-ios-code-to-run-in-the-macos-environment#Handle-unknown-device-types-gracefully
properties["$os_name"] = "macOS"
properties["$device_name"] = processInfo.hostName
} else {
// use https://github.com/devicekit/DeviceKit
properties["$os_name"] = device.systemName
properties["$os_version"] = device.systemVersion
properties["$device_name"] = device.model
}
#elseif os(macOS)
let deviceName = Host.current().localizedName
if (deviceName?.isEmpty) != nil {
properties["$device_name"] = deviceName
}
let processInfo = ProcessInfo.processInfo
properties["$os_name"] = "macOS"
let osVersion = processInfo.operatingSystemVersion
properties["$os_version"] = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
#endif
return properties
}()
#if !os(watchOS)
init(_ reachability: Reachability?) {
self.reachability = reachability
registerNotifications()
}
#else
init() {
if #available(watchOS 7.0, *) {
registerNotifications()
} else {
onShouldUpdateScreenSize()
}
}
#endif
deinit {
#if !os(watchOS)
unregisterNotifications()
#else
if #available(watchOS 7.0, *) {
unregisterNotifications()
}
#endif
}
private lazy var theSdkInfo: [String: Any] = {
var sdkInfo: [String: Any] = [:]
sdkInfo["$lib"] = postHogSdkName
sdkInfo["$lib_version"] = postHogVersion
return sdkInfo
}()
func staticContext() -> [String: Any] {
theStaticContext
}
func sdkInfo() -> [String: Any] {
theSdkInfo
}
private func platform() -> String {
var sysctlName = "hw.machine"
// In case of mac catalyst or iOS running on mac:
// - "hw.machine" returns underlying iPad/iPhone model
// - "hw.model" returns mac model
#if targetEnvironment(macCatalyst)
sysctlName = "hw.model"
#elseif os(iOS) || os(visionOS)
if #available(iOS 14.0, *) {
if ProcessInfo.processInfo.isiOSAppOnMac {
sysctlName = "hw.model"
}
}
#endif
var size = 0
sysctlbyname(sysctlName, nil, &size, nil, 0)
var machine = [CChar](repeating: 0, count: size)
sysctlbyname(sysctlName, &machine, &size, nil, 0)
return String(cString: machine)
}
func dynamicContext() -> [String: Any] {
var properties: [String: Any] = [:]
if let screenSize {
properties["$screen_width"] = Float(screenSize.width)
properties["$screen_height"] = Float(screenSize.height)
}
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
if let languageCode = Locale.current.language.languageCode {
properties["$locale"] = languageCode.identifier
}
} else {
if Locale.current.languageCode != nil {
properties["$locale"] = Locale.current.languageCode
}
}
properties["$timezone"] = TimeZone.current.identifier
#if !os(watchOS)
if reachability != nil {
properties["$network_wifi"] = reachability?.connection == .wifi
properties["$network_cellular"] = reachability?.connection == .cellular
}
#endif
return properties
}
/// Returns person properties context by extracting relevant properties from static context.
/// This centralizes the logic for determining which properties should be used as person properties.
func personPropertiesContext() -> [String: Any] {
let staticCtx = staticContext()
var personProperties: [String: Any] = [:]
// App information
if let appVersion = staticCtx["$app_version"] {
personProperties["$app_version"] = appVersion
}
if let appBuild = staticCtx["$app_build"] {
personProperties["$app_build"] = appBuild
}
// Operating system information
if let osName = staticCtx["$os_name"] {
personProperties["$os_name"] = osName
}
if let osVersion = staticCtx["$os_version"] {
personProperties["$os_version"] = osVersion
}
// Device information
if let deviceType = staticCtx["$device_type"] {
personProperties["$device_type"] = deviceType
}
if let deviceManufacturer = staticCtx["$device_manufacturer"] {
personProperties["$device_manufacturer"] = deviceManufacturer
}
if let deviceModel = staticCtx["$device_model"] {
personProperties["$device_model"] = deviceModel
}
// Localization - read directly to avoid expensive dynamicContext call
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
if let languageCode = Locale.current.language.languageCode {
personProperties["$locale"] = languageCode.identifier
}
} else {
if let languageCode = Locale.current.languageCode {
personProperties["$locale"] = languageCode
}
}
return personProperties
}
private func registerNotifications() {
#if os(iOS) || os(tvOS) || os(visionOS)
#if os(iOS)
NotificationCenter.default.addObserver(self,
selector: #selector(onOrientationDidChange),
name: UIDevice.orientationDidChangeNotification,
object: nil)
#endif
NotificationCenter.default.addObserver(self,
selector: #selector(onShouldUpdateScreenSize),
name: UIWindow.didBecomeKeyNotification,
object: nil)
#elseif os(macOS)
NotificationCenter.default.addObserver(self,
selector: #selector(onShouldUpdateScreenSize),
name: NSWindow.didBecomeKeyNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(onShouldUpdateScreenSize),
name: NSWindow.didChangeScreenNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(onShouldUpdateScreenSize),
name: NSApplication.didBecomeActiveNotification,
object: nil)
#elseif os(watchOS)
if #available(watchOS 7.0, *) {
NotificationCenter.default.addObserver(self,
selector: #selector(onShouldUpdateScreenSize),
name: WKApplication.didBecomeActiveNotification,
object: nil)
}
#endif
}
private func unregisterNotifications() {
#if os(iOS) || os(tvOS) || os(visionOS)
#if os(iOS)
NotificationCenter.default.removeObserver(self,
name: UIDevice.orientationDidChangeNotification,
object: nil)
#endif
NotificationCenter.default.removeObserver(self,
name: UIWindow.didBecomeKeyNotification,
object: nil)
#elseif os(macOS)
NotificationCenter.default.removeObserver(self,
name: NSWindow.didBecomeKeyNotification,
object: nil)
NotificationCenter.default.removeObserver(self,
name: NSWindow.didChangeScreenNotification,
object: nil)
NotificationCenter.default.removeObserver(self,
name: NSApplication.didBecomeActiveNotification,
object: nil)
#elseif os(watchOS)
if #available(watchOS 7.0, *) {
NotificationCenter.default.removeObserver(self,
name: WKApplication.didBecomeActiveNotification,
object: nil)
}
#endif
}
/// Retrieves the current screen size of the application window based on platform
private func getScreenSize() -> CGSize? {
#if os(iOS) || os(tvOS) || os(visionOS)
return UIApplication.getCurrentWindow(filterForegrounded: false)?.bounds.size
#elseif os(macOS)
// NSScreen.frame represents the full screen rectangle and includes any space occupied by menu, dock or camera bezel
return NSApplication.shared.windows.first { $0.isKeyWindow }?.screen?.frame.size
#elseif os(watchOS)
return WKInterfaceDevice.current().screenBounds.size
#else
return nil
#endif
}
#if os(iOS)
// Special treatment for `orientationDidChangeNotification` since the notification seems to be _sometimes_ called early, before screen bounds are flipped
@objc private func onOrientationDidChange() {
updateScreenSize {
self.getScreenSize().map { size in
// manually set width and height based on device orientation. (Needed for fast orientation changes)
if UIDevice.current.orientation.isLandscape {
CGSize(width: max(size.width, size.height), height: min(size.height, size.width))
} else {
CGSize(width: min(size.width, size.height), height: max(size.height, size.width))
}
}
}
}
#endif
@objc private func onShouldUpdateScreenSize() {
updateScreenSize(getScreenSize)
}
private func updateScreenSize(_ getSize: @escaping () -> CGSize?) {
let block = {
self.screenSize = getSize()
}
// ensure block is executed on `main` since closure accesses non thread-safe UI objects like UIApplication
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
static let deviceType: String? = {
#if os(iOS) || os(tvOS)
if isMacCatalystApp || isIOSAppOnMac {
return "Desktop"
} else {
switch UIDevice.current.userInterfaceIdiom {
case UIUserInterfaceIdiom.phone:
return "Mobile"
case UIUserInterfaceIdiom.pad:
return "Tablet"
case UIUserInterfaceIdiom.tv:
return "TV"
case UIUserInterfaceIdiom.carPlay:
return "CarPlay"
case UIUserInterfaceIdiom.mac:
return "Desktop"
case UIUserInterfaceIdiom.vision:
return "Vision"
default:
return nil
}
}
#elseif os(macOS)
return "Desktop"
#else
return nil
#endif
}()
static let isIOSAppOnMac: Bool = {
if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
return ProcessInfo.processInfo.isiOSAppOnMac
}
return false
}()
static let isMacCatalystApp: Bool = {
#if targetEnvironment(macCatalyst)
true
#else
false
#endif
}()
static let isSimulator: Bool = {
#if targetEnvironment(simulator)
true
#else
false
#endif
}()
}

View File

@@ -1,20 +0,0 @@
//
// PostHogExtensions.swift
// PostHog
//
// Created by Manoel Aranda Neto on 13.10.23.
//
import Foundation
/**
# Notifications
This helper module encapsulates all notifications that we trigger from within the SDK.
*/
public extension PostHogSDK {
@objc static let didStartNotification = Notification.Name("PostHogDidStart") // object: nil
@objc static let didReceiveFeatureFlags = Notification.Name("PostHogDidReceiveFeatureFlags") // object: nil
}

View File

@@ -1,114 +0,0 @@
//
// PostHogFileBackedQueue.swift
// PostHog
//
// Created by Manoel Aranda Neto on 13.10.23.
//
import Foundation
class PostHogFileBackedQueue {
let queue: URL
@ReadWriteLock
private var items = [String]()
var depth: Int {
items.count
}
init(queue: URL, oldQueue: URL? = nil) {
self.queue = queue
setup(oldQueue: oldQueue)
}
private func setup(oldQueue: URL?) {
do {
try FileManager.default.createDirectory(atPath: queue.path, withIntermediateDirectories: true)
} catch {
hedgeLog("Error trying to create caching folder \(error)")
}
if oldQueue != nil {
migrateOldQueue(queue: queue, oldQueue: oldQueue!)
}
do {
items = try FileManager.default.contentsOfDirectory(atPath: queue.path)
items.sort { Double($0)! < Double($1)! }
} catch {
hedgeLog("Failed to load files for queue \(error)")
// failed to read directory bad permissions, perhaps?
}
}
func peek(_ count: Int) -> [Data] {
loadFiles(count)
}
func delete(index: Int) {
if items.isEmpty { return }
let removed = items.remove(at: index)
deleteSafely(queue.appendingPathComponent(removed))
}
func pop(_ count: Int) {
deleteFiles(count)
}
func add(_ contents: Data) {
do {
let filename = "\(Date().timeIntervalSince1970)"
try contents.write(to: queue.appendingPathComponent(filename))
items.append(filename)
} catch {
hedgeLog("Could not write file \(error)")
}
}
/// Internal, used for testing
func clear() {
deleteSafely(queue)
setup(oldQueue: nil)
}
private func loadFiles(_ count: Int) -> [Data] {
var results = [Data]()
for item in items {
let itemURL = queue.appendingPathComponent(item)
do {
if !FileManager.default.fileExists(atPath: itemURL.path) {
hedgeLog("File \(itemURL) does not exist")
continue
}
let contents = try Data(contentsOf: itemURL)
results.append(contents)
} catch {
hedgeLog("File \(itemURL) is corrupted \(error)")
deleteSafely(itemURL)
}
if results.count == count {
return results
}
}
return results
}
private func deleteFiles(_ count: Int) {
for _ in 0 ..< count {
if let removed: String = _items.mutate({ items in
if items.isEmpty {
return nil
}
return items.remove(at: 0) // We always remove from the top of the queue
}) {
deleteSafely(queue.appendingPathComponent(removed))
}
}
}
}

View File

@@ -1,59 +0,0 @@
//
// PostHogIntegration.swift
// PostHog
//
// Created by Ioannis Josephides on 25/02/2025.
//
import Foundation
protocol PostHogIntegration {
/**
* Indicates whether this integration requires method swizzling to function.
*
* When `enableSwizzling` is set to `false` in PostHogConfig, integrations
* that return `true` for this property will be skipped during installation.
*/
var requiresSwizzling: Bool { get }
/**
* Installs and initializes the integration with a PostHogSDK instance.
*
* This method should:
* 1. Run checks if needed to ensure that the integration is only installed once
* 2. Initialize any required resources
* 3. Start the integration's functionality
*
* - Parameter postHog: The PostHogSDK instance to integrate with
* - Throws: InternalPostHogError if installation fails (e.g., already installed)
*/
func install(_ postHog: PostHogSDK) throws
/**
* Uninstalls the integration from a specific PostHogSDK instance.
*
* This method should:
* 1. Stop all integration functionality
* 2. Clean up any resources
* 3. Remove references to the PostHog instance
*
* - Parameter postHog: The PostHog SDK instance to uninstall from
*/
func uninstall(_ postHog: PostHogSDK)
/**
* Starts the integration's functionality.
*
* Note: This is typically called automatically during installation
* but may be called manually to restart a stopped integration.
*/
func start()
/**
* Stops the integration's functionality without uninstalling.
*
* Note: This is typically called automatically during uninstallation
* but may be called manually to temporarily suspend the integration
* while maintaining its installation status (e.g manual start/stop for session recording)
*/
func stop()
}

View File

@@ -1,45 +0,0 @@
//
// PostHogLegacyQueue.swift
// PostHog
//
// Created by Manoel Aranda Neto on 30.10.23.
//
import Foundation
// Migrates the Old Queue (v2) to the new Queue (v3)
func migrateOldQueue(queue: URL, oldQueue: URL) {
if !FileManager.default.fileExists(atPath: oldQueue.path) {
return
}
defer {
deleteSafely(oldQueue)
}
do {
let data = try Data(contentsOf: oldQueue)
let array = try JSONSerialization.jsonObject(with: data) as? [Any]
if array == nil {
return
}
for item in array! {
guard let event = item as? [String: Any] else {
continue
}
let timestamp = event["timestamp"] as? String ?? toISO8601String(Date())
let timestampDate = toISO8601Date(timestamp) ?? Date()
let filename = "\(timestampDate.timeIntervalSince1970)"
let contents = try JSONSerialization.data(withJSONObject: event)
try contents.write(to: queue.appendingPathComponent(filename))
}
} catch {
hedgeLog("Failed to migrate queue \(error)")
}
}

View File

@@ -1,20 +0,0 @@
//
// PostHogPersonProfiles.swift
// PostHog
//
// Created by Manoel Aranda Neto on 09.09.24.
//
import Foundation
/// Determines the behavior for processing user profiles.
/// - `never`: We won't process persons for any event. This means that anonymous users will not be merged once
/// they sign up or login, so you lose the ability to create funnels that track users from anonymous to identified.
/// All events (including `$identify`) will be sent with `$process_person_profile: False`.
/// - `always`: We will process persons data for all events.
/// - `identifiedOnly`: (default): we will only process persons when you call `identify`, `alias`, and `group`, Anonymous users won't get person profiles.
@objc(PostHogPersonProfiles) public enum PostHogPersonProfiles: Int {
case never
case always
case identifiedOnly
}

View File

@@ -1,34 +0,0 @@
//
// PostHogPropertiesSanitizer.swift
// PostHog
//
// Created by Manoel Aranda Neto on 06.08.24.
//
import Foundation
/// Protocol to sanitize the event properties
@objc(PostHogPropertiesSanitizer) public protocol PostHogPropertiesSanitizer {
/// Sanitizes the event properties
/// - Parameter properties: the event properties to sanitize
/// - Returns: the sanitized properties
///
/// Obs: `inout` cannot be used in Swift protocols, so you need to clone the properties
///
/// ```swift
/// private class ExampleSanitizer: PostHogPropertiesSanitizer {
/// public func sanitize(_ properties: [String: Any]) -> [String: Any] {
/// var sanitizedProperties = properties
/// // Perform sanitization
/// // For example, removing keys with empty values
/// for (key, value) in properties {
/// if let stringValue = value as? String, stringValue.isEmpty {
/// sanitizedProperties.removeValue(forKey: key)
/// }
/// }
/// return sanitizedProperties
/// }
/// }
/// ```
@objc func sanitize(_ properties: [String: Any]) -> [String: Any]
}

View File

@@ -1,285 +0,0 @@
//
// PostHogQueue.swift
// PostHog
//
// Created by Ben White on 06.02.23.
//
import Foundation
/**
# Queue
The queue uses File persistence. This allows us to
1. Only send events when we have a network connection
2. Ensure that we can survive app closing or offline situations
3. Not hold too much in memory
*/
class PostHogQueue {
enum PostHogApiEndpoint: Int {
case batch
case snapshot
}
private let config: PostHogConfig
private let api: PostHogApi
private var paused: Bool = false
private let pausedLock = NSLock()
private var pausedUntil: Date?
private var retryCount: TimeInterval = 0
#if !os(watchOS)
private let reachability: Reachability?
#endif
private var isFlushing = false
private let isFlushingLock = NSLock()
private var timer: Timer?
private let timerLock = NSLock()
private let endpoint: PostHogApiEndpoint
private let dispatchQueue: DispatchQueue
/// Internal, used for testing
var depth: Int {
fileQueue.depth
}
private let fileQueue: PostHogFileBackedQueue
#if !os(watchOS)
init(_ config: PostHogConfig, _ storage: PostHogStorage, _ api: PostHogApi, _ endpoint: PostHogApiEndpoint, _ reachability: Reachability?) {
self.config = config
self.api = api
self.reachability = reachability
self.endpoint = endpoint
switch endpoint {
case .batch:
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .queue), oldQueue: storage.url(forKey: .oldQeueue))
dispatchQueue = DispatchQueue(label: "com.posthog.Queue", target: .global(qos: .utility))
case .snapshot:
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .replayQeueue))
dispatchQueue = DispatchQueue(label: "com.posthog.ReplayQueue", target: .global(qos: .utility))
}
}
#else
init(_ config: PostHogConfig, _ storage: PostHogStorage, _ api: PostHogApi, _ endpoint: PostHogApiEndpoint) {
self.config = config
self.api = api
self.endpoint = endpoint
switch endpoint {
case .batch:
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .queue), oldQueue: storage.url(forKey: .oldQeueue))
dispatchQueue = DispatchQueue(label: "com.posthog.Queue", target: .global(qos: .utility))
case .snapshot:
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .replayQeueue))
dispatchQueue = DispatchQueue(label: "com.posthog.ReplayQueue", target: .global(qos: .utility))
}
}
#endif
private func eventHandler(_ payload: PostHogConsumerPayload) {
hedgeLog("Sending batch of \(payload.events.count) events to PostHog")
switch endpoint {
case .batch:
api.batch(events: payload.events) { result in
self.handleResult(result, payload)
}
case .snapshot:
api.snapshot(events: payload.events) { result in
self.handleResult(result, payload)
}
}
}
private func handleResult(_ result: PostHogBatchUploadInfo, _ payload: PostHogConsumerPayload) {
// -1 means its not anything related to the API but rather network or something else, so we try again
let statusCode = result.statusCode ?? -1
var shouldRetry = false
if 300 ... 399 ~= statusCode || statusCode == -1 {
shouldRetry = true
}
// TODO: https://github.com/PostHog/posthog-android/pull/130
// fix: reduce batch size if API returns 413
if shouldRetry {
retryCount += 1
let delay = min(retryCount * retryDelay, maxRetryDelay)
pauseFor(seconds: delay)
hedgeLog("Pausing queue consumption for \(delay) seconds due to \(retryCount) API failure(s).")
} else {
retryCount = 0
}
payload.completion(!shouldRetry)
}
func start(disableReachabilityForTesting: Bool,
disableQueueTimerForTesting: Bool)
{
if !disableReachabilityForTesting {
// Setup the monitoring of network status for the queue
#if !os(watchOS)
reachability?.whenReachable = { reachability in
self.pausedLock.withLock {
if self.config.dataMode == .wifi, reachability.connection != .wifi {
hedgeLog("Queue is paused because its not in WiFi mode")
self.paused = true
} else {
self.paused = false
}
}
// Always trigger a flush when we are on wifi
if reachability.connection == .wifi {
if !self.isFlushing {
self.flush()
}
}
}
reachability?.whenUnreachable = { _ in
self.pausedLock.withLock {
hedgeLog("Queue is paused because network is unreachable")
self.paused = true
}
}
do {
try reachability?.startNotifier()
} catch {
hedgeLog("Error: Unable to monitor network reachability: \(error)")
}
#endif
}
if !disableQueueTimerForTesting {
timerLock.withLock {
DispatchQueue.main.async {
self.timer = Timer.scheduledTimer(withTimeInterval: self.config.flushIntervalSeconds, repeats: true, block: { _ in
if !self.isFlushing {
self.flush()
}
})
}
}
}
}
/// Internal, used for testing
func clear() {
fileQueue.clear()
}
func stop() {
timerLock.withLock {
timer?.invalidate()
timer = nil
}
}
func flush() {
if !canFlush() {
return
}
take(config.maxBatchSize) { payload in
if !payload.events.isEmpty {
self.eventHandler(payload)
} else {
// there's nothing to be sent
payload.completion(true)
}
}
}
private func flushIfOverThreshold() {
if fileQueue.depth >= config.flushAt {
flush()
}
}
func add(_ event: PostHogEvent) {
if fileQueue.depth >= config.maxQueueSize {
hedgeLog("Queue is full, dropping oldest event")
// first is always oldest
fileQueue.delete(index: 0)
}
var data: Data?
do {
data = try JSONSerialization.data(withJSONObject: event.toJSON())
} catch {
hedgeLog("Tried to queue unserialisable PostHogEvent \(error)")
return
}
fileQueue.add(data!)
hedgeLog("Queued event '\(event.event)'. Depth: \(fileQueue.depth)")
flushIfOverThreshold()
}
private func take(_ count: Int, completion: @escaping (PostHogConsumerPayload) -> Void) {
dispatchQueue.async {
self.isFlushingLock.withLock {
if self.isFlushing {
return
}
self.isFlushing = true
}
let items = self.fileQueue.peek(count)
var processing = [PostHogEvent]()
for item in items {
// each element is a PostHogEvent if fromJSON succeeds
guard let event = PostHogEvent.fromJSON(item) else {
continue
}
processing.append(event)
}
completion(PostHogConsumerPayload(events: processing) { success in
if success, items.count > 0 {
self.fileQueue.pop(items.count)
hedgeLog("Completed!")
}
self.isFlushingLock.withLock {
self.isFlushing = false
}
})
}
}
private func pauseFor(seconds: TimeInterval) {
pausedUntil = Date().addingTimeInterval(seconds)
}
private func canFlush() -> Bool {
if isFlushing {
hedgeLog("Already flushing")
return false
}
if paused {
// We don't flush data if the queue is paused
hedgeLog("The queue is paused due to the reachability check")
return false
}
if pausedUntil != nil, pausedUntil! > Date() {
// We don't flush data if the queue is temporarily paused
hedgeLog("The queue is paused until `\(pausedUntil!)`")
return false
}
return true
}
}

View File

@@ -1,636 +0,0 @@
//
// PostHogRemoteConfig.swift
// PostHog
//
// Created by Manoel Aranda Neto on 10.10.23.
//
import Foundation
class PostHogRemoteConfig {
private let hasFeatureFlagsKey = "hasFeatureFlags"
private let config: PostHogConfig
private let storage: PostHogStorage
private let api: PostHogApi
private let getDefaultPersonProperties: () -> [String: Any]
private let loadingFeatureFlagsLock = NSLock()
private let featureFlagsLock = NSLock()
private var loadingFeatureFlags = false
private var sessionReplayFlagActive = false
private var flags: [String: Any]?
private var featureFlags: [String: Any]?
private var remoteConfigLock = NSLock()
private let loadingRemoteConfigLock = NSLock()
private var loadingRemoteConfig = false
private var remoteConfig: [String: Any]?
private var remoteConfigDidFetch: Bool = false
private var featureFlagPayloads: [String: Any]?
private var requestId: String?
private let personPropertiesForFlagsLock = NSLock()
private var personPropertiesForFlags: [String: Any] = [:]
private let groupPropertiesForFlagsLock = NSLock()
private var groupPropertiesForFlags: [String: [String: Any]] = [:]
/// Internal, only used for testing
var canReloadFlagsForTesting = true
var onRemoteConfigLoaded: (([String: Any]?) -> Void)?
var onFeatureFlagsLoaded: (([String: Any]?) -> Void)?
private let dispatchQueue = DispatchQueue(label: "com.posthog.RemoteConfig",
target: .global(qos: .utility))
var lastRequestId: String? {
featureFlagsLock.withLock {
requestId ?? storage.getString(forKey: .requestId)
}
}
init(_ config: PostHogConfig,
_ storage: PostHogStorage,
_ api: PostHogApi,
_ getDefaultPersonProperties: @escaping () -> [String: Any])
{
self.config = config
self.storage = storage
self.api = api
self.getDefaultPersonProperties = getDefaultPersonProperties
// Load cached person and group properties for flags
loadCachedPropertiesForFlags()
preloadSessionReplayFlag()
if config.remoteConfig {
preloadRemoteConfig()
} else if config.preloadFeatureFlags {
preloadFeatureFlags()
}
}
private func preloadRemoteConfig() {
remoteConfigLock.withLock {
// load disk cached config to memory
_ = getCachedRemoteConfig()
}
// may have already beed fetched from `loadFeatureFlags` call
if remoteConfigLock.withLock({
self.remoteConfig == nil || !self.remoteConfigDidFetch
}) {
dispatchQueue.async {
self.reloadRemoteConfig { [weak self] remoteConfig in
guard let self else { return }
// if there's no remote config response, skip
guard let remoteConfig else {
hedgeLog("Remote config response is missing, skipping loading flags")
notifyFeatureFlags(nil)
return
}
// Check if the server explicitly responded with hasFeatureFlags key
if let hasFeatureFlagsBoolValue = remoteConfig[self.hasFeatureFlagsKey] as? Bool, !hasFeatureFlagsBoolValue {
hedgeLog("hasFeatureFlags is false, clearing flags and skipping loading flags")
// Server responded with explicit hasFeatureFlags: false, meaning no active flags on the account
clearFeatureFlags()
// need to notify cause people may be waiting for flags to load
notifyFeatureFlags([:])
} else if self.config.preloadFeatureFlags {
// If we reach here, hasFeatureFlags is either true, nil or not a boolean value
// Note: notifyFeatureFlags() will be eventually called inside preloadFeatureFlags()
self.preloadFeatureFlags()
}
}
}
}
}
private func preloadFeatureFlags() {
featureFlagsLock.withLock {
// load disk cached config to memory
_ = getCachedFeatureFlags()
}
if config.preloadFeatureFlags {
dispatchQueue.async {
self.reloadFeatureFlags()
}
}
}
func reloadRemoteConfig(
callback: (([String: Any]?) -> Void)? = nil
) {
guard config.remoteConfig else {
callback?(nil)
return
}
loadingRemoteConfigLock.withLock {
if self.loadingRemoteConfig {
return
}
self.loadingRemoteConfig = true
}
api.remoteConfig { config, _ in
if let config {
// cache config
self.remoteConfigLock.withLock {
self.remoteConfig = config
self.storage.setDictionary(forKey: .remoteConfig, contents: config)
}
// process session replay config
#if os(iOS)
let featureFlags = self.featureFlagsLock.withLock { self.featureFlags }
self.processSessionRecordingConfig(config, featureFlags: featureFlags ?? [:])
#endif
// notify
DispatchQueue.main.async {
self.onRemoteConfigLoaded?(config)
}
}
self.loadingRemoteConfigLock.withLock {
self.remoteConfigDidFetch = true
self.loadingRemoteConfig = false
}
callback?(config)
}
}
func reloadFeatureFlags(
callback: (([String: Any]?) -> Void)? = nil
) {
guard canReloadFlagsForTesting else {
return
}
guard let storageManager = config.storageManager else {
hedgeLog("No PostHogStorageManager found in config, skipping loading feature flags")
callback?(nil)
return
}
let groups = featureFlagsLock.withLock { getGroups() }
let distinctId = storageManager.getDistinctId()
let anonymousId = config.reuseAnonymousId == false ? storageManager.getAnonymousId() : nil
loadFeatureFlags(
distinctId: distinctId,
anonymousId: anonymousId,
groups: groups,
callback: callback ?? { _ in }
)
}
private func preloadSessionReplayFlag() {
var sessionReplay: [String: Any]?
var featureFlags: [String: Any]?
featureFlagsLock.withLock {
sessionReplay = self.storage.getDictionary(forKey: .sessionReplay) as? [String: Any]
featureFlags = self.getCachedFeatureFlags()
}
if let sessionReplay = sessionReplay {
sessionReplayFlagActive = isRecordingActive(featureFlags ?? [:], sessionReplay)
if let endpoint = sessionReplay["endpoint"] as? String {
config.snapshotEndpoint = endpoint
}
}
}
private func isRecordingActive(_ featureFlags: [String: Any], _ sessionRecording: [String: Any]) -> Bool {
var recordingActive = true
// check for boolean flags
if let linkedFlag = sessionRecording["linkedFlag"] as? String {
let value = featureFlags[linkedFlag]
if let boolValue = value as? Bool {
// boolean flag with value
recordingActive = boolValue
} else if value is String {
// its a multi-variant flag linked to "any"
recordingActive = true
} else {
// disable recording if the flag does not exist/quota limited
recordingActive = false
}
// check for specific flag variant
} else if let linkedFlag = sessionRecording["linkedFlag"] as? [String: Any] {
let flag = linkedFlag["flag"] as? String
let variant = linkedFlag["variant"] as? String
if let flag, let variant {
let value = featureFlags[flag] as? String
recordingActive = value == variant
} else {
// disable recording if the flag does not exist/quota limited
recordingActive = false
}
}
// check for multi flag variant (any)
// if let linkedFlag = sessionRecording["linkedFlag"] as? String,
// featureFlags[linkedFlag] != nil
// is also a valid check but since we cannot check the value of the flag,
// we consider session recording is active
return recordingActive
}
func loadFeatureFlags(
distinctId: String,
anonymousId: String?,
groups: [String: String],
callback: @escaping ([String: Any]?) -> Void
) {
loadingFeatureFlagsLock.withLock {
if self.loadingFeatureFlags {
return
}
self.loadingFeatureFlags = true
}
let personProperties = getPersonPropertiesForFlags()
let groupProperties = getGroupPropertiesForFlags()
api.flags(distinctId: distinctId,
anonymousId: anonymousId,
groups: groups,
personProperties: personProperties,
groupProperties: groupProperties.isEmpty ? nil : groupProperties)
{ data, _ in
self.dispatchQueue.async {
// Check for quota limitation first
if let quotaLimited = data?["quotaLimited"] as? [String],
quotaLimited.contains("feature_flags")
{
// swiftlint:disable:next line_length
hedgeLog("Warning: Feature flags quota limit reached - clearing all feature flags and payloads. See https://posthog.com/docs/billing/limits-alerts for more information.")
self.clearFeatureFlags()
self.notifyFeatureFlagsAndRelease([:])
return callback([:])
}
// Safely handle optional data
guard var data = data else {
hedgeLog("Error: Flags response data is nil")
self.notifyFeatureFlagsAndRelease(nil)
return callback(nil)
}
self.normalizeResponse(&data)
let flagsV4 = data["flags"] as? [String: Any]
guard let featureFlags = data["featureFlags"] as? [String: Any],
let featureFlagPayloads = data["featureFlagPayloads"] as? [String: Any]
else {
hedgeLog("Error: Flags response missing correct featureFlags format")
self.notifyFeatureFlagsAndRelease(nil)
return callback(nil)
}
#if os(iOS)
self.processSessionRecordingConfig(data, featureFlags: featureFlags)
#endif
// Grab the request ID from the response
let requestId = data["requestId"] as? String
let errorsWhileComputingFlags = data["errorsWhileComputingFlags"] as? Bool ?? false
var loadedFeatureFlags: [String: Any]?
self.featureFlagsLock.withLock {
if let requestId {
// Store the request ID in the storage.
self.setCachedRequestId(requestId)
}
if errorsWhileComputingFlags {
// v4 cached flags which contains metadata about each flag.
let cachedFlags = self.getCachedFlags() ?? [:]
// The following two aren't necessarily needed for v4, but we'll keep them for now
// for back compatibility for existing v3 users who might already have cached flag data.
let cachedFeatureFlags = self.getCachedFeatureFlags() ?? [:]
let cachedFeatureFlagsPayloads = self.getCachedFeatureFlagPayload() ?? [:]
let newFeatureFlags = cachedFeatureFlags.merging(featureFlags) { _, new in new }
let newFeatureFlagsPayloads = cachedFeatureFlagsPayloads.merging(featureFlagPayloads) { _, new in new }
// if not all flags were computed, we upsert flags instead of replacing them
loadedFeatureFlags = newFeatureFlags
if let flagsV4 {
let newFlags = cachedFlags.merging(flagsV4) { _, new in new }
// if not all flags were computed, we upsert flags instead of replacing them
self.setCachedFlags(newFlags)
}
self.setCachedFeatureFlags(newFeatureFlags)
self.setCachedFeatureFlagPayload(newFeatureFlagsPayloads)
self.notifyFeatureFlagsAndRelease(newFeatureFlags)
} else {
loadedFeatureFlags = featureFlags
if let flagsV4 {
self.setCachedFlags(flagsV4)
}
self.setCachedFeatureFlags(featureFlags)
self.setCachedFeatureFlagPayload(featureFlagPayloads)
self.notifyFeatureFlagsAndRelease(featureFlags)
}
}
return callback(loadedFeatureFlags)
}
}
}
#if os(iOS)
private func processSessionRecordingConfig(_ data: [String: Any]?, featureFlags: [String: Any]) {
if let sessionRecording = data?["sessionRecording"] as? Bool {
sessionReplayFlagActive = sessionRecording
// its always false here anyway
if !sessionRecording {
storage.remove(key: .sessionReplay)
}
} else if let sessionRecording = data?["sessionRecording"] as? [String: Any] {
// keeps the value from config.sessionReplay since having sessionRecording
// means its enabled on the project settings, but its only enabled
// when local replay integration is enabled/active
if let endpoint = sessionRecording["endpoint"] as? String {
config.snapshotEndpoint = endpoint
}
sessionReplayFlagActive = isRecordingActive(featureFlags, sessionRecording)
storage.setDictionary(forKey: .sessionReplay, contents: sessionRecording)
}
}
#endif
private func notifyFeatureFlags(_ featureFlags: [String: Any]?) {
DispatchQueue.main.async {
self.onFeatureFlagsLoaded?(featureFlags)
NotificationCenter.default.post(name: PostHogSDK.didReceiveFeatureFlags, object: nil)
}
}
private func notifyFeatureFlagsAndRelease(_ featureFlags: [String: Any]?) {
notifyFeatureFlags(featureFlags)
loadingFeatureFlagsLock.withLock {
self.loadingFeatureFlags = false
}
}
func getFeatureFlags() -> [String: Any]? {
featureFlagsLock.withLock { getCachedFeatureFlags() }
}
func getFeatureFlag(_ key: String) -> Any? {
var flags: [String: Any]?
featureFlagsLock.withLock {
flags = self.getCachedFeatureFlags()
}
return flags?[key]
}
func getFeatureFlagDetails(_ key: String) -> Any? {
var flags: [String: Any]?
featureFlagsLock.withLock {
flags = self.getCachedFlags()
}
return flags?[key]
}
// To be called after acquiring `featureFlagsLock`
private func getCachedFeatureFlagPayload() -> [String: Any]? {
if featureFlagPayloads == nil {
featureFlagPayloads = storage.getDictionary(forKey: .enabledFeatureFlagPayloads) as? [String: Any]
}
return featureFlagPayloads
}
// To be called after acquiring `featureFlagsLock`
private func setCachedFeatureFlagPayload(_ featureFlagPayloads: [String: Any]) {
self.featureFlagPayloads = featureFlagPayloads
storage.setDictionary(forKey: .enabledFeatureFlagPayloads, contents: featureFlagPayloads)
}
// To be called after acquiring `featureFlagsLock`
private func getCachedFeatureFlags() -> [String: Any]? {
if featureFlags == nil {
featureFlags = storage.getDictionary(forKey: .enabledFeatureFlags) as? [String: Any]
}
return featureFlags
}
// To be called after acquiring `featureFlagsLock`
private func setCachedFeatureFlags(_ featureFlags: [String: Any]) {
self.featureFlags = featureFlags
storage.setDictionary(forKey: .enabledFeatureFlags, contents: featureFlags)
}
// To be called after acquiring `featureFlagsLock`
private func setCachedFlags(_ flags: [String: Any]) {
self.flags = flags
storage.setDictionary(forKey: .flags, contents: flags)
}
// To be called after acquiring `featureFlagsLock`
private func getCachedFlags() -> [String: Any]? {
if flags == nil {
flags = storage.getDictionary(forKey: .flags) as? [String: Any]
}
return flags
}
func setPersonPropertiesForFlags(_ properties: [String: Any]) {
personPropertiesForFlagsLock.withLock {
// Merge properties additively, similar to JS SDK behavior
personPropertiesForFlags.merge(properties, uniquingKeysWith: { _, new in new })
// Persist to disk
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
}
}
func resetPersonPropertiesForFlags() {
personPropertiesForFlagsLock.withLock {
personPropertiesForFlags.removeAll()
// Clear from disk
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
}
}
func setGroupPropertiesForFlags(_ groupType: String, properties: [String: Any]) {
groupPropertiesForFlagsLock.withLock {
// Merge properties additively for this group type
groupPropertiesForFlags[groupType, default: [:]].merge(properties) { _, new in new }
// Persist to disk
storage.setDictionary(forKey: .groupPropertiesForFlags, contents: groupPropertiesForFlags)
}
}
func resetGroupPropertiesForFlags(_ groupType: String? = nil) {
groupPropertiesForFlagsLock.withLock {
if let groupType = groupType {
groupPropertiesForFlags.removeValue(forKey: groupType)
} else {
groupPropertiesForFlags.removeAll()
}
// Persist changes to disk
storage.setDictionary(forKey: .groupPropertiesForFlags, contents: groupPropertiesForFlags)
}
}
private func getGroupPropertiesForFlags() -> [String: [String: Any]] {
groupPropertiesForFlagsLock.withLock {
groupPropertiesForFlags
}
}
private func getPersonPropertiesForFlags() -> [String: Any] {
personPropertiesForFlagsLock.withLock {
var properties = personPropertiesForFlags
// Always include fresh default properties if enabled
if config.setDefaultPersonProperties {
let defaultProperties = getDefaultPersonProperties()
// User-set properties override default properties
properties = defaultProperties.merging(properties) { _, userValue in userValue }
}
return properties
}
}
private func loadCachedPropertiesForFlags() {
personPropertiesForFlagsLock.withLock {
if let cachedPersonProperties = storage.getDictionary(forKey: .personPropertiesForFlags) as? [String: Any] {
personPropertiesForFlags = cachedPersonProperties
}
}
groupPropertiesForFlagsLock.withLock {
if let cachedGroupProperties = storage.getDictionary(forKey: .groupPropertiesForFlags) as? [String: [String: Any]] {
groupPropertiesForFlags = cachedGroupProperties
}
}
}
func getFeatureFlagPayload(_ key: String) -> Any? {
var flags: [String: Any]?
featureFlagsLock.withLock {
flags = getCachedFeatureFlagPayload()
}
let value = flags?[key]
guard let stringValue = value as? String else {
return value
}
do {
// The payload value is stored as a string and is not pre-parsed...
// We need to mimic the JSON.parse of JS which is what posthog-js uses
return try JSONSerialization.jsonObject(with: stringValue.data(using: .utf8)!, options: .fragmentsAllowed)
} catch {
hedgeLog("Error parsing the object \(String(describing: value)): \(error)")
}
// fallback to original value if not possible to serialize
return value
}
// To be called after acquiring `featureFlagsLock`
private func setCachedRequestId(_ value: String?) {
requestId = value
if let value {
storage.setString(forKey: .requestId, contents: value)
} else {
storage.remove(key: .requestId)
}
}
private func normalizeResponse(_ data: inout [String: Any]) {
if let flagsV4 = data["flags"] as? [String: Any] {
var featureFlags = [String: Any]()
var featureFlagsPayloads = [String: Any]()
for (key, value) in flagsV4 {
if let flag = value as? [String: Any] {
if let variant = flag["variant"] as? String {
featureFlags[key] = variant
// If there's a variant, the flag is enabled, so we can store the payload
if let metadata = flag["metadata"] as? [String: Any],
let payload = metadata["payload"]
{
featureFlagsPayloads[key] = payload
}
} else {
let enabled = flag["enabled"] as? Bool
featureFlags[key] = enabled
// Only store payload if the flag is enabled
if enabled == true,
let metadata = flag["metadata"] as? [String: Any],
let payload = metadata["payload"]
{
featureFlagsPayloads[key] = payload
}
}
}
}
data["featureFlags"] = featureFlags
data["featureFlagPayloads"] = featureFlagsPayloads
}
}
private func clearFeatureFlags() {
featureFlagsLock.withLock {
setCachedFlags([:])
setCachedFeatureFlags([:])
setCachedFeatureFlagPayload([:])
setCachedRequestId(nil) // requestId no longer valid
}
}
#if os(iOS)
func isSessionReplayFlagActive() -> Bool {
sessionReplayFlagActive
}
#endif
private func getGroups() -> [String: String] {
guard let groups = storage.getDictionary(forKey: .groups) as? [String: String] else {
return [:]
}
return groups
}
// MARK: Remote Config
func getRemoteConfig() -> [String: Any]? {
remoteConfigLock.withLock { getCachedRemoteConfig() }
}
private func getCachedRemoteConfig() -> [String: Any]? {
if remoteConfig == nil {
remoteConfig = storage.getDictionary(forKey: .remoteConfig) as? [String: Any]
}
return remoteConfig
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,271 +0,0 @@
//
// PostHogSessionManager.swift
// PostHog
//
// Created by Manoel Aranda Neto on 28.08.24.
//
import Foundation
// only for internal use
// Do we need to expose this as public API? Could be internal static instead?
@objc public class PostHogSessionManager: NSObject {
enum SessionIDChangeReason: String {
case sessionIdEmpty = "Session id was empty"
case sessionStart = "Session started"
case sessionEnd = "Session ended"
case sessionReset = "Session was reset"
case sessionTimeout = "Session timed out"
case sessionPastMaximumLength = "Session past maximum length"
case customSessionId = "Custom session set"
}
@objc public static var shared: PostHogSessionManager {
PostHogSDK.shared.sessionManager
}
private var config: PostHogConfig?
override init() {
super.init()
}
func setup(config: PostHogConfig) {
self.config = config
didBecomeActiveToken = nil
didEnterBackgroundToken = nil
applicationEventToken = nil
registerNotifications()
registerApplicationSendEvent()
}
func reset() {
resetSession()
didBecomeActiveToken = nil
didEnterBackgroundToken = nil
applicationEventToken = nil
}
private let queue = DispatchQueue(label: "com.posthog.PostHogSessionManager", target: .global(qos: .utility))
private var sessionId: String?
private var sessionStartTimestamp: TimeInterval?
private var sessionActivityTimestamp: TimeInterval?
private let sessionLock = NSLock()
private var isAppInBackground = true
// 30 minutes in seconds
private let sessionActivityThreshold: TimeInterval = 60 * 30
// 24 hours in seconds
private let sessionMaxLengthThreshold: TimeInterval = 24 * 60 * 60
// Called when session id is cleared or changes
var onSessionIdChanged: () -> Void = {}
@objc public func setSessionId(_ sessionId: String) {
setSessionIdInternal(sessionId, at: now(), reason: .customSessionId)
}
private func isNotReactNative() -> Bool {
// for the RN SDK, the session is handled by the RN SDK itself
postHogSdkName != "posthog-react-native"
}
/**
Returns the current session id, and manages id rotation logic
In addition, this method handles core session cycling logic including:
- Creates a new session id when none exists (but only if app is foregrounded)
- if `readOnly` is false
- Rotates session after *30 minutes* of inactivity
- Clears session after *30 minutes* of inactivity (when app is backgrounded)
- Enforces a maximum session duration of *24 hours*
- Parameters:
- timeNow: Reference timestamp used for evaluating session expiry rules.
Defaults to current system time.
- readOnly: When true, bypasses all session management logic and returns
the current session id without modifications.
Defaults to false.
- Returns: Returns the existing session id, or a new one after performing validity checks
*/
func getSessionId(
at timeNow: Date = now(),
readOnly: Bool = false
) -> String? {
let timestamp = timeNow.timeIntervalSince1970
let (currentSessionId, lastActive, sessionStart, isBackgrounded) = sessionLock.withLock {
(sessionId, sessionActivityTimestamp, sessionStartTimestamp, isAppInBackground)
}
// RN manages its own session, just return session id
guard isNotReactNative(), !readOnly else {
return currentSessionId
}
// Create a new session id if empty
if currentSessionId.isNilOrEmpty, !isBackgrounded {
return rotateSession(force: true, at: timeNow, reason: .sessionIdEmpty)
}
// Check if session has passed maximum inactivity length
if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) {
return isBackgrounded
? clearSession(reason: .sessionTimeout)
: rotateSession(at: timeNow, reason: .sessionTimeout)
}
// Check if session has passed maximum session length
if let sessionStart, isExpired(timestamp, sessionStart, sessionMaxLengthThreshold) {
return isBackgrounded
? clearSession(reason: .sessionPastMaximumLength)
: rotateSession(at: timeNow, reason: .sessionPastMaximumLength)
}
return currentSessionId
}
func getNextSessionId() -> String? {
// if this is RN, return the current session id
guard isNotReactNative() else {
return sessionLock.withLock { sessionId }
}
return rotateSession(force: true, at: now(), reason: .sessionStart)
}
/// Creates a new session id and sets timestamps
func startSession(_ completion: (() -> Void)? = nil) {
guard isNotReactNative() else { return }
rotateSession(force: true, at: now(), reason: .sessionStart)
completion?()
}
/// Clears current session id and timestamps
func endSession(_ completion: (() -> Void)? = nil) {
guard isNotReactNative() else { return }
clearSession(reason: .sessionEnd)
completion?()
}
/// Resets current session id and timestamps
func resetSession() {
guard isNotReactNative() else { return }
rotateSession(force: true, at: now(), reason: .sessionReset)
}
/// Call this method to mark any user activity on this session
func touchSession() {
guard isNotReactNative() else { return }
let (currentSessionId, lastActive) = sessionLock.withLock {
(sessionId, sessionActivityTimestamp)
}
guard currentSessionId != nil else { return }
let timeNow = now()
let timestamp = timeNow.timeIntervalSince1970
// Check if session has passed maximum inactivity length between user activity marks
if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) {
rotateSession(at: timeNow, reason: .sessionTimeout)
} else {
sessionLock.withLock {
sessionActivityTimestamp = timestamp
}
}
}
/**
Rotates the current session id
- Parameters:
- force: When true, creates a new session ID if current one is empty
- reason: The underlying reason behind this session ID rotation
- Returns: a new session id
*/
@discardableResult private func rotateSession(force: Bool = false, at timestamp: Date, reason: SessionIDChangeReason) -> String? {
// only rotate when session is empty
if !force {
let currentSessionId = sessionLock.withLock { sessionId }
if currentSessionId.isNilOrEmpty {
return currentSessionId
}
}
let newSessionId = UUID.v7().uuidString
setSessionIdInternal(newSessionId, at: timestamp, reason: reason)
return newSessionId
}
@discardableResult private func clearSession(reason: SessionIDChangeReason) -> String? {
setSessionIdInternal(nil, at: nil, reason: reason)
return nil
}
private func setSessionIdInternal(_ sessionId: String?, at timestamp: Date?, reason: SessionIDChangeReason) {
let timestamp = timestamp?.timeIntervalSince1970
sessionLock.withLock {
self.sessionId = sessionId
self.sessionStartTimestamp = timestamp
self.sessionActivityTimestamp = timestamp
}
onSessionIdChanged()
if let sessionId {
hedgeLog("New session id created \(sessionId) (\(reason))")
} else {
hedgeLog("Session id cleared - reason: (\(reason))")
}
}
private var didBecomeActiveToken: RegistrationToken?
private var didEnterBackgroundToken: RegistrationToken?
private func registerNotifications() {
let lifecyclePublisher = DI.main.appLifecyclePublisher
didBecomeActiveToken = lifecyclePublisher.onDidBecomeActive { [weak self] in
guard let self, sessionLock.withLock({ self.isAppInBackground }) else {
return
}
// we consider foregrounding an app an activity on the current session
touchSession()
sessionLock.withLock { self.isAppInBackground = false }
}
didEnterBackgroundToken = lifecyclePublisher.onDidEnterBackground { [weak self] in
guard let self, !sessionLock.withLock({ self.isAppInBackground }) else {
return
}
// we consider backgrounding the app an activity on the current session
touchSession()
sessionLock.withLock { self.isAppInBackground = true }
}
}
private var applicationEventToken: RegistrationToken?
private func registerApplicationSendEvent() {
#if os(iOS) || os(tvOS)
guard let config, config.enableSwizzling else {
return
}
applicationEventToken = DI.main.applicationEventPublisher.onApplicationEvent { [weak self] _, _ in
// update "last active" session
// we want to keep track of the idle time, so we need to maintain a timestamp on the last interactions of the user with the app. UIEvents are a good place to do so since it means that the user is actively interacting with the app (e.g not just noise background activity)
self?.queue.async {
self?.touchSession()
}
}
#endif
}
private func isExpired(_ timeNow: TimeInterval, _ timeThen: TimeInterval, _ threshold: TimeInterval) -> Bool {
max(timeNow - timeThen, 0) > threshold
}
}

View File

@@ -1,419 +0,0 @@
//
// PostHogStorage.swift
// PostHog
//
// Created by Ben White on 08.02.23.
//
import Foundation
/**
# Storage
Note for tvOS:
As tvOS restricts access to persisted Application Support directory, we use Library/Caches instead for storage
If needed, we can use UserDefaults for lightweight data - according to Apple, you can use UserDefaults to persist up to 500KB of data on tvOS
see: https://developer.apple.com/forums/thread/16967?answerId=50696022#50696022
*/
func applicationSupportDirectoryURL() -> URL {
#if os(tvOS)
// tvOS restricts access to Application Support directory on physical devices
// Use Library/Caches directory which may have less frequent eviction behavior than temp (which is purged when the app quits)
let searchPath: FileManager.SearchPathDirectory = .cachesDirectory
#else
let searchPath: FileManager.SearchPathDirectory = .applicationSupportDirectory
#endif
let url = FileManager.default.urls(for: searchPath, in: .userDomainMask).first!
let bundleIdentifier = getBundleIdentifier()
return url.appendingPathComponent(bundleIdentifier)
}
/**
From Apple Docs:
In iOS, the value is nil when the group identifier is invalid. In macOS, a URL of the expected form is always
returned, even if the app group is invalid, so be sure to test that you can access the underlying directory
before attempting to use it.
MacOS: The system also creates the Library/Application Support, Library/Caches, and Library/Preferences
subdirectories inside the group directory the first time you use it
iOS: The system creates only the Library/Caches subdirectory automatically
see: https://developer.apple.com/documentation/foundation/filemanager/1412643-containerurl/
*/
func appGroupContainerUrl(config: PostHogConfig) -> URL? {
guard let appGroupIdentifier = config.appGroupIdentifier else { return nil }
#if os(tvOS)
// tvOS: Due to stricter sandbox rules, creating "Application Support" directory is not possible on tvOS
let librarySubPath = "Library/Caches/"
#else
let librarySubPath = "Library/Application Support/"
#endif
let libraryUrl = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent(librarySubPath)
guard let url = libraryUrl?.appendingPathComponent(appGroupIdentifier) else { return nil }
createDirectoryAtURLIfNeeded(url: url)
// Merges a legacy container (using bundleIdentifier) into the new container using appGroupIdentifier
mergeLegacyContainerIfNeeded(within: libraryUrl, to: url)
return directoryExists(url) ? url : nil
}
func getBundleIdentifier() -> String {
#if TESTING // only visible to test targets
return Bundle.main.bundleIdentifier ?? "com.posthog.test"
#else
return Bundle.main.bundleIdentifier!
#endif
}
/**
Merges content from a legacy container directory into the current app group container.
This function handles the migration of PostHog data from the old storage location (using `bundleIdentifier`)
to the new app group shared container location (using `appGroupIdentifier`).
Migration rules:
- Files that already exist at the destination are skipped (no overwrite)
- The anonymousId from the first processed container (legacy or current) is preserved to maintain user identity
- Successfully migrated files are deleted from the source
- Empty directories are cleaned up after migration
- The entire folder structure is preserved during migration
- Parameters:
- libraryUrl: The base library URL where both legacy and new containers might exist
- destinationUrl: The target app group container URL where files should be migrated
*/
func mergeLegacyContainerIfNeeded(within libraryUrl: URL?, to destinationUrl: URL) {
let bundleIdentifier = getBundleIdentifier()
guard let sourceUrl = libraryUrl?.appendingPathComponent(bundleIdentifier), directoryExists(sourceUrl) else {
return
}
hedgeLog("Legacy folder found at \(sourceUrl), merging...")
// Migrate all contents from the legacy container
migrateDirectoryContents(from: sourceUrl, to: destinationUrl)
// Try to remove the source directory if it's empty
if removeIfEmpty(sourceUrl) {
hedgeLog("Successfully migrated and removed legacy folder at \(sourceUrl)")
}
}
/**
Removes a directory if it's empty.
- Parameters:
- url: The directory URL to potentially remove
- Returns: `true` if the directory was removed, `false` otherwise
*/
@discardableResult
func removeIfEmpty(_ url: URL) -> Bool {
let remainingItems = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
if remainingItems?.isEmpty == true {
do {
try FileManager.default.removeItem(at: url)
return true
} catch {
hedgeLog("Failed to remove empty directory at \(url.path): \(error)")
}
}
return false
}
/**
Migrates a single file from source to destination.
Migration rules:
- If the file doesn't exist at destination, it's copied and then deleted from source
- If the file already exists at destination, only the source file is deleted
- Parameters:
- sourceFile: The source file URL
- destinationFile: The destination file URL
- Throws: Any errors that occur during file operations
*/
func migrateFile(from sourceFile: URL, to destinationFile: URL) throws {
if !FileManager.default.fileExists(atPath: destinationFile.path) {
try FileManager.default.copyItem(at: sourceFile, to: destinationFile)
}
// Always delete source file after processing (whether copied or skipped)
try FileManager.default.removeItem(at: sourceFile)
}
/**
Recursively migrates all contents from a source directory to a destination directory.
- Parameters:
- sourceDir: The source directory URL
- destinationDir: The destination directory URL
*/
func migrateDirectoryContents(from sourceDir: URL, to destinationDir: URL) {
do {
// Create destination directory if it doesn't exist (we need to call this here again as the function is recursive)
createDirectoryAtURLIfNeeded(url: destinationDir)
// Get all items in source directory
let items = try FileManager.default.contentsOfDirectory(at: sourceDir, includingPropertiesForKeys: nil, options: [])
for item in items {
let destinationItem = destinationDir.appendingPathComponent(item.lastPathComponent)
// Check if it's a directory
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDirectory) {
if isDirectory.boolValue {
// Recursively migrate subdirectory (preserving the folder structure)
migrateDirectoryContents(from: item, to: destinationItem)
// Remove empty directory after migration
removeIfEmpty(item)
} else {
// Migrate file
do {
try migrateFile(from: item, to: destinationItem)
} catch {
hedgeLog("Failed to migrate file from \(item.path) to \(destinationItem.path): \(error)")
}
}
}
}
} catch {
hedgeLog("Error reading directory contents at \(sourceDir.path): \(error)")
}
}
class PostHogStorage {
// when adding or removing items here, make sure to update the reset method
enum StorageKey: String, CaseIterable {
case distinctId = "posthog.distinctId"
case anonymousId = "posthog.anonymousId"
case queue = "posthog.queueFolder" // NOTE: This is different to posthog-ios v2
case oldQeueue = "posthog.queue.plist"
case replayQeueue = "posthog.replayFolder"
case enabledFeatureFlags = "posthog.enabledFeatureFlags"
case enabledFeatureFlagPayloads = "posthog.enabledFeatureFlagPayloads"
case flags = "posthog.flags"
case groups = "posthog.groups"
case registerProperties = "posthog.registerProperties"
case optOut = "posthog.optOut"
case sessionReplay = "posthog.sessionReplay"
case isIdentified = "posthog.isIdentified"
case personProcessingEnabled = "posthog.enabledPersonProcessing"
case remoteConfig = "posthog.remoteConfig"
case surveySeen = "posthog.surveySeen"
case requestId = "posthog.requestId"
case personPropertiesForFlags = "posthog.personPropertiesForFlags"
case groupPropertiesForFlags = "posthog.groupPropertiesForFlags"
}
// The location for storing data that we always want to keep
let appFolderUrl: URL
init(_ config: PostHogConfig) {
appFolderUrl = Self.getAppFolderUrl(from: config)
// migrate legacy storage if needed
Self.migrateLegacyStorage(from: config, to: appFolderUrl)
}
func url(forKey key: StorageKey) -> URL {
appFolderUrl.appendingPathComponent(key.rawValue)
}
// The "data" methods are the core for storing data and differ between Modes
// All other typed storage methods call these
private func getData(forKey: StorageKey) -> Data? {
let url = url(forKey: forKey)
do {
if FileManager.default.fileExists(atPath: url.path) {
return try Data(contentsOf: url)
}
} catch {
hedgeLog("Error reading data from key \(forKey): \(error)")
}
return nil
}
private func setData(forKey: StorageKey, contents: Data?) {
var url = url(forKey: forKey)
do {
if contents == nil {
deleteSafely(url)
return
}
try contents?.write(to: url)
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try url.setResourceValues(resourceValues)
} catch {
hedgeLog("Failed to write data for key '\(forKey)' error: \(error)")
}
}
private func getJson(forKey key: StorageKey) -> Any? {
guard let data = getData(forKey: key) else { return nil }
do {
return try JSONSerialization.jsonObject(with: data)
} catch {
hedgeLog("Failed to serialize key '\(key)' error: \(error)")
}
return nil
}
private func setJson(forKey key: StorageKey, json: Any) {
var jsonObject: Any?
if let dictionary = json as? [AnyHashable: Any] {
jsonObject = dictionary
} else if let array = json as? [Any] {
jsonObject = array
} else {
// TRICKY: This is weird legacy behaviour storing the data as a dictionary
jsonObject = [key.rawValue: json]
}
var data: Data?
do {
data = try JSONSerialization.data(withJSONObject: jsonObject!)
} catch {
hedgeLog("Failed to serialize key '\(key)' error: \(error)")
}
setData(forKey: key, contents: data)
}
/**
There are cases where applications using posthog-ios want to share analytics data between host app and
an app extension, Widget or App Clip. If there's a defined `appGroupIdentifier` in configuration,
we want to use a shared container for storing data so that extensions correctly identify a user (and batch process events)
*/
private static func getBaseAppFolderUrl(from configuration: PostHogConfig) -> URL {
appGroupContainerUrl(config: configuration) ?? applicationSupportDirectoryURL()
}
private static func migrateItem(at sourceUrl: URL, to destinationUrl: URL, fileManager: FileManager) throws {
guard fileManager.fileExists(atPath: sourceUrl.path) else { return }
// Copy file or directory over (if it doesn't exist)
if !fileManager.fileExists(atPath: destinationUrl.path) {
try fileManager.copyItem(at: sourceUrl, to: destinationUrl)
}
}
private static func migrateLegacyStorage(from configuration: PostHogConfig, to apiDir: URL) {
let legacyUrl = getBaseAppFolderUrl(from: configuration)
if directoryExists(legacyUrl) {
let fileManager = FileManager.default
// Migrate old files that correspond to StorageKey values
for storageKey in StorageKey.allCases {
let legacyFileUrl = legacyUrl.appendingPathComponent(storageKey.rawValue)
let newFileUrl = apiDir.appendingPathComponent(storageKey.rawValue)
do {
// Migrate the item and its contents if it exists
try migrateItem(at: legacyFileUrl, to: newFileUrl, fileManager: fileManager)
} catch {
hedgeLog("Error during storage migration for file \(storageKey.rawValue) at path \(legacyFileUrl.path): \(error)")
}
// Remove the legacy item after successful migration
if fileManager.fileExists(atPath: legacyFileUrl.path) {
do {
try fileManager.removeItem(at: legacyFileUrl)
} catch {
hedgeLog("Could not delete file \(storageKey.rawValue) at path \(legacyFileUrl.path): \(error)")
}
}
}
}
}
private static func getAppFolderUrl(from configuration: PostHogConfig) -> URL {
let apiDir = getBaseAppFolderUrl(from: configuration)
.appendingPathComponent(configuration.apiKey)
createDirectoryAtURLIfNeeded(url: apiDir)
return apiDir
}
func reset(keepAnonymousId: Bool = false) {
// sadly the StorageKey.allCases does not work here
deleteSafely(url(forKey: .distinctId))
if !keepAnonymousId {
deleteSafely(url(forKey: .anonymousId))
}
// .queue, .replayQeueue not needed since it'll be deleted by the queue.clear()
deleteSafely(url(forKey: .oldQeueue))
deleteSafely(url(forKey: .flags))
deleteSafely(url(forKey: .enabledFeatureFlags))
deleteSafely(url(forKey: .enabledFeatureFlagPayloads))
deleteSafely(url(forKey: .groups))
deleteSafely(url(forKey: .registerProperties))
deleteSafely(url(forKey: .optOut))
deleteSafely(url(forKey: .sessionReplay))
deleteSafely(url(forKey: .isIdentified))
deleteSafely(url(forKey: .personProcessingEnabled))
deleteSafely(url(forKey: .remoteConfig))
deleteSafely(url(forKey: .surveySeen))
deleteSafely(url(forKey: .requestId))
deleteSafely(url(forKey: .personPropertiesForFlags))
deleteSafely(url(forKey: .groupPropertiesForFlags))
}
func remove(key: StorageKey) {
let url = url(forKey: key)
deleteSafely(url)
}
func getString(forKey key: StorageKey) -> String? {
let value = getJson(forKey: key)
if let stringValue = value as? String {
return stringValue
} else if let dictValue = value as? [String: String] {
return dictValue[key.rawValue]
}
return nil
}
func setString(forKey key: StorageKey, contents: String) {
setJson(forKey: key, json: contents)
}
func getDictionary(forKey key: StorageKey) -> [AnyHashable: Any]? {
getJson(forKey: key) as? [AnyHashable: Any]
}
func setDictionary(forKey key: StorageKey, contents: [AnyHashable: Any]) {
setJson(forKey: key, json: contents)
}
func getBool(forKey key: StorageKey) -> Bool? {
let value = getJson(forKey: key)
if let boolValue = value as? Bool {
return boolValue
} else if let dictValue = value as? [String: Bool] {
return dictValue[key.rawValue]
}
return nil
}
func setBool(forKey key: StorageKey, contents: Bool) {
setJson(forKey: key, json: contents)
}
}

View File

@@ -1,163 +0,0 @@
//
// PostHogStorageManager.swift
// PostHog
//
// Created by Ben White on 08.02.23.
//
import Foundation
// Internal class to manage the storage metadata of the PostHog SDK
public class PostHogStorageManager {
private let storage: PostHogStorage!
private let anonLock = NSLock()
private let distinctLock = NSLock()
private let identifiedLock = NSLock()
private let personProcessingLock = NSLock()
private let idGen: (UUID) -> UUID
private var distinctId: String?
private var cachedDistinctId = false
private var anonymousId: String?
private var isIdentifiedValue: Bool?
private var personProcessingEnabled: Bool?
init(_ config: PostHogConfig) {
storage = PostHogStorage(config)
idGen = config.getAnonymousId
}
public func getAnonymousId() -> String {
anonLock.withLock {
if anonymousId == nil {
var anonymousId = storage.getString(forKey: .anonymousId)
if anonymousId == nil {
let uuid = UUID.v7()
anonymousId = idGen(uuid).uuidString
setAnonId(anonymousId ?? "")
} else {
// update the memory value
self.anonymousId = anonymousId
}
}
}
return anonymousId ?? ""
}
public func setAnonymousId(_ id: String) {
anonLock.withLock {
setAnonId(id)
}
}
private func setAnonId(_ id: String) {
anonymousId = id
storage.setString(forKey: .anonymousId, contents: id)
}
public func getDistinctId() -> String {
var distinctId: String?
distinctLock.withLock {
if self.distinctId == nil {
// since distinctId is nil until its identified, no need to read from
// cache every single time, otherwise anon users will never used the
// cached values
if !cachedDistinctId {
distinctId = storage.getString(forKey: .distinctId)
cachedDistinctId = true
}
// do this to not assign the AnonymousId to the DistinctId, its just a fallback
if distinctId == nil {
distinctId = getAnonymousId()
} else {
// update the memory value
self.distinctId = distinctId
}
} else {
// read from memory
distinctId = self.distinctId
}
}
return distinctId ?? ""
}
public func setDistinctId(_ id: String) {
distinctLock.withLock {
distinctId = id
storage.setString(forKey: .distinctId, contents: id)
}
}
public func isIdentified() -> Bool {
identifiedLock.withLock {
if isIdentifiedValue == nil {
isIdentifiedValue = storage.getBool(forKey: .isIdentified) ?? (getDistinctId() != getAnonymousId())
}
}
return isIdentifiedValue ?? false
}
public func setIdentified(_ isIdentified: Bool) {
identifiedLock.withLock {
isIdentifiedValue = isIdentified
storage.setBool(forKey: .isIdentified, contents: isIdentified)
}
}
public func isPersonProcessing() -> Bool {
personProcessingLock.withLock {
if personProcessingEnabled == nil {
personProcessingEnabled = storage.getBool(forKey: .personProcessingEnabled) ?? false
}
}
return personProcessingEnabled ?? false
}
public func setPersonProcessing(_ enable: Bool) {
personProcessingLock.withLock {
// only set if its different to avoid IO since this is called more often
if self.personProcessingEnabled != enable {
self.personProcessingEnabled = enable
storage.setBool(forKey: .personProcessingEnabled, contents: enable)
}
}
}
public func reset(keepAnonymousId: Bool = false, _ resetStorage: Bool = false) {
// resetStorage is only used for testing, when the reset method is called,
// the storage is also cleared, so we don't do here to not do it twice.
distinctLock.withLock {
distinctId = nil
cachedDistinctId = false
if resetStorage {
storage.remove(key: .distinctId)
}
}
if !keepAnonymousId {
anonLock.withLock {
anonymousId = nil
if resetStorage {
storage.remove(key: .anonymousId)
}
}
}
identifiedLock.withLock {
isIdentifiedValue = nil
if resetStorage {
storage.remove(key: .isIdentified)
}
}
personProcessingLock.withLock {
personProcessingEnabled = nil
if resetStorage {
storage.remove(key: .personProcessingEnabled)
}
}
}
}

View File

@@ -1,14 +0,0 @@
//
// PostHogSwizzler.swift
// PostHog
//
// Created by Manoel Aranda Neto on 26.03.24.
//
import Foundation
func swizzle(forClass: AnyClass, original: Selector, new: Selector) {
guard let originalMethod = class_getInstanceMethod(forClass, original) else { return }
guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return }
method_exchangeImplementations(originalMethod, swizzledMethod)
}

View File

@@ -1,16 +0,0 @@
//
// PostHogVersion.swift
// PostHog
//
// Created by Manoel Aranda Neto on 13.10.23.
//
import Foundation
// if you change this, make sure to also change it in the podspec and check if the script scripts/bump-version.sh still works
// This property is internal only
public var postHogVersion = "3.34.0"
public let postHogiOSSdkName = "posthog-ios"
// This property is internal only
public var postHogSdkName = postHogiOSSdkName

View File

@@ -1,130 +0,0 @@
//
// ApplicationEventPublisher.swift
// PostHog
//
// Created by Ioannis Josephides on 24/02/2025.
//
import Foundation
#if os(iOS) || os(tvOS)
import UIKit
typealias ApplicationEventHandler = (_ event: UIEvent, _ date: Date) -> Void
protocol ApplicationEventPublishing: AnyObject {
/// Registers a callback for a `UIApplication.sendEvent`
func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken
}
final class ApplicationEventPublisher: BaseApplicationEventPublisher {
static let shared = ApplicationEventPublisher()
private var hasSwizzled: Bool = false
func start() {
swizzleSendEvent()
}
func stop() {
unswizzleSendEvent()
}
func swizzleSendEvent() {
guard !hasSwizzled else { return }
hasSwizzled = true
swizzle(
forClass: UIApplication.self,
original: #selector(UIApplication.sendEvent(_:)),
new: #selector(UIApplication.sendEventOverride)
)
}
func unswizzleSendEvent() {
guard hasSwizzled else { return }
hasSwizzled = false
// swizzling twice will exchange implementations back to original
swizzle(
forClass: UIApplication.self,
original: #selector(UIApplication.sendEvent(_:)),
new: #selector(UIApplication.sendEventOverride)
)
}
override func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken {
let id = UUID()
registrationLock.withLock {
self.onApplicationEventCallbacks[id] = callback
}
// start on first callback registration
if !hasSwizzled {
start()
}
return RegistrationToken { [weak self] in
// Registration token deallocated here
guard let self else { return }
let handlerCount = self.registrationLock.withLock {
self.onApplicationEventCallbacks[id] = nil
return self.onApplicationEventCallbacks.values.count
}
// stop when there are no more callbacks
if handlerCount <= 0 {
self.stop()
}
}
}
// Called from swizzled `UIApplication.sendEvent`
fileprivate func sendEvent(event: UIEvent, date: Date) {
notifyHandlers(uiEvent: event, date: date)
}
}
class BaseApplicationEventPublisher: ApplicationEventPublishing {
fileprivate let registrationLock = NSLock()
var onApplicationEventCallbacks: [UUID: ApplicationEventHandler] = [:]
func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken {
let id = UUID()
registrationLock.withLock {
self.onApplicationEventCallbacks[id] = callback
}
return RegistrationToken { [weak self] in
// Registration token deallocated here
guard let self else { return }
self.registrationLock.withLock {
self.onApplicationEventCallbacks[id] = nil
}
}
}
func notifyHandlers(uiEvent: UIEvent, date: Date) {
let handlers = registrationLock.withLock { onApplicationEventCallbacks.values }
for handler in handlers {
notifyHander(handler, uiEvent: uiEvent, date: date)
}
}
private func notifyHander(_ handler: @escaping ApplicationEventHandler, uiEvent: UIEvent, date: Date) {
if Thread.isMainThread {
handler(uiEvent, date)
} else {
DispatchQueue.main.async { handler(uiEvent, date) }
}
}
}
extension UIApplication {
@objc func sendEventOverride(_ event: UIEvent) {
sendEventOverride(event)
ApplicationEventPublisher.shared.sendEvent(event: event, date: Date())
}
}
#endif

View File

@@ -1,33 +0,0 @@
//
// CGColor+Util.swift
// PostHog
//
// Created by Manoel Aranda Neto on 21.03.24.
//
#if os(iOS)
import Foundation
import UIKit
extension CGColor {
func toRGBString() -> String? {
// see dicussion: https://github.com/PostHog/posthog-ios/issues/226
// Allow only CGColors with an intiialized value of `numberOfComponents` with a value in 3...4 range
// Loading dynamic colors from storyboard sometimes leads to some random values for numberOfComponents like `105553118884896` which crashes the app
guard
3 ... 4 ~= numberOfComponents, // check range
let components = components, // we now assume it's safe to access `components`
components.count >= 3
else {
return nil
}
let red = Int(components[0] * 255)
let green = Int(components[1] * 255)
let blue = Int(components[2] * 255)
return String(format: "#%02X%02X%02X", red, green, blue)
}
}
#endif

View File

@@ -1,19 +0,0 @@
//
// CGSize+Util.swift
// PostHog
//
// Created by Manoel Aranda Neto on 24.07.24.
//
#if os(iOS)
import Foundation
extension CGSize {
func hasSize() -> Bool {
if width == 0 || height == 0 {
return false
}
return true
}
}
#endif

View File

@@ -1,18 +0,0 @@
//
// Date+Util.swift
// PostHog
//
// Created by Manoel Aranda Neto on 21.03.24.
//
import Foundation
extension Date {
func toMillis() -> Int64 {
Int64(timeIntervalSince1970 * 1000)
}
}
public func dateToMillis(_ date: Date) -> Int64 {
date.toMillis()
}

View File

@@ -1,20 +0,0 @@
//
// Float+Util.swift
// PostHog
//
// Created by Yiannis Josephides on 07/02/2025.
//
import Foundation
extension CGFloat {
func toInt() -> Int {
NSNumber(value: rounded()).intValue
}
}
extension Double {
func toInt() -> Int {
NSNumber(value: rounded()).intValue
}
}

View File

@@ -1,120 +0,0 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2019-Present Datadog, Inc.
*/
#if os(iOS)
import Foundation
class MethodSwizzler<TypedIMP, TypedBlockIMP> {
struct FoundMethod: Hashable {
let method: Method
private let klass: AnyClass
fileprivate init(method: Method, klass: AnyClass) {
self.method = method
self.klass = klass
}
static func == (lhs: FoundMethod, rhs: FoundMethod) -> Bool {
let methodParity = (lhs.method == rhs.method)
let classParity = (NSStringFromClass(lhs.klass) == NSStringFromClass(rhs.klass))
return methodParity && classParity
}
func hash(into hasher: inout Hasher) {
let methodName = NSStringFromSelector(method_getName(method))
let klassName = NSStringFromClass(klass)
let identifier = "\(methodName)|||\(klassName)"
hasher.combine(identifier)
}
}
private var implementationCache: [FoundMethod: IMP] = [:]
var swizzledMethods: [FoundMethod] {
Array(implementationCache.keys)
}
static func findMethod(with selector: Selector, in klass: AnyClass) throws -> FoundMethod {
/// NOTE: RUMM-452 as we never add/remove methods/classes at runtime,
/// search operation doesn't have to wrapped in sync {...} although it's visible in the interface
var headKlass: AnyClass? = klass
while let someKlass = headKlass {
if let foundMethod = findMethod(with: selector, in: someKlass) {
return FoundMethod(method: foundMethod, klass: someKlass)
}
headKlass = class_getSuperclass(headKlass)
}
throw InternalPostHogError(description: "\(NSStringFromSelector(selector)) is not found in \(NSStringFromClass(klass))")
}
func originalImplementation(of found: FoundMethod) -> TypedIMP {
sync {
let originalImp: IMP = implementationCache[found] ?? method_getImplementation(found.method)
return unsafeBitCast(originalImp, to: TypedIMP.self)
}
}
func swizzle(
_ foundMethod: FoundMethod,
impProvider: (TypedIMP) -> TypedBlockIMP
) {
sync {
let currentIMP = method_getImplementation(foundMethod.method)
let currentTypedIMP = unsafeBitCast(currentIMP, to: TypedIMP.self)
let newImpBlock: TypedBlockIMP = impProvider(currentTypedIMP)
let newImp: IMP = imp_implementationWithBlock(newImpBlock)
set(newIMP: newImp, for: foundMethod)
}
}
/// Removes swizzling and resets the method to its original implementation.
func unswizzle() {
for foundMethod in swizzledMethods {
let originalTypedIMP = originalImplementation(of: foundMethod)
let originalIMP: IMP = unsafeBitCast(originalTypedIMP, to: IMP.self)
method_setImplementation(foundMethod.method, originalIMP)
}
}
// MARK: - Private methods
@discardableResult
private func sync<T>(block: () -> T) -> T {
objc_sync_enter(self)
defer { objc_sync_exit(self) }
return block()
}
private static func findMethod(with selector: Selector, in klass: AnyClass) -> Method? {
var methodsCount: UInt32 = 0
let methodsCountPtr = withUnsafeMutablePointer(to: &methodsCount) { $0 }
guard let methods: UnsafeMutablePointer<Method> = class_copyMethodList(klass, methodsCountPtr) else {
return nil
}
defer {
free(methods)
}
for index in 0 ..< Int(methodsCount) {
let method = methods.advanced(by: index).pointee
if method_getName(method) == selector {
return method
}
}
return nil
}
private func set(newIMP: IMP, for found: FoundMethod) {
if implementationCache[found] == nil {
implementationCache[found] = method_getImplementation(found.method)
}
method_setImplementation(found.method, newIMP)
}
}
extension MethodSwizzler.FoundMethod {
var swizzlingName: String { "\(klass).\(method_getName(method))" }
}
#endif

View File

@@ -1,58 +0,0 @@
//
// NetworkSample.swift
// PostHog
//
// Created by Manoel Aranda Neto on 26.03.24.
//
#if os(iOS)
import Foundation
struct NetworkSample {
let sessionId: String
let timeOrigin: Date
let entryType = "resource"
var name: String?
var responseStatus: Int?
var initiatorType = "fetch"
var httpMethod: String?
var duration: Int64?
var decodedBodySize: Int64?
init(sessionId: String, timeOrigin: Date, url: String? = nil) {
self.timeOrigin = timeOrigin
self.sessionId = sessionId
name = url
}
func toDict() -> [String: Any] {
var dict: [String: Any] = [
"timestamp": timeOrigin.toMillis(),
"entryType": entryType,
"initiatorType": initiatorType,
]
if let name = name {
dict["name"] = name
}
if let responseStatus = responseStatus {
dict["responseStatus"] = responseStatus
}
if let httpMethod = httpMethod {
dict["method"] = httpMethod
}
if let duration = duration {
dict["duration"] = duration
}
if let decodedBodySize = decodedBodySize {
dict["transferSize"] = decodedBodySize
}
return dict
}
}
#endif

View File

@@ -1,12 +0,0 @@
//
// Optional+Util.swift
// PostHog
//
// Created by Yiannis Josephides on 20/01/2025.
//
extension Optional where Wrapped: Collection {
var isNilOrEmpty: Bool {
self?.isEmpty ?? true
}
}

View File

@@ -1,136 +0,0 @@
//
// PostHogConsoleLogInterceptor.swift
// PostHog
//
// Created by Ioannis Josephides on 05/05/2025.
//
#if os(iOS)
import Foundation
final class PostHogConsoleLogInterceptor {
private let maxLogStringSize = 2000 // Maximum number of characters allowed in a string
struct ConsoleOutput {
let timestamp: Date
let text: String
let level: PostHogLogLevel
}
static let shared = PostHogConsoleLogInterceptor()
// Pipe redirection properties
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private var originalStdout: Int32 = -1
private var originalStderr: Int32 = -1
private init() { /* Singleton */ }
func startCapturing(config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
stopCapturing() // cleanup
setupPipeRedirection(config: config, callback: callback)
}
private func setupPipeRedirection(config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
// Set stdout/stderr to unbuffered mode (_IONBF) to ensure real-time output capture.
// Without this, output might be buffered and only flushed when the buffer is full or
// when explicitly flushed, which is especially problematic without an attached debugger
setvbuf(stdout, nil, _IONBF, 0)
setvbuf(stderr, nil, _IONBF, 0)
// Save original file descriptors
originalStdout = dup(STDOUT_FILENO)
originalStderr = dup(STDERR_FILENO)
stdoutPipe = Pipe()
stderrPipe = Pipe()
guard let stdoutPipe = stdoutPipe, let stderrPipe = stderrPipe else { return }
// Redirect stdout and stderr to our pipes
dup2(stdoutPipe.fileHandleForWriting.fileDescriptor, STDOUT_FILENO)
dup2(stderrPipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO)
// Setup and handle pipe output
setupPipeSource(for: originalStdout, fileHandle: stdoutPipe.fileHandleForReading, config: config, callback: callback)
setupPipeSource(for: originalStderr, fileHandle: stderrPipe.fileHandleForReading, config: config, callback: callback)
}
private func setupPipeSource(for originalFd: Int32, fileHandle: FileHandle, config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
fileHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty,
let output = String(data: data, encoding: .utf8),
let self = self else { return }
// Write to original file descriptor, so logs appear normally
if originalFd != -1 {
if let data = output.data(using: .utf8) {
_ = data.withUnsafeBytes { ptr in
write(originalFd, ptr.baseAddress, ptr.count)
}
}
}
self.processOutput(output, config: config, callback: callback)
}
}
private func processOutput(_ output: String, config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
// Skip internal logs and empty lines
// Note: Need to skip internal logs because `config.debug` may be enabled. If that's the case, then
// the process of capturing logs, will generate more logs, leading to an infinite loop. This relies on hedgeLog() format which should
// be okay, even not ideal
guard !output.contains("[PostHog]"), !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}
// Process log entries from config
let entries = output
.components(separatedBy: CharacterSet.newlines) // split by line
.lazy
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } // Skip empty strings and new lines
.compactMap(config.sessionReplayConfig.captureLogsConfig.logSanitizer)
for entry in entries where shouldCaptureLog(entry: entry, config: config) {
callback(ConsoleOutput(timestamp: Date(), text: truncatedOutput(entry.message), level: entry.level))
}
}
/// Determines if the log message should be captured, based on config
private func shouldCaptureLog(entry: PostHogLogEntry, config: PostHogConfig) -> Bool {
entry.level.rawValue >= config.sessionReplayConfig.captureLogsConfig.minLogLevel.rawValue
}
/// Console logs can be really large.
/// This function returns a truncated version of the console output if it exceeds `maxLogStringSize`
private func truncatedOutput(_ output: String) -> String {
guard output.count > maxLogStringSize else { return output }
return "\(output.prefix(maxLogStringSize))...[truncated]"
}
func stopCapturing() {
// Restore original file descriptors
if originalStdout != -1 {
dup2(originalStdout, STDOUT_FILENO)
close(originalStdout)
originalStdout = -1
}
if originalStderr != -1 {
dup2(originalStderr, STDERR_FILENO)
close(originalStderr)
originalStderr = -1
}
// remove pipes
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
stderrPipe?.fileHandleForReading.readabilityHandler = nil
stdoutPipe?.fileHandleForReading.closeFile()
stderrPipe?.fileHandleForReading.closeFile()
stdoutPipe = nil
stderrPipe = nil
}
}
#endif

View File

@@ -1,37 +0,0 @@
//
// PostHogLogEntry.swift
// PostHog
//
// Created by Ioannis Josephides on 09/05/2025.
//
#if os(iOS)
import Foundation
/**
A model representing a processed console log entry for session replay.
Describes a single console log entry after it has been processed by `PostHogSessionReplayConsoleLogConfig.logSanitizer`.
Each instance contains the log message content and its determined severity level.
*/
@objc public class PostHogLogEntry: NSObject {
/// The severity level of the log entry.
/// This determines how the log will be displayed in the session replay and
/// whether it will be captured based on `minLogLevel` setting.
@objc public let level: PostHogLogLevel
/// The actual content of the log message.
/// This is the processed and sanitized log message
@objc public let message: String
/// Creates a new console log result.
/// - Parameters:
/// - level: The severity level of the log entry
/// - message: The processed log message content
@objc public init(level: PostHogLogLevel, message: String) {
self.level = level
self.message = message
super.init()
}
}
#endif

View File

@@ -1,22 +0,0 @@
//
// PostHogLogLevel.swift
// PostHog
//
// Created by Ioannis Josephides on 09/05/2025.
//
#if os(iOS)
import Foundation
/// The severity level of a console log entry.
///
/// Used to categorize logs by their severity in session replay.
@objc public enum PostHogLogLevel: Int {
/// Informational messages, debugging output, and general logs
case info
/// Warning messages indicating potential issues or deprecation notices
case warn
/// Error messages indicating failures or critical issues
case error
}
#endif

View File

@@ -1,86 +0,0 @@
//
// PostHogSessionReplayConsoleLogsPlugin.swift
// PostHog
//
// Created by Ioannis Josephides on 09/05/2025.
//
#if os(iOS)
import Foundation
final class PostHogSessionReplayConsoleLogsPlugin: PostHogSessionReplayPlugin {
private weak var postHog: PostHogSDK?
private var isActive = false
func start(postHog: PostHogSDK) {
self.postHog = postHog
isActive = true
PostHogConsoleLogInterceptor.shared.startCapturing(config: postHog.config) { [weak self] output in
self?.handleConsoleLog(output)
}
hedgeLog("[Session Replay] Console logs plugin started")
}
func stop() {
postHog = nil
isActive = false
PostHogConsoleLogInterceptor.shared.stopCapturing()
hedgeLog("[Session Replay] Console logs plugin stopped")
}
func resume() {
guard !isActive, let postHog else { return }
isActive = true
PostHogConsoleLogInterceptor.shared.startCapturing(config: postHog.config) { [weak self] output in
self?.handleConsoleLog(output)
}
hedgeLog("[Session Replay] Console logs plugin resumed")
}
func pause() {
guard isActive else { return }
isActive = false
PostHogConsoleLogInterceptor.shared.stopCapturing()
hedgeLog("[Session Replay] Console logs plugin paused")
}
private func handleConsoleLog(_ output: PostHogConsoleLogInterceptor.ConsoleOutput) {
guard
isActive,
let postHog,
postHog.isSessionReplayActive(),
let sessionId = postHog.sessionManager.getSessionId(at: output.timestamp)
else {
return
}
// `PostHogLogLevel`` needs to be an Int enum for objc interop
// So we need to convert this to a String before sending upstream
let level = switch output.level {
case .error: "error"
case .info: "info"
case .warn: "warn"
}
var snapshotsData: [Any] = []
let payloadData: [String: Any] = ["level": level, "payload": output.text]
let pluginData: [String: Any] = ["plugin": "rrweb/console@1", "payload": payloadData]
snapshotsData.append([
"type": 6,
"data": pluginData,
"timestamp": output.timestamp.toMillis(),
])
postHog.capture(
"$snapshot",
properties: [
"$snapshot_source": "mobile",
"$snapshot_data": snapshotsData,
"$session_id": sessionId,
],
timestamp: output.timestamp
)
}
}
#endif

View File

@@ -1,89 +0,0 @@
//
// PostHogSessionReplayNetworkPlugin.swift
// PostHog
//
// Created by Ioannis Josephides on 28/05/2025.
//
#if os(iOS)
import Foundation
/// Session replay plugin that captures network requests using URLSession swizzling.
class PostHogSessionReplayNetworkPlugin: PostHogSessionReplayPlugin {
private var sessionSwizzler: URLSessionSwizzler?
private var postHog: PostHogSDK?
private var isActive = false
func start(postHog: PostHogSDK) {
self.postHog = postHog
do {
sessionSwizzler = try URLSessionSwizzler(
shouldCapture: shouldCaptureNetworkSample,
onCapture: handleNetworkSample,
getSessionId: { [weak self] date in
self?.postHog?.sessionManager.getSessionId(at: date)
}
)
sessionSwizzler?.swizzle()
hedgeLog("[Session Replay] Network telemetry plugin started")
isActive = true
} catch {
hedgeLog("[Session Replay] Failed to initialize network telemetry: \(error)")
}
}
func stop() {
sessionSwizzler?.unswizzle()
sessionSwizzler = nil
postHog = nil
isActive = false
hedgeLog("[Session Replay] Network telemetry plugin stopped")
}
func resume() {
guard !isActive else { return }
isActive = true
hedgeLog("[Session Replay] Network telemetry plugin resumed")
}
func pause() {
guard isActive else { return }
isActive = false
hedgeLog("[Session Replay] Network telemetry plugin paused")
}
private func shouldCaptureNetworkSample() -> Bool {
guard let postHog else { return false }
return isActive && postHog.config.sessionReplayConfig.captureNetworkTelemetry && postHog.isSessionReplayActive()
}
private func handleNetworkSample(sample: NetworkSample) {
guard let postHog else { return }
let timestamp = sample.timeOrigin
var snapshotsData: [Any] = []
let requestsData = [sample.toDict()]
let payloadData: [String: Any] = ["requests": requestsData]
let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData]
let data: [String: Any] = [
"type": 6,
"data": pluginData,
"timestamp": timestamp.toMillis(),
]
snapshotsData.append(data)
postHog.capture(
"$snapshot",
properties: [
"$snapshot_source": "mobile",
"$snapshot_data": snapshotsData,
"$session_id": sample.sessionId,
],
timestamp: sample.timeOrigin
)
}
}
#endif

View File

@@ -1,231 +0,0 @@
#if os(iOS)
import Foundation
public extension URLSession {
private func getMonotonicTimeInMilliseconds() -> UInt64 {
// Get the raw mach time
let machTime = mach_absolute_time()
// Get timebase info to convert to nanoseconds
var timebaseInfo = mach_timebase_info_data_t()
mach_timebase_info(&timebaseInfo)
// Convert mach time to nanoseconds
let nanoTime = machTime * UInt64(timebaseInfo.numer) / UInt64(timebaseInfo.denom)
// Convert nanoseconds to milliseconds
let milliTime = nanoTime / 1_000_000
return milliTime
}
private func executeRequest(request: URLRequest? = nil,
action: () async throws -> (Data, URLResponse),
postHog: PostHogSDK?) async throws -> (Data, URLResponse)
{
let timestamp = Date()
let startMillis = getMonotonicTimeInMilliseconds()
var endMillis: UInt64?
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
do {
let (data, response) = try await action()
endMillis = getMonotonicTimeInMilliseconds()
captureData(request: request,
response: response,
sessionId: sessionId,
timestamp: timestamp,
start: startMillis,
end: endMillis,
postHog: postHog)
return (data, response)
} catch {
captureData(request: request,
response: nil,
sessionId: sessionId,
timestamp: timestamp,
start: startMillis,
end: endMillis,
postHog: postHog)
throw error
}
}
private func executeRequest(request: URLRequest? = nil,
action: () async throws -> (URL, URLResponse),
postHog: PostHogSDK?) async throws -> (URL, URLResponse)
{
let timestamp = Date()
let startMillis = getMonotonicTimeInMilliseconds()
var endMillis: UInt64?
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
do {
let (url, response) = try await action()
endMillis = getMonotonicTimeInMilliseconds()
captureData(request: request,
response: response,
sessionId: sessionId,
timestamp: timestamp,
start: startMillis,
end: endMillis,
postHog: postHog)
return (url, response)
} catch {
captureData(request: request,
response: nil,
sessionId: sessionId,
timestamp: timestamp,
start: startMillis,
end: endMillis,
postHog: postHog)
throw error
}
}
func postHogData(for request: URLRequest, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) {
try await executeRequest(request: request, action: { try await data(for: request) }, postHog: postHog)
}
func postHogData(from url: URL, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) {
try await executeRequest(action: { try await data(from: url) }, postHog: postHog)
}
func postHogUpload(
for request: URLRequest,
fromFile fileURL: URL,
postHog: PostHogSDK? = nil
) async throws -> (Data, URLResponse) {
try await executeRequest(request: request, action: { try await upload(for: request, fromFile: fileURL) }, postHog: postHog)
}
func postHogUpload(
for request: URLRequest,
from bodyData: Data,
postHog: PostHogSDK? = nil
) async throws -> (Data, URLResponse) {
try await executeRequest(request: request, action: { try await upload(for: request, from: bodyData) }, postHog: postHog)
}
@available(iOS 15.0, *)
func postHogData(
for request: URLRequest,
delegate: (any URLSessionTaskDelegate)? = nil,
postHog: PostHogSDK? = nil
) async throws -> (Data, URLResponse) {
try await executeRequest(request: request, action: { try await data(for: request, delegate: delegate) }, postHog: postHog)
}
@available(iOS 15.0, *)
func postHogData(
from url: URL,
delegate: (any URLSessionTaskDelegate)? = nil,
postHog: PostHogSDK? = nil
) async throws -> (Data, URLResponse) {
try await executeRequest(action: { try await data(from: url, delegate: delegate) }, postHog: postHog)
}
@available(iOS 15.0, *)
func postHogUpload(
for request: URLRequest,
fromFile fileURL: URL,
delegate: (any URLSessionTaskDelegate)? = nil,
postHog: PostHogSDK? = nil
) async throws -> (Data, URLResponse) {
try await executeRequest(request: request, action: { try await upload(for: request, fromFile: fileURL, delegate: delegate) }, postHog: postHog)
}
@available(iOS 15.0, *)
func postHogUpload(
for request: URLRequest,
from bodyData: Data,
delegate: (any URLSessionTaskDelegate)? = nil,
postHog: PostHogSDK? = nil
) async throws -> (Data, URLResponse) {
try await executeRequest(request: request, action: { try await upload(for: request, from: bodyData, delegate: delegate) }, postHog: postHog)
}
@available(iOS 15.0, *)
func postHogDownload(
for request: URLRequest,
delegate: (any URLSessionTaskDelegate)? = nil,
postHog: PostHogSDK? = nil
) async throws -> (URL, URLResponse) {
try await executeRequest(request: request, action: { try await download(for: request, delegate: delegate) }, postHog: postHog)
}
@available(iOS 15.0, *)
func postHogDownload(
from url: URL,
delegate: (any URLSessionTaskDelegate)? = nil,
postHog: PostHogSDK? = nil
) async throws -> (URL, URLResponse) {
try await executeRequest(action: { try await download(from: url, delegate: delegate) }, postHog: postHog)
}
@available(iOS 15.0, *)
func postHogDownload(
resumeFrom resumeData: Data,
delegate: (any URLSessionTaskDelegate)? = nil,
postHog: PostHogSDK? = nil
) async throws -> (URL, URLResponse) {
try await executeRequest(action: { try await download(resumeFrom: resumeData, delegate: delegate) }, postHog: postHog)
}
// MARK: Private methods
private func captureData(
request: URLRequest? = nil,
response: URLResponse? = nil,
sessionId: String?,
timestamp: Date,
start: UInt64,
end: UInt64? = nil,
postHog: PostHogSDK?
) {
let instance = postHog ?? PostHogSDK.shared
// we don't check config.sessionReplayConfig.captureNetworkTelemetry here since this extension
// has to be called manually anyway
guard let sessionId, instance.isSessionReplayActive() else {
return
}
let currentEnd = end ?? getMonotonicTimeInMilliseconds()
PostHogReplayIntegration.dispatchQueue.async {
var snapshotsData: [Any] = []
var requestsData: [String: Any] = ["duration": currentEnd - start,
"method": request?.httpMethod ?? "GET",
"name": request?.url?.absoluteString ?? (response?.url?.absoluteString ?? ""),
"initiatorType": "fetch",
"entryType": "resource",
"timestamp": timestamp.toMillis()]
// the UI special case if the transferSize is 0 as coming from cache
let transferSize = Int64(request?.httpBody?.count ?? 0) + (response?.expectedContentLength ?? 0)
if transferSize > 0 {
requestsData["transferSize"] = transferSize
}
if let urlResponse = response as? HTTPURLResponse {
requestsData["responseStatus"] = urlResponse.statusCode
}
let payloadData: [String: Any] = ["requests": [requestsData]]
let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData]
let recordingData: [String: Any] = ["type": 6, "data": pluginData, "timestamp": timestamp.toMillis()]
snapshotsData.append(recordingData)
instance.capture(
"$snapshot",
properties: [
"$snapshot_source": "mobile",
"$snapshot_data": snapshotsData,
"$session_id": sessionId,
],
timestamp: timestamp
)
}
}
}
#endif

View File

@@ -1,163 +0,0 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2019-Present Datadog, Inc.
*/
#if os(iOS)
import Foundation
class URLSessionInterceptor {
private let tasksLock = NSLock()
private let shouldCapture: () -> Bool
private let onCapture: (NetworkSample) -> Void
private let getSessionId: (Date) -> String?
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) {
self.shouldCapture = shouldCapture
self.onCapture = onCapture
self.getSessionId = getSessionId
}
/// An internal queue for synchronising the access to `samplesByTask`.
private let queue = DispatchQueue(label: "com.posthog.URLSessionInterceptor", target: .global(qos: .utility))
private var samplesByTask: [URLSessionTask: NetworkSample] = [:]
// MARK: - Interception Flow
/// Notifies the `URLSessionTask` creation.
/// This method should be called as soon as the task was created.
/// - Parameter task: the task object obtained from `URLSession`.
func taskCreated(task: URLSessionTask, session _: URLSession? = nil) {
guard shouldCapture() else {
return
}
guard let request = task.originalRequest else {
return
}
guard let url = request.url else {
return
}
let date = now()
guard let sessionId = getSessionId(date) else {
return
}
queue.async {
let sample = NetworkSample(
sessionId: sessionId,
timeOrigin: date,
url: url.absoluteString
)
self.tasksLock.withLock {
self.samplesByTask[task] = sample
}
self.finishAll()
}
}
/// Notifies the `URLSessionTask` completion.
/// This method should be called as soon as the task was completed.
/// - Parameter task: the task object obtained from `URLSession`.
/// - Parameter error: optional `Error` if the task completed with error.
func taskCompleted(task: URLSessionTask, error _: Error?) {
guard shouldCapture() else {
return
}
let date = Date()
queue.async {
var sampleTask: NetworkSample?
self.tasksLock.withLock {
sampleTask = self.samplesByTask[task]
}
guard var sample = sampleTask else {
return
}
self.finish(task: task, sample: &sample, date: date)
self.finishAll()
}
}
private func finish(task: URLSessionTask, sample: inout NetworkSample, date: Date? = nil) {
// only safe guard, should not happen
guard let request = task.originalRequest else {
tasksLock.withLock {
_ = samplesByTask.removeValue(forKey: task)
}
return
}
let responseStatusCode = urlResponseStatusCode(response: task.response)
if responseStatusCode != -1 {
sample.responseStatus = responseStatusCode
}
sample.httpMethod = request.httpMethod
sample.initiatorType = "fetch"
// instrumented requests that dont use the completion handler wont have the duration set
if let date = date {
sample.duration = (date.toMillis() - sample.timeOrigin.toMillis())
}
// the UI special case if the transferSize is 0 as coming from cache
let transferSize = Int64(request.httpBody?.count ?? 0) + (task.response?.expectedContentLength ?? 0)
if transferSize > 0 {
sample.decodedBodySize = transferSize
}
finish(task: task, sample: sample)
}
// MARK: - Private
private func urlResponseStatusCode(response: URLResponse?) -> Int {
if let urlResponse = response as? HTTPURLResponse {
return urlResponse.statusCode
}
return -1
}
private func finish(task: URLSessionTask, sample: NetworkSample) {
if shouldCapture() {
onCapture(sample)
}
tasksLock.withLock {
_ = samplesByTask.removeValue(forKey: task)
}
}
private func finishAll() {
var completedTasks: [URLSessionTask: NetworkSample] = [:]
tasksLock.withLock {
for item in samplesByTask where item.key.state == .completed {
completedTasks[item.key] = item.value
}
}
for item in completedTasks {
var value = item.value
finish(task: item.key, sample: &value)
}
}
func stop() {
tasksLock.withLock {
samplesByTask.removeAll()
}
}
}
#endif

View File

@@ -1,251 +0,0 @@
// swiftlint:disable nesting
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2019-Present Datadog, Inc.
*/
#if os(iOS)
import Foundation
class URLSessionSwizzler {
/// `URLSession.dataTask(with:completionHandler:)` (for `URLRequest`) swizzling.
private let dataTaskWithURLRequestAndCompletion: DataTaskWithURLRequestAndCompletion
/// `URLSession.dataTask(with:)` (for `URLRequest`) swizzling.
private let dataTaskWithURLRequest: DataTaskWithURLRequest
/// `URLSession.dataTask(with:completionHandler:)` (for `URL`) swizzling. Only applied on iOS 13 and above.
private let dataTaskWithURLAndCompletion: DataTaskWithURLAndCompletion?
/// `URLSession.dataTask(with:)` (for `URL`) swizzling. Only applied on iOS 13 and above.
private let dataTaskWithURL: DataTaskWithURL?
private let interceptor: URLSessionInterceptor
private var hasSwizzled = false
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) throws {
interceptor = URLSessionInterceptor(
shouldCapture: shouldCapture,
onCapture: onCapture,
getSessionId: getSessionId
)
dataTaskWithURLAndCompletion = try DataTaskWithURLAndCompletion.build(interceptor: interceptor)
dataTaskWithURL = try DataTaskWithURL.build(interceptor: interceptor)
dataTaskWithURLRequestAndCompletion = try DataTaskWithURLRequestAndCompletion.build(interceptor: interceptor)
dataTaskWithURLRequest = try DataTaskWithURLRequest.build(interceptor: interceptor)
}
func swizzle() {
dataTaskWithURLRequestAndCompletion.swizzle()
dataTaskWithURLAndCompletion?.swizzle()
dataTaskWithURLRequest.swizzle()
dataTaskWithURL?.swizzle()
hasSwizzled = true
}
func unswizzle() {
if !hasSwizzled {
return
}
dataTaskWithURLRequestAndCompletion.unswizzle()
dataTaskWithURLRequest.unswizzle()
dataTaskWithURLAndCompletion?.unswizzle()
dataTaskWithURL?.unswizzle()
hasSwizzled = false
}
// MARK: - Swizzlings
typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
/// Swizzles the `URLSession.dataTask(with:completionHandler:)` for `URLRequest`.
class DataTaskWithURLRequestAndCompletion: MethodSwizzler<
@convention(c) (URLSession, Selector, URLRequest, CompletionHandler?) -> URLSessionDataTask,
@convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask
> {
private static let selector = #selector(
URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URLRequest, @escaping CompletionHandler) -> URLSessionDataTask
)
private let method: FoundMethod
private let interceptor: URLSessionInterceptor
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLRequestAndCompletion {
try DataTaskWithURLRequestAndCompletion(
selector: selector,
klass: URLSession.self,
interceptor: interceptor
)
}
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
method = try Self.findMethod(with: selector, in: klass)
self.interceptor = interceptor
super.init()
}
func swizzle() {
typealias Signature = @convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask
swizzle(method) { previousImplementation -> Signature in { session, urlRequest, completionHandler -> URLSessionDataTask in
let task: URLSessionDataTask
if completionHandler != nil {
var taskReference: URLSessionDataTask?
let newCompletionHandler: CompletionHandler = { data, response, error in
if let task = taskReference { // sanity check, should always succeed
self.interceptor.taskCompleted(task: task, error: error)
}
completionHandler?(data, response, error)
}
task = previousImplementation(session, Self.selector, urlRequest, newCompletionHandler)
taskReference = task
} else {
// The `completionHandler` can be `nil` in two cases:
// - on iOS 11 or 12, where `dataTask(with:)` (for `URL` and `URLRequest`) calls
// the `dataTask(with:completionHandler:)` (for `URLRequest`) internally by nullifying the completion block.
// - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing
// `nil` as the `completionHandler` (it produces a warning, but compiles).
task = previousImplementation(session, Self.selector, urlRequest, completionHandler)
}
self.interceptor.taskCreated(task: task, session: session)
return task
}
}
}
}
/// Swizzles the `URLSession.dataTask(with:completionHandler:)` for `URL`.
class DataTaskWithURLAndCompletion: MethodSwizzler<
@convention(c) (URLSession, Selector, URL, CompletionHandler?) -> URLSessionDataTask,
@convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask
> {
private static let selector = #selector(
URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URL, @escaping CompletionHandler) -> URLSessionDataTask
)
private let method: FoundMethod
private let interceptor: URLSessionInterceptor
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLAndCompletion {
try DataTaskWithURLAndCompletion(
selector: selector,
klass: URLSession.self,
interceptor: interceptor
)
}
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
method = try Self.findMethod(with: selector, in: klass)
self.interceptor = interceptor
super.init()
}
func swizzle() {
typealias Signature = @convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask
swizzle(method) { previousImplementation -> Signature in { session, url, completionHandler -> URLSessionDataTask in
let task: URLSessionDataTask
if completionHandler != nil {
var taskReference: URLSessionDataTask?
let newCompletionHandler: CompletionHandler = { data, response, error in
if let task = taskReference { // sanity check, should always succeed
self.interceptor.taskCompleted(task: task, error: error)
}
completionHandler?(data, response, error)
}
task = previousImplementation(session, Self.selector, url, newCompletionHandler)
taskReference = task
} else {
// The `completionHandler` can be `nil` in one case:
// - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing
// `nil` as the `completionHandler` (it produces a warning, but compiles).
task = previousImplementation(session, Self.selector, url, completionHandler)
}
self.interceptor.taskCreated(task: task, session: session)
return task
}
}
}
}
/// Swizzles the `URLSession.dataTask(with:)` for `URLRequest`.
class DataTaskWithURLRequest: MethodSwizzler<
@convention(c) (URLSession, Selector, URLRequest) -> URLSessionDataTask,
@convention(block) (URLSession, URLRequest) -> URLSessionDataTask
> {
private static let selector = #selector(
URLSession.dataTask(with:) as (URLSession) -> (URLRequest) -> URLSessionDataTask
)
private let method: FoundMethod
private let interceptor: URLSessionInterceptor
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLRequest {
try DataTaskWithURLRequest(
selector: selector,
klass: URLSession.self,
interceptor: interceptor
)
}
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
method = try Self.findMethod(with: selector, in: klass)
self.interceptor = interceptor
super.init()
}
func swizzle() {
typealias Signature = @convention(block) (URLSession, URLRequest) -> URLSessionDataTask
swizzle(method) { previousImplementation -> Signature in { session, urlRequest -> URLSessionDataTask in
let task = previousImplementation(session, Self.selector, urlRequest)
self.interceptor.taskCreated(task: task, session: session)
return task
}
}
}
}
/// Swizzles the `URLSession.dataTask(with:)` for `URL`.
class DataTaskWithURL: MethodSwizzler<
@convention(c) (URLSession, Selector, URL) -> URLSessionDataTask,
@convention(block) (URLSession, URL) -> URLSessionDataTask
> {
private static let selector = #selector(
URLSession.dataTask(with:) as (URLSession) -> (URL) -> URLSessionDataTask
)
private let method: FoundMethod
private let interceptor: URLSessionInterceptor
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURL {
try DataTaskWithURL(
selector: selector,
klass: URLSession.self,
interceptor: interceptor
)
}
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
method = try Self.findMethod(with: selector, in: klass)
self.interceptor = interceptor
super.init()
}
func swizzle() {
typealias Signature = @convention(block) (URLSession, URL) -> URLSessionDataTask
swizzle(method) { previousImplementation -> Signature in { session, url -> URLSessionDataTask in
let task = previousImplementation(session, Self.selector, url)
self.interceptor.taskCreated(task: task, session: session)
return task
}
}
}
}
}
#endif
// swiftlint:enable nesting

View File

@@ -1,43 +0,0 @@
//
// PostHogSessionReplayPlugin.swift
// PostHog
//
// Created by Ioannis Josephides on 12/05/2025.
//
#if os(iOS)
import Foundation
/// Session replay plugins are used to capture specific types of meta data during a session,
/// such as console logs, network requests and user interactions. Each plugin is responsible
/// for managing its own capture lifecycle and sending data to PostHog.
///
/// Plugins are installed automatically based on the session replay configuration.
protocol PostHogSessionReplayPlugin {
/// Starts the plugin and begins data capture.
///
/// Called when session replay is started. The plugin should set up any required
/// resources and begin capturing data.
///
/// - Parameter postHog: The PostHog SDK instance to use for sending data
func start(postHog: PostHogSDK)
/// Stops the plugin and cleans up resources.
///
/// Called when session replay is stopped. The plugin should clean up any resources
/// and stop capturing data.
func stop()
/// Temporarily pauses data capture.
///
/// Called by session replay integration when plugin is requested to temporarily pause capturing data
/// The plugin should pause data capture but maintain its state.
func pause()
/// Resumes data capture after being paused.
///
/// Called by session replay integration when plugin is requested to resume normal capturing data
/// The plugin should resume data capture from its previous state.
func resume()
}
#endif

View File

@@ -1,865 +0,0 @@
// swiftlint:disable cyclomatic_complexity
//
// PostHogReplayIntegration.swift
// PostHog
//
// Created by Manoel Aranda Neto on 19.03.24.
//
#if os(iOS)
import Foundation
import PhotosUI
import SwiftUI
import UIKit
import WebKit
class PostHogReplayIntegration: PostHogIntegration {
var requiresSwizzling: Bool { true }
private static var integrationInstalledLock = NSLock()
private static var integrationInstalled = false
private var config: PostHogConfig? {
postHog?.config
}
private weak var postHog: PostHogSDK?
private var isEnabled: Bool = false
private let windowViewsLock = NSLock()
private let windowViews = NSMapTable<UIWindow, ViewTreeSnapshotStatus>.weakToStrongObjects()
private var applicationEventToken: RegistrationToken?
private var applicationBackgroundedToken: RegistrationToken?
private var applicationForegroundedToken: RegistrationToken?
private var viewLayoutToken: RegistrationToken?
private var installedPlugins: [PostHogSessionReplayPlugin] = []
/**
### Mapping of SwiftUI Views to UIKit
This section summarizes findings on how SwiftUI views map to UIKit components
#### Image-Based Views
- **`AsyncImage` and `Image`**
- Both views have a `CALayer` of type `SwiftUI.ImageLayer`.
- The associated `UIView` is of type `SwiftUI._UIGraphicsView`.
#### Graphic-based Views
- **`Color`, `Divider`, `Gradient` etc
- These are backed by `SwiftUI._UIGraphicsView` but have a different layer type than images
#### Text-Based Views
- **`Text`, `Button`, and `TextEditor`**
- These views are backed by a `UIView` of type `SwiftUI.CGDrawingView`, which is a subclass of `SwiftUI._UIGraphicsView`.
- CoreGraphics (`CG`) is used for rendering text content directly, making it challenging to access the value programmatically.
#### UIKit-Mapped Views
- **Views Hosted by `UIViewRepresentable`**
- Some SwiftUI views map directly to UIKit classes or to a subclass:
- **Control Images** (e.g., in `Picker` drop-downs) may map to `UIImageView`.
- **Buttons** map to `SwiftUI.UIKitIconPreferringButton` (a subclass of `UIButton`).
- **Toggle** maps to `UISwitch` (the toggle itself, excluding its label).
- **Picker** with wheel style maps to `UIPickerView`. Other styles use combinations of image-based and text-based views.
#### Layout and Structure Views
- **`Spacer`, `VStack`, `HStack`, `ZStack`, and Lazy Stacks**
- These views do not correspond to specific a `UIView`. Instead, they translate directly into layout constraints.
#### List-Based Views
- **`List` and Scrollable Container Views**
- Backed by a subclass of `UICollectionView`
#### Other SwiftUI Views
- Most other SwiftUI views are *compositions* of the views described above
SwiftUI Image Types:
- [StackOverflow: Subviews of a Window or View in SwiftUI](https://stackoverflow.com/questions/57554590/how-to-get-all-the-subviews-of-a-window-or-view-in-latest-swiftui-app)
- [StackOverflow: Detect SwiftUI Usage Programmatically](https://stackoverflow.com/questions/58336045/how-to-detect-swiftui-usage-programmatically-in-an-ios-application)
*/
/// `AsyncImage` and `Image`
private let swiftUIImageLayerTypes = [
"SwiftUI.ImageLayer",
].compactMap(NSClassFromString)
/// `Text`, `Button`, `TextEditor` views
private let swiftUITextBasedViewTypes = [
"SwiftUI.CGDrawingView", // Text, Button
"SwiftUI.TextEditorTextView", // TextEditor
"SwiftUI.VerticalTextView", // TextField, vertical axis
].compactMap(NSClassFromString)
private let swiftUIGenericTypes = [
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
].compactMap(NSClassFromString)
private let reactNativeTextView: AnyClass? = NSClassFromString("RCTTextView")
private let reactNativeImageView: AnyClass? = NSClassFromString("RCTImageView")
// These are usually views that don't belong to the current process and are most likely sensitive
private let systemSandboxedView: AnyClass? = NSClassFromString("_UIRemoteView")
// These layer types should be safe to ignore while masking
private let swiftUISafeLayerTypes: [AnyClass] = [
"SwiftUI.GradientLayer", // Views like LinearGradient, RadialGradient, or AngularGradient
].compactMap(NSClassFromString)
static let dispatchQueue = DispatchQueue(label: "com.posthog.PostHogReplayIntegration",
target: .global(qos: .utility))
private func isNotFlutter() -> Bool {
// for the Flutter SDK, screen recordings are managed by Flutter SDK itself
postHogSdkName != "posthog-flutter"
}
func install(_ postHog: PostHogSDK) throws {
try PostHogReplayIntegration.integrationInstalledLock.withLock {
if PostHogReplayIntegration.integrationInstalled {
throw InternalPostHogError(description: "Replay integration already installed to another PostHogSDK instance.")
}
PostHogReplayIntegration.integrationInstalled = true
}
self.postHog = postHog
start()
}
func uninstall(_ postHog: PostHogSDK) {
if self.postHog === postHog || self.postHog == nil {
stop()
self.postHog = nil
PostHogReplayIntegration.integrationInstalledLock.withLock {
PostHogReplayIntegration.integrationInstalled = false
}
}
}
func start() {
guard let postHog, !isEnabled else {
return
}
isEnabled = true
// reset views when session id changes (or is cleared) so we can re-send new metadata (or full snapshot in the future)
postHog.sessionManager.onSessionIdChanged = { [weak self] in
self?.resetViews()
}
// flutter captures snapshots, so we don't need to capture them here
if isNotFlutter() {
let interval = postHog.config.sessionReplayConfig.throttleDelay
viewLayoutToken = DI.main.viewLayoutPublisher.onViewLayout(throttle: interval) { [weak self] in
// called on main thread
self?.snapshot()
}
}
// start listening to `UIApplication.sendEvent`
let applicationEventPublisher = DI.main.applicationEventPublisher
applicationEventToken = applicationEventPublisher.onApplicationEvent { [weak self] event, date in
self?.handleApplicationEvent(event: event, date: date)
}
// Install plugins
let plugins = postHog.config.sessionReplayConfig.getPlugins()
installedPlugins = []
for plugin in plugins {
plugin.start(postHog: postHog)
installedPlugins.append(plugin)
}
// Start listening to application background events and pause all plugins
let applicationLifecyclePublisher = DI.main.appLifecyclePublisher
applicationBackgroundedToken = applicationLifecyclePublisher.onDidEnterBackground { [weak self] in
self?.pauseAllPlugins()
}
// Start listening to application foreground events and resume all plugins
applicationForegroundedToken = applicationLifecyclePublisher.onDidBecomeActive { [weak self] in
self?.resumeAllPlugins()
}
}
func stop() {
guard isEnabled else { return }
isEnabled = false
resetViews()
postHog?.sessionManager.onSessionIdChanged = {}
// stop listening to `UIApplication.sendEvent`
applicationEventToken = nil
// stop listening to Application lifecycle events
applicationBackgroundedToken = nil
applicationForegroundedToken = nil
// stop listening to `UIView.layoutSubviews` events
viewLayoutToken = nil
// stop plugins
for plugin in installedPlugins {
plugin.stop()
}
installedPlugins = []
}
func isActive() -> Bool {
isEnabled
}
private func resetViews() {
// Ensure thread-safe access to windowViews
windowViewsLock.withLock {
windowViews.removeAllObjects()
}
}
private func pauseAllPlugins() {
for plugin in installedPlugins {
plugin.pause()
}
}
private func resumeAllPlugins() {
for plugin in installedPlugins {
plugin.resume()
}
}
private func handleApplicationEvent(event: UIEvent, date: Date) {
guard let postHog, postHog.isSessionReplayActive() else {
return
}
guard event.type == .touches else {
return
}
guard let window = UIApplication.getCurrentWindow() else {
return
}
guard let touches = event.touches(for: window) else {
return
}
// capture necessary touch information on the main thread before performing any asynchronous operations
// - this ensures that UITouch associated objects like UIView, UIWindow, or [UIGestureRecognizer] are still valid.
// - these objects may be released or erased by the system if accessed asynchronously, resulting in invalid/zeroed-out touch coordinates
let touchInfo = touches.map {
(phase: $0.phase, location: $0.location(in: window))
}
PostHogReplayIntegration.dispatchQueue.async { [touchInfo, weak postHog = postHog] in
// always make sure we have a fresh session id as early as possible
guard let sessionId = postHog?.sessionManager.getSessionId(at: date) else {
return
}
// captured weakly since integration may have uninstalled by now
guard let postHog else { return }
var snapshotsData: [Any] = []
for touch in touchInfo {
let phase = touch.phase
let type: Int
if phase == .began {
type = 7
} else if phase == .ended {
type = 9
} else {
continue
}
// we keep a failsafe here just in case, but this will likely never be triggered
guard touch.location != .zero else {
continue
}
let posX = touch.location.x.toInt()
let posY = touch.location.y.toInt()
// if the id is 0, BE transformer will set it to the virtual bodyId
let touchData: [String: Any] = ["id": 0, "pointerType": 2, "source": 2, "type": type, "x": posX, "y": posY]
let data: [String: Any] = ["type": 3, "data": touchData, "timestamp": date.toMillis()]
snapshotsData.append(data)
}
if !snapshotsData.isEmpty {
postHog.capture(
"$snapshot",
properties: [
"$snapshot_source": "mobile",
"$snapshot_data": snapshotsData,
"$session_id": sessionId,
],
timestamp: date
)
}
}
}
private func generateSnapshot(_ window: UIWindow, _ screenName: String? = nil, postHog: PostHogSDK) {
var hasChanges = false
guard let wireframe = postHog.config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(window) : toWireframe(window) else {
return
}
// capture timestamp after snapshot was taken
let timestampDate = Date()
let timestamp = timestampDate.toMillis()
let snapshotStatus = windowViewsLock.withLock {
windowViews.object(forKey: window) ?? ViewTreeSnapshotStatus()
}
var snapshotsData: [Any] = []
if !snapshotStatus.sentMetaEvent {
let size = window.bounds.size
let width = size.width.toInt()
let height = size.height.toInt()
var data: [String: Any] = ["width": width, "height": height]
if let screenName = screenName {
data["href"] = screenName
}
let snapshotData: [String: Any] = ["type": 4, "data": data, "timestamp": timestamp]
snapshotsData.append(snapshotData)
snapshotStatus.sentMetaEvent = true
hasChanges = true
}
if hasChanges {
windowViewsLock.withLock {
windowViews.setObject(snapshotStatus, forKey: window)
}
}
// TODO: IncrementalSnapshot, type=2
PostHogReplayIntegration.dispatchQueue.async {
// always make sure we have a fresh session id at correct timestamp
guard let sessionId = postHog.sessionManager.getSessionId(at: timestampDate) else {
return
}
var wireframes: [Any] = []
wireframes.append(wireframe.toDict())
let initialOffset = ["top": 0, "left": 0]
let data: [String: Any] = ["initialOffset": initialOffset, "wireframes": wireframes]
let snapshotData: [String: Any] = ["type": 2, "data": data, "timestamp": timestamp]
snapshotsData.append(snapshotData)
postHog.capture(
"$snapshot",
properties: [
"$snapshot_source": "mobile",
"$snapshot_data": snapshotsData,
"$session_id": sessionId,
],
timestamp: timestampDate
)
}
}
private func setAlignment(_ alignment: NSTextAlignment, _ style: RRStyle) {
if alignment == .center {
style.verticalAlign = "center"
style.horizontalAlign = "center"
} else if alignment == .right {
style.horizontalAlign = "right"
} else if alignment == .left {
style.horizontalAlign = "left"
}
}
private func setPadding(_ insets: UIEdgeInsets, _ style: RRStyle) {
style.paddingTop = insets.top.toInt()
style.paddingRight = insets.right.toInt()
style.paddingBottom = insets.bottom.toInt()
style.paddingLeft = insets.left.toInt()
}
private func createBasicWireframe(_ view: UIView) -> RRWireframe {
let wireframe = RRWireframe()
// since FE will render each node of the wireframe with position: fixed
// we need to convert bounds to global screen coordinates
// otherwise each view of depth > 1 will likely have an origin of 0,0 (which is the local origin)
let frame = view.toAbsoluteRect(view.window)
wireframe.id = view.hash
wireframe.posX = frame.origin.x.toInt()
wireframe.posY = frame.origin.y.toInt()
wireframe.width = frame.size.width.toInt()
wireframe.height = frame.size.height.toInt()
return wireframe
}
private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) {
// User explicitly marked this view (and its subviews) as non-maskable through `.postHogNoMask()` view modifier
if view.postHogNoMask {
return
}
if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView
if isTextViewSensitive(textView) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
/// SwiftUI: `TextField`, `SecureField` will land here
if let textField = view as? UITextField {
if isTextFieldSensitive(textField) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
if let reactNativeTextView = reactNativeTextView {
if view.isKind(of: reactNativeTextView), config?.sessionReplayConfig.maskAllTextInputs == true {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
/// SwiftUI: Some control images like the ones in `Picker` view may land here
if let image = view as? UIImageView {
if isImageViewSensitive(image) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
if let reactNativeImageView = reactNativeImageView {
if view.isKind(of: reactNativeImageView), config?.sessionReplayConfig.maskAllImages == true {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
if isLabelSensitive(label) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
if let webView = view as? WKWebView { // Link, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
// since we cannot mask the webview content, if masking texts or images are enabled
// we mask the whole webview as well
if isAnyInputSensitive(webView) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
/// SwiftUI: `SwiftUI.UIKitIconPreferringButton` and other subclasses will land here
if let button = view as? UIButton {
if isButtonSensitive(button) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
/// SwiftUI: `Toggle` (no text, labels are just rendered to Text (swiftUIImageTypes))
if let theSwitch = view as? UISwitch {
if isSwitchSensitive(theSwitch) {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
// detect any views that don't belong to the current process (likely system views)
if config?.sessionReplayConfig.maskAllSandboxedViews == true,
let systemSandboxedView,
view.isKind(of: systemSandboxedView)
{
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
// if its a generic type and has subviews, subviews have to be checked first
let hasSubViews = !view.subviews.isEmpty
/// SwiftUI: `Picker` with .pickerStyle(.wheel) will land here
if let picker = view as? UIPickerView {
if isTextInputSensitive(picker), !hasSubViews {
maskableWidgets.append(picker.toAbsoluteRect(window))
return
}
}
/// SwiftUI: Text based views like `Text`, `Button`, `TextEditor`
if swiftUITextBasedViewTypes.contains(where: view.isKind(of:)) {
if isTextInputSensitive(view), !hasSubViews {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
/// SwiftUI: Image based views like `Image`, `AsyncImage`. (Note: We check the layer type here)
if swiftUIImageLayerTypes.contains(where: view.layer.isKind(of:)) {
if isSwiftUIImageSensitive(view), !hasSubViews {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
// this can be anything, so better to be conservative
if swiftUIGenericTypes.contains(where: { view.isKind(of: $0) }), !isSwiftUILayerSafe(view.layer) {
if isTextInputSensitive(view), !hasSubViews {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
}
// manually masked views through `.postHogMask()` view modifier
if view.postHogNoCapture {
maskableWidgets.append(view.toAbsoluteRect(window))
return
}
// on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask
// in such cases
if view.isNoCapture() || maskChildren {
let viewRect = view.toAbsoluteRect(window)
let windowRect = window.frame
// Check if the rectangles do not match
if !viewRect.equalTo(windowRect) {
maskableWidgets.append(view.toAbsoluteRect(window))
} else {
maskChildren = true
}
}
if !view.subviews.isEmpty {
for child in view.subviews {
if !child.isVisible() {
continue
}
findMaskableWidgets(child, window, &maskableWidgets, &maskChildren)
}
}
maskChildren = false
}
private func toScreenshotWireframe(_ window: UIWindow) -> RRWireframe? {
// this will bail on view controller animations (interactive or not)
if !window.isVisible() || isAnimatingTransition(window) {
return nil
}
var maskableWidgets: [CGRect] = []
var maskChildren = false
findMaskableWidgets(window, window, &maskableWidgets, &maskChildren)
let wireframe = createBasicWireframe(window)
if let image = window.toImage() {
if !image.size.hasSize() {
return nil
}
wireframe.maskableWidgets = maskableWidgets
wireframe.image = image
}
wireframe.type = "screenshot"
return wireframe
}
/// Check if any view controller in the hierarchy is animating a transition
private func isAnimatingTransition(_ window: UIWindow) -> Bool {
guard let rootViewController = window.rootViewController else { return false }
return isAnimatingTransition(rootViewController)
}
private func isAnimatingTransition(_ viewController: UIViewController) -> Bool {
// Check if this view controller is animating
if viewController.transitionCoordinator?.isAnimated ?? false {
return true
}
// Check if presented view controller is animating
if let presented = viewController.presentedViewController, isAnimatingTransition(presented) {
return true
}
// Check if any of the child view controllers is animating
if viewController.children.first(where: isAnimatingTransition) != nil {
return true
}
return false
}
private func isAssetsImage(_ image: UIImage) -> Bool {
// https://github.com/daydreamboy/lldb_scripts#9-pimage
// do not mask if its an asset image, likely not PII anyway
image.imageAsset?.value(forKey: "_containingBundle") != nil
}
private func isAnyInputSensitive(_ view: UIView) -> Bool {
isTextInputSensitive(view) || config?.sessionReplayConfig.maskAllImages == true
}
private func isTextInputSensitive(_ view: UIView) -> Bool {
config?.sessionReplayConfig.maskAllTextInputs == true || view.isNoCapture()
}
private func isLabelSensitive(_ view: UILabel) -> Bool {
isTextInputSensitive(view) && hasText(view.text)
}
private func isButtonSensitive(_ view: UIButton) -> Bool {
isTextInputSensitive(view) && hasText(view.titleLabel?.text)
}
private func isTextViewSensitive(_ view: UITextView) -> Bool {
(isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text)
}
private func isSwitchSensitive(_ view: UISwitch) -> Bool {
var containsText = true
if #available(iOS 14.0, *) {
containsText = hasText(view.title)
}
return isTextInputSensitive(view) && containsText
}
private func isTextFieldSensitive(_ view: UITextField) -> Bool {
(isTextInputSensitive(view) || view.isSensitiveText()) && (hasText(view.text) || hasText(view.placeholder))
}
private func isSwiftUILayerSafe(_ layer: CALayer) -> Bool {
swiftUISafeLayerTypes.contains(where: { layer.isKind(of: $0) })
}
private func hasText(_ text: String?) -> Bool {
if let text = text, !text.isEmpty {
return true
} else {
// if there's no text, there's nothing to mask
return false
}
}
private func isSwiftUIImageSensitive(_ view: UIView) -> Bool {
// No way of checking if this is an asset image or not
// No way of checking if there's actual content in the image or not
config?.sessionReplayConfig.maskAllImages == true || view.isNoCapture()
}
private func isImageViewSensitive(_ view: UIImageView) -> Bool {
// if there's no image, there's nothing to mask
guard let image = view.image else { return false }
// sensitive, regardless
if view.isNoCapture() {
return true
}
// asset images are probably not sensitive
if isAssetsImage(image) {
return false
}
// symbols are probably not sensitive
if image.isSymbolImage {
return false
}
return config?.sessionReplayConfig.maskAllImages == true
}
private func toWireframe(_ view: UIView) -> RRWireframe? {
if !view.isVisible() {
return nil
}
let wireframe = createBasicWireframe(view)
let style = RRStyle()
if let textView = view as? UITextView {
wireframe.type = "text"
wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text
wireframe.disabled = !textView.isEditable
style.color = textView.textColor?.toRGBString()
style.fontFamily = textView.font?.familyName
if let fontSize = textView.font?.pointSize.toInt() {
style.fontSize = fontSize
}
setAlignment(textView.textAlignment, style)
setPadding(textView.textContainerInset, style)
}
if let textField = view as? UITextField {
wireframe.type = "input"
wireframe.inputType = "text_area"
let isSensitive = isTextFieldSensitive(textField)
if let text = textField.text {
wireframe.value = isSensitive ? text.mask() : text
} else {
if let text = textField.placeholder {
wireframe.value = isSensitive ? text.mask() : text
}
}
wireframe.disabled = !textField.isEnabled
style.color = textField.textColor?.toRGBString()
style.fontFamily = textField.font?.familyName
if let fontSize = textField.font?.pointSize.toInt() {
style.fontSize = fontSize
}
setAlignment(textField.textAlignment, style)
}
if view is UIPickerView {
wireframe.type = "input"
wireframe.inputType = "select"
// set wireframe.value from selected row
}
if let theSwitch = view as? UISwitch {
wireframe.type = "input"
wireframe.inputType = "toggle"
wireframe.checked = theSwitch.isOn
if #available(iOS 14.0, *) {
if let text = theSwitch.title {
wireframe.label = isSwitchSensitive(theSwitch) ? text.mask() : text
}
}
}
if let imageView = view as? UIImageView {
wireframe.type = "image"
if let image = imageView.image {
if !isImageViewSensitive(imageView) {
wireframe.image = image
}
}
}
if let button = view as? UIButton {
wireframe.type = "input"
wireframe.inputType = "button"
wireframe.disabled = !button.isEnabled
if let text = button.titleLabel?.text {
// NOTE: this will create a ghosting effect since text will also be captured in child UILabel
// We also may be masking this UIButton but child UILabel may remain unmasked
wireframe.value = isButtonSensitive(button) ? text.mask() : text
}
}
if let label = view as? UILabel {
wireframe.type = "text"
if let text = label.text {
wireframe.text = isLabelSensitive(label) ? text.mask() : text
}
wireframe.disabled = !label.isEnabled
style.color = label.textColor?.toRGBString()
style.fontFamily = label.font?.familyName
if let fontSize = label.font?.pointSize.toInt() {
style.fontSize = fontSize
}
setAlignment(label.textAlignment, style)
}
if view is WKWebView {
wireframe.type = "web_view"
}
if let progressView = view as? UIProgressView {
wireframe.type = "input"
wireframe.inputType = "progress"
wireframe.value = progressView.progress
wireframe.max = 1
// UIProgressView theres not circular format, only custom view or swiftui
style.bar = "horizontal"
}
if view is UIActivityIndicatorView {
wireframe.type = "input"
wireframe.inputType = "progress"
style.bar = "circular"
}
// TODO: props: backgroundImage (probably not needed)
// TODO: componenets: UITabBar, UINavigationBar, UISlider, UIStepper, UIDatePicker
style.backgroundColor = view.backgroundColor?.toRGBString()
let layer = view.layer
style.borderWidth = layer.borderWidth.toInt()
style.borderRadius = layer.cornerRadius.toInt()
style.borderColor = layer.borderColor?.toRGBString()
wireframe.style = style
if !view.subviews.isEmpty {
var childWireframes: [RRWireframe] = []
for subview in view.subviews {
if let child = toWireframe(subview) {
childWireframes.append(child)
}
}
wireframe.childWireframes = childWireframes
}
return wireframe
}
@objc private func snapshot() {
guard let postHog, postHog.isSessionReplayActive() else {
return
}
guard let window = UIApplication.getCurrentWindow() else {
return
}
var screenName: String?
if let controller = window.rootViewController {
// SwiftUI only supported with screenshotMode
if controller is AnyObjectUIHostingViewController, !postHog.config.sessionReplayConfig.screenshotMode {
hedgeLog("SwiftUI snapshot not supported, enable screenshotMode.")
return
// screen name only makes sense if we are not using SwiftUI
} else if !postHog.config.sessionReplayConfig.screenshotMode {
screenName = UIViewController.getViewControllerName(controller)
}
}
// this cannot run off of the main thread because most properties require to be called within the main thread
// this method has to be fast and do as little as possible
generateSnapshot(window, screenName, postHog: postHog)
}
}
private protocol AnyObjectUIHostingViewController: AnyObject {}
extension UIHostingController: AnyObjectUIHostingViewController {}
#if TESTING
extension PostHogReplayIntegration {
static func clearInstalls() {
integrationInstalledLock.withLock {
integrationInstalled = false
}
}
}
#endif
#endif
// swiftlint:enable cyclomatic_complexity

View File

@@ -1,93 +0,0 @@
//
// PostHogSessionReplayConfig.swift
// PostHog
//
// Created by Manoel Aranda Neto on 19.03.24.
//
#if os(iOS)
import Foundation
@objc(PostHogSessionReplayConfig) public class PostHogSessionReplayConfig: NSObject {
/// Enable masking of all text and text input fields
/// Default: true
@objc public var maskAllTextInputs: Bool = true
/// Enable masking of all images to a placeholder
/// Default: true
@objc public var maskAllImages: Bool = true
/// Enable masking of all sandboxed system views
/// These may include UIImagePickerController, PHPickerViewController and CNContactPickerViewController
/// Default: true
@objc public var maskAllSandboxedViews: Bool = true
/// Enable masking of images that likely originated from user's photo library (UIKit only)
/// Default: false
///
/// - Note: Deprecated
@available(*, deprecated, message: "This property has no effect and will be removed in the next major release. To learn how to manually mask user photos please see our Privacy controls documentation: https://posthog.com/docs/session-replay/privacy?tab=iOS")
@objc public var maskPhotoLibraryImages: Bool = false
/// Enable capturing network telemetry
/// Default: true
@objc public var captureNetworkTelemetry: Bool = true
/// By default Session replay will capture all the views on the screen as a wireframe,
/// By enabling this option, PostHog will capture the screenshot of the screen.
/// The screenshot may contain sensitive information, use with caution.
/// Default: false
@objc public var screenshotMode: Bool = false
/// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact
/// This is used for capturing the view as a wireframe or screenshot
/// The lower the number more snapshots will be captured but higher the performance impact
/// Defaults to 1s
@available(*, deprecated, message: "Deprecated in favor of 'throttleDelay' which provides identical functionality. Will be removed in the next major release.")
@objc public var debouncerDelay: TimeInterval {
get { throttleDelay }
set { throttleDelay = newValue }
}
/// Throttle delay used to reduce the number of snapshots captured and reduce performance impact
/// This is used for capturing the view as a wireframe or screenshot
/// The lower the number more snapshots will be captured but higher the performance impact
/// Defaults to 1s
///
/// Note: Previously `debouncerDelay`
@objc public var throttleDelay: TimeInterval = 1
/// Enable capturing console output for session replay.
///
/// When enabled, logs from the following sources will be captured:
/// - Standard output (stdout)
/// - Standard error (stderr)
/// - OSLog messages
/// - NSLog messages
///
/// Each log entry will be tagged with a level (info/warning/error) based on the message content
/// and the source.
///
/// Defaults to `false`
@objc public var captureLogs: Bool = false
/// Further configuration for capturing console output
@objc public var captureLogsConfig: PostHogSessionReplayConsoleLogConfig = .init()
// TODO: sessionRecording config such as networkPayloadCapture, sampleRate, etc
/// Returns an array of plugins to be installed based on current configuration
func getPlugins() -> [PostHogSessionReplayPlugin] {
var plugins: [PostHogSessionReplayPlugin] = []
if captureLogs {
plugins.append(PostHogSessionReplayConsoleLogsPlugin())
}
if captureNetworkTelemetry {
plugins.append(PostHogSessionReplayNetworkPlugin())
}
return plugins
}
}
#endif

View File

@@ -1,73 +0,0 @@
//
// PostHogSessionReplayConsoleLogConfig.swift
// PostHog
//
// Created by Ioannis Josephides on 09/05/2025.
//
#if os(iOS)
import Foundation
@objc public class PostHogSessionReplayConsoleLogConfig: NSObject {
/// Block to process and format captured console output for session replay.
///
/// This block is called whenever console output is captured. It allows you to:
/// 1. Filter or modify log messages before they are sent to session replay
/// 2. Determine the appropriate log level (info/warn/error) for each message
/// 3. Format, sanitize or skip a log messages (e.g. remove sensitive data or PII)
///
/// The default implementation:
/// - Detect log level (best effort)
/// - Process OSLog messages to remove metadata
///
/// - Parameter output: The raw console output to process
/// - Returns: Array of `PostHogConsoleLogResult` objects, one for each processed log entry. Return an empty array to skip a log output
@objc public var logSanitizer: ((String) -> PostHogLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer
/// The minimum log level to capture in session replay.
/// Only log messages with this level or higher will be captured.
/// For example, if set to `.warn`:
/// - `.error` messages will be captured
/// - `.warn` messages will be captured
/// - `.info` messages will be skipped
///
/// Defaults to `.error` to minimize noise in session replays.
@objc public var minLogLevel: PostHogLogLevel = .error
/// Default implementation for processing console output.
static func defaultLogSanitizer(_ message: String) -> PostHogLogEntry? {
let message = String(message)
// Determine console log level
let level: PostHogLogLevel = {
if message.range(of: logMessageWarningPattern, options: .regularExpression) != nil { return .warn }
if message.range(of: logMessageErrorPattern, options: .regularExpression) != nil { return .error }
return .info
}()
// For OSLog messages, extract just the log message part
let sanitizedMessage = message.contains("OSLOG-") ? {
if let tabIndex = message.lastIndex(of: "\t") {
return String(message[message.index(after: tabIndex)...])
}
return message
}() : message
return PostHogLogEntry(level: level, message: sanitizedMessage)
}
/// Default regular expression pattern used to identify error-level log messages.
///
/// By default, it matches common error indicators such as:
/// - The word "error", "exception", "fail" or "failed"
/// - OSLog messages with type "Error" or "Fault"
private static let logMessageErrorPattern = "(error|exception|fail(ed)?|OSLOG-.*type:\"Error\"|OSLOG-.*type:\"Fault\")"
/// Default regular expression pattern used to identify warning-level log messages.
///
/// By default, it matches common warning indicators such as:
/// - The words "warning", "warn", "caution", or "deprecated"
/// - OSLog messages with type "Warning"
///
private static let logMessageWarningPattern = "(warn(ing)?|caution|deprecated|OSLOG-.*type:\"Warning\")"
}
#endif

View File

@@ -1,96 +0,0 @@
// swiftlint:disable cyclomatic_complexity
//
// RRStyle.swift
// PostHog
//
// Created by Manoel Aranda Neto on 21.03.24.
//
import Foundation
class RRStyle {
var color: String?
var backgroundColor: String?
var backgroundImage: String?
var borderWidth: Int?
var borderRadius: Int?
var borderColor: String?
var fontSize: Int?
var fontFamily: String?
var horizontalAlign: String?
var verticalAlign: String?
var paddingTop: Int?
var paddingBottom: Int?
var paddingLeft: Int?
var paddingRight: Int?
var bar: String?
func toDict() -> [String: Any] {
var dict: [String: Any] = [:]
if let color = color {
dict["color"] = color
}
if let backgroundColor = backgroundColor {
dict["backgroundColor"] = backgroundColor
}
if let backgroundImage = backgroundImage {
dict["backgroundImage"] = backgroundImage
}
if let borderWidth = borderWidth {
dict["borderWidth"] = borderWidth
}
if let borderRadius = borderRadius {
dict["borderRadius"] = borderRadius
}
if let borderColor = borderColor {
dict["borderColor"] = borderColor
}
if let fontSize = fontSize {
dict["fontSize"] = fontSize
}
if let fontFamily = fontFamily {
dict["fontFamily"] = fontFamily
}
if let horizontalAlign = horizontalAlign {
dict["horizontalAlign"] = horizontalAlign
}
if let verticalAlign = verticalAlign {
dict["verticalAlign"] = verticalAlign
}
if let paddingTop = paddingTop {
dict["paddingTop"] = paddingTop
}
if let paddingBottom = paddingBottom {
dict["paddingBottom"] = paddingBottom
}
if let paddingLeft = paddingLeft {
dict["paddingLeft"] = paddingLeft
}
if let paddingRight = paddingRight {
dict["paddingRight"] = paddingRight
}
if let bar = bar {
dict["bar"] = bar
}
return dict
}
}
// swiftlint:enable cyclomatic_complexity

View File

@@ -1,133 +0,0 @@
// swiftlint:disable cyclomatic_complexity
//
// RRWireframe.swift
// PostHog
//
// Created by Manoel Aranda Neto on 21.03.24.
//
import Foundation
#if os(iOS)
import UIKit
#endif
class RRWireframe {
var id: Int = 0
var posX: Int = 0
var posY: Int = 0
var width: Int = 0
var height: Int = 0
var childWireframes: [RRWireframe]?
var type: String? // text|image|rectangle|input|div|screenshot
var inputType: String?
var text: String?
var label: String?
var value: Any? // string or number
#if os(iOS)
var image: UIImage?
var maskableWidgets: [CGRect]?
#endif
var base64: String?
var style: RRStyle?
var disabled: Bool?
var checked: Bool?
var options: [String]?
var max: Int?
// internal
var parentId: Int?
#if os(iOS)
private func maskImage() -> UIImage? {
if let image = image {
// the scale also affects the image size/resolution, from usually 100kb to 15kb each
let redactedImage = UIGraphicsImageRenderer(size: image.size, format: .init(for: .init(displayScale: 1))).image { context in
context.cgContext.interpolationQuality = .none
image.draw(at: .zero)
if let maskableWidgets = maskableWidgets {
for rect in maskableWidgets {
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10)
UIColor.black.setFill()
path.fill()
}
}
}
return redactedImage
}
return nil
}
#endif
func toDict() -> [String: Any] {
var dict: [String: Any] = [
"id": id,
"x": posX,
"y": posY,
"width": width,
"height": height,
]
if let childWireframes = childWireframes {
dict["childWireframes"] = childWireframes.map { $0.toDict() }
}
if let type = type {
dict["type"] = type
}
if let inputType = inputType {
dict["inputType"] = inputType
}
if let text = text {
dict["text"] = text
}
if let label = label {
dict["label"] = label
}
if let value = value {
dict["value"] = value
}
#if os(iOS)
if let image = image {
if let maskedImage = maskImage() {
base64 = maskedImage.toBase64()
} else {
base64 = image.toBase64()
}
}
#endif
if let base64 = base64 {
dict["base64"] = base64
}
if let style = style {
dict["style"] = style.toDict()
}
if let disabled = disabled {
dict["disabled"] = disabled
}
if let checked = checked {
dict["checked"] = checked
}
if let options = options {
dict["options"] = options
}
if let max = max {
dict["max"] = max
}
return dict
}
}
// swiftlint:enable cyclomatic_complexity

View File

@@ -1,14 +0,0 @@
//
// String+Util.swift
// PostHog
//
// Created by Manoel Aranda Neto on 21.03.24.
//
import Foundation
extension String {
func mask() -> String {
String(repeating: "*", count: count)
}
}

View File

@@ -1,17 +0,0 @@
//
// UIColor+Util.swift
// PostHog
//
// Created by Manoel Aranda Neto on 21.03.24.
//
#if os(iOS)
import Foundation
import UIKit
extension UIColor {
func toRGBString() -> String? {
cgColor.toRGBString()
}
}
#endif

Some files were not shown because too many files have changed in this diff Show More