PDF BOM export
This commit is contained in:
@@ -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;
|
||||
|
||||
3
Cable.xcworkspace/contents.xcworkspacedata
generated
3
Cable.xcworkspace/contents.xcworkspacedata
generated
@@ -4,7 +4,4 @@
|
||||
<FileRef
|
||||
location = "group:Cable.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 %@.";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
Cable/Loads/ElectricalCalculations.swift
Normal file
138
Cable/Loads/ElectricalCalculations.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
11
Cable/Shared/ShareSheet.swift
Normal file
11
Cable/Shared/ShareSheet.swift
Normal 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) {}
|
||||
}
|
||||
17
Cable/Systems/BillOfMaterialsSnapshot.swift
Normal file
17
Cable/Systems/BillOfMaterialsSnapshot.swift
Normal 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]
|
||||
}
|
||||
313
Cable/Systems/SystemBillOfMaterialsPDFExporter.swift
Normal file
313
Cable/Systems/SystemBillOfMaterialsPDFExporter.swift
Normal 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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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 %@.";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 l’instant.";
|
||||
"bom.export.pdf.button" = "Exporter en PDF";
|
||||
"bom.export.pdf.error.title" = "Échec de l’export";
|
||||
"bom.export.pdf.error.empty" = "Ajoutez au moins un composant avant l’export.";
|
||||
"bom.pdf.header.title" = "Liste de matériaux du système";
|
||||
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||
"bom.pdf.header.inline" = "Système d’unité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 l’utilisation";
|
||||
"generic.ok" = "OK";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -8,7 +8,37 @@
|
||||
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,
|
||||
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
||||
@@ -30,66 +60,63 @@ 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"]
|
||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
1
Podfile
1
Podfile
@@ -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
|
||||
|
||||
16
Podfile.lock
16
Podfile.lock
@@ -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
16
Pods/Manifest.lock
generated
@@ -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
|
||||
2254
Pods/Pods.xcodeproj/project.pbxproj
generated
2254
Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
21
Pods/PostHog/LICENSE
generated
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
28
Pods/PostHog/PostHog/DI.swift
generated
28
Pods/PostHog/PostHog/DI.swift
generated
@@ -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
|
||||
}
|
||||
}
|
||||
102
Pods/PostHog/PostHog/Models/PostHogEvent.swift
generated
102
Pods/PostHog/PostHog/Models/PostHogEvent.swift
generated
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Pods/PostHog/PostHog/PostHog.h
generated
60
Pods/PostHog/PostHog/PostHog.h
generated
@@ -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>
|
||||
337
Pods/PostHog/PostHog/PostHogApi.swift
generated
337
Pods/PostHog/PostHog/PostHogApi.swift
generated
@@ -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
|
||||
}()
|
||||
}
|
||||
13
Pods/PostHog/PostHog/PostHogBatchUploadInfo.swift
generated
13
Pods/PostHog/PostHog/PostHogBatchUploadInfo.swift
generated
@@ -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?
|
||||
}
|
||||
276
Pods/PostHog/PostHog/PostHogConfig.swift
generated
276
Pods/PostHog/PostHog/PostHogConfig.swift
generated
@@ -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 Group’s 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)
|
||||
}
|
||||
}
|
||||
13
Pods/PostHog/PostHog/PostHogConsumerPayload.swift
generated
13
Pods/PostHog/PostHog/PostHogConsumerPayload.swift
generated
@@ -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
|
||||
}
|
||||
411
Pods/PostHog/PostHog/PostHogContext.swift
generated
411
Pods/PostHog/PostHog/PostHogContext.swift
generated
@@ -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
|
||||
}()
|
||||
}
|
||||
20
Pods/PostHog/PostHog/PostHogExtensions.swift
generated
20
Pods/PostHog/PostHog/PostHogExtensions.swift
generated
@@ -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
|
||||
}
|
||||
114
Pods/PostHog/PostHog/PostHogFileBackedQueue.swift
generated
114
Pods/PostHog/PostHog/PostHogFileBackedQueue.swift
generated
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Pods/PostHog/PostHog/PostHogIntegration.swift
generated
59
Pods/PostHog/PostHog/PostHogIntegration.swift
generated
@@ -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()
|
||||
}
|
||||
45
Pods/PostHog/PostHog/PostHogLegacyQueue.swift
generated
45
Pods/PostHog/PostHog/PostHogLegacyQueue.swift
generated
@@ -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)")
|
||||
}
|
||||
}
|
||||
20
Pods/PostHog/PostHog/PostHogPersonProfiles.swift
generated
20
Pods/PostHog/PostHog/PostHogPersonProfiles.swift
generated
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
285
Pods/PostHog/PostHog/PostHogQueue.swift
generated
285
Pods/PostHog/PostHog/PostHogQueue.swift
generated
@@ -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
|
||||
}
|
||||
}
|
||||
636
Pods/PostHog/PostHog/PostHogRemoteConfig.swift
generated
636
Pods/PostHog/PostHog/PostHogRemoteConfig.swift
generated
@@ -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
|
||||
}
|
||||
}
|
||||
1499
Pods/PostHog/PostHog/PostHogSDK.swift
generated
1499
Pods/PostHog/PostHog/PostHogSDK.swift
generated
File diff suppressed because it is too large
Load Diff
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
@@ -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
|
||||
}
|
||||
}
|
||||
419
Pods/PostHog/PostHog/PostHogStorage.swift
generated
419
Pods/PostHog/PostHog/PostHogStorage.swift
generated
@@ -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)
|
||||
}
|
||||
}
|
||||
163
Pods/PostHog/PostHog/PostHogStorageManager.swift
generated
163
Pods/PostHog/PostHog/PostHogStorageManager.swift
generated
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Pods/PostHog/PostHog/PostHogSwizzler.swift
generated
14
Pods/PostHog/PostHog/PostHogSwizzler.swift
generated
@@ -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)
|
||||
}
|
||||
16
Pods/PostHog/PostHog/PostHogVersion.swift
generated
16
Pods/PostHog/PostHog/PostHogVersion.swift
generated
@@ -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
|
||||
@@ -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
|
||||
33
Pods/PostHog/PostHog/Replay/CGColor+Util.swift
generated
33
Pods/PostHog/PostHog/Replay/CGColor+Util.swift
generated
@@ -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
|
||||
19
Pods/PostHog/PostHog/Replay/CGSize+Util.swift
generated
19
Pods/PostHog/PostHog/Replay/CGSize+Util.swift
generated
@@ -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
|
||||
18
Pods/PostHog/PostHog/Replay/Date+Util.swift
generated
18
Pods/PostHog/PostHog/Replay/Date+Util.swift
generated
@@ -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()
|
||||
}
|
||||
20
Pods/PostHog/PostHog/Replay/Float+Util.swift
generated
20
Pods/PostHog/PostHog/Replay/Float+Util.swift
generated
@@ -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
|
||||
}
|
||||
}
|
||||
120
Pods/PostHog/PostHog/Replay/MethodSwizzler.swift
generated
120
Pods/PostHog/PostHog/Replay/MethodSwizzler.swift
generated
@@ -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
|
||||
58
Pods/PostHog/PostHog/Replay/NetworkSample.swift
generated
58
Pods/PostHog/PostHog/Replay/NetworkSample.swift
generated
@@ -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
|
||||
12
Pods/PostHog/PostHog/Replay/Optional+Util.swift
generated
12
Pods/PostHog/PostHog/Replay/Optional+Util.swift
generated
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
96
Pods/PostHog/PostHog/Replay/RRStyle.swift
generated
96
Pods/PostHog/PostHog/Replay/RRStyle.swift
generated
@@ -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
|
||||
133
Pods/PostHog/PostHog/Replay/RRWireframe.swift
generated
133
Pods/PostHog/PostHog/Replay/RRWireframe.swift
generated
@@ -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
|
||||
14
Pods/PostHog/PostHog/Replay/String+Util.swift
generated
14
Pods/PostHog/PostHog/Replay/String+Util.swift
generated
@@ -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)
|
||||
}
|
||||
}
|
||||
17
Pods/PostHog/PostHog/Replay/UIColor+Util.swift
generated
17
Pods/PostHog/PostHog/Replay/UIColor+Util.swift
generated
@@ -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
Reference in New Issue
Block a user