PDF BOM export
This commit is contained in:
@@ -6,13 +6,6 @@
|
|||||||
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;
|
||||||
@@ -38,22 +31,10 @@
|
|||||||
/* 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 */
|
||||||
@@ -64,11 +45,21 @@
|
|||||||
);
|
);
|
||||||
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
|
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
|
||||||
};
|
};
|
||||||
|
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
CableUITestsScreenshotLaunchTests.swift,
|
||||||
|
);
|
||||||
|
target = 3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
|
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */,
|
||||||
|
);
|
||||||
path = CableUITestsScreenshot;
|
path = CableUITestsScreenshot;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -97,7 +88,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
156FA26BC2A070D3E79DBC53 /* Pods_Cable_CableUITestsScreenshot.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -105,7 +95,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
0DBC1CAB8BE5C690AE39630C /* Pods_Cable.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -113,7 +102,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
85A2E22A9DF253A619C833B2 /* Pods_CableTests.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -121,7 +109,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
4472B945421CAB58A81AAF03 /* Pods_Cable_CableUITests.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -137,7 +124,6 @@
|
|||||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||||
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
||||||
57738E9B07763CFA62681EEE /* Pods */,
|
57738E9B07763CFA62681EEE /* Pods */,
|
||||||
9D16D1FE8C8B34C13C51D389 /* Frameworks */,
|
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -155,29 +141,10 @@
|
|||||||
57738E9B07763CFA62681EEE /* Pods */ = {
|
57738E9B07763CFA62681EEE /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
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;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
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 */
|
||||||
@@ -185,11 +152,9 @@
|
|||||||
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 = (
|
||||||
);
|
);
|
||||||
@@ -208,11 +173,9 @@
|
|||||||
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 = (
|
||||||
);
|
);
|
||||||
@@ -230,7 +193,6 @@
|
|||||||
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 */,
|
||||||
@@ -252,11 +214,9 @@
|
|||||||
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 = (
|
||||||
);
|
);
|
||||||
@@ -355,160 +315,6 @@
|
|||||||
};
|
};
|
||||||
/* 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;
|
||||||
@@ -561,7 +367,6 @@
|
|||||||
/* 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;
|
||||||
@@ -583,7 +388,6 @@
|
|||||||
};
|
};
|
||||||
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;
|
||||||
@@ -605,7 +409,6 @@
|
|||||||
};
|
};
|
||||||
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;
|
||||||
@@ -642,7 +445,6 @@
|
|||||||
};
|
};
|
||||||
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;
|
||||||
@@ -802,7 +604,6 @@
|
|||||||
};
|
};
|
||||||
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;
|
||||||
@@ -822,7 +623,6 @@
|
|||||||
};
|
};
|
||||||
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;
|
||||||
@@ -842,7 +642,6 @@
|
|||||||
};
|
};
|
||||||
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;
|
||||||
@@ -860,7 +659,6 @@
|
|||||||
};
|
};
|
||||||
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;
|
||||||
|
|||||||
3
Cable.xcworkspace/contents.xcworkspacedata
generated
3
Cable.xcworkspace/contents.xcworkspacedata
generated
@@ -4,7 +4,4 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Cable.xcodeproj">
|
location = "group:Cable.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
|
||||||
location = "group:Pods/Pods.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -7,18 +7,30 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PostHog
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
let POSTHOG_API_KEY = "phc_icZY61N3vdg4Sr3lzz9DNAqCRh6hCorVJbytduWORO9"
|
AnalyticsTracker.configure()
|
||||||
let POSTHOG_HOST = "https://eu.i.posthog.com"
|
|
||||||
|
|
||||||
let config = PostHogConfig(apiKey: POSTHOG_API_KEY, host: POSTHOG_HOST)
|
|
||||||
|
|
||||||
PostHogSDK.shared.setup(config)
|
|
||||||
NSLog("Launched")
|
NSLog("Launched")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AnalyticsTracker {
|
||||||
|
static func configure() {}
|
||||||
|
|
||||||
|
static func log(_ event: String, properties: [String: Any] = [:]) {
|
||||||
|
#if DEBUG
|
||||||
|
if properties.isEmpty {
|
||||||
|
NSLog("Analytics: %@", event)
|
||||||
|
} else {
|
||||||
|
let formatted = properties
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.sorted()
|
||||||
|
.joined(separator: ", ")
|
||||||
|
NSLog("Analytics: %@ { %@ }", event, formatted)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,29 @@
|
|||||||
"bom.navigation.title.system" = "BOM – %@";
|
"bom.navigation.title.system" = "BOM – %@";
|
||||||
"bom.size.unknown" = "Size TBD";
|
"bom.size.unknown" = "Size TBD";
|
||||||
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
|
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
|
||||||
|
"bom.empty.message" = "No components saved in this system yet.";
|
||||||
|
"bom.export.pdf.button" = "Export PDF";
|
||||||
|
"bom.export.pdf.error.title" = "Export Failed";
|
||||||
|
"bom.export.pdf.error.empty" = "Add at least one component before exporting.";
|
||||||
|
"bom.pdf.header.title" = "System Bill of Materials";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Unit System: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "No components available.";
|
||||||
|
"bom.pdf.page.number" = "Page %d";
|
||||||
|
"bom.category.components.title" = "Components & Chargers";
|
||||||
|
"bom.category.components.subtitle" = "Primary devices, controllers, and charging gear.";
|
||||||
|
"bom.category.batteries.title" = "Batteries";
|
||||||
|
"bom.category.batteries.subtitle" = "House banks and storage.";
|
||||||
|
"bom.category.cables.title" = "Cables";
|
||||||
|
"bom.category.cables.subtitle" = "Sized power runs for every circuit.";
|
||||||
|
"bom.category.fuses.title" = "Fuses";
|
||||||
|
"bom.category.fuses.subtitle" = "Circuit protection and holders.";
|
||||||
|
"bom.category.accessories.title" = "Accessories";
|
||||||
|
"bom.category.accessories.subtitle" = "Fuses, lugs, and supporting hardware.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"cable.pro.privacy.label" = "Privacy";
|
"cable.pro.privacy.label" = "Privacy";
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
||||||
"cable.pro.terms.label" = "Terms";
|
"cable.pro.terms.label" = "Terms";
|
||||||
@@ -277,6 +300,7 @@
|
|||||||
"cable.pro.alert.restored.body" = "Your purchases are available again.";
|
"cable.pro.alert.restored.body" = "Your purchases are available again.";
|
||||||
"cable.pro.alert.error.title" = "Purchase Failed";
|
"cable.pro.alert.error.title" = "Purchase Failed";
|
||||||
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
|
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
|
||||||
|
"generic.ok" = "OK";
|
||||||
"cable.pro.trial.badge" = "Includes a %@ free trial";
|
"cable.pro.trial.badge" = "Includes a %@ free trial";
|
||||||
"cable.pro.subscription.renews" = "Renews %@.";
|
"cable.pro.subscription.renews" = "Renews %@.";
|
||||||
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";
|
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct CableApp: App {
|
|||||||
_unitSettings = StateObject(wrappedValue: unitSettings)
|
_unitSettings = StateObject(wrappedValue: unitSettings)
|
||||||
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
|
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
|
UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ final class SavedCharger {
|
|||||||
var remoteIconURLString: String?
|
var remoteIconURLString: String?
|
||||||
var affiliateURLString: String?
|
var affiliateURLString: String?
|
||||||
var affiliateCountryCode: String?
|
var affiliateCountryCode: String?
|
||||||
|
var bomCompletedItemIDs: [String] = []
|
||||||
var identifier: String
|
var identifier: String
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -32,6 +33,7 @@ final class SavedCharger {
|
|||||||
remoteIconURLString: String? = nil,
|
remoteIconURLString: String? = nil,
|
||||||
affiliateURLString: String? = nil,
|
affiliateURLString: String? = nil,
|
||||||
affiliateCountryCode: String? = nil,
|
affiliateCountryCode: String? = nil,
|
||||||
|
bomCompletedItemIDs: [String] = [],
|
||||||
identifier: String = UUID().uuidString
|
identifier: String = UUID().uuidString
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -47,6 +49,7 @@ final class SavedCharger {
|
|||||||
self.remoteIconURLString = remoteIconURLString
|
self.remoteIconURLString = remoteIconURLString
|
||||||
self.affiliateURLString = affiliateURLString
|
self.affiliateURLString = affiliateURLString
|
||||||
self.affiliateCountryCode = affiliateCountryCode
|
self.affiliateCountryCode = affiliateCountryCode
|
||||||
|
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,31 +34,12 @@ class CableCalculator: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
|
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
|
||||||
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048 // ft to m
|
ElectricalCalculations.recommendedCrossSection(
|
||||||
// Simplified calculation: minimum cross-section based on current and voltage drop
|
length: length,
|
||||||
let maxVoltageDrop = voltage * 0.05 // 5% voltage drop limit
|
current: current,
|
||||||
let resistivity = 0.017 // Copper resistivity at 20°C (Ω⋅mm²/m)
|
voltage: voltage,
|
||||||
let calculatedMinCrossSection = (2 * current * lengthInMeters * resistivity) / maxVoltageDrop
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
if unitSystem == .imperial {
|
|
||||||
// Standard AWG wire sizes
|
|
||||||
let standardAWG = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
|
|
||||||
let awgCrossSections = [0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0]
|
|
||||||
|
|
||||||
// Find the smallest AWG that meets the requirement
|
|
||||||
for (index, crossSection) in awgCrossSections.enumerated() {
|
|
||||||
if crossSection >= calculatedMinCrossSection {
|
|
||||||
return Double(standardAWG[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Double(standardAWG.last!) // Largest available
|
|
||||||
} else {
|
|
||||||
// Standard metric cable cross-sections in mm²
|
|
||||||
let standardSizes = [0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0]
|
|
||||||
|
|
||||||
// Find the smallest standard size that meets the requirement
|
|
||||||
return standardSizes.first { $0 >= max(0.75, calculatedMinCrossSection) } ?? standardSizes.last!
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func crossSection(for unitSystem: UnitSystem) -> Double {
|
func crossSection(for unitSystem: UnitSystem) -> Double {
|
||||||
@@ -66,42 +47,34 @@ class CableCalculator: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
||||||
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048
|
ElectricalCalculations.voltageDrop(
|
||||||
let crossSectionMM2 = unitSystem == .metric ? crossSection(for: unitSystem) : crossSectionFromAWG(crossSection(for: unitSystem))
|
length: length,
|
||||||
let resistivity = 0.017
|
current: current,
|
||||||
let effectiveCurrent = current // Always use the current property which gets updated
|
voltage: voltage,
|
||||||
return (2 * effectiveCurrent * lengthInMeters * resistivity) / crossSectionMM2
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
||||||
(voltageDrop(for: unitSystem) / voltage) * 100
|
ElectricalCalculations.voltageDropPercentage(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
||||||
let effectiveCurrent = current
|
ElectricalCalculations.powerLoss(
|
||||||
return effectiveCurrent * voltageDrop(for: unitSystem)
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var recommendedFuse: Int {
|
var recommendedFuse: Int {
|
||||||
let targetFuse = current * 1.25 // 125% of load current for safety
|
ElectricalCalculations.recommendedFuse(forCurrent: current)
|
||||||
|
|
||||||
// Common fuse values in amperes
|
|
||||||
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
|
|
||||||
|
|
||||||
// Find the smallest standard fuse that's >= target
|
|
||||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
|
|
||||||
}
|
|
||||||
|
|
||||||
// AWG conversion helper for voltage drop calculations
|
|
||||||
private func crossSectionFromAWG(_ awg: Double) -> Double {
|
|
||||||
let awgSizes = [20: 0.519, 18: 0.823, 16: 1.31, 14: 2.08, 12: 3.31, 10: 5.26, 8: 8.37, 6: 13.3, 4: 21.2, 2: 33.6, 1: 42.4, 0: 53.5]
|
|
||||||
|
|
||||||
// Handle 00, 000, 0000 AWG (represented as negative values)
|
|
||||||
if awg == 00 { return 67.4 }
|
|
||||||
if awg == 000 { return 85.0 }
|
|
||||||
if awg == 0000 { return 107.0 }
|
|
||||||
|
|
||||||
return awgSizes[Int(awg)] ?? 0.75
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
138
Cable/Loads/ElectricalCalculations.swift
Normal file
138
Cable/Loads/ElectricalCalculations.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//
|
||||||
|
// ElectricalCalculations.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Created by GPT on request.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ElectricalCalculations {
|
||||||
|
private static let maxVoltageDropFraction = 0.05
|
||||||
|
private static let copperResistivity = 0.017 // Ω⋅mm²/m
|
||||||
|
private static let feetToMeters = 0.3048
|
||||||
|
|
||||||
|
private static let standardMetricCrossSections: [Double] = [
|
||||||
|
0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0,
|
||||||
|
150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0,
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let standardAWG: [Int] = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
|
||||||
|
private static let awgCrossSections: [Double] = [
|
||||||
|
0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0,
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let standardFuses: [Int] = [
|
||||||
|
1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50,
|
||||||
|
60, 70, 80, 100, 125, 150, 175, 200, 225, 250,
|
||||||
|
300, 350, 400, 450, 500, 600, 700, 800,
|
||||||
|
]
|
||||||
|
|
||||||
|
static func recommendedCrossSection(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem
|
||||||
|
) -> Double {
|
||||||
|
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
|
||||||
|
let maxVoltageDrop = voltage * maxVoltageDropFraction
|
||||||
|
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
|
||||||
|
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
|
||||||
|
}
|
||||||
|
|
||||||
|
if unitSystem == .imperial {
|
||||||
|
for (index, crossSection) in awgCrossSections.enumerated() where crossSection >= minimumCrossSection {
|
||||||
|
return Double(standardAWG[index])
|
||||||
|
}
|
||||||
|
return Double(standardAWG.last ?? 0)
|
||||||
|
} else {
|
||||||
|
return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) }
|
||||||
|
?? standardMetricCrossSections.last ?? 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func voltageDrop(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
crossSection: Double? = nil
|
||||||
|
) -> Double {
|
||||||
|
let selectedCrossSection = crossSection ?? recommendedCrossSection(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
|
||||||
|
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
|
||||||
|
let crossSectionMM2: Double
|
||||||
|
if unitSystem == .metric {
|
||||||
|
crossSectionMM2 = selectedCrossSection
|
||||||
|
} else {
|
||||||
|
crossSectionMM2 = crossSectionFromAWG(selectedCrossSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard crossSectionMM2 > 0 else { return 0 }
|
||||||
|
return (2 * current * lengthInMeters * copperResistivity) / crossSectionMM2
|
||||||
|
}
|
||||||
|
|
||||||
|
static func voltageDropPercentage(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
crossSection: Double? = nil
|
||||||
|
) -> Double {
|
||||||
|
guard voltage != 0 else { return 0 }
|
||||||
|
let drop = voltageDrop(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
crossSection: crossSection
|
||||||
|
)
|
||||||
|
return (drop / voltage) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
static func powerLoss(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
crossSection: Double? = nil
|
||||||
|
) -> Double {
|
||||||
|
let drop = voltageDrop(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
crossSection: crossSection
|
||||||
|
)
|
||||||
|
return current * drop
|
||||||
|
}
|
||||||
|
|
||||||
|
static func recommendedFuse(forCurrent current: Double) -> Int {
|
||||||
|
let target = Int((current * 1.25).rounded(.up))
|
||||||
|
return standardFuses.first(where: { $0 >= target }) ?? standardFuses.last ?? target
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func guardAgainstZero(_ divisor: Double, calculation: () -> Double) -> Double {
|
||||||
|
guard divisor > 0 else { return 0 }
|
||||||
|
return calculation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func crossSectionFromAWG(_ awg: Double) -> Double {
|
||||||
|
switch awg {
|
||||||
|
case 00: return 67.4
|
||||||
|
case 000: return 85.0
|
||||||
|
case 0000: return 107.0
|
||||||
|
default:
|
||||||
|
let index = standardAWG.firstIndex(of: Int(awg)) ?? -1
|
||||||
|
if index >= 0 && index < awgCrossSections.count {
|
||||||
|
return awgCrossSections[index]
|
||||||
|
}
|
||||||
|
return 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import PostHog
|
|
||||||
|
|
||||||
struct LoadsView: View {
|
struct LoadsView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@@ -64,6 +63,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "rectangle.3.group"
|
systemImage: "rectangle.3.group"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("overview-tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
componentsTab
|
componentsTab
|
||||||
@@ -77,6 +77,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "square.stack.3d.up"
|
systemImage: "square.stack.3d.up"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("components-tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
@@ -106,6 +107,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "battery.100"
|
systemImage: "battery.100"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("batteries-tab")
|
||||||
}
|
}
|
||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
|
|
||||||
@@ -127,6 +129,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "bolt.fill"
|
systemImage: "bolt.fill"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("chargers-tab")
|
||||||
}
|
}
|
||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
}
|
}
|
||||||
@@ -215,6 +218,8 @@ struct LoadsView: View {
|
|||||||
SystemBillOfMaterialsView(
|
SystemBillOfMaterialsView(
|
||||||
systemName: system.name,
|
systemName: system.name,
|
||||||
loads: savedLoads,
|
loads: savedLoads,
|
||||||
|
batteries: savedBatteries,
|
||||||
|
chargers: savedChargers,
|
||||||
unitSystem: unitSettings.unitSystem
|
unitSystem: unitSettings.unitSystem
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -266,7 +271,7 @@ struct LoadsView: View {
|
|||||||
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
|
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
|
||||||
hasOpenedLoadOnAppear = true
|
hasOpenedLoadOnAppear = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Load Opened",
|
"Load Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"mode": loadToOpen.isWattMode ? "watt" : "amp",
|
"mode": loadToOpen.isWattMode ? "watt" : "amp",
|
||||||
@@ -469,7 +474,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func selectLoad(_ load: SavedLoad) {
|
private func selectLoad(_ load: SavedLoad) {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Load Opened",
|
"Load Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"mode": load.isWattMode ? "watt" : "amp",
|
"mode": load.isWattMode ? "watt" : "amp",
|
||||||
@@ -760,7 +765,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func presentSystemEditor(source: String) {
|
private func presentSystemEditor(source: String) {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"System Editor Opened",
|
"System Editor Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"source": source,
|
"source": source,
|
||||||
@@ -771,7 +776,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openComponentLibrary(source: String) {
|
private func openComponentLibrary(source: String) {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Component Library Opened",
|
"Component Library Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"source": source,
|
"source": source,
|
||||||
@@ -782,7 +787,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openBillOfMaterials() {
|
private func openBillOfMaterials() {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Bill Of Materials Opened",
|
"Bill Of Materials Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"system": system.name
|
"system": system.name
|
||||||
@@ -795,7 +800,7 @@ struct LoadsView: View {
|
|||||||
let loadsToDelete = offsets.map { savedLoads[$0] }
|
let loadsToDelete = offsets.map { savedLoads[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for load in loadsToDelete {
|
for load in loadsToDelete {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Load Deleted",
|
"Load Deleted",
|
||||||
properties: [
|
properties: [
|
||||||
"name": load.name,
|
"name": load.name,
|
||||||
@@ -815,7 +820,7 @@ struct LoadsView: View {
|
|||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
existingChargers: savedChargers
|
existingChargers: savedChargers
|
||||||
)
|
)
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Load Created",
|
"Load Created",
|
||||||
properties: [
|
properties: [
|
||||||
"name": newLoad.name,
|
"name": newLoad.name,
|
||||||
@@ -826,7 +831,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startBatteryConfiguration() {
|
private func startBatteryConfiguration() {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Battery Editor Opened",
|
"Battery Editor Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"source": "create",
|
"source": "create",
|
||||||
@@ -850,7 +855,7 @@ struct LoadsView: View {
|
|||||||
in: modelContext
|
in: modelContext
|
||||||
)
|
)
|
||||||
let eventName = isExisting ? "Battery Updated" : "Battery Created"
|
let eventName = isExisting ? "Battery Updated" : "Battery Created"
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
eventName,
|
eventName,
|
||||||
properties: [
|
properties: [
|
||||||
"name": configuration.name,
|
"name": configuration.name,
|
||||||
@@ -860,7 +865,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func editBattery(_ battery: SavedBattery) {
|
private func editBattery(_ battery: SavedBattery) {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Battery Editor Opened",
|
"Battery Editor Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"source": "edit",
|
"source": "edit",
|
||||||
@@ -874,7 +879,7 @@ struct LoadsView: View {
|
|||||||
let batteriesToDelete = offsets.map { savedBatteries[$0] }
|
let batteriesToDelete = offsets.map { savedBatteries[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for battery in batteriesToDelete {
|
for battery in batteriesToDelete {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Battery Deleted",
|
"Battery Deleted",
|
||||||
properties: [
|
properties: [
|
||||||
"name": battery.name,
|
"name": battery.name,
|
||||||
@@ -891,7 +896,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startChargerConfiguration() {
|
private func startChargerConfiguration() {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Charger Editor Opened",
|
"Charger Editor Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"source": "create",
|
"source": "create",
|
||||||
@@ -915,7 +920,7 @@ struct LoadsView: View {
|
|||||||
in: modelContext
|
in: modelContext
|
||||||
)
|
)
|
||||||
let eventName = isExisting ? "Charger Updated" : "Charger Created"
|
let eventName = isExisting ? "Charger Updated" : "Charger Created"
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
eventName,
|
eventName,
|
||||||
properties: [
|
properties: [
|
||||||
"name": configuration.name,
|
"name": configuration.name,
|
||||||
@@ -925,7 +930,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func editCharger(_ charger: SavedCharger) {
|
private func editCharger(_ charger: SavedCharger) {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Charger Editor Opened",
|
"Charger Editor Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"source": "edit",
|
"source": "edit",
|
||||||
@@ -939,7 +944,7 @@ struct LoadsView: View {
|
|||||||
let chargersToDelete = offsets.map { savedChargers[$0] }
|
let chargersToDelete = offsets.map { savedChargers[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for charger in chargersToDelete {
|
for charger in chargersToDelete {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Charger Deleted",
|
"Charger Deleted",
|
||||||
properties: [
|
properties: [
|
||||||
"name": charger.name,
|
"name": charger.name,
|
||||||
@@ -964,7 +969,7 @@ struct LoadsView: View {
|
|||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
existingChargers: savedChargers
|
existingChargers: savedChargers
|
||||||
)
|
)
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Library Load Added",
|
"Library Load Added",
|
||||||
properties: [
|
properties: [
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
@@ -986,13 +991,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func recommendedFuse(for load: SavedLoad) -> Int {
|
private func recommendedFuse(for load: SavedLoad) -> Int {
|
||||||
let targetFuse = load.current * 1.25 // 125% of load current for safety
|
ElectricalCalculations.recommendedFuse(forCurrent: load.current)
|
||||||
|
|
||||||
// Common fuse values in amperes
|
|
||||||
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
|
|
||||||
|
|
||||||
// Find the smallest standard fuse that's >= target
|
|
||||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ComponentTab: Hashable {
|
private enum ComponentTab: Hashable {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ struct OnboardingInfoView: View {
|
|||||||
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
|
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("create-component-button")
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ struct OnboardingInfoView: View {
|
|||||||
Label(secondaryTitle, systemImage: secondaryIcon)
|
Label(secondaryTitle, systemImage: secondaryIcon)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("select-component-button")
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ struct SystemOverviewView: View {
|
|||||||
goalHours: nil,
|
goalHours: nil,
|
||||||
progressFraction: bomCompletionFraction,
|
progressFraction: bomCompletionFraction,
|
||||||
hasValue: bomItemsCount > 0,
|
hasValue: bomItemsCount > 0,
|
||||||
action: onShowBillOfMaterials
|
action: onShowBillOfMaterials,
|
||||||
|
accessibilityIdentifier: "system-bom-button"
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
@@ -190,7 +192,8 @@ struct SystemOverviewView: View {
|
|||||||
goalHours: Double?,
|
goalHours: Double?,
|
||||||
progressFraction: Double?,
|
progressFraction: Double?,
|
||||||
hasValue: Bool,
|
hasValue: Bool,
|
||||||
action: (() -> Void)? = nil
|
action: (() -> Void)? = nil,
|
||||||
|
accessibilityIdentifier: String? = nil
|
||||||
) -> some View {
|
) -> some View {
|
||||||
let content = VStack(alignment: .leading, spacing: 10) {
|
let content = VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .center, spacing: 12) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
@@ -240,12 +243,28 @@ struct SystemOverviewView: View {
|
|||||||
|
|
||||||
let paddedContent = content
|
let paddedContent = content
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
|
||||||
if let action {
|
if let action {
|
||||||
Button(action: action) {
|
if let accessibilityIdentifier {
|
||||||
paddedContent
|
Button(action: action) {
|
||||||
|
paddedContent
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(accessibilityIdentifier)
|
||||||
|
.accessibilityLabel(title)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
} else {
|
||||||
|
Button(action: action) {
|
||||||
|
paddedContent
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(title)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
} else {
|
} else {
|
||||||
paddedContent
|
paddedContent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class SavedBattery {
|
|||||||
var iconName: String = "battery.100"
|
var iconName: String = "battery.100"
|
||||||
var colorName: String = "blue"
|
var colorName: String = "blue"
|
||||||
var system: ElectricalSystem?
|
var system: ElectricalSystem?
|
||||||
|
var bomCompletedItemIDs: [String] = []
|
||||||
var timestamp: Date
|
var timestamp: Date
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -32,6 +33,7 @@ class SavedBattery {
|
|||||||
iconName: String = "battery.100",
|
iconName: String = "battery.100",
|
||||||
colorName: String = "blue",
|
colorName: String = "blue",
|
||||||
system: ElectricalSystem? = nil,
|
system: ElectricalSystem? = nil,
|
||||||
|
bomCompletedItemIDs: [String] = [],
|
||||||
timestamp: Date = Date()
|
timestamp: Date = Date()
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -47,6 +49,7 @@ class SavedBattery {
|
|||||||
self.iconName = iconName
|
self.iconName = iconName
|
||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
|
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||||
self.timestamp = timestamp
|
self.timestamp = timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
Cable/Shared/ShareSheet.swift
Normal file
11
Cable/Shared/ShareSheet.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
17
Cable/Systems/BillOfMaterialsSnapshot.swift
Normal file
17
Cable/Systems/BillOfMaterialsSnapshot.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BillOfMaterialsItemSnapshot: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
let iconSystemName: String
|
||||||
|
let isPrimaryComponent: Bool
|
||||||
|
let metric: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BillOfMaterialsSectionSnapshot: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let items: [BillOfMaterialsItemSnapshot]
|
||||||
|
}
|
||||||
313
Cable/Systems/SystemBillOfMaterialsPDFExporter.swift
Normal file
313
Cable/Systems/SystemBillOfMaterialsPDFExporter.swift
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct SystemBillOfMaterialsPDFExporter {
|
||||||
|
private let pageRect = CGRect(x: 0, y: 0, width: 595, height: 842) // A4 portrait in points
|
||||||
|
private let margin: CGFloat = 40
|
||||||
|
private let primaryTextColor = UIColor.black
|
||||||
|
private let secondaryTextColor = UIColor.darkGray
|
||||||
|
private let tertiaryTextColor = UIColor.gray
|
||||||
|
private let accentColor = UIColor(red: 0.45, green: 0.34, blue: 0.86, alpha: 1)
|
||||||
|
|
||||||
|
func export(
|
||||||
|
systemName: String,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
sections: [BillOfMaterialsSectionSnapshot]
|
||||||
|
) throws -> URL {
|
||||||
|
let format = UIGraphicsPDFRendererFormat()
|
||||||
|
let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)
|
||||||
|
var pageIndex = 1
|
||||||
|
|
||||||
|
let data = renderer.pdfData { context in
|
||||||
|
var cursorY = beginPage(
|
||||||
|
context: context,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
isFirstPage: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if sections.isEmpty {
|
||||||
|
cursorY = ensureSpace(
|
||||||
|
requiredHeight: 60,
|
||||||
|
cursorY: cursorY,
|
||||||
|
context: context,
|
||||||
|
pageIndex: &pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
let emptyMessage = NSLocalizedString(
|
||||||
|
"bom.pdf.placeholder.empty",
|
||||||
|
comment: "Message shown in the PDF export when no components are available"
|
||||||
|
)
|
||||||
|
drawPlaceholder(in: context.cgContext, text: emptyMessage, at: cursorY)
|
||||||
|
} else {
|
||||||
|
for section in sections {
|
||||||
|
let requiredHeight = sectionHeight(for: section)
|
||||||
|
cursorY = ensureSpace(
|
||||||
|
requiredHeight: requiredHeight,
|
||||||
|
cursorY: cursorY,
|
||||||
|
context: context,
|
||||||
|
pageIndex: &pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
|
||||||
|
cursorY = drawSectionHeader(
|
||||||
|
title: section.title,
|
||||||
|
subtitle: section.subtitle,
|
||||||
|
at: cursorY,
|
||||||
|
in: context.cgContext
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in section.items {
|
||||||
|
cursorY = drawItem(item, at: cursorY, in: context.cgContext)
|
||||||
|
cursorY += 12
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorY += 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFooter(pageIndex: pageIndex, in: context.cgContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("System-BOM-\(UUID().uuidString).pdf")
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginPage(
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
pageIndex: Int,
|
||||||
|
systemName: String,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
isFirstPage: Bool
|
||||||
|
) -> CGFloat {
|
||||||
|
context.beginPage()
|
||||||
|
let titleFont = UIFont.systemFont(ofSize: isFirstPage ? 26 : 18, weight: .bold)
|
||||||
|
let subtitleFont = UIFont.systemFont(ofSize: isFirstPage ? 16 : 12, weight: .medium)
|
||||||
|
let title = isFirstPage
|
||||||
|
? NSLocalizedString(
|
||||||
|
"bom.pdf.header.title",
|
||||||
|
comment: "Primary title shown at the top of the BOM PDF"
|
||||||
|
)
|
||||||
|
: systemName
|
||||||
|
|
||||||
|
let subtitle: String
|
||||||
|
if isFirstPage {
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.pdf.header.subtitle",
|
||||||
|
comment: "Subtitle format combining system name and unit system for the BOM PDF"
|
||||||
|
)
|
||||||
|
subtitle = String(
|
||||||
|
format: format,
|
||||||
|
locale: Locale.current,
|
||||||
|
systemName,
|
||||||
|
unitSystem.displayName
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.pdf.header.inline",
|
||||||
|
comment: "Subtitle describing the active unit system on subsequent PDF pages"
|
||||||
|
)
|
||||||
|
subtitle = String(
|
||||||
|
format: format,
|
||||||
|
locale: Locale.current,
|
||||||
|
unitSystem.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableWidth = pageRect.width - (margin * 2)
|
||||||
|
let titleRect = CGRect(x: margin, y: margin, width: availableWidth, height: titleFont.lineHeight + 4)
|
||||||
|
title.draw(in: titleRect, withAttributes: [
|
||||||
|
.font: titleFont,
|
||||||
|
.foregroundColor: primaryTextColor
|
||||||
|
])
|
||||||
|
|
||||||
|
let subtitleRect = CGRect(
|
||||||
|
x: margin,
|
||||||
|
y: titleRect.maxY + 4,
|
||||||
|
width: availableWidth,
|
||||||
|
height: subtitleFont.lineHeight + 2
|
||||||
|
)
|
||||||
|
subtitle.draw(in: subtitleRect, withAttributes: [
|
||||||
|
.font: subtitleFont,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
])
|
||||||
|
|
||||||
|
return subtitleRect.maxY + (isFirstPage ? 24 : 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSpace(
|
||||||
|
requiredHeight: CGFloat,
|
||||||
|
cursorY: CGFloat,
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
pageIndex: inout Int,
|
||||||
|
systemName: String,
|
||||||
|
unitSystem: UnitSystem
|
||||||
|
) -> CGFloat {
|
||||||
|
if cursorY + requiredHeight <= pageRect.height - margin {
|
||||||
|
return cursorY
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFooter(pageIndex: pageIndex, in: context.cgContext)
|
||||||
|
pageIndex += 1
|
||||||
|
return beginPage(
|
||||||
|
context: context,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
isFirstPage: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sectionHeaderHeight: CGFloat {
|
||||||
|
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
return headerFont.lineHeight + subtitleFont.lineHeight + 14
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeight(for section: BillOfMaterialsSectionSnapshot) -> CGFloat {
|
||||||
|
let itemsHeight = section.items.reduce(0) { partialResult, item in
|
||||||
|
partialResult + itemBlockHeight(for: item) + 12
|
||||||
|
}
|
||||||
|
return sectionHeaderHeight + itemsHeight + 8
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawSectionHeader(title: String, subtitle: String, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
|
||||||
|
var cursorY = yPosition
|
||||||
|
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
let availableWidth = pageRect.width - (margin * 2)
|
||||||
|
|
||||||
|
title.draw(
|
||||||
|
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: headerFont.lineHeight + 4),
|
||||||
|
withAttributes: [
|
||||||
|
.font: headerFont,
|
||||||
|
.foregroundColor: primaryTextColor
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cursorY += headerFont.lineHeight + 4
|
||||||
|
|
||||||
|
subtitle.draw(
|
||||||
|
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: subtitleFont.lineHeight + 2),
|
||||||
|
withAttributes: [
|
||||||
|
.font: subtitleFont,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cursorY += subtitleFont.lineHeight + 10
|
||||||
|
|
||||||
|
return cursorY
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemBlockHeight(for item: BillOfMaterialsItemSnapshot) -> CGFloat {
|
||||||
|
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
|
||||||
|
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
var height: CGFloat = 0
|
||||||
|
if item.metric != nil {
|
||||||
|
height += metricFont.lineHeight + 2
|
||||||
|
}
|
||||||
|
height += titleFont.lineHeight + 2
|
||||||
|
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
height += detailFont.lineHeight + 4
|
||||||
|
}
|
||||||
|
return height + 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawItem(_ item: BillOfMaterialsItemSnapshot, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
|
||||||
|
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
|
||||||
|
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
let titleAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: titleFont,
|
||||||
|
.foregroundColor: primaryTextColor
|
||||||
|
]
|
||||||
|
let detailAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: detailFont,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
]
|
||||||
|
let metricAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: metricFont,
|
||||||
|
.foregroundColor: accentColor
|
||||||
|
]
|
||||||
|
|
||||||
|
let bulletWidth: CGFloat = 6
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let availableWidth = pageRect.width - (margin * 2) - bulletWidth - spacing
|
||||||
|
let firstLineHeight = item.metric != nil ? metricFont.lineHeight : titleFont.lineHeight
|
||||||
|
let bulletRect = CGRect(
|
||||||
|
x: margin,
|
||||||
|
y: yPosition + (firstLineHeight / 2) - (bulletWidth / 2),
|
||||||
|
width: bulletWidth,
|
||||||
|
height: bulletWidth
|
||||||
|
)
|
||||||
|
context.setFillColor(accentColor.cgColor)
|
||||||
|
context.fillEllipse(in: bulletRect)
|
||||||
|
|
||||||
|
var cursorY = yPosition
|
||||||
|
let textX = margin + bulletWidth + spacing
|
||||||
|
|
||||||
|
if let metric = item.metric {
|
||||||
|
let metricRect = CGRect(x: textX, y: cursorY, width: availableWidth, height: metricFont.lineHeight + 2)
|
||||||
|
metric.draw(in: metricRect, withAttributes: metricAttributes)
|
||||||
|
cursorY = metricRect.maxY + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let titleRect = CGRect(
|
||||||
|
x: textX,
|
||||||
|
y: cursorY,
|
||||||
|
width: availableWidth,
|
||||||
|
height: titleFont.lineHeight + 2
|
||||||
|
)
|
||||||
|
item.title.draw(in: titleRect, withAttributes: titleAttributes)
|
||||||
|
cursorY = titleRect.maxY + 2
|
||||||
|
|
||||||
|
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let detailRect = CGRect(
|
||||||
|
x: textX,
|
||||||
|
y: cursorY,
|
||||||
|
width: availableWidth,
|
||||||
|
height: detailFont.lineHeight + 4
|
||||||
|
)
|
||||||
|
item.detail.draw(in: detailRect, withAttributes: detailAttributes)
|
||||||
|
cursorY = detailRect.maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursorY
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawFooter(pageIndex: Int, in context: CGContext) {
|
||||||
|
let footerFont = UIFont.systemFont(ofSize: 11, weight: .regular)
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: footerFont,
|
||||||
|
.foregroundColor: tertiaryTextColor
|
||||||
|
]
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.pdf.page.number",
|
||||||
|
comment: "Format string for the PDF page number footer"
|
||||||
|
)
|
||||||
|
let text = String(format: format, locale: Locale.current, pageIndex)
|
||||||
|
let size = text.size(withAttributes: attributes)
|
||||||
|
let origin = CGPoint(
|
||||||
|
x: (pageRect.width - size.width) / 2,
|
||||||
|
y: pageRect.height - margin + 10
|
||||||
|
)
|
||||||
|
text.draw(at: origin, withAttributes: attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawPlaceholder(in context: CGContext, text: String, at yPosition: CGFloat) {
|
||||||
|
let font = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
]
|
||||||
|
text.draw(
|
||||||
|
in: CGRect(x: margin, y: yPosition, width: pageRect.width - (margin * 2), height: font.lineHeight + 4),
|
||||||
|
withAttributes: attributes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
import SwiftUI
|
import 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")
|
||||||
@@ -94,7 +93,7 @@ struct SystemsOnboardingView: View {
|
|||||||
.onAppear(perform: resetState)
|
.onAppear(perform: resetState)
|
||||||
.onReceive(timer) { _ in advanceCarousel() }
|
.onReceive(timer) { _ in advanceCarousel() }
|
||||||
.task {
|
.task {
|
||||||
PostHogSDK.shared.capture("Launched")
|
AnalyticsTracker.log("Launched")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +105,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])
|
AnalyticsTracker.log("System Created", properties: ["name": trimmed])
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
onCreate(trimmed)
|
onCreate(trimmed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -77,48 +76,16 @@ struct SystemsView: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(systems) { system in
|
ForEach(systems) { system in
|
||||||
NavigationLink(destination: LoadsView(system: system)) {
|
Button {
|
||||||
HStack(spacing: 12) {
|
handleSystemSelection(system)
|
||||||
ZStack {
|
} label: {
|
||||||
RoundedRectangle(cornerRadius: 10)
|
systemRow(for: system)
|
||||||
.fill(Color.componentColor(named: system.colorName))
|
.contentShape(Rectangle())
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
|
|
||||||
Image(systemName: system.iconName)
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(system.name)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
|
|
||||||
if !system.location.isEmpty {
|
|
||||||
Text(system.location)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
Text(componentSummary(for: system))
|
.accessibilityLabel(system.name)
|
||||||
.font(.caption)
|
.accessibilityHint(Text("systems.list.row.accessibility.hint", comment: "Accessibility hint for systems list row"))
|
||||||
.foregroundColor(.secondary)
|
.accessibilityAddTraits(.isButton)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
.simultaneousGesture(
|
|
||||||
TapGesture().onEnded {
|
|
||||||
PostHogSDK.shared.capture(
|
|
||||||
"System Opened",
|
|
||||||
properties: [
|
|
||||||
"name": system.name,
|
|
||||||
"source": "list"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteSystems)
|
.onDelete(perform: deleteSystems)
|
||||||
}
|
}
|
||||||
@@ -137,7 +104,7 @@ struct SystemsView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
PostHogSDK.shared.capture("System Create Navigation")
|
AnalyticsTracker.log("System Create Navigation")
|
||||||
createNewSystem()
|
createNewSystem()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
@@ -175,13 +142,67 @@ struct SystemsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openSettings() {
|
private func openSettings() {
|
||||||
PostHogSDK.shared.capture("Settings Opened")
|
AnalyticsTracker.log("Settings Opened")
|
||||||
showingSettings = true
|
showingSettings = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleSystemSelection(_ system: ElectricalSystem) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"System Opened",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "list"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "list"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func systemRow(for system: ElectricalSystem) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.componentColor(named: system.colorName))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: system.iconName)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(system.name)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
if !system.location.isEmpty {
|
||||||
|
Text(system.location)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(componentSummary(for: system))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundColor(.secondary.opacity(0.6))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
private func createNewSystem() {
|
private func createNewSystem() {
|
||||||
let system = makeSystem()
|
let system = makeSystem()
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"System Created",
|
"System Created",
|
||||||
properties: [
|
properties: [
|
||||||
"name": system.name,
|
"name": system.name,
|
||||||
@@ -198,7 +219,7 @@ struct SystemsView: View {
|
|||||||
|
|
||||||
private func createNewSystem(named name: String) {
|
private func createNewSystem(named name: String) {
|
||||||
let system = makeSystem(preferredName: name)
|
let system = makeSystem(preferredName: name)
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"System Created",
|
"System Created",
|
||||||
properties: [
|
properties: [
|
||||||
"name": system.name,
|
"name": system.name,
|
||||||
@@ -233,7 +254,7 @@ struct SystemsView: View {
|
|||||||
animated: Bool = true,
|
animated: Bool = true,
|
||||||
source: String = "programmatic"
|
source: String = "programmatic"
|
||||||
) {
|
) {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"System Opened",
|
"System Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"name": system.name,
|
"name": system.name,
|
||||||
@@ -300,7 +321,7 @@ struct SystemsView: View {
|
|||||||
|
|
||||||
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
||||||
let system = makeSystem()
|
let system = makeSystem()
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"System Created",
|
"System Created",
|
||||||
properties: [
|
properties: [
|
||||||
"name": system.name,
|
"name": system.name,
|
||||||
@@ -308,7 +329,7 @@ struct SystemsView: View {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
let load = createLoad(from: item, in: system)
|
let load = createLoad(from: item, in: system)
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"Library Load Added",
|
"Library Load Added",
|
||||||
properties: [
|
properties: [
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
@@ -397,7 +418,7 @@ struct SystemsView: View {
|
|||||||
let systemsToDelete = offsets.map { systems[$0] }
|
let systemsToDelete = offsets.map { systems[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for system in systemsToDelete {
|
for system in systemsToDelete {
|
||||||
PostHogSDK.shared.capture(
|
AnalyticsTracker.log(
|
||||||
"System Deleted",
|
"System Deleted",
|
||||||
properties: [
|
properties: [
|
||||||
"name": system.name,
|
"name": system.name,
|
||||||
@@ -414,7 +435,7 @@ 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(
|
AnalyticsTracker.log(
|
||||||
"Load Deleted",
|
"Load Deleted",
|
||||||
properties: [
|
properties: [
|
||||||
"name": load.name,
|
"name": load.name,
|
||||||
|
|||||||
@@ -7,27 +7,44 @@ import Foundation
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
enum UITestSampleData {
|
enum UITestSampleData {
|
||||||
static let argument = "--uitest-sample-data"
|
static let sampleArgument = "--uitest-sample-data"
|
||||||
|
static let resetArgument = "--uitest-reset-data"
|
||||||
|
|
||||||
static func prepareIfNeeded(container: ModelContainer) {
|
static func handleLaunchArguments(container: ModelContainer) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
|
let arguments = ProcessInfo.processInfo.arguments
|
||||||
|
NSLog("UITestSampleData arguments: %@", arguments.joined(separator: ", "))
|
||||||
|
guard arguments.contains(sampleArgument) || arguments.contains(resetArgument) else { return }
|
||||||
|
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try clearExistingData(in: context)
|
if arguments.contains(resetArgument) {
|
||||||
try seedSampleData(in: context)
|
NSLog("UITestSampleData resetting data store")
|
||||||
try context.save()
|
try clearExistingData(in: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
if arguments.contains(sampleArgument) {
|
||||||
|
NSLog("UITestSampleData seeding sample data")
|
||||||
|
if !arguments.contains(resetArgument) {
|
||||||
|
try clearExistingData(in: context)
|
||||||
|
}
|
||||||
|
try seedSampleData(in: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.hasChanges {
|
||||||
|
try context.save()
|
||||||
|
NSLog("UITestSampleData save completed")
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
assertionFailure("Failed to seed UI test sample data: \(error)")
|
assertionFailure("Failed to prepare UI test data: \(error)")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private extension UITestSampleData {
|
extension UITestSampleData {
|
||||||
static func clearExistingData(in context: ModelContext) throws {
|
static func clearExistingData(in context: ModelContext) throws {
|
||||||
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
|
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
|
||||||
let loadDescriptor = FetchDescriptor<SavedLoad>()
|
let loadDescriptor = FetchDescriptor<SavedLoad>()
|
||||||
|
|||||||
@@ -144,6 +144,29 @@
|
|||||||
"bom.navigation.title.system" = "Stückliste – %@";
|
"bom.navigation.title.system" = "Stückliste – %@";
|
||||||
"bom.size.unknown" = "Größe offen";
|
"bom.size.unknown" = "Größe offen";
|
||||||
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
|
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
|
||||||
|
"bom.empty.message" = "Dieses System hat noch keine Komponenten.";
|
||||||
|
"bom.export.pdf.button" = "PDF exportieren";
|
||||||
|
"bom.export.pdf.error.title" = "Export fehlgeschlagen";
|
||||||
|
"bom.export.pdf.error.empty" = "Füge vor dem Export mindestens eine Komponente hinzu.";
|
||||||
|
"bom.pdf.header.title" = "System-Stückliste";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Einheitensystem: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "Keine Komponenten verfügbar.";
|
||||||
|
"bom.pdf.page.number" = "Seite %d";
|
||||||
|
"bom.category.components.title" = "Komponenten & Ladegeräte";
|
||||||
|
"bom.category.components.subtitle" = "Hauptverbraucher, Regler und Ladehardware.";
|
||||||
|
"bom.category.batteries.title" = "Batterien";
|
||||||
|
"bom.category.batteries.subtitle" = "Hausspeicher und Batteriebänke.";
|
||||||
|
"bom.category.cables.title" = "Kabel";
|
||||||
|
"bom.category.cables.subtitle" = "Passende Leitungen für jede Strecke.";
|
||||||
|
"bom.category.fuses.title" = "Sicherungen";
|
||||||
|
"bom.category.fuses.subtitle" = "Stromkreisschutz und Halter.";
|
||||||
|
"bom.category.accessories.title" = "Zubehör";
|
||||||
|
"bom.category.accessories.subtitle" = "Sicherungen, Kabelschuhe und weiteres Montagematerial.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"cable.pro.privacy.label" = "Datenschutz";
|
"cable.pro.privacy.label" = "Datenschutz";
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
|
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
|
||||||
"cable.pro.terms.label" = "Nutzungsbedingungen";
|
"cable.pro.terms.label" = "Nutzungsbedingungen";
|
||||||
@@ -264,7 +287,7 @@
|
|||||||
"sample.load.charger.name" = "Werkzeugladegerät";
|
"sample.load.charger.name" = "Werkzeugladegerät";
|
||||||
"sample.load.compressor.name" = "Luftkompressor";
|
"sample.load.compressor.name" = "Luftkompressor";
|
||||||
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
|
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
|
||||||
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
|
"sample.load.lighting.name" = "LED-Streifen";
|
||||||
"sample.system.rv.location" = "12V Wohnstromkreis";
|
"sample.system.rv.location" = "12V Wohnstromkreis";
|
||||||
"sample.system.rv.name" = "Abenteuer-Van";
|
"sample.system.rv.name" = "Abenteuer-Van";
|
||||||
"sample.system.workshop.location" = "Werkzeugecke";
|
"sample.system.workshop.location" = "Werkzeugecke";
|
||||||
@@ -337,6 +360,7 @@
|
|||||||
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
|
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
|
||||||
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
|
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
|
||||||
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
|
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
|
||||||
|
"generic.ok" = "OK";
|
||||||
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
|
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
|
||||||
"cable.pro.subscription.renews" = "Verlängert sich %@.";
|
"cable.pro.subscription.renews" = "Verlängert sich %@.";
|
||||||
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";
|
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";
|
||||||
|
|||||||
@@ -14,6 +14,29 @@
|
|||||||
"bom.navigation.title.system" = "Lista de materiales – %@";
|
"bom.navigation.title.system" = "Lista de materiales – %@";
|
||||||
"bom.size.unknown" = "Tamaño por definir";
|
"bom.size.unknown" = "Tamaño por definir";
|
||||||
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
|
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
|
||||||
|
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
|
||||||
|
"bom.export.pdf.button" = "Exportar PDF";
|
||||||
|
"bom.export.pdf.error.title" = "Exportación fallida";
|
||||||
|
"bom.export.pdf.error.empty" = "Agrega al menos un componente antes de exportar.";
|
||||||
|
"bom.pdf.header.title" = "Lista de materiales del sistema";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Sistema de unidades: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "No hay componentes disponibles.";
|
||||||
|
"bom.pdf.page.number" = "Página %d";
|
||||||
|
"bom.category.components.title" = "Componentes y cargadores";
|
||||||
|
"bom.category.components.subtitle" = "Dispositivos principales, controladores y equipos de carga.";
|
||||||
|
"bom.category.batteries.title" = "Baterías";
|
||||||
|
"bom.category.batteries.subtitle" = "Bancos domésticos y almacenamiento.";
|
||||||
|
"bom.category.cables.title" = "Cables";
|
||||||
|
"bom.category.cables.subtitle" = "Tendidos dimensionados para cada circuito.";
|
||||||
|
"bom.category.fuses.title" = "Fusibles";
|
||||||
|
"bom.category.fuses.subtitle" = "Protección de circuitos y portafusibles.";
|
||||||
|
"bom.category.accessories.title" = "Accesorios";
|
||||||
|
"bom.category.accessories.subtitle" = "Fusibles, terminales y piezas de soporte.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"component.fallback.name" = "Componente";
|
"component.fallback.name" = "Componente";
|
||||||
"default.load.library" = "Carga de la biblioteca";
|
"default.load.library" = "Carga de la biblioteca";
|
||||||
"default.load.name" = "Mi carga";
|
"default.load.name" = "Mi carga";
|
||||||
@@ -323,3 +346,4 @@
|
|||||||
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
|
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
|
||||||
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
|
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
|
||||||
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
|
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
|
||||||
|
"generic.ok" = "Aceptar";
|
||||||
|
|||||||
@@ -14,6 +14,29 @@
|
|||||||
"bom.navigation.title.system" = "Liste de matériel – %@";
|
"bom.navigation.title.system" = "Liste de matériel – %@";
|
||||||
"bom.size.unknown" = "Taille à déterminer";
|
"bom.size.unknown" = "Taille à déterminer";
|
||||||
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
|
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
|
||||||
|
"bom.empty.message" = "Aucun composant enregistré pour ce système pour l’instant.";
|
||||||
|
"bom.export.pdf.button" = "Exporter en PDF";
|
||||||
|
"bom.export.pdf.error.title" = "Échec de l’export";
|
||||||
|
"bom.export.pdf.error.empty" = "Ajoutez au moins un composant avant l’export.";
|
||||||
|
"bom.pdf.header.title" = "Liste de matériaux du système";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Système d’unités : %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "Aucun composant disponible.";
|
||||||
|
"bom.pdf.page.number" = "Page %d";
|
||||||
|
"bom.category.components.title" = "Composants et chargeurs";
|
||||||
|
"bom.category.components.subtitle" = "Appareils principaux, contrôleurs et équipements de charge.";
|
||||||
|
"bom.category.batteries.title" = "Batteries";
|
||||||
|
"bom.category.batteries.subtitle" = "Banques domestiques et stockage.";
|
||||||
|
"bom.category.cables.title" = "Câbles";
|
||||||
|
"bom.category.cables.subtitle" = "Liaisons dimensionnées pour chaque circuit.";
|
||||||
|
"bom.category.fuses.title" = "Fusibles";
|
||||||
|
"bom.category.fuses.subtitle" = "Protection des circuits et porte-fusibles.";
|
||||||
|
"bom.category.accessories.title" = "Accessoires";
|
||||||
|
"bom.category.accessories.subtitle" = "Fusibles, cosses et pièces complémentaires.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"component.fallback.name" = "Composant";
|
"component.fallback.name" = "Composant";
|
||||||
"default.load.library" = "Charge de la bibliothèque";
|
"default.load.library" = "Charge de la bibliothèque";
|
||||||
"default.load.name" = "Ma charge";
|
"default.load.name" = "Ma charge";
|
||||||
@@ -322,4 +345,5 @@
|
|||||||
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
|
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
|
||||||
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
|
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
|
||||||
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
|
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
|
||||||
"cable.pro.feature.usageBased" = "Calculs basés sur l'utilisation";
|
"cable.pro.feature.usageBased" = "Calculs basés sur l’utilisation";
|
||||||
|
"generic.ok" = "OK";
|
||||||
|
|||||||
@@ -14,6 +14,29 @@
|
|||||||
"bom.navigation.title.system" = "Materiaallijst – %@";
|
"bom.navigation.title.system" = "Materiaallijst – %@";
|
||||||
"bom.size.unknown" = "Afmeting nog onbekend";
|
"bom.size.unknown" = "Afmeting nog onbekend";
|
||||||
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
|
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
|
||||||
|
"bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen.";
|
||||||
|
"bom.export.pdf.button" = "PDF exporteren";
|
||||||
|
"bom.export.pdf.error.title" = "Export mislukt";
|
||||||
|
"bom.export.pdf.error.empty" = "Voeg minimaal één component toe voordat je exporteert.";
|
||||||
|
"bom.pdf.header.title" = "Stuklijst van het systeem";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Maateenheid: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "Geen componenten beschikbaar.";
|
||||||
|
"bom.pdf.page.number" = "Pagina %d";
|
||||||
|
"bom.category.components.title" = "Componenten en laders";
|
||||||
|
"bom.category.components.subtitle" = "Hoofdapparaten, regelaars en laadapparatuur.";
|
||||||
|
"bom.category.batteries.title" = "Batterijen";
|
||||||
|
"bom.category.batteries.subtitle" = "Huishoudbanken en opslag.";
|
||||||
|
"bom.category.cables.title" = "Kabels";
|
||||||
|
"bom.category.cables.subtitle" = "Op maat gemaakte stroomtrajecten per circuit.";
|
||||||
|
"bom.category.fuses.title" = "Zekeringen";
|
||||||
|
"bom.category.fuses.subtitle" = "Circuitbeveiliging en houders.";
|
||||||
|
"bom.category.accessories.title" = "Accessoires";
|
||||||
|
"bom.category.accessories.subtitle" = "Zekeringen, kabelschoenen en ondersteunende onderdelen.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"component.fallback.name" = "Component";
|
"component.fallback.name" = "Component";
|
||||||
"default.load.library" = "Bibliotheeklast";
|
"default.load.library" = "Bibliotheeklast";
|
||||||
"default.load.name" = "Mijn last";
|
"default.load.name" = "Mijn last";
|
||||||
@@ -323,3 +346,4 @@
|
|||||||
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
|
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
|
||||||
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
|
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
|
||||||
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
|
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
|
||||||
|
"generic.ok" = "OK";
|
||||||
|
|||||||
@@ -11,40 +11,75 @@ import Testing
|
|||||||
struct CableTests {
|
struct CableTests {
|
||||||
|
|
||||||
@Test func metricWireSizingUsesNearestStandardSize() async throws {
|
@Test func metricWireSizingUsesNearestStandardSize() async throws {
|
||||||
let calculator = CableCalculator()
|
let crossSection = ElectricalCalculations.recommendedCrossSection(
|
||||||
calculator.voltage = 12
|
length: 10,
|
||||||
calculator.current = 5
|
current: 5,
|
||||||
calculator.length = 10 // meters
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
let crossSection = calculator.recommendedCrossSection(for: .metric)
|
)
|
||||||
#expect(crossSection == 4.0)
|
#expect(crossSection == 4.0)
|
||||||
|
|
||||||
let voltageDrop = calculator.voltageDrop(for: .metric)
|
let voltageDrop = ElectricalCalculations.voltageDrop(
|
||||||
|
length: 10,
|
||||||
|
current: 5,
|
||||||
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
|
)
|
||||||
#expect(abs(voltageDrop - 0.425) < 0.001)
|
#expect(abs(voltageDrop - 0.425) < 0.001)
|
||||||
|
|
||||||
let dropPercentage = calculator.voltageDropPercentage(for: .metric)
|
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
|
||||||
|
length: 10,
|
||||||
|
current: 5,
|
||||||
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
|
)
|
||||||
#expect(abs(dropPercentage - 3.5417) < 0.001)
|
#expect(abs(dropPercentage - 3.5417) < 0.001)
|
||||||
|
|
||||||
let powerLoss = calculator.powerLoss(for: .metric)
|
let powerLoss = ElectricalCalculations.powerLoss(
|
||||||
|
length: 10,
|
||||||
|
current: 5,
|
||||||
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
|
)
|
||||||
#expect(abs(powerLoss - 2.125) < 0.001)
|
#expect(abs(powerLoss - 2.125) < 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
|
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
|
||||||
let calculator = CableCalculator()
|
let awg = ElectricalCalculations.recommendedCrossSection(
|
||||||
calculator.voltage = 120
|
length: 25,
|
||||||
calculator.current = 15
|
current: 15,
|
||||||
calculator.length = 25 // feet
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
let awg = calculator.recommendedCrossSection(for: .imperial)
|
)
|
||||||
#expect(awg == 18.0)
|
#expect(awg == 18.0)
|
||||||
|
|
||||||
let voltageDrop = calculator.voltageDrop(for: .imperial)
|
let voltageDrop = ElectricalCalculations.voltageDrop(
|
||||||
|
length: 25,
|
||||||
|
current: 15,
|
||||||
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
|
)
|
||||||
#expect(abs(voltageDrop - 4.722) < 0.01)
|
#expect(abs(voltageDrop - 4.722) < 0.01)
|
||||||
|
|
||||||
let dropPercentage = calculator.voltageDropPercentage(for: .imperial)
|
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
|
||||||
|
length: 25,
|
||||||
|
current: 15,
|
||||||
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
|
)
|
||||||
#expect(abs(dropPercentage - 3.935) < 0.01)
|
#expect(abs(dropPercentage - 3.935) < 0.01)
|
||||||
|
|
||||||
let powerLoss = calculator.powerLoss(for: .imperial)
|
let powerLoss = ElectricalCalculations.powerLoss(
|
||||||
|
length: 25,
|
||||||
|
current: 15,
|
||||||
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
|
)
|
||||||
#expect(abs(powerLoss - 70.83) < 0.05)
|
#expect(abs(powerLoss - 70.83) < 0.05)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func recommendedFuseRoundsUpToNearestStandardSize() async throws {
|
||||||
|
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10)
|
||||||
|
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Cable
|
@testable import Cable
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let german = Locale(identifier: "de_DE")
|
let german = Foundation.Locale(identifier: "de_DE")
|
||||||
#expect(item.localizedName(for: german) == "Ankerwinde")
|
#expect(item.localizedName(for: german) == "Ankerwinde")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let french = Locale(identifier: "fr_FR")
|
let french = Foundation.Locale(identifier: "fr_FR")
|
||||||
#expect(item.localizedName(for: french) == "Anchor Winch")
|
#expect(item.localizedName(for: french) == "Anchor Winch")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let spanishMexico = Locale(identifier: "es_MX")
|
let spanishMexico = Foundation.Locale(identifier: "es_MX")
|
||||||
#expect(item.localizedName(for: spanishMexico) == "Molinete")
|
#expect(item.localizedName(for: spanishMexico) == "Molinete")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let germanSwitzerland = Locale(identifier: "de_CH")
|
let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
|
||||||
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
|
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let french = Locale(identifier: "fr_FR")
|
let french = Foundation.Locale(identifier: "fr_FR")
|
||||||
#expect(item.localizedName(for: french) == "Guindeau")
|
#expect(item.localizedName(for: french) == "Guindeau")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,536 @@
|
|||||||
//
|
|
||||||
// CableUITestsScreenshot.swift
|
|
||||||
// CableUITestsScreenshot
|
|
||||||
//
|
|
||||||
// Created by Stefan Lange-Hegermann on 06.10.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class CableUITestsScreenshot: XCTestCase {
|
final class CableUITestsScreenshot: XCTestCase {
|
||||||
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
|
|
||||||
|
private enum UIStringKey: String {
|
||||||
|
case addLoad
|
||||||
|
case browseLibrary
|
||||||
|
case library
|
||||||
|
case overviewTab
|
||||||
|
case componentsTab
|
||||||
|
case batteriesTab
|
||||||
|
case chargersTab
|
||||||
|
case close
|
||||||
|
case cancel
|
||||||
|
case settings
|
||||||
|
case defaultLoadName
|
||||||
|
case billOfMaterials
|
||||||
|
case systemEditorTitle
|
||||||
|
case systemsTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
private let translations: [UIStringKey: [String: String]] = [
|
||||||
|
.addLoad: [
|
||||||
|
"en": "Add Load",
|
||||||
|
"de": "Verbraucher hinzufügen",
|
||||||
|
"es": "Añadir carga",
|
||||||
|
"fr": "Ajouter une charge",
|
||||||
|
"nl": "Belasting toevoegen",
|
||||||
|
],
|
||||||
|
.browseLibrary: [
|
||||||
|
"en": "Browse Library",
|
||||||
|
"de": "Bibliothek durchsuchen",
|
||||||
|
"es": "Explorar biblioteca",
|
||||||
|
"fr": "Parcourir la bibliothèque",
|
||||||
|
"nl": "Bibliotheek bekijken",
|
||||||
|
],
|
||||||
|
.library: [
|
||||||
|
"en": "Library",
|
||||||
|
"de": "Bibliothek",
|
||||||
|
"es": "Biblioteca",
|
||||||
|
"fr": "Bibliothèque",
|
||||||
|
"nl": "Bibliotheek",
|
||||||
|
],
|
||||||
|
.overviewTab: [
|
||||||
|
"en": "Overview",
|
||||||
|
"de": "Übersicht",
|
||||||
|
"es": "Resumen",
|
||||||
|
"fr": "Aperçu",
|
||||||
|
"nl": "Overzicht",
|
||||||
|
],
|
||||||
|
.componentsTab: [
|
||||||
|
"en": "Components",
|
||||||
|
"de": "Verbraucher",
|
||||||
|
"es": "Componentes",
|
||||||
|
"fr": "Composants",
|
||||||
|
"nl": "Componenten",
|
||||||
|
],
|
||||||
|
.batteriesTab: [
|
||||||
|
"en": "Batteries",
|
||||||
|
"de": "Batterien",
|
||||||
|
"es": "Baterías",
|
||||||
|
"fr": "Batteries",
|
||||||
|
"nl": "Batterijen",
|
||||||
|
],
|
||||||
|
.chargersTab: [
|
||||||
|
"en": "Chargers",
|
||||||
|
"de": "Ladegeräte",
|
||||||
|
"es": "Cargadores",
|
||||||
|
"fr": "Chargeurs",
|
||||||
|
"nl": "Laders",
|
||||||
|
],
|
||||||
|
.close: [
|
||||||
|
"en": "Close",
|
||||||
|
"de": "Schließen",
|
||||||
|
"es": "Cerrar",
|
||||||
|
"fr": "Fermer",
|
||||||
|
"nl": "Sluiten",
|
||||||
|
],
|
||||||
|
.cancel: [
|
||||||
|
"en": "Cancel",
|
||||||
|
"de": "Abbrechen",
|
||||||
|
"es": "Cancelar",
|
||||||
|
"fr": "Annuler",
|
||||||
|
"nl": "Annuleren",
|
||||||
|
],
|
||||||
|
.settings: [
|
||||||
|
"en": "Settings",
|
||||||
|
"de": "Einstellungen",
|
||||||
|
"es": "Configuración",
|
||||||
|
"fr": "Réglages",
|
||||||
|
"nl": "Instellingen",
|
||||||
|
],
|
||||||
|
.defaultLoadName: [
|
||||||
|
"en": "New Load",
|
||||||
|
"de": "Neuer Verbraucher",
|
||||||
|
"es": "Carga nueva",
|
||||||
|
"fr": "Nouvelle charge",
|
||||||
|
"nl": "Nieuwe last",
|
||||||
|
],
|
||||||
|
.billOfMaterials: [
|
||||||
|
"en": "Bill of Materials",
|
||||||
|
"de": "Stückliste",
|
||||||
|
"es": "Lista de materiales",
|
||||||
|
"fr": "Liste de matériel",
|
||||||
|
"nl": "Stuklijst",
|
||||||
|
],
|
||||||
|
.systemEditorTitle: [
|
||||||
|
"en": "Edit System",
|
||||||
|
"de": "System bearbeiten",
|
||||||
|
"es": "Editar sistema",
|
||||||
|
"fr": "Modifier le système",
|
||||||
|
"nl": "Systeem bewerken",
|
||||||
|
],
|
||||||
|
.systemsTitle: [
|
||||||
|
"en": "Systems",
|
||||||
|
"de": "Systeme",
|
||||||
|
"es": "Sistemas",
|
||||||
|
"fr": "Systèmes",
|
||||||
|
"nl": "Systemen",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
continueAfterFailure = false
|
|
||||||
try super.setUpWithError()
|
try super.setUpWithError()
|
||||||
ensureDoNotDisturbEnabled()
|
continueAfterFailure = false
|
||||||
dismissSystemOverlays()
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
//ensureDoNotDisturbEnabled()
|
||||||
|
//dismissSystemOverlays()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
try super.tearDownWithError()
|
try super.tearDownWithError()
|
||||||
dismissSystemOverlays()
|
//dismissSystemOverlays()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testExample() throws {
|
func testOnboardingScreenshots() throws {
|
||||||
|
let app = launchApp(arguments: ["--uitest-reset-data"])
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
|
||||||
|
let createSystemButton = app.buttons["create-system-button"]
|
||||||
|
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "01-OnboardingSystemsView")
|
||||||
|
|
||||||
|
createSystemButton.tap()
|
||||||
|
|
||||||
|
let addLoadButton = button(in: app.buttons, for: .addLoad)
|
||||||
|
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
|
||||||
|
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
|
||||||
|
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
|
||||||
|
|
||||||
|
waitForStability()
|
||||||
|
takeScreenshot(named: "02-OnboardingSystemView")
|
||||||
|
|
||||||
|
browseLibraryButton.tap()
|
||||||
|
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||||
|
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "04-ComponentSelectorView")
|
||||||
|
libraryCloseButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 4))
|
||||||
|
addLoadButton.tap()
|
||||||
|
|
||||||
|
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
|
||||||
|
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "03-LoadEditorView")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSampleDataScreenshots() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
|
||||||
app.launch()
|
app.launch()
|
||||||
dismissSystemOverlays()
|
|
||||||
|
let systemsList = resolvedSystemsList(in: app)
|
||||||
|
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
|
||||||
|
takeScreenshot(named: "05-SystemsWithSampleData")
|
||||||
|
|
||||||
|
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
|
||||||
|
let systemName = firstSystemCell.staticTexts.firstMatch.label
|
||||||
|
|
||||||
|
let systemButton = firstSystemCell.buttons.firstMatch
|
||||||
|
if systemButton.exists {
|
||||||
|
systemButton.tap()
|
||||||
|
} else {
|
||||||
|
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailVisible = waitForSystemDetail(named: systemName, in: app)
|
||||||
|
if !detailVisible {
|
||||||
|
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
detailVisible = waitForSystemDetail(named: systemName, in: app)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(detailVisible)
|
||||||
|
takeScreenshot(named: "06-AdventureVanOverview")
|
||||||
|
|
||||||
|
// let overviewTab = app.buttons["overview-tab"]
|
||||||
|
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
|
||||||
|
// overviewTab.tap()
|
||||||
|
waitForStability(long: false)
|
||||||
|
let bomElement = resolveBillOfMaterialsElement(in: app)
|
||||||
|
|
||||||
|
if !bomElement.waitForExistence(timeout: 6) {
|
||||||
|
bringElementIntoView(bomElement, in: app)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(bomElement.exists)
|
||||||
|
|
||||||
|
if !bomElement.isHittable {
|
||||||
|
bringElementIntoView(bomElement, in: app, requireHittable: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bomElement.isHittable {
|
||||||
|
bomElement.tap()
|
||||||
|
} else {
|
||||||
|
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "08-BillOfMaterials")
|
||||||
|
|
||||||
|
let closeButton = app.buttons["system-bom-close-button"]
|
||||||
|
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
|
||||||
|
closeButton.tap()
|
||||||
|
|
||||||
|
let componentsTab = componentsTabButton(in: app)
|
||||||
|
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
|
||||||
|
if componentsTab.isHittable {
|
||||||
|
componentsTab.tap()
|
||||||
|
} else {
|
||||||
|
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadsList = resolvedLoadsList(in: app)
|
||||||
|
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
|
||||||
|
takeScreenshot(named: "07-AdventureVanLoads")
|
||||||
|
|
||||||
|
waitForStability()
|
||||||
|
let firstLoad = loadsList.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
|
||||||
|
let loadName = firstLoad.staticTexts.firstMatch.label
|
||||||
|
firstLoad.tap()
|
||||||
|
|
||||||
|
let loadNavButton = app.navigationBars.buttons[loadName]
|
||||||
|
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
|
||||||
|
takeScreenshot(named: "09-AdventureVanCalculator")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp(arguments: [String]) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
var launchArguments = ["--uitest-reset-data"]
|
||||||
|
launchArguments.append(contentsOf: arguments)
|
||||||
|
app.launchArguments = launchArguments
|
||||||
|
app.launch()
|
||||||
|
//dismissSystemOverlays()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["systems-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
|
||||||
|
return app.collectionViews.firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["systems-list"]
|
||||||
|
if table.waitForExistence(timeout: 6) {
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(app.tables.firstMatch.waitForExistence(timeout: 2))
|
||||||
|
return app.tables.firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["loads-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["loads-list"]
|
||||||
|
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
private func takeScreenshot(named name: String) {
|
||||||
|
let screenshot = XCUIScreen.main.screenshot()
|
||||||
|
let attachment = XCTAttachment(screenshot: screenshot)
|
||||||
|
attachment.name = name
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForStability(long: Bool = false) {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let identifierMatch = app.descendants(matching: .any)
|
||||||
|
.matching(identifier: "components-tab").firstMatch
|
||||||
|
if identifierMatch.exists {
|
||||||
|
return identifierMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
let localizedLabels = [
|
||||||
|
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
|
||||||
|
]
|
||||||
|
for label in localizedLabels {
|
||||||
|
let button = app.buttons[label]
|
||||||
|
if button.exists {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabBarButton = app.tabBars.buttons[label]
|
||||||
|
if tabBarButton.exists {
|
||||||
|
return tabBarButton
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentedButton = app.segmentedControls.buttons[label]
|
||||||
|
if segmentedButton.exists {
|
||||||
|
return segmentedButton
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentedOther = app.segmentedControls.otherElements[label]
|
||||||
|
if segmentedOther.exists {
|
||||||
|
return segmentedOther
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
|
||||||
|
if fallbackSegmented.exists {
|
||||||
|
return fallbackSegmented
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
|
||||||
|
if tabBarButton.exists {
|
||||||
|
return tabBarButton
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.tabBars.descendants(matching: .any).firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if app.otherElements["system-overview"].exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let navBar = app.navigationBars.firstMatch
|
||||||
|
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.otherElements["system-overview"].exists
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bringElementIntoView(
|
||||||
|
_ element: XCUIElement,
|
||||||
|
in app: XCUIApplication,
|
||||||
|
requireHittable: Bool = false,
|
||||||
|
attempts: Int = 8
|
||||||
|
) {
|
||||||
|
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.tables.firstMatch
|
||||||
|
for _ in 0..<attempts {
|
||||||
|
if element.exists, (!requireHittable || element.isHittable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scrollContainer.exists {
|
||||||
|
scrollContainer.swipeUp()
|
||||||
|
} else {
|
||||||
|
app.swipeUp()
|
||||||
|
}
|
||||||
|
waitForStability()
|
||||||
|
_ = element.waitForExistence(timeout: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let identifier = "system-bom-button"
|
||||||
|
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
|
||||||
|
if buttonByIdentifier.exists { return buttonByIdentifier }
|
||||||
|
|
||||||
|
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
|
||||||
|
if elementByIdentifier.exists { return elementByIdentifier }
|
||||||
|
|
||||||
|
let candidates = candidateStrings(for: .billOfMaterials)
|
||||||
|
for candidate in candidates {
|
||||||
|
let button = app.buttons[candidate]
|
||||||
|
if button.exists {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
let other = app.otherElements[candidate]
|
||||||
|
if other.exists {
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttonByIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissNotificationBannersIfNeeded() {
|
||||||
|
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
|
||||||
|
if banner.waitForExistence(timeout: 1) {
|
||||||
|
if banner.isHittable {
|
||||||
|
banner.swipeUp()
|
||||||
|
} else {
|
||||||
|
let start = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||||
|
let end = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: -1.5))
|
||||||
|
start.press(forDuration: 0.05, thenDragTo: end)
|
||||||
|
}
|
||||||
|
waitForStability()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func candidateStrings(for key: UIStringKey) -> [String] {
|
||||||
|
var values = Set<String>()
|
||||||
|
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
|
||||||
|
let localized = translations[key]?[languageCode] {
|
||||||
|
values.insert(localized)
|
||||||
|
}
|
||||||
|
if let english = translations[key]?["en"] {
|
||||||
|
values.insert(english)
|
||||||
|
}
|
||||||
|
if let others = translations[key]?.values {
|
||||||
|
values.formUnion(others)
|
||||||
|
}
|
||||||
|
if key == .settings {
|
||||||
|
values.insert("gearshape")
|
||||||
|
}
|
||||||
|
return Array(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func button(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement {
|
||||||
|
let candidates = candidateStrings(for: key)
|
||||||
|
for candidate in candidates {
|
||||||
|
let element = query[candidate]
|
||||||
|
if element.exists {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let predicate = NSPredicate(
|
||||||
|
format: "label IN %@ OR identifier IN %@",
|
||||||
|
NSArray(array: candidates),
|
||||||
|
NSArray(array: candidates)
|
||||||
|
)
|
||||||
|
return query.matching(predicate).firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
|
||||||
|
let element = button(in: query, for: key)
|
||||||
|
return element.exists ? element : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
|
||||||
|
let tabSpecific = button(in: app.tabBars.buttons, for: key)
|
||||||
|
if tabSpecific.exists {
|
||||||
|
return tabSpecific
|
||||||
|
}
|
||||||
|
return button(in: app.buttons, for: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
|
||||||
|
let candidates = candidateStrings(for: key)
|
||||||
|
for candidate in candidates {
|
||||||
|
let bar = app.navigationBars[candidate]
|
||||||
|
if bar.exists {
|
||||||
|
return bar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app.navigationBars.element(boundBy: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
|
||||||
|
let candidates = candidateStrings(for: key)
|
||||||
|
for candidate in candidates {
|
||||||
|
let button = app.buttons[candidate]
|
||||||
|
if button.waitForExistence(timeout: 2) {
|
||||||
|
button.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openBillOfMaterials(app: XCUIApplication) {
|
||||||
|
let bomButton = button(in: app.buttons, for: .billOfMaterials)
|
||||||
|
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
|
||||||
|
bomButton.tap()
|
||||||
|
let bomView = app.otherElements["system-bom-view"]
|
||||||
|
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeBillOfMaterials(app: XCUIApplication) {
|
||||||
|
tapButtonIfPresent(app: app, key: .close)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigateBack(app: XCUIApplication) {
|
||||||
|
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||||
|
if backButton.exists {
|
||||||
|
backButton.tap()
|
||||||
|
} else {
|
||||||
|
app.swipeRight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSettings(app: XCUIApplication) {
|
||||||
|
let systemsBar = navigationBar(in: app, key: .systemsTitle)
|
||||||
|
let settingsButton = button(in: systemsBar.buttons, for: .settings)
|
||||||
|
if settingsButton.exists {
|
||||||
|
settingsButton.tap()
|
||||||
|
} else {
|
||||||
|
systemsBar.buttons.element(boundBy: 0).tap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureDoNotDisturbEnabled() {
|
private func ensureDoNotDisturbEnabled() {
|
||||||
@@ -41,6 +545,8 @@ final class CableUITestsScreenshot: XCTestCase {
|
|||||||
focusTile.press(forDuration: 1.0)
|
focusTile.press(forDuration: 1.0)
|
||||||
} else if focusButton.waitForExistence(timeout: 2) {
|
} else if focusButton.waitForExistence(timeout: 2) {
|
||||||
focusButton.press(forDuration: 1.0)
|
focusButton.press(forDuration: 1.0)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let dndButton = springboard.buttons["Do Not Disturb"]
|
let dndButton = springboard.buttons["Do Not Disturb"]
|
||||||
|
|||||||
@@ -8,7 +8,37 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||||
|
private func launchApp(arguments: [String] = []) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
var launchArguments = ["--uitest-reset-data"]
|
||||||
|
launchArguments.append(contentsOf: arguments)
|
||||||
|
app.launchArguments = launchArguments
|
||||||
|
app.launch()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["systems-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["systems-list"]
|
||||||
|
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["loads-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["loads-list"]
|
||||||
|
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private func takeScreenshot(name: String,
|
private func takeScreenshot(name: String,
|
||||||
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
||||||
@@ -30,66 +60,63 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testOnboardingLoadsView() throws {
|
func testOnboardingLoadsView() throws {
|
||||||
let app = XCUIApplication()
|
let app = launchApp(arguments: ["--uitest-reset-data"])
|
||||||
|
|
||||||
app.launch()
|
|
||||||
takeScreenshot(name: "01-OnboardingSystemsView")
|
takeScreenshot(name: "01-OnboardingSystemsView")
|
||||||
|
|
||||||
let createSystemButton = app.buttons["create-system-button"]
|
let createSystemButton = app.buttons["create-system-button"]
|
||||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||||
createSystemButton.tap()
|
createSystemButton.tap()
|
||||||
takeScreenshot(name: "02-OnboardingLoadsView")
|
takeScreenshot(name: "02-OnboardingLoadsView")
|
||||||
|
|
||||||
|
let componentsTab = app.buttons["components-tab"]
|
||||||
|
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
|
||||||
|
componentsTab.tap()
|
||||||
|
|
||||||
let libraryCloseButton = app.buttons["library-view-close-button"]
|
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||||
let selectComponentButton = app.buttons["select-component-button"]
|
let browseLibraryButton = onboardingSecondaryButton(in: app)
|
||||||
XCTAssertTrue(selectComponentButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5))
|
||||||
selectComponentButton.tap()
|
browseLibraryButton.tap()
|
||||||
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
|
||||||
Thread.sleep(forTimeInterval: 10)
|
Thread.sleep(forTimeInterval: 10)
|
||||||
takeScreenshot(name: "04-ComponentSelectorView")
|
takeScreenshot(name: "04-ComponentSelectorView")
|
||||||
libraryCloseButton.tap()
|
libraryCloseButton.tap()
|
||||||
|
|
||||||
let createComponentButton = app.buttons["create-component-button"]
|
let createComponentButton = onboardingPrimaryButton(in: app)
|
||||||
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
||||||
createComponentButton.tap()
|
createComponentButton.tap()
|
||||||
takeScreenshot(name: "03-LoadEditorView")
|
takeScreenshot(name: "03-LoadEditorView")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWithSampleData() throws {
|
func testWithSampleData() throws {
|
||||||
let app = XCUIApplication()
|
let app = launchApp(arguments: ["--uitest-sample-data"])
|
||||||
app.launchArguments.append("--uitest-sample-data")
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
let systemsCollection = app.collectionViews.firstMatch
|
let systemsList = resolvedSystemsList(in: app)
|
||||||
let collectionExists = systemsCollection.waitForExistence(timeout: 3)
|
|
||||||
|
|
||||||
let systemsList: XCUIElement
|
|
||||||
if collectionExists {
|
|
||||||
systemsList = systemsCollection
|
|
||||||
} else {
|
|
||||||
let table = app.tables.firstMatch
|
|
||||||
XCTAssertTrue(table.waitForExistence(timeout: 3))
|
|
||||||
systemsList = table
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||||
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
||||||
|
let systemName = firstSystemCell.staticTexts.firstMatch.label
|
||||||
|
|
||||||
takeScreenshot(name: "05-SystemsWithSampleData")
|
takeScreenshot(name: "05-SystemsWithSampleData")
|
||||||
|
|
||||||
firstSystemCell.tap()
|
let rowButton = firstSystemCell.buttons.firstMatch
|
||||||
|
if rowButton.waitForExistence(timeout: 2) {
|
||||||
let loadsCollection = app.collectionViews["loads-list"]
|
rowButton.tap()
|
||||||
let loadsTable = app.tables["loads-list"]
|
|
||||||
|
|
||||||
let loadsElement: XCUIElement
|
|
||||||
if loadsCollection.waitForExistence(timeout: 3) {
|
|
||||||
loadsElement = loadsCollection
|
|
||||||
} else {
|
} else {
|
||||||
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
|
firstSystemCell.tap()
|
||||||
loadsElement = loadsTable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let navButton = app.navigationBars.buttons[systemName]
|
||||||
|
if !navButton.waitForExistence(timeout: 3) {
|
||||||
|
let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||||
|
coordinate.tap()
|
||||||
|
XCTAssertTrue(navButton.waitForExistence(timeout: 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
tapComponentsTab(in: app)
|
||||||
|
|
||||||
|
let loadsElement = resolvedLoadsList(in: app)
|
||||||
|
XCTAssertTrue(loadsElement.waitForExistence(timeout: 6))
|
||||||
|
|
||||||
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
||||||
Thread.sleep(forTimeInterval: 1)
|
Thread.sleep(forTimeInterval: 1)
|
||||||
takeScreenshot(name: "06-AdventureVanLoads")
|
takeScreenshot(name: "06-AdventureVanLoads")
|
||||||
@@ -98,9 +125,42 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
|||||||
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
||||||
bomButton.tap()
|
bomButton.tap()
|
||||||
|
|
||||||
let bomView = app.otherElements["system-bom-view"]
|
// let bomView = app.otherElements["system-bom-view"]
|
||||||
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
// XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
||||||
Thread.sleep(forTimeInterval: 1)
|
//
|
||||||
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
// Thread.sleep(forTimeInterval: 1)
|
||||||
|
// takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapComponentsTab(in app: XCUIApplication) {
|
||||||
|
let button = componentsTabButton(in: app)
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 3))
|
||||||
|
button.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let button = app.buttons["create-component-button"]
|
||||||
|
if button.exists { return button }
|
||||||
|
return app.buttons["onboarding-primary-button"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let button = app.buttons["select-component-button"]
|
||||||
|
if button.exists { return button }
|
||||||
|
return app.buttons["onboarding-secondary-button"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let idButton = app.buttons["components-tab"]
|
||||||
|
if idButton.exists {
|
||||||
|
return idButton
|
||||||
|
}
|
||||||
|
|
||||||
|
let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"]
|
||||||
|
for label in labels {
|
||||||
|
let button = app.buttons[label]
|
||||||
|
if button.exists { return button }
|
||||||
|
}
|
||||||
|
return app.tabBars.buttons.element(boundBy: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
Podfile
1
Podfile
@@ -6,7 +6,6 @@ target 'Cable' do
|
|||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
|
||||||
# Pods for Cable
|
# Pods for Cable
|
||||||
pod "PostHog", "~> 3.0"
|
|
||||||
target 'CableTests' do
|
target 'CableTests' do
|
||||||
inherit! :search_paths
|
inherit! :search_paths
|
||||||
# Pods for testing
|
# Pods for testing
|
||||||
|
|||||||
16
Podfile.lock
16
Podfile.lock
@@ -1,16 +0,0 @@
|
|||||||
PODS:
|
|
||||||
- PostHog (3.34.0)
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
|
||||||
- PostHog (~> 3.0)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- PostHog
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
|
||||||
PostHog: bbb7eaecb2f5a286d9da3c833cbb18ae08799655
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: b59bd921fb9a91e981795c18b6ff238370434172
|
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
|
||||||
16
Pods/Manifest.lock
generated
16
Pods/Manifest.lock
generated
@@ -1,16 +0,0 @@
|
|||||||
PODS:
|
|
||||||
- PostHog (3.34.0)
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
|
||||||
- PostHog (~> 3.0)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- PostHog
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
|
||||||
PostHog: bbb7eaecb2f5a286d9da3c833cbb18ae08799655
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: b59bd921fb9a91e981795c18b6ff238370434172
|
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
|
||||||
2254
Pods/Pods.xcodeproj/project.pbxproj
generated
2254
Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1600"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "6835ABF5E9176D16603D3FAED02C1229"
|
|
||||||
BuildableName = "Pods_Cable_CableUITests.framework"
|
|
||||||
BlueprintName = "Pods-Cable-CableUITests"
|
|
||||||
ReferencedContainer = "container:Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1600"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "86C5E834AAC4A69D5D37D96BAF8B8330"
|
|
||||||
BuildableName = "Pods_Cable_CableUITestsScreenshot.framework"
|
|
||||||
BlueprintName = "Pods-Cable-CableUITestsScreenshot"
|
|
||||||
ReferencedContainer = "container:Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1600"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "8012054959C338A62834AD9706977FB0"
|
|
||||||
BuildableName = "Pods_Cable.framework"
|
|
||||||
BlueprintName = "Pods-Cable"
|
|
||||||
ReferencedContainer = "container:Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1600"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "52F449E691F258410D3E74F5BAFD41CD"
|
|
||||||
BuildableName = "Pods_CableTests.framework"
|
|
||||||
BlueprintName = "Pods-CableTests"
|
|
||||||
ReferencedContainer = "container:Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1600"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "E326EE08AE4CF9FA8C947B96B6F8AB07"
|
|
||||||
BuildableName = "PostHog.bundle"
|
|
||||||
BlueprintName = "PostHog-PostHog"
|
|
||||||
ReferencedContainer = "container:Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1600"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "8879D5F28A55518ACFB247594F87F75A"
|
|
||||||
BuildableName = "PostHog.framework"
|
|
||||||
BlueprintName = "PostHog"
|
|
||||||
ReferencedContainer = "container:Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>SchemeUserState</key>
|
|
||||||
<dict>
|
|
||||||
<key>Pods-Cable-CableUITests.xcscheme</key>
|
|
||||||
<dict>
|
|
||||||
<key>isShown</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
<key>Pods-Cable-CableUITestsScreenshot.xcscheme</key>
|
|
||||||
<dict>
|
|
||||||
<key>isShown</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
<key>Pods-Cable.xcscheme</key>
|
|
||||||
<dict>
|
|
||||||
<key>isShown</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
<key>Pods-CableTests.xcscheme</key>
|
|
||||||
<dict>
|
|
||||||
<key>isShown</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
<key>PostHog-PostHog.xcscheme</key>
|
|
||||||
<dict>
|
|
||||||
<key>isShown</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
<key>PostHog.xcscheme</key>
|
|
||||||
<dict>
|
|
||||||
<key>isShown</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>SuppressBuildableAutocreation</key>
|
|
||||||
<dict/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
21
Pods/PostHog/LICENSE
generated
21
Pods/PostHog/LICENSE
generated
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) [2023] [PostHog]
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
//
|
|
||||||
// ApplicationLifecyclePublisher.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 16/12/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#elseif os(watchOS)
|
|
||||||
import WatchKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typealias AppLifecycleHandler = () -> Void
|
|
||||||
|
|
||||||
protocol AppLifecyclePublishing: AnyObject {
|
|
||||||
/// Registers a callback for the `didBecomeActive` event.
|
|
||||||
func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
|
|
||||||
/// Registers a callback for the `didEnterBackground` event.
|
|
||||||
func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
|
|
||||||
/// Registers a callback for the `didFinishLaunching` event.
|
|
||||||
func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
A publisher that handles application lifecycle events and allows registering callbacks for them.
|
|
||||||
|
|
||||||
This class provides a way to observe application lifecycle events like when the app becomes active,
|
|
||||||
enters background, or finishes launching. Callbacks can be registered for each event type and will
|
|
||||||
be automatically unregistered when their registration token is deallocated.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```
|
|
||||||
let token = ApplicationLifecyclePublisher.shared.onDidBecomeActive {
|
|
||||||
// App became active logic
|
|
||||||
}
|
|
||||||
// Keep `token` in memory to keep the registration active
|
|
||||||
// When token is deallocated, the callback will be automatically unregistered
|
|
||||||
```
|
|
||||||
*/
|
|
||||||
final class ApplicationLifecyclePublisher: BaseApplicationLifecyclePublisher {
|
|
||||||
/// Shared instance to allow easy access across the app.
|
|
||||||
static let shared = ApplicationLifecyclePublisher()
|
|
||||||
|
|
||||||
override private init() {
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
let defaultCenter = NotificationCenter.default
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidFinishLaunching),
|
|
||||||
name: UIApplication.didFinishLaunchingNotification,
|
|
||||||
object: nil)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidEnterBackground),
|
|
||||||
name: UIApplication.didEnterBackgroundNotification,
|
|
||||||
object: nil)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
#elseif os(visionOS)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidFinishLaunching),
|
|
||||||
name: UIApplication.didFinishLaunchingNotification,
|
|
||||||
object: nil)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidEnterBackground),
|
|
||||||
name: UIScene.willDeactivateNotification,
|
|
||||||
object: nil)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidBecomeActive),
|
|
||||||
name: UIScene.didActivateNotification,
|
|
||||||
object: nil)
|
|
||||||
#elseif os(macOS)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidFinishLaunching),
|
|
||||||
name: NSApplication.didFinishLaunchingNotification,
|
|
||||||
object: nil)
|
|
||||||
// macOS does not have didEnterBackgroundNotification, so we use didResignActiveNotification
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidEnterBackground),
|
|
||||||
name: NSApplication.didResignActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
defaultCenter.addObserver(self,
|
|
||||||
selector: #selector(appDidBecomeActive),
|
|
||||||
name: NSApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
#elseif os(watchOS)
|
|
||||||
if #available(watchOS 7.0, *) {
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(appDidBecomeActive),
|
|
||||||
name: WKApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
} else {
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(appDidBecomeActive),
|
|
||||||
name: .init("UIApplicationDidBecomeActiveNotification"),
|
|
||||||
object: nil)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Handlers
|
|
||||||
|
|
||||||
@objc private func appDidEnterBackground() {
|
|
||||||
notifyHandlers(didEnterBackgroundHandlers)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func appDidBecomeActive() {
|
|
||||||
notifyHandlers(didBecomeActiveHandlers)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func appDidFinishLaunching() {
|
|
||||||
notifyHandlers(didFinishLaunchingHandlers)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyHandlers(_ handlers: [AppLifecycleHandler]) {
|
|
||||||
for handler in handlers {
|
|
||||||
notifyHander(handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyHander(_ handler: @escaping AppLifecycleHandler) {
|
|
||||||
if Thread.isMainThread {
|
|
||||||
handler()
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async(execute: handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BaseApplicationLifecyclePublisher: AppLifecyclePublishing {
|
|
||||||
private let registrationLock = NSLock()
|
|
||||||
|
|
||||||
private var didBecomeActiveCallbacks: [UUID: AppLifecycleHandler] = [:]
|
|
||||||
private var didEnterBackgroundCallbacks: [UUID: AppLifecycleHandler] = [:]
|
|
||||||
private var didFinishLaunchingCallbacks: [UUID: AppLifecycleHandler] = [:]
|
|
||||||
|
|
||||||
var didBecomeActiveHandlers: [AppLifecycleHandler] {
|
|
||||||
registrationLock.withLock { Array(didBecomeActiveCallbacks.values) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var didEnterBackgroundHandlers: [AppLifecycleHandler] {
|
|
||||||
registrationLock.withLock { Array(didEnterBackgroundCallbacks.values) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var didFinishLaunchingHandlers: [AppLifecycleHandler] {
|
|
||||||
registrationLock.withLock { Array(didFinishLaunchingCallbacks.values) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a callback for the `didBecomeActive` event.
|
|
||||||
func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
|
|
||||||
register(handler: callback, on: \.didBecomeActiveCallbacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a callback for the `didEnterBackground` event.
|
|
||||||
func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
|
|
||||||
register(handler: callback, on: \.didEnterBackgroundCallbacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a callback for the `didFinishLaunching` event.
|
|
||||||
func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
|
|
||||||
register(handler: callback, on: \.didFinishLaunchingCallbacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func register(
|
|
||||||
handler callback: @escaping AppLifecycleHandler,
|
|
||||||
on keyPath: ReferenceWritableKeyPath<BaseApplicationLifecyclePublisher, [UUID: AppLifecycleHandler]>
|
|
||||||
) -> RegistrationToken {
|
|
||||||
let id = UUID()
|
|
||||||
registrationLock.withLock {
|
|
||||||
self[keyPath: keyPath][id] = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
return RegistrationToken { [weak self] in
|
|
||||||
// Registration token deallocated here
|
|
||||||
guard let self else { return }
|
|
||||||
self.registrationLock.withLock {
|
|
||||||
self[keyPath: keyPath][id] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class RegistrationToken {
|
|
||||||
private let onDealloc: () -> Void
|
|
||||||
|
|
||||||
init(_ onDealloc: @escaping () -> Void) {
|
|
||||||
self.onDealloc = onDealloc
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
onDealloc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogAppLifeCycleIntegration.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 19/02/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/**
|
|
||||||
Add capability to capture application lifecycle events.
|
|
||||||
|
|
||||||
This integration:
|
|
||||||
- captures an `App Installed` event on the first launch of the app
|
|
||||||
- captures an `App Updated` event on any subsequent launch with a different version
|
|
||||||
- captures an `App Opened` event when the app is opened (including the first launch)
|
|
||||||
- captures an `App Backgrounded` event when the app moves to the background
|
|
||||||
*/
|
|
||||||
final class PostHogAppLifeCycleIntegration: PostHogIntegration {
|
|
||||||
var requiresSwizzling: Bool { false }
|
|
||||||
|
|
||||||
private static var integrationInstalledLock = NSLock()
|
|
||||||
private static var integrationInstalled = false
|
|
||||||
private static var didCaptureAppInstallOrUpdate = false
|
|
||||||
|
|
||||||
private weak var postHog: PostHogSDK?
|
|
||||||
|
|
||||||
// True if the app is launched for the first time
|
|
||||||
private var isFreshAppLaunch = true
|
|
||||||
// Manually maintained flag to determine background status of the app
|
|
||||||
private var isAppBackgrounded: Bool = true
|
|
||||||
|
|
||||||
private var didBecomeActiveToken: RegistrationToken?
|
|
||||||
private var didEnterBackgroundToken: RegistrationToken?
|
|
||||||
private var didFinishLaunchingToken: RegistrationToken?
|
|
||||||
|
|
||||||
func install(_ postHog: PostHogSDK) throws {
|
|
||||||
try PostHogAppLifeCycleIntegration.integrationInstalledLock.withLock {
|
|
||||||
if PostHogAppLifeCycleIntegration.integrationInstalled {
|
|
||||||
throw InternalPostHogError(description: "App life cycle integration already installed to another PostHogSDK instance.")
|
|
||||||
}
|
|
||||||
PostHogAppLifeCycleIntegration.integrationInstalled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
self.postHog = postHog
|
|
||||||
|
|
||||||
start()
|
|
||||||
captureAppInstallOrUpdated()
|
|
||||||
}
|
|
||||||
|
|
||||||
func uninstall(_ postHog: PostHogSDK) {
|
|
||||||
// uninstall only for integration instance
|
|
||||||
if self.postHog === postHog || self.postHog == nil {
|
|
||||||
stop()
|
|
||||||
self.postHog = nil
|
|
||||||
PostHogAppLifeCycleIntegration.integrationInstalledLock.withLock {
|
|
||||||
PostHogAppLifeCycleIntegration.integrationInstalled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Start capturing app lifecycles events
|
|
||||||
*/
|
|
||||||
func start() {
|
|
||||||
let publisher = DI.main.appLifecyclePublisher
|
|
||||||
didFinishLaunchingToken = publisher.onDidFinishLaunching { [weak self] in
|
|
||||||
self?.captureAppInstallOrUpdated()
|
|
||||||
}
|
|
||||||
didBecomeActiveToken = publisher.onDidBecomeActive { [weak self] in
|
|
||||||
self?.captureAppOpened()
|
|
||||||
}
|
|
||||||
didEnterBackgroundToken = publisher.onDidEnterBackground { [weak self] in
|
|
||||||
self?.captureAppBackgrounded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Stop capturing app lifecycle events
|
|
||||||
*/
|
|
||||||
func stop() {
|
|
||||||
didFinishLaunchingToken = nil
|
|
||||||
didBecomeActiveToken = nil
|
|
||||||
didEnterBackgroundToken = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func captureAppInstallOrUpdated() {
|
|
||||||
// Check if Application Installed or Application Updated was already checked in the lifecycle of this app
|
|
||||||
// This can be called multiple times in case of optOut, multiple instances or start/stop integration
|
|
||||||
guard let postHog, !PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate else { return }
|
|
||||||
|
|
||||||
PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate = true
|
|
||||||
|
|
||||||
if !postHog.config.captureApplicationLifecycleEvents {
|
|
||||||
hedgeLog("Skipping Application Installed/Application Updated event - captureApplicationLifecycleEvents is disabled in configuration")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let bundle = Bundle.main
|
|
||||||
|
|
||||||
let versionName = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
|
|
||||||
let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String
|
|
||||||
|
|
||||||
// capture app installed/updated
|
|
||||||
let userDefaults = UserDefaults.standard
|
|
||||||
|
|
||||||
let previousVersion = userDefaults.string(forKey: "PHGVersionKey")
|
|
||||||
let previousVersionCode = userDefaults.string(forKey: "PHGBuildKeyV2")
|
|
||||||
|
|
||||||
var props: [String: Any] = [:]
|
|
||||||
var event: String
|
|
||||||
if previousVersionCode == nil {
|
|
||||||
// installed
|
|
||||||
event = "Application Installed"
|
|
||||||
} else {
|
|
||||||
event = "Application Updated"
|
|
||||||
|
|
||||||
// Do not send version updates if its the same
|
|
||||||
if previousVersionCode == versionCode {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if previousVersion != nil {
|
|
||||||
props["previous_version"] = previousVersion
|
|
||||||
}
|
|
||||||
props["previous_build"] = previousVersionCode
|
|
||||||
}
|
|
||||||
|
|
||||||
var syncDefaults = false
|
|
||||||
if versionName != nil {
|
|
||||||
props["version"] = versionName
|
|
||||||
userDefaults.setValue(versionName, forKey: "PHGVersionKey")
|
|
||||||
syncDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if versionCode != nil {
|
|
||||||
props["build"] = versionCode
|
|
||||||
userDefaults.setValue(versionCode, forKey: "PHGBuildKeyV2")
|
|
||||||
syncDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if syncDefaults {
|
|
||||||
userDefaults.synchronize()
|
|
||||||
}
|
|
||||||
|
|
||||||
postHog.capture(event, properties: props)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func captureAppOpened() {
|
|
||||||
guard let postHog else { return }
|
|
||||||
|
|
||||||
guard isAppBackgrounded else {
|
|
||||||
hedgeLog("Skipping Application Opened event - app already in foreground")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isAppBackgrounded = false
|
|
||||||
|
|
||||||
if !postHog.config.captureApplicationLifecycleEvents {
|
|
||||||
hedgeLog("Skipping Application Opened event - captureApplicationLifecycleEvents is disabled in configuration")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var props: [String: Any] = [:]
|
|
||||||
props["from_background"] = !isFreshAppLaunch
|
|
||||||
|
|
||||||
if isFreshAppLaunch {
|
|
||||||
let bundle = Bundle.main
|
|
||||||
|
|
||||||
let versionName = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
|
|
||||||
let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String
|
|
||||||
|
|
||||||
if versionName != nil {
|
|
||||||
props["version"] = versionName
|
|
||||||
}
|
|
||||||
if versionCode != nil {
|
|
||||||
props["build"] = versionCode
|
|
||||||
}
|
|
||||||
|
|
||||||
isFreshAppLaunch = false
|
|
||||||
}
|
|
||||||
|
|
||||||
postHog.capture("Application Opened", properties: props)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func captureAppBackgrounded() {
|
|
||||||
guard let postHog else { return }
|
|
||||||
|
|
||||||
guard !isAppBackgrounded else {
|
|
||||||
hedgeLog("Skipping Application Opened event - app already in background")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isAppBackgrounded = true
|
|
||||||
|
|
||||||
if !postHog.config.captureApplicationLifecycleEvents {
|
|
||||||
hedgeLog("Skipping Application Backgrounded event - captureApplicationLifecycleEvents is disabled in configuration")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
postHog.capture("Application Backgrounded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if TESTING
|
|
||||||
extension PostHogAppLifeCycleIntegration {
|
|
||||||
static func clearInstalls() {
|
|
||||||
PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate = false
|
|
||||||
integrationInstalledLock.withLock {
|
|
||||||
integrationInstalled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
//
|
|
||||||
// ApplicationViewLayoutPublisher.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 19/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
typealias ApplicationViewLayoutHandler = () -> Void
|
|
||||||
|
|
||||||
protocol ViewLayoutPublishing: AnyObject {
|
|
||||||
/// Registers a callback for getting notified when a UIView is laid out.
|
|
||||||
/// Note: callback guaranteed to be called on main thread
|
|
||||||
func onViewLayout(throttle: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken
|
|
||||||
}
|
|
||||||
|
|
||||||
final class ApplicationViewLayoutPublisher: BaseApplicationViewLayoutPublisher {
|
|
||||||
static let shared = ApplicationViewLayoutPublisher()
|
|
||||||
|
|
||||||
private var hasSwizzled: Bool = false
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
swizzleLayoutSubviews()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
unswizzleLayoutSubviews()
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzleLayoutSubviews() {
|
|
||||||
guard !hasSwizzled else { return }
|
|
||||||
hasSwizzled = true
|
|
||||||
|
|
||||||
swizzle(
|
|
||||||
forClass: UIView.self,
|
|
||||||
original: #selector(UIView.layoutSublayers(of:)),
|
|
||||||
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func unswizzleLayoutSubviews() {
|
|
||||||
guard hasSwizzled else { return }
|
|
||||||
hasSwizzled = false
|
|
||||||
|
|
||||||
// swizzling twice will exchange implementations back to original
|
|
||||||
swizzle(
|
|
||||||
forClass: UIView.self,
|
|
||||||
original: #selector(UIView.layoutSublayers(of:)),
|
|
||||||
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
|
|
||||||
let id = UUID()
|
|
||||||
registrationLock.withLock {
|
|
||||||
self.onViewLayoutCallbacks[id] = ThrottledHandler(handler: callback, interval: interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
// start on first callback registration
|
|
||||||
if !hasSwizzled {
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
return RegistrationToken { [weak self] in
|
|
||||||
// Registration token deallocated here
|
|
||||||
guard let self else { return }
|
|
||||||
let handlerCount = self.registrationLock.withLock {
|
|
||||||
self.onViewLayoutCallbacks[id] = nil
|
|
||||||
return self.onViewLayoutCallbacks.values.count
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop when there are no more callbacks
|
|
||||||
if handlerCount <= 0 {
|
|
||||||
self.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called from swizzled `UIView.layoutSubviews`
|
|
||||||
fileprivate func layoutSubviews() {
|
|
||||||
notifyHandlers()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if TESTING
|
|
||||||
func simulateLayoutSubviews() {
|
|
||||||
layoutSubviews()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
class BaseApplicationViewLayoutPublisher: ViewLayoutPublishing {
|
|
||||||
fileprivate let registrationLock = NSLock()
|
|
||||||
|
|
||||||
var onViewLayoutCallbacks: [UUID: ThrottledHandler] = [:]
|
|
||||||
|
|
||||||
final class ThrottledHandler {
|
|
||||||
static let throttleQueue = DispatchQueue(label: "com.posthog.ThrottledHandler",
|
|
||||||
target: .global(qos: .utility))
|
|
||||||
|
|
||||||
let interval: TimeInterval
|
|
||||||
let handler: ApplicationViewLayoutHandler
|
|
||||||
|
|
||||||
private var lastFired: Date = .distantPast
|
|
||||||
|
|
||||||
init(handler: @escaping ApplicationViewLayoutHandler, interval: TimeInterval) {
|
|
||||||
self.handler = handler
|
|
||||||
self.interval = interval
|
|
||||||
}
|
|
||||||
|
|
||||||
func throttleHandler() {
|
|
||||||
let now = now()
|
|
||||||
let timeSinceLastFired = now.timeIntervalSince(lastFired)
|
|
||||||
|
|
||||||
if timeSinceLastFired >= interval {
|
|
||||||
lastFired = now
|
|
||||||
// notify on main
|
|
||||||
DispatchQueue.main.async(execute: handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
|
|
||||||
let id = UUID()
|
|
||||||
registrationLock.withLock {
|
|
||||||
self.onViewLayoutCallbacks[id] = ThrottledHandler(
|
|
||||||
handler: callback,
|
|
||||||
interval: interval
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return RegistrationToken { [weak self] in
|
|
||||||
// Registration token deallocated here
|
|
||||||
guard let self else { return }
|
|
||||||
self.registrationLock.withLock {
|
|
||||||
self.onViewLayoutCallbacks[id] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func notifyHandlers() {
|
|
||||||
ThrottledHandler.throttleQueue.async {
|
|
||||||
// Don't lock on main
|
|
||||||
let handlers = self.registrationLock.withLock { self.onViewLayoutCallbacks.values }
|
|
||||||
for handler in handlers {
|
|
||||||
handler.throttleHandler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIView {
|
|
||||||
@objc func ph_swizzled_layoutSublayers(of layer: CALayer) {
|
|
||||||
ph_swizzled_layoutSublayers(of: layer) // call original, not altering execution logic
|
|
||||||
ApplicationViewLayoutPublisher.shared.layoutSubviews()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// AutocaptureEventProcessing.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 30/10/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
protocol AutocaptureEventProcessing: AnyObject {
|
|
||||||
func process(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//
|
|
||||||
// ForwardingPickerViewDelegate.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 24/10/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class ForwardingPickerViewDelegate: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
|
|
||||||
// this needs to be weak since `actualDelegate` will hold a strong reference to `ForwardingPickerViewDelegate`
|
|
||||||
weak var actualDelegate: UIPickerViewDelegate?
|
|
||||||
private var valueChangedCallback: (() -> Void)?
|
|
||||||
|
|
||||||
// We respond to the same selectors that the original delegate responds to
|
|
||||||
override func responds(to aSelector: Selector!) -> Bool {
|
|
||||||
actualDelegate?.responds(to: aSelector) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
init(delegate: UIPickerViewDelegate?, onValueChanged: @escaping () -> Void) {
|
|
||||||
actualDelegate = delegate
|
|
||||||
valueChangedCallback = onValueChanged
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UIPickerViewDataSource
|
|
||||||
|
|
||||||
func numberOfComponents(in pickerView: UIPickerView) -> Int {
|
|
||||||
(actualDelegate as? UIPickerViewDataSource)?.numberOfComponents(in: pickerView) ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
|
|
||||||
(actualDelegate as? UIPickerViewDataSource)?.pickerView(pickerView, numberOfRowsInComponent: component) ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UIPickerViewDelegate
|
|
||||||
|
|
||||||
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
|
|
||||||
valueChangedCallback?()
|
|
||||||
actualDelegate?.pickerView?(pickerView, didSelectRow: row, inComponent: component)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
|
|
||||||
actualDelegate?.pickerView?(pickerView, viewForRow: row, forComponent: component, reusing: view) ?? UIView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
|
|
||||||
actualDelegate?.pickerView?(pickerView, widthForComponent: component) ?? .zero
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
|
|
||||||
actualDelegate?.pickerView?(pickerView, rowHeightForComponent: component) ?? .zero
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
|
|
||||||
actualDelegate?.pickerView?(pickerView, titleForRow: row, forComponent: component)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
|
|
||||||
actualDelegate?.pickerView?(pickerView, attributedTitleForRow: row, forComponent: component)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIPickerViewDelegate {
|
|
||||||
var ph_forwardingDelegate: UIPickerViewDelegate? {
|
|
||||||
get { objc_getAssociatedObject(self, &AssociatedKeys.phForwardingDelegate) as? UIPickerViewDelegate }
|
|
||||||
set { objc_setAssociatedObject(self, &AssociatedKeys.phForwardingDelegate, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,606 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogAutocaptureEventTracker.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 14/10/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class PostHogAutocaptureEventTracker {
|
|
||||||
struct EventData {
|
|
||||||
let touchCoordinates: CGPoint?
|
|
||||||
let value: String?
|
|
||||||
let screenName: String?
|
|
||||||
let viewHierarchy: [Element]
|
|
||||||
// values >0 means that this event will be debounced for `debounceInterval`
|
|
||||||
let debounceInterval: TimeInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Element {
|
|
||||||
let text: String
|
|
||||||
let targetClass: String
|
|
||||||
let baseClass: String?
|
|
||||||
let label: String?
|
|
||||||
|
|
||||||
var elementsChainEntry: String {
|
|
||||||
var attributes = [String]()
|
|
||||||
|
|
||||||
if !text.isEmpty {
|
|
||||||
attributes.append("text=\(text.quoted)")
|
|
||||||
}
|
|
||||||
if let baseClass, !baseClass.isEmpty {
|
|
||||||
attributes.append("attr__class=\(baseClass.quoted)")
|
|
||||||
}
|
|
||||||
if let label, !label.isEmpty {
|
|
||||||
attributes.append("attr_id=\(label.quoted)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributes.isEmpty ? targetClass : "\(targetClass):\(attributes.joined())"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum EventSource {
|
|
||||||
case notification(name: String)
|
|
||||||
case actionMethod(description: String)
|
|
||||||
case gestureRecognizer(description: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
static var eventProcessor: (any AutocaptureEventProcessing)? {
|
|
||||||
willSet {
|
|
||||||
if newValue != nil {
|
|
||||||
swizzle()
|
|
||||||
} else {
|
|
||||||
unswizzle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var hasSwizzled: Bool = false
|
|
||||||
private static func swizzle() {
|
|
||||||
guard !hasSwizzled else { return }
|
|
||||||
hasSwizzled = true
|
|
||||||
swizzleMethods()
|
|
||||||
registerNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func unswizzle() {
|
|
||||||
guard hasSwizzled else { return }
|
|
||||||
hasSwizzled = false
|
|
||||||
swizzleMethods() // swizzling again will exchange implementations back to original
|
|
||||||
unregisterNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func swizzleMethods() {
|
|
||||||
PostHog.swizzle(
|
|
||||||
forClass: UIApplication.self,
|
|
||||||
original: #selector(UIApplication.sendAction),
|
|
||||||
new: #selector(UIApplication.ph_swizzled_uiapplication_sendAction)
|
|
||||||
)
|
|
||||||
|
|
||||||
PostHog.swizzle(
|
|
||||||
forClass: UIGestureRecognizer.self,
|
|
||||||
original: #selector(setter: UIGestureRecognizer.state),
|
|
||||||
new: #selector(UIGestureRecognizer.ph_swizzled_uigesturerecognizer_state_Setter)
|
|
||||||
)
|
|
||||||
|
|
||||||
PostHog.swizzle(
|
|
||||||
forClass: UIScrollView.self,
|
|
||||||
original: #selector(setter: UIScrollView.contentOffset),
|
|
||||||
new: #selector(UIScrollView.ph_swizzled_setContentOffset_Setter)
|
|
||||||
)
|
|
||||||
|
|
||||||
PostHog.swizzle(
|
|
||||||
forClass: UIPickerView.self,
|
|
||||||
original: #selector(setter: UIPickerView.delegate),
|
|
||||||
new: #selector(UIPickerView.ph_swizzled_setDelegate)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func registerNotifications() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
PostHogAutocaptureEventTracker.self,
|
|
||||||
selector: #selector(didEndEditing),
|
|
||||||
name: UITextField.textDidEndEditingNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
PostHogAutocaptureEventTracker.self,
|
|
||||||
selector: #selector(didEndEditing),
|
|
||||||
name: UITextView.textDidEndEditingNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func unregisterNotifications() {
|
|
||||||
NotificationCenter.default.removeObserver(PostHogAutocaptureEventTracker.self, name: UITextField.textDidEndEditingNotification, object: nil)
|
|
||||||
NotificationCenter.default.removeObserver(PostHogAutocaptureEventTracker.self, name: UITextView.textDidEndEditingNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// `UITextField` or `UITextView` did end editing notification
|
|
||||||
@objc static func didEndEditing(_ notification: NSNotification) {
|
|
||||||
guard let view = notification.object as? UIView, let eventData = view.eventData else { return }
|
|
||||||
|
|
||||||
eventProcessor?.process(source: .notification(name: "change"), event: eventData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIApplication {
|
|
||||||
@objc func ph_swizzled_uiapplication_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool {
|
|
||||||
defer {
|
|
||||||
// Currently, the action methods pointing to a SwiftUI target are blocked.
|
|
||||||
let targetClass = String(describing: object_getClassName(target))
|
|
||||||
if targetClass.contains("SwiftUI") {
|
|
||||||
hedgeLog("Action methods on SwiftUI targets are not yet supported.")
|
|
||||||
} else if let control = sender as? UIControl,
|
|
||||||
control.ph_shouldTrack(action, for: target),
|
|
||||||
let eventData = control.eventData,
|
|
||||||
let eventDescription = control.event(for: action, to: target)?.description(forControl: control)
|
|
||||||
{
|
|
||||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .actionMethod(description: eventDescription), event: eventData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// first, call original method
|
|
||||||
return ph_swizzled_uiapplication_sendAction(action, to: target, from: sender, for: event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIGestureRecognizer {
|
|
||||||
// swiftlint:disable:next cyclomatic_complexity
|
|
||||||
@objc func ph_swizzled_uigesturerecognizer_state_Setter(_ state: UIGestureRecognizer.State) {
|
|
||||||
// first, call original method
|
|
||||||
ph_swizzled_uigesturerecognizer_state_Setter(state)
|
|
||||||
|
|
||||||
guard state == .ended, let view, shouldTrack(view) else { return }
|
|
||||||
|
|
||||||
// block scroll and zoom gestures for `UIScrollView`.
|
|
||||||
if let scrollView = view as? UIScrollView {
|
|
||||||
if self === scrollView.panGestureRecognizer {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#if !os(tvOS)
|
|
||||||
if self === scrollView.pinchGestureRecognizer {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// block all gestures for `UISwitch` (already captured via `.valueChanged` action)
|
|
||||||
if String(describing: type(of: view)).starts(with: "UISwitch") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// ignore gestures in `UIPickerColumnView`
|
|
||||||
if String(describing: type(of: view)) == "UIPickerColumnView" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let gestureDescription: String?
|
|
||||||
switch self {
|
|
||||||
case is UITapGestureRecognizer:
|
|
||||||
gestureDescription = EventType.kTouch
|
|
||||||
case is UISwipeGestureRecognizer:
|
|
||||||
gestureDescription = EventType.kSwipe
|
|
||||||
case is UIPanGestureRecognizer:
|
|
||||||
gestureDescription = EventType.kPan
|
|
||||||
case is UILongPressGestureRecognizer:
|
|
||||||
gestureDescription = EventType.kLongPress
|
|
||||||
#if !os(tvOS)
|
|
||||||
case is UIPinchGestureRecognizer:
|
|
||||||
gestureDescription = EventType.kPinch
|
|
||||||
case is UIRotationGestureRecognizer:
|
|
||||||
gestureDescription = EventType.kRotation
|
|
||||||
case is UIScreenEdgePanGestureRecognizer:
|
|
||||||
gestureDescription = EventType.kPan
|
|
||||||
#endif
|
|
||||||
default:
|
|
||||||
gestureDescription = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let gestureDescription else { return }
|
|
||||||
|
|
||||||
if let eventData = view.eventData {
|
|
||||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: gestureDescription), event: eventData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIScrollView {
|
|
||||||
@objc func ph_swizzled_setContentOffset_Setter(_ newContentOffset: CGPoint) {
|
|
||||||
// first, call original method
|
|
||||||
ph_swizzled_setContentOffset_Setter(newContentOffset)
|
|
||||||
|
|
||||||
guard shouldTrack(self) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore all keyboard events
|
|
||||||
if let window, window.isKeyboardWindow {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// scrollview did not scroll (contentOffset didn't change)
|
|
||||||
guard contentOffset != newContentOffset else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// block scrolls on UIPickerTableView. (captured via a forwarding delegate implementation)
|
|
||||||
if String(describing: type(of: self)) == "UIPickerTableView" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let eventData {
|
|
||||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: EventType.kScroll), event: eventData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIPickerView {
|
|
||||||
@objc func ph_swizzled_setDelegate(_ delegate: (any UIPickerViewDelegate)?) {
|
|
||||||
guard let delegate else {
|
|
||||||
// this just removes the delegate
|
|
||||||
return ph_swizzled_setDelegate(delegate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if delegate doesn't respond to this selector, then we can't intercept selection changes
|
|
||||||
guard delegate.responds(to: #selector(UIPickerViewDelegate.pickerView(_:didSelectRow:inComponent:))) else {
|
|
||||||
return ph_swizzled_setDelegate(delegate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrap in a forwarding delegate so we can intercept calls
|
|
||||||
let forwardingDelegate = ForwardingPickerViewDelegate(delegate: delegate) { [weak self] in
|
|
||||||
if let data = self?.eventData {
|
|
||||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: EventType.kValueChange), event: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to keep a strong reference to keep this forwarding delegate instance alive
|
|
||||||
delegate.ph_forwardingDelegate = forwardingDelegate
|
|
||||||
|
|
||||||
// call original setter
|
|
||||||
ph_swizzled_setDelegate(forwardingDelegate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIView {
|
|
||||||
var eventData: PostHogAutocaptureEventTracker.EventData? {
|
|
||||||
guard shouldTrack(self) else { return nil }
|
|
||||||
return PostHogAutocaptureEventTracker.EventData(
|
|
||||||
touchCoordinates: nil,
|
|
||||||
value: ph_autocaptureText
|
|
||||||
.map(sanitizeText),
|
|
||||||
screenName: nearestViewController
|
|
||||||
.flatMap(UIViewController.ph_topViewController)
|
|
||||||
.flatMap(UIViewController.getViewControllerName),
|
|
||||||
viewHierarchy: sequence(first: self, next: \.superview)
|
|
||||||
.map(\.toElement),
|
|
||||||
debounceInterval: ph_autocaptureDebounceInterval
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension UIView {
|
|
||||||
var toElement: PostHogAutocaptureEventTracker.Element {
|
|
||||||
PostHogAutocaptureEventTracker.Element(
|
|
||||||
text: ph_autocaptureText.map(sanitizeText) ?? "",
|
|
||||||
targetClass: descriptiveTypeName,
|
|
||||||
baseClass: baseTypeName,
|
|
||||||
label: postHogLabel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIControl {
|
|
||||||
func event(for action: Selector, to target: Any?) -> UIControl.Event? {
|
|
||||||
var events: [UIControl.Event] = [
|
|
||||||
.valueChanged,
|
|
||||||
.touchDown,
|
|
||||||
.touchDownRepeat,
|
|
||||||
.touchDragInside,
|
|
||||||
.touchDragOutside,
|
|
||||||
.touchDragEnter,
|
|
||||||
.touchDragExit,
|
|
||||||
.touchUpInside,
|
|
||||||
.touchUpOutside,
|
|
||||||
.touchCancel,
|
|
||||||
.editingDidBegin,
|
|
||||||
.editingChanged,
|
|
||||||
.editingDidEnd,
|
|
||||||
.editingDidEndOnExit,
|
|
||||||
.primaryActionTriggered,
|
|
||||||
]
|
|
||||||
|
|
||||||
if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *) {
|
|
||||||
events.append(.menuActionTriggered)
|
|
||||||
}
|
|
||||||
|
|
||||||
// latest event for action
|
|
||||||
return events.first { event in
|
|
||||||
self.actions(forTarget: target, forControlEvent: event)?.contains(action.description) ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIControl.Event {
|
|
||||||
// swiftlint:disable:next cyclomatic_complexity
|
|
||||||
func description(forControl control: UIControl) -> String? {
|
|
||||||
if self == .primaryActionTriggered {
|
|
||||||
if control is UIButton {
|
|
||||||
return EventType.kTouch // UIButton triggers primaryAction with a touch interaction
|
|
||||||
} else if control is UISegmentedControl {
|
|
||||||
return EventType.kValueChange // UISegmentedControl changes its value
|
|
||||||
} else if control is UITextField {
|
|
||||||
return EventType.kSubmit // UITextField uses this for submit-like behavior
|
|
||||||
} else if control is UISwitch {
|
|
||||||
return EventType.kToggle
|
|
||||||
} else if control is UIDatePicker {
|
|
||||||
return EventType.kValueChange
|
|
||||||
} else if control is UIStepper {
|
|
||||||
return EventType.kValueChange
|
|
||||||
} else {
|
|
||||||
return EventType.kPrimaryAction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// General event descriptions
|
|
||||||
if UIControl.Event.allTouchEvents.contains(self) {
|
|
||||||
return EventType.kTouch
|
|
||||||
} else if UIControl.Event.allEditingEvents.contains(self) {
|
|
||||||
return EventType.kChange
|
|
||||||
} else if self == .valueChanged {
|
|
||||||
if control is UISwitch {
|
|
||||||
// toggle better describes a value chagne in a switch control
|
|
||||||
return EventType.kToggle
|
|
||||||
}
|
|
||||||
return EventType.kValueChange
|
|
||||||
} else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered {
|
|
||||||
return EventType.kMenuAction
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIViewController {
|
|
||||||
class func ph_topViewController(base: UIViewController? = UIApplication.getCurrentWindow()?.rootViewController) -> UIViewController? {
|
|
||||||
if let nav = base as? UINavigationController {
|
|
||||||
return ph_topViewController(base: nav.visibleViewController)
|
|
||||||
|
|
||||||
} else if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
|
|
||||||
return ph_topViewController(base: selected)
|
|
||||||
|
|
||||||
} else if let presented = base?.presentedViewController {
|
|
||||||
return ph_topViewController(base: presented)
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIResponder {
|
|
||||||
var nearestViewController: UIViewController? {
|
|
||||||
self as? UIViewController ?? next?.nearestViewController
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func typeName(of type: AnyClass) -> String {
|
|
||||||
let typeName = String(describing: type)
|
|
||||||
if let match = typeName.range(of: "^[^<]+", options: .regularExpression) {
|
|
||||||
// Extracts everything before the first '<' to deal with generics
|
|
||||||
return String(typeName[match])
|
|
||||||
}
|
|
||||||
return typeName
|
|
||||||
}
|
|
||||||
|
|
||||||
// common base types in UIKit that should not be captured
|
|
||||||
private let excludedBaseTypes: [AnyClass] = [
|
|
||||||
NSObject.self,
|
|
||||||
UIResponder.self,
|
|
||||||
UIControl.self,
|
|
||||||
UIView.self,
|
|
||||||
UIScrollView.self,
|
|
||||||
]
|
|
||||||
|
|
||||||
extension NSObject {
|
|
||||||
var descriptiveTypeName: String {
|
|
||||||
typeName(of: type(of: self))
|
|
||||||
}
|
|
||||||
|
|
||||||
var baseTypeName: String? {
|
|
||||||
guard
|
|
||||||
let superclass = type(of: self).superclass(),
|
|
||||||
!excludedBaseTypes.contains(where: { $0 == superclass })
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return typeName(of: superclass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol AutoCapturable {
|
|
||||||
var ph_autocaptureText: String? { get }
|
|
||||||
var ph_autocaptureEvents: UIControl.Event { get }
|
|
||||||
var ph_autocaptureDebounceInterval: TimeInterval { get }
|
|
||||||
func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIView: AutoCapturable {
|
|
||||||
@objc var ph_autocaptureEvents: UIControl.Event { .touchUpInside }
|
|
||||||
@objc var ph_autocaptureText: String? { nil }
|
|
||||||
@objc var ph_autocaptureDebounceInterval: TimeInterval { 0 }
|
|
||||||
@objc func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
|
|
||||||
false // by default views are not tracked. Can be overridden in subclasses
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIButton {
|
|
||||||
override var ph_autocaptureText: String? { title(for: .normal) ?? title(for: .selected) }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIControl {
|
|
||||||
@objc override func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool {
|
|
||||||
guard shouldTrack(self) else { return false }
|
|
||||||
return actions(forTarget: target, forControlEvent: ph_autocaptureEvents)?.contains(action.description) ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIScrollView {
|
|
||||||
override var ph_autocaptureDebounceInterval: TimeInterval { 0.4 }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UISegmentedControl {
|
|
||||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
|
||||||
override var ph_autocaptureText: String? {
|
|
||||||
// -1 if no segment is selected
|
|
||||||
if (0 ..< numberOfSegments) ~= selectedSegmentIndex {
|
|
||||||
return titleForSegment(at: selectedSegmentIndex)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIPageControl {
|
|
||||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UISearchBar {
|
|
||||||
override var ph_autocaptureEvents: UIControl.Event { .editingDidEnd }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIToolbar {
|
|
||||||
override var ph_autocaptureEvents: UIControl.Event {
|
|
||||||
if #available(iOS 14.0, *) { .menuActionTriggered } else { .primaryActionTriggered }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UITextField {
|
|
||||||
override var ph_autocaptureText: String? { text ?? attributedText?.string ?? placeholder }
|
|
||||||
override func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
|
|
||||||
// Just making sure that in the future we don't intercept UIControl.Ecent (even though it's not currently emited)
|
|
||||||
// Tracked via `UITextField.textDidEndEditingNotification`
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UITextView {
|
|
||||||
override var ph_autocaptureText: String? { text ?? attributedText?.string }
|
|
||||||
override func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
|
|
||||||
shouldTrack(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIStepper {
|
|
||||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
|
||||||
override var ph_autocaptureText: String? { "\(value)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UISlider {
|
|
||||||
override var ph_autocaptureDebounceInterval: TimeInterval { 0.3 }
|
|
||||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
|
||||||
override var ph_autocaptureText: String? { "\(value)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UISwitch {
|
|
||||||
@objc override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
|
||||||
override var ph_autocaptureText: String? { "\(isOn)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIPickerView {
|
|
||||||
override var ph_autocaptureText: String? {
|
|
||||||
(0 ..< numberOfComponents).reduce("") { result, component in
|
|
||||||
// -1 if no row is selected
|
|
||||||
let selectedRow = selectedRow(inComponent: component)
|
|
||||||
let rowCount = numberOfRows(inComponent: component)
|
|
||||||
|
|
||||||
if (0 ..< rowCount) ~= selectedRow {
|
|
||||||
if let title = delegate?.pickerView?(self, titleForRow: selectedRow, forComponent: component) {
|
|
||||||
return result.isEmpty ? title : "\(result) \(title)"
|
|
||||||
} else if let title = delegate?.pickerView?(self, attributedTitleForRow: selectedRow, forComponent: component) {
|
|
||||||
return result.isEmpty ? title.string : "\(result) \(title.string)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
extension UIDatePicker {
|
|
||||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func shouldTrack(_ view: UIView) -> Bool {
|
|
||||||
if view.isHidden { return false }
|
|
||||||
if !view.isUserInteractionEnabled { return false }
|
|
||||||
if view.isNoCapture() { return false }
|
|
||||||
if view.window?.isKeyboardWindow == true { return false }
|
|
||||||
|
|
||||||
if let textField = view as? UITextField, textField.isSensitiveText() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if let textView = view as? UITextView, textView.isSensitiveText() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// check view hierarchy up
|
|
||||||
if let superview = view.superview {
|
|
||||||
return shouldTrack(superview)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Filter out or obfuscate strings that look like sensitive data
|
|
||||||
// see: https://github.com/PostHog/posthog-js/blob/0cfffcac9bdf1da3fbb9478c1a51170a325bd57f/src/autocapture-utils.ts#L389
|
|
||||||
private func sanitizeText(_ title: String) -> String {
|
|
||||||
title
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) // trim
|
|
||||||
.replacingOccurrences( // sequence of spaces, returns and line breaks
|
|
||||||
of: "[ \\r\\n]+",
|
|
||||||
with: " ",
|
|
||||||
options: .regularExpression
|
|
||||||
)
|
|
||||||
.replacingOccurrences( // sanitize zero-width unicode characters
|
|
||||||
of: "[\\u{200B}\\u{200C}\\u{200D}\\u{FEFF}]",
|
|
||||||
with: "",
|
|
||||||
options: .regularExpression
|
|
||||||
)
|
|
||||||
.limit(to: 255)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum EventType {
|
|
||||||
static let kValueChange = "value_changed"
|
|
||||||
static let kSubmit = "submit"
|
|
||||||
static let kToggle = "toggle"
|
|
||||||
static let kPrimaryAction = "primary_action"
|
|
||||||
static let kMenuAction = "menu_action"
|
|
||||||
static let kChange = "change"
|
|
||||||
|
|
||||||
static let kTouch = "touch"
|
|
||||||
static let kSwipe = "swipe"
|
|
||||||
static let kPinch = "pinch"
|
|
||||||
static let kPan = "pan"
|
|
||||||
static let kScroll = "scroll"
|
|
||||||
static let kRotation = "rotation"
|
|
||||||
static let kLongPress = "long_press"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private extension String {
|
|
||||||
func limit(to length: Int) -> String {
|
|
||||||
if count > length {
|
|
||||||
let index = index(startIndex, offsetBy: length)
|
|
||||||
return String(self[..<index]) + "..."
|
|
||||||
}
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
|
|
||||||
var quoted: String {
|
|
||||||
"\"\(self)\""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogAutocaptureIntegration.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 22/10/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
private let elementsChainDelimiter = ";"
|
|
||||||
|
|
||||||
class PostHogAutocaptureIntegration: AutocaptureEventProcessing, PostHogIntegration {
|
|
||||||
var requiresSwizzling: Bool { true }
|
|
||||||
|
|
||||||
private static var integrationInstalledLock = NSLock()
|
|
||||||
private static var integrationInstalled = false
|
|
||||||
|
|
||||||
private weak var postHog: PostHogSDK?
|
|
||||||
private var debounceTimers: [Int: Timer] = [:]
|
|
||||||
|
|
||||||
func install(_ postHog: PostHogSDK) throws {
|
|
||||||
try PostHogAutocaptureIntegration.integrationInstalledLock.withLock {
|
|
||||||
if PostHogAutocaptureIntegration.integrationInstalled {
|
|
||||||
throw InternalPostHogError(description: "Autocapture integration already installed to another PostHogSDK instance.")
|
|
||||||
}
|
|
||||||
PostHogAutocaptureIntegration.integrationInstalled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
self.postHog = postHog
|
|
||||||
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func uninstall(_ postHog: PostHogSDK) {
|
|
||||||
// uninstall only for integration instance
|
|
||||||
if self.postHog === postHog || self.postHog == nil {
|
|
||||||
stop()
|
|
||||||
self.postHog = nil
|
|
||||||
PostHogAutocaptureIntegration.integrationInstalledLock.withLock {
|
|
||||||
PostHogAutocaptureIntegration.integrationInstalled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Activates the autocapture integration by routing events from PostHogAutocaptureEventTracker to this instance.
|
|
||||||
*/
|
|
||||||
func start() {
|
|
||||||
PostHogAutocaptureEventTracker.eventProcessor = self
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Disables the autocapture integration by clearing the PostHogAutocaptureEventTracker routing
|
|
||||||
*/
|
|
||||||
func stop() {
|
|
||||||
if PostHogAutocaptureEventTracker.eventProcessor != nil {
|
|
||||||
PostHogAutocaptureEventTracker.eventProcessor = nil
|
|
||||||
debounceTimers.values.forEach { $0.invalidate() }
|
|
||||||
debounceTimers.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Processes an autocapture event, with optional debounce logic for controls that emit frequent events.
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- source: The source of the event (e.g., gesture recognizer, action method, or notification).
|
|
||||||
- event: The autocapture event data, containing properties, screen name, and other metadata.
|
|
||||||
|
|
||||||
If the event has a `debounceInterval` greater than 0, the event is debounced.
|
|
||||||
This is useful for UIControls like `UISlider` that emit frequent value changes, ensuring only the last value is captured.
|
|
||||||
The debounce interval is defined per UIControl by the `ph_autocaptureDebounceInterval` property of `AutoCapturable`
|
|
||||||
*/
|
|
||||||
func process(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData) {
|
|
||||||
guard postHog?.isAutocaptureActive() == true else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let eventHash = event.viewHierarchy.map(\.targetClass).hashValue
|
|
||||||
// debounce frequent UIControl events (e.g., UISlider) to reduce event noise
|
|
||||||
if event.debounceInterval > 0 {
|
|
||||||
debounceTimers[eventHash]?.invalidate() // Keep cancelling existing
|
|
||||||
debounceTimers[eventHash] = Timer.scheduledTimer(withTimeInterval: event.debounceInterval, repeats: false) { [weak self] _ in
|
|
||||||
self?.handleEventProcessing(source: source, event: event)
|
|
||||||
self?.debounceTimers.removeValue(forKey: eventHash) // Clean up once fired
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handleEventProcessing(source: source, event: event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Handles the processing of autocapture events by extracting event details, building properties, and sending them to PostHog.
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- source: The source of the event (action method, gesture, or notification). Values are already mapped to `$event_type` earlier in the chain
|
|
||||||
- event: The event data including view hierarchy, screen name, and other metadata.
|
|
||||||
|
|
||||||
This function extracts event details such as the event type, view hierarchy, and touch coordinates.
|
|
||||||
It creates a structured payload with relevant properties (e.g., tag_name, elements, element_chain) and sends it to the
|
|
||||||
associated PostHog instance for further processing.
|
|
||||||
*/
|
|
||||||
private func handleEventProcessing(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData) {
|
|
||||||
guard let postHog else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let eventType: String = switch source {
|
|
||||||
case let .actionMethod(description): description
|
|
||||||
case let .gestureRecognizer(description): description
|
|
||||||
case let .notification(name): name
|
|
||||||
}
|
|
||||||
|
|
||||||
var properties: [String: Any] = [:]
|
|
||||||
|
|
||||||
if let screenName = event.screenName {
|
|
||||||
properties["$screen_name"] = screenName
|
|
||||||
}
|
|
||||||
|
|
||||||
let elementsChain = event.viewHierarchy
|
|
||||||
.map(\.elementsChainEntry)
|
|
||||||
.joined(separator: elementsChainDelimiter)
|
|
||||||
|
|
||||||
if let coordinates = event.touchCoordinates {
|
|
||||||
properties["$touch_x"] = coordinates.x
|
|
||||||
properties["$touch_y"] = coordinates.y
|
|
||||||
}
|
|
||||||
|
|
||||||
postHog.autocapture(
|
|
||||||
eventType: eventType,
|
|
||||||
elementsChain: elementsChain,
|
|
||||||
properties: properties
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if TESTING
|
|
||||||
extension PostHogAutocaptureIntegration {
|
|
||||||
static func clearInstalls() {
|
|
||||||
integrationInstalledLock.withLock {
|
|
||||||
integrationInstalled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
//
|
|
||||||
// View+PostHogLabel.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 04/12/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public extension View {
|
|
||||||
/**
|
|
||||||
Adds a custom label to this view for use with PostHog's auto-capture functionality.
|
|
||||||
|
|
||||||
By setting a custom label, you can easily identify and filter interactions with this specific element in your analytics data.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
```swift
|
|
||||||
struct ContentView: View {
|
|
||||||
var body: some View {
|
|
||||||
Button("Login") {
|
|
||||||
...
|
|
||||||
}
|
|
||||||
.postHogLabel("loginButton")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Parameter label: A custom label that uniquely identifies the element for analytics purposes.
|
|
||||||
*/
|
|
||||||
func postHogLabel(_ label: String?) -> some View {
|
|
||||||
modifier(PostHogLabelTaggerViewModifier(label: label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PostHogLabelTaggerViewModifier: ViewModifier {
|
|
||||||
let label: String?
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.background(viewTagger)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var viewTagger: some View {
|
|
||||||
if let label {
|
|
||||||
PostHogLabelViewTagger(label: label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PostHogLabelViewTagger: UIViewRepresentable {
|
|
||||||
let label: String
|
|
||||||
|
|
||||||
func makeUIView(context _: Context) -> PostHogLabelTaggerView {
|
|
||||||
PostHogLabelTaggerView(label: label)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_: PostHogLabelTaggerView, context _: Context) {
|
|
||||||
// nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PostHogLabelTaggerView: UIView {
|
|
||||||
private let label: String
|
|
||||||
weak var taggedView: UIView?
|
|
||||||
|
|
||||||
init(label: String) {
|
|
||||||
self.label = label
|
|
||||||
super.init(frame: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
label = ""
|
|
||||||
super.init(frame: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.didMoveToWindow()
|
|
||||||
|
|
||||||
// try to find a "taggable" cousin view in hierarchy
|
|
||||||
//
|
|
||||||
// ### Why cousin view?
|
|
||||||
//
|
|
||||||
// Because of SwiftUI-to-UIKit view bridging:
|
|
||||||
//
|
|
||||||
// OriginalView (SwiftUI)
|
|
||||||
// L SwiftUITextFieldRepresentable (ViewRepresentable)
|
|
||||||
// L UITextField (UIControl) <- we tag here
|
|
||||||
// L PostHogLabelViewTagger (ViewRepresentable)
|
|
||||||
// L PostHogLabelTaggerView (UIView) <- we are here
|
|
||||||
//
|
|
||||||
if let view = findCousinView(of: PostHogSwiftUITaggable.self) {
|
|
||||||
taggedView = view
|
|
||||||
view.postHogLabel = label
|
|
||||||
} else {
|
|
||||||
// just tag grandparent view
|
|
||||||
//
|
|
||||||
// ### Why grandparent view?
|
|
||||||
//
|
|
||||||
// Because of SwiftUI-to-UIKit view bridging:
|
|
||||||
// OriginalView (SwiftUI) <- we tag here
|
|
||||||
// L PostHogLabelViewTagger (ViewRepresentable)
|
|
||||||
// L PostHogLabelTaggerView (UIView) <- we are here
|
|
||||||
//
|
|
||||||
taggedView = superview?.superview
|
|
||||||
superview?.superview?.postHogLabel = label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func removeFromSuperview() {
|
|
||||||
super.removeFromSuperview()
|
|
||||||
// remove custom label when removed from hierarchy
|
|
||||||
taggedView?.postHogLabel = nil
|
|
||||||
taggedView = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findCousinView<T>(of _: T.Type) -> T? {
|
|
||||||
for sibling in superview?.siblings() ?? [] {
|
|
||||||
if let match = sibling.child(of: T.self) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private extension UIView {
|
|
||||||
func siblings() -> [UIView] {
|
|
||||||
superview?.subviews.reduce(into: []) { result, current in
|
|
||||||
if current !== self { result.append(current) }
|
|
||||||
} ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
func child<T>(of type: T.Type) -> T? {
|
|
||||||
for child in subviews {
|
|
||||||
if let curT = child as? T ?? child.child(of: type) {
|
|
||||||
return curT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol PostHogSwiftUITaggable: UIView { /**/ }
|
|
||||||
|
|
||||||
extension UIControl: PostHogSwiftUITaggable { /**/ }
|
|
||||||
extension UIPickerView: PostHogSwiftUITaggable { /**/ }
|
|
||||||
extension UITextView: PostHogSwiftUITaggable { /**/ }
|
|
||||||
extension UICollectionView: PostHogSwiftUITaggable { /**/ }
|
|
||||||
extension UITableView: PostHogSwiftUITaggable { /**/ }
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
//
|
|
||||||
// UIView+PostHogLabel.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 04/12/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public extension UIView {
|
|
||||||
/**
|
|
||||||
Adds a custom label to this view for use with PostHog's auto-capture functionality.
|
|
||||||
|
|
||||||
By setting a custom label, you can easily identify and filter interactions with this specific element in your analytics data.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
```swift
|
|
||||||
let myView = UIView()
|
|
||||||
myView.postHogLabel = "customLabel"
|
|
||||||
```
|
|
||||||
*/
|
|
||||||
var postHogLabel: String? {
|
|
||||||
get { objc_getAssociatedObject(self, &AssociatedKeys.phLabel) as? String }
|
|
||||||
set { objc_setAssociatedObject(self, &AssociatedKeys.phLabel, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
28
Pods/PostHog/PostHog/DI.swift
generated
28
Pods/PostHog/PostHog/DI.swift
generated
@@ -1,28 +0,0 @@
|
|||||||
//
|
|
||||||
// DI.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 17/12/2024.
|
|
||||||
//
|
|
||||||
|
|
||||||
// swiftlint:disable:next type_name
|
|
||||||
enum DI {
|
|
||||||
static var main = Container()
|
|
||||||
|
|
||||||
final class Container {
|
|
||||||
// publishes global app lifecycle events
|
|
||||||
lazy var appLifecyclePublisher: AppLifecyclePublishing = ApplicationLifecyclePublisher.shared
|
|
||||||
// publishes global screen view events (UIViewController.viewDidAppear)
|
|
||||||
lazy var screenViewPublisher: ScreenViewPublishing = ApplicationScreenViewPublisher.shared
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
// publishes global application events (UIApplication.sendEvent)
|
|
||||||
lazy var applicationEventPublisher: ApplicationEventPublishing = ApplicationEventPublisher.shared
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
// publishes global view layout events within a throttle interval (UIView.layoutSubviews)
|
|
||||||
lazy var viewLayoutPublisher: ViewLayoutPublishing = ApplicationViewLayoutPublisher.shared
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
102
Pods/PostHog/PostHog/Models/PostHogEvent.swift
generated
102
Pods/PostHog/PostHog/Models/PostHogEvent.swift
generated
@@ -1,102 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogEvent.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 13.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@objc(PostHogEvent) public class PostHogEvent: NSObject {
|
|
||||||
@objc public var event: String
|
|
||||||
@objc public var distinctId: String
|
|
||||||
@objc public var properties: [String: Any]
|
|
||||||
@objc public var timestamp: Date
|
|
||||||
@objc public private(set) var uuid: UUID
|
|
||||||
// Only used for Replay
|
|
||||||
var apiKey: String?
|
|
||||||
|
|
||||||
init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), apiKey: String? = nil) {
|
|
||||||
self.event = event
|
|
||||||
self.distinctId = distinctId
|
|
||||||
self.properties = properties ?? [:]
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.uuid = uuid
|
|
||||||
self.apiKey = apiKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Ideally we would use the NSCoding behaviour but it gets needlessly complex
|
|
||||||
// given we only need this for sending to the API
|
|
||||||
static func fromJSON(_ data: Data) -> PostHogEvent? {
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fromJSON(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fromJSON(_ json: [String: Any]) -> PostHogEvent? {
|
|
||||||
guard let event = json["event"] as? String else { return nil }
|
|
||||||
|
|
||||||
let timestamp = json["timestamp"] as? String ?? toISO8601String(Date())
|
|
||||||
|
|
||||||
let timestampDate = toISO8601Date(timestamp) ?? Date()
|
|
||||||
|
|
||||||
var properties = (json["properties"] as? [String: Any]) ?? [:]
|
|
||||||
|
|
||||||
// back compatibility with v2
|
|
||||||
let setProps = json["$set"] as? [String: Any]
|
|
||||||
if setProps != nil {
|
|
||||||
properties["$set"] = setProps
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let distinctId = (json["distinct_id"] as? String) ?? (properties["distinct_id"] as? String) else { return nil }
|
|
||||||
|
|
||||||
let uuid = ((json["uuid"] as? String) ?? (json["message_id"] as? String)) ?? UUID.v7().uuidString
|
|
||||||
let uuidObj = UUID(uuidString: uuid) ?? UUID.v7()
|
|
||||||
|
|
||||||
let apiKey = json["api_key"] as? String
|
|
||||||
|
|
||||||
return PostHogEvent(
|
|
||||||
event: event,
|
|
||||||
distinctId: distinctId,
|
|
||||||
properties: properties,
|
|
||||||
timestamp: timestampDate,
|
|
||||||
uuid: uuidObj,
|
|
||||||
apiKey: apiKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toJSON() -> [String: Any] {
|
|
||||||
var json: [String: Any] = [
|
|
||||||
"event": event,
|
|
||||||
"distinct_id": distinctId,
|
|
||||||
"properties": properties,
|
|
||||||
"timestamp": toISO8601String(timestamp),
|
|
||||||
"uuid": uuid.uuidString,
|
|
||||||
]
|
|
||||||
|
|
||||||
if let apiKey {
|
|
||||||
json["api_key"] = apiKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogKnownUnsafeEditableEvent: String {
|
|
||||||
case snapshot = "$snapshot"
|
|
||||||
case screen = "$screen"
|
|
||||||
case set = "$set"
|
|
||||||
case surveyDismissed = "survey dismissed"
|
|
||||||
case surveySent = "survey sent"
|
|
||||||
case surveyShown = "survey shown"
|
|
||||||
case identify = "$identify"
|
|
||||||
case groupidentify = "$groupidentify"
|
|
||||||
case createAlias = "$create_alias"
|
|
||||||
case featureFlagCalled = "$feature_flag_called"
|
|
||||||
|
|
||||||
static func contains(_ name: String) -> Bool {
|
|
||||||
PostHogKnownUnsafeEditableEvent(rawValue: name) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#if os(iOS) || TESTING
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension PostHogSurvey {
|
|
||||||
func toDisplaySurvey() -> PostHogDisplaySurvey {
|
|
||||||
PostHogDisplaySurvey(
|
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
questions: questions.compactMap { $0.toDisplayQuestion() },
|
|
||||||
appearance: appearance?.toDisplayAppearance(),
|
|
||||||
startDate: startDate,
|
|
||||||
endDate: endDate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PostHogSurveyQuestion {
|
|
||||||
func toDisplayQuestion() -> PostHogDisplaySurveyQuestion? {
|
|
||||||
switch self {
|
|
||||||
case let .open(question):
|
|
||||||
return PostHogDisplayOpenQuestion(
|
|
||||||
id: question.id,
|
|
||||||
question: question.question,
|
|
||||||
questionDescription: question.description,
|
|
||||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
|
||||||
isOptional: question.optional ?? false,
|
|
||||||
buttonText: question.buttonText
|
|
||||||
)
|
|
||||||
|
|
||||||
case let .link(question):
|
|
||||||
return PostHogDisplayLinkQuestion(
|
|
||||||
id: question.id,
|
|
||||||
question: question.question,
|
|
||||||
questionDescription: question.description,
|
|
||||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
|
||||||
isOptional: question.optional ?? false,
|
|
||||||
buttonText: question.buttonText,
|
|
||||||
link: question.link ?? ""
|
|
||||||
)
|
|
||||||
|
|
||||||
case let .rating(question):
|
|
||||||
return PostHogDisplayRatingQuestion(
|
|
||||||
id: question.id,
|
|
||||||
question: question.question,
|
|
||||||
questionDescription: question.description,
|
|
||||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
|
||||||
isOptional: question.optional ?? false,
|
|
||||||
buttonText: question.buttonText,
|
|
||||||
ratingType: question.display.toDisplayRatingType(),
|
|
||||||
scaleLowerBound: question.scale.range.lowerBound,
|
|
||||||
scaleUpperBound: question.scale.range.upperBound,
|
|
||||||
lowerBoundLabel: question.lowerBoundLabel,
|
|
||||||
upperBoundLabel: question.upperBoundLabel
|
|
||||||
)
|
|
||||||
|
|
||||||
case let .singleChoice(question), let .multipleChoice(question):
|
|
||||||
return PostHogDisplayChoiceQuestion(
|
|
||||||
id: question.id,
|
|
||||||
question: question.question,
|
|
||||||
questionDescription: question.description,
|
|
||||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
|
||||||
isOptional: question.optional ?? false,
|
|
||||||
buttonText: question.buttonText,
|
|
||||||
choices: question.choices,
|
|
||||||
hasOpenChoice: question.hasOpenChoice ?? false,
|
|
||||||
shuffleOptions: question.shuffleOptions ?? false,
|
|
||||||
isMultipleChoice: isMultipleChoice
|
|
||||||
)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isMultipleChoice: Bool {
|
|
||||||
switch self {
|
|
||||||
case .multipleChoice: return true
|
|
||||||
default: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PostHogSurveyTextContentType {
|
|
||||||
func toDisplayContentType() -> PostHogDisplaySurveyTextContentType {
|
|
||||||
if case .html = self {
|
|
||||||
return .html
|
|
||||||
}
|
|
||||||
return .text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PostHogSurveyRatingDisplayType {
|
|
||||||
func toDisplayRatingType() -> PostHogDisplaySurveyRatingType {
|
|
||||||
if case .emoji = self {
|
|
||||||
return .emoji
|
|
||||||
}
|
|
||||||
return .number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PostHogSurveyAppearance {
|
|
||||||
func toDisplayAppearance() -> PostHogDisplaySurveyAppearance {
|
|
||||||
PostHogDisplaySurveyAppearance(
|
|
||||||
fontFamily: fontFamily,
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
borderColor: borderColor,
|
|
||||||
submitButtonColor: submitButtonColor,
|
|
||||||
submitButtonText: submitButtonText,
|
|
||||||
submitButtonTextColor: submitButtonTextColor,
|
|
||||||
descriptionTextColor: descriptionTextColor,
|
|
||||||
ratingButtonColor: ratingButtonColor,
|
|
||||||
ratingButtonActiveColor: ratingButtonActiveColor,
|
|
||||||
placeholder: placeholder,
|
|
||||||
displayThankYouMessage: displayThankYouMessage ?? true,
|
|
||||||
thankYouMessageHeader: thankYouMessageHeader,
|
|
||||||
thankYouMessageDescription: thankYouMessageDescription,
|
|
||||||
thankYouMessageDescriptionContentType: thankYouMessageDescriptionContentType?.toDisplayContentType(),
|
|
||||||
thankYouMessageCloseButtonText: thankYouMessageCloseButtonText
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSurvey.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 20/01/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Represents the main survey object containing metadata, questions, conditions, and appearance settings.
|
|
||||||
/// see: posthog-js/posthog-surveys-types.ts
|
|
||||||
struct PostHogSurvey: Decodable, Identifiable {
|
|
||||||
/// The unique identifier for the survey
|
|
||||||
let id: String
|
|
||||||
/// The name of the survey
|
|
||||||
let name: String
|
|
||||||
/// Type of the survey (e.g., "popover")
|
|
||||||
let type: PostHogSurveyType
|
|
||||||
/// The questions asked in the survey
|
|
||||||
let questions: [PostHogSurveyQuestion]
|
|
||||||
/// Multiple feature flag keys. Must all (AND) evaluate to true for the survey to be shown (optional)
|
|
||||||
let featureFlagKeys: [PostHogSurveyFeatureFlagKeyValue]?
|
|
||||||
/// Linked feature flag key. Must evaluate to true for the survey to be shown (optional)
|
|
||||||
let linkedFlagKey: String?
|
|
||||||
/// Targeting feature flag key. Must evaluate to true for the survey to be shown (optional)
|
|
||||||
let targetingFlagKey: String?
|
|
||||||
/// Internal targeting flag key. Must evaluate to true for the survey to be shown (optional)
|
|
||||||
let internalTargetingFlagKey: String?
|
|
||||||
/// Conditions for displaying the survey (optional)
|
|
||||||
let conditions: PostHogSurveyConditions?
|
|
||||||
/// Appearance settings for the survey (optional)
|
|
||||||
let appearance: PostHogSurveyAppearance?
|
|
||||||
/// The iteration number for the survey (optional)
|
|
||||||
let currentIteration: Int?
|
|
||||||
/// The start date for the current iteration of the survey (optional)
|
|
||||||
let currentIterationStartDate: Date?
|
|
||||||
/// Start date of the survey (optional)
|
|
||||||
let startDate: Date?
|
|
||||||
/// End date of the survey (optional)
|
|
||||||
let endDate: Date?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PostHogSurveyFeatureFlagKeyValue: Equatable, Decodable {
|
|
||||||
let key: String
|
|
||||||
let value: String?
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSurveyAppearance.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 08/04/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Represents the appearance settings for the survey, such as colors, fonts, and layout
|
|
||||||
struct PostHogSurveyAppearance: Decodable {
|
|
||||||
let position: PostHogSurveyAppearancePosition?
|
|
||||||
let fontFamily: String?
|
|
||||||
let backgroundColor: String?
|
|
||||||
let submitButtonColor: String?
|
|
||||||
let submitButtonText: String?
|
|
||||||
let submitButtonTextColor: String?
|
|
||||||
let descriptionTextColor: String?
|
|
||||||
let ratingButtonColor: String?
|
|
||||||
let ratingButtonActiveColor: String?
|
|
||||||
let ratingButtonHoverColor: String?
|
|
||||||
let whiteLabel: Bool?
|
|
||||||
let autoDisappear: Bool?
|
|
||||||
let displayThankYouMessage: Bool?
|
|
||||||
let thankYouMessageHeader: String?
|
|
||||||
let thankYouMessageDescription: String?
|
|
||||||
let thankYouMessageDescriptionContentType: PostHogSurveyTextContentType?
|
|
||||||
let thankYouMessageCloseButtonText: String?
|
|
||||||
let borderColor: String?
|
|
||||||
let placeholder: String?
|
|
||||||
let shuffleQuestions: Bool?
|
|
||||||
let surveyPopupDelaySeconds: TimeInterval?
|
|
||||||
// widget options
|
|
||||||
let widgetType: PostHogSurveyAppearanceWidgetType?
|
|
||||||
let widgetSelector: String?
|
|
||||||
let widgetLabel: String?
|
|
||||||
let widgetColor: String?
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSurveyConditions.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 08/04/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Represents conditions for displaying the survey, such as URL or event-based triggers
|
|
||||||
struct PostHogSurveyConditions: Decodable {
|
|
||||||
/// Target URL for the survey (optional)
|
|
||||||
let url: String?
|
|
||||||
/// The match type for the url condition (optional)
|
|
||||||
let urlMatchType: PostHogSurveyMatchType?
|
|
||||||
/// CSS selector for displaying the survey (optional)
|
|
||||||
let selector: String?
|
|
||||||
/// Device type based conditions for displaying the survey (optional)
|
|
||||||
let deviceTypes: [String]?
|
|
||||||
/// The match type for the device type condition (optional)
|
|
||||||
let deviceTypesMatchType: PostHogSurveyMatchType?
|
|
||||||
/// Minimum wait period before showing the survey again (optional)
|
|
||||||
let seenSurveyWaitPeriodInDays: Int?
|
|
||||||
/// Event-based conditions for displaying the survey (optional)
|
|
||||||
let events: PostHogSurveyEventConditions?
|
|
||||||
/// Action-based conditions for displaying the survey (optional)
|
|
||||||
let actions: PostHogSurveyActionsConditions?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents event-based conditions for displaying the survey
|
|
||||||
struct PostHogSurveyEventConditions: Decodable {
|
|
||||||
let repeatedActivation: Bool?
|
|
||||||
/// List of events that trigger the survey
|
|
||||||
let values: [PostHogEventCondition]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents action-based conditions for displaying the survey
|
|
||||||
struct PostHogSurveyActionsConditions: Decodable {
|
|
||||||
/// List of events that trigger the survey
|
|
||||||
let values: [PostHogEventCondition]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a single event condition used in survey targeting
|
|
||||||
struct PostHogEventCondition: Decodable, Equatable {
|
|
||||||
/// Name of the event (e.g., "content loaded")
|
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSurveyEnums.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 08/04/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
|
||||||
|
|
||||||
enum PostHogSurveyType: Decodable, Equatable {
|
|
||||||
case popover
|
|
||||||
case api
|
|
||||||
case widget
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let typeString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch typeString {
|
|
||||||
case "popover":
|
|
||||||
self = .popover
|
|
||||||
case "api":
|
|
||||||
self = .api
|
|
||||||
case "widget":
|
|
||||||
self = .widget
|
|
||||||
default:
|
|
||||||
self = .unknown(type: typeString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyQuestionType: Decodable, Equatable {
|
|
||||||
case open
|
|
||||||
case link
|
|
||||||
case rating
|
|
||||||
case multipleChoice
|
|
||||||
case singleChoice
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let typeString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch typeString {
|
|
||||||
case "open":
|
|
||||||
self = .open
|
|
||||||
case "link":
|
|
||||||
self = .link
|
|
||||||
case "rating":
|
|
||||||
self = .rating
|
|
||||||
case "multiple_choice":
|
|
||||||
self = .multipleChoice
|
|
||||||
case "single_choice":
|
|
||||||
self = .singleChoice
|
|
||||||
default:
|
|
||||||
self = .unknown(type: typeString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyTextContentType: Decodable, Equatable {
|
|
||||||
case html
|
|
||||||
case text
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let typeString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch typeString {
|
|
||||||
case "html":
|
|
||||||
self = .html
|
|
||||||
case "text":
|
|
||||||
self = .text
|
|
||||||
default:
|
|
||||||
self = .unknown(type: typeString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyMatchType: Decodable, Equatable {
|
|
||||||
case regex
|
|
||||||
case notRegex
|
|
||||||
case exact
|
|
||||||
case isNot
|
|
||||||
case iContains
|
|
||||||
case notIContains
|
|
||||||
case unknown(value: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let valueString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch valueString {
|
|
||||||
case "regex":
|
|
||||||
self = .regex
|
|
||||||
case "not_regex":
|
|
||||||
self = .notRegex
|
|
||||||
case "exact":
|
|
||||||
self = .exact
|
|
||||||
case "is_not":
|
|
||||||
self = .isNot
|
|
||||||
case "icontains":
|
|
||||||
self = .iContains
|
|
||||||
case "not_icontains":
|
|
||||||
self = .notIContains
|
|
||||||
default:
|
|
||||||
self = .unknown(value: valueString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyAppearancePosition: Decodable, Equatable {
|
|
||||||
case topLeft
|
|
||||||
case topCenter
|
|
||||||
case topRight
|
|
||||||
case middleLeft
|
|
||||||
case middleCenter
|
|
||||||
case middleRight
|
|
||||||
case left
|
|
||||||
case right
|
|
||||||
case center
|
|
||||||
case unknown(position: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let positionString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch positionString {
|
|
||||||
case "top_left":
|
|
||||||
self = .topLeft
|
|
||||||
case "top_center":
|
|
||||||
self = .topCenter
|
|
||||||
case "top_right":
|
|
||||||
self = .topRight
|
|
||||||
case "middle_left":
|
|
||||||
self = .middleLeft
|
|
||||||
case "middle_center":
|
|
||||||
self = .middleCenter
|
|
||||||
case "middle_right":
|
|
||||||
self = .middleRight
|
|
||||||
case "left":
|
|
||||||
self = .left
|
|
||||||
case "right":
|
|
||||||
self = .right
|
|
||||||
case "center":
|
|
||||||
self = .center
|
|
||||||
default:
|
|
||||||
self = .unknown(position: positionString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyAppearanceWidgetType: Decodable, Equatable {
|
|
||||||
case button
|
|
||||||
case tab
|
|
||||||
case selector
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let typeString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch typeString {
|
|
||||||
case "button":
|
|
||||||
self = .button
|
|
||||||
case "tab":
|
|
||||||
self = .tab
|
|
||||||
case "selector":
|
|
||||||
self = .selector
|
|
||||||
default:
|
|
||||||
self = .unknown(type: typeString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyRatingDisplayType: Decodable, Equatable {
|
|
||||||
case number
|
|
||||||
case emoji
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let typeString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch typeString {
|
|
||||||
case "number":
|
|
||||||
self = .number
|
|
||||||
case "emoji":
|
|
||||||
self = .emoji
|
|
||||||
default:
|
|
||||||
self = .unknown(type: typeString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyRatingScale: Decodable, Equatable {
|
|
||||||
case threePoint
|
|
||||||
case fivePoint
|
|
||||||
case sevenPoint
|
|
||||||
case tenPoint
|
|
||||||
case unknown(scale: Int)
|
|
||||||
|
|
||||||
var rawValue: Int {
|
|
||||||
switch self {
|
|
||||||
case .threePoint: 3
|
|
||||||
case .fivePoint: 5
|
|
||||||
case .sevenPoint: 7
|
|
||||||
case .tenPoint: 10
|
|
||||||
case let .unknown(scale): scale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var range: ClosedRange<Int> {
|
|
||||||
switch self {
|
|
||||||
case .threePoint: 1 ... 3
|
|
||||||
case .fivePoint: 1 ... 5
|
|
||||||
case .sevenPoint: 1 ... 7
|
|
||||||
case .tenPoint: 0 ... 10
|
|
||||||
case let .unknown(scale): 1 ... scale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(range: ClosedRange<Int>) {
|
|
||||||
switch range {
|
|
||||||
case 1 ... 3: self = .threePoint
|
|
||||||
case 1 ... 5: self = .fivePoint
|
|
||||||
case 1 ... 7: self = .sevenPoint
|
|
||||||
case 0 ... 10: self = .tenPoint
|
|
||||||
default: self = .unknown(scale: range.upperBound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let scaleInt = try container.decode(Int.self)
|
|
||||||
|
|
||||||
switch scaleInt {
|
|
||||||
case 3:
|
|
||||||
self = .threePoint
|
|
||||||
case 5:
|
|
||||||
self = .fivePoint
|
|
||||||
case 7:
|
|
||||||
self = .sevenPoint
|
|
||||||
case 10:
|
|
||||||
self = .tenPoint
|
|
||||||
default:
|
|
||||||
self = .unknown(scale: scaleInt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PostHogSurveyQuestionBranchingType: Decodable, Equatable {
|
|
||||||
case nextQuestion
|
|
||||||
case end
|
|
||||||
case responseBased
|
|
||||||
case specificQuestion
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let typeString = try container.decode(String.self)
|
|
||||||
|
|
||||||
switch typeString {
|
|
||||||
case "next_question":
|
|
||||||
self = .nextQuestion
|
|
||||||
case "end":
|
|
||||||
self = .end
|
|
||||||
case "response_based":
|
|
||||||
self = .responseBased
|
|
||||||
case "specific_question":
|
|
||||||
self = .specificQuestion
|
|
||||||
default:
|
|
||||||
self = .unknown(type: typeString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSurveyQuestion.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 08/04/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Question Models
|
|
||||||
|
|
||||||
/// Protocol defining common properties for all survey question types
|
|
||||||
protocol PostHogSurveyQuestionProperties {
|
|
||||||
/// Question ID, empty if none
|
|
||||||
var id: String { get }
|
|
||||||
/// Question text
|
|
||||||
var question: String { get }
|
|
||||||
/// Additional description or instructions (optional)
|
|
||||||
var description: String? { get }
|
|
||||||
/// Content type of the description (e.g., "text", "html") (optional)
|
|
||||||
var descriptionContentType: PostHogSurveyTextContentType? { get }
|
|
||||||
/// Indicates if this question is optional (optional)
|
|
||||||
var optional: Bool? { get }
|
|
||||||
/// Text for the main CTA associated with this question (optional)
|
|
||||||
var buttonText: String? { get }
|
|
||||||
/// Original index of the question in the survey (optional)
|
|
||||||
var originalQuestionIndex: Int? { get }
|
|
||||||
/// Question branching logic if any (optional)
|
|
||||||
var branching: PostHogSurveyQuestionBranching? { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents different types of survey questions with their associated data
|
|
||||||
enum PostHogSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
|
||||||
case open(PostHogOpenSurveyQuestion)
|
|
||||||
case link(PostHogLinkSurveyQuestion)
|
|
||||||
case rating(PostHogRatingSurveyQuestion)
|
|
||||||
case singleChoice(PostHogMultipleSurveyQuestion)
|
|
||||||
case multipleChoice(PostHogMultipleSurveyQuestion)
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
let type = try container.decode(PostHogSurveyQuestionType.self, forKey: .type)
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .open:
|
|
||||||
self = try .open(PostHogOpenSurveyQuestion(from: decoder))
|
|
||||||
case .link:
|
|
||||||
self = try .link(PostHogLinkSurveyQuestion(from: decoder))
|
|
||||||
case .rating:
|
|
||||||
self = try .rating(PostHogRatingSurveyQuestion(from: decoder))
|
|
||||||
case .singleChoice:
|
|
||||||
self = try .singleChoice(PostHogMultipleSurveyQuestion(from: decoder))
|
|
||||||
case .multipleChoice:
|
|
||||||
self = try .multipleChoice(PostHogMultipleSurveyQuestion(from: decoder))
|
|
||||||
case let .unknown(type):
|
|
||||||
self = .unknown(type: type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var id: String {
|
|
||||||
wrappedQuestion?.id ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var question: String {
|
|
||||||
wrappedQuestion?.question ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String? {
|
|
||||||
wrappedQuestion?.description
|
|
||||||
}
|
|
||||||
|
|
||||||
var descriptionContentType: PostHogSurveyTextContentType? {
|
|
||||||
wrappedQuestion?.descriptionContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
var optional: Bool? {
|
|
||||||
wrappedQuestion?.optional
|
|
||||||
}
|
|
||||||
|
|
||||||
var buttonText: String? {
|
|
||||||
wrappedQuestion?.buttonText
|
|
||||||
}
|
|
||||||
|
|
||||||
var originalQuestionIndex: Int? {
|
|
||||||
wrappedQuestion?.originalQuestionIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
var branching: PostHogSurveyQuestionBranching? {
|
|
||||||
wrappedQuestion?.branching
|
|
||||||
}
|
|
||||||
|
|
||||||
private var wrappedQuestion: PostHogSurveyQuestionProperties? {
|
|
||||||
switch self {
|
|
||||||
case let .open(question): question
|
|
||||||
case let .link(question): question
|
|
||||||
case let .rating(question): question
|
|
||||||
case let .singleChoice(question): question
|
|
||||||
case let .multipleChoice(question): question
|
|
||||||
case .unknown: nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: CodingKey {
|
|
||||||
case type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a basic open-ended survey question
|
|
||||||
struct PostHogOpenSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
|
||||||
let id: String
|
|
||||||
let question: String
|
|
||||||
let description: String?
|
|
||||||
let descriptionContentType: PostHogSurveyTextContentType?
|
|
||||||
let optional: Bool?
|
|
||||||
let buttonText: String?
|
|
||||||
let originalQuestionIndex: Int?
|
|
||||||
let branching: PostHogSurveyQuestionBranching?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a survey question with an associated link
|
|
||||||
struct PostHogLinkSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
|
||||||
let id: String
|
|
||||||
let question: String
|
|
||||||
let description: String?
|
|
||||||
let descriptionContentType: PostHogSurveyTextContentType?
|
|
||||||
let optional: Bool?
|
|
||||||
let buttonText: String?
|
|
||||||
let originalQuestionIndex: Int?
|
|
||||||
let branching: PostHogSurveyQuestionBranching?
|
|
||||||
/// URL link associated with the question
|
|
||||||
let link: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a rating-based survey question
|
|
||||||
struct PostHogRatingSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
|
||||||
let id: String
|
|
||||||
let question: String
|
|
||||||
let description: String?
|
|
||||||
let descriptionContentType: PostHogSurveyTextContentType?
|
|
||||||
let optional: Bool?
|
|
||||||
let buttonText: String?
|
|
||||||
let originalQuestionIndex: Int?
|
|
||||||
let branching: PostHogSurveyQuestionBranching?
|
|
||||||
/// Display type for the rating ("number" or "emoji")
|
|
||||||
let display: PostHogSurveyRatingDisplayType
|
|
||||||
/// Scale of the rating (3, 5, 7, or 10)
|
|
||||||
let scale: PostHogSurveyRatingScale
|
|
||||||
let lowerBoundLabel: String
|
|
||||||
let upperBoundLabel: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a multiple-choice or single-choice survey question
|
|
||||||
struct PostHogMultipleSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
|
||||||
let id: String
|
|
||||||
let question: String
|
|
||||||
let description: String?
|
|
||||||
let descriptionContentType: PostHogSurveyTextContentType?
|
|
||||||
let optional: Bool?
|
|
||||||
let buttonText: String?
|
|
||||||
let originalQuestionIndex: Int?
|
|
||||||
let branching: PostHogSurveyQuestionBranching?
|
|
||||||
/// List of choices for multiple-choice or single-choice questions
|
|
||||||
let choices: [String]
|
|
||||||
/// Indicates if there is an open choice option (optional)
|
|
||||||
let hasOpenChoice: Bool?
|
|
||||||
/// Indicates if choices should be shuffled or not (optional)
|
|
||||||
let shuffleOptions: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents branching logic for a question based on user responses
|
|
||||||
enum PostHogSurveyQuestionBranching: Decodable {
|
|
||||||
case next
|
|
||||||
case end
|
|
||||||
case responseBased(responseValues: [String: Any])
|
|
||||||
case specificQuestion(index: Int)
|
|
||||||
case unknown(type: String)
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
let type = try container.decode(PostHogSurveyQuestionBranchingType.self, forKey: .type)
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .nextQuestion:
|
|
||||||
self = .next
|
|
||||||
case .end:
|
|
||||||
self = .end
|
|
||||||
case .responseBased:
|
|
||||||
do {
|
|
||||||
let responseValues = try container.decode(JSON.self, forKey: .responseValues)
|
|
||||||
guard let dict = responseValues.value as? [String: Any] else {
|
|
||||||
throw DecodingError.typeMismatch(
|
|
||||||
[String: Any].self,
|
|
||||||
DecodingError.Context(
|
|
||||||
codingPath: container.codingPath,
|
|
||||||
debugDescription: "Expected responseValues to be a dictionary"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
self = .responseBased(responseValues: dict)
|
|
||||||
} catch {
|
|
||||||
throw DecodingError.dataCorruptedError(
|
|
||||||
forKey: .responseValues,
|
|
||||||
in: container,
|
|
||||||
debugDescription: "responseValues is not a valid JSON object"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case .specificQuestion:
|
|
||||||
self = try .specificQuestion(index: container.decode(Int.self, forKey: .index))
|
|
||||||
case let .unknown(type):
|
|
||||||
self = .unknown(type: type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: CodingKey {
|
|
||||||
case type, responseValues, index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A helper type for decoding JSON values, which may be nested objects, arrays, strings, numbers, booleans, or nulls.
|
|
||||||
private struct JSON: Decodable {
|
|
||||||
let value: Any
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
|
|
||||||
if container.decodeNil() {
|
|
||||||
value = NSNull()
|
|
||||||
} else if let object = try? container.decode([String: JSON].self) {
|
|
||||||
value = object.mapValues { $0.value }
|
|
||||||
} else if let array = try? container.decode([JSON].self) {
|
|
||||||
value = array.map(\.value)
|
|
||||||
} else if let string = try? container.decode(String.self) {
|
|
||||||
value = string
|
|
||||||
} else if let bool = try? container.decode(Bool.self) {
|
|
||||||
value = bool
|
|
||||||
} else if let number = try? container.decode(Double.self) {
|
|
||||||
value = NSNumber(value: number)
|
|
||||||
} else if let number = try? container.decode(Int.self) {
|
|
||||||
value = NSNumber(value: number)
|
|
||||||
} else {
|
|
||||||
throw DecodingError.dataCorruptedError(
|
|
||||||
in: container, debugDescription: "Invalid JSON value"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
60
Pods/PostHog/PostHog/PostHog.h
generated
60
Pods/PostHog/PostHog/PostHog.h
generated
@@ -1,60 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHog.h
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ben White on 10.01.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
//! Project version number for PostHog.
|
|
||||||
FOUNDATION_EXPORT double PostHogVersionNumber;
|
|
||||||
|
|
||||||
//! Project version string for PostHog.
|
|
||||||
FOUNDATION_EXPORT const unsigned char PostHogVersionString[];
|
|
||||||
|
|
||||||
// In this header, you should import all the public headers of your framework using statements like #import <PostHog/PublicHeader.h>
|
|
||||||
#import <PostHog/ph_backward_references_enc.h>
|
|
||||||
#import <PostHog/ph_bit_reader_utils.h>
|
|
||||||
#import <PostHog/ph_bit_writer_utils.h>
|
|
||||||
#import <PostHog/ph_color_cache_utils.h>
|
|
||||||
#import <PostHog/ph_common_dec.h>
|
|
||||||
#import <PostHog/ph_common_sse2.h>
|
|
||||||
#import <PostHog/ph_common_sse41.h>
|
|
||||||
#import <PostHog/ph_cost_enc.h>
|
|
||||||
#import <PostHog/ph_cpu.h>
|
|
||||||
#import <PostHog/ph_decode.h>
|
|
||||||
#import <PostHog/ph_dsp.h>
|
|
||||||
#import <PostHog/ph_encode.h>
|
|
||||||
#import <PostHog/ph_endian_inl_utils.h>
|
|
||||||
#import <PostHog/ph_filters_utils.h>
|
|
||||||
#import <PostHog/ph_format_constants.h>
|
|
||||||
#import <PostHog/ph_histogram_enc.h>
|
|
||||||
#import <PostHog/ph_huffman_encode_utils.h>
|
|
||||||
#import <PostHog/ph_lossless.h>
|
|
||||||
#import <PostHog/ph_lossless_common.h>
|
|
||||||
#import <PostHog/ph_mux.h>
|
|
||||||
#import <PostHog/ph_muxi.h>
|
|
||||||
#import <PostHog/ph_mux_types.h>
|
|
||||||
#import <PostHog/ph_neon.h>
|
|
||||||
#import <PostHog/ph_palette.h>
|
|
||||||
#import <PostHog/ph_quant.h>
|
|
||||||
#import <PostHog/ph_quant_levels_utils.h>
|
|
||||||
#import <PostHog/ph_random_utils.h>
|
|
||||||
#import <PostHog/ph_rescaler_utils.h>
|
|
||||||
#import <PostHog/ph_sharpyuv.h>
|
|
||||||
#import <PostHog/ph_sharpyuv_cpu.h>
|
|
||||||
#import <PostHog/ph_sharpyuv_csp.h>
|
|
||||||
#import <PostHog/ph_sharpyuv_dsp.h>
|
|
||||||
#import <PostHog/ph_sharpyuv_gamma.h>
|
|
||||||
#import <PostHog/ph_thread_utils.h>
|
|
||||||
#import <PostHog/ph_types.h>
|
|
||||||
#import <PostHog/ph_utils.h>
|
|
||||||
#import <PostHog/ph_vp8i_enc.h>
|
|
||||||
#import <PostHog/ph_vp8li_enc.h>
|
|
||||||
#import <PostHog/ph_vp8_dec.h>
|
|
||||||
#import <PostHog/ph_vp8i_dec.h>
|
|
||||||
#import <PostHog/ph_vp8li_dec.h>
|
|
||||||
#import <PostHog/ph_webpi_dec.h>
|
|
||||||
#import <PostHog/ph_huffman_utils.h>
|
|
||||||
#import <PostHog/ph_yuv.h>
|
|
||||||
337
Pods/PostHog/PostHog/PostHogApi.swift
generated
337
Pods/PostHog/PostHog/PostHogApi.swift
generated
@@ -1,337 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogApi.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ben White on 06.02.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class PostHogApi {
|
|
||||||
private let config: PostHogConfig
|
|
||||||
|
|
||||||
// default is 60s but we do 10s
|
|
||||||
private let defaultTimeout: TimeInterval = 10
|
|
||||||
|
|
||||||
init(_ config: PostHogConfig) {
|
|
||||||
self.config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
func sessionConfig() -> URLSessionConfiguration {
|
|
||||||
let config = URLSessionConfiguration.default
|
|
||||||
|
|
||||||
config.httpAdditionalHeaders = [
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"User-Agent": "\(postHogSdkName)/\(postHogVersion)",
|
|
||||||
]
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getURLRequest(_ url: URL) -> URLRequest {
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.timeoutInterval = defaultTimeout
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getEndpointURL(
|
|
||||||
_ endpoint: String,
|
|
||||||
queryItems: URLQueryItem...,
|
|
||||||
relativeTo baseUrl: URL
|
|
||||||
) -> URL? {
|
|
||||||
guard var components = URLComponents(
|
|
||||||
url: baseUrl,
|
|
||||||
resolvingAgainstBaseURL: true
|
|
||||||
) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let path = "\(components.path)/\(endpoint)"
|
|
||||||
.replacingOccurrences(of: "/+", with: "/", options: .regularExpression)
|
|
||||||
components.path = path
|
|
||||||
components.queryItems = queryItems
|
|
||||||
return components.url
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getRemoteConfigRequest() -> URLRequest? {
|
|
||||||
guard let baseUrl: URL = switch config.host.absoluteString {
|
|
||||||
case "https://us.i.posthog.com":
|
|
||||||
URL(string: "https://us-assets.i.posthog.com")
|
|
||||||
case "https://eu.i.posthog.com":
|
|
||||||
URL(string: "https://eu-assets.i.posthog.com")
|
|
||||||
default:
|
|
||||||
config.host
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = baseUrl.appendingPathComponent("/array/\(config.apiKey)/config")
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "GET"
|
|
||||||
request.timeoutInterval = defaultTimeout
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
func batch(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) {
|
|
||||||
guard let url = getEndpointURL("/batch", relativeTo: config.host) else {
|
|
||||||
hedgeLog("Malformed batch URL error.")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = sessionConfig()
|
|
||||||
var headers = config.httpAdditionalHeaders ?? [:]
|
|
||||||
headers["Accept-Encoding"] = "gzip"
|
|
||||||
headers["Content-Encoding"] = "gzip"
|
|
||||||
config.httpAdditionalHeaders = headers
|
|
||||||
|
|
||||||
let request = getURLRequest(url)
|
|
||||||
|
|
||||||
let toSend: [String: Any] = [
|
|
||||||
"api_key": self.config.apiKey,
|
|
||||||
"batch": events.map { $0.toJSON() },
|
|
||||||
"sent_at": toISO8601String(Date()),
|
|
||||||
]
|
|
||||||
|
|
||||||
var data: Data?
|
|
||||||
|
|
||||||
do {
|
|
||||||
data = try JSONSerialization.data(withJSONObject: toSend)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error parsing the batch body: \(error)")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
|
||||||
}
|
|
||||||
|
|
||||||
var gzippedPayload: Data?
|
|
||||||
do {
|
|
||||||
gzippedPayload = try data!.gzipped()
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error gzipping the batch body: \(error).")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
|
||||||
}
|
|
||||||
|
|
||||||
URLSession(configuration: config).uploadTask(with: request, from: gzippedPayload!) { data, response, error in
|
|
||||||
if error != nil {
|
|
||||||
hedgeLog("Error calling the batch API: \(String(describing: error)).")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
|
||||||
}
|
|
||||||
|
|
||||||
let httpResponse = response as! HTTPURLResponse
|
|
||||||
|
|
||||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
|
||||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
|
||||||
let errorMessage = "Error sending events to batch API: status: \(jsonBody)."
|
|
||||||
hedgeLog(errorMessage)
|
|
||||||
} else {
|
|
||||||
hedgeLog("Events sent successfully.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: httpResponse.statusCode, error: error))
|
|
||||||
}.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) {
|
|
||||||
guard let url = getEndpointURL(config.snapshotEndpoint, relativeTo: config.host) else {
|
|
||||||
hedgeLog("Malformed snapshot URL error.")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
for event in events {
|
|
||||||
event.apiKey = self.config.apiKey
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = sessionConfig()
|
|
||||||
var headers = config.httpAdditionalHeaders ?? [:]
|
|
||||||
headers["Accept-Encoding"] = "gzip"
|
|
||||||
headers["Content-Encoding"] = "gzip"
|
|
||||||
config.httpAdditionalHeaders = headers
|
|
||||||
|
|
||||||
let request = getURLRequest(url)
|
|
||||||
|
|
||||||
let toSend = events.map { $0.toJSON() }
|
|
||||||
|
|
||||||
var data: Data?
|
|
||||||
|
|
||||||
do {
|
|
||||||
data = try JSONSerialization.data(withJSONObject: toSend)
|
|
||||||
// remove it only for debugging
|
|
||||||
// if let newData = data {
|
|
||||||
// let convertedString = String(data: newData, encoding: .utf8)
|
|
||||||
// hedgeLog("snapshot body: \(convertedString ?? "")")
|
|
||||||
// }
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error parsing the snapshot body: \(error)")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
|
||||||
}
|
|
||||||
|
|
||||||
var gzippedPayload: Data?
|
|
||||||
do {
|
|
||||||
gzippedPayload = try data!.gzipped()
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error gzipping the snapshot body: \(error).")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
|
||||||
}
|
|
||||||
|
|
||||||
URLSession(configuration: config).uploadTask(with: request, from: gzippedPayload!) { data, response, error in
|
|
||||||
if error != nil {
|
|
||||||
hedgeLog("Error calling the snapshot API: \(String(describing: error)).")
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
|
||||||
}
|
|
||||||
|
|
||||||
let httpResponse = response as! HTTPURLResponse
|
|
||||||
|
|
||||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
|
||||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
|
||||||
let errorMessage = "Error sending events to snapshot API: status: \(httpResponse.statusCode), body: \(jsonBody)."
|
|
||||||
hedgeLog(errorMessage)
|
|
||||||
} else {
|
|
||||||
hedgeLog("Snapshots sent successfully.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return completion(PostHogBatchUploadInfo(statusCode: httpResponse.statusCode, error: error))
|
|
||||||
}.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
func flags(
|
|
||||||
distinctId: String,
|
|
||||||
anonymousId: String?,
|
|
||||||
groups: [String: String],
|
|
||||||
personProperties: [String: Any],
|
|
||||||
groupProperties: [String: [String: Any]]? = nil,
|
|
||||||
completion: @escaping ([String: Any]?, _ error: Error?) -> Void
|
|
||||||
) {
|
|
||||||
let url = getEndpointURL(
|
|
||||||
"/flags",
|
|
||||||
queryItems: URLQueryItem(name: "v", value: "2"), URLQueryItem(name: "config", value: "true"),
|
|
||||||
relativeTo: config.host
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let url else {
|
|
||||||
hedgeLog("Malformed flags URL error.")
|
|
||||||
return completion(nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = sessionConfig()
|
|
||||||
|
|
||||||
let request = getURLRequest(url)
|
|
||||||
|
|
||||||
var toSend: [String: Any] = [
|
|
||||||
"api_key": self.config.apiKey,
|
|
||||||
"distinct_id": distinctId,
|
|
||||||
"$groups": groups,
|
|
||||||
]
|
|
||||||
|
|
||||||
if let anonymousId {
|
|
||||||
toSend["$anon_distinct_id"] = anonymousId
|
|
||||||
}
|
|
||||||
|
|
||||||
if !personProperties.isEmpty {
|
|
||||||
toSend["person_properties"] = personProperties
|
|
||||||
}
|
|
||||||
|
|
||||||
if let groupProperties, !groupProperties.isEmpty {
|
|
||||||
toSend["group_properties"] = groupProperties
|
|
||||||
}
|
|
||||||
|
|
||||||
if let evaluationEnvironments = self.config.evaluationEnvironments, !evaluationEnvironments.isEmpty {
|
|
||||||
toSend["evaluation_environments"] = evaluationEnvironments
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data?
|
|
||||||
|
|
||||||
do {
|
|
||||||
data = try JSONSerialization.data(withJSONObject: toSend)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error parsing the flags body: \(error)")
|
|
||||||
return completion(nil, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
URLSession(configuration: config).uploadTask(with: request, from: data!) { data, response, error in
|
|
||||||
if error != nil {
|
|
||||||
hedgeLog("Error calling the flags API: \(String(describing: error))")
|
|
||||||
return completion(nil, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
let httpResponse = response as! HTTPURLResponse
|
|
||||||
|
|
||||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
|
||||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
|
||||||
let errorMessage = "Error calling flags API: status: \(httpResponse.statusCode), body: \(jsonBody)."
|
|
||||||
hedgeLog(errorMessage)
|
|
||||||
|
|
||||||
return completion(nil,
|
|
||||||
InternalPostHogError(description: errorMessage))
|
|
||||||
} else {
|
|
||||||
hedgeLog("Flags called successfully.")
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let jsonData = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any]
|
|
||||||
completion(jsonData, nil)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error parsing the flags response: \(error)")
|
|
||||||
completion(nil, error)
|
|
||||||
}
|
|
||||||
}.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
func remoteConfig(
|
|
||||||
completion: @escaping ([String: Any]?, _ error: Error?) -> Void
|
|
||||||
) {
|
|
||||||
guard let request = getRemoteConfigRequest() else {
|
|
||||||
hedgeLog("Error calling the remote config API: unable to create request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = sessionConfig()
|
|
||||||
|
|
||||||
let task = URLSession(configuration: config).dataTask(with: request) { data, response, error in
|
|
||||||
if let error {
|
|
||||||
hedgeLog("Error calling the remote config API: \(error.localizedDescription)")
|
|
||||||
return completion(nil, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
let httpResponse = response as! HTTPURLResponse
|
|
||||||
|
|
||||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
|
||||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
|
||||||
let errorMessage = "Error calling the remote config API: status: \(httpResponse.statusCode), body: \(jsonBody)."
|
|
||||||
hedgeLog(errorMessage)
|
|
||||||
|
|
||||||
return completion(nil,
|
|
||||||
InternalPostHogError(description: errorMessage))
|
|
||||||
} else {
|
|
||||||
hedgeLog("Remote config called successfully.")
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let jsonData = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any]
|
|
||||||
completion(jsonData, nil)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error parsing the remote config response: \(error)")
|
|
||||||
completion(nil, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PostHogApi {
|
|
||||||
static var jsonDecoder: JSONDecoder = {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
|
|
||||||
decoder.dateDecodingStrategy = .custom { decoder in
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let dateString = try container.decode(String.self)
|
|
||||||
guard let date = apiDateFormatter.date(from: dateString) else {
|
|
||||||
throw DecodingError.dataCorruptedError(
|
|
||||||
in: container, debugDescription: "Invalid date format"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
return decoder
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
13
Pods/PostHog/PostHog/PostHogBatchUploadInfo.swift
generated
13
Pods/PostHog/PostHog/PostHogBatchUploadInfo.swift
generated
@@ -1,13 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogBatchUploadInfo.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 13.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct PostHogBatchUploadInfo {
|
|
||||||
let statusCode: Int?
|
|
||||||
let error: Error?
|
|
||||||
}
|
|
||||||
276
Pods/PostHog/PostHog/PostHogConfig.swift
generated
276
Pods/PostHog/PostHog/PostHogConfig.swift
generated
@@ -1,276 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogConfig.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ben White on 07.02.23.
|
|
||||||
//
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
|
|
||||||
|
|
||||||
@objc public final class BoxedBeforeSendBlock: NSObject {
|
|
||||||
@objc public let block: BeforeSendBlock
|
|
||||||
|
|
||||||
@objc(block:)
|
|
||||||
public init(block: @escaping BeforeSendBlock) {
|
|
||||||
self.block = block
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(PostHogConfig) public class PostHogConfig: NSObject {
|
|
||||||
enum Defaults {
|
|
||||||
#if os(tvOS)
|
|
||||||
static let flushAt: Int = 5
|
|
||||||
static let maxQueueSize: Int = 100
|
|
||||||
#else
|
|
||||||
static let flushAt: Int = 20
|
|
||||||
static let maxQueueSize: Int = 1000
|
|
||||||
#endif
|
|
||||||
static let maxBatchSize: Int = 50
|
|
||||||
static let flushIntervalSeconds: TimeInterval = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(PostHogDataMode) public enum PostHogDataMode: Int {
|
|
||||||
case wifi
|
|
||||||
case cellular
|
|
||||||
case any
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc public let host: URL
|
|
||||||
@objc public let apiKey: String
|
|
||||||
@objc public var flushAt: Int = Defaults.flushAt
|
|
||||||
@objc public var maxQueueSize: Int = Defaults.maxQueueSize
|
|
||||||
@objc public var maxBatchSize: Int = Defaults.maxBatchSize
|
|
||||||
@objc public var flushIntervalSeconds: TimeInterval = Defaults.flushIntervalSeconds
|
|
||||||
@objc public var dataMode: PostHogDataMode = .any
|
|
||||||
@objc public var sendFeatureFlagEvent: Bool = true
|
|
||||||
@objc public var preloadFeatureFlags: Bool = true
|
|
||||||
|
|
||||||
/// Preload PostHog remote config automatically
|
|
||||||
/// Default: true
|
|
||||||
///
|
|
||||||
/// Note: Surveys rely on remote config. Disabling this will also disable Surveys
|
|
||||||
@objc public var remoteConfig: Bool = true
|
|
||||||
|
|
||||||
@objc public var captureApplicationLifecycleEvents: Bool = true
|
|
||||||
@objc public var captureScreenViews: Bool = true
|
|
||||||
|
|
||||||
/// Enable method swizzling for SDK functionality that depends on it
|
|
||||||
///
|
|
||||||
/// When disabled, functionality that require swizzling (like autocapture, screen views, session replay, surveys) will not be installed.
|
|
||||||
///
|
|
||||||
/// Note: Disabling swizzling will limit session rotation logic to only detect application open and background events.
|
|
||||||
/// Session rotation will still work, just with reduced granularity for detecting user activity.
|
|
||||||
///
|
|
||||||
/// Default: true
|
|
||||||
@objc public var enableSwizzling: Bool = true
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
/// Enable autocapture for iOS
|
|
||||||
/// Default: false
|
|
||||||
@objc public var captureElementInteractions: Bool = false
|
|
||||||
#endif
|
|
||||||
@objc public var debug: Bool = false
|
|
||||||
@objc public var optOut: Bool = false
|
|
||||||
@objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid }
|
|
||||||
|
|
||||||
/// Flag to reuse the anonymous Id between `reset()` and next `identify()` calls
|
|
||||||
///
|
|
||||||
/// If enabled, the anonymous Id will be reused for all anonymous users on this device,
|
|
||||||
/// essentially creating a "Guest user Id" as long as this option is enabled.
|
|
||||||
///
|
|
||||||
/// Note:
|
|
||||||
/// Events captured *before* call to *identify()* won't be linked to the identified user
|
|
||||||
/// Events captured *after* call to *reset()* won't be linked to the identified user
|
|
||||||
///
|
|
||||||
/// Defaults to false.
|
|
||||||
@objc public var reuseAnonymousId: Bool = false
|
|
||||||
|
|
||||||
/// Hook that allows to sanitize the event properties
|
|
||||||
/// The hook is called before the event is cached or sent over the wire
|
|
||||||
@available(*, deprecated, message: "Use beforeSend instead")
|
|
||||||
@objc public var propertiesSanitizer: PostHogPropertiesSanitizer?
|
|
||||||
/// Determines the behavior for processing user profiles.
|
|
||||||
@objc public var personProfiles: PostHogPersonProfiles = .identifiedOnly
|
|
||||||
|
|
||||||
/// Automatically set common device and app properties as person properties for feature flag evaluation.
|
|
||||||
///
|
|
||||||
/// When enabled, the SDK will automatically set the following person properties:
|
|
||||||
/// - $app_version: App version from bundle
|
|
||||||
/// - $app_build: App build number from bundle
|
|
||||||
/// - $os_name: Operating system name (iOS, macOS, etc.)
|
|
||||||
/// - $os_version: Operating system version
|
|
||||||
/// - $device_type: Device type (Mobile, Tablet, Desktop, etc.)
|
|
||||||
/// - $locale: User's current locale
|
|
||||||
///
|
|
||||||
/// This helps ensure feature flags that rely on these properties work correctly
|
|
||||||
/// without waiting for server-side processing of identify() calls.
|
|
||||||
///
|
|
||||||
/// Default: true
|
|
||||||
@objc public var setDefaultPersonProperties: Bool = true
|
|
||||||
|
|
||||||
/// Evaluation environments for feature flags.
|
|
||||||
///
|
|
||||||
/// When configured, only feature flags that have at least one matching evaluation tag
|
|
||||||
/// will be evaluated. Feature flags with no evaluation tags will always be evaluated
|
|
||||||
/// for backward compatibility.
|
|
||||||
///
|
|
||||||
/// Example usage:
|
|
||||||
/// ```swift
|
|
||||||
/// config.evaluationEnvironments = ["production", "web", "checkout"]
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// This helps ensure feature flags are only evaluated in the appropriate environments
|
|
||||||
/// for your SDK instance.
|
|
||||||
///
|
|
||||||
/// Default: nil (all flags are evaluated)
|
|
||||||
@objc public var evaluationEnvironments: [String]?
|
|
||||||
|
|
||||||
/// The identifier of the App Group that should be used to store shared analytics data.
|
|
||||||
/// PostHog will try to get the physical location of the App Group’s shared container, otherwise fallback to the default location
|
|
||||||
/// Default: nil
|
|
||||||
@objc public var appGroupIdentifier: String?
|
|
||||||
|
|
||||||
/// Internal
|
|
||||||
/// Do not modify it, this flag is read and updated by the SDK via feature flags
|
|
||||||
@objc public var snapshotEndpoint: String = "/s/"
|
|
||||||
|
|
||||||
/// or EU Host: 'https://eu.i.posthog.com'
|
|
||||||
public static let defaultHost: String = "https://us.i.posthog.com"
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
/// Enable Recording of Session Replays for iOS
|
|
||||||
/// Default: false
|
|
||||||
@objc public var sessionReplay: Bool = false
|
|
||||||
/// Session Replay configuration
|
|
||||||
@objc public let sessionReplayConfig: PostHogSessionReplayConfig = .init()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Enable mobile surveys
|
|
||||||
///
|
|
||||||
/// Default: true
|
|
||||||
///
|
|
||||||
/// Note: Event triggers will only work with the instance that first enables surveys.
|
|
||||||
/// In case of multiple instances, please make sure you are capturing events on the instance that has config.surveys = true
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
@available(watchOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@available(macOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@available(tvOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@available(visionOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@objc public var surveys: Bool {
|
|
||||||
get { _surveys }
|
|
||||||
set { setSurveys(newValue) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
@available(watchOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@available(macOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@available(tvOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@available(visionOS, unavailable, message: "Surveys are only available on iOS 15+")
|
|
||||||
@objc public var surveysConfig: PostHogSurveysConfig {
|
|
||||||
get { _surveysConfig }
|
|
||||||
set { setSurveysConfig(newValue) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// only internal
|
|
||||||
var disableReachabilityForTesting: Bool = false
|
|
||||||
var disableQueueTimerForTesting: Bool = false
|
|
||||||
// internal
|
|
||||||
public var storageManager: PostHogStorageManager?
|
|
||||||
|
|
||||||
@objc(apiKey:)
|
|
||||||
public init(
|
|
||||||
apiKey: String
|
|
||||||
) {
|
|
||||||
self.apiKey = apiKey
|
|
||||||
host = URL(string: PostHogConfig.defaultHost)!
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(apiKey:host:)
|
|
||||||
public init(
|
|
||||||
apiKey: String,
|
|
||||||
host: String = defaultHost
|
|
||||||
) {
|
|
||||||
self.apiKey = apiKey
|
|
||||||
self.host = URL(string: host) ?? URL(string: PostHogConfig.defaultHost)!
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an array of integrations to be installed based on current configuration
|
|
||||||
func getIntegrations() -> [PostHogIntegration] {
|
|
||||||
var integrations: [PostHogIntegration] = []
|
|
||||||
|
|
||||||
if captureScreenViews {
|
|
||||||
integrations.append(PostHogScreenViewIntegration())
|
|
||||||
}
|
|
||||||
|
|
||||||
if captureApplicationLifecycleEvents {
|
|
||||||
integrations.append(PostHogAppLifeCycleIntegration())
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
if sessionReplay {
|
|
||||||
integrations.append(PostHogReplayIntegration())
|
|
||||||
}
|
|
||||||
|
|
||||||
if _surveys {
|
|
||||||
integrations.append(PostHogSurveyIntegration())
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
||||||
if captureElementInteractions {
|
|
||||||
integrations.append(PostHogAutocaptureIntegration())
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return integrations
|
|
||||||
}
|
|
||||||
|
|
||||||
var _surveys: Bool = true // swiftlint:disable:this identifier_name
|
|
||||||
private func setSurveys(_ value: Bool) {
|
|
||||||
// protection against objc API availability warning instead of error
|
|
||||||
// Unlike swift, which enforces stricter safety rules, objc just displays a warning
|
|
||||||
if #available(iOS 15.0, *) {
|
|
||||||
_surveys = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _surveysConfig: PostHogSurveysConfig = .init() // swiftlint:disable:this identifier_name
|
|
||||||
private func setSurveysConfig(_ value: PostHogSurveysConfig) {
|
|
||||||
// protection against objc API availability warning instead of error
|
|
||||||
// Unlike swift, which enforces stricter safety rules, objc just displays a warning
|
|
||||||
if #available(iOS 15.0, *) {
|
|
||||||
_surveysConfig = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hook that allows to sanitize the event
|
|
||||||
/// The hook is called before the event is cached or sent over the wire
|
|
||||||
private var beforeSend: BeforeSendBlock = { $0 }
|
|
||||||
|
|
||||||
private static func buildBeforeSendBlock(_ blocks: [BeforeSendBlock]) -> BeforeSendBlock {
|
|
||||||
{ event in
|
|
||||||
blocks.reduce(event) { event, block in
|
|
||||||
event.flatMap(block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setBeforeSend(_ blocks: [BeforeSendBlock]) {
|
|
||||||
beforeSend = Self.buildBeforeSendBlock(blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setBeforeSend(_ blocks: BeforeSendBlock...) {
|
|
||||||
setBeforeSend(blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead")
|
|
||||||
@objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) {
|
|
||||||
setBeforeSend(blocks.map(\.block))
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBeforeSend(_ event: PostHogEvent) -> PostHogEvent? {
|
|
||||||
beforeSend(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
Pods/PostHog/PostHog/PostHogConsumerPayload.swift
generated
13
Pods/PostHog/PostHog/PostHogConsumerPayload.swift
generated
@@ -1,13 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogConsumerPayload.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 13.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct PostHogConsumerPayload {
|
|
||||||
let events: [PostHogEvent]
|
|
||||||
let completion: (Bool) -> Void
|
|
||||||
}
|
|
||||||
411
Pods/PostHog/PostHog/PostHogContext.swift
generated
411
Pods/PostHog/PostHog/PostHogContext.swift
generated
@@ -1,411 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogContext.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 16.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#elseif os(watchOS)
|
|
||||||
import WatchKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
class PostHogContext {
|
|
||||||
@ReadWriteLock
|
|
||||||
private var screenSize: CGSize?
|
|
||||||
|
|
||||||
#if !os(watchOS)
|
|
||||||
private let reachability: Reachability?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private lazy var theStaticContext: [String: Any] = {
|
|
||||||
// Properties that do not change over the lifecycle of an application
|
|
||||||
var properties: [String: Any] = [:]
|
|
||||||
|
|
||||||
let infoDictionary = Bundle.main.infoDictionary
|
|
||||||
|
|
||||||
if let appName = infoDictionary?[kCFBundleNameKey as String] {
|
|
||||||
properties["$app_name"] = appName
|
|
||||||
} else if let appName = infoDictionary?["CFBundleDisplayName"] {
|
|
||||||
properties["$app_name"] = appName
|
|
||||||
}
|
|
||||||
if let appVersion = infoDictionary?["CFBundleShortVersionString"] {
|
|
||||||
properties["$app_version"] = appVersion
|
|
||||||
}
|
|
||||||
if let appBuild = infoDictionary?["CFBundleVersion"] {
|
|
||||||
properties["$app_build"] = appBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
if Bundle.main.bundleIdentifier != nil {
|
|
||||||
properties["$app_namespace"] = Bundle.main.bundleIdentifier
|
|
||||||
}
|
|
||||||
properties["$device_manufacturer"] = "Apple"
|
|
||||||
properties["$device_model"] = platform()
|
|
||||||
|
|
||||||
if let deviceType = PostHogContext.deviceType {
|
|
||||||
properties["$device_type"] = deviceType
|
|
||||||
}
|
|
||||||
|
|
||||||
properties["$is_emulator"] = PostHogContext.isSimulator
|
|
||||||
|
|
||||||
let isIOSAppOnMac = PostHogContext.isIOSAppOnMac
|
|
||||||
let isMacCatalystApp = PostHogContext.isMacCatalystApp
|
|
||||||
|
|
||||||
properties["$is_ios_running_on_mac"] = isIOSAppOnMac
|
|
||||||
properties["$is_mac_catalyst_app"] = isMacCatalystApp
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
|
||||||
let device = UIDevice.current
|
|
||||||
// use https://github.com/devicekit/DeviceKit
|
|
||||||
let processInfo = ProcessInfo.processInfo
|
|
||||||
|
|
||||||
if isMacCatalystApp || isIOSAppOnMac {
|
|
||||||
let underlyingOS = device.systemName
|
|
||||||
let underlyingOSVersion = device.systemVersion
|
|
||||||
let macOSVersion = processInfo.operatingSystemVersionString
|
|
||||||
|
|
||||||
if isMacCatalystApp {
|
|
||||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
|
||||||
properties["$os_version"] = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
|
||||||
} else {
|
|
||||||
let osVersionString = processInfo.operatingSystemVersionString
|
|
||||||
if let versionRange = osVersionString.range(of: #"\d+\.\d+\.\d+"#, options: .regularExpression) {
|
|
||||||
properties["$os_version"] = osVersionString[versionRange]
|
|
||||||
} else {
|
|
||||||
// fallback to full version string in case formatting changes
|
|
||||||
properties["$os_version"] = osVersionString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// device.userInterfaceIdiom reports .pad here, so we use a static value instead
|
|
||||||
// - For an app deployable on iPad, the idiom type is always .pad (instead of .mac)
|
|
||||||
//
|
|
||||||
// Source: https://developer.apple.com/documentation/apple-silicon/adapting-ios-code-to-run-in-the-macos-environment#Handle-unknown-device-types-gracefully
|
|
||||||
properties["$os_name"] = "macOS"
|
|
||||||
properties["$device_name"] = processInfo.hostName
|
|
||||||
} else {
|
|
||||||
// use https://github.com/devicekit/DeviceKit
|
|
||||||
properties["$os_name"] = device.systemName
|
|
||||||
properties["$os_version"] = device.systemVersion
|
|
||||||
properties["$device_name"] = device.model
|
|
||||||
}
|
|
||||||
#elseif os(macOS)
|
|
||||||
let deviceName = Host.current().localizedName
|
|
||||||
if (deviceName?.isEmpty) != nil {
|
|
||||||
properties["$device_name"] = deviceName
|
|
||||||
}
|
|
||||||
let processInfo = ProcessInfo.processInfo
|
|
||||||
properties["$os_name"] = "macOS"
|
|
||||||
let osVersion = processInfo.operatingSystemVersion
|
|
||||||
properties["$os_version"] = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return properties
|
|
||||||
}()
|
|
||||||
|
|
||||||
#if !os(watchOS)
|
|
||||||
init(_ reachability: Reachability?) {
|
|
||||||
self.reachability = reachability
|
|
||||||
registerNotifications()
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
init() {
|
|
||||||
if #available(watchOS 7.0, *) {
|
|
||||||
registerNotifications()
|
|
||||||
} else {
|
|
||||||
onShouldUpdateScreenSize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
#if !os(watchOS)
|
|
||||||
unregisterNotifications()
|
|
||||||
#else
|
|
||||||
if #available(watchOS 7.0, *) {
|
|
||||||
unregisterNotifications()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private lazy var theSdkInfo: [String: Any] = {
|
|
||||||
var sdkInfo: [String: Any] = [:]
|
|
||||||
sdkInfo["$lib"] = postHogSdkName
|
|
||||||
sdkInfo["$lib_version"] = postHogVersion
|
|
||||||
return sdkInfo
|
|
||||||
}()
|
|
||||||
|
|
||||||
func staticContext() -> [String: Any] {
|
|
||||||
theStaticContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func sdkInfo() -> [String: Any] {
|
|
||||||
theSdkInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
private func platform() -> String {
|
|
||||||
var sysctlName = "hw.machine"
|
|
||||||
|
|
||||||
// In case of mac catalyst or iOS running on mac:
|
|
||||||
// - "hw.machine" returns underlying iPad/iPhone model
|
|
||||||
// - "hw.model" returns mac model
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
sysctlName = "hw.model"
|
|
||||||
#elseif os(iOS) || os(visionOS)
|
|
||||||
if #available(iOS 14.0, *) {
|
|
||||||
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
||||||
sysctlName = "hw.model"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var size = 0
|
|
||||||
sysctlbyname(sysctlName, nil, &size, nil, 0)
|
|
||||||
var machine = [CChar](repeating: 0, count: size)
|
|
||||||
sysctlbyname(sysctlName, &machine, &size, nil, 0)
|
|
||||||
return String(cString: machine)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dynamicContext() -> [String: Any] {
|
|
||||||
var properties: [String: Any] = [:]
|
|
||||||
|
|
||||||
if let screenSize {
|
|
||||||
properties["$screen_width"] = Float(screenSize.width)
|
|
||||||
properties["$screen_height"] = Float(screenSize.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
|
||||||
if let languageCode = Locale.current.language.languageCode {
|
|
||||||
properties["$locale"] = languageCode.identifier
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if Locale.current.languageCode != nil {
|
|
||||||
properties["$locale"] = Locale.current.languageCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
properties["$timezone"] = TimeZone.current.identifier
|
|
||||||
|
|
||||||
#if !os(watchOS)
|
|
||||||
if reachability != nil {
|
|
||||||
properties["$network_wifi"] = reachability?.connection == .wifi
|
|
||||||
properties["$network_cellular"] = reachability?.connection == .cellular
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return properties
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns person properties context by extracting relevant properties from static context.
|
|
||||||
/// This centralizes the logic for determining which properties should be used as person properties.
|
|
||||||
func personPropertiesContext() -> [String: Any] {
|
|
||||||
let staticCtx = staticContext()
|
|
||||||
var personProperties: [String: Any] = [:]
|
|
||||||
|
|
||||||
// App information
|
|
||||||
if let appVersion = staticCtx["$app_version"] {
|
|
||||||
personProperties["$app_version"] = appVersion
|
|
||||||
}
|
|
||||||
if let appBuild = staticCtx["$app_build"] {
|
|
||||||
personProperties["$app_build"] = appBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
// Operating system information
|
|
||||||
if let osName = staticCtx["$os_name"] {
|
|
||||||
personProperties["$os_name"] = osName
|
|
||||||
}
|
|
||||||
if let osVersion = staticCtx["$os_version"] {
|
|
||||||
personProperties["$os_version"] = osVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device information
|
|
||||||
if let deviceType = staticCtx["$device_type"] {
|
|
||||||
personProperties["$device_type"] = deviceType
|
|
||||||
}
|
|
||||||
if let deviceManufacturer = staticCtx["$device_manufacturer"] {
|
|
||||||
personProperties["$device_manufacturer"] = deviceManufacturer
|
|
||||||
}
|
|
||||||
if let deviceModel = staticCtx["$device_model"] {
|
|
||||||
personProperties["$device_model"] = deviceModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// Localization - read directly to avoid expensive dynamicContext call
|
|
||||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
|
||||||
if let languageCode = Locale.current.language.languageCode {
|
|
||||||
personProperties["$locale"] = languageCode.identifier
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let languageCode = Locale.current.languageCode {
|
|
||||||
personProperties["$locale"] = languageCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return personProperties
|
|
||||||
}
|
|
||||||
|
|
||||||
private func registerNotifications() {
|
|
||||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
|
||||||
#if os(iOS)
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(onOrientationDidChange),
|
|
||||||
name: UIDevice.orientationDidChangeNotification,
|
|
||||||
object: nil)
|
|
||||||
#endif
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(onShouldUpdateScreenSize),
|
|
||||||
name: UIWindow.didBecomeKeyNotification,
|
|
||||||
object: nil)
|
|
||||||
#elseif os(macOS)
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(onShouldUpdateScreenSize),
|
|
||||||
name: NSWindow.didBecomeKeyNotification,
|
|
||||||
object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(onShouldUpdateScreenSize),
|
|
||||||
name: NSWindow.didChangeScreenNotification,
|
|
||||||
object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(onShouldUpdateScreenSize),
|
|
||||||
name: NSApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
#elseif os(watchOS)
|
|
||||||
if #available(watchOS 7.0, *) {
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(onShouldUpdateScreenSize),
|
|
||||||
name: WKApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func unregisterNotifications() {
|
|
||||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
|
||||||
#if os(iOS)
|
|
||||||
NotificationCenter.default.removeObserver(self,
|
|
||||||
name: UIDevice.orientationDidChangeNotification,
|
|
||||||
object: nil)
|
|
||||||
#endif
|
|
||||||
NotificationCenter.default.removeObserver(self,
|
|
||||||
name: UIWindow.didBecomeKeyNotification,
|
|
||||||
object: nil)
|
|
||||||
|
|
||||||
#elseif os(macOS)
|
|
||||||
NotificationCenter.default.removeObserver(self,
|
|
||||||
name: NSWindow.didBecomeKeyNotification,
|
|
||||||
object: nil)
|
|
||||||
NotificationCenter.default.removeObserver(self,
|
|
||||||
name: NSWindow.didChangeScreenNotification,
|
|
||||||
object: nil)
|
|
||||||
NotificationCenter.default.removeObserver(self,
|
|
||||||
name: NSApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
#elseif os(watchOS)
|
|
||||||
if #available(watchOS 7.0, *) {
|
|
||||||
NotificationCenter.default.removeObserver(self,
|
|
||||||
name: WKApplication.didBecomeActiveNotification,
|
|
||||||
object: nil)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieves the current screen size of the application window based on platform
|
|
||||||
private func getScreenSize() -> CGSize? {
|
|
||||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
|
||||||
return UIApplication.getCurrentWindow(filterForegrounded: false)?.bounds.size
|
|
||||||
#elseif os(macOS)
|
|
||||||
// NSScreen.frame represents the full screen rectangle and includes any space occupied by menu, dock or camera bezel
|
|
||||||
return NSApplication.shared.windows.first { $0.isKeyWindow }?.screen?.frame.size
|
|
||||||
#elseif os(watchOS)
|
|
||||||
return WKInterfaceDevice.current().screenBounds.size
|
|
||||||
#else
|
|
||||||
return nil
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
// Special treatment for `orientationDidChangeNotification` since the notification seems to be _sometimes_ called early, before screen bounds are flipped
|
|
||||||
@objc private func onOrientationDidChange() {
|
|
||||||
updateScreenSize {
|
|
||||||
self.getScreenSize().map { size in
|
|
||||||
// manually set width and height based on device orientation. (Needed for fast orientation changes)
|
|
||||||
if UIDevice.current.orientation.isLandscape {
|
|
||||||
CGSize(width: max(size.width, size.height), height: min(size.height, size.width))
|
|
||||||
} else {
|
|
||||||
CGSize(width: min(size.width, size.height), height: max(size.height, size.width))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@objc private func onShouldUpdateScreenSize() {
|
|
||||||
updateScreenSize(getScreenSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateScreenSize(_ getSize: @escaping () -> CGSize?) {
|
|
||||||
let block = {
|
|
||||||
self.screenSize = getSize()
|
|
||||||
}
|
|
||||||
// ensure block is executed on `main` since closure accesses non thread-safe UI objects like UIApplication
|
|
||||||
if Thread.isMainThread {
|
|
||||||
block()
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async(execute: block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let deviceType: String? = {
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
if isMacCatalystApp || isIOSAppOnMac {
|
|
||||||
return "Desktop"
|
|
||||||
} else {
|
|
||||||
switch UIDevice.current.userInterfaceIdiom {
|
|
||||||
case UIUserInterfaceIdiom.phone:
|
|
||||||
return "Mobile"
|
|
||||||
case UIUserInterfaceIdiom.pad:
|
|
||||||
return "Tablet"
|
|
||||||
case UIUserInterfaceIdiom.tv:
|
|
||||||
return "TV"
|
|
||||||
case UIUserInterfaceIdiom.carPlay:
|
|
||||||
return "CarPlay"
|
|
||||||
case UIUserInterfaceIdiom.mac:
|
|
||||||
return "Desktop"
|
|
||||||
case UIUserInterfaceIdiom.vision:
|
|
||||||
return "Vision"
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#elseif os(macOS)
|
|
||||||
return "Desktop"
|
|
||||||
#else
|
|
||||||
return nil
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let isIOSAppOnMac: Bool = {
|
|
||||||
if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
|
|
||||||
return ProcessInfo.processInfo.isiOSAppOnMac
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let isMacCatalystApp: Bool = {
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
true
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let isSimulator: Bool = {
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
true
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
20
Pods/PostHog/PostHog/PostHogExtensions.swift
generated
20
Pods/PostHog/PostHog/PostHogExtensions.swift
generated
@@ -1,20 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogExtensions.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 13.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/**
|
|
||||||
# Notifications
|
|
||||||
|
|
||||||
This helper module encapsulates all notifications that we trigger from within the SDK.
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
public extension PostHogSDK {
|
|
||||||
@objc static let didStartNotification = Notification.Name("PostHogDidStart") // object: nil
|
|
||||||
@objc static let didReceiveFeatureFlags = Notification.Name("PostHogDidReceiveFeatureFlags") // object: nil
|
|
||||||
}
|
|
||||||
114
Pods/PostHog/PostHog/PostHogFileBackedQueue.swift
generated
114
Pods/PostHog/PostHog/PostHogFileBackedQueue.swift
generated
@@ -1,114 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogFileBackedQueue.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 13.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class PostHogFileBackedQueue {
|
|
||||||
let queue: URL
|
|
||||||
@ReadWriteLock
|
|
||||||
private var items = [String]()
|
|
||||||
|
|
||||||
var depth: Int {
|
|
||||||
items.count
|
|
||||||
}
|
|
||||||
|
|
||||||
init(queue: URL, oldQueue: URL? = nil) {
|
|
||||||
self.queue = queue
|
|
||||||
setup(oldQueue: oldQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setup(oldQueue: URL?) {
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(atPath: queue.path, withIntermediateDirectories: true)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error trying to create caching folder \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if oldQueue != nil {
|
|
||||||
migrateOldQueue(queue: queue, oldQueue: oldQueue!)
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
items = try FileManager.default.contentsOfDirectory(atPath: queue.path)
|
|
||||||
items.sort { Double($0)! < Double($1)! }
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Failed to load files for queue \(error)")
|
|
||||||
// failed to read directory – bad permissions, perhaps?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peek(_ count: Int) -> [Data] {
|
|
||||||
loadFiles(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(index: Int) {
|
|
||||||
if items.isEmpty { return }
|
|
||||||
let removed = items.remove(at: index)
|
|
||||||
|
|
||||||
deleteSafely(queue.appendingPathComponent(removed))
|
|
||||||
}
|
|
||||||
|
|
||||||
func pop(_ count: Int) {
|
|
||||||
deleteFiles(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(_ contents: Data) {
|
|
||||||
do {
|
|
||||||
let filename = "\(Date().timeIntervalSince1970)"
|
|
||||||
try contents.write(to: queue.appendingPathComponent(filename))
|
|
||||||
items.append(filename)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Could not write file \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal, used for testing
|
|
||||||
func clear() {
|
|
||||||
deleteSafely(queue)
|
|
||||||
setup(oldQueue: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFiles(_ count: Int) -> [Data] {
|
|
||||||
var results = [Data]()
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
let itemURL = queue.appendingPathComponent(item)
|
|
||||||
do {
|
|
||||||
if !FileManager.default.fileExists(atPath: itemURL.path) {
|
|
||||||
hedgeLog("File \(itemURL) does not exist")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let contents = try Data(contentsOf: itemURL)
|
|
||||||
|
|
||||||
results.append(contents)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("File \(itemURL) is corrupted \(error)")
|
|
||||||
|
|
||||||
deleteSafely(itemURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if results.count == count {
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteFiles(_ count: Int) {
|
|
||||||
for _ in 0 ..< count {
|
|
||||||
if let removed: String = _items.mutate({ items in
|
|
||||||
if items.isEmpty {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return items.remove(at: 0) // We always remove from the top of the queue
|
|
||||||
}) {
|
|
||||||
deleteSafely(queue.appendingPathComponent(removed))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
59
Pods/PostHog/PostHog/PostHogIntegration.swift
generated
59
Pods/PostHog/PostHog/PostHogIntegration.swift
generated
@@ -1,59 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogIntegration.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 25/02/2025.
|
|
||||||
//
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
protocol PostHogIntegration {
|
|
||||||
/**
|
|
||||||
* Indicates whether this integration requires method swizzling to function.
|
|
||||||
*
|
|
||||||
* When `enableSwizzling` is set to `false` in PostHogConfig, integrations
|
|
||||||
* that return `true` for this property will be skipped during installation.
|
|
||||||
*/
|
|
||||||
var requiresSwizzling: Bool { get }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs and initializes the integration with a PostHogSDK instance.
|
|
||||||
*
|
|
||||||
* This method should:
|
|
||||||
* 1. Run checks if needed to ensure that the integration is only installed once
|
|
||||||
* 2. Initialize any required resources
|
|
||||||
* 3. Start the integration's functionality
|
|
||||||
*
|
|
||||||
* - Parameter postHog: The PostHogSDK instance to integrate with
|
|
||||||
* - Throws: InternalPostHogError if installation fails (e.g., already installed)
|
|
||||||
*/
|
|
||||||
func install(_ postHog: PostHogSDK) throws
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uninstalls the integration from a specific PostHogSDK instance.
|
|
||||||
*
|
|
||||||
* This method should:
|
|
||||||
* 1. Stop all integration functionality
|
|
||||||
* 2. Clean up any resources
|
|
||||||
* 3. Remove references to the PostHog instance
|
|
||||||
*
|
|
||||||
* - Parameter postHog: The PostHog SDK instance to uninstall from
|
|
||||||
*/
|
|
||||||
func uninstall(_ postHog: PostHogSDK)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the integration's functionality.
|
|
||||||
*
|
|
||||||
* Note: This is typically called automatically during installation
|
|
||||||
* but may be called manually to restart a stopped integration.
|
|
||||||
*/
|
|
||||||
func start()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops the integration's functionality without uninstalling.
|
|
||||||
*
|
|
||||||
* Note: This is typically called automatically during uninstallation
|
|
||||||
* but may be called manually to temporarily suspend the integration
|
|
||||||
* while maintaining its installation status (e.g manual start/stop for session recording)
|
|
||||||
*/
|
|
||||||
func stop()
|
|
||||||
}
|
|
||||||
45
Pods/PostHog/PostHog/PostHogLegacyQueue.swift
generated
45
Pods/PostHog/PostHog/PostHogLegacyQueue.swift
generated
@@ -1,45 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogLegacyQueue.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 30.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// Migrates the Old Queue (v2) to the new Queue (v3)
|
|
||||||
func migrateOldQueue(queue: URL, oldQueue: URL) {
|
|
||||||
if !FileManager.default.fileExists(atPath: oldQueue.path) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer {
|
|
||||||
deleteSafely(oldQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: oldQueue)
|
|
||||||
let array = try JSONSerialization.jsonObject(with: data) as? [Any]
|
|
||||||
|
|
||||||
if array == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in array! {
|
|
||||||
guard let event = item as? [String: Any] else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let timestamp = event["timestamp"] as? String ?? toISO8601String(Date())
|
|
||||||
|
|
||||||
let timestampDate = toISO8601Date(timestamp) ?? Date()
|
|
||||||
|
|
||||||
let filename = "\(timestampDate.timeIntervalSince1970)"
|
|
||||||
|
|
||||||
let contents = try JSONSerialization.data(withJSONObject: event)
|
|
||||||
|
|
||||||
try contents.write(to: queue.appendingPathComponent(filename))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Failed to migrate queue \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
Pods/PostHog/PostHog/PostHogPersonProfiles.swift
generated
20
Pods/PostHog/PostHog/PostHogPersonProfiles.swift
generated
@@ -1,20 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogPersonProfiles.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 09.09.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Determines the behavior for processing user profiles.
|
|
||||||
/// - `never`: We won't process persons for any event. This means that anonymous users will not be merged once
|
|
||||||
/// they sign up or login, so you lose the ability to create funnels that track users from anonymous to identified.
|
|
||||||
/// All events (including `$identify`) will be sent with `$process_person_profile: False`.
|
|
||||||
/// - `always`: We will process persons data for all events.
|
|
||||||
/// - `identifiedOnly`: (default): we will only process persons when you call `identify`, `alias`, and `group`, Anonymous users won't get person profiles.
|
|
||||||
@objc(PostHogPersonProfiles) public enum PostHogPersonProfiles: Int {
|
|
||||||
case never
|
|
||||||
case always
|
|
||||||
case identifiedOnly
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogPropertiesSanitizer.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 06.08.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Protocol to sanitize the event properties
|
|
||||||
@objc(PostHogPropertiesSanitizer) public protocol PostHogPropertiesSanitizer {
|
|
||||||
/// Sanitizes the event properties
|
|
||||||
/// - Parameter properties: the event properties to sanitize
|
|
||||||
/// - Returns: the sanitized properties
|
|
||||||
///
|
|
||||||
/// Obs: `inout` cannot be used in Swift protocols, so you need to clone the properties
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// private class ExampleSanitizer: PostHogPropertiesSanitizer {
|
|
||||||
/// public func sanitize(_ properties: [String: Any]) -> [String: Any] {
|
|
||||||
/// var sanitizedProperties = properties
|
|
||||||
/// // Perform sanitization
|
|
||||||
/// // For example, removing keys with empty values
|
|
||||||
/// for (key, value) in properties {
|
|
||||||
/// if let stringValue = value as? String, stringValue.isEmpty {
|
|
||||||
/// sanitizedProperties.removeValue(forKey: key)
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// return sanitizedProperties
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
@objc func sanitize(_ properties: [String: Any]) -> [String: Any]
|
|
||||||
}
|
|
||||||
285
Pods/PostHog/PostHog/PostHogQueue.swift
generated
285
Pods/PostHog/PostHog/PostHogQueue.swift
generated
@@ -1,285 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogQueue.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ben White on 06.02.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/**
|
|
||||||
# Queue
|
|
||||||
|
|
||||||
The queue uses File persistence. This allows us to
|
|
||||||
1. Only send events when we have a network connection
|
|
||||||
2. Ensure that we can survive app closing or offline situations
|
|
||||||
3. Not hold too much in memory
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
class PostHogQueue {
|
|
||||||
enum PostHogApiEndpoint: Int {
|
|
||||||
case batch
|
|
||||||
case snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
private let config: PostHogConfig
|
|
||||||
private let api: PostHogApi
|
|
||||||
private var paused: Bool = false
|
|
||||||
private let pausedLock = NSLock()
|
|
||||||
private var pausedUntil: Date?
|
|
||||||
private var retryCount: TimeInterval = 0
|
|
||||||
#if !os(watchOS)
|
|
||||||
private let reachability: Reachability?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private var isFlushing = false
|
|
||||||
private let isFlushingLock = NSLock()
|
|
||||||
private var timer: Timer?
|
|
||||||
private let timerLock = NSLock()
|
|
||||||
private let endpoint: PostHogApiEndpoint
|
|
||||||
private let dispatchQueue: DispatchQueue
|
|
||||||
|
|
||||||
/// Internal, used for testing
|
|
||||||
var depth: Int {
|
|
||||||
fileQueue.depth
|
|
||||||
}
|
|
||||||
|
|
||||||
private let fileQueue: PostHogFileBackedQueue
|
|
||||||
|
|
||||||
#if !os(watchOS)
|
|
||||||
init(_ config: PostHogConfig, _ storage: PostHogStorage, _ api: PostHogApi, _ endpoint: PostHogApiEndpoint, _ reachability: Reachability?) {
|
|
||||||
self.config = config
|
|
||||||
self.api = api
|
|
||||||
self.reachability = reachability
|
|
||||||
self.endpoint = endpoint
|
|
||||||
|
|
||||||
switch endpoint {
|
|
||||||
case .batch:
|
|
||||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .queue), oldQueue: storage.url(forKey: .oldQeueue))
|
|
||||||
dispatchQueue = DispatchQueue(label: "com.posthog.Queue", target: .global(qos: .utility))
|
|
||||||
case .snapshot:
|
|
||||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .replayQeueue))
|
|
||||||
dispatchQueue = DispatchQueue(label: "com.posthog.ReplayQueue", target: .global(qos: .utility))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
init(_ config: PostHogConfig, _ storage: PostHogStorage, _ api: PostHogApi, _ endpoint: PostHogApiEndpoint) {
|
|
||||||
self.config = config
|
|
||||||
self.api = api
|
|
||||||
self.endpoint = endpoint
|
|
||||||
|
|
||||||
switch endpoint {
|
|
||||||
case .batch:
|
|
||||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .queue), oldQueue: storage.url(forKey: .oldQeueue))
|
|
||||||
dispatchQueue = DispatchQueue(label: "com.posthog.Queue", target: .global(qos: .utility))
|
|
||||||
case .snapshot:
|
|
||||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .replayQeueue))
|
|
||||||
dispatchQueue = DispatchQueue(label: "com.posthog.ReplayQueue", target: .global(qos: .utility))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func eventHandler(_ payload: PostHogConsumerPayload) {
|
|
||||||
hedgeLog("Sending batch of \(payload.events.count) events to PostHog")
|
|
||||||
|
|
||||||
switch endpoint {
|
|
||||||
case .batch:
|
|
||||||
api.batch(events: payload.events) { result in
|
|
||||||
self.handleResult(result, payload)
|
|
||||||
}
|
|
||||||
case .snapshot:
|
|
||||||
api.snapshot(events: payload.events) { result in
|
|
||||||
self.handleResult(result, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleResult(_ result: PostHogBatchUploadInfo, _ payload: PostHogConsumerPayload) {
|
|
||||||
// -1 means its not anything related to the API but rather network or something else, so we try again
|
|
||||||
let statusCode = result.statusCode ?? -1
|
|
||||||
|
|
||||||
var shouldRetry = false
|
|
||||||
if 300 ... 399 ~= statusCode || statusCode == -1 {
|
|
||||||
shouldRetry = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: https://github.com/PostHog/posthog-android/pull/130
|
|
||||||
// fix: reduce batch size if API returns 413
|
|
||||||
|
|
||||||
if shouldRetry {
|
|
||||||
retryCount += 1
|
|
||||||
let delay = min(retryCount * retryDelay, maxRetryDelay)
|
|
||||||
pauseFor(seconds: delay)
|
|
||||||
hedgeLog("Pausing queue consumption for \(delay) seconds due to \(retryCount) API failure(s).")
|
|
||||||
} else {
|
|
||||||
retryCount = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.completion(!shouldRetry)
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(disableReachabilityForTesting: Bool,
|
|
||||||
disableQueueTimerForTesting: Bool)
|
|
||||||
{
|
|
||||||
if !disableReachabilityForTesting {
|
|
||||||
// Setup the monitoring of network status for the queue
|
|
||||||
#if !os(watchOS)
|
|
||||||
reachability?.whenReachable = { reachability in
|
|
||||||
self.pausedLock.withLock {
|
|
||||||
if self.config.dataMode == .wifi, reachability.connection != .wifi {
|
|
||||||
hedgeLog("Queue is paused because its not in WiFi mode")
|
|
||||||
self.paused = true
|
|
||||||
} else {
|
|
||||||
self.paused = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always trigger a flush when we are on wifi
|
|
||||||
if reachability.connection == .wifi {
|
|
||||||
if !self.isFlushing {
|
|
||||||
self.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reachability?.whenUnreachable = { _ in
|
|
||||||
self.pausedLock.withLock {
|
|
||||||
hedgeLog("Queue is paused because network is unreachable")
|
|
||||||
self.paused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
try reachability?.startNotifier()
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error: Unable to monitor network reachability: \(error)")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
if !disableQueueTimerForTesting {
|
|
||||||
timerLock.withLock {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.timer = Timer.scheduledTimer(withTimeInterval: self.config.flushIntervalSeconds, repeats: true, block: { _ in
|
|
||||||
if !self.isFlushing {
|
|
||||||
self.flush()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal, used for testing
|
|
||||||
func clear() {
|
|
||||||
fileQueue.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
timerLock.withLock {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func flush() {
|
|
||||||
if !canFlush() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
take(config.maxBatchSize) { payload in
|
|
||||||
if !payload.events.isEmpty {
|
|
||||||
self.eventHandler(payload)
|
|
||||||
} else {
|
|
||||||
// there's nothing to be sent
|
|
||||||
payload.completion(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func flushIfOverThreshold() {
|
|
||||||
if fileQueue.depth >= config.flushAt {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(_ event: PostHogEvent) {
|
|
||||||
if fileQueue.depth >= config.maxQueueSize {
|
|
||||||
hedgeLog("Queue is full, dropping oldest event")
|
|
||||||
// first is always oldest
|
|
||||||
fileQueue.delete(index: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data?
|
|
||||||
do {
|
|
||||||
data = try JSONSerialization.data(withJSONObject: event.toJSON())
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Tried to queue unserialisable PostHogEvent \(error)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileQueue.add(data!)
|
|
||||||
hedgeLog("Queued event '\(event.event)'. Depth: \(fileQueue.depth)")
|
|
||||||
flushIfOverThreshold()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func take(_ count: Int, completion: @escaping (PostHogConsumerPayload) -> Void) {
|
|
||||||
dispatchQueue.async {
|
|
||||||
self.isFlushingLock.withLock {
|
|
||||||
if self.isFlushing {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.isFlushing = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let items = self.fileQueue.peek(count)
|
|
||||||
|
|
||||||
var processing = [PostHogEvent]()
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
// each element is a PostHogEvent if fromJSON succeeds
|
|
||||||
guard let event = PostHogEvent.fromJSON(item) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
processing.append(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
completion(PostHogConsumerPayload(events: processing) { success in
|
|
||||||
if success, items.count > 0 {
|
|
||||||
self.fileQueue.pop(items.count)
|
|
||||||
hedgeLog("Completed!")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.isFlushingLock.withLock {
|
|
||||||
self.isFlushing = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pauseFor(seconds: TimeInterval) {
|
|
||||||
pausedUntil = Date().addingTimeInterval(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func canFlush() -> Bool {
|
|
||||||
if isFlushing {
|
|
||||||
hedgeLog("Already flushing")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if paused {
|
|
||||||
// We don't flush data if the queue is paused
|
|
||||||
hedgeLog("The queue is paused due to the reachability check")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if pausedUntil != nil, pausedUntil! > Date() {
|
|
||||||
// We don't flush data if the queue is temporarily paused
|
|
||||||
hedgeLog("The queue is paused until `\(pausedUntil!)`")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
636
Pods/PostHog/PostHog/PostHogRemoteConfig.swift
generated
636
Pods/PostHog/PostHog/PostHogRemoteConfig.swift
generated
@@ -1,636 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogRemoteConfig.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 10.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class PostHogRemoteConfig {
|
|
||||||
private let hasFeatureFlagsKey = "hasFeatureFlags"
|
|
||||||
|
|
||||||
private let config: PostHogConfig
|
|
||||||
private let storage: PostHogStorage
|
|
||||||
private let api: PostHogApi
|
|
||||||
private let getDefaultPersonProperties: () -> [String: Any]
|
|
||||||
|
|
||||||
private let loadingFeatureFlagsLock = NSLock()
|
|
||||||
private let featureFlagsLock = NSLock()
|
|
||||||
private var loadingFeatureFlags = false
|
|
||||||
private var sessionReplayFlagActive = false
|
|
||||||
|
|
||||||
private var flags: [String: Any]?
|
|
||||||
private var featureFlags: [String: Any]?
|
|
||||||
|
|
||||||
private var remoteConfigLock = NSLock()
|
|
||||||
private let loadingRemoteConfigLock = NSLock()
|
|
||||||
private var loadingRemoteConfig = false
|
|
||||||
private var remoteConfig: [String: Any]?
|
|
||||||
private var remoteConfigDidFetch: Bool = false
|
|
||||||
private var featureFlagPayloads: [String: Any]?
|
|
||||||
private var requestId: String?
|
|
||||||
|
|
||||||
private let personPropertiesForFlagsLock = NSLock()
|
|
||||||
private var personPropertiesForFlags: [String: Any] = [:]
|
|
||||||
|
|
||||||
private let groupPropertiesForFlagsLock = NSLock()
|
|
||||||
private var groupPropertiesForFlags: [String: [String: Any]] = [:]
|
|
||||||
|
|
||||||
/// Internal, only used for testing
|
|
||||||
var canReloadFlagsForTesting = true
|
|
||||||
|
|
||||||
var onRemoteConfigLoaded: (([String: Any]?) -> Void)?
|
|
||||||
var onFeatureFlagsLoaded: (([String: Any]?) -> Void)?
|
|
||||||
|
|
||||||
private let dispatchQueue = DispatchQueue(label: "com.posthog.RemoteConfig",
|
|
||||||
target: .global(qos: .utility))
|
|
||||||
|
|
||||||
var lastRequestId: String? {
|
|
||||||
featureFlagsLock.withLock {
|
|
||||||
requestId ?? storage.getString(forKey: .requestId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(_ config: PostHogConfig,
|
|
||||||
_ storage: PostHogStorage,
|
|
||||||
_ api: PostHogApi,
|
|
||||||
_ getDefaultPersonProperties: @escaping () -> [String: Any])
|
|
||||||
{
|
|
||||||
self.config = config
|
|
||||||
self.storage = storage
|
|
||||||
self.api = api
|
|
||||||
self.getDefaultPersonProperties = getDefaultPersonProperties
|
|
||||||
|
|
||||||
// Load cached person and group properties for flags
|
|
||||||
loadCachedPropertiesForFlags()
|
|
||||||
|
|
||||||
preloadSessionReplayFlag()
|
|
||||||
|
|
||||||
if config.remoteConfig {
|
|
||||||
preloadRemoteConfig()
|
|
||||||
} else if config.preloadFeatureFlags {
|
|
||||||
preloadFeatureFlags()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func preloadRemoteConfig() {
|
|
||||||
remoteConfigLock.withLock {
|
|
||||||
// load disk cached config to memory
|
|
||||||
_ = getCachedRemoteConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
// may have already beed fetched from `loadFeatureFlags` call
|
|
||||||
if remoteConfigLock.withLock({
|
|
||||||
self.remoteConfig == nil || !self.remoteConfigDidFetch
|
|
||||||
}) {
|
|
||||||
dispatchQueue.async {
|
|
||||||
self.reloadRemoteConfig { [weak self] remoteConfig in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
// if there's no remote config response, skip
|
|
||||||
guard let remoteConfig else {
|
|
||||||
hedgeLog("Remote config response is missing, skipping loading flags")
|
|
||||||
notifyFeatureFlags(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the server explicitly responded with hasFeatureFlags key
|
|
||||||
if let hasFeatureFlagsBoolValue = remoteConfig[self.hasFeatureFlagsKey] as? Bool, !hasFeatureFlagsBoolValue {
|
|
||||||
hedgeLog("hasFeatureFlags is false, clearing flags and skipping loading flags")
|
|
||||||
// Server responded with explicit hasFeatureFlags: false, meaning no active flags on the account
|
|
||||||
clearFeatureFlags()
|
|
||||||
// need to notify cause people may be waiting for flags to load
|
|
||||||
notifyFeatureFlags([:])
|
|
||||||
} else if self.config.preloadFeatureFlags {
|
|
||||||
// If we reach here, hasFeatureFlags is either true, nil or not a boolean value
|
|
||||||
// Note: notifyFeatureFlags() will be eventually called inside preloadFeatureFlags()
|
|
||||||
self.preloadFeatureFlags()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func preloadFeatureFlags() {
|
|
||||||
featureFlagsLock.withLock {
|
|
||||||
// load disk cached config to memory
|
|
||||||
_ = getCachedFeatureFlags()
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.preloadFeatureFlags {
|
|
||||||
dispatchQueue.async {
|
|
||||||
self.reloadFeatureFlags()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadRemoteConfig(
|
|
||||||
callback: (([String: Any]?) -> Void)? = nil
|
|
||||||
) {
|
|
||||||
guard config.remoteConfig else {
|
|
||||||
callback?(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingRemoteConfigLock.withLock {
|
|
||||||
if self.loadingRemoteConfig {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.loadingRemoteConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
api.remoteConfig { config, _ in
|
|
||||||
if let config {
|
|
||||||
// cache config
|
|
||||||
self.remoteConfigLock.withLock {
|
|
||||||
self.remoteConfig = config
|
|
||||||
self.storage.setDictionary(forKey: .remoteConfig, contents: config)
|
|
||||||
}
|
|
||||||
|
|
||||||
// process session replay config
|
|
||||||
#if os(iOS)
|
|
||||||
let featureFlags = self.featureFlagsLock.withLock { self.featureFlags }
|
|
||||||
self.processSessionRecordingConfig(config, featureFlags: featureFlags ?? [:])
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// notify
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onRemoteConfigLoaded?(config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.loadingRemoteConfigLock.withLock {
|
|
||||||
self.remoteConfigDidFetch = true
|
|
||||||
self.loadingRemoteConfig = false
|
|
||||||
}
|
|
||||||
|
|
||||||
callback?(config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadFeatureFlags(
|
|
||||||
callback: (([String: Any]?) -> Void)? = nil
|
|
||||||
) {
|
|
||||||
guard canReloadFlagsForTesting else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let storageManager = config.storageManager else {
|
|
||||||
hedgeLog("No PostHogStorageManager found in config, skipping loading feature flags")
|
|
||||||
callback?(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let groups = featureFlagsLock.withLock { getGroups() }
|
|
||||||
let distinctId = storageManager.getDistinctId()
|
|
||||||
let anonymousId = config.reuseAnonymousId == false ? storageManager.getAnonymousId() : nil
|
|
||||||
|
|
||||||
loadFeatureFlags(
|
|
||||||
distinctId: distinctId,
|
|
||||||
anonymousId: anonymousId,
|
|
||||||
groups: groups,
|
|
||||||
callback: callback ?? { _ in }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func preloadSessionReplayFlag() {
|
|
||||||
var sessionReplay: [String: Any]?
|
|
||||||
var featureFlags: [String: Any]?
|
|
||||||
featureFlagsLock.withLock {
|
|
||||||
sessionReplay = self.storage.getDictionary(forKey: .sessionReplay) as? [String: Any]
|
|
||||||
featureFlags = self.getCachedFeatureFlags()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sessionReplay = sessionReplay {
|
|
||||||
sessionReplayFlagActive = isRecordingActive(featureFlags ?? [:], sessionReplay)
|
|
||||||
|
|
||||||
if let endpoint = sessionReplay["endpoint"] as? String {
|
|
||||||
config.snapshotEndpoint = endpoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isRecordingActive(_ featureFlags: [String: Any], _ sessionRecording: [String: Any]) -> Bool {
|
|
||||||
var recordingActive = true
|
|
||||||
|
|
||||||
// check for boolean flags
|
|
||||||
if let linkedFlag = sessionRecording["linkedFlag"] as? String {
|
|
||||||
let value = featureFlags[linkedFlag]
|
|
||||||
|
|
||||||
if let boolValue = value as? Bool {
|
|
||||||
// boolean flag with value
|
|
||||||
recordingActive = boolValue
|
|
||||||
} else if value is String {
|
|
||||||
// its a multi-variant flag linked to "any"
|
|
||||||
recordingActive = true
|
|
||||||
} else {
|
|
||||||
// disable recording if the flag does not exist/quota limited
|
|
||||||
recordingActive = false
|
|
||||||
}
|
|
||||||
// check for specific flag variant
|
|
||||||
} else if let linkedFlag = sessionRecording["linkedFlag"] as? [String: Any] {
|
|
||||||
let flag = linkedFlag["flag"] as? String
|
|
||||||
let variant = linkedFlag["variant"] as? String
|
|
||||||
|
|
||||||
if let flag, let variant {
|
|
||||||
let value = featureFlags[flag] as? String
|
|
||||||
recordingActive = value == variant
|
|
||||||
} else {
|
|
||||||
// disable recording if the flag does not exist/quota limited
|
|
||||||
recordingActive = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// check for multi flag variant (any)
|
|
||||||
// if let linkedFlag = sessionRecording["linkedFlag"] as? String,
|
|
||||||
// featureFlags[linkedFlag] != nil
|
|
||||||
// is also a valid check but since we cannot check the value of the flag,
|
|
||||||
// we consider session recording is active
|
|
||||||
|
|
||||||
return recordingActive
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadFeatureFlags(
|
|
||||||
distinctId: String,
|
|
||||||
anonymousId: String?,
|
|
||||||
groups: [String: String],
|
|
||||||
callback: @escaping ([String: Any]?) -> Void
|
|
||||||
) {
|
|
||||||
loadingFeatureFlagsLock.withLock {
|
|
||||||
if self.loadingFeatureFlags {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.loadingFeatureFlags = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let personProperties = getPersonPropertiesForFlags()
|
|
||||||
let groupProperties = getGroupPropertiesForFlags()
|
|
||||||
|
|
||||||
api.flags(distinctId: distinctId,
|
|
||||||
anonymousId: anonymousId,
|
|
||||||
groups: groups,
|
|
||||||
personProperties: personProperties,
|
|
||||||
groupProperties: groupProperties.isEmpty ? nil : groupProperties)
|
|
||||||
{ data, _ in
|
|
||||||
self.dispatchQueue.async {
|
|
||||||
// Check for quota limitation first
|
|
||||||
if let quotaLimited = data?["quotaLimited"] as? [String],
|
|
||||||
quotaLimited.contains("feature_flags")
|
|
||||||
{
|
|
||||||
// swiftlint:disable:next line_length
|
|
||||||
hedgeLog("Warning: Feature flags quota limit reached - clearing all feature flags and payloads. See https://posthog.com/docs/billing/limits-alerts for more information.")
|
|
||||||
|
|
||||||
self.clearFeatureFlags()
|
|
||||||
self.notifyFeatureFlagsAndRelease([:])
|
|
||||||
return callback([:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely handle optional data
|
|
||||||
guard var data = data else {
|
|
||||||
hedgeLog("Error: Flags response data is nil")
|
|
||||||
self.notifyFeatureFlagsAndRelease(nil)
|
|
||||||
return callback(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.normalizeResponse(&data)
|
|
||||||
|
|
||||||
let flagsV4 = data["flags"] as? [String: Any]
|
|
||||||
|
|
||||||
guard let featureFlags = data["featureFlags"] as? [String: Any],
|
|
||||||
let featureFlagPayloads = data["featureFlagPayloads"] as? [String: Any]
|
|
||||||
else {
|
|
||||||
hedgeLog("Error: Flags response missing correct featureFlags format")
|
|
||||||
self.notifyFeatureFlagsAndRelease(nil)
|
|
||||||
return callback(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
self.processSessionRecordingConfig(data, featureFlags: featureFlags)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Grab the request ID from the response
|
|
||||||
let requestId = data["requestId"] as? String
|
|
||||||
let errorsWhileComputingFlags = data["errorsWhileComputingFlags"] as? Bool ?? false
|
|
||||||
var loadedFeatureFlags: [String: Any]?
|
|
||||||
|
|
||||||
self.featureFlagsLock.withLock {
|
|
||||||
if let requestId {
|
|
||||||
// Store the request ID in the storage.
|
|
||||||
self.setCachedRequestId(requestId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errorsWhileComputingFlags {
|
|
||||||
// v4 cached flags which contains metadata about each flag.
|
|
||||||
let cachedFlags = self.getCachedFlags() ?? [:]
|
|
||||||
|
|
||||||
// The following two aren't necessarily needed for v4, but we'll keep them for now
|
|
||||||
// for back compatibility for existing v3 users who might already have cached flag data.
|
|
||||||
let cachedFeatureFlags = self.getCachedFeatureFlags() ?? [:]
|
|
||||||
let cachedFeatureFlagsPayloads = self.getCachedFeatureFlagPayload() ?? [:]
|
|
||||||
|
|
||||||
let newFeatureFlags = cachedFeatureFlags.merging(featureFlags) { _, new in new }
|
|
||||||
let newFeatureFlagsPayloads = cachedFeatureFlagsPayloads.merging(featureFlagPayloads) { _, new in new }
|
|
||||||
|
|
||||||
// if not all flags were computed, we upsert flags instead of replacing them
|
|
||||||
loadedFeatureFlags = newFeatureFlags
|
|
||||||
if let flagsV4 {
|
|
||||||
let newFlags = cachedFlags.merging(flagsV4) { _, new in new }
|
|
||||||
// if not all flags were computed, we upsert flags instead of replacing them
|
|
||||||
self.setCachedFlags(newFlags)
|
|
||||||
}
|
|
||||||
self.setCachedFeatureFlags(newFeatureFlags)
|
|
||||||
self.setCachedFeatureFlagPayload(newFeatureFlagsPayloads)
|
|
||||||
self.notifyFeatureFlagsAndRelease(newFeatureFlags)
|
|
||||||
} else {
|
|
||||||
loadedFeatureFlags = featureFlags
|
|
||||||
if let flagsV4 {
|
|
||||||
self.setCachedFlags(flagsV4)
|
|
||||||
}
|
|
||||||
self.setCachedFeatureFlags(featureFlags)
|
|
||||||
self.setCachedFeatureFlagPayload(featureFlagPayloads)
|
|
||||||
self.notifyFeatureFlagsAndRelease(featureFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return callback(loadedFeatureFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
private func processSessionRecordingConfig(_ data: [String: Any]?, featureFlags: [String: Any]) {
|
|
||||||
if let sessionRecording = data?["sessionRecording"] as? Bool {
|
|
||||||
sessionReplayFlagActive = sessionRecording
|
|
||||||
|
|
||||||
// its always false here anyway
|
|
||||||
if !sessionRecording {
|
|
||||||
storage.remove(key: .sessionReplay)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if let sessionRecording = data?["sessionRecording"] as? [String: Any] {
|
|
||||||
// keeps the value from config.sessionReplay since having sessionRecording
|
|
||||||
// means its enabled on the project settings, but its only enabled
|
|
||||||
// when local replay integration is enabled/active
|
|
||||||
if let endpoint = sessionRecording["endpoint"] as? String {
|
|
||||||
config.snapshotEndpoint = endpoint
|
|
||||||
}
|
|
||||||
sessionReplayFlagActive = isRecordingActive(featureFlags, sessionRecording)
|
|
||||||
storage.setDictionary(forKey: .sessionReplay, contents: sessionRecording)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func notifyFeatureFlags(_ featureFlags: [String: Any]?) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onFeatureFlagsLoaded?(featureFlags)
|
|
||||||
NotificationCenter.default.post(name: PostHogSDK.didReceiveFeatureFlags, object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyFeatureFlagsAndRelease(_ featureFlags: [String: Any]?) {
|
|
||||||
notifyFeatureFlags(featureFlags)
|
|
||||||
|
|
||||||
loadingFeatureFlagsLock.withLock {
|
|
||||||
self.loadingFeatureFlags = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFeatureFlags() -> [String: Any]? {
|
|
||||||
featureFlagsLock.withLock { getCachedFeatureFlags() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFeatureFlag(_ key: String) -> Any? {
|
|
||||||
var flags: [String: Any]?
|
|
||||||
featureFlagsLock.withLock {
|
|
||||||
flags = self.getCachedFeatureFlags()
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags?[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFeatureFlagDetails(_ key: String) -> Any? {
|
|
||||||
var flags: [String: Any]?
|
|
||||||
featureFlagsLock.withLock {
|
|
||||||
flags = self.getCachedFlags()
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags?[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
// To be called after acquiring `featureFlagsLock`
|
|
||||||
private func getCachedFeatureFlagPayload() -> [String: Any]? {
|
|
||||||
if featureFlagPayloads == nil {
|
|
||||||
featureFlagPayloads = storage.getDictionary(forKey: .enabledFeatureFlagPayloads) as? [String: Any]
|
|
||||||
}
|
|
||||||
return featureFlagPayloads
|
|
||||||
}
|
|
||||||
|
|
||||||
// To be called after acquiring `featureFlagsLock`
|
|
||||||
private func setCachedFeatureFlagPayload(_ featureFlagPayloads: [String: Any]) {
|
|
||||||
self.featureFlagPayloads = featureFlagPayloads
|
|
||||||
storage.setDictionary(forKey: .enabledFeatureFlagPayloads, contents: featureFlagPayloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
// To be called after acquiring `featureFlagsLock`
|
|
||||||
private func getCachedFeatureFlags() -> [String: Any]? {
|
|
||||||
if featureFlags == nil {
|
|
||||||
featureFlags = storage.getDictionary(forKey: .enabledFeatureFlags) as? [String: Any]
|
|
||||||
}
|
|
||||||
return featureFlags
|
|
||||||
}
|
|
||||||
|
|
||||||
// To be called after acquiring `featureFlagsLock`
|
|
||||||
private func setCachedFeatureFlags(_ featureFlags: [String: Any]) {
|
|
||||||
self.featureFlags = featureFlags
|
|
||||||
storage.setDictionary(forKey: .enabledFeatureFlags, contents: featureFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
// To be called after acquiring `featureFlagsLock`
|
|
||||||
private func setCachedFlags(_ flags: [String: Any]) {
|
|
||||||
self.flags = flags
|
|
||||||
storage.setDictionary(forKey: .flags, contents: flags)
|
|
||||||
}
|
|
||||||
|
|
||||||
// To be called after acquiring `featureFlagsLock`
|
|
||||||
private func getCachedFlags() -> [String: Any]? {
|
|
||||||
if flags == nil {
|
|
||||||
flags = storage.getDictionary(forKey: .flags) as? [String: Any]
|
|
||||||
}
|
|
||||||
return flags
|
|
||||||
}
|
|
||||||
|
|
||||||
func setPersonPropertiesForFlags(_ properties: [String: Any]) {
|
|
||||||
personPropertiesForFlagsLock.withLock {
|
|
||||||
// Merge properties additively, similar to JS SDK behavior
|
|
||||||
personPropertiesForFlags.merge(properties, uniquingKeysWith: { _, new in new })
|
|
||||||
// Persist to disk
|
|
||||||
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetPersonPropertiesForFlags() {
|
|
||||||
personPropertiesForFlagsLock.withLock {
|
|
||||||
personPropertiesForFlags.removeAll()
|
|
||||||
// Clear from disk
|
|
||||||
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGroupPropertiesForFlags(_ groupType: String, properties: [String: Any]) {
|
|
||||||
groupPropertiesForFlagsLock.withLock {
|
|
||||||
// Merge properties additively for this group type
|
|
||||||
groupPropertiesForFlags[groupType, default: [:]].merge(properties) { _, new in new }
|
|
||||||
// Persist to disk
|
|
||||||
storage.setDictionary(forKey: .groupPropertiesForFlags, contents: groupPropertiesForFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetGroupPropertiesForFlags(_ groupType: String? = nil) {
|
|
||||||
groupPropertiesForFlagsLock.withLock {
|
|
||||||
if let groupType = groupType {
|
|
||||||
groupPropertiesForFlags.removeValue(forKey: groupType)
|
|
||||||
} else {
|
|
||||||
groupPropertiesForFlags.removeAll()
|
|
||||||
}
|
|
||||||
// Persist changes to disk
|
|
||||||
storage.setDictionary(forKey: .groupPropertiesForFlags, contents: groupPropertiesForFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getGroupPropertiesForFlags() -> [String: [String: Any]] {
|
|
||||||
groupPropertiesForFlagsLock.withLock {
|
|
||||||
groupPropertiesForFlags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getPersonPropertiesForFlags() -> [String: Any] {
|
|
||||||
personPropertiesForFlagsLock.withLock {
|
|
||||||
var properties = personPropertiesForFlags
|
|
||||||
|
|
||||||
// Always include fresh default properties if enabled
|
|
||||||
if config.setDefaultPersonProperties {
|
|
||||||
let defaultProperties = getDefaultPersonProperties()
|
|
||||||
// User-set properties override default properties
|
|
||||||
properties = defaultProperties.merging(properties) { _, userValue in userValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
return properties
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadCachedPropertiesForFlags() {
|
|
||||||
personPropertiesForFlagsLock.withLock {
|
|
||||||
if let cachedPersonProperties = storage.getDictionary(forKey: .personPropertiesForFlags) as? [String: Any] {
|
|
||||||
personPropertiesForFlags = cachedPersonProperties
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
groupPropertiesForFlagsLock.withLock {
|
|
||||||
if let cachedGroupProperties = storage.getDictionary(forKey: .groupPropertiesForFlags) as? [String: [String: Any]] {
|
|
||||||
groupPropertiesForFlags = cachedGroupProperties
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFeatureFlagPayload(_ key: String) -> Any? {
|
|
||||||
var flags: [String: Any]?
|
|
||||||
featureFlagsLock.withLock {
|
|
||||||
flags = getCachedFeatureFlagPayload()
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = flags?[key]
|
|
||||||
|
|
||||||
guard let stringValue = value as? String else {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
// The payload value is stored as a string and is not pre-parsed...
|
|
||||||
// We need to mimic the JSON.parse of JS which is what posthog-js uses
|
|
||||||
return try JSONSerialization.jsonObject(with: stringValue.data(using: .utf8)!, options: .fragmentsAllowed)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error parsing the object \(String(describing: value)): \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback to original value if not possible to serialize
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// To be called after acquiring `featureFlagsLock`
|
|
||||||
private func setCachedRequestId(_ value: String?) {
|
|
||||||
requestId = value
|
|
||||||
if let value {
|
|
||||||
storage.setString(forKey: .requestId, contents: value)
|
|
||||||
} else {
|
|
||||||
storage.remove(key: .requestId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeResponse(_ data: inout [String: Any]) {
|
|
||||||
if let flagsV4 = data["flags"] as? [String: Any] {
|
|
||||||
var featureFlags = [String: Any]()
|
|
||||||
var featureFlagsPayloads = [String: Any]()
|
|
||||||
for (key, value) in flagsV4 {
|
|
||||||
if let flag = value as? [String: Any] {
|
|
||||||
if let variant = flag["variant"] as? String {
|
|
||||||
featureFlags[key] = variant
|
|
||||||
// If there's a variant, the flag is enabled, so we can store the payload
|
|
||||||
if let metadata = flag["metadata"] as? [String: Any],
|
|
||||||
let payload = metadata["payload"]
|
|
||||||
{
|
|
||||||
featureFlagsPayloads[key] = payload
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let enabled = flag["enabled"] as? Bool
|
|
||||||
featureFlags[key] = enabled
|
|
||||||
|
|
||||||
// Only store payload if the flag is enabled
|
|
||||||
if enabled == true,
|
|
||||||
let metadata = flag["metadata"] as? [String: Any],
|
|
||||||
let payload = metadata["payload"]
|
|
||||||
{
|
|
||||||
featureFlagsPayloads[key] = payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data["featureFlags"] = featureFlags
|
|
||||||
data["featureFlagPayloads"] = featureFlagsPayloads
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearFeatureFlags() {
|
|
||||||
featureFlagsLock.withLock {
|
|
||||||
setCachedFlags([:])
|
|
||||||
setCachedFeatureFlags([:])
|
|
||||||
setCachedFeatureFlagPayload([:])
|
|
||||||
setCachedRequestId(nil) // requestId no longer valid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
func isSessionReplayFlagActive() -> Bool {
|
|
||||||
sessionReplayFlagActive
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func getGroups() -> [String: String] {
|
|
||||||
guard let groups = storage.getDictionary(forKey: .groups) as? [String: String] else {
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Remote Config
|
|
||||||
|
|
||||||
func getRemoteConfig() -> [String: Any]? {
|
|
||||||
remoteConfigLock.withLock { getCachedRemoteConfig() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getCachedRemoteConfig() -> [String: Any]? {
|
|
||||||
if remoteConfig == nil {
|
|
||||||
remoteConfig = storage.getDictionary(forKey: .remoteConfig) as? [String: Any]
|
|
||||||
}
|
|
||||||
return remoteConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1499
Pods/PostHog/PostHog/PostHogSDK.swift
generated
1499
Pods/PostHog/PostHog/PostHogSDK.swift
generated
File diff suppressed because it is too large
Load Diff
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
@@ -1,271 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSessionManager.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 28.08.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// only for internal use
|
|
||||||
// Do we need to expose this as public API? Could be internal static instead?
|
|
||||||
@objc public class PostHogSessionManager: NSObject {
|
|
||||||
enum SessionIDChangeReason: String {
|
|
||||||
case sessionIdEmpty = "Session id was empty"
|
|
||||||
case sessionStart = "Session started"
|
|
||||||
case sessionEnd = "Session ended"
|
|
||||||
case sessionReset = "Session was reset"
|
|
||||||
case sessionTimeout = "Session timed out"
|
|
||||||
case sessionPastMaximumLength = "Session past maximum length"
|
|
||||||
case customSessionId = "Custom session set"
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc public static var shared: PostHogSessionManager {
|
|
||||||
PostHogSDK.shared.sessionManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private var config: PostHogConfig?
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setup(config: PostHogConfig) {
|
|
||||||
self.config = config
|
|
||||||
didBecomeActiveToken = nil
|
|
||||||
didEnterBackgroundToken = nil
|
|
||||||
applicationEventToken = nil
|
|
||||||
registerNotifications()
|
|
||||||
registerApplicationSendEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
resetSession()
|
|
||||||
didBecomeActiveToken = nil
|
|
||||||
didEnterBackgroundToken = nil
|
|
||||||
applicationEventToken = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.posthog.PostHogSessionManager", target: .global(qos: .utility))
|
|
||||||
private var sessionId: String?
|
|
||||||
private var sessionStartTimestamp: TimeInterval?
|
|
||||||
private var sessionActivityTimestamp: TimeInterval?
|
|
||||||
private let sessionLock = NSLock()
|
|
||||||
private var isAppInBackground = true
|
|
||||||
// 30 minutes in seconds
|
|
||||||
private let sessionActivityThreshold: TimeInterval = 60 * 30
|
|
||||||
// 24 hours in seconds
|
|
||||||
private let sessionMaxLengthThreshold: TimeInterval = 24 * 60 * 60
|
|
||||||
// Called when session id is cleared or changes
|
|
||||||
var onSessionIdChanged: () -> Void = {}
|
|
||||||
|
|
||||||
@objc public func setSessionId(_ sessionId: String) {
|
|
||||||
setSessionIdInternal(sessionId, at: now(), reason: .customSessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isNotReactNative() -> Bool {
|
|
||||||
// for the RN SDK, the session is handled by the RN SDK itself
|
|
||||||
postHogSdkName != "posthog-react-native"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Returns the current session id, and manages id rotation logic
|
|
||||||
|
|
||||||
In addition, this method handles core session cycling logic including:
|
|
||||||
- Creates a new session id when none exists (but only if app is foregrounded)
|
|
||||||
- if `readOnly` is false
|
|
||||||
- Rotates session after *30 minutes* of inactivity
|
|
||||||
- Clears session after *30 minutes* of inactivity (when app is backgrounded)
|
|
||||||
- Enforces a maximum session duration of *24 hours*
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- timeNow: Reference timestamp used for evaluating session expiry rules.
|
|
||||||
Defaults to current system time.
|
|
||||||
- readOnly: When true, bypasses all session management logic and returns
|
|
||||||
the current session id without modifications.
|
|
||||||
Defaults to false.
|
|
||||||
|
|
||||||
- Returns: Returns the existing session id, or a new one after performing validity checks
|
|
||||||
*/
|
|
||||||
func getSessionId(
|
|
||||||
at timeNow: Date = now(),
|
|
||||||
readOnly: Bool = false
|
|
||||||
) -> String? {
|
|
||||||
let timestamp = timeNow.timeIntervalSince1970
|
|
||||||
let (currentSessionId, lastActive, sessionStart, isBackgrounded) = sessionLock.withLock {
|
|
||||||
(sessionId, sessionActivityTimestamp, sessionStartTimestamp, isAppInBackground)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RN manages its own session, just return session id
|
|
||||||
guard isNotReactNative(), !readOnly else {
|
|
||||||
return currentSessionId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new session id if empty
|
|
||||||
if currentSessionId.isNilOrEmpty, !isBackgrounded {
|
|
||||||
return rotateSession(force: true, at: timeNow, reason: .sessionIdEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session has passed maximum inactivity length
|
|
||||||
if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) {
|
|
||||||
return isBackgrounded
|
|
||||||
? clearSession(reason: .sessionTimeout)
|
|
||||||
: rotateSession(at: timeNow, reason: .sessionTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session has passed maximum session length
|
|
||||||
if let sessionStart, isExpired(timestamp, sessionStart, sessionMaxLengthThreshold) {
|
|
||||||
return isBackgrounded
|
|
||||||
? clearSession(reason: .sessionPastMaximumLength)
|
|
||||||
: rotateSession(at: timeNow, reason: .sessionPastMaximumLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentSessionId
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNextSessionId() -> String? {
|
|
||||||
// if this is RN, return the current session id
|
|
||||||
guard isNotReactNative() else {
|
|
||||||
return sessionLock.withLock { sessionId }
|
|
||||||
}
|
|
||||||
|
|
||||||
return rotateSession(force: true, at: now(), reason: .sessionStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new session id and sets timestamps
|
|
||||||
func startSession(_ completion: (() -> Void)? = nil) {
|
|
||||||
guard isNotReactNative() else { return }
|
|
||||||
|
|
||||||
rotateSession(force: true, at: now(), reason: .sessionStart)
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears current session id and timestamps
|
|
||||||
func endSession(_ completion: (() -> Void)? = nil) {
|
|
||||||
guard isNotReactNative() else { return }
|
|
||||||
|
|
||||||
clearSession(reason: .sessionEnd)
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resets current session id and timestamps
|
|
||||||
func resetSession() {
|
|
||||||
guard isNotReactNative() else { return }
|
|
||||||
|
|
||||||
rotateSession(force: true, at: now(), reason: .sessionReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call this method to mark any user activity on this session
|
|
||||||
func touchSession() {
|
|
||||||
guard isNotReactNative() else { return }
|
|
||||||
|
|
||||||
let (currentSessionId, lastActive) = sessionLock.withLock {
|
|
||||||
(sessionId, sessionActivityTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard currentSessionId != nil else { return }
|
|
||||||
|
|
||||||
let timeNow = now()
|
|
||||||
let timestamp = timeNow.timeIntervalSince1970
|
|
||||||
|
|
||||||
// Check if session has passed maximum inactivity length between user activity marks
|
|
||||||
if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) {
|
|
||||||
rotateSession(at: timeNow, reason: .sessionTimeout)
|
|
||||||
} else {
|
|
||||||
sessionLock.withLock {
|
|
||||||
sessionActivityTimestamp = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Rotates the current session id
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- force: When true, creates a new session ID if current one is empty
|
|
||||||
- reason: The underlying reason behind this session ID rotation
|
|
||||||
- Returns: a new session id
|
|
||||||
*/
|
|
||||||
@discardableResult private func rotateSession(force: Bool = false, at timestamp: Date, reason: SessionIDChangeReason) -> String? {
|
|
||||||
// only rotate when session is empty
|
|
||||||
if !force {
|
|
||||||
let currentSessionId = sessionLock.withLock { sessionId }
|
|
||||||
if currentSessionId.isNilOrEmpty {
|
|
||||||
return currentSessionId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let newSessionId = UUID.v7().uuidString
|
|
||||||
setSessionIdInternal(newSessionId, at: timestamp, reason: reason)
|
|
||||||
return newSessionId
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult private func clearSession(reason: SessionIDChangeReason) -> String? {
|
|
||||||
setSessionIdInternal(nil, at: nil, reason: reason)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setSessionIdInternal(_ sessionId: String?, at timestamp: Date?, reason: SessionIDChangeReason) {
|
|
||||||
let timestamp = timestamp?.timeIntervalSince1970
|
|
||||||
|
|
||||||
sessionLock.withLock {
|
|
||||||
self.sessionId = sessionId
|
|
||||||
self.sessionStartTimestamp = timestamp
|
|
||||||
self.sessionActivityTimestamp = timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
onSessionIdChanged()
|
|
||||||
|
|
||||||
if let sessionId {
|
|
||||||
hedgeLog("New session id created \(sessionId) (\(reason))")
|
|
||||||
} else {
|
|
||||||
hedgeLog("Session id cleared - reason: (\(reason))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var didBecomeActiveToken: RegistrationToken?
|
|
||||||
private var didEnterBackgroundToken: RegistrationToken?
|
|
||||||
|
|
||||||
private func registerNotifications() {
|
|
||||||
let lifecyclePublisher = DI.main.appLifecyclePublisher
|
|
||||||
didBecomeActiveToken = lifecyclePublisher.onDidBecomeActive { [weak self] in
|
|
||||||
guard let self, sessionLock.withLock({ self.isAppInBackground }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// we consider foregrounding an app an activity on the current session
|
|
||||||
touchSession()
|
|
||||||
sessionLock.withLock { self.isAppInBackground = false }
|
|
||||||
}
|
|
||||||
didEnterBackgroundToken = lifecyclePublisher.onDidEnterBackground { [weak self] in
|
|
||||||
guard let self, !sessionLock.withLock({ self.isAppInBackground }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// we consider backgrounding the app an activity on the current session
|
|
||||||
touchSession()
|
|
||||||
sessionLock.withLock { self.isAppInBackground = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var applicationEventToken: RegistrationToken?
|
|
||||||
|
|
||||||
private func registerApplicationSendEvent() {
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
guard let config, config.enableSwizzling else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
applicationEventToken = DI.main.applicationEventPublisher.onApplicationEvent { [weak self] _, _ in
|
|
||||||
// update "last active" session
|
|
||||||
// we want to keep track of the idle time, so we need to maintain a timestamp on the last interactions of the user with the app. UIEvents are a good place to do so since it means that the user is actively interacting with the app (e.g not just noise background activity)
|
|
||||||
self?.queue.async {
|
|
||||||
self?.touchSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isExpired(_ timeNow: TimeInterval, _ timeThen: TimeInterval, _ threshold: TimeInterval) -> Bool {
|
|
||||||
max(timeNow - timeThen, 0) > threshold
|
|
||||||
}
|
|
||||||
}
|
|
||||||
419
Pods/PostHog/PostHog/PostHogStorage.swift
generated
419
Pods/PostHog/PostHog/PostHogStorage.swift
generated
@@ -1,419 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogStorage.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ben White on 08.02.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/**
|
|
||||||
# Storage
|
|
||||||
|
|
||||||
Note for tvOS:
|
|
||||||
As tvOS restricts access to persisted Application Support directory, we use Library/Caches instead for storage
|
|
||||||
|
|
||||||
If needed, we can use UserDefaults for lightweight data - according to Apple, you can use UserDefaults to persist up to 500KB of data on tvOS
|
|
||||||
see: https://developer.apple.com/forums/thread/16967?answerId=50696022#50696022
|
|
||||||
*/
|
|
||||||
func applicationSupportDirectoryURL() -> URL {
|
|
||||||
#if os(tvOS)
|
|
||||||
// tvOS restricts access to Application Support directory on physical devices
|
|
||||||
// Use Library/Caches directory which may have less frequent eviction behavior than temp (which is purged when the app quits)
|
|
||||||
let searchPath: FileManager.SearchPathDirectory = .cachesDirectory
|
|
||||||
#else
|
|
||||||
let searchPath: FileManager.SearchPathDirectory = .applicationSupportDirectory
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let url = FileManager.default.urls(for: searchPath, in: .userDomainMask).first!
|
|
||||||
let bundleIdentifier = getBundleIdentifier()
|
|
||||||
|
|
||||||
return url.appendingPathComponent(bundleIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
|
|
||||||
From Apple Docs:
|
|
||||||
In iOS, the value is nil when the group identifier is invalid. In macOS, a URL of the expected form is always
|
|
||||||
returned, even if the app group is invalid, so be sure to test that you can access the underlying directory
|
|
||||||
before attempting to use it.
|
|
||||||
|
|
||||||
MacOS: The system also creates the Library/Application Support, Library/Caches, and Library/Preferences
|
|
||||||
subdirectories inside the group directory the first time you use it
|
|
||||||
iOS: The system creates only the Library/Caches subdirectory automatically
|
|
||||||
|
|
||||||
see: https://developer.apple.com/documentation/foundation/filemanager/1412643-containerurl/
|
|
||||||
*/
|
|
||||||
func appGroupContainerUrl(config: PostHogConfig) -> URL? {
|
|
||||||
guard let appGroupIdentifier = config.appGroupIdentifier else { return nil }
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
// tvOS: Due to stricter sandbox rules, creating "Application Support" directory is not possible on tvOS
|
|
||||||
let librarySubPath = "Library/Caches/"
|
|
||||||
#else
|
|
||||||
let librarySubPath = "Library/Application Support/"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let libraryUrl = FileManager.default
|
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
|
|
||||||
.appendingPathComponent(librarySubPath)
|
|
||||||
|
|
||||||
guard let url = libraryUrl?.appendingPathComponent(appGroupIdentifier) else { return nil }
|
|
||||||
|
|
||||||
createDirectoryAtURLIfNeeded(url: url)
|
|
||||||
|
|
||||||
// Merges a legacy container (using bundleIdentifier) into the new container using appGroupIdentifier
|
|
||||||
mergeLegacyContainerIfNeeded(within: libraryUrl, to: url)
|
|
||||||
|
|
||||||
return directoryExists(url) ? url : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBundleIdentifier() -> String {
|
|
||||||
#if TESTING // only visible to test targets
|
|
||||||
return Bundle.main.bundleIdentifier ?? "com.posthog.test"
|
|
||||||
#else
|
|
||||||
return Bundle.main.bundleIdentifier!
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Merges content from a legacy container directory into the current app group container.
|
|
||||||
|
|
||||||
This function handles the migration of PostHog data from the old storage location (using `bundleIdentifier`)
|
|
||||||
to the new app group shared container location (using `appGroupIdentifier`).
|
|
||||||
|
|
||||||
Migration rules:
|
|
||||||
- Files that already exist at the destination are skipped (no overwrite)
|
|
||||||
- The anonymousId from the first processed container (legacy or current) is preserved to maintain user identity
|
|
||||||
- Successfully migrated files are deleted from the source
|
|
||||||
- Empty directories are cleaned up after migration
|
|
||||||
- The entire folder structure is preserved during migration
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- libraryUrl: The base library URL where both legacy and new containers might exist
|
|
||||||
- destinationUrl: The target app group container URL where files should be migrated
|
|
||||||
*/
|
|
||||||
func mergeLegacyContainerIfNeeded(within libraryUrl: URL?, to destinationUrl: URL) {
|
|
||||||
let bundleIdentifier = getBundleIdentifier()
|
|
||||||
guard let sourceUrl = libraryUrl?.appendingPathComponent(bundleIdentifier), directoryExists(sourceUrl) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hedgeLog("Legacy folder found at \(sourceUrl), merging...")
|
|
||||||
|
|
||||||
// Migrate all contents from the legacy container
|
|
||||||
migrateDirectoryContents(from: sourceUrl, to: destinationUrl)
|
|
||||||
|
|
||||||
// Try to remove the source directory if it's empty
|
|
||||||
if removeIfEmpty(sourceUrl) {
|
|
||||||
hedgeLog("Successfully migrated and removed legacy folder at \(sourceUrl)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Removes a directory if it's empty.
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- url: The directory URL to potentially remove
|
|
||||||
- Returns: `true` if the directory was removed, `false` otherwise
|
|
||||||
*/
|
|
||||||
@discardableResult
|
|
||||||
func removeIfEmpty(_ url: URL) -> Bool {
|
|
||||||
let remainingItems = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
|
|
||||||
if remainingItems?.isEmpty == true {
|
|
||||||
do {
|
|
||||||
try FileManager.default.removeItem(at: url)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Failed to remove empty directory at \(url.path): \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Migrates a single file from source to destination.
|
|
||||||
|
|
||||||
Migration rules:
|
|
||||||
- If the file doesn't exist at destination, it's copied and then deleted from source
|
|
||||||
- If the file already exists at destination, only the source file is deleted
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- sourceFile: The source file URL
|
|
||||||
- destinationFile: The destination file URL
|
|
||||||
- Throws: Any errors that occur during file operations
|
|
||||||
*/
|
|
||||||
func migrateFile(from sourceFile: URL, to destinationFile: URL) throws {
|
|
||||||
if !FileManager.default.fileExists(atPath: destinationFile.path) {
|
|
||||||
try FileManager.default.copyItem(at: sourceFile, to: destinationFile)
|
|
||||||
}
|
|
||||||
// Always delete source file after processing (whether copied or skipped)
|
|
||||||
try FileManager.default.removeItem(at: sourceFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Recursively migrates all contents from a source directory to a destination directory.
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- sourceDir: The source directory URL
|
|
||||||
- destinationDir: The destination directory URL
|
|
||||||
*/
|
|
||||||
func migrateDirectoryContents(from sourceDir: URL, to destinationDir: URL) {
|
|
||||||
do {
|
|
||||||
// Create destination directory if it doesn't exist (we need to call this here again as the function is recursive)
|
|
||||||
createDirectoryAtURLIfNeeded(url: destinationDir)
|
|
||||||
|
|
||||||
// Get all items in source directory
|
|
||||||
let items = try FileManager.default.contentsOfDirectory(at: sourceDir, includingPropertiesForKeys: nil, options: [])
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
let destinationItem = destinationDir.appendingPathComponent(item.lastPathComponent)
|
|
||||||
|
|
||||||
// Check if it's a directory
|
|
||||||
var isDirectory: ObjCBool = false
|
|
||||||
if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDirectory) {
|
|
||||||
if isDirectory.boolValue {
|
|
||||||
// Recursively migrate subdirectory (preserving the folder structure)
|
|
||||||
migrateDirectoryContents(from: item, to: destinationItem)
|
|
||||||
// Remove empty directory after migration
|
|
||||||
removeIfEmpty(item)
|
|
||||||
} else {
|
|
||||||
// Migrate file
|
|
||||||
do {
|
|
||||||
try migrateFile(from: item, to: destinationItem)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Failed to migrate file from \(item.path) to \(destinationItem.path): \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error reading directory contents at \(sourceDir.path): \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PostHogStorage {
|
|
||||||
// when adding or removing items here, make sure to update the reset method
|
|
||||||
enum StorageKey: String, CaseIterable {
|
|
||||||
case distinctId = "posthog.distinctId"
|
|
||||||
case anonymousId = "posthog.anonymousId"
|
|
||||||
case queue = "posthog.queueFolder" // NOTE: This is different to posthog-ios v2
|
|
||||||
case oldQeueue = "posthog.queue.plist"
|
|
||||||
case replayQeueue = "posthog.replayFolder"
|
|
||||||
case enabledFeatureFlags = "posthog.enabledFeatureFlags"
|
|
||||||
case enabledFeatureFlagPayloads = "posthog.enabledFeatureFlagPayloads"
|
|
||||||
case flags = "posthog.flags"
|
|
||||||
case groups = "posthog.groups"
|
|
||||||
case registerProperties = "posthog.registerProperties"
|
|
||||||
case optOut = "posthog.optOut"
|
|
||||||
case sessionReplay = "posthog.sessionReplay"
|
|
||||||
case isIdentified = "posthog.isIdentified"
|
|
||||||
case personProcessingEnabled = "posthog.enabledPersonProcessing"
|
|
||||||
case remoteConfig = "posthog.remoteConfig"
|
|
||||||
case surveySeen = "posthog.surveySeen"
|
|
||||||
case requestId = "posthog.requestId"
|
|
||||||
case personPropertiesForFlags = "posthog.personPropertiesForFlags"
|
|
||||||
case groupPropertiesForFlags = "posthog.groupPropertiesForFlags"
|
|
||||||
}
|
|
||||||
|
|
||||||
// The location for storing data that we always want to keep
|
|
||||||
let appFolderUrl: URL
|
|
||||||
|
|
||||||
init(_ config: PostHogConfig) {
|
|
||||||
appFolderUrl = Self.getAppFolderUrl(from: config)
|
|
||||||
|
|
||||||
// migrate legacy storage if needed
|
|
||||||
Self.migrateLegacyStorage(from: config, to: appFolderUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
func url(forKey key: StorageKey) -> URL {
|
|
||||||
appFolderUrl.appendingPathComponent(key.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "data" methods are the core for storing data and differ between Modes
|
|
||||||
// All other typed storage methods call these
|
|
||||||
private func getData(forKey: StorageKey) -> Data? {
|
|
||||||
let url = url(forKey: forKey)
|
|
||||||
|
|
||||||
do {
|
|
||||||
if FileManager.default.fileExists(atPath: url.path) {
|
|
||||||
return try Data(contentsOf: url)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error reading data from key \(forKey): \(error)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setData(forKey: StorageKey, contents: Data?) {
|
|
||||||
var url = url(forKey: forKey)
|
|
||||||
|
|
||||||
do {
|
|
||||||
if contents == nil {
|
|
||||||
deleteSafely(url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try contents?.write(to: url)
|
|
||||||
|
|
||||||
var resourceValues = URLResourceValues()
|
|
||||||
resourceValues.isExcludedFromBackup = true
|
|
||||||
try url.setResourceValues(resourceValues)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Failed to write data for key '\(forKey)' error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getJson(forKey key: StorageKey) -> Any? {
|
|
||||||
guard let data = getData(forKey: key) else { return nil }
|
|
||||||
|
|
||||||
do {
|
|
||||||
return try JSONSerialization.jsonObject(with: data)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Failed to serialize key '\(key)' error: \(error)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setJson(forKey key: StorageKey, json: Any) {
|
|
||||||
var jsonObject: Any?
|
|
||||||
|
|
||||||
if let dictionary = json as? [AnyHashable: Any] {
|
|
||||||
jsonObject = dictionary
|
|
||||||
} else if let array = json as? [Any] {
|
|
||||||
jsonObject = array
|
|
||||||
} else {
|
|
||||||
// TRICKY: This is weird legacy behaviour storing the data as a dictionary
|
|
||||||
jsonObject = [key.rawValue: json]
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data?
|
|
||||||
do {
|
|
||||||
data = try JSONSerialization.data(withJSONObject: jsonObject!)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Failed to serialize key '\(key)' error: \(error)")
|
|
||||||
}
|
|
||||||
setData(forKey: key, contents: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
There are cases where applications using posthog-ios want to share analytics data between host app and
|
|
||||||
an app extension, Widget or App Clip. If there's a defined `appGroupIdentifier` in configuration,
|
|
||||||
we want to use a shared container for storing data so that extensions correctly identify a user (and batch process events)
|
|
||||||
*/
|
|
||||||
private static func getBaseAppFolderUrl(from configuration: PostHogConfig) -> URL {
|
|
||||||
appGroupContainerUrl(config: configuration) ?? applicationSupportDirectoryURL()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func migrateItem(at sourceUrl: URL, to destinationUrl: URL, fileManager: FileManager) throws {
|
|
||||||
guard fileManager.fileExists(atPath: sourceUrl.path) else { return }
|
|
||||||
// Copy file or directory over (if it doesn't exist)
|
|
||||||
if !fileManager.fileExists(atPath: destinationUrl.path) {
|
|
||||||
try fileManager.copyItem(at: sourceUrl, to: destinationUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func migrateLegacyStorage(from configuration: PostHogConfig, to apiDir: URL) {
|
|
||||||
let legacyUrl = getBaseAppFolderUrl(from: configuration)
|
|
||||||
if directoryExists(legacyUrl) {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
|
|
||||||
// Migrate old files that correspond to StorageKey values
|
|
||||||
for storageKey in StorageKey.allCases {
|
|
||||||
let legacyFileUrl = legacyUrl.appendingPathComponent(storageKey.rawValue)
|
|
||||||
let newFileUrl = apiDir.appendingPathComponent(storageKey.rawValue)
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Migrate the item and its contents if it exists
|
|
||||||
try migrateItem(at: legacyFileUrl, to: newFileUrl, fileManager: fileManager)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Error during storage migration for file \(storageKey.rawValue) at path \(legacyFileUrl.path): \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the legacy item after successful migration
|
|
||||||
if fileManager.fileExists(atPath: legacyFileUrl.path) {
|
|
||||||
do {
|
|
||||||
try fileManager.removeItem(at: legacyFileUrl)
|
|
||||||
} catch {
|
|
||||||
hedgeLog("Could not delete file \(storageKey.rawValue) at path \(legacyFileUrl.path): \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func getAppFolderUrl(from configuration: PostHogConfig) -> URL {
|
|
||||||
let apiDir = getBaseAppFolderUrl(from: configuration)
|
|
||||||
.appendingPathComponent(configuration.apiKey)
|
|
||||||
|
|
||||||
createDirectoryAtURLIfNeeded(url: apiDir)
|
|
||||||
|
|
||||||
return apiDir
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(keepAnonymousId: Bool = false) {
|
|
||||||
// sadly the StorageKey.allCases does not work here
|
|
||||||
deleteSafely(url(forKey: .distinctId))
|
|
||||||
if !keepAnonymousId {
|
|
||||||
deleteSafely(url(forKey: .anonymousId))
|
|
||||||
}
|
|
||||||
// .queue, .replayQeueue not needed since it'll be deleted by the queue.clear()
|
|
||||||
deleteSafely(url(forKey: .oldQeueue))
|
|
||||||
deleteSafely(url(forKey: .flags))
|
|
||||||
deleteSafely(url(forKey: .enabledFeatureFlags))
|
|
||||||
deleteSafely(url(forKey: .enabledFeatureFlagPayloads))
|
|
||||||
deleteSafely(url(forKey: .groups))
|
|
||||||
deleteSafely(url(forKey: .registerProperties))
|
|
||||||
deleteSafely(url(forKey: .optOut))
|
|
||||||
deleteSafely(url(forKey: .sessionReplay))
|
|
||||||
deleteSafely(url(forKey: .isIdentified))
|
|
||||||
deleteSafely(url(forKey: .personProcessingEnabled))
|
|
||||||
deleteSafely(url(forKey: .remoteConfig))
|
|
||||||
deleteSafely(url(forKey: .surveySeen))
|
|
||||||
deleteSafely(url(forKey: .requestId))
|
|
||||||
deleteSafely(url(forKey: .personPropertiesForFlags))
|
|
||||||
deleteSafely(url(forKey: .groupPropertiesForFlags))
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(key: StorageKey) {
|
|
||||||
let url = url(forKey: key)
|
|
||||||
|
|
||||||
deleteSafely(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getString(forKey key: StorageKey) -> String? {
|
|
||||||
let value = getJson(forKey: key)
|
|
||||||
if let stringValue = value as? String {
|
|
||||||
return stringValue
|
|
||||||
} else if let dictValue = value as? [String: String] {
|
|
||||||
return dictValue[key.rawValue]
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setString(forKey key: StorageKey, contents: String) {
|
|
||||||
setJson(forKey: key, json: contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDictionary(forKey key: StorageKey) -> [AnyHashable: Any]? {
|
|
||||||
getJson(forKey: key) as? [AnyHashable: Any]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setDictionary(forKey key: StorageKey, contents: [AnyHashable: Any]) {
|
|
||||||
setJson(forKey: key, json: contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBool(forKey key: StorageKey) -> Bool? {
|
|
||||||
let value = getJson(forKey: key)
|
|
||||||
if let boolValue = value as? Bool {
|
|
||||||
return boolValue
|
|
||||||
} else if let dictValue = value as? [String: Bool] {
|
|
||||||
return dictValue[key.rawValue]
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBool(forKey key: StorageKey, contents: Bool) {
|
|
||||||
setJson(forKey: key, json: contents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
163
Pods/PostHog/PostHog/PostHogStorageManager.swift
generated
163
Pods/PostHog/PostHog/PostHogStorageManager.swift
generated
@@ -1,163 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogStorageManager.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ben White on 08.02.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// Internal class to manage the storage metadata of the PostHog SDK
|
|
||||||
public class PostHogStorageManager {
|
|
||||||
private let storage: PostHogStorage!
|
|
||||||
|
|
||||||
private let anonLock = NSLock()
|
|
||||||
private let distinctLock = NSLock()
|
|
||||||
private let identifiedLock = NSLock()
|
|
||||||
private let personProcessingLock = NSLock()
|
|
||||||
private let idGen: (UUID) -> UUID
|
|
||||||
|
|
||||||
private var distinctId: String?
|
|
||||||
private var cachedDistinctId = false
|
|
||||||
private var anonymousId: String?
|
|
||||||
private var isIdentifiedValue: Bool?
|
|
||||||
private var personProcessingEnabled: Bool?
|
|
||||||
|
|
||||||
init(_ config: PostHogConfig) {
|
|
||||||
storage = PostHogStorage(config)
|
|
||||||
idGen = config.getAnonymousId
|
|
||||||
}
|
|
||||||
|
|
||||||
public func getAnonymousId() -> String {
|
|
||||||
anonLock.withLock {
|
|
||||||
if anonymousId == nil {
|
|
||||||
var anonymousId = storage.getString(forKey: .anonymousId)
|
|
||||||
|
|
||||||
if anonymousId == nil {
|
|
||||||
let uuid = UUID.v7()
|
|
||||||
anonymousId = idGen(uuid).uuidString
|
|
||||||
setAnonId(anonymousId ?? "")
|
|
||||||
} else {
|
|
||||||
// update the memory value
|
|
||||||
self.anonymousId = anonymousId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return anonymousId ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setAnonymousId(_ id: String) {
|
|
||||||
anonLock.withLock {
|
|
||||||
setAnonId(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setAnonId(_ id: String) {
|
|
||||||
anonymousId = id
|
|
||||||
storage.setString(forKey: .anonymousId, contents: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func getDistinctId() -> String {
|
|
||||||
var distinctId: String?
|
|
||||||
distinctLock.withLock {
|
|
||||||
if self.distinctId == nil {
|
|
||||||
// since distinctId is nil until its identified, no need to read from
|
|
||||||
// cache every single time, otherwise anon users will never used the
|
|
||||||
// cached values
|
|
||||||
if !cachedDistinctId {
|
|
||||||
distinctId = storage.getString(forKey: .distinctId)
|
|
||||||
cachedDistinctId = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// do this to not assign the AnonymousId to the DistinctId, its just a fallback
|
|
||||||
if distinctId == nil {
|
|
||||||
distinctId = getAnonymousId()
|
|
||||||
} else {
|
|
||||||
// update the memory value
|
|
||||||
self.distinctId = distinctId
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// read from memory
|
|
||||||
distinctId = self.distinctId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return distinctId ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setDistinctId(_ id: String) {
|
|
||||||
distinctLock.withLock {
|
|
||||||
distinctId = id
|
|
||||||
storage.setString(forKey: .distinctId, contents: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isIdentified() -> Bool {
|
|
||||||
identifiedLock.withLock {
|
|
||||||
if isIdentifiedValue == nil {
|
|
||||||
isIdentifiedValue = storage.getBool(forKey: .isIdentified) ?? (getDistinctId() != getAnonymousId())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isIdentifiedValue ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setIdentified(_ isIdentified: Bool) {
|
|
||||||
identifiedLock.withLock {
|
|
||||||
isIdentifiedValue = isIdentified
|
|
||||||
storage.setBool(forKey: .isIdentified, contents: isIdentified)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isPersonProcessing() -> Bool {
|
|
||||||
personProcessingLock.withLock {
|
|
||||||
if personProcessingEnabled == nil {
|
|
||||||
personProcessingEnabled = storage.getBool(forKey: .personProcessingEnabled) ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return personProcessingEnabled ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setPersonProcessing(_ enable: Bool) {
|
|
||||||
personProcessingLock.withLock {
|
|
||||||
// only set if its different to avoid IO since this is called more often
|
|
||||||
if self.personProcessingEnabled != enable {
|
|
||||||
self.personProcessingEnabled = enable
|
|
||||||
storage.setBool(forKey: .personProcessingEnabled, contents: enable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func reset(keepAnonymousId: Bool = false, _ resetStorage: Bool = false) {
|
|
||||||
// resetStorage is only used for testing, when the reset method is called,
|
|
||||||
// the storage is also cleared, so we don't do here to not do it twice.
|
|
||||||
distinctLock.withLock {
|
|
||||||
distinctId = nil
|
|
||||||
cachedDistinctId = false
|
|
||||||
if resetStorage {
|
|
||||||
storage.remove(key: .distinctId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !keepAnonymousId {
|
|
||||||
anonLock.withLock {
|
|
||||||
anonymousId = nil
|
|
||||||
if resetStorage {
|
|
||||||
storage.remove(key: .anonymousId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identifiedLock.withLock {
|
|
||||||
isIdentifiedValue = nil
|
|
||||||
if resetStorage {
|
|
||||||
storage.remove(key: .isIdentified)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
personProcessingLock.withLock {
|
|
||||||
personProcessingEnabled = nil
|
|
||||||
if resetStorage {
|
|
||||||
storage.remove(key: .personProcessingEnabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
Pods/PostHog/PostHog/PostHogSwizzler.swift
generated
14
Pods/PostHog/PostHog/PostHogSwizzler.swift
generated
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSwizzler.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 26.03.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
func swizzle(forClass: AnyClass, original: Selector, new: Selector) {
|
|
||||||
guard let originalMethod = class_getInstanceMethod(forClass, original) else { return }
|
|
||||||
guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return }
|
|
||||||
method_exchangeImplementations(originalMethod, swizzledMethod)
|
|
||||||
}
|
|
||||||
16
Pods/PostHog/PostHog/PostHogVersion.swift
generated
16
Pods/PostHog/PostHog/PostHogVersion.swift
generated
@@ -1,16 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogVersion.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 13.10.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// if you change this, make sure to also change it in the podspec and check if the script scripts/bump-version.sh still works
|
|
||||||
// This property is internal only
|
|
||||||
public var postHogVersion = "3.34.0"
|
|
||||||
|
|
||||||
public let postHogiOSSdkName = "posthog-ios"
|
|
||||||
// This property is internal only
|
|
||||||
public var postHogSdkName = postHogiOSSdkName
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
//
|
|
||||||
// ApplicationEventPublisher.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 24/02/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
typealias ApplicationEventHandler = (_ event: UIEvent, _ date: Date) -> Void
|
|
||||||
|
|
||||||
protocol ApplicationEventPublishing: AnyObject {
|
|
||||||
/// Registers a callback for a `UIApplication.sendEvent`
|
|
||||||
func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken
|
|
||||||
}
|
|
||||||
|
|
||||||
final class ApplicationEventPublisher: BaseApplicationEventPublisher {
|
|
||||||
static let shared = ApplicationEventPublisher()
|
|
||||||
|
|
||||||
private var hasSwizzled: Bool = false
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
swizzleSendEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
unswizzleSendEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzleSendEvent() {
|
|
||||||
guard !hasSwizzled else { return }
|
|
||||||
hasSwizzled = true
|
|
||||||
|
|
||||||
swizzle(
|
|
||||||
forClass: UIApplication.self,
|
|
||||||
original: #selector(UIApplication.sendEvent(_:)),
|
|
||||||
new: #selector(UIApplication.sendEventOverride)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func unswizzleSendEvent() {
|
|
||||||
guard hasSwizzled else { return }
|
|
||||||
hasSwizzled = false
|
|
||||||
|
|
||||||
// swizzling twice will exchange implementations back to original
|
|
||||||
swizzle(
|
|
||||||
forClass: UIApplication.self,
|
|
||||||
original: #selector(UIApplication.sendEvent(_:)),
|
|
||||||
new: #selector(UIApplication.sendEventOverride)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken {
|
|
||||||
let id = UUID()
|
|
||||||
registrationLock.withLock {
|
|
||||||
self.onApplicationEventCallbacks[id] = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
// start on first callback registration
|
|
||||||
if !hasSwizzled {
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
return RegistrationToken { [weak self] in
|
|
||||||
// Registration token deallocated here
|
|
||||||
guard let self else { return }
|
|
||||||
let handlerCount = self.registrationLock.withLock {
|
|
||||||
self.onApplicationEventCallbacks[id] = nil
|
|
||||||
return self.onApplicationEventCallbacks.values.count
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop when there are no more callbacks
|
|
||||||
if handlerCount <= 0 {
|
|
||||||
self.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called from swizzled `UIApplication.sendEvent`
|
|
||||||
fileprivate func sendEvent(event: UIEvent, date: Date) {
|
|
||||||
notifyHandlers(uiEvent: event, date: date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BaseApplicationEventPublisher: ApplicationEventPublishing {
|
|
||||||
fileprivate let registrationLock = NSLock()
|
|
||||||
|
|
||||||
var onApplicationEventCallbacks: [UUID: ApplicationEventHandler] = [:]
|
|
||||||
|
|
||||||
func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken {
|
|
||||||
let id = UUID()
|
|
||||||
registrationLock.withLock {
|
|
||||||
self.onApplicationEventCallbacks[id] = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
return RegistrationToken { [weak self] in
|
|
||||||
// Registration token deallocated here
|
|
||||||
guard let self else { return }
|
|
||||||
self.registrationLock.withLock {
|
|
||||||
self.onApplicationEventCallbacks[id] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func notifyHandlers(uiEvent: UIEvent, date: Date) {
|
|
||||||
let handlers = registrationLock.withLock { onApplicationEventCallbacks.values }
|
|
||||||
for handler in handlers {
|
|
||||||
notifyHander(handler, uiEvent: uiEvent, date: date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyHander(_ handler: @escaping ApplicationEventHandler, uiEvent: UIEvent, date: Date) {
|
|
||||||
if Thread.isMainThread {
|
|
||||||
handler(uiEvent, date)
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async { handler(uiEvent, date) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIApplication {
|
|
||||||
@objc func sendEventOverride(_ event: UIEvent) {
|
|
||||||
sendEventOverride(event)
|
|
||||||
ApplicationEventPublisher.shared.sendEvent(event: event, date: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
33
Pods/PostHog/PostHog/Replay/CGColor+Util.swift
generated
33
Pods/PostHog/PostHog/Replay/CGColor+Util.swift
generated
@@ -1,33 +0,0 @@
|
|||||||
//
|
|
||||||
// CGColor+Util.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 21.03.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension CGColor {
|
|
||||||
func toRGBString() -> String? {
|
|
||||||
// see dicussion: https://github.com/PostHog/posthog-ios/issues/226
|
|
||||||
// Allow only CGColors with an intiialized value of `numberOfComponents` with a value in 3...4 range
|
|
||||||
// Loading dynamic colors from storyboard sometimes leads to some random values for numberOfComponents like `105553118884896` which crashes the app
|
|
||||||
guard
|
|
||||||
3 ... 4 ~= numberOfComponents, // check range
|
|
||||||
let components = components, // we now assume it's safe to access `components`
|
|
||||||
components.count >= 3
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let red = Int(components[0] * 255)
|
|
||||||
let green = Int(components[1] * 255)
|
|
||||||
let blue = Int(components[2] * 255)
|
|
||||||
|
|
||||||
return String(format: "#%02X%02X%02X", red, green, blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
19
Pods/PostHog/PostHog/Replay/CGSize+Util.swift
generated
19
Pods/PostHog/PostHog/Replay/CGSize+Util.swift
generated
@@ -1,19 +0,0 @@
|
|||||||
//
|
|
||||||
// CGSize+Util.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 24.07.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension CGSize {
|
|
||||||
func hasSize() -> Bool {
|
|
||||||
if width == 0 || height == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
18
Pods/PostHog/PostHog/Replay/Date+Util.swift
generated
18
Pods/PostHog/PostHog/Replay/Date+Util.swift
generated
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// Date+Util.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 21.03.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension Date {
|
|
||||||
func toMillis() -> Int64 {
|
|
||||||
Int64(timeIntervalSince1970 * 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func dateToMillis(_ date: Date) -> Int64 {
|
|
||||||
date.toMillis()
|
|
||||||
}
|
|
||||||
20
Pods/PostHog/PostHog/Replay/Float+Util.swift
generated
20
Pods/PostHog/PostHog/Replay/Float+Util.swift
generated
@@ -1,20 +0,0 @@
|
|||||||
//
|
|
||||||
// Float+Util.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 07/02/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension CGFloat {
|
|
||||||
func toInt() -> Int {
|
|
||||||
NSNumber(value: rounded()).intValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Double {
|
|
||||||
func toInt() -> Int {
|
|
||||||
NSNumber(value: rounded()).intValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
120
Pods/PostHog/PostHog/Replay/MethodSwizzler.swift
generated
120
Pods/PostHog/PostHog/Replay/MethodSwizzler.swift
generated
@@ -1,120 +0,0 @@
|
|||||||
/*
|
|
||||||
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
|
|
||||||
* This product includes software developed at Datadog (https://www.datadoghq.com/).
|
|
||||||
* Copyright 2019-Present Datadog, Inc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class MethodSwizzler<TypedIMP, TypedBlockIMP> {
|
|
||||||
struct FoundMethod: Hashable {
|
|
||||||
let method: Method
|
|
||||||
private let klass: AnyClass
|
|
||||||
|
|
||||||
fileprivate init(method: Method, klass: AnyClass) {
|
|
||||||
self.method = method
|
|
||||||
self.klass = klass
|
|
||||||
}
|
|
||||||
|
|
||||||
static func == (lhs: FoundMethod, rhs: FoundMethod) -> Bool {
|
|
||||||
let methodParity = (lhs.method == rhs.method)
|
|
||||||
let classParity = (NSStringFromClass(lhs.klass) == NSStringFromClass(rhs.klass))
|
|
||||||
return methodParity && classParity
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
let methodName = NSStringFromSelector(method_getName(method))
|
|
||||||
let klassName = NSStringFromClass(klass)
|
|
||||||
let identifier = "\(methodName)|||\(klassName)"
|
|
||||||
hasher.combine(identifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var implementationCache: [FoundMethod: IMP] = [:]
|
|
||||||
var swizzledMethods: [FoundMethod] {
|
|
||||||
Array(implementationCache.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func findMethod(with selector: Selector, in klass: AnyClass) throws -> FoundMethod {
|
|
||||||
/// NOTE: RUMM-452 as we never add/remove methods/classes at runtime,
|
|
||||||
/// search operation doesn't have to wrapped in sync {...} although it's visible in the interface
|
|
||||||
var headKlass: AnyClass? = klass
|
|
||||||
while let someKlass = headKlass {
|
|
||||||
if let foundMethod = findMethod(with: selector, in: someKlass) {
|
|
||||||
return FoundMethod(method: foundMethod, klass: someKlass)
|
|
||||||
}
|
|
||||||
headKlass = class_getSuperclass(headKlass)
|
|
||||||
}
|
|
||||||
throw InternalPostHogError(description: "\(NSStringFromSelector(selector)) is not found in \(NSStringFromClass(klass))")
|
|
||||||
}
|
|
||||||
|
|
||||||
func originalImplementation(of found: FoundMethod) -> TypedIMP {
|
|
||||||
sync {
|
|
||||||
let originalImp: IMP = implementationCache[found] ?? method_getImplementation(found.method)
|
|
||||||
return unsafeBitCast(originalImp, to: TypedIMP.self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzle(
|
|
||||||
_ foundMethod: FoundMethod,
|
|
||||||
impProvider: (TypedIMP) -> TypedBlockIMP
|
|
||||||
) {
|
|
||||||
sync {
|
|
||||||
let currentIMP = method_getImplementation(foundMethod.method)
|
|
||||||
let currentTypedIMP = unsafeBitCast(currentIMP, to: TypedIMP.self)
|
|
||||||
let newImpBlock: TypedBlockIMP = impProvider(currentTypedIMP)
|
|
||||||
let newImp: IMP = imp_implementationWithBlock(newImpBlock)
|
|
||||||
|
|
||||||
set(newIMP: newImp, for: foundMethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes swizzling and resets the method to its original implementation.
|
|
||||||
func unswizzle() {
|
|
||||||
for foundMethod in swizzledMethods {
|
|
||||||
let originalTypedIMP = originalImplementation(of: foundMethod)
|
|
||||||
let originalIMP: IMP = unsafeBitCast(originalTypedIMP, to: IMP.self)
|
|
||||||
method_setImplementation(foundMethod.method, originalIMP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private methods
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func sync<T>(block: () -> T) -> T {
|
|
||||||
objc_sync_enter(self)
|
|
||||||
defer { objc_sync_exit(self) }
|
|
||||||
return block()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func findMethod(with selector: Selector, in klass: AnyClass) -> Method? {
|
|
||||||
var methodsCount: UInt32 = 0
|
|
||||||
let methodsCountPtr = withUnsafeMutablePointer(to: &methodsCount) { $0 }
|
|
||||||
guard let methods: UnsafeMutablePointer<Method> = class_copyMethodList(klass, methodsCountPtr) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer {
|
|
||||||
free(methods)
|
|
||||||
}
|
|
||||||
for index in 0 ..< Int(methodsCount) {
|
|
||||||
let method = methods.advanced(by: index).pointee
|
|
||||||
if method_getName(method) == selector {
|
|
||||||
return method
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func set(newIMP: IMP, for found: FoundMethod) {
|
|
||||||
if implementationCache[found] == nil {
|
|
||||||
implementationCache[found] = method_getImplementation(found.method)
|
|
||||||
}
|
|
||||||
method_setImplementation(found.method, newIMP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MethodSwizzler.FoundMethod {
|
|
||||||
var swizzlingName: String { "\(klass).\(method_getName(method))" }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
58
Pods/PostHog/PostHog/Replay/NetworkSample.swift
generated
58
Pods/PostHog/PostHog/Replay/NetworkSample.swift
generated
@@ -1,58 +0,0 @@
|
|||||||
//
|
|
||||||
// NetworkSample.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 26.03.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct NetworkSample {
|
|
||||||
let sessionId: String
|
|
||||||
let timeOrigin: Date
|
|
||||||
let entryType = "resource"
|
|
||||||
var name: String?
|
|
||||||
var responseStatus: Int?
|
|
||||||
var initiatorType = "fetch"
|
|
||||||
var httpMethod: String?
|
|
||||||
var duration: Int64?
|
|
||||||
var decodedBodySize: Int64?
|
|
||||||
|
|
||||||
init(sessionId: String, timeOrigin: Date, url: String? = nil) {
|
|
||||||
self.timeOrigin = timeOrigin
|
|
||||||
self.sessionId = sessionId
|
|
||||||
name = url
|
|
||||||
}
|
|
||||||
|
|
||||||
func toDict() -> [String: Any] {
|
|
||||||
var dict: [String: Any] = [
|
|
||||||
"timestamp": timeOrigin.toMillis(),
|
|
||||||
"entryType": entryType,
|
|
||||||
"initiatorType": initiatorType,
|
|
||||||
]
|
|
||||||
|
|
||||||
if let name = name {
|
|
||||||
dict["name"] = name
|
|
||||||
}
|
|
||||||
|
|
||||||
if let responseStatus = responseStatus {
|
|
||||||
dict["responseStatus"] = responseStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
if let httpMethod = httpMethod {
|
|
||||||
dict["method"] = httpMethod
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = duration {
|
|
||||||
dict["duration"] = duration
|
|
||||||
}
|
|
||||||
|
|
||||||
if let decodedBodySize = decodedBodySize {
|
|
||||||
dict["transferSize"] = decodedBodySize
|
|
||||||
}
|
|
||||||
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
12
Pods/PostHog/PostHog/Replay/Optional+Util.swift
generated
12
Pods/PostHog/PostHog/Replay/Optional+Util.swift
generated
@@ -1,12 +0,0 @@
|
|||||||
//
|
|
||||||
// Optional+Util.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Yiannis Josephides on 20/01/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
extension Optional where Wrapped: Collection {
|
|
||||||
var isNilOrEmpty: Bool {
|
|
||||||
self?.isEmpty ?? true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogConsoleLogInterceptor.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 05/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class PostHogConsoleLogInterceptor {
|
|
||||||
private let maxLogStringSize = 2000 // Maximum number of characters allowed in a string
|
|
||||||
|
|
||||||
struct ConsoleOutput {
|
|
||||||
let timestamp: Date
|
|
||||||
let text: String
|
|
||||||
let level: PostHogLogLevel
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = PostHogConsoleLogInterceptor()
|
|
||||||
|
|
||||||
// Pipe redirection properties
|
|
||||||
private var stdoutPipe: Pipe?
|
|
||||||
private var stderrPipe: Pipe?
|
|
||||||
private var originalStdout: Int32 = -1
|
|
||||||
private var originalStderr: Int32 = -1
|
|
||||||
|
|
||||||
private init() { /* Singleton */ }
|
|
||||||
|
|
||||||
func startCapturing(config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
|
||||||
stopCapturing() // cleanup
|
|
||||||
setupPipeRedirection(config: config, callback: callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupPipeRedirection(config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
|
||||||
// Set stdout/stderr to unbuffered mode (_IONBF) to ensure real-time output capture.
|
|
||||||
// Without this, output might be buffered and only flushed when the buffer is full or
|
|
||||||
// when explicitly flushed, which is especially problematic without an attached debugger
|
|
||||||
setvbuf(stdout, nil, _IONBF, 0)
|
|
||||||
setvbuf(stderr, nil, _IONBF, 0)
|
|
||||||
|
|
||||||
// Save original file descriptors
|
|
||||||
originalStdout = dup(STDOUT_FILENO)
|
|
||||||
originalStderr = dup(STDERR_FILENO)
|
|
||||||
|
|
||||||
stdoutPipe = Pipe()
|
|
||||||
stderrPipe = Pipe()
|
|
||||||
|
|
||||||
guard let stdoutPipe = stdoutPipe, let stderrPipe = stderrPipe else { return }
|
|
||||||
|
|
||||||
// Redirect stdout and stderr to our pipes
|
|
||||||
dup2(stdoutPipe.fileHandleForWriting.fileDescriptor, STDOUT_FILENO)
|
|
||||||
dup2(stderrPipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO)
|
|
||||||
|
|
||||||
// Setup and handle pipe output
|
|
||||||
setupPipeSource(for: originalStdout, fileHandle: stdoutPipe.fileHandleForReading, config: config, callback: callback)
|
|
||||||
setupPipeSource(for: originalStderr, fileHandle: stderrPipe.fileHandleForReading, config: config, callback: callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupPipeSource(for originalFd: Int32, fileHandle: FileHandle, config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
|
||||||
fileHandle.readabilityHandler = { [weak self] handle in
|
|
||||||
let data = handle.availableData
|
|
||||||
guard !data.isEmpty,
|
|
||||||
let output = String(data: data, encoding: .utf8),
|
|
||||||
let self = self else { return }
|
|
||||||
|
|
||||||
// Write to original file descriptor, so logs appear normally
|
|
||||||
if originalFd != -1 {
|
|
||||||
if let data = output.data(using: .utf8) {
|
|
||||||
_ = data.withUnsafeBytes { ptr in
|
|
||||||
write(originalFd, ptr.baseAddress, ptr.count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.processOutput(output, config: config, callback: callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processOutput(_ output: String, config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
|
||||||
// Skip internal logs and empty lines
|
|
||||||
// Note: Need to skip internal logs because `config.debug` may be enabled. If that's the case, then
|
|
||||||
// the process of capturing logs, will generate more logs, leading to an infinite loop. This relies on hedgeLog() format which should
|
|
||||||
// be okay, even not ideal
|
|
||||||
guard !output.contains("[PostHog]"), !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process log entries from config
|
|
||||||
let entries = output
|
|
||||||
.components(separatedBy: CharacterSet.newlines) // split by line
|
|
||||||
.lazy
|
|
||||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } // Skip empty strings and new lines
|
|
||||||
.compactMap(config.sessionReplayConfig.captureLogsConfig.logSanitizer)
|
|
||||||
|
|
||||||
for entry in entries where shouldCaptureLog(entry: entry, config: config) {
|
|
||||||
callback(ConsoleOutput(timestamp: Date(), text: truncatedOutput(entry.message), level: entry.level))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determines if the log message should be captured, based on config
|
|
||||||
private func shouldCaptureLog(entry: PostHogLogEntry, config: PostHogConfig) -> Bool {
|
|
||||||
entry.level.rawValue >= config.sessionReplayConfig.captureLogsConfig.minLogLevel.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Console logs can be really large.
|
|
||||||
/// This function returns a truncated version of the console output if it exceeds `maxLogStringSize`
|
|
||||||
private func truncatedOutput(_ output: String) -> String {
|
|
||||||
guard output.count > maxLogStringSize else { return output }
|
|
||||||
return "\(output.prefix(maxLogStringSize))...[truncated]"
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopCapturing() {
|
|
||||||
// Restore original file descriptors
|
|
||||||
if originalStdout != -1 {
|
|
||||||
dup2(originalStdout, STDOUT_FILENO)
|
|
||||||
close(originalStdout)
|
|
||||||
originalStdout = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if originalStderr != -1 {
|
|
||||||
dup2(originalStderr, STDERR_FILENO)
|
|
||||||
close(originalStderr)
|
|
||||||
originalStderr = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove pipes
|
|
||||||
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
|
||||||
stderrPipe?.fileHandleForReading.readabilityHandler = nil
|
|
||||||
stdoutPipe?.fileHandleForReading.closeFile()
|
|
||||||
stderrPipe?.fileHandleForReading.closeFile()
|
|
||||||
stdoutPipe = nil
|
|
||||||
stderrPipe = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogLogEntry.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 09/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/**
|
|
||||||
A model representing a processed console log entry for session replay.
|
|
||||||
|
|
||||||
Describes a single console log entry after it has been processed by `PostHogSessionReplayConsoleLogConfig.logSanitizer`.
|
|
||||||
Each instance contains the log message content and its determined severity level.
|
|
||||||
*/
|
|
||||||
@objc public class PostHogLogEntry: NSObject {
|
|
||||||
/// The severity level of the log entry.
|
|
||||||
/// This determines how the log will be displayed in the session replay and
|
|
||||||
/// whether it will be captured based on `minLogLevel` setting.
|
|
||||||
@objc public let level: PostHogLogLevel
|
|
||||||
|
|
||||||
/// The actual content of the log message.
|
|
||||||
/// This is the processed and sanitized log message
|
|
||||||
@objc public let message: String
|
|
||||||
|
|
||||||
/// Creates a new console log result.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - level: The severity level of the log entry
|
|
||||||
/// - message: The processed log message content
|
|
||||||
@objc public init(level: PostHogLogLevel, message: String) {
|
|
||||||
self.level = level
|
|
||||||
self.message = message
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogLogLevel.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 09/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// The severity level of a console log entry.
|
|
||||||
///
|
|
||||||
/// Used to categorize logs by their severity in session replay.
|
|
||||||
@objc public enum PostHogLogLevel: Int {
|
|
||||||
/// Informational messages, debugging output, and general logs
|
|
||||||
case info
|
|
||||||
/// Warning messages indicating potential issues or deprecation notices
|
|
||||||
case warn
|
|
||||||
/// Error messages indicating failures or critical issues
|
|
||||||
case error
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSessionReplayConsoleLogsPlugin.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 09/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class PostHogSessionReplayConsoleLogsPlugin: PostHogSessionReplayPlugin {
|
|
||||||
private weak var postHog: PostHogSDK?
|
|
||||||
private var isActive = false
|
|
||||||
|
|
||||||
func start(postHog: PostHogSDK) {
|
|
||||||
self.postHog = postHog
|
|
||||||
isActive = true
|
|
||||||
PostHogConsoleLogInterceptor.shared.startCapturing(config: postHog.config) { [weak self] output in
|
|
||||||
self?.handleConsoleLog(output)
|
|
||||||
}
|
|
||||||
hedgeLog("[Session Replay] Console logs plugin started")
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
postHog = nil
|
|
||||||
isActive = false
|
|
||||||
PostHogConsoleLogInterceptor.shared.stopCapturing()
|
|
||||||
hedgeLog("[Session Replay] Console logs plugin stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
func resume() {
|
|
||||||
guard !isActive, let postHog else { return }
|
|
||||||
isActive = true
|
|
||||||
PostHogConsoleLogInterceptor.shared.startCapturing(config: postHog.config) { [weak self] output in
|
|
||||||
self?.handleConsoleLog(output)
|
|
||||||
}
|
|
||||||
hedgeLog("[Session Replay] Console logs plugin resumed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
guard isActive else { return }
|
|
||||||
isActive = false
|
|
||||||
PostHogConsoleLogInterceptor.shared.stopCapturing()
|
|
||||||
hedgeLog("[Session Replay] Console logs plugin paused")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleConsoleLog(_ output: PostHogConsoleLogInterceptor.ConsoleOutput) {
|
|
||||||
guard
|
|
||||||
isActive,
|
|
||||||
let postHog,
|
|
||||||
postHog.isSessionReplayActive(),
|
|
||||||
let sessionId = postHog.sessionManager.getSessionId(at: output.timestamp)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// `PostHogLogLevel`` needs to be an Int enum for objc interop
|
|
||||||
// So we need to convert this to a String before sending upstream
|
|
||||||
let level = switch output.level {
|
|
||||||
case .error: "error"
|
|
||||||
case .info: "info"
|
|
||||||
case .warn: "warn"
|
|
||||||
}
|
|
||||||
|
|
||||||
var snapshotsData: [Any] = []
|
|
||||||
let payloadData: [String: Any] = ["level": level, "payload": output.text]
|
|
||||||
let pluginData: [String: Any] = ["plugin": "rrweb/console@1", "payload": payloadData]
|
|
||||||
|
|
||||||
snapshotsData.append([
|
|
||||||
"type": 6,
|
|
||||||
"data": pluginData,
|
|
||||||
"timestamp": output.timestamp.toMillis(),
|
|
||||||
])
|
|
||||||
|
|
||||||
postHog.capture(
|
|
||||||
"$snapshot",
|
|
||||||
properties: [
|
|
||||||
"$snapshot_source": "mobile",
|
|
||||||
"$snapshot_data": snapshotsData,
|
|
||||||
"$session_id": sessionId,
|
|
||||||
],
|
|
||||||
timestamp: output.timestamp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSessionReplayNetworkPlugin.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 28/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Session replay plugin that captures network requests using URLSession swizzling.
|
|
||||||
class PostHogSessionReplayNetworkPlugin: PostHogSessionReplayPlugin {
|
|
||||||
private var sessionSwizzler: URLSessionSwizzler?
|
|
||||||
private var postHog: PostHogSDK?
|
|
||||||
private var isActive = false
|
|
||||||
|
|
||||||
func start(postHog: PostHogSDK) {
|
|
||||||
self.postHog = postHog
|
|
||||||
do {
|
|
||||||
sessionSwizzler = try URLSessionSwizzler(
|
|
||||||
shouldCapture: shouldCaptureNetworkSample,
|
|
||||||
onCapture: handleNetworkSample,
|
|
||||||
getSessionId: { [weak self] date in
|
|
||||||
self?.postHog?.sessionManager.getSessionId(at: date)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
sessionSwizzler?.swizzle()
|
|
||||||
hedgeLog("[Session Replay] Network telemetry plugin started")
|
|
||||||
isActive = true
|
|
||||||
} catch {
|
|
||||||
hedgeLog("[Session Replay] Failed to initialize network telemetry: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
sessionSwizzler?.unswizzle()
|
|
||||||
sessionSwizzler = nil
|
|
||||||
postHog = nil
|
|
||||||
isActive = false
|
|
||||||
hedgeLog("[Session Replay] Network telemetry plugin stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
func resume() {
|
|
||||||
guard !isActive else { return }
|
|
||||||
isActive = true
|
|
||||||
hedgeLog("[Session Replay] Network telemetry plugin resumed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
guard isActive else { return }
|
|
||||||
isActive = false
|
|
||||||
hedgeLog("[Session Replay] Network telemetry plugin paused")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldCaptureNetworkSample() -> Bool {
|
|
||||||
guard let postHog else { return false }
|
|
||||||
return isActive && postHog.config.sessionReplayConfig.captureNetworkTelemetry && postHog.isSessionReplayActive()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleNetworkSample(sample: NetworkSample) {
|
|
||||||
guard let postHog else { return }
|
|
||||||
|
|
||||||
let timestamp = sample.timeOrigin
|
|
||||||
|
|
||||||
var snapshotsData: [Any] = []
|
|
||||||
|
|
||||||
let requestsData = [sample.toDict()]
|
|
||||||
let payloadData: [String: Any] = ["requests": requestsData]
|
|
||||||
let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData]
|
|
||||||
|
|
||||||
let data: [String: Any] = [
|
|
||||||
"type": 6,
|
|
||||||
"data": pluginData,
|
|
||||||
"timestamp": timestamp.toMillis(),
|
|
||||||
]
|
|
||||||
snapshotsData.append(data)
|
|
||||||
|
|
||||||
postHog.capture(
|
|
||||||
"$snapshot",
|
|
||||||
properties: [
|
|
||||||
"$snapshot_source": "mobile",
|
|
||||||
"$snapshot_data": snapshotsData,
|
|
||||||
"$session_id": sample.sessionId,
|
|
||||||
],
|
|
||||||
timestamp: sample.timeOrigin
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public extension URLSession {
|
|
||||||
private func getMonotonicTimeInMilliseconds() -> UInt64 {
|
|
||||||
// Get the raw mach time
|
|
||||||
let machTime = mach_absolute_time()
|
|
||||||
|
|
||||||
// Get timebase info to convert to nanoseconds
|
|
||||||
var timebaseInfo = mach_timebase_info_data_t()
|
|
||||||
mach_timebase_info(&timebaseInfo)
|
|
||||||
|
|
||||||
// Convert mach time to nanoseconds
|
|
||||||
let nanoTime = machTime * UInt64(timebaseInfo.numer) / UInt64(timebaseInfo.denom)
|
|
||||||
|
|
||||||
// Convert nanoseconds to milliseconds
|
|
||||||
let milliTime = nanoTime / 1_000_000
|
|
||||||
|
|
||||||
return milliTime
|
|
||||||
}
|
|
||||||
|
|
||||||
private func executeRequest(request: URLRequest? = nil,
|
|
||||||
action: () async throws -> (Data, URLResponse),
|
|
||||||
postHog: PostHogSDK?) async throws -> (Data, URLResponse)
|
|
||||||
{
|
|
||||||
let timestamp = Date()
|
|
||||||
let startMillis = getMonotonicTimeInMilliseconds()
|
|
||||||
var endMillis: UInt64?
|
|
||||||
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
|
|
||||||
do {
|
|
||||||
let (data, response) = try await action()
|
|
||||||
endMillis = getMonotonicTimeInMilliseconds()
|
|
||||||
captureData(request: request,
|
|
||||||
response: response,
|
|
||||||
sessionId: sessionId,
|
|
||||||
timestamp: timestamp,
|
|
||||||
start: startMillis,
|
|
||||||
end: endMillis,
|
|
||||||
postHog: postHog)
|
|
||||||
return (data, response)
|
|
||||||
} catch {
|
|
||||||
captureData(request: request,
|
|
||||||
response: nil,
|
|
||||||
sessionId: sessionId,
|
|
||||||
timestamp: timestamp,
|
|
||||||
start: startMillis,
|
|
||||||
end: endMillis,
|
|
||||||
postHog: postHog)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func executeRequest(request: URLRequest? = nil,
|
|
||||||
action: () async throws -> (URL, URLResponse),
|
|
||||||
postHog: PostHogSDK?) async throws -> (URL, URLResponse)
|
|
||||||
{
|
|
||||||
let timestamp = Date()
|
|
||||||
let startMillis = getMonotonicTimeInMilliseconds()
|
|
||||||
var endMillis: UInt64?
|
|
||||||
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
|
|
||||||
do {
|
|
||||||
let (url, response) = try await action()
|
|
||||||
endMillis = getMonotonicTimeInMilliseconds()
|
|
||||||
captureData(request: request,
|
|
||||||
response: response,
|
|
||||||
sessionId: sessionId,
|
|
||||||
timestamp: timestamp,
|
|
||||||
start: startMillis,
|
|
||||||
end: endMillis,
|
|
||||||
postHog: postHog)
|
|
||||||
return (url, response)
|
|
||||||
} catch {
|
|
||||||
captureData(request: request,
|
|
||||||
response: nil,
|
|
||||||
sessionId: sessionId,
|
|
||||||
timestamp: timestamp,
|
|
||||||
start: startMillis,
|
|
||||||
end: endMillis,
|
|
||||||
postHog: postHog)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postHogData(for request: URLRequest, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(request: request, action: { try await data(for: request) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postHogData(from url: URL, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(action: { try await data(from: url) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postHogUpload(
|
|
||||||
for request: URLRequest,
|
|
||||||
fromFile fileURL: URL,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(request: request, action: { try await upload(for: request, fromFile: fileURL) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postHogUpload(
|
|
||||||
for request: URLRequest,
|
|
||||||
from bodyData: Data,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(request: request, action: { try await upload(for: request, from: bodyData) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func postHogData(
|
|
||||||
for request: URLRequest,
|
|
||||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(request: request, action: { try await data(for: request, delegate: delegate) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func postHogData(
|
|
||||||
from url: URL,
|
|
||||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(action: { try await data(from: url, delegate: delegate) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func postHogUpload(
|
|
||||||
for request: URLRequest,
|
|
||||||
fromFile fileURL: URL,
|
|
||||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(request: request, action: { try await upload(for: request, fromFile: fileURL, delegate: delegate) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func postHogUpload(
|
|
||||||
for request: URLRequest,
|
|
||||||
from bodyData: Data,
|
|
||||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (Data, URLResponse) {
|
|
||||||
try await executeRequest(request: request, action: { try await upload(for: request, from: bodyData, delegate: delegate) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func postHogDownload(
|
|
||||||
for request: URLRequest,
|
|
||||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (URL, URLResponse) {
|
|
||||||
try await executeRequest(request: request, action: { try await download(for: request, delegate: delegate) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func postHogDownload(
|
|
||||||
from url: URL,
|
|
||||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (URL, URLResponse) {
|
|
||||||
try await executeRequest(action: { try await download(from: url, delegate: delegate) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func postHogDownload(
|
|
||||||
resumeFrom resumeData: Data,
|
|
||||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
|
||||||
postHog: PostHogSDK? = nil
|
|
||||||
) async throws -> (URL, URLResponse) {
|
|
||||||
try await executeRequest(action: { try await download(resumeFrom: resumeData, delegate: delegate) }, postHog: postHog)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Private methods
|
|
||||||
|
|
||||||
private func captureData(
|
|
||||||
request: URLRequest? = nil,
|
|
||||||
response: URLResponse? = nil,
|
|
||||||
sessionId: String?,
|
|
||||||
timestamp: Date,
|
|
||||||
start: UInt64,
|
|
||||||
end: UInt64? = nil,
|
|
||||||
postHog: PostHogSDK?
|
|
||||||
) {
|
|
||||||
let instance = postHog ?? PostHogSDK.shared
|
|
||||||
|
|
||||||
// we don't check config.sessionReplayConfig.captureNetworkTelemetry here since this extension
|
|
||||||
// has to be called manually anyway
|
|
||||||
guard let sessionId, instance.isSessionReplayActive() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let currentEnd = end ?? getMonotonicTimeInMilliseconds()
|
|
||||||
|
|
||||||
PostHogReplayIntegration.dispatchQueue.async {
|
|
||||||
var snapshotsData: [Any] = []
|
|
||||||
|
|
||||||
var requestsData: [String: Any] = ["duration": currentEnd - start,
|
|
||||||
"method": request?.httpMethod ?? "GET",
|
|
||||||
"name": request?.url?.absoluteString ?? (response?.url?.absoluteString ?? ""),
|
|
||||||
"initiatorType": "fetch",
|
|
||||||
"entryType": "resource",
|
|
||||||
"timestamp": timestamp.toMillis()]
|
|
||||||
|
|
||||||
// the UI special case if the transferSize is 0 as coming from cache
|
|
||||||
let transferSize = Int64(request?.httpBody?.count ?? 0) + (response?.expectedContentLength ?? 0)
|
|
||||||
if transferSize > 0 {
|
|
||||||
requestsData["transferSize"] = transferSize
|
|
||||||
}
|
|
||||||
|
|
||||||
if let urlResponse = response as? HTTPURLResponse {
|
|
||||||
requestsData["responseStatus"] = urlResponse.statusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloadData: [String: Any] = ["requests": [requestsData]]
|
|
||||||
let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData]
|
|
||||||
|
|
||||||
let recordingData: [String: Any] = ["type": 6, "data": pluginData, "timestamp": timestamp.toMillis()]
|
|
||||||
snapshotsData.append(recordingData)
|
|
||||||
|
|
||||||
instance.capture(
|
|
||||||
"$snapshot",
|
|
||||||
properties: [
|
|
||||||
"$snapshot_source": "mobile",
|
|
||||||
"$snapshot_data": snapshotsData,
|
|
||||||
"$session_id": sessionId,
|
|
||||||
],
|
|
||||||
timestamp: timestamp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
/*
|
|
||||||
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
|
|
||||||
* This product includes software developed at Datadog (https://www.datadoghq.com/).
|
|
||||||
* Copyright 2019-Present Datadog, Inc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class URLSessionInterceptor {
|
|
||||||
private let tasksLock = NSLock()
|
|
||||||
private let shouldCapture: () -> Bool
|
|
||||||
private let onCapture: (NetworkSample) -> Void
|
|
||||||
private let getSessionId: (Date) -> String?
|
|
||||||
|
|
||||||
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) {
|
|
||||||
self.shouldCapture = shouldCapture
|
|
||||||
self.onCapture = onCapture
|
|
||||||
self.getSessionId = getSessionId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An internal queue for synchronising the access to `samplesByTask`.
|
|
||||||
private let queue = DispatchQueue(label: "com.posthog.URLSessionInterceptor", target: .global(qos: .utility))
|
|
||||||
private var samplesByTask: [URLSessionTask: NetworkSample] = [:]
|
|
||||||
|
|
||||||
// MARK: - Interception Flow
|
|
||||||
|
|
||||||
/// Notifies the `URLSessionTask` creation.
|
|
||||||
/// This method should be called as soon as the task was created.
|
|
||||||
/// - Parameter task: the task object obtained from `URLSession`.
|
|
||||||
func taskCreated(task: URLSessionTask, session _: URLSession? = nil) {
|
|
||||||
guard shouldCapture() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let request = task.originalRequest else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let url = request.url else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let date = now()
|
|
||||||
|
|
||||||
guard let sessionId = getSessionId(date) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
queue.async {
|
|
||||||
let sample = NetworkSample(
|
|
||||||
sessionId: sessionId,
|
|
||||||
timeOrigin: date,
|
|
||||||
url: url.absoluteString
|
|
||||||
)
|
|
||||||
|
|
||||||
self.tasksLock.withLock {
|
|
||||||
self.samplesByTask[task] = sample
|
|
||||||
}
|
|
||||||
|
|
||||||
self.finishAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notifies the `URLSessionTask` completion.
|
|
||||||
/// This method should be called as soon as the task was completed.
|
|
||||||
/// - Parameter task: the task object obtained from `URLSession`.
|
|
||||||
/// - Parameter error: optional `Error` if the task completed with error.
|
|
||||||
func taskCompleted(task: URLSessionTask, error _: Error?) {
|
|
||||||
guard shouldCapture() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let date = Date()
|
|
||||||
|
|
||||||
queue.async {
|
|
||||||
var sampleTask: NetworkSample?
|
|
||||||
self.tasksLock.withLock {
|
|
||||||
sampleTask = self.samplesByTask[task]
|
|
||||||
}
|
|
||||||
|
|
||||||
guard var sample = sampleTask else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.finish(task: task, sample: &sample, date: date)
|
|
||||||
|
|
||||||
self.finishAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finish(task: URLSessionTask, sample: inout NetworkSample, date: Date? = nil) {
|
|
||||||
// only safe guard, should not happen
|
|
||||||
guard let request = task.originalRequest else {
|
|
||||||
tasksLock.withLock {
|
|
||||||
_ = samplesByTask.removeValue(forKey: task)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let responseStatusCode = urlResponseStatusCode(response: task.response)
|
|
||||||
|
|
||||||
if responseStatusCode != -1 {
|
|
||||||
sample.responseStatus = responseStatusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
sample.httpMethod = request.httpMethod
|
|
||||||
sample.initiatorType = "fetch"
|
|
||||||
// instrumented requests that dont use the completion handler wont have the duration set
|
|
||||||
if let date = date {
|
|
||||||
sample.duration = (date.toMillis() - sample.timeOrigin.toMillis())
|
|
||||||
}
|
|
||||||
|
|
||||||
// the UI special case if the transferSize is 0 as coming from cache
|
|
||||||
let transferSize = Int64(request.httpBody?.count ?? 0) + (task.response?.expectedContentLength ?? 0)
|
|
||||||
if transferSize > 0 {
|
|
||||||
sample.decodedBodySize = transferSize
|
|
||||||
}
|
|
||||||
|
|
||||||
finish(task: task, sample: sample)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
|
||||||
|
|
||||||
private func urlResponseStatusCode(response: URLResponse?) -> Int {
|
|
||||||
if let urlResponse = response as? HTTPURLResponse {
|
|
||||||
return urlResponse.statusCode
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finish(task: URLSessionTask, sample: NetworkSample) {
|
|
||||||
if shouldCapture() {
|
|
||||||
onCapture(sample)
|
|
||||||
}
|
|
||||||
|
|
||||||
tasksLock.withLock {
|
|
||||||
_ = samplesByTask.removeValue(forKey: task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishAll() {
|
|
||||||
var completedTasks: [URLSessionTask: NetworkSample] = [:]
|
|
||||||
tasksLock.withLock {
|
|
||||||
for item in samplesByTask where item.key.state == .completed {
|
|
||||||
completedTasks[item.key] = item.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in completedTasks {
|
|
||||||
var value = item.value
|
|
||||||
finish(task: item.key, sample: &value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
tasksLock.withLock {
|
|
||||||
samplesByTask.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
// swiftlint:disable nesting
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
|
|
||||||
* This product includes software developed at Datadog (https://www.datadoghq.com/).
|
|
||||||
* Copyright 2019-Present Datadog, Inc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class URLSessionSwizzler {
|
|
||||||
/// `URLSession.dataTask(with:completionHandler:)` (for `URLRequest`) swizzling.
|
|
||||||
private let dataTaskWithURLRequestAndCompletion: DataTaskWithURLRequestAndCompletion
|
|
||||||
/// `URLSession.dataTask(with:)` (for `URLRequest`) swizzling.
|
|
||||||
private let dataTaskWithURLRequest: DataTaskWithURLRequest
|
|
||||||
|
|
||||||
/// `URLSession.dataTask(with:completionHandler:)` (for `URL`) swizzling. Only applied on iOS 13 and above.
|
|
||||||
private let dataTaskWithURLAndCompletion: DataTaskWithURLAndCompletion?
|
|
||||||
/// `URLSession.dataTask(with:)` (for `URL`) swizzling. Only applied on iOS 13 and above.
|
|
||||||
private let dataTaskWithURL: DataTaskWithURL?
|
|
||||||
|
|
||||||
private let interceptor: URLSessionInterceptor
|
|
||||||
|
|
||||||
private var hasSwizzled = false
|
|
||||||
|
|
||||||
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) throws {
|
|
||||||
interceptor = URLSessionInterceptor(
|
|
||||||
shouldCapture: shouldCapture,
|
|
||||||
onCapture: onCapture,
|
|
||||||
getSessionId: getSessionId
|
|
||||||
)
|
|
||||||
|
|
||||||
dataTaskWithURLAndCompletion = try DataTaskWithURLAndCompletion.build(interceptor: interceptor)
|
|
||||||
dataTaskWithURL = try DataTaskWithURL.build(interceptor: interceptor)
|
|
||||||
|
|
||||||
dataTaskWithURLRequestAndCompletion = try DataTaskWithURLRequestAndCompletion.build(interceptor: interceptor)
|
|
||||||
dataTaskWithURLRequest = try DataTaskWithURLRequest.build(interceptor: interceptor)
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzle() {
|
|
||||||
dataTaskWithURLRequestAndCompletion.swizzle()
|
|
||||||
dataTaskWithURLAndCompletion?.swizzle()
|
|
||||||
dataTaskWithURLRequest.swizzle()
|
|
||||||
dataTaskWithURL?.swizzle()
|
|
||||||
hasSwizzled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func unswizzle() {
|
|
||||||
if !hasSwizzled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dataTaskWithURLRequestAndCompletion.unswizzle()
|
|
||||||
dataTaskWithURLRequest.unswizzle()
|
|
||||||
dataTaskWithURLAndCompletion?.unswizzle()
|
|
||||||
dataTaskWithURL?.unswizzle()
|
|
||||||
hasSwizzled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Swizzlings
|
|
||||||
|
|
||||||
typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
|
|
||||||
|
|
||||||
/// Swizzles the `URLSession.dataTask(with:completionHandler:)` for `URLRequest`.
|
|
||||||
class DataTaskWithURLRequestAndCompletion: MethodSwizzler<
|
|
||||||
@convention(c) (URLSession, Selector, URLRequest, CompletionHandler?) -> URLSessionDataTask,
|
|
||||||
@convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask
|
|
||||||
> {
|
|
||||||
private static let selector = #selector(
|
|
||||||
URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URLRequest, @escaping CompletionHandler) -> URLSessionDataTask
|
|
||||||
)
|
|
||||||
|
|
||||||
private let method: FoundMethod
|
|
||||||
private let interceptor: URLSessionInterceptor
|
|
||||||
|
|
||||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLRequestAndCompletion {
|
|
||||||
try DataTaskWithURLRequestAndCompletion(
|
|
||||||
selector: selector,
|
|
||||||
klass: URLSession.self,
|
|
||||||
interceptor: interceptor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
|
||||||
method = try Self.findMethod(with: selector, in: klass)
|
|
||||||
self.interceptor = interceptor
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzle() {
|
|
||||||
typealias Signature = @convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask
|
|
||||||
swizzle(method) { previousImplementation -> Signature in { session, urlRequest, completionHandler -> URLSessionDataTask in
|
|
||||||
let task: URLSessionDataTask
|
|
||||||
if completionHandler != nil {
|
|
||||||
var taskReference: URLSessionDataTask?
|
|
||||||
let newCompletionHandler: CompletionHandler = { data, response, error in
|
|
||||||
if let task = taskReference { // sanity check, should always succeed
|
|
||||||
self.interceptor.taskCompleted(task: task, error: error)
|
|
||||||
}
|
|
||||||
completionHandler?(data, response, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
task = previousImplementation(session, Self.selector, urlRequest, newCompletionHandler)
|
|
||||||
taskReference = task
|
|
||||||
} else {
|
|
||||||
// The `completionHandler` can be `nil` in two cases:
|
|
||||||
// - on iOS 11 or 12, where `dataTask(with:)` (for `URL` and `URLRequest`) calls
|
|
||||||
// the `dataTask(with:completionHandler:)` (for `URLRequest`) internally by nullifying the completion block.
|
|
||||||
// - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing
|
|
||||||
// `nil` as the `completionHandler` (it produces a warning, but compiles).
|
|
||||||
task = previousImplementation(session, Self.selector, urlRequest, completionHandler)
|
|
||||||
}
|
|
||||||
self.interceptor.taskCreated(task: task, session: session)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Swizzles the `URLSession.dataTask(with:completionHandler:)` for `URL`.
|
|
||||||
class DataTaskWithURLAndCompletion: MethodSwizzler<
|
|
||||||
@convention(c) (URLSession, Selector, URL, CompletionHandler?) -> URLSessionDataTask,
|
|
||||||
@convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask
|
|
||||||
> {
|
|
||||||
private static let selector = #selector(
|
|
||||||
URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URL, @escaping CompletionHandler) -> URLSessionDataTask
|
|
||||||
)
|
|
||||||
|
|
||||||
private let method: FoundMethod
|
|
||||||
private let interceptor: URLSessionInterceptor
|
|
||||||
|
|
||||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLAndCompletion {
|
|
||||||
try DataTaskWithURLAndCompletion(
|
|
||||||
selector: selector,
|
|
||||||
klass: URLSession.self,
|
|
||||||
interceptor: interceptor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
|
||||||
method = try Self.findMethod(with: selector, in: klass)
|
|
||||||
self.interceptor = interceptor
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzle() {
|
|
||||||
typealias Signature = @convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask
|
|
||||||
swizzle(method) { previousImplementation -> Signature in { session, url, completionHandler -> URLSessionDataTask in
|
|
||||||
let task: URLSessionDataTask
|
|
||||||
if completionHandler != nil {
|
|
||||||
var taskReference: URLSessionDataTask?
|
|
||||||
let newCompletionHandler: CompletionHandler = { data, response, error in
|
|
||||||
if let task = taskReference { // sanity check, should always succeed
|
|
||||||
self.interceptor.taskCompleted(task: task, error: error)
|
|
||||||
}
|
|
||||||
completionHandler?(data, response, error)
|
|
||||||
}
|
|
||||||
task = previousImplementation(session, Self.selector, url, newCompletionHandler)
|
|
||||||
taskReference = task
|
|
||||||
} else {
|
|
||||||
// The `completionHandler` can be `nil` in one case:
|
|
||||||
// - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing
|
|
||||||
// `nil` as the `completionHandler` (it produces a warning, but compiles).
|
|
||||||
task = previousImplementation(session, Self.selector, url, completionHandler)
|
|
||||||
}
|
|
||||||
self.interceptor.taskCreated(task: task, session: session)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Swizzles the `URLSession.dataTask(with:)` for `URLRequest`.
|
|
||||||
class DataTaskWithURLRequest: MethodSwizzler<
|
|
||||||
@convention(c) (URLSession, Selector, URLRequest) -> URLSessionDataTask,
|
|
||||||
@convention(block) (URLSession, URLRequest) -> URLSessionDataTask
|
|
||||||
> {
|
|
||||||
private static let selector = #selector(
|
|
||||||
URLSession.dataTask(with:) as (URLSession) -> (URLRequest) -> URLSessionDataTask
|
|
||||||
)
|
|
||||||
|
|
||||||
private let method: FoundMethod
|
|
||||||
private let interceptor: URLSessionInterceptor
|
|
||||||
|
|
||||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLRequest {
|
|
||||||
try DataTaskWithURLRequest(
|
|
||||||
selector: selector,
|
|
||||||
klass: URLSession.self,
|
|
||||||
interceptor: interceptor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
|
||||||
method = try Self.findMethod(with: selector, in: klass)
|
|
||||||
self.interceptor = interceptor
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzle() {
|
|
||||||
typealias Signature = @convention(block) (URLSession, URLRequest) -> URLSessionDataTask
|
|
||||||
swizzle(method) { previousImplementation -> Signature in { session, urlRequest -> URLSessionDataTask in
|
|
||||||
let task = previousImplementation(session, Self.selector, urlRequest)
|
|
||||||
self.interceptor.taskCreated(task: task, session: session)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Swizzles the `URLSession.dataTask(with:)` for `URL`.
|
|
||||||
class DataTaskWithURL: MethodSwizzler<
|
|
||||||
@convention(c) (URLSession, Selector, URL) -> URLSessionDataTask,
|
|
||||||
@convention(block) (URLSession, URL) -> URLSessionDataTask
|
|
||||||
> {
|
|
||||||
private static let selector = #selector(
|
|
||||||
URLSession.dataTask(with:) as (URLSession) -> (URL) -> URLSessionDataTask
|
|
||||||
)
|
|
||||||
|
|
||||||
private let method: FoundMethod
|
|
||||||
private let interceptor: URLSessionInterceptor
|
|
||||||
|
|
||||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURL {
|
|
||||||
try DataTaskWithURL(
|
|
||||||
selector: selector,
|
|
||||||
klass: URLSession.self,
|
|
||||||
interceptor: interceptor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
|
||||||
method = try Self.findMethod(with: selector, in: klass)
|
|
||||||
self.interceptor = interceptor
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func swizzle() {
|
|
||||||
typealias Signature = @convention(block) (URLSession, URL) -> URLSessionDataTask
|
|
||||||
swizzle(method) { previousImplementation -> Signature in { session, url -> URLSessionDataTask in
|
|
||||||
let task = previousImplementation(session, Self.selector, url)
|
|
||||||
self.interceptor.taskCreated(task: task, session: session)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// swiftlint:enable nesting
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSessionReplayPlugin.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 12/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Session replay plugins are used to capture specific types of meta data during a session,
|
|
||||||
/// such as console logs, network requests and user interactions. Each plugin is responsible
|
|
||||||
/// for managing its own capture lifecycle and sending data to PostHog.
|
|
||||||
///
|
|
||||||
/// Plugins are installed automatically based on the session replay configuration.
|
|
||||||
protocol PostHogSessionReplayPlugin {
|
|
||||||
/// Starts the plugin and begins data capture.
|
|
||||||
///
|
|
||||||
/// Called when session replay is started. The plugin should set up any required
|
|
||||||
/// resources and begin capturing data.
|
|
||||||
///
|
|
||||||
/// - Parameter postHog: The PostHog SDK instance to use for sending data
|
|
||||||
func start(postHog: PostHogSDK)
|
|
||||||
|
|
||||||
/// Stops the plugin and cleans up resources.
|
|
||||||
///
|
|
||||||
/// Called when session replay is stopped. The plugin should clean up any resources
|
|
||||||
/// and stop capturing data.
|
|
||||||
func stop()
|
|
||||||
|
|
||||||
/// Temporarily pauses data capture.
|
|
||||||
///
|
|
||||||
/// Called by session replay integration when plugin is requested to temporarily pause capturing data
|
|
||||||
/// The plugin should pause data capture but maintain its state.
|
|
||||||
func pause()
|
|
||||||
|
|
||||||
/// Resumes data capture after being paused.
|
|
||||||
///
|
|
||||||
/// Called by session replay integration when plugin is requested to resume normal capturing data
|
|
||||||
/// The plugin should resume data capture from its previous state.
|
|
||||||
func resume()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,865 +0,0 @@
|
|||||||
// swiftlint:disable cyclomatic_complexity
|
|
||||||
|
|
||||||
//
|
|
||||||
// PostHogReplayIntegration.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 19.03.24.
|
|
||||||
//
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
import PhotosUI
|
|
||||||
import SwiftUI
|
|
||||||
import UIKit
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
class PostHogReplayIntegration: PostHogIntegration {
|
|
||||||
var requiresSwizzling: Bool { true }
|
|
||||||
|
|
||||||
private static var integrationInstalledLock = NSLock()
|
|
||||||
private static var integrationInstalled = false
|
|
||||||
|
|
||||||
private var config: PostHogConfig? {
|
|
||||||
postHog?.config
|
|
||||||
}
|
|
||||||
|
|
||||||
private weak var postHog: PostHogSDK?
|
|
||||||
|
|
||||||
private var isEnabled: Bool = false
|
|
||||||
|
|
||||||
private let windowViewsLock = NSLock()
|
|
||||||
private let windowViews = NSMapTable<UIWindow, ViewTreeSnapshotStatus>.weakToStrongObjects()
|
|
||||||
private var applicationEventToken: RegistrationToken?
|
|
||||||
private var applicationBackgroundedToken: RegistrationToken?
|
|
||||||
private var applicationForegroundedToken: RegistrationToken?
|
|
||||||
private var viewLayoutToken: RegistrationToken?
|
|
||||||
private var installedPlugins: [PostHogSessionReplayPlugin] = []
|
|
||||||
|
|
||||||
/**
|
|
||||||
### Mapping of SwiftUI Views to UIKit
|
|
||||||
|
|
||||||
This section summarizes findings on how SwiftUI views map to UIKit components
|
|
||||||
|
|
||||||
#### Image-Based Views
|
|
||||||
- **`AsyncImage` and `Image`**
|
|
||||||
- Both views have a `CALayer` of type `SwiftUI.ImageLayer`.
|
|
||||||
- The associated `UIView` is of type `SwiftUI._UIGraphicsView`.
|
|
||||||
|
|
||||||
#### Graphic-based Views
|
|
||||||
- **`Color`, `Divider`, `Gradient` etc
|
|
||||||
- These are backed by `SwiftUI._UIGraphicsView` but have a different layer type than images
|
|
||||||
|
|
||||||
#### Text-Based Views
|
|
||||||
- **`Text`, `Button`, and `TextEditor`**
|
|
||||||
- These views are backed by a `UIView` of type `SwiftUI.CGDrawingView`, which is a subclass of `SwiftUI._UIGraphicsView`.
|
|
||||||
- CoreGraphics (`CG`) is used for rendering text content directly, making it challenging to access the value programmatically.
|
|
||||||
|
|
||||||
#### UIKit-Mapped Views
|
|
||||||
- **Views Hosted by `UIViewRepresentable`**
|
|
||||||
- Some SwiftUI views map directly to UIKit classes or to a subclass:
|
|
||||||
- **Control Images** (e.g., in `Picker` drop-downs) may map to `UIImageView`.
|
|
||||||
- **Buttons** map to `SwiftUI.UIKitIconPreferringButton` (a subclass of `UIButton`).
|
|
||||||
- **Toggle** maps to `UISwitch` (the toggle itself, excluding its label).
|
|
||||||
- **Picker** with wheel style maps to `UIPickerView`. Other styles use combinations of image-based and text-based views.
|
|
||||||
|
|
||||||
#### Layout and Structure Views
|
|
||||||
- **`Spacer`, `VStack`, `HStack`, `ZStack`, and Lazy Stacks**
|
|
||||||
- These views do not correspond to specific a `UIView`. Instead, they translate directly into layout constraints.
|
|
||||||
|
|
||||||
#### List-Based Views
|
|
||||||
- **`List` and Scrollable Container Views**
|
|
||||||
- Backed by a subclass of `UICollectionView`
|
|
||||||
|
|
||||||
#### Other SwiftUI Views
|
|
||||||
- Most other SwiftUI views are *compositions* of the views described above
|
|
||||||
|
|
||||||
SwiftUI Image Types:
|
|
||||||
- [StackOverflow: Subviews of a Window or View in SwiftUI](https://stackoverflow.com/questions/57554590/how-to-get-all-the-subviews-of-a-window-or-view-in-latest-swiftui-app)
|
|
||||||
- [StackOverflow: Detect SwiftUI Usage Programmatically](https://stackoverflow.com/questions/58336045/how-to-detect-swiftui-usage-programmatically-in-an-ios-application)
|
|
||||||
*/
|
|
||||||
|
|
||||||
/// `AsyncImage` and `Image`
|
|
||||||
private let swiftUIImageLayerTypes = [
|
|
||||||
"SwiftUI.ImageLayer",
|
|
||||||
].compactMap(NSClassFromString)
|
|
||||||
|
|
||||||
/// `Text`, `Button`, `TextEditor` views
|
|
||||||
private let swiftUITextBasedViewTypes = [
|
|
||||||
"SwiftUI.CGDrawingView", // Text, Button
|
|
||||||
"SwiftUI.TextEditorTextView", // TextEditor
|
|
||||||
"SwiftUI.VerticalTextView", // TextField, vertical axis
|
|
||||||
].compactMap(NSClassFromString)
|
|
||||||
|
|
||||||
private let swiftUIGenericTypes = [
|
|
||||||
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
|
|
||||||
].compactMap(NSClassFromString)
|
|
||||||
|
|
||||||
private let reactNativeTextView: AnyClass? = NSClassFromString("RCTTextView")
|
|
||||||
private let reactNativeImageView: AnyClass? = NSClassFromString("RCTImageView")
|
|
||||||
// These are usually views that don't belong to the current process and are most likely sensitive
|
|
||||||
private let systemSandboxedView: AnyClass? = NSClassFromString("_UIRemoteView")
|
|
||||||
|
|
||||||
// These layer types should be safe to ignore while masking
|
|
||||||
private let swiftUISafeLayerTypes: [AnyClass] = [
|
|
||||||
"SwiftUI.GradientLayer", // Views like LinearGradient, RadialGradient, or AngularGradient
|
|
||||||
].compactMap(NSClassFromString)
|
|
||||||
|
|
||||||
static let dispatchQueue = DispatchQueue(label: "com.posthog.PostHogReplayIntegration",
|
|
||||||
target: .global(qos: .utility))
|
|
||||||
|
|
||||||
private func isNotFlutter() -> Bool {
|
|
||||||
// for the Flutter SDK, screen recordings are managed by Flutter SDK itself
|
|
||||||
postHogSdkName != "posthog-flutter"
|
|
||||||
}
|
|
||||||
|
|
||||||
func install(_ postHog: PostHogSDK) throws {
|
|
||||||
try PostHogReplayIntegration.integrationInstalledLock.withLock {
|
|
||||||
if PostHogReplayIntegration.integrationInstalled {
|
|
||||||
throw InternalPostHogError(description: "Replay integration already installed to another PostHogSDK instance.")
|
|
||||||
}
|
|
||||||
PostHogReplayIntegration.integrationInstalled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
self.postHog = postHog
|
|
||||||
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func uninstall(_ postHog: PostHogSDK) {
|
|
||||||
if self.postHog === postHog || self.postHog == nil {
|
|
||||||
stop()
|
|
||||||
self.postHog = nil
|
|
||||||
PostHogReplayIntegration.integrationInstalledLock.withLock {
|
|
||||||
PostHogReplayIntegration.integrationInstalled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard let postHog, !isEnabled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isEnabled = true
|
|
||||||
// reset views when session id changes (or is cleared) so we can re-send new metadata (or full snapshot in the future)
|
|
||||||
postHog.sessionManager.onSessionIdChanged = { [weak self] in
|
|
||||||
self?.resetViews()
|
|
||||||
}
|
|
||||||
|
|
||||||
// flutter captures snapshots, so we don't need to capture them here
|
|
||||||
if isNotFlutter() {
|
|
||||||
let interval = postHog.config.sessionReplayConfig.throttleDelay
|
|
||||||
viewLayoutToken = DI.main.viewLayoutPublisher.onViewLayout(throttle: interval) { [weak self] in
|
|
||||||
// called on main thread
|
|
||||||
self?.snapshot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start listening to `UIApplication.sendEvent`
|
|
||||||
let applicationEventPublisher = DI.main.applicationEventPublisher
|
|
||||||
applicationEventToken = applicationEventPublisher.onApplicationEvent { [weak self] event, date in
|
|
||||||
self?.handleApplicationEvent(event: event, date: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install plugins
|
|
||||||
let plugins = postHog.config.sessionReplayConfig.getPlugins()
|
|
||||||
installedPlugins = []
|
|
||||||
for plugin in plugins {
|
|
||||||
plugin.start(postHog: postHog)
|
|
||||||
installedPlugins.append(plugin)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start listening to application background events and pause all plugins
|
|
||||||
let applicationLifecyclePublisher = DI.main.appLifecyclePublisher
|
|
||||||
applicationBackgroundedToken = applicationLifecyclePublisher.onDidEnterBackground { [weak self] in
|
|
||||||
self?.pauseAllPlugins()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start listening to application foreground events and resume all plugins
|
|
||||||
applicationForegroundedToken = applicationLifecyclePublisher.onDidBecomeActive { [weak self] in
|
|
||||||
self?.resumeAllPlugins()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
isEnabled = false
|
|
||||||
resetViews()
|
|
||||||
postHog?.sessionManager.onSessionIdChanged = {}
|
|
||||||
|
|
||||||
// stop listening to `UIApplication.sendEvent`
|
|
||||||
applicationEventToken = nil
|
|
||||||
// stop listening to Application lifecycle events
|
|
||||||
applicationBackgroundedToken = nil
|
|
||||||
applicationForegroundedToken = nil
|
|
||||||
// stop listening to `UIView.layoutSubviews` events
|
|
||||||
viewLayoutToken = nil
|
|
||||||
|
|
||||||
// stop plugins
|
|
||||||
for plugin in installedPlugins {
|
|
||||||
plugin.stop()
|
|
||||||
}
|
|
||||||
installedPlugins = []
|
|
||||||
}
|
|
||||||
|
|
||||||
func isActive() -> Bool {
|
|
||||||
isEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetViews() {
|
|
||||||
// Ensure thread-safe access to windowViews
|
|
||||||
windowViewsLock.withLock {
|
|
||||||
windowViews.removeAllObjects()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pauseAllPlugins() {
|
|
||||||
for plugin in installedPlugins {
|
|
||||||
plugin.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resumeAllPlugins() {
|
|
||||||
for plugin in installedPlugins {
|
|
||||||
plugin.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleApplicationEvent(event: UIEvent, date: Date) {
|
|
||||||
guard let postHog, postHog.isSessionReplayActive() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard event.type == .touches else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let window = UIApplication.getCurrentWindow() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let touches = event.touches(for: window) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// capture necessary touch information on the main thread before performing any asynchronous operations
|
|
||||||
// - this ensures that UITouch associated objects like UIView, UIWindow, or [UIGestureRecognizer] are still valid.
|
|
||||||
// - these objects may be released or erased by the system if accessed asynchronously, resulting in invalid/zeroed-out touch coordinates
|
|
||||||
let touchInfo = touches.map {
|
|
||||||
(phase: $0.phase, location: $0.location(in: window))
|
|
||||||
}
|
|
||||||
|
|
||||||
PostHogReplayIntegration.dispatchQueue.async { [touchInfo, weak postHog = postHog] in
|
|
||||||
// always make sure we have a fresh session id as early as possible
|
|
||||||
guard let sessionId = postHog?.sessionManager.getSessionId(at: date) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// captured weakly since integration may have uninstalled by now
|
|
||||||
guard let postHog else { return }
|
|
||||||
|
|
||||||
var snapshotsData: [Any] = []
|
|
||||||
for touch in touchInfo {
|
|
||||||
let phase = touch.phase
|
|
||||||
|
|
||||||
let type: Int
|
|
||||||
if phase == .began {
|
|
||||||
type = 7
|
|
||||||
} else if phase == .ended {
|
|
||||||
type = 9
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// we keep a failsafe here just in case, but this will likely never be triggered
|
|
||||||
guard touch.location != .zero else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let posX = touch.location.x.toInt()
|
|
||||||
let posY = touch.location.y.toInt()
|
|
||||||
|
|
||||||
// if the id is 0, BE transformer will set it to the virtual bodyId
|
|
||||||
let touchData: [String: Any] = ["id": 0, "pointerType": 2, "source": 2, "type": type, "x": posX, "y": posY]
|
|
||||||
|
|
||||||
let data: [String: Any] = ["type": 3, "data": touchData, "timestamp": date.toMillis()]
|
|
||||||
snapshotsData.append(data)
|
|
||||||
}
|
|
||||||
if !snapshotsData.isEmpty {
|
|
||||||
postHog.capture(
|
|
||||||
"$snapshot",
|
|
||||||
properties: [
|
|
||||||
"$snapshot_source": "mobile",
|
|
||||||
"$snapshot_data": snapshotsData,
|
|
||||||
"$session_id": sessionId,
|
|
||||||
],
|
|
||||||
timestamp: date
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func generateSnapshot(_ window: UIWindow, _ screenName: String? = nil, postHog: PostHogSDK) {
|
|
||||||
var hasChanges = false
|
|
||||||
|
|
||||||
guard let wireframe = postHog.config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(window) : toWireframe(window) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// capture timestamp after snapshot was taken
|
|
||||||
let timestampDate = Date()
|
|
||||||
let timestamp = timestampDate.toMillis()
|
|
||||||
|
|
||||||
let snapshotStatus = windowViewsLock.withLock {
|
|
||||||
windowViews.object(forKey: window) ?? ViewTreeSnapshotStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
var snapshotsData: [Any] = []
|
|
||||||
|
|
||||||
if !snapshotStatus.sentMetaEvent {
|
|
||||||
let size = window.bounds.size
|
|
||||||
let width = size.width.toInt()
|
|
||||||
let height = size.height.toInt()
|
|
||||||
|
|
||||||
var data: [String: Any] = ["width": width, "height": height]
|
|
||||||
|
|
||||||
if let screenName = screenName {
|
|
||||||
data["href"] = screenName
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshotData: [String: Any] = ["type": 4, "data": data, "timestamp": timestamp]
|
|
||||||
snapshotsData.append(snapshotData)
|
|
||||||
snapshotStatus.sentMetaEvent = true
|
|
||||||
hasChanges = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasChanges {
|
|
||||||
windowViewsLock.withLock {
|
|
||||||
windowViews.setObject(snapshotStatus, forKey: window)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: IncrementalSnapshot, type=2
|
|
||||||
|
|
||||||
PostHogReplayIntegration.dispatchQueue.async {
|
|
||||||
// always make sure we have a fresh session id at correct timestamp
|
|
||||||
guard let sessionId = postHog.sessionManager.getSessionId(at: timestampDate) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var wireframes: [Any] = []
|
|
||||||
wireframes.append(wireframe.toDict())
|
|
||||||
let initialOffset = ["top": 0, "left": 0]
|
|
||||||
let data: [String: Any] = ["initialOffset": initialOffset, "wireframes": wireframes]
|
|
||||||
let snapshotData: [String: Any] = ["type": 2, "data": data, "timestamp": timestamp]
|
|
||||||
snapshotsData.append(snapshotData)
|
|
||||||
|
|
||||||
postHog.capture(
|
|
||||||
"$snapshot",
|
|
||||||
properties: [
|
|
||||||
"$snapshot_source": "mobile",
|
|
||||||
"$snapshot_data": snapshotsData,
|
|
||||||
"$session_id": sessionId,
|
|
||||||
],
|
|
||||||
timestamp: timestampDate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setAlignment(_ alignment: NSTextAlignment, _ style: RRStyle) {
|
|
||||||
if alignment == .center {
|
|
||||||
style.verticalAlign = "center"
|
|
||||||
style.horizontalAlign = "center"
|
|
||||||
} else if alignment == .right {
|
|
||||||
style.horizontalAlign = "right"
|
|
||||||
} else if alignment == .left {
|
|
||||||
style.horizontalAlign = "left"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setPadding(_ insets: UIEdgeInsets, _ style: RRStyle) {
|
|
||||||
style.paddingTop = insets.top.toInt()
|
|
||||||
style.paddingRight = insets.right.toInt()
|
|
||||||
style.paddingBottom = insets.bottom.toInt()
|
|
||||||
style.paddingLeft = insets.left.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createBasicWireframe(_ view: UIView) -> RRWireframe {
|
|
||||||
let wireframe = RRWireframe()
|
|
||||||
|
|
||||||
// since FE will render each node of the wireframe with position: fixed
|
|
||||||
// we need to convert bounds to global screen coordinates
|
|
||||||
// otherwise each view of depth > 1 will likely have an origin of 0,0 (which is the local origin)
|
|
||||||
let frame = view.toAbsoluteRect(view.window)
|
|
||||||
|
|
||||||
wireframe.id = view.hash
|
|
||||||
wireframe.posX = frame.origin.x.toInt()
|
|
||||||
wireframe.posY = frame.origin.y.toInt()
|
|
||||||
wireframe.width = frame.size.width.toInt()
|
|
||||||
wireframe.height = frame.size.height.toInt()
|
|
||||||
|
|
||||||
return wireframe
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) {
|
|
||||||
// User explicitly marked this view (and its subviews) as non-maskable through `.postHogNoMask()` view modifier
|
|
||||||
if view.postHogNoMask {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView
|
|
||||||
if isTextViewSensitive(textView) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SwiftUI: `TextField`, `SecureField` will land here
|
|
||||||
if let textField = view as? UITextField {
|
|
||||||
if isTextFieldSensitive(textField) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let reactNativeTextView = reactNativeTextView {
|
|
||||||
if view.isKind(of: reactNativeTextView), config?.sessionReplayConfig.maskAllTextInputs == true {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SwiftUI: Some control images like the ones in `Picker` view may land here
|
|
||||||
if let image = view as? UIImageView {
|
|
||||||
if isImageViewSensitive(image) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let reactNativeImageView = reactNativeImageView {
|
|
||||||
if view.isKind(of: reactNativeImageView), config?.sessionReplayConfig.maskAllImages == true {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
|
|
||||||
if isLabelSensitive(label) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let webView = view as? WKWebView { // Link, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
|
|
||||||
// since we cannot mask the webview content, if masking texts or images are enabled
|
|
||||||
// we mask the whole webview as well
|
|
||||||
if isAnyInputSensitive(webView) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SwiftUI: `SwiftUI.UIKitIconPreferringButton` and other subclasses will land here
|
|
||||||
if let button = view as? UIButton {
|
|
||||||
if isButtonSensitive(button) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SwiftUI: `Toggle` (no text, labels are just rendered to Text (swiftUIImageTypes))
|
|
||||||
if let theSwitch = view as? UISwitch {
|
|
||||||
if isSwitchSensitive(theSwitch) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// detect any views that don't belong to the current process (likely system views)
|
|
||||||
if config?.sessionReplayConfig.maskAllSandboxedViews == true,
|
|
||||||
let systemSandboxedView,
|
|
||||||
view.isKind(of: systemSandboxedView)
|
|
||||||
{
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// if its a generic type and has subviews, subviews have to be checked first
|
|
||||||
let hasSubViews = !view.subviews.isEmpty
|
|
||||||
|
|
||||||
/// SwiftUI: `Picker` with .pickerStyle(.wheel) will land here
|
|
||||||
if let picker = view as? UIPickerView {
|
|
||||||
if isTextInputSensitive(picker), !hasSubViews {
|
|
||||||
maskableWidgets.append(picker.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SwiftUI: Text based views like `Text`, `Button`, `TextEditor`
|
|
||||||
if swiftUITextBasedViewTypes.contains(where: view.isKind(of:)) {
|
|
||||||
if isTextInputSensitive(view), !hasSubViews {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SwiftUI: Image based views like `Image`, `AsyncImage`. (Note: We check the layer type here)
|
|
||||||
if swiftUIImageLayerTypes.contains(where: view.layer.isKind(of:)) {
|
|
||||||
if isSwiftUIImageSensitive(view), !hasSubViews {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// this can be anything, so better to be conservative
|
|
||||||
if swiftUIGenericTypes.contains(where: { view.isKind(of: $0) }), !isSwiftUILayerSafe(view.layer) {
|
|
||||||
if isTextInputSensitive(view), !hasSubViews {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// manually masked views through `.postHogMask()` view modifier
|
|
||||||
if view.postHogNoCapture {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask
|
|
||||||
// in such cases
|
|
||||||
if view.isNoCapture() || maskChildren {
|
|
||||||
let viewRect = view.toAbsoluteRect(window)
|
|
||||||
let windowRect = window.frame
|
|
||||||
|
|
||||||
// Check if the rectangles do not match
|
|
||||||
if !viewRect.equalTo(windowRect) {
|
|
||||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
|
||||||
} else {
|
|
||||||
maskChildren = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !view.subviews.isEmpty {
|
|
||||||
for child in view.subviews {
|
|
||||||
if !child.isVisible() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
findMaskableWidgets(child, window, &maskableWidgets, &maskChildren)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maskChildren = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func toScreenshotWireframe(_ window: UIWindow) -> RRWireframe? {
|
|
||||||
// this will bail on view controller animations (interactive or not)
|
|
||||||
if !window.isVisible() || isAnimatingTransition(window) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var maskableWidgets: [CGRect] = []
|
|
||||||
var maskChildren = false
|
|
||||||
findMaskableWidgets(window, window, &maskableWidgets, &maskChildren)
|
|
||||||
|
|
||||||
let wireframe = createBasicWireframe(window)
|
|
||||||
|
|
||||||
if let image = window.toImage() {
|
|
||||||
if !image.size.hasSize() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
wireframe.maskableWidgets = maskableWidgets
|
|
||||||
|
|
||||||
wireframe.image = image
|
|
||||||
}
|
|
||||||
wireframe.type = "screenshot"
|
|
||||||
return wireframe
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if any view controller in the hierarchy is animating a transition
|
|
||||||
private func isAnimatingTransition(_ window: UIWindow) -> Bool {
|
|
||||||
guard let rootViewController = window.rootViewController else { return false }
|
|
||||||
return isAnimatingTransition(rootViewController)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isAnimatingTransition(_ viewController: UIViewController) -> Bool {
|
|
||||||
// Check if this view controller is animating
|
|
||||||
if viewController.transitionCoordinator?.isAnimated ?? false {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if presented view controller is animating
|
|
||||||
if let presented = viewController.presentedViewController, isAnimatingTransition(presented) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any of the child view controllers is animating
|
|
||||||
if viewController.children.first(where: isAnimatingTransition) != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isAssetsImage(_ image: UIImage) -> Bool {
|
|
||||||
// https://github.com/daydreamboy/lldb_scripts#9-pimage
|
|
||||||
// do not mask if its an asset image, likely not PII anyway
|
|
||||||
image.imageAsset?.value(forKey: "_containingBundle") != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isAnyInputSensitive(_ view: UIView) -> Bool {
|
|
||||||
isTextInputSensitive(view) || config?.sessionReplayConfig.maskAllImages == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isTextInputSensitive(_ view: UIView) -> Bool {
|
|
||||||
config?.sessionReplayConfig.maskAllTextInputs == true || view.isNoCapture()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isLabelSensitive(_ view: UILabel) -> Bool {
|
|
||||||
isTextInputSensitive(view) && hasText(view.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isButtonSensitive(_ view: UIButton) -> Bool {
|
|
||||||
isTextInputSensitive(view) && hasText(view.titleLabel?.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isTextViewSensitive(_ view: UITextView) -> Bool {
|
|
||||||
(isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSwitchSensitive(_ view: UISwitch) -> Bool {
|
|
||||||
var containsText = true
|
|
||||||
if #available(iOS 14.0, *) {
|
|
||||||
containsText = hasText(view.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isTextInputSensitive(view) && containsText
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isTextFieldSensitive(_ view: UITextField) -> Bool {
|
|
||||||
(isTextInputSensitive(view) || view.isSensitiveText()) && (hasText(view.text) || hasText(view.placeholder))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSwiftUILayerSafe(_ layer: CALayer) -> Bool {
|
|
||||||
swiftUISafeLayerTypes.contains(where: { layer.isKind(of: $0) })
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hasText(_ text: String?) -> Bool {
|
|
||||||
if let text = text, !text.isEmpty {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
// if there's no text, there's nothing to mask
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSwiftUIImageSensitive(_ view: UIView) -> Bool {
|
|
||||||
// No way of checking if this is an asset image or not
|
|
||||||
// No way of checking if there's actual content in the image or not
|
|
||||||
config?.sessionReplayConfig.maskAllImages == true || view.isNoCapture()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isImageViewSensitive(_ view: UIImageView) -> Bool {
|
|
||||||
// if there's no image, there's nothing to mask
|
|
||||||
guard let image = view.image else { return false }
|
|
||||||
|
|
||||||
// sensitive, regardless
|
|
||||||
if view.isNoCapture() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// asset images are probably not sensitive
|
|
||||||
if isAssetsImage(image) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// symbols are probably not sensitive
|
|
||||||
if image.isSymbolImage {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return config?.sessionReplayConfig.maskAllImages == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func toWireframe(_ view: UIView) -> RRWireframe? {
|
|
||||||
if !view.isVisible() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let wireframe = createBasicWireframe(view)
|
|
||||||
|
|
||||||
let style = RRStyle()
|
|
||||||
|
|
||||||
if let textView = view as? UITextView {
|
|
||||||
wireframe.type = "text"
|
|
||||||
wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text
|
|
||||||
wireframe.disabled = !textView.isEditable
|
|
||||||
style.color = textView.textColor?.toRGBString()
|
|
||||||
style.fontFamily = textView.font?.familyName
|
|
||||||
if let fontSize = textView.font?.pointSize.toInt() {
|
|
||||||
style.fontSize = fontSize
|
|
||||||
}
|
|
||||||
setAlignment(textView.textAlignment, style)
|
|
||||||
setPadding(textView.textContainerInset, style)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let textField = view as? UITextField {
|
|
||||||
wireframe.type = "input"
|
|
||||||
wireframe.inputType = "text_area"
|
|
||||||
let isSensitive = isTextFieldSensitive(textField)
|
|
||||||
if let text = textField.text {
|
|
||||||
wireframe.value = isSensitive ? text.mask() : text
|
|
||||||
} else {
|
|
||||||
if let text = textField.placeholder {
|
|
||||||
wireframe.value = isSensitive ? text.mask() : text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wireframe.disabled = !textField.isEnabled
|
|
||||||
style.color = textField.textColor?.toRGBString()
|
|
||||||
style.fontFamily = textField.font?.familyName
|
|
||||||
if let fontSize = textField.font?.pointSize.toInt() {
|
|
||||||
style.fontSize = fontSize
|
|
||||||
}
|
|
||||||
setAlignment(textField.textAlignment, style)
|
|
||||||
}
|
|
||||||
|
|
||||||
if view is UIPickerView {
|
|
||||||
wireframe.type = "input"
|
|
||||||
wireframe.inputType = "select"
|
|
||||||
// set wireframe.value from selected row
|
|
||||||
}
|
|
||||||
|
|
||||||
if let theSwitch = view as? UISwitch {
|
|
||||||
wireframe.type = "input"
|
|
||||||
wireframe.inputType = "toggle"
|
|
||||||
wireframe.checked = theSwitch.isOn
|
|
||||||
if #available(iOS 14.0, *) {
|
|
||||||
if let text = theSwitch.title {
|
|
||||||
wireframe.label = isSwitchSensitive(theSwitch) ? text.mask() : text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let imageView = view as? UIImageView {
|
|
||||||
wireframe.type = "image"
|
|
||||||
if let image = imageView.image {
|
|
||||||
if !isImageViewSensitive(imageView) {
|
|
||||||
wireframe.image = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let button = view as? UIButton {
|
|
||||||
wireframe.type = "input"
|
|
||||||
wireframe.inputType = "button"
|
|
||||||
wireframe.disabled = !button.isEnabled
|
|
||||||
|
|
||||||
if let text = button.titleLabel?.text {
|
|
||||||
// NOTE: this will create a ghosting effect since text will also be captured in child UILabel
|
|
||||||
// We also may be masking this UIButton but child UILabel may remain unmasked
|
|
||||||
wireframe.value = isButtonSensitive(button) ? text.mask() : text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let label = view as? UILabel {
|
|
||||||
wireframe.type = "text"
|
|
||||||
if let text = label.text {
|
|
||||||
wireframe.text = isLabelSensitive(label) ? text.mask() : text
|
|
||||||
}
|
|
||||||
wireframe.disabled = !label.isEnabled
|
|
||||||
style.color = label.textColor?.toRGBString()
|
|
||||||
style.fontFamily = label.font?.familyName
|
|
||||||
if let fontSize = label.font?.pointSize.toInt() {
|
|
||||||
style.fontSize = fontSize
|
|
||||||
}
|
|
||||||
setAlignment(label.textAlignment, style)
|
|
||||||
}
|
|
||||||
|
|
||||||
if view is WKWebView {
|
|
||||||
wireframe.type = "web_view"
|
|
||||||
}
|
|
||||||
|
|
||||||
if let progressView = view as? UIProgressView {
|
|
||||||
wireframe.type = "input"
|
|
||||||
wireframe.inputType = "progress"
|
|
||||||
wireframe.value = progressView.progress
|
|
||||||
wireframe.max = 1
|
|
||||||
// UIProgressView theres not circular format, only custom view or swiftui
|
|
||||||
style.bar = "horizontal"
|
|
||||||
}
|
|
||||||
|
|
||||||
if view is UIActivityIndicatorView {
|
|
||||||
wireframe.type = "input"
|
|
||||||
wireframe.inputType = "progress"
|
|
||||||
style.bar = "circular"
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: props: backgroundImage (probably not needed)
|
|
||||||
// TODO: componenets: UITabBar, UINavigationBar, UISlider, UIStepper, UIDatePicker
|
|
||||||
|
|
||||||
style.backgroundColor = view.backgroundColor?.toRGBString()
|
|
||||||
let layer = view.layer
|
|
||||||
style.borderWidth = layer.borderWidth.toInt()
|
|
||||||
style.borderRadius = layer.cornerRadius.toInt()
|
|
||||||
style.borderColor = layer.borderColor?.toRGBString()
|
|
||||||
|
|
||||||
wireframe.style = style
|
|
||||||
|
|
||||||
if !view.subviews.isEmpty {
|
|
||||||
var childWireframes: [RRWireframe] = []
|
|
||||||
for subview in view.subviews {
|
|
||||||
if let child = toWireframe(subview) {
|
|
||||||
childWireframes.append(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wireframe.childWireframes = childWireframes
|
|
||||||
}
|
|
||||||
|
|
||||||
return wireframe
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func snapshot() {
|
|
||||||
guard let postHog, postHog.isSessionReplayActive() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let window = UIApplication.getCurrentWindow() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var screenName: String?
|
|
||||||
if let controller = window.rootViewController {
|
|
||||||
// SwiftUI only supported with screenshotMode
|
|
||||||
if controller is AnyObjectUIHostingViewController, !postHog.config.sessionReplayConfig.screenshotMode {
|
|
||||||
hedgeLog("SwiftUI snapshot not supported, enable screenshotMode.")
|
|
||||||
return
|
|
||||||
// screen name only makes sense if we are not using SwiftUI
|
|
||||||
} else if !postHog.config.sessionReplayConfig.screenshotMode {
|
|
||||||
screenName = UIViewController.getViewControllerName(controller)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// this cannot run off of the main thread because most properties require to be called within the main thread
|
|
||||||
// this method has to be fast and do as little as possible
|
|
||||||
generateSnapshot(window, screenName, postHog: postHog)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private protocol AnyObjectUIHostingViewController: AnyObject {}
|
|
||||||
|
|
||||||
extension UIHostingController: AnyObjectUIHostingViewController {}
|
|
||||||
|
|
||||||
#if TESTING
|
|
||||||
extension PostHogReplayIntegration {
|
|
||||||
static func clearInstalls() {
|
|
||||||
integrationInstalledLock.withLock {
|
|
||||||
integrationInstalled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// swiftlint:enable cyclomatic_complexity
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSessionReplayConfig.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 19.03.24.
|
|
||||||
//
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@objc(PostHogSessionReplayConfig) public class PostHogSessionReplayConfig: NSObject {
|
|
||||||
/// Enable masking of all text and text input fields
|
|
||||||
/// Default: true
|
|
||||||
@objc public var maskAllTextInputs: Bool = true
|
|
||||||
|
|
||||||
/// Enable masking of all images to a placeholder
|
|
||||||
/// Default: true
|
|
||||||
@objc public var maskAllImages: Bool = true
|
|
||||||
|
|
||||||
/// Enable masking of all sandboxed system views
|
|
||||||
/// These may include UIImagePickerController, PHPickerViewController and CNContactPickerViewController
|
|
||||||
/// Default: true
|
|
||||||
@objc public var maskAllSandboxedViews: Bool = true
|
|
||||||
|
|
||||||
/// Enable masking of images that likely originated from user's photo library (UIKit only)
|
|
||||||
/// Default: false
|
|
||||||
///
|
|
||||||
/// - Note: Deprecated
|
|
||||||
@available(*, deprecated, message: "This property has no effect and will be removed in the next major release. To learn how to manually mask user photos please see our Privacy controls documentation: https://posthog.com/docs/session-replay/privacy?tab=iOS")
|
|
||||||
@objc public var maskPhotoLibraryImages: Bool = false
|
|
||||||
|
|
||||||
/// Enable capturing network telemetry
|
|
||||||
/// Default: true
|
|
||||||
@objc public var captureNetworkTelemetry: Bool = true
|
|
||||||
|
|
||||||
/// By default Session replay will capture all the views on the screen as a wireframe,
|
|
||||||
/// By enabling this option, PostHog will capture the screenshot of the screen.
|
|
||||||
/// The screenshot may contain sensitive information, use with caution.
|
|
||||||
/// Default: false
|
|
||||||
@objc public var screenshotMode: Bool = false
|
|
||||||
|
|
||||||
/// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact
|
|
||||||
/// This is used for capturing the view as a wireframe or screenshot
|
|
||||||
/// The lower the number more snapshots will be captured but higher the performance impact
|
|
||||||
/// Defaults to 1s
|
|
||||||
@available(*, deprecated, message: "Deprecated in favor of 'throttleDelay' which provides identical functionality. Will be removed in the next major release.")
|
|
||||||
@objc public var debouncerDelay: TimeInterval {
|
|
||||||
get { throttleDelay }
|
|
||||||
set { throttleDelay = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Throttle delay used to reduce the number of snapshots captured and reduce performance impact
|
|
||||||
/// This is used for capturing the view as a wireframe or screenshot
|
|
||||||
/// The lower the number more snapshots will be captured but higher the performance impact
|
|
||||||
/// Defaults to 1s
|
|
||||||
///
|
|
||||||
/// Note: Previously `debouncerDelay`
|
|
||||||
@objc public var throttleDelay: TimeInterval = 1
|
|
||||||
|
|
||||||
/// Enable capturing console output for session replay.
|
|
||||||
///
|
|
||||||
/// When enabled, logs from the following sources will be captured:
|
|
||||||
/// - Standard output (stdout)
|
|
||||||
/// - Standard error (stderr)
|
|
||||||
/// - OSLog messages
|
|
||||||
/// - NSLog messages
|
|
||||||
///
|
|
||||||
/// Each log entry will be tagged with a level (info/warning/error) based on the message content
|
|
||||||
/// and the source.
|
|
||||||
///
|
|
||||||
/// Defaults to `false`
|
|
||||||
@objc public var captureLogs: Bool = false
|
|
||||||
|
|
||||||
/// Further configuration for capturing console output
|
|
||||||
@objc public var captureLogsConfig: PostHogSessionReplayConsoleLogConfig = .init()
|
|
||||||
|
|
||||||
// TODO: sessionRecording config such as networkPayloadCapture, sampleRate, etc
|
|
||||||
|
|
||||||
/// Returns an array of plugins to be installed based on current configuration
|
|
||||||
func getPlugins() -> [PostHogSessionReplayPlugin] {
|
|
||||||
var plugins: [PostHogSessionReplayPlugin] = []
|
|
||||||
|
|
||||||
if captureLogs {
|
|
||||||
plugins.append(PostHogSessionReplayConsoleLogsPlugin())
|
|
||||||
}
|
|
||||||
|
|
||||||
if captureNetworkTelemetry {
|
|
||||||
plugins.append(PostHogSessionReplayNetworkPlugin())
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
//
|
|
||||||
// PostHogSessionReplayConsoleLogConfig.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Ioannis Josephides on 09/05/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@objc public class PostHogSessionReplayConsoleLogConfig: NSObject {
|
|
||||||
/// Block to process and format captured console output for session replay.
|
|
||||||
///
|
|
||||||
/// This block is called whenever console output is captured. It allows you to:
|
|
||||||
/// 1. Filter or modify log messages before they are sent to session replay
|
|
||||||
/// 2. Determine the appropriate log level (info/warn/error) for each message
|
|
||||||
/// 3. Format, sanitize or skip a log messages (e.g. remove sensitive data or PII)
|
|
||||||
///
|
|
||||||
/// The default implementation:
|
|
||||||
/// - Detect log level (best effort)
|
|
||||||
/// - Process OSLog messages to remove metadata
|
|
||||||
///
|
|
||||||
/// - Parameter output: The raw console output to process
|
|
||||||
/// - Returns: Array of `PostHogConsoleLogResult` objects, one for each processed log entry. Return an empty array to skip a log output
|
|
||||||
@objc public var logSanitizer: ((String) -> PostHogLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer
|
|
||||||
|
|
||||||
/// The minimum log level to capture in session replay.
|
|
||||||
/// Only log messages with this level or higher will be captured.
|
|
||||||
/// For example, if set to `.warn`:
|
|
||||||
/// - `.error` messages will be captured
|
|
||||||
/// - `.warn` messages will be captured
|
|
||||||
/// - `.info` messages will be skipped
|
|
||||||
///
|
|
||||||
/// Defaults to `.error` to minimize noise in session replays.
|
|
||||||
@objc public var minLogLevel: PostHogLogLevel = .error
|
|
||||||
|
|
||||||
/// Default implementation for processing console output.
|
|
||||||
static func defaultLogSanitizer(_ message: String) -> PostHogLogEntry? {
|
|
||||||
let message = String(message)
|
|
||||||
// Determine console log level
|
|
||||||
let level: PostHogLogLevel = {
|
|
||||||
if message.range(of: logMessageWarningPattern, options: .regularExpression) != nil { return .warn }
|
|
||||||
if message.range(of: logMessageErrorPattern, options: .regularExpression) != nil { return .error }
|
|
||||||
return .info
|
|
||||||
}()
|
|
||||||
|
|
||||||
// For OSLog messages, extract just the log message part
|
|
||||||
let sanitizedMessage = message.contains("OSLOG-") ? {
|
|
||||||
if let tabIndex = message.lastIndex(of: "\t") {
|
|
||||||
return String(message[message.index(after: tabIndex)...])
|
|
||||||
}
|
|
||||||
return message
|
|
||||||
}() : message
|
|
||||||
|
|
||||||
return PostHogLogEntry(level: level, message: sanitizedMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default regular expression pattern used to identify error-level log messages.
|
|
||||||
///
|
|
||||||
/// By default, it matches common error indicators such as:
|
|
||||||
/// - The word "error", "exception", "fail" or "failed"
|
|
||||||
/// - OSLog messages with type "Error" or "Fault"
|
|
||||||
private static let logMessageErrorPattern = "(error|exception|fail(ed)?|OSLOG-.*type:\"Error\"|OSLOG-.*type:\"Fault\")"
|
|
||||||
|
|
||||||
/// Default regular expression pattern used to identify warning-level log messages.
|
|
||||||
///
|
|
||||||
/// By default, it matches common warning indicators such as:
|
|
||||||
/// - The words "warning", "warn", "caution", or "deprecated"
|
|
||||||
/// - OSLog messages with type "Warning"
|
|
||||||
///
|
|
||||||
private static let logMessageWarningPattern = "(warn(ing)?|caution|deprecated|OSLOG-.*type:\"Warning\")"
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
96
Pods/PostHog/PostHog/Replay/RRStyle.swift
generated
96
Pods/PostHog/PostHog/Replay/RRStyle.swift
generated
@@ -1,96 +0,0 @@
|
|||||||
// swiftlint:disable cyclomatic_complexity
|
|
||||||
|
|
||||||
//
|
|
||||||
// RRStyle.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 21.03.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class RRStyle {
|
|
||||||
var color: String?
|
|
||||||
var backgroundColor: String?
|
|
||||||
var backgroundImage: String?
|
|
||||||
var borderWidth: Int?
|
|
||||||
var borderRadius: Int?
|
|
||||||
var borderColor: String?
|
|
||||||
var fontSize: Int?
|
|
||||||
var fontFamily: String?
|
|
||||||
var horizontalAlign: String?
|
|
||||||
var verticalAlign: String?
|
|
||||||
var paddingTop: Int?
|
|
||||||
var paddingBottom: Int?
|
|
||||||
var paddingLeft: Int?
|
|
||||||
var paddingRight: Int?
|
|
||||||
var bar: String?
|
|
||||||
|
|
||||||
func toDict() -> [String: Any] {
|
|
||||||
var dict: [String: Any] = [:]
|
|
||||||
|
|
||||||
if let color = color {
|
|
||||||
dict["color"] = color
|
|
||||||
}
|
|
||||||
|
|
||||||
if let backgroundColor = backgroundColor {
|
|
||||||
dict["backgroundColor"] = backgroundColor
|
|
||||||
}
|
|
||||||
|
|
||||||
if let backgroundImage = backgroundImage {
|
|
||||||
dict["backgroundImage"] = backgroundImage
|
|
||||||
}
|
|
||||||
|
|
||||||
if let borderWidth = borderWidth {
|
|
||||||
dict["borderWidth"] = borderWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
if let borderRadius = borderRadius {
|
|
||||||
dict["borderRadius"] = borderRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
if let borderColor = borderColor {
|
|
||||||
dict["borderColor"] = borderColor
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fontSize = fontSize {
|
|
||||||
dict["fontSize"] = fontSize
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fontFamily = fontFamily {
|
|
||||||
dict["fontFamily"] = fontFamily
|
|
||||||
}
|
|
||||||
|
|
||||||
if let horizontalAlign = horizontalAlign {
|
|
||||||
dict["horizontalAlign"] = horizontalAlign
|
|
||||||
}
|
|
||||||
|
|
||||||
if let verticalAlign = verticalAlign {
|
|
||||||
dict["verticalAlign"] = verticalAlign
|
|
||||||
}
|
|
||||||
|
|
||||||
if let paddingTop = paddingTop {
|
|
||||||
dict["paddingTop"] = paddingTop
|
|
||||||
}
|
|
||||||
|
|
||||||
if let paddingBottom = paddingBottom {
|
|
||||||
dict["paddingBottom"] = paddingBottom
|
|
||||||
}
|
|
||||||
|
|
||||||
if let paddingLeft = paddingLeft {
|
|
||||||
dict["paddingLeft"] = paddingLeft
|
|
||||||
}
|
|
||||||
|
|
||||||
if let paddingRight = paddingRight {
|
|
||||||
dict["paddingRight"] = paddingRight
|
|
||||||
}
|
|
||||||
|
|
||||||
if let bar = bar {
|
|
||||||
dict["bar"] = bar
|
|
||||||
}
|
|
||||||
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable cyclomatic_complexity
|
|
||||||
133
Pods/PostHog/PostHog/Replay/RRWireframe.swift
generated
133
Pods/PostHog/PostHog/Replay/RRWireframe.swift
generated
@@ -1,133 +0,0 @@
|
|||||||
// swiftlint:disable cyclomatic_complexity
|
|
||||||
|
|
||||||
//
|
|
||||||
// RRWireframe.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 21.03.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
class RRWireframe {
|
|
||||||
var id: Int = 0
|
|
||||||
var posX: Int = 0
|
|
||||||
var posY: Int = 0
|
|
||||||
var width: Int = 0
|
|
||||||
var height: Int = 0
|
|
||||||
var childWireframes: [RRWireframe]?
|
|
||||||
var type: String? // text|image|rectangle|input|div|screenshot
|
|
||||||
var inputType: String?
|
|
||||||
var text: String?
|
|
||||||
var label: String?
|
|
||||||
var value: Any? // string or number
|
|
||||||
#if os(iOS)
|
|
||||||
var image: UIImage?
|
|
||||||
var maskableWidgets: [CGRect]?
|
|
||||||
#endif
|
|
||||||
var base64: String?
|
|
||||||
var style: RRStyle?
|
|
||||||
var disabled: Bool?
|
|
||||||
var checked: Bool?
|
|
||||||
var options: [String]?
|
|
||||||
var max: Int?
|
|
||||||
// internal
|
|
||||||
var parentId: Int?
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
private func maskImage() -> UIImage? {
|
|
||||||
if let image = image {
|
|
||||||
// the scale also affects the image size/resolution, from usually 100kb to 15kb each
|
|
||||||
let redactedImage = UIGraphicsImageRenderer(size: image.size, format: .init(for: .init(displayScale: 1))).image { context in
|
|
||||||
context.cgContext.interpolationQuality = .none
|
|
||||||
image.draw(at: .zero)
|
|
||||||
|
|
||||||
if let maskableWidgets = maskableWidgets {
|
|
||||||
for rect in maskableWidgets {
|
|
||||||
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10)
|
|
||||||
UIColor.black.setFill()
|
|
||||||
path.fill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return redactedImage
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func toDict() -> [String: Any] {
|
|
||||||
var dict: [String: Any] = [
|
|
||||||
"id": id,
|
|
||||||
"x": posX,
|
|
||||||
"y": posY,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
]
|
|
||||||
|
|
||||||
if let childWireframes = childWireframes {
|
|
||||||
dict["childWireframes"] = childWireframes.map { $0.toDict() }
|
|
||||||
}
|
|
||||||
|
|
||||||
if let type = type {
|
|
||||||
dict["type"] = type
|
|
||||||
}
|
|
||||||
|
|
||||||
if let inputType = inputType {
|
|
||||||
dict["inputType"] = inputType
|
|
||||||
}
|
|
||||||
|
|
||||||
if let text = text {
|
|
||||||
dict["text"] = text
|
|
||||||
}
|
|
||||||
|
|
||||||
if let label = label {
|
|
||||||
dict["label"] = label
|
|
||||||
}
|
|
||||||
|
|
||||||
if let value = value {
|
|
||||||
dict["value"] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
if let image = image {
|
|
||||||
if let maskedImage = maskImage() {
|
|
||||||
base64 = maskedImage.toBase64()
|
|
||||||
} else {
|
|
||||||
base64 = image.toBase64()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if let base64 = base64 {
|
|
||||||
dict["base64"] = base64
|
|
||||||
}
|
|
||||||
|
|
||||||
if let style = style {
|
|
||||||
dict["style"] = style.toDict()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let disabled = disabled {
|
|
||||||
dict["disabled"] = disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let checked = checked {
|
|
||||||
dict["checked"] = checked
|
|
||||||
}
|
|
||||||
|
|
||||||
if let options = options {
|
|
||||||
dict["options"] = options
|
|
||||||
}
|
|
||||||
|
|
||||||
if let max = max {
|
|
||||||
dict["max"] = max
|
|
||||||
}
|
|
||||||
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable cyclomatic_complexity
|
|
||||||
14
Pods/PostHog/PostHog/Replay/String+Util.swift
generated
14
Pods/PostHog/PostHog/Replay/String+Util.swift
generated
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// String+Util.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 21.03.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
func mask() -> String {
|
|
||||||
String(repeating: "*", count: count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
Pods/PostHog/PostHog/Replay/UIColor+Util.swift
generated
17
Pods/PostHog/PostHog/Replay/UIColor+Util.swift
generated
@@ -1,17 +0,0 @@
|
|||||||
//
|
|
||||||
// UIColor+Util.swift
|
|
||||||
// PostHog
|
|
||||||
//
|
|
||||||
// Created by Manoel Aranda Neto on 21.03.24.
|
|
||||||
//
|
|
||||||
#if os(iOS)
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIColor {
|
|
||||||
func toRGBString() -> String? {
|
|
||||||
cgColor.toRGBString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user