ads tracking
This commit is contained in:
@@ -6,6 +6,13 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
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 */
|
/* Begin PBXContainerItemProxy section */
|
||||||
3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */ = {
|
3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
@@ -31,10 +38,22 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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; };
|
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; };
|
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; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -78,6 +97,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
156FA26BC2A070D3E79DBC53 /* Pods_Cable_CableUITestsScreenshot.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -85,6 +105,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
0DBC1CAB8BE5C690AE39630C /* Pods_Cable.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -92,6 +113,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
85A2E22A9DF253A619C833B2 /* Pods_CableTests.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -99,6 +121,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
4472B945421CAB58A81AAF03 /* Pods_Cable_CableUITests.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -113,6 +136,8 @@
|
|||||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||||
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
||||||
|
57738E9B07763CFA62681EEE /* Pods */,
|
||||||
|
9D16D1FE8C8B34C13C51D389 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -127,6 +152,32 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -134,9 +185,11 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 3E37F65D2E93FB6F00836187 /* Build configuration list for PBXNativeTarget "CableUITestsScreenshot" */;
|
buildConfigurationList = 3E37F65D2E93FB6F00836187 /* Build configuration list for PBXNativeTarget "CableUITestsScreenshot" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
ECF8C5947A59DAC9118AE4F4 /* [CP] Check Pods Manifest.lock */,
|
||||||
3E37F6512E93FB6F00836187 /* Sources */,
|
3E37F6512E93FB6F00836187 /* Sources */,
|
||||||
3E37F6522E93FB6F00836187 /* Frameworks */,
|
3E37F6522E93FB6F00836187 /* Frameworks */,
|
||||||
3E37F6532E93FB6F00836187 /* Resources */,
|
3E37F6532E93FB6F00836187 /* Resources */,
|
||||||
|
611809BC8E1F9DF30E9C4629 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -147,8 +200,6 @@
|
|||||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||||
);
|
);
|
||||||
name = CableUITestsScreenshot;
|
name = CableUITestsScreenshot;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CableUITestsScreenshot;
|
productName = CableUITestsScreenshot;
|
||||||
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
|
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
@@ -157,9 +208,11 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 3E5C0BF02E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "Cable" */;
|
buildConfigurationList = 3E5C0BF02E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "Cable" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
3585B809C20C4D3B1FE82C78 /* [CP] Check Pods Manifest.lock */,
|
||||||
3E5C0BC82E72C0FD00247EC8 /* Sources */,
|
3E5C0BC82E72C0FD00247EC8 /* Sources */,
|
||||||
3E5C0BC92E72C0FD00247EC8 /* Frameworks */,
|
3E5C0BC92E72C0FD00247EC8 /* Frameworks */,
|
||||||
3E5C0BCA2E72C0FD00247EC8 /* Resources */,
|
3E5C0BCA2E72C0FD00247EC8 /* Resources */,
|
||||||
|
E8C196B44C4F00DA4E300C55 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -169,8 +222,6 @@
|
|||||||
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
|
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
|
||||||
);
|
);
|
||||||
name = Cable;
|
name = Cable;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = Cable;
|
productName = Cable;
|
||||||
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
|
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -179,6 +230,7 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 3E5C0BF52E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "CableTests" */;
|
buildConfigurationList = 3E5C0BF52E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "CableTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
3D80694CE29BD68AE168E8DF /* [CP] Check Pods Manifest.lock */,
|
||||||
3E5C0BD92E72C0FE00247EC8 /* Sources */,
|
3E5C0BD92E72C0FE00247EC8 /* Sources */,
|
||||||
3E5C0BDA2E72C0FE00247EC8 /* Frameworks */,
|
3E5C0BDA2E72C0FE00247EC8 /* Frameworks */,
|
||||||
3E5C0BDB2E72C0FE00247EC8 /* Resources */,
|
3E5C0BDB2E72C0FE00247EC8 /* Resources */,
|
||||||
@@ -192,8 +244,6 @@
|
|||||||
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
||||||
);
|
);
|
||||||
name = CableTests;
|
name = CableTests;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CableTests;
|
productName = CableTests;
|
||||||
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
|
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
@@ -202,9 +252,11 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 3E5C0BF82E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "CableUITests" */;
|
buildConfigurationList = 3E5C0BF82E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "CableUITests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
D1A689F595A65E0530AAACB0 /* [CP] Check Pods Manifest.lock */,
|
||||||
3E5C0BE32E72C0FE00247EC8 /* Sources */,
|
3E5C0BE32E72C0FE00247EC8 /* Sources */,
|
||||||
3E5C0BE42E72C0FE00247EC8 /* Frameworks */,
|
3E5C0BE42E72C0FE00247EC8 /* Frameworks */,
|
||||||
3E5C0BE52E72C0FE00247EC8 /* Resources */,
|
3E5C0BE52E72C0FE00247EC8 /* Resources */,
|
||||||
|
3808009BC0D951592701EA88 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -215,8 +267,6 @@
|
|||||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||||
);
|
);
|
||||||
name = CableUITests;
|
name = CableUITests;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CableUITests;
|
productName = CableUITests;
|
||||||
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
|
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
@@ -305,6 +355,160 @@
|
|||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* 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 */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
3E37F6512E93FB6F00836187 /* Sources */ = {
|
3E37F6512E93FB6F00836187 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
@@ -357,6 +561,7 @@
|
|||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
3E37F65E2E93FB6F00836187 /* Debug */ = {
|
3E37F65E2E93FB6F00836187 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 83D6CB62ED3959EC1EC8027D /* Pods-Cable-CableUITestsScreenshot.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -378,6 +583,7 @@
|
|||||||
};
|
};
|
||||||
3E37F65F2E93FB6F00836187 /* Release */ = {
|
3E37F65F2E93FB6F00836187 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 4017B33DF440FA2BC612E06E /* Pods-Cable-CableUITestsScreenshot.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -399,16 +605,18 @@
|
|||||||
};
|
};
|
||||||
3E5C0BF12E72C0FE00247EC8 /* Debug */ = {
|
3E5C0BF12E72C0FE00247EC8 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = B5E79A38FD11ED9D9A21BB7E /* Pods-Cable.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 39;
|
||||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Cable/Info.plist;
|
INFOPLIST_FILE = Cable/Info.plist;
|
||||||
@@ -434,16 +642,18 @@
|
|||||||
};
|
};
|
||||||
3E5C0BF22E72C0FE00247EC8 /* Release */ = {
|
3E5C0BF22E72C0FE00247EC8 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = BED2A9D04FDB84725E0725E9 /* Pods-Cable.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 39;
|
||||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Cable/Info.plist;
|
INFOPLIST_FILE = Cable/Info.plist;
|
||||||
@@ -592,6 +802,7 @@
|
|||||||
};
|
};
|
||||||
3E5C0BF62E72C0FE00247EC8 /* Debug */ = {
|
3E5C0BF62E72C0FE00247EC8 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 0A51CE1631634DF868118C1B /* Pods-CableTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -611,6 +822,7 @@
|
|||||||
};
|
};
|
||||||
3E5C0BF72E72C0FE00247EC8 /* Release */ = {
|
3E5C0BF72E72C0FE00247EC8 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 10D59C9B7039F7390CB71DAA /* Pods-CableTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -630,6 +842,7 @@
|
|||||||
};
|
};
|
||||||
3E5C0BF92E72C0FE00247EC8 /* Debug */ = {
|
3E5C0BF92E72C0FE00247EC8 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 340C908BC5784DC053266DDB /* Pods-Cable-CableUITests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -647,6 +860,7 @@
|
|||||||
};
|
};
|
||||||
3E5C0BFA2E72C0FE00247EC8 /* Release */ = {
|
3E5C0BFA2E72C0FE00247EC8 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = F4D8F0C9760202BC765B4260 /* Pods-Cable-CableUITests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
|||||||
10
Cable.xcworkspace/contents.xcworkspacedata
generated
Normal file
10
Cable.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Cable.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
24
Cable/AppDelegate.swift
Normal file
24
Cable/AppDelegate.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// AppDelegate.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Created by Stefan Lange-Hegermann on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
NSLog("Launched")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"value" : {
|
||||||
"solid" : "display-p3:0.31765,0.56494,0.59766,1.00000"
|
"solid" : "display-p3:0.31765,0.56471,0.59608,1.00000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -154,26 +154,7 @@ struct BatteriesView: View {
|
|||||||
emptyState
|
emptyState
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
summarySection
|
batteriesListWithHeader
|
||||||
|
|
||||||
List {
|
|
||||||
ForEach(batteries) { battery in
|
|
||||||
Button {
|
|
||||||
onEdit(battery)
|
|
||||||
} label: {
|
|
||||||
batteryRow(for: battery)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(editMode == .active)
|
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
.onDelete(perform: onDelete)
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.environment(\.editMode, $editMode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -197,7 +178,13 @@ struct BatteriesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summarySection: some View {
|
private var batteryStatsHeader: some View {
|
||||||
|
StatsHeaderContainer {
|
||||||
|
batterySummaryContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batterySummaryContent: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
@@ -225,15 +212,47 @@ struct BatteriesView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.background(Color(.separator))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var batteriesListWithHeader: some View {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
baseBatteriesList
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
batteryStatsHeader
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseBatteriesList
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
batteryStatsHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baseBatteriesList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(batteries) { battery in
|
||||||
|
Button {
|
||||||
|
onEdit(battery)
|
||||||
|
} label: {
|
||||||
|
batteryRow(for: battery)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: onDelete)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
|
}
|
||||||
|
|
||||||
private func batteryRow(for battery: SavedBattery) -> some View {
|
private func batteryRow(for battery: SavedBattery) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct BatteryEditorView: View {
|
|||||||
@State private var minimumTemperatureInput: String = ""
|
@State private var minimumTemperatureInput: String = ""
|
||||||
@State private var maximumTemperatureInput: String = ""
|
@State private var maximumTemperatureInput: String = ""
|
||||||
@State private var showingAppearanceEditor = false
|
@State private var showingAppearanceEditor = false
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
@State private var hasActiveProSubscription = false
|
@State private var hasActiveProSubscription = false
|
||||||
let onSave: (BatteryConfiguration) -> Void
|
let onSave: (BatteryConfiguration) -> Void
|
||||||
|
|
||||||
@@ -532,7 +533,10 @@ struct BatteryEditorView: View {
|
|||||||
CableProPaywallView(isPresented: $showingProUpsell)
|
CableProPaywallView(isPresented: $showingProUpsell)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
|
}
|
||||||
|
.onReceive(storeKitManager.$status) { _ in
|
||||||
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
NSLocalizedString(
|
NSLocalizedString(
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import SwiftData
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct CableApp: App {
|
struct CableApp: App {
|
||||||
@StateObject private var unitSettings = UnitSystemSettings()
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@StateObject private var unitSettings: UnitSystemSettings
|
||||||
|
@StateObject private var storeKitManager: StoreKitManager
|
||||||
|
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
do {
|
do {
|
||||||
@@ -31,6 +33,9 @@ struct CableApp: App {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
let unitSettings = UnitSystemSettings()
|
||||||
|
_unitSettings = StateObject(wrappedValue: unitSettings)
|
||||||
|
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
|
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
|
||||||
#endif
|
#endif
|
||||||
@@ -40,6 +45,7 @@ struct CableApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(unitSettings)
|
.environmentObject(unitSettings)
|
||||||
|
.environmentObject(storeKitManager)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.modelContainer(sharedModelContainer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,34 +110,20 @@ struct ChargersView: View {
|
|||||||
emptyState
|
emptyState
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
summarySection
|
chargersListWithHeader
|
||||||
|
|
||||||
List {
|
|
||||||
ForEach(chargers) { charger in
|
|
||||||
Button {
|
|
||||||
onEdit(charger)
|
|
||||||
} label: {
|
|
||||||
chargerRow(for: charger)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(editMode == .active)
|
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
.onDelete(perform: onDelete)
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.environment(\.editMode, $editMode)
|
|
||||||
.accessibilityIdentifier("chargers-list")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summarySection: some View {
|
private var chargerStatsHeader: some View {
|
||||||
|
StatsHeaderContainer {
|
||||||
|
chargerSummaryContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chargerSummaryContent: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
@@ -157,18 +143,50 @@ struct ChargersView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.trailing, 16)
|
.padding(.horizontal, 2)
|
||||||
}
|
}
|
||||||
.scrollClipDisabled(true)
|
.scrollClipDisabled(false)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.background(Color(.separator))
|
|
||||||
.padding(.leading, 0)
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var chargersListWithHeader: some View {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
baseChargersList
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
chargerStatsHeader
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseChargersList
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
chargerStatsHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baseChargersList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(chargers) { charger in
|
||||||
|
Button {
|
||||||
|
onEdit(charger)
|
||||||
|
} label: {
|
||||||
|
chargerRow(for: charger)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: onDelete)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
|
.accessibilityIdentifier("chargers-list")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summaryMetrics: [SummaryMetric] {
|
private var summaryMetrics: [SummaryMetric] {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ struct CalculatorView: View {
|
|||||||
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
||||||
@State private var completedItemIDs: Set<String>
|
@State private var completedItemIDs: Set<String>
|
||||||
@State private var isAdvancedExpanded = false
|
@State private var isAdvancedExpanded = false
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
@State private var hasActiveProSubscription = false
|
@State private var hasActiveProSubscription = false
|
||||||
|
|
||||||
let savedLoad: SavedLoad?
|
let savedLoad: SavedLoad?
|
||||||
@@ -80,7 +81,10 @@ struct CalculatorView: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.task {
|
.task {
|
||||||
hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
|
}
|
||||||
|
.onReceive(storeKitManager.$status) { _ in
|
||||||
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import PostHog
|
||||||
|
|
||||||
struct LoadsView: View {
|
struct LoadsView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@@ -134,7 +135,7 @@ struct LoadsView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingSystemEditor = true
|
presentSystemEditor(source: "toolbar")
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -258,13 +259,20 @@ struct LoadsView: View {
|
|||||||
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
||||||
hasPresentedSystemEditorOnAppear = true
|
hasPresentedSystemEditorOnAppear = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
showingSystemEditor = true
|
presentSystemEditor(source: "auto")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
|
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
|
||||||
hasOpenedLoadOnAppear = true
|
hasOpenedLoadOnAppear = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Load Opened",
|
||||||
|
properties: [
|
||||||
|
"mode": loadToOpen.isWattMode ? "watt" : "amp",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = loadToOpen
|
newLoadToEdit = loadToOpen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,109 +295,116 @@ struct LoadsView: View {
|
|||||||
onSelectBatteries: { selectedComponentTab = .batteries },
|
onSelectBatteries: { selectedComponentTab = .batteries },
|
||||||
onSelectChargers: { selectedComponentTab = .chargers },
|
onSelectChargers: { selectedComponentTab = .chargers },
|
||||||
onCreateLoad: { createNewLoad() },
|
onCreateLoad: { createNewLoad() },
|
||||||
onBrowseLibrary: { showingComponentLibrary = true },
|
onBrowseLibrary: { openComponentLibrary(source: "overview") },
|
||||||
onShowBillOfMaterials: { showingSystemBOM = true },
|
onShowBillOfMaterials: { openBillOfMaterials() },
|
||||||
onCreateBattery: { startBatteryConfiguration() },
|
onCreateBattery: { startBatteryConfiguration() },
|
||||||
onCreateCharger: { startChargerConfiguration() }
|
onCreateCharger: { startChargerConfiguration() }
|
||||||
)
|
)
|
||||||
.accessibilityIdentifier("system-overview")
|
.accessibilityIdentifier("system-overview")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summarySection: some View {
|
private var loadsStatsHeader: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
StatsHeaderContainer {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
loadsSummaryContent
|
||||||
HStack(alignment: .firstTextBaseline) {
|
}
|
||||||
Text(loadsSummaryTitle)
|
}
|
||||||
.font(.headline.weight(.semibold))
|
|
||||||
Spacer()
|
private var loadsSummaryContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(loadsSummaryTitle)
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "square.stack.3d.up",
|
||||||
|
label: loadsCountLabel,
|
||||||
|
value: "\(savedLoads.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.fill",
|
||||||
|
label: loadsCurrentLabel,
|
||||||
|
value: formattedCurrent(totalCurrent),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: loadsPowerLabel,
|
||||||
|
value: formattedPower(totalPower),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ViewThatFits(in: .horizontal) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(spacing: 16) {
|
summaryMetric(
|
||||||
summaryMetric(
|
icon: "square.stack.3d.up",
|
||||||
icon: "square.stack.3d.up",
|
label: loadsCountLabel,
|
||||||
label: loadsCountLabel,
|
value: "\(savedLoads.count)",
|
||||||
value: "\(savedLoads.count)",
|
tint: .blue
|
||||||
tint: .blue
|
)
|
||||||
)
|
summaryMetric(
|
||||||
summaryMetric(
|
icon: "bolt.fill",
|
||||||
icon: "bolt.fill",
|
label: loadsCurrentLabel,
|
||||||
label: loadsCurrentLabel,
|
value: formattedCurrent(totalCurrent),
|
||||||
value: formattedCurrent(totalCurrent),
|
tint: .orange
|
||||||
tint: .orange
|
)
|
||||||
)
|
summaryMetric(
|
||||||
summaryMetric(
|
icon: "gauge.medium",
|
||||||
icon: "gauge.medium",
|
label: loadsPowerLabel,
|
||||||
label: loadsPowerLabel,
|
value: formattedPower(totalPower),
|
||||||
value: formattedPower(totalPower),
|
tint: .green
|
||||||
tint: .green
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
summaryMetric(
|
|
||||||
icon: "square.stack.3d.up",
|
|
||||||
label: loadsCountLabel,
|
|
||||||
value: "\(savedLoads.count)",
|
|
||||||
tint: .blue
|
|
||||||
)
|
|
||||||
summaryMetric(
|
|
||||||
icon: "bolt.fill",
|
|
||||||
label: loadsCurrentLabel,
|
|
||||||
value: formattedCurrent(totalCurrent),
|
|
||||||
tint: .orange
|
|
||||||
)
|
|
||||||
summaryMetric(
|
|
||||||
icon: "gauge.medium",
|
|
||||||
label: loadsPowerLabel,
|
|
||||||
value: formattedPower(totalPower),
|
|
||||||
tint: .green
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let status = loadStatus {
|
|
||||||
Button {
|
|
||||||
activeStatus = status
|
|
||||||
} label: {
|
|
||||||
statusBanner(for: status)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
|
|
||||||
Divider()
|
if let status = loadStatus {
|
||||||
.background(Color(.separator))
|
Button {
|
||||||
|
activeStatus = status
|
||||||
libraryButton
|
} label: {
|
||||||
.padding(.trailing, 16)
|
statusBanner(for: status)
|
||||||
.padding(.bottom, 6)
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var libraryButton: some View {
|
private var libraryButton: some View {
|
||||||
Button {
|
Button {
|
||||||
showingComponentLibrary = true
|
openComponentLibrary(source: "library-button")
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Group {
|
||||||
String(
|
if #available(iOS 26.0, *) {
|
||||||
localized: "loads.library.button",
|
libraryButtonLabel
|
||||||
bundle: .main,
|
.padding(.horizontal, 18)
|
||||||
comment: "Button title to open component library"
|
.padding(.vertical, 12)
|
||||||
),
|
.glassEffect(.regular, in: .capsule)
|
||||||
systemImage: "books.vertical"
|
} else {
|
||||||
)
|
libraryButtonLabel
|
||||||
.font(.footnote.weight(.semibold))
|
.padding(.horizontal, 14)
|
||||||
.padding(.horizontal, 14)
|
.padding(.vertical, 10)
|
||||||
.padding(.vertical, 10)
|
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
|
||||||
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
|
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
|
}
|
||||||
|
|
||||||
|
private var libraryButtonLabel: some View {
|
||||||
|
Label(
|
||||||
|
String(
|
||||||
|
localized: "loads.library.button",
|
||||||
|
bundle: .main,
|
||||||
|
comment: "Button title to open component library"
|
||||||
|
),
|
||||||
|
systemImage: "books.vertical"
|
||||||
|
)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var componentsTab: some View {
|
private var componentsTab: some View {
|
||||||
@@ -398,37 +413,69 @@ struct LoadsView: View {
|
|||||||
OnboardingInfoView(
|
OnboardingInfoView(
|
||||||
configuration: .loads(),
|
configuration: .loads(),
|
||||||
onPrimaryAction: { createNewLoad() },
|
onPrimaryAction: { createNewLoad() },
|
||||||
onSecondaryAction: { showingComponentLibrary = true }
|
onSecondaryAction: { openComponentLibrary(source: "components-onboarding") }
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 0)
|
.padding(.horizontal, 0)
|
||||||
} else {
|
} else {
|
||||||
summarySection
|
loadsListWithHeader
|
||||||
|
|
||||||
List {
|
|
||||||
ForEach(savedLoads) { load in
|
|
||||||
Button {
|
|
||||||
selectLoad(load)
|
|
||||||
} label: {
|
|
||||||
loadRow(for: load)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(editMode == .active)
|
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
.onDelete(perform: deleteLoads)
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.accessibilityIdentifier("loads-list")
|
|
||||||
.environment(\.editMode, $editMode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loadsListWithHeader: some View {
|
||||||
|
Group {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
baseLoadsList
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
loadsStatsHeader
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseLoadsList
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
loadsStatsHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
libraryButton
|
||||||
|
.padding(.trailing, 24)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baseLoadsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(savedLoads) { load in
|
||||||
|
Button {
|
||||||
|
selectLoad(load)
|
||||||
|
} label: {
|
||||||
|
loadRow(for: load)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteLoads)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.accessibilityIdentifier("loads-list")
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
|
}
|
||||||
|
|
||||||
private func selectLoad(_ load: SavedLoad) {
|
private func selectLoad(_ load: SavedLoad) {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Load Opened",
|
||||||
|
properties: [
|
||||||
|
"mode": load.isWattMode ? "watt" : "amp",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = load
|
newLoadToEdit = load
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,14 +746,6 @@ struct LoadsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteLoads(offsets: IndexSet) {
|
|
||||||
withAnimation {
|
|
||||||
for index in offsets {
|
|
||||||
modelContext.delete(savedLoads[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePrimaryAction() {
|
private func handlePrimaryAction() {
|
||||||
switch selectedComponentTab {
|
switch selectedComponentTab {
|
||||||
case .overview:
|
case .overview:
|
||||||
@@ -720,6 +759,54 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentSystemEditor(source: String) {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"System Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": source,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
showingSystemEditor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openComponentLibrary(source: String) {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Component Library Opened",
|
||||||
|
properties: [
|
||||||
|
"source": source,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
showingComponentLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openBillOfMaterials() {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Bill Of Materials Opened",
|
||||||
|
properties: [
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
showingSystemBOM = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteLoads(offsets: IndexSet) {
|
||||||
|
let loadsToDelete = offsets.map { savedLoads[$0] }
|
||||||
|
withAnimation {
|
||||||
|
for load in loadsToDelete {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Load Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": load.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
modelContext.delete(load)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func createNewLoad() {
|
private func createNewLoad() {
|
||||||
let newLoad = SystemComponentsPersistence.createDefaultLoad(
|
let newLoad = SystemComponentsPersistence.createDefaultLoad(
|
||||||
for: system,
|
for: system,
|
||||||
@@ -728,10 +815,24 @@ struct LoadsView: View {
|
|||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
existingChargers: savedChargers
|
existingChargers: savedChargers
|
||||||
)
|
)
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Load Created",
|
||||||
|
properties: [
|
||||||
|
"name": newLoad.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = newLoad
|
newLoadToEdit = newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startBatteryConfiguration() {
|
private func startBatteryConfiguration() {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Battery Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "create",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
||||||
for: system,
|
for: system,
|
||||||
existingLoads: savedLoads,
|
existingLoads: savedLoads,
|
||||||
@@ -741,20 +842,46 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveBattery(_ configuration: BatteryConfiguration) {
|
private func saveBattery(_ configuration: BatteryConfiguration) {
|
||||||
|
let isExisting = savedBatteries.contains { $0.id == configuration.id }
|
||||||
SystemComponentsPersistence.saveBattery(
|
SystemComponentsPersistence.saveBattery(
|
||||||
configuration,
|
configuration,
|
||||||
for: system,
|
for: system,
|
||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
in: modelContext
|
in: modelContext
|
||||||
)
|
)
|
||||||
|
let eventName = isExisting ? "Battery Updated" : "Battery Created"
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
eventName,
|
||||||
|
properties: [
|
||||||
|
"name": configuration.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func editBattery(_ battery: SavedBattery) {
|
private func editBattery(_ battery: SavedBattery) {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Battery Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "edit",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
|
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteBatteries(_ offsets: IndexSet) {
|
private func deleteBatteries(_ offsets: IndexSet) {
|
||||||
|
let batteriesToDelete = offsets.map { savedBatteries[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
for battery in batteriesToDelete {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Battery Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": battery.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
SystemComponentsPersistence.deleteBatteries(
|
SystemComponentsPersistence.deleteBatteries(
|
||||||
at: offsets,
|
at: offsets,
|
||||||
from: savedBatteries,
|
from: savedBatteries,
|
||||||
@@ -764,6 +891,13 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startChargerConfiguration() {
|
private func startChargerConfiguration() {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Charger Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "create",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
|
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
|
||||||
for: system,
|
for: system,
|
||||||
existingLoads: savedLoads,
|
existingLoads: savedLoads,
|
||||||
@@ -773,20 +907,46 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveCharger(_ configuration: ChargerConfiguration) {
|
private func saveCharger(_ configuration: ChargerConfiguration) {
|
||||||
|
let isExisting = savedChargers.contains { $0.id == configuration.id }
|
||||||
SystemComponentsPersistence.saveCharger(
|
SystemComponentsPersistence.saveCharger(
|
||||||
configuration,
|
configuration,
|
||||||
for: system,
|
for: system,
|
||||||
existingChargers: savedChargers,
|
existingChargers: savedChargers,
|
||||||
in: modelContext
|
in: modelContext
|
||||||
)
|
)
|
||||||
|
let eventName = isExisting ? "Charger Updated" : "Charger Created"
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
eventName,
|
||||||
|
properties: [
|
||||||
|
"name": configuration.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func editCharger(_ charger: SavedCharger) {
|
private func editCharger(_ charger: SavedCharger) {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Charger Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "edit",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
chargerDraft = ChargerConfiguration(savedCharger: charger, system: system)
|
chargerDraft = ChargerConfiguration(savedCharger: charger, system: system)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteChargers(_ offsets: IndexSet) {
|
private func deleteChargers(_ offsets: IndexSet) {
|
||||||
|
let chargersToDelete = offsets.map { savedChargers[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
for charger in chargersToDelete {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Charger Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": charger.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
SystemComponentsPersistence.deleteChargers(
|
SystemComponentsPersistence.deleteChargers(
|
||||||
at: offsets,
|
at: offsets,
|
||||||
from: savedChargers,
|
from: savedChargers,
|
||||||
@@ -804,6 +964,14 @@ struct LoadsView: View {
|
|||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
existingChargers: savedChargers
|
existingChargers: savedChargers
|
||||||
)
|
)
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Library Load Added",
|
||||||
|
properties: [
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.localizedName,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = newLoad
|
newLoadToEdit = newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -592,6 +592,16 @@ struct SystemOverviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var totalAverageLoadPower: Double {
|
||||||
|
loads.reduce(0) { result, load in
|
||||||
|
let power = max(load.power, 0)
|
||||||
|
guard power > 0 else { return result }
|
||||||
|
let dutyCycleFraction = max(min(load.dutyCyclePercent, 100), 0) / 100
|
||||||
|
let usageFraction = max(min(load.dailyUsageHours, 24), 0) / 24
|
||||||
|
return result + power * dutyCycleFraction * usageFraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var totalCapacity: Double {
|
private var totalCapacity: Double {
|
||||||
batteries.reduce(0) { result, battery in
|
batteries.reduce(0) { result, battery in
|
||||||
result + battery.capacityAmpHours
|
result + battery.capacityAmpHours
|
||||||
@@ -714,9 +724,11 @@ struct SystemOverviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var completedBOMItemCount: Int {
|
private var completedBOMItemCount: Int {
|
||||||
settledLoads.reduce(into: Set<String>()) { partialResult, load in
|
settledLoads.reduce(0) { result, load in
|
||||||
load.bomCompletedItemIDs.forEach { partialResult.insert($0) }
|
let uniqueItems = Set(load.bomCompletedItemIDs)
|
||||||
}.count
|
let cappedCount = min(uniqueItems.count, Self.bomItemsPerLoad)
|
||||||
|
return result + cappedCount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var bomItemsCount: Int {
|
private var bomItemsCount: Int {
|
||||||
@@ -794,8 +806,8 @@ struct SystemOverviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var estimatedRuntimeHours: Double? {
|
private var estimatedRuntimeHours: Double? {
|
||||||
guard totalPower > 0, totalUsableEnergy > 0 else { return nil }
|
guard totalAverageLoadPower > 0, totalUsableEnergy > 0 else { return nil }
|
||||||
let hours = totalUsableEnergy / totalPower
|
let hours = totalUsableEnergy / totalAverageLoadPower
|
||||||
return hours.isFinite && hours > 0 ? hours : nil
|
return hours.isFinite && hours > 0 ? hours : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ final class CableProPaywallViewModel: ObservableObject {
|
|||||||
for await result in Transaction.currentEntitlements {
|
for await result in Transaction.currentEntitlements {
|
||||||
switch result {
|
switch result {
|
||||||
case .verified(let transaction):
|
case .verified(let transaction):
|
||||||
unlocked.insert(transaction.productID)
|
if productIdentifiers.contains(transaction.productID) {
|
||||||
|
unlocked.insert(transaction.productID)
|
||||||
|
}
|
||||||
case .unverified:
|
case .unverified:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -132,14 +134,12 @@ struct CableProPaywallView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@EnvironmentObject private var unitSettings: UnitSystemSettings
|
@EnvironmentObject private var unitSettings: UnitSystemSettings
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
|
|
||||||
@StateObject private var viewModel: CableProPaywallViewModel
|
@StateObject private var viewModel: CableProPaywallViewModel
|
||||||
@State private var alertInfo: PaywallAlert?
|
@State private var alertInfo: PaywallAlert?
|
||||||
|
|
||||||
private static let defaultProductIds = [
|
private static let defaultProductIds = StoreKitManager.subscriptionProductIDs
|
||||||
"app.voltplan.cable.weekly",
|
|
||||||
"app.voltplan.cable.yearly"
|
|
||||||
]
|
|
||||||
|
|
||||||
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
|
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
|
||||||
_isPresented = isPresented
|
_isPresented = isPresented
|
||||||
@@ -168,9 +168,12 @@ struct CableProPaywallView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadProducts(force: true)
|
await viewModel.loadProducts(force: true)
|
||||||
unitSettings.isProUnlocked = !viewModel.purchasedProductIDs.isEmpty
|
await storeKitManager.refreshEntitlements()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadProducts(force: true)
|
||||||
|
await storeKitManager.refreshEntitlements()
|
||||||
}
|
}
|
||||||
.refreshable { await viewModel.loadProducts(force: true) }
|
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.alert) { newValue in
|
.onChange(of: viewModel.alert) { newValue in
|
||||||
alertInfo = newValue
|
alertInfo = newValue
|
||||||
@@ -186,7 +189,7 @@ struct CableProPaywallView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.purchasedProductIDs) { newValue in
|
.onChange(of: viewModel.purchasedProductIDs) { newValue in
|
||||||
unitSettings.isProUnlocked = !newValue.isEmpty
|
Task { await storeKitManager.refreshEntitlements() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,5 +538,9 @@ struct PaywallAlert: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CableProPaywallView(isPresented: .constant(true))
|
let unitSettings = UnitSystemSettings()
|
||||||
|
let manager = StoreKitManager(unitSettings: unitSettings)
|
||||||
|
return CableProPaywallView(isPresented: .constant(true))
|
||||||
|
.environmentObject(unitSettings)
|
||||||
|
.environmentObject(manager)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,14 @@
|
|||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
import StoreKit
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
@State private var showingProPaywall = false
|
@State private var showingProPaywall = false
|
||||||
@State private var isLoadingProStatus = true
|
|
||||||
@State private var proStatus: ProSubscriptionStatus?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -75,29 +72,28 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await loadProStatus() }
|
|
||||||
.sheet(isPresented: $showingProPaywall) {
|
.sheet(isPresented: $showingProPaywall) {
|
||||||
CableProPaywallView(isPresented: $showingProPaywall)
|
CableProPaywallView(isPresented: $showingProPaywall)
|
||||||
}
|
}
|
||||||
.onChange(of: showingProPaywall) { isPresented in
|
.onChange(of: showingProPaywall) { isPresented in
|
||||||
if !isPresented {
|
if !isPresented {
|
||||||
Task { await loadProStatus() }
|
Task { await storeKitManager.refreshEntitlements() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await loadProStatus() }
|
Task { await storeKitManager.refreshEntitlements() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var proSectionContent: some View {
|
private var proSectionContent: some View {
|
||||||
if isLoadingProStatus {
|
if storeKitManager.isRefreshing && storeKitManager.status == nil {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else if let status = proStatus {
|
} else if let status = storeKitManager.status {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label(status.displayName, systemImage: "checkmark.seal.fill")
|
Label(status.displayName, systemImage: "checkmark.seal.fill")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -114,6 +110,18 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status.isInGracePeriod {
|
||||||
|
Text(localizedString("settings.pro.grace_period", defaultValue: "We're retrying your last payment; access remains during the grace period."))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let isAutoRenewEnabled = status.isAutoRenewEnabled, !isAutoRenewEnabled {
|
||||||
|
Text(localizedString("settings.pro.autorenew.off", defaultValue: "Auto-renew is off—consider renewing to keep Cable PRO."))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
|
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -149,15 +157,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadProStatus() async {
|
|
||||||
isLoadingProStatus = true
|
|
||||||
defer { isLoadingProStatus = false }
|
|
||||||
let status = await SettingsView.fetchProStatus()
|
|
||||||
proStatus = status
|
|
||||||
unitSettings.isProUnlocked = status != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func renewalText(for date: Date) -> String {
|
private func renewalText(for date: Date) -> String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateStyle = .medium
|
formatter.dateStyle = .medium
|
||||||
@@ -168,7 +167,7 @@ struct SettingsView: View {
|
|||||||
return String(format: template, dateString)
|
return String(format: template, dateString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func trialMessage(for status: ProSubscriptionStatus) -> String? {
|
private func trialMessage(for status: StoreKitManager.SubscriptionStatus) -> String? {
|
||||||
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
|
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
|
||||||
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
|
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
|
||||||
if days > 0 {
|
if days > 0 {
|
||||||
@@ -199,48 +198,15 @@ struct SettingsView: View {
|
|||||||
return formatter.string(from: NSNumber(value: value)) ?? String(value)
|
return formatter.string(from: NSNumber(value: value)) ?? String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func fetchProStatus() async -> ProSubscriptionStatus? {
|
|
||||||
let productIDs = Set(["app.voltplan.cable.weekly", "app.voltplan.cable.yearly"])
|
|
||||||
for await result in Transaction.currentEntitlements {
|
|
||||||
guard case .verified(let transaction) = result,
|
|
||||||
productIDs.contains(transaction.productID) else { continue }
|
|
||||||
|
|
||||||
let product = try? await Product.products(for: [transaction.productID]).first
|
|
||||||
let displayName = product?.displayName ?? transaction.productID
|
|
||||||
let renewalDate = transaction.expirationDate
|
|
||||||
|
|
||||||
let hasIntroOffer = transaction.offerType == .introductory
|
|
||||||
let paymentMode = product?.subscription?.introductoryOffer?.paymentMode
|
|
||||||
let isInTrial = hasIntroOffer && paymentMode == .freeTrial
|
|
||||||
let trialEndDate = isInTrial ? transaction.expirationDate : nil
|
|
||||||
|
|
||||||
return ProSubscriptionStatus(
|
|
||||||
productId: transaction.productID,
|
|
||||||
displayName: displayName,
|
|
||||||
renewalDate: renewalDate,
|
|
||||||
isInTrial: isInTrial,
|
|
||||||
trialEndDate: trialEndDate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func localizedString(_ key: String, defaultValue: String) -> String {
|
private func localizedString(_ key: String, defaultValue: String) -> String {
|
||||||
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
|
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProSubscriptionStatus {
|
|
||||||
let productId: String
|
|
||||||
let displayName: String
|
|
||||||
let renewalDate: Date?
|
|
||||||
let isInTrial: Bool
|
|
||||||
let trialEndDate: Date?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Settings (Default)") {
|
#Preview("Settings (Default)") {
|
||||||
let settings = UnitSystemSettings()
|
let settings = UnitSystemSettings()
|
||||||
|
let manager = StoreKitManager(unitSettings: settings)
|
||||||
return SettingsView()
|
return SettingsView()
|
||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
|
.environmentObject(manager)
|
||||||
}
|
}
|
||||||
|
|||||||
48
Cable/StatsHeaderContainer.swift
Normal file
48
Cable/StatsHeaderContainer.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Reusable wrapper that applies the system overview stats card styling to a header view.
|
||||||
|
struct StatsHeaderContainer<Content: View>: View {
|
||||||
|
private let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
card
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
} else {
|
||||||
|
card
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.15))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var card: some View {
|
||||||
|
content
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(Color(red: 81 / 255, green: 144 / 255, blue: 152 / 255).opacity(0.12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
221
Cable/StoreKitManager.swift
Normal file
221
Cable/StoreKitManager.swift
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import Foundation
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class StoreKitManager: ObservableObject {
|
||||||
|
struct SubscriptionStatus: Equatable {
|
||||||
|
let productId: String
|
||||||
|
let displayName: String
|
||||||
|
let renewalDate: Date?
|
||||||
|
let isInTrial: Bool
|
||||||
|
let trialEndDate: Date?
|
||||||
|
let isInGracePeriod: Bool
|
||||||
|
let isAutoRenewEnabled: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static let subscriptionProductIDs: [String] = [
|
||||||
|
"app.voltplan.cable.weekly",
|
||||||
|
"app.voltplan.cable.yearly"
|
||||||
|
]
|
||||||
|
|
||||||
|
@Published private(set) var status: SubscriptionStatus?
|
||||||
|
@Published private(set) var isRefreshing = false
|
||||||
|
|
||||||
|
var isProUnlocked: Bool {
|
||||||
|
status != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private let productIDs: Set<String>
|
||||||
|
private weak var unitSettings: UnitSystemSettings?
|
||||||
|
private var updatesTask: Task<Void, Never>?
|
||||||
|
private var productCache: [String: Product] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
productIDs: [String] = StoreKitManager.subscriptionProductIDs,
|
||||||
|
unitSettings: UnitSystemSettings? = nil
|
||||||
|
) {
|
||||||
|
self.productIDs = Set(productIDs)
|
||||||
|
self.unitSettings = unitSettings
|
||||||
|
|
||||||
|
updatesTask = Task { [weak self] in
|
||||||
|
await self?.observeTransactionUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.finishUnfinishedTransactions()
|
||||||
|
await self?.refreshEntitlements()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachUnitSettings(_ settings: UnitSystemSettings) {
|
||||||
|
unitSettings = settings
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.refreshEntitlements()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshEntitlements() async {
|
||||||
|
guard !isRefreshing else { return }
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
defer { isRefreshing = false }
|
||||||
|
|
||||||
|
let resolvedStatus = await loadCurrentStatus()
|
||||||
|
status = resolvedStatus
|
||||||
|
unitSettings?.isProUnlocked = resolvedStatus != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCurrentStatus() async -> SubscriptionStatus? {
|
||||||
|
if let entitlementStatus = await statusFromCurrentEntitlements() {
|
||||||
|
return entitlementStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
return await statusFromLatestTransactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusFromCurrentEntitlements() async -> SubscriptionStatus? {
|
||||||
|
var newestTransaction: StoreKit.Transaction?
|
||||||
|
|
||||||
|
for await result in StoreKit.Transaction.currentEntitlements {
|
||||||
|
guard case .verified(let transaction) = result,
|
||||||
|
productIDs.contains(transaction.productID),
|
||||||
|
transaction.revocationDate == nil,
|
||||||
|
!isExpired(transaction) else { continue }
|
||||||
|
|
||||||
|
if let existing = newestTransaction {
|
||||||
|
let existingExpiration = existing.expirationDate ?? .distantPast
|
||||||
|
let candidateExpiration = transaction.expirationDate ?? .distantPast
|
||||||
|
if candidateExpiration > existingExpiration {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let activeTransaction = newestTransaction else { return nil }
|
||||||
|
return await status(for: activeTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusFromLatestTransactions() async -> SubscriptionStatus? {
|
||||||
|
var newestTransaction: StoreKit.Transaction?
|
||||||
|
|
||||||
|
for productID in productIDs {
|
||||||
|
guard let latestResult = await StoreKit.Transaction.latest(for: productID) else { continue }
|
||||||
|
guard case .verified(let transaction) = latestResult,
|
||||||
|
transaction.revocationDate == nil,
|
||||||
|
!isExpired(transaction) else { continue }
|
||||||
|
|
||||||
|
if let existing = newestTransaction {
|
||||||
|
let existingExpiration = existing.expirationDate ?? .distantPast
|
||||||
|
let candidateExpiration = transaction.expirationDate ?? .distantPast
|
||||||
|
if candidateExpiration > existingExpiration {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let activeTransaction = newestTransaction else { return nil }
|
||||||
|
return await status(for: activeTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeTransactionUpdates() async {
|
||||||
|
for await result in StoreKit.Transaction.updates {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .verified(let transaction):
|
||||||
|
await transaction.finish()
|
||||||
|
await refreshEntitlements()
|
||||||
|
case .unverified:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishUnfinishedTransactions() async {
|
||||||
|
for await result in StoreKit.Transaction.unfinished {
|
||||||
|
guard case .verified(let transaction) = result else { continue }
|
||||||
|
await transaction.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func status(for transaction: StoreKit.Transaction) async -> SubscriptionStatus? {
|
||||||
|
let product = await product(for: transaction.productID)
|
||||||
|
let displayName = product?.displayName ?? transaction.productID
|
||||||
|
|
||||||
|
var isInGracePeriod = false
|
||||||
|
var isAutoRenewEnabled: Bool?
|
||||||
|
var isInTrial = false
|
||||||
|
var trialEndDate: Date?
|
||||||
|
|
||||||
|
if let currentStatus = await transaction.subscriptionStatus {
|
||||||
|
if currentStatus.state == .inGracePeriod {
|
||||||
|
isInGracePeriod = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .verified(let renewalInfo) = currentStatus.renewalInfo {
|
||||||
|
isAutoRenewEnabled = renewalInfo.willAutoRenew
|
||||||
|
|
||||||
|
if renewalInfo.gracePeriodExpirationDate != nil {
|
||||||
|
isInGracePeriod = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
|
||||||
|
if let offer = renewalInfo.offer, offer.type == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = transaction.expirationDate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#if compiler(>=5.3)
|
||||||
|
if renewalInfo.offerType == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = transaction.expirationDate
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} else if case .verified(let statusTransaction) = currentStatus.transaction {
|
||||||
|
if let offer = statusTransaction.offer, offer.type == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = statusTransaction.expirationDate ?? transaction.expirationDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let offer = transaction.offer, offer.type == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = transaction.expirationDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return SubscriptionStatus(
|
||||||
|
productId: transaction.productID,
|
||||||
|
displayName: displayName,
|
||||||
|
renewalDate: transaction.expirationDate,
|
||||||
|
isInTrial: isInTrial,
|
||||||
|
trialEndDate: trialEndDate,
|
||||||
|
isInGracePeriod: isInGracePeriod,
|
||||||
|
isAutoRenewEnabled: isAutoRenewEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isExpired(_ transaction: StoreKit.Transaction) -> Bool {
|
||||||
|
if let expirationDate = transaction.expirationDate {
|
||||||
|
return expirationDate < Date()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func product(for id: String) async -> Product? {
|
||||||
|
if let cached = productCache[id] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let product = try? await Product.products(for: [id]).first else { return nil }
|
||||||
|
productCache[id] = product
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PostHog
|
||||||
|
|
||||||
struct SystemsOnboardingView: View {
|
struct SystemsOnboardingView: View {
|
||||||
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
|
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
|
||||||
@@ -92,6 +93,9 @@ struct SystemsOnboardingView: View {
|
|||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
.onAppear(perform: resetState)
|
.onAppear(perform: resetState)
|
||||||
.onReceive(timer) { _ in advanceCarousel() }
|
.onReceive(timer) { _ in advanceCarousel() }
|
||||||
|
.task {
|
||||||
|
PostHogSDK.shared.capture("Launched")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetState() {
|
private func resetState() {
|
||||||
@@ -102,6 +106,7 @@ struct SystemsOnboardingView: View {
|
|||||||
private func createSystem() {
|
private func createSystem() {
|
||||||
isFieldFocused = false
|
isFieldFocused = false
|
||||||
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
PostHogSDK.shared.capture("System Created", properties: ["name": trimmed])
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
onCreate(trimmed)
|
onCreate(trimmed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import PostHog
|
||||||
|
|
||||||
struct SystemsView: View {
|
struct SystemsView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@@ -107,6 +108,17 @@ struct SystemsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
.simultaneousGesture(
|
||||||
|
TapGesture().onEnded {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"System Opened",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "list"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteSystems)
|
.onDelete(perform: deleteSystems)
|
||||||
}
|
}
|
||||||
@@ -117,7 +129,7 @@ struct SystemsView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button {
|
Button {
|
||||||
showingSettings = true
|
openSettings()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
}
|
}
|
||||||
@@ -125,6 +137,7 @@ struct SystemsView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
PostHogSDK.shared.capture("System Create Navigation")
|
||||||
createNewSystem()
|
createNewSystem()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
@@ -161,14 +174,43 @@ struct SystemsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openSettings() {
|
||||||
|
PostHogSDK.shared.capture("Settings Opened")
|
||||||
|
showingSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
private func createNewSystem() {
|
private func createNewSystem() {
|
||||||
let system = makeSystem()
|
let system = makeSystem()
|
||||||
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
|
PostHogSDK.shared.capture(
|
||||||
|
"System Created",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "toolbar"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: true,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "created"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createNewSystem(named name: String) {
|
private func createNewSystem(named name: String) {
|
||||||
let system = makeSystem(preferredName: name)
|
let system = makeSystem(preferredName: name)
|
||||||
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
|
PostHogSDK.shared.capture(
|
||||||
|
"System Created",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "named"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: true,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "created-named"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createOnboardingSystem(named name: String) {
|
private func createOnboardingSystem(named name: String) {
|
||||||
@@ -176,10 +218,29 @@ struct SystemsView: View {
|
|||||||
preferredName: name,
|
preferredName: name,
|
||||||
colorName: randomSystemColorName()
|
colorName: randomSystemColorName()
|
||||||
)
|
)
|
||||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "onboarding"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
|
private func navigateToSystem(
|
||||||
|
_ system: ElectricalSystem,
|
||||||
|
presentSystemEditor: Bool,
|
||||||
|
loadToOpen: SavedLoad?,
|
||||||
|
animated: Bool = true,
|
||||||
|
source: String = "programmatic"
|
||||||
|
) {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"System Opened",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": source,
|
||||||
|
"loads": loads(for: system).count
|
||||||
|
]
|
||||||
|
)
|
||||||
let target = SystemNavigationTarget(
|
let target = SystemNavigationTarget(
|
||||||
system: system,
|
system: system,
|
||||||
presentSystemEditor: presentSystemEditor,
|
presentSystemEditor: presentSystemEditor,
|
||||||
@@ -228,13 +289,40 @@ struct SystemsView: View {
|
|||||||
hasPerformedInitialAutoNavigation = true
|
hasPerformedInitialAutoNavigation = true
|
||||||
|
|
||||||
guard systems.count == 1, let system = systems.first else { return }
|
guard systems.count == 1, let system = systems.first else { return }
|
||||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil, animated: false)
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: nil,
|
||||||
|
animated: false,
|
||||||
|
source: "auto"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
||||||
let system = makeSystem()
|
let system = makeSystem()
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"System Created",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "library"
|
||||||
|
]
|
||||||
|
)
|
||||||
let load = createLoad(from: item, in: system)
|
let load = createLoad(from: item, in: system)
|
||||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
|
PostHogSDK.shared.capture(
|
||||||
|
"Library Load Added",
|
||||||
|
properties: [
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.localizedName,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: load,
|
||||||
|
animated: false,
|
||||||
|
source: "library"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
||||||
@@ -306,9 +394,16 @@ struct SystemsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func deleteSystems(offsets: IndexSet) {
|
private func deleteSystems(offsets: IndexSet) {
|
||||||
|
let systemsToDelete = offsets.map { systems[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for index in offsets {
|
for system in systemsToDelete {
|
||||||
let system = systems[index]
|
PostHogSDK.shared.capture(
|
||||||
|
"System Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"loads": loads(for: system).count
|
||||||
|
]
|
||||||
|
)
|
||||||
deleteLoads(for: system)
|
deleteLoads(for: system)
|
||||||
modelContext.delete(system)
|
modelContext.delete(system)
|
||||||
}
|
}
|
||||||
@@ -319,6 +414,14 @@ struct SystemsView: View {
|
|||||||
let descriptor = FetchDescriptor<SavedLoad>()
|
let descriptor = FetchDescriptor<SavedLoad>()
|
||||||
if let loads = try? modelContext.fetch(descriptor) {
|
if let loads = try? modelContext.fetch(descriptor) {
|
||||||
for load in loads where load.system == system {
|
for load in loads where load.system == system {
|
||||||
|
PostHogSDK.shared.capture(
|
||||||
|
"Load Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": load.name,
|
||||||
|
"system": system.name,
|
||||||
|
"source": "system-delete"
|
||||||
|
]
|
||||||
|
)
|
||||||
modelContext.delete(load)
|
modelContext.delete(load)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
Podfile
Normal file
23
Podfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Uncomment the next line to define a global platform for your project
|
||||||
|
# platform :ios, '9.0'
|
||||||
|
platform :ios, '17.6'
|
||||||
|
target 'Cable' do
|
||||||
|
# Comment the next line if you don't want to use dynamic frameworks
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
# Pods for Cable
|
||||||
|
pod "PostHog", "~> 3.0"
|
||||||
|
target 'CableTests' do
|
||||||
|
inherit! :search_paths
|
||||||
|
# Pods for testing
|
||||||
|
end
|
||||||
|
|
||||||
|
target 'CableUITests' do
|
||||||
|
# Pods for testing
|
||||||
|
end
|
||||||
|
|
||||||
|
target 'CableUITestsScreenshot' do
|
||||||
|
# Pods for testing
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
16
Podfile.lock
Normal file
16
Podfile.lock
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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
Normal file
16
Pods/Manifest.lock
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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
Normal file
2254
Pods/Pods.xcodeproj/project.pbxproj
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?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>
|
||||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-Cable.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-Cable.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?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>
|
||||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-CableTests.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-CableTests.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?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>
|
||||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog-PostHog.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog-PostHog.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?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>
|
||||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?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>
|
||||||
41
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/xcschememanagement.plist
generated
Normal file
41
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/xcschememanagement.plist
generated
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?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
Normal file
21
Pods/PostHog/LICENSE
generated
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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.
|
||||||
199
Pods/PostHog/PostHog/App Life Cycle/ApplicationLifecyclePublisher.swift
generated
Normal file
199
Pods/PostHog/PostHog/App Life Cycle/ApplicationLifecyclePublisher.swift
generated
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Pods/PostHog/PostHog/App Life Cycle/PostHogAppLifeCycleIntegration.swift
generated
Normal file
214
Pods/PostHog/PostHog/App Life Cycle/PostHogAppLifeCycleIntegration.swift
generated
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
159
Pods/PostHog/PostHog/ApplicationViewLayoutPublisher.swift
generated
Normal file
159
Pods/PostHog/PostHog/ApplicationViewLayoutPublisher.swift
generated
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
14
Pods/PostHog/PostHog/Autocapture/AutocaptureEventProcessing.swift
generated
Normal file
14
Pods/PostHog/PostHog/Autocapture/AutocaptureEventProcessing.swift
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
71
Pods/PostHog/PostHog/Autocapture/ForwardingPickerViewDelegate.swift
generated
Normal file
71
Pods/PostHog/PostHog/Autocapture/ForwardingPickerViewDelegate.swift
generated
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
606
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift
generated
Normal file
606
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift
generated
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
147
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureIntegration.swift
generated
Normal file
147
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureIntegration.swift
generated
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
156
Pods/PostHog/PostHog/Autocapture/SwiftUI/View+PostHogLabel.swift
generated
Normal file
156
Pods/PostHog/PostHog/Autocapture/SwiftUI/View+PostHogLabel.swift
generated
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
29
Pods/PostHog/PostHog/Autocapture/UIView+PostHogLabel.swift
generated
Normal file
29
Pods/PostHog/PostHog/Autocapture/UIView+PostHogLabel.swift
generated
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
28
Pods/PostHog/PostHog/DI.swift
generated
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
102
Pods/PostHog/PostHog/Models/PostHogEvent.swift
generated
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
122
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey+Display.swift
generated
Normal file
122
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey+Display.swift
generated
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#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
|
||||||
46
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey.swift
generated
Normal file
46
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey.swift
generated
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//
|
||||||
|
// 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?
|
||||||
|
}
|
||||||
38
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyAppearance.swift
generated
Normal file
38
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyAppearance.swift
generated
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// 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?
|
||||||
|
}
|
||||||
47
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyConditions.swift
generated
Normal file
47
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyConditions.swift
generated
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
280
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyEnums.swift
generated
Normal file
280
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyEnums.swift
generated
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyQuestion.swift
generated
Normal file
247
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyQuestion.swift
generated
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
60
Pods/PostHog/PostHog/PostHog.h
generated
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
337
Pods/PostHog/PostHog/PostHogApi.swift
generated
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
13
Pods/PostHog/PostHog/PostHogBatchUploadInfo.swift
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
276
Pods/PostHog/PostHog/PostHogConfig.swift
generated
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
13
Pods/PostHog/PostHog/PostHogConsumerPayload.swift
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
411
Pods/PostHog/PostHog/PostHogContext.swift
generated
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
20
Pods/PostHog/PostHog/PostHogExtensions.swift
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
114
Pods/PostHog/PostHog/PostHogFileBackedQueue.swift
generated
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
59
Pods/PostHog/PostHog/PostHogIntegration.swift
generated
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
45
Pods/PostHog/PostHog/PostHogLegacyQueue.swift
generated
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
20
Pods/PostHog/PostHog/PostHogPersonProfiles.swift
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
34
Pods/PostHog/PostHog/PostHogPropertiesSanitizer.swift
generated
Normal file
34
Pods/PostHog/PostHog/PostHogPropertiesSanitizer.swift
generated
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
285
Pods/PostHog/PostHog/PostHogQueue.swift
generated
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
636
Pods/PostHog/PostHog/PostHogRemoteConfig.swift
generated
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
1499
Pods/PostHog/PostHog/PostHogSDK.swift
generated
Normal file
File diff suppressed because it is too large
Load Diff
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
Normal file
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
419
Pods/PostHog/PostHog/PostHogStorage.swift
generated
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
163
Pods/PostHog/PostHog/PostHogStorageManager.swift
generated
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
14
Pods/PostHog/PostHog/PostHogSwizzler.swift
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
16
Pods/PostHog/PostHog/PostHogVersion.swift
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
130
Pods/PostHog/PostHog/Replay/ApplicationEventPublisher.swift
generated
Normal file
130
Pods/PostHog/PostHog/Replay/ApplicationEventPublisher.swift
generated
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
33
Pods/PostHog/PostHog/Replay/CGColor+Util.swift
generated
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
19
Pods/PostHog/PostHog/Replay/CGSize+Util.swift
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
18
Pods/PostHog/PostHog/Replay/Date+Util.swift
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
20
Pods/PostHog/PostHog/Replay/Float+Util.swift
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
120
Pods/PostHog/PostHog/Replay/MethodSwizzler.swift
generated
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
* 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
Normal file
58
Pods/PostHog/PostHog/Replay/NetworkSample.swift
generated
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
12
Pods/PostHog/PostHog/Replay/Optional+Util.swift
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// Optional+Util.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Yiannis Josephides on 20/01/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
extension Optional where Wrapped: Collection {
|
||||||
|
var isNilOrEmpty: Bool {
|
||||||
|
self?.isEmpty ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
136
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift
generated
Normal file
136
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift
generated
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
37
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift
generated
Normal file
37
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift
generated
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
22
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift
generated
Normal file
22
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
86
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift
generated
Normal file
86
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift
generated
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
89
Pods/PostHog/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift
generated
Normal file
89
Pods/PostHog/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift
generated
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
231
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionExtension.swift
generated
Normal file
231
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionExtension.swift
generated
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
#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
|
||||||
163
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift
generated
Normal file
163
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift
generated
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
251
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift
generated
Normal file
251
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift
generated
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// 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
|
||||||
43
Pods/PostHog/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift
generated
Normal file
43
Pods/PostHog/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift
generated
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
865
Pods/PostHog/PostHog/Replay/PostHogReplayIntegration.swift
generated
Normal file
865
Pods/PostHog/PostHog/Replay/PostHogReplayIntegration.swift
generated
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
// 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
|
||||||
93
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConfig.swift
generated
Normal file
93
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConfig.swift
generated
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
73
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift
generated
Normal file
73
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift
generated
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
96
Pods/PostHog/PostHog/Replay/RRStyle.swift
generated
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// 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
Normal file
133
Pods/PostHog/PostHog/Replay/RRWireframe.swift
generated
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// 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
Normal file
14
Pods/PostHog/PostHog/Replay/String+Util.swift
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// 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
Normal file
17
Pods/PostHog/PostHog/Replay/UIColor+Util.swift
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
33
Pods/PostHog/PostHog/Replay/UIImage+Util.swift
generated
Normal file
33
Pods/PostHog/PostHog/Replay/UIImage+Util.swift
generated
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// UIImage+Util.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Manoel Aranda Neto on 27.11.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
func toBase64(_ compressionQuality: CGFloat = 0.3) -> String? {
|
||||||
|
toWebPBase64(compressionQuality) ?? toJpegBase64(compressionQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toWebPBase64(_ compressionQuality: CGFloat) -> String? {
|
||||||
|
webpData(compressionQuality: compressionQuality).map { data in
|
||||||
|
"data:image/webp;base64,\(data.base64EncodedString())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toJpegBase64(_ compressionQuality: CGFloat) -> String? {
|
||||||
|
jpegData(compressionQuality: compressionQuality).map { data in
|
||||||
|
"data:image/jpeg;base64,\(data.base64EncodedString())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? {
|
||||||
|
image.toBase64(compressionQuality)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
36
Pods/PostHog/PostHog/Replay/UITextInputTraits+Util.swift
generated
Normal file
36
Pods/PostHog/PostHog/Replay/UITextInputTraits+Util.swift
generated
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// UITextInputTraits+Util.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Manoel Aranda Neto on 21.03.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
private let sensibleTypes: [UITextContentType] = [
|
||||||
|
.newPassword, .oneTimeCode, .creditCardNumber,
|
||||||
|
.telephoneNumber, .emailAddress, .password,
|
||||||
|
.username, .URL, .name, .nickname,
|
||||||
|
.middleName, .familyName, .nameSuffix,
|
||||||
|
.namePrefix, .organizationName, .location,
|
||||||
|
.fullStreetAddress, .streetAddressLine1,
|
||||||
|
.streetAddressLine2, .addressCity, .addressState,
|
||||||
|
.addressCityAndState, .postalCode,
|
||||||
|
]
|
||||||
|
|
||||||
|
extension UITextInputTraits {
|
||||||
|
func isSensitiveText() -> Bool {
|
||||||
|
if isSecureTextEntry ?? false {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let contentType = textContentType, let contentType = contentType {
|
||||||
|
return sensibleTypes.contains(contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
70
Pods/PostHog/PostHog/Replay/UIView+Util.swift
generated
Normal file
70
Pods/PostHog/PostHog/Replay/UIView+Util.swift
generated
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// UIView+Util.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Manoel Aranda Neto on 21.03.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIView {
|
||||||
|
func isVisible() -> Bool {
|
||||||
|
if isHidden || alpha == 0 || frame == .zero {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNoCapture() -> Bool {
|
||||||
|
var isNoCapture = false
|
||||||
|
if let identifier = accessibilityIdentifier {
|
||||||
|
isNoCapture = checkLabel(identifier)
|
||||||
|
}
|
||||||
|
// read accessibilityLabel from the parent's view to skip the RCTRecursiveAccessibilityLabel on RN which is slow and may cause an endless loop
|
||||||
|
// see https://github.com/facebook/react-native/issues/33084
|
||||||
|
if let label = super.accessibilityLabel, !isNoCapture {
|
||||||
|
isNoCapture = checkLabel(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNoCapture
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkLabel(_ label: String) -> Bool {
|
||||||
|
label.lowercased().contains("ph-no-capture")
|
||||||
|
}
|
||||||
|
|
||||||
|
func toImage() -> UIImage? {
|
||||||
|
// Avoid Rendering Offscreen Views
|
||||||
|
let bounds = superview?.bounds ?? bounds
|
||||||
|
let size = bounds.intersection(bounds).size
|
||||||
|
|
||||||
|
if !size.hasSize() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendererFormat = UIGraphicsImageRendererFormat.default()
|
||||||
|
|
||||||
|
// This can significantly improve rendering performance because the renderer won't need to
|
||||||
|
// process transparency.
|
||||||
|
rendererFormat.opaque = isOpaque
|
||||||
|
// Another way to improve rendering performance is to scale the renderer's content.
|
||||||
|
// rendererFormat.scale = 0.5
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size, format: rendererFormat)
|
||||||
|
|
||||||
|
let image = renderer.image { _ in
|
||||||
|
/// Note: Always `false` for `afterScreenUpdates` since this will cause the screen to flicker when a sensitive text field is visible on screen
|
||||||
|
/// This can potentially affect capturing a snapshot during a screen transition but we want the lesser of the two evils here
|
||||||
|
drawHierarchy(in: bounds, afterScreenUpdates: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
// you need this because of SwiftUI otherwise the coordinates always zeroed for some reason
|
||||||
|
func toAbsoluteRect(_ window: UIWindow?) -> CGRect {
|
||||||
|
convert(bounds, to: window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
15
Pods/PostHog/PostHog/Replay/ViewTreeSnapshotStatus.swift
generated
Normal file
15
Pods/PostHog/PostHog/Replay/ViewTreeSnapshotStatus.swift
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// ViewTreeSnapshotStatus.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Manoel Aranda Neto on 20.03.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class ViewTreeSnapshotStatus {
|
||||||
|
var sentFullSnapshot: Bool = false
|
||||||
|
var sentMetaEvent: Bool = false
|
||||||
|
var keyboardVisible: Bool = false
|
||||||
|
var lastSnapshot: Bool = false
|
||||||
|
}
|
||||||
44
Pods/PostHog/PostHog/Resources/PrivacyInfo.xcprivacy
generated
Normal file
44
Pods/PostHog/PostHog/Resources/PrivacyInfo.xcprivacy
generated
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?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>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeProductInteraction</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeOtherUsageData</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
172
Pods/PostHog/PostHog/Screen Views/ApplicationScreenViewPublisher.swift
generated
Normal file
172
Pods/PostHog/PostHog/Screen Views/ApplicationScreenViewPublisher.swift
generated
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//
|
||||||
|
// ApplicationScreenViewPublisher.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Ioannis Josephides on 20/02/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typealias ScreenViewHandler = (String) -> Void
|
||||||
|
|
||||||
|
protocol ScreenViewPublishing: AnyObject {
|
||||||
|
/// Registers a callback for a view appeared event
|
||||||
|
func onScreenView(_ callback: @escaping ScreenViewHandler) -> RegistrationToken
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ApplicationScreenViewPublisher: BaseScreenViewPublisher {
|
||||||
|
static let shared = ApplicationScreenViewPublisher()
|
||||||
|
|
||||||
|
private var hasSwizzled: Bool = false
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
// no-op if not UIKit
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
swizzleViewDidAppear()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
// no-op if not UIKit
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
unswizzleViewDidAppear()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
override func onScreenView(_ callback: @escaping ScreenViewHandler) -> RegistrationToken {
|
||||||
|
let id = UUID()
|
||||||
|
registrationLock.withLock {
|
||||||
|
self.onScreenViewCallbacks[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.onScreenViewCallbacks[id] = nil
|
||||||
|
return self.onScreenViewCallbacks.values.count
|
||||||
|
}
|
||||||
|
// stop when there are no more callbacks
|
||||||
|
if handlerCount <= 0 {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
func swizzleViewDidAppear() {
|
||||||
|
guard !hasSwizzled else { return }
|
||||||
|
hasSwizzled = true
|
||||||
|
swizzle(
|
||||||
|
forClass: UIViewController.self,
|
||||||
|
original: #selector(UIViewController.viewDidAppear(_:)),
|
||||||
|
new: #selector(UIViewController.viewDidAppearOverride)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unswizzleViewDidAppear() {
|
||||||
|
guard hasSwizzled else { return }
|
||||||
|
hasSwizzled = false
|
||||||
|
swizzle(
|
||||||
|
forClass: UIViewController.self,
|
||||||
|
original: #selector(UIViewController.viewDidAppearOverride),
|
||||||
|
new: #selector(UIViewController.viewDidAppear(_:))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from swizzled `viewDidAppearOverride`
|
||||||
|
fileprivate func viewDidAppear(in viewController: UIViewController?) {
|
||||||
|
// ignore views from keyboard window
|
||||||
|
guard let window = viewController?.viewIfLoaded?.window, !window.isKeyboardWindow else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let top = findVisibleViewController(viewController) else { return }
|
||||||
|
|
||||||
|
if let name = UIViewController.getViewControllerName(top) {
|
||||||
|
notifyHandlers(screen: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findVisibleViewController(_ controller: UIViewController?) -> UIViewController? {
|
||||||
|
if let navigationController = controller as? UINavigationController {
|
||||||
|
return findVisibleViewController(navigationController.visibleViewController)
|
||||||
|
}
|
||||||
|
if let tabController = controller as? UITabBarController {
|
||||||
|
if let selected = tabController.selectedViewController {
|
||||||
|
return findVisibleViewController(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let presented = controller?.presentedViewController {
|
||||||
|
return findVisibleViewController(presented)
|
||||||
|
}
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseScreenViewPublisher: ScreenViewPublishing {
|
||||||
|
fileprivate let registrationLock = NSLock()
|
||||||
|
|
||||||
|
var onScreenViewCallbacks: [UUID: ScreenViewHandler] = [:]
|
||||||
|
|
||||||
|
func onScreenView(_ callback: @escaping ScreenViewHandler) -> RegistrationToken {
|
||||||
|
let id = UUID()
|
||||||
|
registrationLock.withLock {
|
||||||
|
self.onScreenViewCallbacks[id] = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegistrationToken { [weak self] in
|
||||||
|
// Registration token deallocated here
|
||||||
|
guard let self else { return }
|
||||||
|
self.registrationLock.withLock {
|
||||||
|
self.onScreenViewCallbacks[id] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyHandlers(screen: String) {
|
||||||
|
let handlers = registrationLock.withLock { onScreenViewCallbacks.values }
|
||||||
|
for handler in handlers {
|
||||||
|
notifyHander(handler, screen: screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyHander(_ handler: @escaping ScreenViewHandler, screen: String) {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
handler(screen)
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { handler(screen) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
private extension UIViewController {
|
||||||
|
@objc func viewDidAppearOverride(animated: Bool) {
|
||||||
|
ApplicationScreenViewPublisher.shared.viewDidAppear(in: activeController)
|
||||||
|
|
||||||
|
// it looks like we're calling ourselves, but we're actually
|
||||||
|
// calling the original implementation of viewDidAppear since it's been swizzled.
|
||||||
|
viewDidAppearOverride(animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeController: UIViewController? {
|
||||||
|
// if a view is being dismissed, this will return nil
|
||||||
|
if let root = viewIfLoaded?.window?.rootViewController {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
// TODO: handle container controllers (see ph_topViewController)
|
||||||
|
return UIApplication.getCurrentWindow()?.rootViewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
79
Pods/PostHog/PostHog/Screen Views/PostHogScreenViewIntegration.swift
generated
Normal file
79
Pods/PostHog/PostHog/Screen Views/PostHogScreenViewIntegration.swift
generated
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// PostHogScreenViewIntegration.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Ioannis Josephides on 20/02/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class PostHogScreenViewIntegration: PostHogIntegration {
|
||||||
|
var requiresSwizzling: Bool { true }
|
||||||
|
|
||||||
|
private static var integrationInstalledLock = NSLock()
|
||||||
|
private static var integrationInstalled = false
|
||||||
|
|
||||||
|
private weak var postHog: PostHogSDK?
|
||||||
|
private var screenViewToken: RegistrationToken?
|
||||||
|
|
||||||
|
func install(_ postHog: PostHogSDK) throws {
|
||||||
|
try PostHogScreenViewIntegration.integrationInstalledLock.withLock {
|
||||||
|
if PostHogScreenViewIntegration.integrationInstalled {
|
||||||
|
throw InternalPostHogError(description: "Autocapture integration already installed to another PostHogSDK instance.")
|
||||||
|
}
|
||||||
|
PostHogScreenViewIntegration.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
|
||||||
|
PostHogScreenViewIntegration.integrationInstalledLock.withLock {
|
||||||
|
PostHogScreenViewIntegration.integrationInstalled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Start capturing screen view events
|
||||||
|
*/
|
||||||
|
func start() {
|
||||||
|
let screenViewPublisher = DI.main.screenViewPublisher
|
||||||
|
screenViewToken = screenViewPublisher.onScreenView { [weak self] screen in
|
||||||
|
self?.captureScreenView(screen: screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Stop capturing screen view events
|
||||||
|
*/
|
||||||
|
func stop() {
|
||||||
|
screenViewToken = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func captureScreenView(screen screenName: String) {
|
||||||
|
guard let postHog else { return }
|
||||||
|
|
||||||
|
if postHog.config.captureScreenViews {
|
||||||
|
postHog.screen(screenName)
|
||||||
|
} else {
|
||||||
|
hedgeLog("Skipping $screen event - captureScreenViews is disabled in configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if TESTING
|
||||||
|
extension PostHogScreenViewIntegration {
|
||||||
|
static func clearInstalls() {
|
||||||
|
integrationInstalledLock.withLock {
|
||||||
|
integrationInstalled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
23
Pods/PostHog/PostHog/Surveys/BottomSection.swift
generated
Normal file
23
Pods/PostHog/PostHog/Surveys/BottomSection.swift
generated
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// BottomSection.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Ioannis Josephides on 18/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
struct BottomSection: View {
|
||||||
|
let label: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(label, action: action)
|
||||||
|
.buttonStyle(SurveyButtonStyle())
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
42
Pods/PostHog/PostHog/Surveys/ConfirmationMessage.swift
generated
Normal file
42
Pods/PostHog/PostHog/Surveys/ConfirmationMessage.swift
generated
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
//
|
||||||
|
// ConfirmationMessage.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Ioannis Josephides on 13/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
struct ConfirmationMessage: View {
|
||||||
|
@Environment(\.surveyAppearance) private var appearance
|
||||||
|
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(appearance.thankYouMessageHeader)
|
||||||
|
.font(.body.bold())
|
||||||
|
.foregroundStyle(foregroundTextColor)
|
||||||
|
if let description = appearance.thankYouMessageDescription, appearance.thankYouMessageDescriptionContentType == .text {
|
||||||
|
Text(description)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(foregroundTextColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
BottomSection(label: appearance.thankYouMessageCloseButtonText, action: onClose)
|
||||||
|
.padding(.top, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foregroundTextColor: Color {
|
||||||
|
appearance.backgroundColor.getContrastingTextColor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
#Preview {
|
||||||
|
ConfirmationMessage {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
57
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurvey.swift
generated
Normal file
57
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurvey.swift
generated
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// PostHogDisplaySurvey.swift
|
||||||
|
// PostHog
|
||||||
|
//
|
||||||
|
// Created by Ioannis Josephides on 18/06/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A model representing a PostHog survey to be displayed to users
|
||||||
|
@objc public class PostHogDisplaySurvey: NSObject, Identifiable {
|
||||||
|
/// Unique identifier for the survey
|
||||||
|
public let id: String
|
||||||
|
/// Name of the survey
|
||||||
|
public let name: String
|
||||||
|
/// Array of questions to be presented in the survey
|
||||||
|
public let questions: [PostHogDisplaySurveyQuestion]
|
||||||
|
/// Optional appearance configuration for customizing the survey's look and feel
|
||||||
|
public let appearance: PostHogDisplaySurveyAppearance?
|
||||||
|
/// Optional date indicating when the survey should start being shown
|
||||||
|
public let startDate: Date?
|
||||||
|
/// Optional date indicating when the survey should stop being shown
|
||||||
|
public let endDate: Date?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
questions: [PostHogDisplaySurveyQuestion],
|
||||||
|
appearance: PostHogDisplaySurveyAppearance?,
|
||||||
|
startDate: Date?,
|
||||||
|
endDate: Date?
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.questions = questions
|
||||||
|
self.appearance = appearance
|
||||||
|
self.startDate = startDate
|
||||||
|
self.endDate = endDate
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of rating display for survey rating questions
|
||||||
|
@objc public enum PostHogDisplaySurveyRatingType: Int {
|
||||||
|
/// Display numeric rating options
|
||||||
|
case number
|
||||||
|
/// Display emoji rating options
|
||||||
|
case emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content type for text-based survey elements
|
||||||
|
@objc public enum PostHogDisplaySurveyTextContentType: Int {
|
||||||
|
/// Content should be rendered as HTML
|
||||||
|
case html
|
||||||
|
/// Content should be rendered as plain text
|
||||||
|
case text
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user