From d3094496a4c607917b1b22b6b0c7902d6093cfde Mon Sep 17 00:00:00 2001 From: Kapusch <9576733+Kapusch@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:16:02 +0200 Subject: [PATCH 1/4] fix(ios): retain share interop and accept one image --- Docs/Integration.md | 5 ++- samples/Kapusch.TikTok.iOS.Sample/README.md | 4 +-- scripts/validate-package.sh | 11 +++++++ .../KapuschTikTokShareInterop/Interop.swift | 14 ++++++++- .../TikTok/NativeTikTokShare.iOS.cs | 31 +++++++------------ 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index e67e0ac..633202f 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -1,7 +1,10 @@ # Integration Reference `Kapusch.TikTok.iOS`, configure the three public TikTok values in the app `Info.plist`, and forward both open-URL and universal-link callbacks to `NativeTikTokShare`. +The wrapper rejects URLs that do not match the configured `TikTokURLScheme` or +`TikTokRedirectURI`; host applications should still route only those URLs to the +TikTok handler. -`ShareImagesAsync` expects two to twelve Photos local identifiers for image sharing. The host app is responsible for add-only Photos authorization and for explaining that the images remain in the photo library. +`ShareImagesAsync` expects one to twelve Photos local identifiers for image sharing. The host app is responsible for add-only Photos authorization and for explaining that the images remain in the photo library. The official 2.5.0 Core and Share privacy manifests declare no collected data and no tracking. Audit the final IPA because the rest of the application can add other declarations. diff --git a/samples/Kapusch.TikTok.iOS.Sample/README.md b/samples/Kapusch.TikTok.iOS.Sample/README.md index b05dd01..f007e20 100644 --- a/samples/Kapusch.TikTok.iOS.Sample/README.md +++ b/samples/Kapusch.TikTok.iOS.Sample/README.md @@ -17,11 +17,11 @@ public override bool ContinueUserActivity( && NativeTikTokShare.HandleOpenUrl(url.Handle); ``` -After obtaining add-only Photos permission and creating two assets, pass their local identifiers in display order: +After obtaining add-only Photos permission and creating one or more assets, pass their local identifiers in display order: ```csharp var result = await NativeTikTokShare.ShareImagesAsync( - [firstImageAsset.LocalIdentifier, secondImageAsset.LocalIdentifier], + [firstImageAsset.LocalIdentifier], CancellationToken.None ); diff --git a/scripts/validate-package.sh b/scripts/validate-package.sh index 1267e12..512258b 100755 --- a/scripts/validate-package.sh +++ b/scripts/validate-package.sh @@ -3,11 +3,22 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" package="${1:-$repo_root/artifacts/nuget/Kapusch.TikTok.iOS.1.0.0.nupkg}" +managed_source="$repo_root/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs" if [ ! -f "$package" ]; then echo "Package not found: $package" >&2 exit 1 fi +if rg -F "NativeLibrary.GetExport" "$managed_source" >/dev/null; then + echo "TikTok Share must use static __Internal imports so iOS retains its native symbols." >&2 + exit 1 +fi + +for symbol in ktk_tiktok_handle_open_url ktk_tiktok_share_images; do + rg -F "EntryPoint = \"$symbol\"" "$managed_source" >/dev/null \ + || { echo "Missing static TikTok Share import: $symbol" >&2; exit 1; } +done + entries="$(unzip -Z1 "$package")" for expected in \ "ktkshare.xcframework/ios-arm64/libKapuschTikTokShareInterop_iphoneos.a" \ diff --git a/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift b/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift index df4cb24..fd06a8e 100644 --- a/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift +++ b/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift @@ -46,7 +46,7 @@ public func ktk_tiktok_share_images( let data = Data(String(cString: localIdentifiersJson).utf8) guard let localIdentifiers = try? JSONDecoder().decode([String].self, from: data), - localIdentifiers.count >= 2, + localIdentifiers.count >= 1, localIdentifiers.count <= 12 else { invoke(callback, status: .failed, errorCode: "invalid_image_identifiers", context: context) @@ -103,5 +103,17 @@ public func ktk_tiktok_share_images( @_cdecl("ktk_tiktok_handle_open_url") public func ktk_tiktok_handle_open_url(_ urlPtr: UnsafeMutableRawPointer) -> Bool { let url = Unmanaged.fromOpaque(urlPtr).takeUnretainedValue() as URL + let info = Bundle.main.infoDictionary ?? [:] + let callbackScheme = info["TikTokURLScheme"] as? String + let redirectURI = (info["TikTokRedirectURI"] as? String).flatMap(URL.init(string:)) + let isCustomSchemeCallback = + url.scheme?.caseInsensitiveCompare(callbackScheme ?? "") == .orderedSame + let isUniversalLinkCallback = + url.scheme?.caseInsensitiveCompare(redirectURI?.scheme ?? "") == .orderedSame + && url.host?.caseInsensitiveCompare(redirectURI?.host ?? "") == .orderedSame + && url.path == redirectURI?.path + guard isCustomSchemeCallback || isUniversalLinkCallback else { + return false + } return TikTokURLHandler.handleOpenURL(url) } diff --git a/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs b/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs index ade3edc..8a06daf 100644 --- a/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs +++ b/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs @@ -4,7 +4,7 @@ namespace Kapusch.TikTok.iOS; -public static unsafe class NativeTikTokShare +public static unsafe partial class NativeTikTokShare { public static Task ShareImagesAsync( IReadOnlyList localIdentifiers, @@ -12,7 +12,7 @@ public static Task ShareImagesAsync( ) { ArgumentNullException.ThrowIfNull(localIdentifiers); - if (localIdentifiers.Count is < 2 or > 12) + if (localIdentifiers.Count is < 1 or > 12) throw new ArgumentOutOfRangeException(nameof(localIdentifiers)); cancellationToken.ThrowIfCancellationRequested(); @@ -33,7 +33,7 @@ public static Task ShareImagesAsync( var jsonPointer = Marshal.StringToCoTaskMemUTF8(json); try { - ResolveShareImages()(jsonPointer, &ShareCallback, GCHandle.ToIntPtr(handle)); + ShareImagesNative(jsonPointer, &ShareCallback, GCHandle.ToIntPtr(handle)); } catch { @@ -51,7 +51,7 @@ public static bool HandleOpenUrl(IntPtr nsUrlHandle) { if (nsUrlHandle == IntPtr.Zero) return false; - return ResolveHandleOpenUrl()(nsUrlHandle) != 0; + return HandleOpenUrlNative(nsUrlHandle) != 0; } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] @@ -74,20 +74,13 @@ private static void ShareCallback(int status, IntPtr errorCode, IntPtr context) } } - private static IntPtr Resolve(string symbol) => - NativeLibrary.GetExport(NativeLibrary.GetMainProgramHandle(), symbol); + [LibraryImport("__Internal", EntryPoint = "ktk_tiktok_share_images")] + private static partial void ShareImagesNative( + IntPtr localIdentifiersJson, + delegate* unmanaged[Cdecl] callback, + IntPtr context + ); - private static delegate* unmanaged[Cdecl]< - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void> ResolveShareImages() => - (delegate* unmanaged[Cdecl]< - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void>)Resolve("ktk_tiktok_share_images"); - - private static delegate* unmanaged[Cdecl] ResolveHandleOpenUrl() => - (delegate* unmanaged[Cdecl])Resolve("ktk_tiktok_handle_open_url"); + [LibraryImport("__Internal", EntryPoint = "ktk_tiktok_handle_open_url")] + private static partial byte HandleOpenUrlNative(IntPtr nsUrlHandle); } From 1f6ddc08669d8692bec661948001399fa359a3c3 Mon Sep 17 00:00:00 2001 From: Kapusch <9576733+Kapusch@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:16:56 +0200 Subject: [PATCH 2/4] fix(ios): recover stalled share sessions --- scripts/validate-package.sh | 5 +- .../KapuschTikTokShareInterop/Interop.swift | 56 +++++++++++++++---- .../TikTok/NativeTikTokShare.iOS.cs | 5 ++ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/scripts/validate-package.sh b/scripts/validate-package.sh index 512258b..61e20a9 100755 --- a/scripts/validate-package.sh +++ b/scripts/validate-package.sh @@ -14,7 +14,10 @@ if rg -F "NativeLibrary.GetExport" "$managed_source" >/dev/null; then exit 1 fi -for symbol in ktk_tiktok_handle_open_url ktk_tiktok_share_images; do +for symbol in \ + ktk_tiktok_handle_open_url \ + ktk_tiktok_share_images \ + ktk_tiktok_abandon_pending_share; do rg -F "EntryPoint = \"$symbol\"" "$managed_source" >/dev/null \ || { echo "Missing static TikTok Share import: $symbol" >&2; exit 1; } done diff --git a/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift b/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift index fd06a8e..b8932be 100644 --- a/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift +++ b/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift @@ -16,8 +16,32 @@ private enum ShareStatus: Int32 { case failed = 3 } +private final class ShareSession { + let callback: KapuschTikTokShareCallback + let context: UnsafeMutableRawPointer + private(set) var didFinish = false + + init(callback: @escaping KapuschTikTokShareCallback, context: UnsafeMutableRawPointer) { + self.callback = callback + self.context = context + } + + func finish(status: ShareStatus, errorCode: String? = nil) { + guard !didFinish else { return } + didFinish = true + invoke(callback, status: status, errorCode: errorCode, context: context) + } +} + private final class ShareState { nonisolated(unsafe) static var request: TikTokShareRequest? + nonisolated(unsafe) static var session: ShareSession? + + static func clear(_ session: ShareSession) { + guard self.session === session else { return } + request = nil + self.session = nil + } } private func invoke( @@ -39,7 +63,7 @@ public func ktk_tiktok_share_images( _ callback: @escaping KapuschTikTokShareCallback, _ context: UnsafeMutableRawPointer ) { - guard ShareState.request == nil else { + guard ShareState.session == nil else { invoke(callback, status: .failed, errorCode: "already_in_progress", context: context) return } @@ -71,35 +95,43 @@ public func ktk_tiktok_share_images( redirectURI: redirectURI ) request.customConfig = .init(clientKey: clientKey, callerUrlScheme: urlScheme) + let session = ShareSession(callback: callback, context: context) ShareState.request = request + ShareState.session = session let sent = request.send { response in - defer { ShareState.request = nil } + defer { ShareState.clear(session) } guard let response = response as? TikTokShareResponse else { - invoke(callback, status: .failed, errorCode: "invalid_share_response", context: context) + session.finish(status: .failed, errorCode: "invalid_share_response") return } switch response.shareState { case .success: - invoke(callback, status: .success, context: context) + session.finish(status: .success) case .saveAsDraft: - invoke(callback, status: .draft, context: context) + session.finish(status: .draft) case .cancelled: - invoke(callback, status: .cancelled, context: context) + session.finish(status: .cancelled) default: - invoke( - callback, + session.finish( status: .failed, - errorCode: "tiktok_share_\(response.shareState.rawValue)_\(response.errorCode.rawValue)", - context: context + errorCode: "tiktok_share_\(response.shareState.rawValue)_\(response.errorCode.rawValue)" ) } } if !sent { - ShareState.request = nil - invoke(callback, status: .failed, errorCode: "share_request_not_sent", context: context) + session.finish(status: .failed, errorCode: "share_request_not_sent") + ShareState.clear(session) } } +@_cdecl("ktk_tiktok_abandon_pending_share") +public func ktk_tiktok_abandon_pending_share() -> Bool { + guard let session = ShareState.session else { return false } + session.finish(status: .cancelled, errorCode: "returned_without_callback") + ShareState.clear(session) + return true +} + @_cdecl("ktk_tiktok_handle_open_url") public func ktk_tiktok_handle_open_url(_ urlPtr: UnsafeMutableRawPointer) -> Bool { let url = Unmanaged.fromOpaque(urlPtr).takeUnretainedValue() as URL diff --git a/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs b/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs index 8a06daf..3697dc5 100644 --- a/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs +++ b/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs @@ -54,6 +54,8 @@ public static bool HandleOpenUrl(IntPtr nsUrlHandle) return HandleOpenUrlNative(nsUrlHandle) != 0; } + public static bool AbandonPendingShare() => AbandonPendingShareNative() != 0; + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static void ShareCallback(int status, IntPtr errorCode, IntPtr context) { @@ -83,4 +85,7 @@ IntPtr context [LibraryImport("__Internal", EntryPoint = "ktk_tiktok_handle_open_url")] private static partial byte HandleOpenUrlNative(IntPtr nsUrlHandle); + + [LibraryImport("__Internal", EntryPoint = "ktk_tiktok_abandon_pending_share")] + private static partial byte AbandonPendingShareNative(); } From 8c18c12c6eb3674837fa772c0583b9cfb1876f06 Mon Sep 17 00:00:00 2001 From: Kapusch <9576733+Kapusch@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:39:19 +0200 Subject: [PATCH 3/4] docs: document stalled share recovery --- Docs/Integration.md | 19 ++++++++++++++++++- samples/Kapusch.TikTok.iOS.Sample/README.md | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Docs/Integration.md b/Docs/Integration.md index 633202f..b870f5b 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -5,6 +5,23 @@ The wrapper rejects URLs that do not match the configured `TikTokURLScheme` or `TikTokRedirectURI`; host applications should still route only those URLs to the TikTok handler. -`ShareImagesAsync` expects one to twelve Photos local identifiers for image sharing. The host app is responsible for add-only Photos authorization and for explaining that the images remain in the photo library. +`ShareImagesAsync` expects one to twelve Photos local identifiers for image sharing. +TikTok OpenSDK 2.5.0 does not accept file paths or in-memory image data for this +flow. The host app is responsible for add-only Photos authorization and for +explaining that the images remain in the photo library. + +OpenSDK does not always return a callback when the user switches back to the host +app without completing or cancelling in TikTok. A host that waits for +`ShareImagesAsync` should: + +1. observe that it actually entered the background after sending the request; +2. observe the subsequent return to the active state; +3. leave a short grace period for the official callback; +4. call `NativeTikTokShare.AbandonPendingShare()` if the request is still pending. + +Abandoning completes the pending task once with `Cancelled` and the stable error +code `returned_without_callback`. A late OpenSDK callback is ignored. Do not call +this API for an activation event that was not preceded by the share handoff +entering the background. The official 2.5.0 Core and Share privacy manifests declare no collected data and no tracking. Audit the final IPA because the rest of the application can add other declarations. diff --git a/samples/Kapusch.TikTok.iOS.Sample/README.md b/samples/Kapusch.TikTok.iOS.Sample/README.md index f007e20..1e19167 100644 --- a/samples/Kapusch.TikTok.iOS.Sample/README.md +++ b/samples/Kapusch.TikTok.iOS.Sample/README.md @@ -28,4 +28,9 @@ var result = await NativeTikTokShare.ShareImagesAsync( Console.WriteLine($"TikTok share: {result.Status} {result.ErrorCode}"); ``` +If the host app returns from the background while this task remains pending, give +the SDK callback a short grace period and then call +`NativeTikTokShare.AbandonPendingShare()`. The task completes as `Cancelled` with +`returned_without_callback`, and any later SDK callback is ignored. + The SDK handoff and callback require an approved TikTok application and a physical device with TikTok installed. From 7b522c5d8dbfcf2dece081294ea4fb3f3720d349 Mon Sep 17 00:00:00 2001 From: Kapusch <9576733+Kapusch@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:02:00 +0200 Subject: [PATCH 4/4] fix(ci): support runners without ripgrep --- scripts/validate-package.sh | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/validate-package.sh b/scripts/validate-package.sh index 61e20a9..2708970 100755 --- a/scripts/validate-package.sh +++ b/scripts/validate-package.sh @@ -9,7 +9,18 @@ if [ ! -f "$package" ]; then exit 1 fi -if rg -F "NativeLibrary.GetExport" "$managed_source" >/dev/null; then +contains_fixed_text() { + local pattern="$1" + local file="$2" + + if command -v rg >/dev/null 2>&1; then + rg -F "$pattern" "$file" >/dev/null + else + grep -Fq -- "$pattern" "$file" + fi +} + +if contains_fixed_text "NativeLibrary.GetExport" "$managed_source"; then echo "TikTok Share must use static __Internal imports so iOS retains its native symbols." >&2 exit 1 fi @@ -18,7 +29,7 @@ for symbol in \ ktk_tiktok_handle_open_url \ ktk_tiktok_share_images \ ktk_tiktok_abandon_pending_share; do - rg -F "EntryPoint = \"$symbol\"" "$managed_source" >/dev/null \ + contains_fixed_text "EntryPoint = \"$symbol\"" "$managed_source" \ || { echo "Missing static TikTok Share import: $symbol" >&2; exit 1; } done