From 41250fbd463371309091520931065532d1cdedaf Mon Sep 17 00:00:00 2001 From: Preeternal Date: Mon, 20 Jul 2026 21:46:40 +0300 Subject: [PATCH 01/22] fix: normalize getFromResponse cookie handling --- CHANGELOG.md | 21 ++ README.md | 119 +++++--- android/src/main/AndroidManifest.xml | 1 + .../CookieManagerModule.kt | 276 +++++++++++++++++- ios/CookieManager.swift | 11 +- src/NativeCookieManager.ts | 4 + src/index.tsx | 4 + 7 files changed, 384 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a007ebf..76a7a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ --- +## v6.4.0 (Unreleased): response-cookie contract + +### Fixed + +- Android `getFromResponse()` now performs the inherited GET request instead of returning the input URL. +- Android applies `Set-Cookie` before each redirect and selects stored cookies for every destination URL instead of forwarding the original `Cookie` header. +- Both platforms return the declared `Cookies` object shape and update their native cookie store. +- On iOS, `getFromResponse()` values are now `Cookie` objects instead of raw strings; read the cookie value through `.value`. This aligns runtime behavior with the existing TypeScript contract. + +### Deprecated + +- `getFromResponse()` is deprecated but retained for compatibility with `@react-native-cookies/cookies`; no removal version is scheduled. +- Prefer making requests with Fetch/Axios and reading the shared native store with `get(url)`. This avoids a duplicate request and leaves request configuration with the application. + +### Documentation + +- Documented the network and cookie-store side effects of the legacy API. +- Added a concise Fetch/Axios response flow and clarified the advanced `setFromResponse()` use case. + +--- + ## v6.3.3: Strict cookie domain validation This release fixes inherited cookie domain validation behavior that accepted substring matches instead of RFC-style domain matches. diff --git a/README.md b/README.md index 114ee95..ef69da7 100644 --- a/README.md +++ b/README.md @@ -38,59 +38,82 @@ Supports both old (bridged) and New Architecture (TurboModule) builds out of the ## Usage +### After a network request + +React Native stores response cookies automatically. Make the request with your HTTP client, then read cookies matching the URL: + ```ts +import axios from 'axios'; import CookieManager from '@preeternal/react-native-cookie-manager'; -// Set a cookie -try { - await CookieManager.set('https://example.com', { - name: 'session', - value: 'abc123', - domain: 'example.com', - path: '/', - secure: true, - httpOnly: true, - }); - - // Set cookies from a Set-Cookie header - await CookieManager.setFromResponse( - 'https://example.com', - 'user_session=abcdefg; path=/; expires=Thu, 1 Jan 2030 00:00:00 -0000; secure; HttpOnly' - ); - - // Get cookies for a URL - const cookies = await CookieManager.get('https://example.com'); - - // iOS only: get all cookies - const allCookies = await CookieManager.getAll(); - - // Clear by name (iOS only) - await CookieManager.clearByName('https://example.com', 'session'); - - // Clear everything - await CookieManager.clearAll(); - - // Android only: persist to storage - await CookieManager.flush(); - - // Android only: remove session cookies (no expires) - await CookieManager.removeSessionCookies(); -} catch (err) { - console.warn('Cookie operation failed', err); -} +const url = 'https://example.com/login'; +await axios.get(url); +// Fetch alternative: await fetch(url); +const cookies = await CookieManager.get(url); +``` + +Standard React Native networking handles cookies by default. Credentials options are only needed if your client configuration explicitly disables cookie handling. `get()` only reads the native cookie store; it does not make a request. + +The upstream-compatible `getFromResponse(url)` remains available but is deprecated: it performs a separate GET, follows redirects, and updates the cookie store without options for headers, authentication, timeout, or cancellation. Prefer the flow above to avoid a duplicate request and its side effects. + +### Manage the cookie store + +```ts +await CookieManager.set('https://example.com', { + name: 'session', + value: 'abc123', + domain: 'example.com', + path: '/', + secure: true, + httpOnly: true, +}); + +const cookies = await CookieManager.get('https://example.com'); + +// iOS only: get all cookies +const allCookies = await CookieManager.getAll(); + +// Clear by name (iOS only) +await CookieManager.clearByName('https://example.com', 'session'); + +// Clear all cookies +await CookieManager.clearAll(); + +// Android only: persist cookies to storage +await CookieManager.flush(); + +// Android only: remove session cookies (cookies without an expiry date) +await CookieManager.removeSessionCookies(); +``` + +All methods return Promises and reject when an operation fails. + +### Import a Set-Cookie header + +`setFromResponse()` is an advanced API for importing a raw `Set-Cookie` header from a custom HTTP client that does not share React Native's cookie store. It is normally unnecessary with Fetch or Axios. Call it once for each `Set-Cookie` header value. + +```ts +await CookieManager.setFromResponse( + 'https://example.com', + 'session=abc123; Path=/; Secure; HttpOnly' +); ``` -## API (compatible with @react-native-cookies/cookies) - -- `set(url, cookie, useWebKit?)` -- `setFromResponse(url, cookieHeader)` -- `get(url, useWebKit?)` -- `getFromResponse(url)` -- `clearAll(useWebKit?)` -- `flush()` — Android -- `removeSessionCookies()` — Android -- `getAll(useWebKit?)` — iOS -- `clearByName(url, name, useWebKit?)` — iOS +## API + +The public API remains compatible with `@react-native-cookies/cookies`. + +| Method | Returns | Description | +| --- | --- | --- | +| `set(url, cookie, useWebKit?)` | `Promise` | Stores a cookie. | +| `get(url, useWebKit?)` | `Promise` | Reads stored cookies matching the URL; makes no request. | +| `clearAll(useWebKit?)` | `Promise` | Clears the selected cookie store. | +| `getAll(useWebKit?)` | `Promise` | Reads all cookies (iOS; rejects on Android). | +| `clearByName(url, name, useWebKit?)` | `Promise` | Clears matching cookies by name (iOS; rejects on Android). | +| `flush()` | `Promise` | Persists cookies to storage (Android; no-op on iOS). | +| `removeSessionCookies()` | `Promise` | Removes cookies without an expiry date (Android; returns `false` on iOS). | +| `setFromResponse(url, cookieHeader)` | `Promise` | Imports one raw `Set-Cookie` header value. | +| `getFromResponse(url)` | `Promise` | Deprecated; performs a GET and returns cookies from the final response. | `useWebKit` applies only to iOS (switches to `WKHTTPCookieStore`); on Android it is ignored because WebView and native share a single cookie store. diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index a2f47b6..f79cac1 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1,3 @@ + diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index e4df0ee..0cc6e76 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -12,12 +12,19 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule import java.lang.Exception import java.net.HttpCookie +import java.net.HttpURLConnection +import java.net.ProtocolException +import java.net.SocketTimeoutException import java.net.URL import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @ReactModule(name = CookieManagerModule.NAME) class CookieManagerModule(reactContext: ReactApplicationContext) : @@ -70,7 +77,21 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } override fun getFromResponse(url: String, promise: Promise) { - promise.resolve(url) + val parsedUrl = try { + URL(url) + } catch (e: Exception) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP, e) + return + } + + if (parsedUrl.host.isEmpty() || !isHttpScheme(parsedUrl.protocol)) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + NETWORK_EXECUTOR.execute { + fetchResponseCookies(parsedUrl, promise) + } } override fun getAll(useWebKit: Boolean?, promise: Promise) { @@ -141,6 +162,228 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : return allCookiesMap } + private fun fetchResponseCookies( + url: URL, + promise: Promise + ) { + var currentUrl = url + var redirectCount = 0 + + try { + while (true) { + var connection: HttpURLConnection? = null + try { + connection = currentUrl.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.instanceFollowRedirects = false + connection.connectTimeout = FETCH_TIMEOUT_MILLISECONDS + connection.readTimeout = FETCH_TIMEOUT_MILLISECONDS + + val requestCookieHeader = getCookieManager().getCookie(currentUrl.toString()) + if (!requestCookieHeader.isNullOrEmpty()) { + connection.setRequestProperty("Cookie", requestCookieHeader) + } + + // Accessing the status code performs the request. HTTP error statuses + // are still valid responses and may contain Set-Cookie headers. + val responseCode = connection.responseCode + val setCookieHeaders = getSetCookieHeaders(connection) + val redirectUrl = getRedirectUrl(currentUrl, connection, responseCode) + + if (redirectUrl != null) { + if (redirectCount >= MAX_REDIRECTS) { + throw ProtocolException("Too many redirects: ${redirectCount + 1}") + } + + // Apply redirect cookies before selecting cookies for the next URL. + // The callback confirms that WebView's cookie store has been updated. + storeResponseCookiesAndWait(currentUrl, setCookieHeaders) + currentUrl = redirectUrl + redirectCount += 1 + continue + } + + val responseCookies = parseResponseCookies(setCookieHeaders, currentUrl) + reactApplicationContext.runOnUiQueueThread { + storeResponseCookies(currentUrl, setCookieHeaders, responseCookies, promise) + } + return + } finally { + connection?.disconnect() + } + } + } catch (e: Exception) { + promise.reject("get_from_response_error", e) + } + } + + private fun getSetCookieHeaders(connection: HttpURLConnection): List = + connection.headerFields + .filterKeys { key -> key != null && isSetCookieHeader(key) } + .values + .flatten() + + private fun getRedirectUrl( + currentUrl: URL, + connection: HttpURLConnection, + responseCode: Int + ): URL? { + if (!isRedirectStatus(responseCode)) { + return null + } + + val location = connection.getHeaderField("Location") ?: return null + val redirectUrl = URL(currentUrl, location) + if (redirectUrl.host.isEmpty() || !isHttpScheme(redirectUrl.protocol)) { + throw ProtocolException("Unsupported redirect URL: $redirectUrl") + } + return redirectUrl + } + + private fun storeResponseCookiesAndWait(url: URL, headers: List) { + if (headers.isEmpty()) { + return + } + + val completionLatch = CountDownLatch(headers.size) + val storeError = AtomicReference(null) + + reactApplicationContext.runOnUiQueueThread { + try { + val cookieManager = getCookieManager() + for (header in headers) { + try { + cookieManager.setCookie(url.toString(), header) { + completionLatch.countDown() + } + } catch (e: Exception) { + storeError.compareAndSet(null, e) + completionLatch.countDown() + } + } + } catch (e: Exception) { + storeError.compareAndSet(null, e) + while (completionLatch.count > 0) { + completionLatch.countDown() + } + } + } + + if (!completionLatch.await(FETCH_TIMEOUT_MILLISECONDS.toLong(), TimeUnit.MILLISECONDS)) { + throw SocketTimeoutException("Timed out while storing redirect cookies") + } + storeError.get()?.let { throw it } + } + + private fun parseResponseCookies(headers: List, responseUrl: URL): List { + val parsedCookies = mutableListOf() + val parsedAt = System.currentTimeMillis() + val defaultPath = defaultCookiePath(responseUrl) + + for (header in headers) { + val cookies = try { + HttpCookie.parse(header) + } catch (e: IllegalArgumentException) { + Log.i(NAME, e.message ?: "Unable to parse Set-Cookie header") + continue + } + + for (cookie in cookies) { + val expiresAt = if (cookie.maxAge >= 0) { + try { + Math.addExact(parsedAt, Math.multiplyExact(cookie.maxAge, 1000L)) + } catch (_: ArithmeticException) { + null + } + } else { + null + } + + parsedCookies.add( + ResponseCookie( + name = cookie.name, + value = cookie.value, + domain = cookie.domain ?: responseUrl.host, + path = cookie.path ?: defaultPath, + version = cookie.version.toString(), + expiresAt = expiresAt, + secure = cookie.secure, + httpOnly = HTTP_ONLY_SUPPORTED && cookie.isHttpOnly + ) + ) + } + } + + return parsedCookies + } + + private fun storeResponseCookies( + responseUrl: URL, + headers: List, + cookies: List, + promise: Promise + ) { + val result = createResponseCookieList(cookies) + if (headers.isEmpty()) { + promise.resolve(result) + return + } + + var settled = false + try { + val cookieManager = getCookieManager() + var remaining = headers.size + + for (header in headers) { + cookieManager.setCookie(responseUrl.toString(), header) { + remaining -= 1 + if (remaining == 0 && !settled) { + settled = true + promise.resolve(result) + } + } + } + } catch (e: Exception) { + if (!settled) { + settled = true + promise.reject("get_from_response_error", e) + } + } + } + + private fun createResponseCookieList(cookies: List): WritableMap { + val result = Arguments.createMap() + + for (cookie in cookies) { + val cookieMap = Arguments.createMap() + cookieMap.putString("name", cookie.name) + cookieMap.putString("value", cookie.value) + cookieMap.putString("domain", cookie.domain) + cookieMap.putString("path", cookie.path) + cookieMap.putString("version", cookie.version) + cookieMap.putBoolean("secure", cookie.secure) + cookieMap.putBoolean("httpOnly", cookie.httpOnly) + cookie.expiresAt?.let { expiresAt -> + formatDate(Date(expiresAt))?.let { expires -> + cookieMap.putString("expires", expires) + } + } + result.putMap(cookie.name, cookieMap) + } + + return result + } + + private fun defaultCookiePath(url: URL): String { + val path = url.path + if (path.isNullOrEmpty() || !path.startsWith('/')) { + return "/" + } + + val lastSlash = path.lastIndexOf('/') + return if (lastSlash <= 0) "/" else path.substring(0, lastSlash) + } + @Throws(Exception::class) private fun makeHTTPCookieObject(url: String, cookie: ReadableMap): HttpCookie { val parsedUrl = try { @@ -234,6 +477,21 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : (normalizedHost == normalizedDomain || normalizedHost.endsWith(".$normalizedDomain")) } + private fun isHttpScheme(scheme: String): Boolean = + scheme.equals("http", ignoreCase = true) || scheme.equals("https", ignoreCase = true) + + private fun isSetCookieHeader(name: String): Boolean = + name.equals("Set-Cookie", ignoreCase = true) || + name.equals("Set-Cookie2", ignoreCase = true) + + private fun isRedirectStatus(statusCode: Int): Boolean = + statusCode == HttpURLConnection.HTTP_MULT_CHOICE || + statusCode == HttpURLConnection.HTTP_MOVED_PERM || + statusCode == HttpURLConnection.HTTP_MOVED_TEMP || + statusCode == HttpURLConnection.HTTP_SEE_OTHER || + statusCode == HTTP_TEMPORARY_REDIRECT || + statusCode == HTTP_PERMANENT_REDIRECT + private fun dateFormatter(): DateFormat { val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", Locale.US) df.timeZone = TimeZone.getTimeZone("GMT") @@ -299,6 +557,17 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : return builder.toString() } + private data class ResponseCookie( + val name: String, + val value: String, + val domain: String, + val path: String, + val version: String, + val expiresAt: Long?, + val secure: Boolean, + val httpOnly: Boolean + ) + companion object { private const val INVALID_URL_MISSING_HTTP = "Invalid URL: It may be missing a protocol (ex. http:// or https://)." @@ -307,9 +576,14 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : private const val CLEAR_BY_NAME_NOT_SUPPORTED = "Cannot remove a single cookie by name on Android" private const val INVALID_DOMAINS = "Cookie URL host %s and domain %s mismatched. The cookie won't set correctly." + private const val FETCH_TIMEOUT_MILLISECONDS = 60_000 + private const val MAX_REDIRECTS = 20 + private const val HTTP_TEMPORARY_REDIRECT = 307 + private const val HTTP_PERMANENT_REDIRECT = 308 private val USES_LEGACY_STORE = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP private val HTTP_ONLY_SUPPORTED = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + private val NETWORK_EXECUTOR = Executors.newCachedThreadPool() const val NAME = "CookieManager" } diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index 010202f..3be2d37 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -73,7 +73,12 @@ public class CookieManagerImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { - guard let parsedUrl = URL(string: url as String) else { + guard + let parsedUrl = URL(string: url as String), + let scheme = parsedUrl.scheme?.lowercased(), + ["http", "https"].contains(scheme), + parsedUrl.host != nil + else { reject("invalid_url", Self.invalidURLMissingHTTP, nil) return } @@ -94,9 +99,9 @@ public class CookieManagerImpl: NSObject { let responseURL = httpResponse.url ?? parsedUrl let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: responseURL) - var result: [String: String] = [:] + var result: [String: Any] = [:] cookies.forEach { cookie in - result[cookie.name] = cookie.value + result[cookie.name] = self.createCookieData(cookie) HTTPCookieStorage.shared.setCookie(cookie) } resolve(result) diff --git a/src/NativeCookieManager.ts b/src/NativeCookieManager.ts index 396da22..c7c46c6 100644 --- a/src/NativeCookieManager.ts +++ b/src/NativeCookieManager.ts @@ -17,6 +17,10 @@ export interface Spec extends TurboModule { setCookie(url: string, cookie: Cookie, useWebKit?: boolean): Promise; setFromResponse(url: string, cookie: string): Promise; getCookies(url: string, useWebKit?: boolean): Promise; + /** + * @deprecated Make the request with `fetch`/Axios and then call `get()`. + * The native name is retained for upstream API compatibility. + */ getFromResponse(url: string): Promise; clearAll(useWebKit?: boolean): Promise; flush(): Promise; diff --git a/src/index.tsx b/src/index.tsx index 6cb61ba..25d8929 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -21,6 +21,10 @@ const CookieManager = { : Promise.resolve(false), setFromResponse: (url: string, cookie: string) => CookieManagerNative.setFromResponse(url, cookie), + /** + * @deprecated Make the request with `fetch`/Axios and then call `get()`. + * This upstream-compatible method performs its own GET request. + */ getFromResponse: (url: string) => CookieManagerNative.getFromResponse(url), }; From 13835c499091a3f23450381dd9f51576aa096a72 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Mon, 20 Jul 2026 21:52:22 +0300 Subject: [PATCH 02/22] docs: axios changes --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ef69da7..183725b 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,11 @@ Supports both old (bridged) and New Architecture (TurboModule) builds out of the React Native stores response cookies automatically. Make the request with your HTTP client, then read cookies matching the URL: ```ts -import axios from 'axios'; import CookieManager from '@preeternal/react-native-cookie-manager'; const url = 'https://example.com/login'; -await axios.get(url); -// Fetch alternative: await fetch(url); +await fetch(url); +// axios alternative: await axios(url);; const cookies = await CookieManager.get(url); ``` From 9664b85226754904cfdef0b1983cf1f1a4e7e026 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Mon, 20 Jul 2026 21:53:25 +0300 Subject: [PATCH 03/22] fix: docs typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 183725b..0fbb8ad 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ import CookieManager from '@preeternal/react-native-cookie-manager'; const url = 'https://example.com/login'; await fetch(url); -// axios alternative: await axios(url);; +// axios alternative: await axios(url); const cookies = await CookieManager.get(url); ``` From 02eafbdcda24cad8b4b35543dcdfe4a99eaac6ba Mon Sep 17 00:00:00 2001 From: Preeternal Date: Mon, 20 Jul 2026 22:54:49 +0300 Subject: [PATCH 04/22] fix(android): resolve cookie mutations after flush --- CHANGELOG.md | 3 ++- .../CookieManagerModule.kt | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a7a4d..f0fa887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ --- -## v6.4.0 (Unreleased): response-cookie contract +## v6.4.0 (Unreleased) ### Fixed @@ -10,6 +10,7 @@ - Android applies `Set-Cookie` before each redirect and selects stored cookies for every destination URL instead of forwarding the original `Cookie` header. - Both platforms return the declared `Cookies` object shape and update their native cookie store. - On iOS, `getFromResponse()` values are now `Cookie` objects instead of raw strings; read the cookie value through `.value`. This aligns runtime behavior with the existing TypeScript contract. +- Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. ### Deprecated diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index 0cc6e76..d2babe5 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -105,8 +105,9 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : override fun clearAll(useWebKit: Boolean?, promise: Promise) { try { val cookieManager = getCookieManager() - cookieManager.removeAllCookies { promise.resolve(it) } - cookieManager.flush() + cookieManager.removeAllCookies { + flushAndResolve(cookieManager, it, promise, "clear_all_error") + } } catch (e: Exception) { promise.reject("clear_all_error", e) } @@ -132,13 +133,30 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : private fun addCookies(url: String, cookieString: String, promise: Promise) { try { val cookieManager = getCookieManager() - cookieManager.setCookie(url, cookieString) { promise.resolve(it) } - cookieManager.flush() + cookieManager.setCookie(url, cookieString) { + flushAndResolve(cookieManager, it, promise, "add_cookie_error") + } } catch (e: Exception) { promise.reject("add_cookie_error", e) } } + private fun flushAndResolve( + cookieManager: CookieManager, + result: Boolean, + promise: Promise, + errorCode: String + ) { + PERSISTENCE_EXECUTOR.execute { + try { + cookieManager.flush() + promise.resolve(result) + } catch (e: Exception) { + promise.reject(errorCode, e) + } + } + } + private fun createCookieList(allCookies: String?): WritableMap { val allCookiesMap = Arguments.createMap() @@ -584,6 +602,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : private val USES_LEGACY_STORE = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP private val HTTP_ONLY_SUPPORTED = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N private val NETWORK_EXECUTOR = Executors.newCachedThreadPool() + private val PERSISTENCE_EXECUTOR = Executors.newSingleThreadExecutor() const val NAME = "CookieManager" } From 8c1623615c9384aecbdb0d4833a836f74701aa9a Mon Sep 17 00:00:00 2001 From: Preeternal Date: Mon, 20 Jul 2026 23:00:43 +0300 Subject: [PATCH 05/22] fix(ios): await WebKit clearByName deletions --- CHANGELOG.md | 1 + ios/CookieManager.swift | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fa887..d311a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Both platforms return the declared `Cookies` object shape and update their native cookie store. - On iOS, `getFromResponse()` values are now `Cookie` objects instead of raw strings; read the cookie value through `.value`. This aligns runtime behavior with the existing TypeScript contract. - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. +- Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. ### Deprecated diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index 3be2d37..dcca1a7 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -205,11 +205,30 @@ public class CookieManagerImpl: NSObject { DispatchQueue.main.async { WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in let store = WKWebsiteDataStore.default().httpCookieStore - for cookie in allCookies where cookie.name == name && self.isMatchingDomain(originDomain: topLevelDomain, cookieDomain: cookie.domain) { - store.delete(cookie, completionHandler: nil) - found = true + let matchingCookies = allCookies.filter { cookie in + cookie.name == name && + self.isMatchingDomain( + originDomain: topLevelDomain, + cookieDomain: cookie.domain + ) + } + + guard !matchingCookies.isEmpty else { + resolve(false) + return + } + + let deletionGroup = DispatchGroup() + for cookie in matchingCookies { + deletionGroup.enter() + store.delete(cookie) { + deletionGroup.leave() + } + } + + deletionGroup.notify(queue: .main) { + resolve(true) } - resolve(found) } } } else { From 6216083e59e815952d47c4b90706859d96ac4114 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 13:01:27 +0300 Subject: [PATCH 06/22] fix(android): return stored cookie attributes from get --- CHANGELOG.md | 9 ++ README.md | 2 + android/build.gradle | 3 + .../CookieManagerModule.kt | 60 +++---- .../CookieReadLogic.kt | 113 +++++++++++++ .../CookieReadLogicTest.kt | 152 ++++++++++++++++++ 6 files changed, 301 insertions(+), 38 deletions(-) create mode 100644 android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt create mode 100644 android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d311a85..6387ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ - On iOS, `getFromResponse()` values are now `Cookie` objects instead of raw strings; read the cookie value through `.value`. This aligns runtime behavior with the existing TypeScript contract. - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. - Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. +- Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews transparently retain the previous name/value-only behavior. + +### Tests + +- Added Android unit coverage for the detailed cookie read, both fallback paths, empty results, attribute parsing, expiration precedence/conversion, and legacy behavior. + +### Compatibility + +- Android now depends on `androidx.webkit:webkit:1.16.0` (`minSdk 24`, `compileSdk 33+`, and Android Gradle Plugin 7.2+); the library defaults remain `minSdk 24`, `compileSdk 36`, and AGP 8.7.2. ### Deprecated diff --git a/README.md b/README.md index 0fbb8ad..af621e0 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ type Cookie = { }; ``` +On Android, metadata is populated when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews fall back to legacy name/value parsing, so `domain`, `path`, and `expires` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. + ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) diff --git a/android/build.gradle b/android/build.gradle index fbe828c..7f11f12 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -64,4 +64,7 @@ android { dependencies { implementation "com.facebook.react:react-android" + implementation "androidx.webkit:webkit:1.16.0" + + testImplementation "junit:junit:4.13.2" } diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index d2babe5..c89d803 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -4,6 +4,8 @@ import android.os.Build import android.util.Log import android.webkit.CookieManager import android.webkit.ValueCallback +import androidx.webkit.CookieManagerCompat +import androidx.webkit.WebViewFeature import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise @@ -69,8 +71,13 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } try { - val cookiesString = getCookieManager().getCookie(url) - promise.resolve(createCookieList(cookiesString)) + val cookieManager = getCookieManager() + val cookieHeaders = readCookieHeaders( + supportsDetailedRead = WebViewFeature.isFeatureSupported(WebViewFeature.GET_COOKIE_INFO), + detailedReader = { CookieManagerCompat.getCookieInfo(cookieManager, url) }, + legacyReader = { cookieManager.getCookie(url) } + ) + promise.resolve(createCookieList(cookieHeaders)) } catch (e: Exception) { promise.reject("get_cookie_error", e) } @@ -157,24 +164,23 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } } - private fun createCookieList(allCookies: String?): WritableMap { + private fun createCookieList(cookieReadResult: CookieReadResult): WritableMap { val allCookiesMap = Arguments.createMap() - if (!allCookies.isNullOrEmpty()) { - val cookieHeaders = allCookies.split(";") - for (singleCookie in cookieHeaders) { - val cookies = HttpCookie.parse(singleCookie) - for (cookie in cookies) { - if (cookie != null) { - val name = cookie.name - val value = cookie.value - if (!isEmpty(name) && !isEmpty(value)) { - val cookieMap = createCookieData(cookie) - allCookiesMap.putMap(name, cookieMap) - } - } + for (cookie in parseCookieReadResult(cookieReadResult)) { + val cookieMap = Arguments.createMap() + cookieMap.putString("name", cookie.name) + cookieMap.putString("value", cookie.value) + cookieMap.putString("domain", cookie.domain) + cookieMap.putString("path", cookie.path) + cookieMap.putBoolean("secure", cookie.secure) + cookieMap.putBoolean("httpOnly", cookie.httpOnly) + cookie.expiresAt?.let { expiresAt -> + formatDate(Date(expiresAt))?.let { expires -> + cookieMap.putString("expires", expires) } } + allCookiesMap.putMap(cookie.name, cookieMap) } return allCookiesMap @@ -455,28 +461,6 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : return cookieBuilder } - private fun createCookieData(cookie: HttpCookie): WritableMap { - val cookieMap = Arguments.createMap() - cookieMap.putString("name", cookie.name) - cookieMap.putString("value", cookie.value) - cookieMap.putString("domain", cookie.domain) - cookieMap.putString("path", cookie.path) - cookieMap.putBoolean("secure", cookie.secure) - if (HTTP_ONLY_SUPPORTED) { - cookieMap.putBoolean("httpOnly", cookie.isHttpOnly) - } - - val expires = cookie.maxAge - if (expires > 0) { - val expiry = formatDate(Date(expires)) - if (!isEmpty(expiry)) { - cookieMap.putString("expires", expiry) - } - } - - return cookieMap - } - private fun getCookieManager(): CookieManager { val cookieManager = CookieManager.getInstance() cookieManager.setAcceptCookie(true) diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt new file mode 100644 index 0000000..d890283 --- /dev/null +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt @@ -0,0 +1,113 @@ +package com.preeternal.reactnativecookiemanager + +import java.net.HttpCookie +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +internal sealed interface CookieReadResult { + data class Detailed(val headers: List) : CookieReadResult + + data class Legacy(val header: String?) : CookieReadResult +} + +internal data class ParsedCookie( + val name: String, + val value: String, + val domain: String?, + val path: String?, + val expiresAt: Long?, + val secure: Boolean, + val httpOnly: Boolean +) + +internal fun readCookieHeaders( + supportsDetailedRead: Boolean, + detailedReader: () -> List, + legacyReader: () -> String? +): CookieReadResult { + if (supportsDetailedRead) { + try { + return CookieReadResult.Detailed(detailedReader()) + } catch (_: UnsupportedOperationException) { + // The installed WebView is the final authority for feature support. + } + } + + return CookieReadResult.Legacy(legacyReader()) +} + +internal fun parseCookieReadResult( + result: CookieReadResult, + parsedAtMillis: Long = System.currentTimeMillis() +): List { + val headers = when (result) { + is CookieReadResult.Detailed -> result.headers + is CookieReadResult.Legacy -> result.header?.split(';') ?: emptyList() + } + val hasAttributes = result is CookieReadResult.Detailed + + return headers.flatMap { header -> + if (header.isBlank()) { + emptyList() + } else { + HttpCookie.parse(header).mapNotNull { cookie -> + if (cookie.name.isEmpty() || cookie.value.isEmpty()) { + null + } else { + ParsedCookie( + name = cookie.name, + value = cookie.value, + domain = cookie.domain, + path = cookie.path, + expiresAt = if (hasAttributes) { + expirationTime(header, parsedAtMillis, cookie.maxAge) + } else { + null + }, + secure = cookie.secure, + httpOnly = cookie.isHttpOnly + ) + } + } + } + } +} + +private fun expirationTime( + header: String, + parsedAtMillis: Long, + maxAgeSeconds: Long +): Long? { + val maxAgeAttribute = MAX_AGE_ATTRIBUTE.find(header)?.groupValues?.get(1)?.trim() + if (maxAgeAttribute?.toLongOrNull() == null) { + val expiresAttribute = EXPIRES_ATTRIBUTE.find(header)?.groupValues?.get(1)?.trim() + parseExpires(expiresAttribute)?.let { return it } + } + + if (maxAgeSeconds < 0) return null + + return try { + Math.addExact(parsedAtMillis, Math.multiplyExact(maxAgeSeconds, 1000L)) + } catch (_: ArithmeticException) { + null + } +} + +private fun parseExpires(value: String?): Long? { + if (value.isNullOrEmpty()) return null + + return try { + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { + isLenient = false + timeZone = TimeZone.getTimeZone("GMT") + }.parse(value)?.time + } catch (_: Exception) { + null + } +} + +private val MAX_AGE_ATTRIBUTE = + Regex("(?:^|;)\\s*max-age\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) +private val EXPIRES_ATTRIBUTE = + Regex("(?:^|;)\\s*expires\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt new file mode 100644 index 0000000..85f5c54 --- /dev/null +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt @@ -0,0 +1,152 @@ +package com.preeternal.reactnativecookiemanager + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class CookieReadLogicTest { + @Test + fun usesDetailedReaderWhenSupported() { + var legacyReaderCalled = false + + val result = readCookieHeaders( + supportsDetailedRead = true, + detailedReader = { listOf("session=abc; domain=example.com; path=/") }, + legacyReader = { + legacyReaderCalled = true + "legacy=value" + } + ) + + assertEquals( + CookieReadResult.Detailed(listOf("session=abc; domain=example.com; path=/")), + result + ) + assertFalse(legacyReaderCalled) + } + + @Test + fun usesLegacyReaderWhenDetailedReadIsUnsupported() { + var detailedReaderCalled = false + + val result = readCookieHeaders( + supportsDetailedRead = false, + detailedReader = { + detailedReaderCalled = true + emptyList() + }, + legacyReader = { "session=abc; theme=dark" } + ) + + assertEquals(CookieReadResult.Legacy("session=abc; theme=dark"), result) + assertFalse(detailedReaderCalled) + } + + @Test + fun fallsBackWhenWebViewRejectsDetailedRead() { + val result = readCookieHeaders( + supportsDetailedRead = true, + detailedReader = { throw UnsupportedOperationException("Not supported") }, + legacyReader = { "session=abc" } + ) + + assertEquals(CookieReadResult.Legacy("session=abc"), result) + } + + @Test + fun doesNotFallbackWhenDetailedReadReturnsNoCookies() { + var legacyReaderCalled = false + + val result = readCookieHeaders( + supportsDetailedRead = true, + detailedReader = { emptyList() }, + legacyReader = { + legacyReaderCalled = true + "legacy=value" + } + ) + + assertEquals(CookieReadResult.Detailed(emptyList()), result) + assertFalse(legacyReaderCalled) + } + + @Test + fun parsesDetailedCookieAttributes() { + val parsedAt = 1_700_000_000_000L + val result = CookieReadResult.Detailed( + listOf( + "session=abc; domain=.example.com; path=/account; max-age=3600; " + + "secure; httponly; partitioned; samesite=lax" + ) + ) + + val cookies = parseCookieReadResult(result, parsedAt) + + assertEquals(1, cookies.size) + assertEquals("session", cookies[0].name) + assertEquals("abc", cookies[0].value) + assertEquals(".example.com", cookies[0].domain) + assertEquals("/account", cookies[0].path) + assertEquals(parsedAt + 3_600_000L, cookies[0].expiresAt) + assertTrue(cookies[0].secure) + assertTrue(cookies[0].httpOnly) + } + + @Test + fun convertsExpiresAttributeToAbsoluteTime() { + val parsedAt = System.currentTimeMillis() + val expectedExpiresAt = ((parsedAt + 3_600_000L) / 1000L) * 1000L + val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + val expires = formatter.format(Date(expectedExpiresAt)) + + val cookie = parseCookieReadResult( + CookieReadResult.Detailed( + listOf("session=abc; domain=example.com; path=/; expires=$expires") + ), + parsedAt + ).single() + + assertEquals(expectedExpiresAt, cookie.expiresAt) + } + + @Test + fun maxAgeTakesPrecedenceOverExpires() { + val parsedAt = 1_700_000_000_000L + val cookie = parseCookieReadResult( + CookieReadResult.Detailed( + listOf( + "session=abc; domain=example.com; path=/; max-age=60; " + + "expires=Wed, 09 Jun 2032 10:18:14 GMT" + ) + ), + parsedAt + ).single() + + assertEquals(parsedAt + 60_000L, cookie.expiresAt) + } + + @Test + fun legacyHeaderKeepsPreviousNameValueOnlyBehavior() { + val cookies = parseCookieReadResult( + CookieReadResult.Legacy("session=abc; theme=dark") + ) + + assertEquals(listOf("session", "theme"), cookies.map { it.name }) + assertEquals(listOf("abc", "dark"), cookies.map { it.value }) + for (cookie in cookies) { + assertNull(cookie.domain) + assertNull(cookie.path) + assertNull(cookie.expiresAt) + assertFalse(cookie.secure) + assertFalse(cookie.httpOnly) + } + } +} From 6763cfd3840fd80f180bb931f06ea25988fc6f90 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 14:33:34 +0300 Subject: [PATCH 07/22] fix: make removeSessionCookies cross-platform and deterministic --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 + Package.swift | 23 ++- README.md | 6 +- .../CookieManagerModule.kt | 11 +- ios/CookieManager.mm | 26 ++- ios/CookieManager.swift | 48 +++-- ios/CookieSessionLogic.swift | 75 ++++++++ src/NativeCookieManager.ts | 5 +- src/index.tsx | 28 ++- .../CookieSessionLogicTests.swift | 179 ++++++++++++++++++ 11 files changed, 370 insertions(+), 35 deletions(-) create mode 100644 ios/CookieSessionLogic.swift create mode 100644 swift-tests/CookieSessionLogicTests/CookieSessionLogicTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef8215..a565f66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,7 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Run Swift domain tests + - name: Run Swift tests run: | CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/clang-module-cache" \ SWIFT_MODULECACHE_PATH="$RUNNER_TEMP/swift-module-cache" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6387ef4..3721990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,12 @@ - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. - Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. - Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews transparently retain the previous name/value-only behavior. +- `removeSessionCookies()` now works on iOS and resolves only after session cookies have been removed from both Foundation and the default WebKit store; persistent cookies remain untouched. Both stores are cleared by default, with the additive `iosCookieStore` option available for selecting only `foundation` or `webKit`. On Android, awaiting it now also guarantees that its automatic `flush()` has completed, so an immediate shutdown cannot leave removed session cookies on disk. ### Tests - Added Android unit coverage for the detailed cookie read, both fallback paths, empty results, attribute parsing, expiration precedence/conversion, and legacy behavior. +- Added Swift coverage for Foundation-only, WebKit-only, and both-store session cleanup, persistent-cookie preservation, and asynchronous deletion completion ordering. ### Compatibility diff --git a/Package.swift b/Package.swift index 0ca4496..67a1428 100644 --- a/Package.swift +++ b/Package.swift @@ -11,13 +11,34 @@ let package = Package( .target( name: "CookieDomainLogic", path: "ios", - exclude: ["CookieManager.h", "CookieManager.mm", "CookieManager.swift"], + exclude: [ + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + ], sources: ["CookieDomainLogic.swift"] ), + .target( + name: "CookieSessionLogic", + path: "ios", + exclude: [ + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + ], + sources: ["CookieSessionLogic.swift"] + ), .testTarget( name: "CookieDomainLogicTests", dependencies: ["CookieDomainLogic"], path: "swift-tests/CookieDomainLogicTests" ), + .testTarget( + name: "CookieSessionLogicTests", + dependencies: ["CookieSessionLogic"], + path: "swift-tests/CookieSessionLogicTests" + ), ] ) diff --git a/README.md b/README.md index 9faa622..81cda47 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ await CookieManager.clearAll(); // Android only: persist cookies to storage await CookieManager.flush(); -// Android only: remove session cookies (cookies without an expiry date) +// Remove session cookies (cookies without an expiry date) await CookieManager.removeSessionCookies(); ``` @@ -110,12 +110,14 @@ The public API remains compatible with `@react-native-cookies/cookies`. | `getAll(useWebKit?)` | `Promise` | Reads all cookies (iOS; rejects on Android). | | `clearByName(url, name, useWebKit?)` | `Promise` | Clears matching cookies by name (iOS; rejects on Android). | | `flush()` | `Promise` | Persists cookies to storage (Android; no-op on iOS). | -| `removeSessionCookies()` | `Promise` | Removes cookies without an expiry date (Android; returns `false` on iOS). | +| `removeSessionCookies(options?)` | `Promise` | Removes cookies without an expiry date (Android store; Foundation and default WebKit stores on iOS). | | `setFromResponse(url, cookieHeader)` | `Promise` | Imports one raw `Set-Cookie` header value. | | `getFromResponse(url)` | `Promise` | Deprecated; performs a GET and returns cookies from the final response. | `useWebKit` applies only to iOS (switches to `WKHTTPCookieStore`); on Android it is ignored because WebView and native share a single cookie store. +`removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. + ### WebKit on iOS - iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index c89d803..9da5741 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -129,9 +129,16 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } } - override fun removeSessionCookies(promise: Promise) { + override fun removeSessionCookies( + iosClearFoundation: Boolean, + iosClearWebKit: Boolean, + promise: Promise + ) { try { - getCookieManager().removeSessionCookies { promise.resolve(it) } + val cookieManager = getCookieManager() + cookieManager.removeSessionCookies { + flushAndResolve(cookieManager, it, promise, "remove_session_error") + } } catch (e: Exception) { promise.reject("remove_session_error", e) } diff --git a/ios/CookieManager.mm b/ios/CookieManager.mm index fb952c3..b9edfb7 100644 --- a/ios/CookieManager.mm +++ b/ios/CookieManager.mm @@ -94,10 +94,15 @@ - (void)handleFlushWithResolve:(RCTPromiseResolveBlock)resolve [_impl flushWithResolve:resolve reject:reject]; } -- (void)handleRemoveSessionCookies:(RCTPromiseResolveBlock)resolve +- (void)handleRemoveSessionCookies:(BOOL)clearFoundation + clearWebKit:(BOOL)clearWebKit + resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - [_impl removeSessionCookiesWithResolve:resolve reject:reject]; + [_impl removeSessionCookiesWithClearFoundation:clearFoundation + clearWebKit:clearWebKit + resolve:resolve + reject:reject]; } #if RCT_NEW_ARCH_ENABLED @@ -192,9 +197,15 @@ - (void)flush:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reje [self handleFlushWithResolve:resolve reject:reject]; } -- (void)removeSessionCookies:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +- (void)removeSessionCookies:(BOOL)iosClearFoundation + iosClearWebKit:(BOOL)iosClearWebKit + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [self handleRemoveSessionCookies:resolve reject:reject]; + [self handleRemoveSessionCookies:iosClearFoundation + clearWebKit:iosClearWebKit + resolve:resolve + reject:reject]; } - (std::shared_ptr)getTurboModule: @@ -279,10 +290,15 @@ - (void)removeSessionCookies:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseR } RCT_EXPORT_METHOD(removeSessionCookies + : (BOOL)iosClearFoundation iosClearWebKit + : (BOOL)iosClearWebKit resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { - [self handleRemoveSessionCookies:resolve reject:reject]; + [self handleRemoveSessionCookies:iosClearFoundation + clearWebKit:iosClearWebKit + resolve:resolve + reject:reject]; } #endif diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index dcca1a7..50c38b4 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -275,33 +275,43 @@ public class CookieManagerImpl: NSObject { resolve(true) } - @objc(removeSessionCookiesWithResolve:reject:) + @objc(removeSessionCookiesWithClearFoundation:clearWebKit:resolve:reject:) public func removeSessionCookies( + clearFoundation: Bool, + clearWebKit: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { - var removed = false let storage = HTTPCookieStorage.shared - storage.cookies?.forEach { cookie in - if cookie.isSessionOnly || cookie.expiresDate == nil { + CookieSessionLogic.removeSessionCookies( + foundationCookies: storage.cookies ?? [], + clearFoundation: clearFoundation, + clearWebKit: clearWebKit, + deleteFoundation: { cookie in storage.deleteCookie(cookie) - removed = true - } - } - - if #available(iOS 11.0, *) { - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in - var updatedRemoved = removed - let store = WKWebsiteDataStore.default().httpCookieStore - for cookie in cookies where cookie.expiresDate == nil { - store.delete(cookie, completionHandler: nil) - updatedRemoved = true - } - resolve(updatedRemoved) + }, + loadWebKitCookies: { completion in + guard #available(iOS 11.0, *) else { + completion([]) + return + } + DispatchQueue.main.async { + WKWebsiteDataStore.default().httpCookieStore.getAllCookies(completion) + } + }, + deleteWebKitCookie: { cookie, completion in + guard #available(iOS 11.0, *) else { + completion() + return + } + DispatchQueue.main.async { + WKWebsiteDataStore.default().httpCookieStore.delete( + cookie, + completionHandler: completion + ) } } - } else { + ) { removed in resolve(removed) } } diff --git a/ios/CookieSessionLogic.swift b/ios/CookieSessionLogic.swift new file mode 100644 index 0000000..cac1b01 --- /dev/null +++ b/ios/CookieSessionLogic.swift @@ -0,0 +1,75 @@ +import Foundation + +enum CookieSessionLogic { + static func isSessionCookie(_ cookie: HTTPCookie) -> Bool { + cookie.isSessionOnly || cookie.expiresDate == nil + } + + static func sessionCookies( + from cookies: [HTTPCookie], + enabled: Bool = true + ) -> [HTTPCookie] { + guard enabled else { return [] } + return cookies.filter(isSessionCookie) + } + + static func removeSessionCookies( + foundationCookies: [HTTPCookie], + clearFoundation: Bool, + clearWebKit: Bool, + deleteFoundation: (_ cookie: HTTPCookie) -> Void, + loadWebKitCookies: (_ completion: @escaping ([HTTPCookie]) -> Void) -> Void, + deleteWebKitCookie: @escaping ( + _ cookie: HTTPCookie, + _ completion: @escaping () -> Void + ) -> Void, + completion: @escaping (_ removed: Bool) -> Void + ) { + let foundationSessionCookies = sessionCookies( + from: foundationCookies, + enabled: clearFoundation + ) + for cookie in foundationSessionCookies { + deleteFoundation(cookie) + } + let removedFromFoundation = !foundationSessionCookies.isEmpty + + guard clearWebKit else { + completion(removedFromFoundation) + return + } + + loadWebKitCookies { webKitCookies in + removeSessionCookies( + from: webKitCookies, + delete: deleteWebKitCookie + ) { removedFromWebKit in + completion(removedFromFoundation || removedFromWebKit) + } + } + } + + static func removeSessionCookies( + from cookies: [HTTPCookie], + delete: (_ cookie: HTTPCookie, _ completion: @escaping () -> Void) -> Void, + completion: @escaping (_ removed: Bool) -> Void + ) { + let sessionCookies = sessionCookies(from: cookies) + guard !sessionCookies.isEmpty else { + completion(false) + return + } + + let deletionGroup = DispatchGroup() + for cookie in sessionCookies { + deletionGroup.enter() + delete(cookie) { + deletionGroup.leave() + } + } + + deletionGroup.notify(queue: .main) { + completion(true) + } + } +} diff --git a/src/NativeCookieManager.ts b/src/NativeCookieManager.ts index c7c46c6..8ee8062 100644 --- a/src/NativeCookieManager.ts +++ b/src/NativeCookieManager.ts @@ -24,7 +24,10 @@ export interface Spec extends TurboModule { getFromResponse(url: string): Promise; clearAll(useWebKit?: boolean): Promise; flush(): Promise; - removeSessionCookies(): Promise; + removeSessionCookies( + iosClearFoundation: boolean, + iosClearWebKit: boolean + ): Promise; getAll(useWebKit?: boolean): Promise; clearByName(url: string, name: string, useWebKit?: boolean): Promise; } diff --git a/src/index.tsx b/src/index.tsx index 25d8929..335987f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,29 @@ import CookieManagerNative, { type Cookies, } from './NativeCookieManager'; +export type IOSCookieStore = 'foundation' | 'webKit' | 'both'; + +export type RemoveSessionCookiesOptions = { + iosCookieStore?: IOSCookieStore; +}; + +const removeSessionCookies = ( + options: RemoveSessionCookiesOptions = {} +): Promise => { + switch (options.iosCookieStore ?? 'both') { + case 'foundation': + return CookieManagerNative.removeSessionCookies(true, false); + case 'webKit': + return CookieManagerNative.removeSessionCookies(false, true); + case 'both': + return CookieManagerNative.removeSessionCookies(true, true); + default: + return Promise.reject( + new Error('iosCookieStore must be "foundation", "webKit", or "both"') + ); + } +}; + const CookieManager = { getAll: (useWebKit = false) => CookieManagerNative.getAll(useWebKit), clearAll: (useWebKit = false) => CookieManagerNative.clearAll(useWebKit), @@ -15,10 +38,7 @@ const CookieManager = { CookieManagerNative.clearByName(url, name, useWebKit), flush: () => Platform.OS === 'android' ? CookieManagerNative.flush() : Promise.resolve(), - removeSessionCookies: () => - Platform.OS === 'android' - ? CookieManagerNative.removeSessionCookies() - : Promise.resolve(false), + removeSessionCookies, setFromResponse: (url: string, cookie: string) => CookieManagerNative.setFromResponse(url, cookie), /** diff --git a/swift-tests/CookieSessionLogicTests/CookieSessionLogicTests.swift b/swift-tests/CookieSessionLogicTests/CookieSessionLogicTests.swift new file mode 100644 index 0000000..2843cfe --- /dev/null +++ b/swift-tests/CookieSessionLogicTests/CookieSessionLogicTests.swift @@ -0,0 +1,179 @@ +import XCTest +@testable import CookieSessionLogic + +final class CookieSessionLogicTests: XCTestCase { + func testFoundationOnlyDoesNotLoadOrDeleteWebKitCookies() { + let foundationSessionCookie = makeCookie(name: "foundation-session") + let webKitSessionCookie = makeCookie(name: "webkit-session") + var deletedFoundationNames: [String] = [] + var webKitLoaded = false + var deletedWebKitNames: [String] = [] + var result: Bool? + + CookieSessionLogic.removeSessionCookies( + foundationCookies: [foundationSessionCookie], + clearFoundation: true, + clearWebKit: false, + deleteFoundation: { cookie in + deletedFoundationNames.append(cookie.name) + }, + loadWebKitCookies: { completion in + webKitLoaded = true + completion([webKitSessionCookie]) + }, + deleteWebKitCookie: { cookie, completion in + deletedWebKitNames.append(cookie.name) + completion() + } + ) { removed in + result = removed + } + + XCTAssertEqual(deletedFoundationNames, ["foundation-session"]) + XCTAssertFalse(webKitLoaded) + XCTAssertTrue(deletedWebKitNames.isEmpty) + XCTAssertEqual(result, true) + } + + func testWebKitOnlyKeepsFoundationCookies() { + let foundationSessionCookie = makeCookie(name: "foundation-session") + let webKitSessionCookie = makeCookie(name: "webkit-session") + var deletedFoundationNames: [String] = [] + var deletedWebKitNames: [String] = [] + var result: Bool? + let completionExpectation = expectation(description: "WebKit cookies removed") + + CookieSessionLogic.removeSessionCookies( + foundationCookies: [foundationSessionCookie], + clearFoundation: false, + clearWebKit: true, + deleteFoundation: { cookie in + deletedFoundationNames.append(cookie.name) + }, + loadWebKitCookies: { completion in + completion([webKitSessionCookie]) + }, + deleteWebKitCookie: { cookie, completion in + deletedWebKitNames.append(cookie.name) + completion() + } + ) { removed in + result = removed + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 1) + XCTAssertTrue(deletedFoundationNames.isEmpty) + XCTAssertEqual(deletedWebKitNames, ["webkit-session"]) + XCTAssertEqual(result, true) + } + + func testBothStoresDeleteSessionCookiesAndKeepPersistentCookies() { + let foundationSessionCookie = makeCookie(name: "foundation-session") + let foundationPersistentCookie = makeCookie( + name: "foundation-persistent", + expires: Date(timeIntervalSinceNow: 3_600) + ) + let webKitSessionCookie = makeCookie(name: "webkit-session") + let webKitPersistentCookie = makeCookie( + name: "webkit-persistent", + expires: Date(timeIntervalSinceNow: 3_600) + ) + var deletedFoundationNames: [String] = [] + var deletedWebKitNames: [String] = [] + var result: Bool? + let completionExpectation = expectation(description: "both stores cleared") + + CookieSessionLogic.removeSessionCookies( + foundationCookies: [foundationSessionCookie, foundationPersistentCookie], + clearFoundation: true, + clearWebKit: true, + deleteFoundation: { cookie in + deletedFoundationNames.append(cookie.name) + }, + loadWebKitCookies: { completion in + completion([webKitSessionCookie, webKitPersistentCookie]) + }, + deleteWebKitCookie: { cookie, completion in + deletedWebKitNames.append(cookie.name) + completion() + } + ) { removed in + result = removed + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 1) + XCTAssertEqual(deletedFoundationNames, ["foundation-session"]) + XCTAssertEqual(deletedWebKitNames, ["webkit-session"]) + XCTAssertEqual(result, true) + } + + func testRemoveSessionCookiesKeepsPersistentCookiesAndWaitsForEveryDeletion() { + let firstSessionCookie = makeCookie(name: "first-session") + let persistentCookie = makeCookie( + name: "persistent", + expires: Date(timeIntervalSinceNow: 3_600) + ) + let secondSessionCookie = makeCookie(name: "second-session") + var deletedNames: [String] = [] + var deletionCompletions: [() -> Void] = [] + var result: Bool? + let completionExpectation = expectation(description: "all session cookies removed") + + CookieSessionLogic.removeSessionCookies( + from: [firstSessionCookie, persistentCookie, secondSessionCookie], + delete: { cookie, completion in + deletedNames.append(cookie.name) + deletionCompletions.append(completion) + } + ) { removed in + result = removed + completionExpectation.fulfill() + } + + XCTAssertEqual(deletedNames, ["first-session", "second-session"]) + XCTAssertEqual(deletionCompletions.count, 2) + XCTAssertNil(result) + + deletionCompletions.removeFirst()() + XCTAssertNil(result) + + deletionCompletions.removeFirst()() + wait(for: [completionExpectation], timeout: 1) + XCTAssertEqual(result, true) + } + + func testRemoveSessionCookiesReturnsFalseWithoutDeletingPersistentCookies() { + let persistentCookie = makeCookie( + name: "persistent", + expires: Date(timeIntervalSinceNow: 3_600) + ) + var deleteCalled = false + var result: Bool? + + CookieSessionLogic.removeSessionCookies( + from: [persistentCookie], + delete: { _, _ in + deleteCalled = true + } + ) { removed in + result = removed + } + + XCTAssertFalse(deleteCalled) + XCTAssertEqual(result, false) + } + + private func makeCookie(name: String, expires: Date? = nil) -> HTTPCookie { + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: "example.com", + .path: "/", + .name: name, + .value: "value", + ] + properties[.expires] = expires + + return HTTPCookie(properties: properties)! + } +} From 93ba0f030bca1b591917652f3dc35a8c3c70fc81 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 17:31:57 +0300 Subject: [PATCH 08/22] feat: add deterministic clearAllStores --- CHANGELOG.md | 8 ++- Package.swift | 19 +++++++ README.md | 44 +++++++++------ .../CookieManagerModule.kt | 13 ++++- example/src/App.tsx | 13 +---- ios/CookieManager.mm | 18 ++++++ ios/CookieManager.swift | 56 +++++++++++++++---- ios/CookieStoreClearLogic.swift | 12 ++++ src/NativeCookieManager.ts | 1 + src/index.tsx | 1 + .../CookieStoreClearLogicTests.swift | 28 ++++++++++ 11 files changed, 169 insertions(+), 44 deletions(-) create mode 100644 ios/CookieStoreClearLogic.swift create mode 100644 swift-tests/CookieStoreClearLogicTests/CookieStoreClearLogicTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3721990..63bdd22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## v6.4.0 (Unreleased) +### Added + +- Added `clearAllStores()` for deterministic full-store cleanup. It clears Foundation and the default WebKit store on iOS, or the shared cookie store on Android, and resolves only after native cleanup (including Android persistence) completes. Existing `clearAll(useWebKit?)` behavior is unchanged. + ### Fixed - Android `getFromResponse()` now performs the inherited GET request instead of returning the input URL. @@ -13,12 +17,14 @@ - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. - Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. - Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews transparently retain the previous name/value-only behavior. -- `removeSessionCookies()` now works on iOS and resolves only after session cookies have been removed from both Foundation and the default WebKit store; persistent cookies remain untouched. Both stores are cleared by default, with the additive `iosCookieStore` option available for selecting only `foundation` or `webKit`. On Android, awaiting it now also guarantees that its automatic `flush()` has completed, so an immediate shutdown cannot leave removed session cookies on disk. +- `removeSessionCookies()` now works on iOS and resolves only after session cookies have been removed from the selected stores; persistent cookies remain untouched. Foundation and the default WebKit store are selected by default. On Android, awaiting it now also guarantees that its automatic `flush()` has completed, so an immediate shutdown cannot leave removed session cookies on disk. +- iOS cookie cleanup no longer invokes the unrelated deprecated `UserDefaults.synchronize()` API. ### Tests - Added Android unit coverage for the detailed cookie read, both fallback paths, empty results, attribute parsing, expiration precedence/conversion, and legacy behavior. - Added Swift coverage for Foundation-only, WebKit-only, and both-store session cleanup, persistent-cookie preservation, and asynchronous deletion completion ordering. +- Added Swift coverage for full-store cleanup ordering and WebKit completion. ### Compatibility diff --git a/Package.swift b/Package.swift index 67a1428..2089ca8 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( "CookieManager.h", "CookieManager.mm", "CookieManager.swift", + "CookieStoreClearLogic.swift", "CookieSessionLogic.swift", ], sources: ["CookieDomainLogic.swift"] @@ -27,9 +28,22 @@ let package = Package( "CookieManager.h", "CookieManager.mm", "CookieManager.swift", + "CookieStoreClearLogic.swift", ], sources: ["CookieSessionLogic.swift"] ), + .target( + name: "CookieStoreClearLogic", + path: "ios", + exclude: [ + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + ], + sources: ["CookieStoreClearLogic.swift"] + ), .testTarget( name: "CookieDomainLogicTests", dependencies: ["CookieDomainLogic"], @@ -40,5 +54,10 @@ let package = Package( dependencies: ["CookieSessionLogic"], path: "swift-tests/CookieSessionLogicTests" ), + .testTarget( + name: "CookieStoreClearLogicTests", + dependencies: ["CookieStoreClearLogic"], + path: "swift-tests/CookieStoreClearLogicTests" + ), ] ) diff --git a/README.md b/README.md index 81cda47..b713f30 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![npm version](https://img.shields.io/npm/v/@preeternal/react-native-cookie-manager.svg)](https://www.npmjs.com/package/@preeternal/react-native-cookie-manager) [![npm downloads](https://img.shields.io/npm/dm/@preeternal/react-native-cookie-manager.svg)](https://www.npmjs.com/package/@preeternal/react-native-cookie-manager) -A modern, New Architecture–ready Cookie Manager for React Native. This is a drop-in replacement for @react-native-cookies/cookies, rewritten with TypeScript, TurboModules, and platform-native implementations for iOS (Swift) and Android (Kotlin). +A modern, New Architecture–ready Cookie Manager for React Native. This is a drop-in replacement for `@react-native-cookies/cookies`, rewritten with TypeScript, TurboModules, and platform-native implementations for iOS (Swift) and Android (Kotlin). ## Upstream / credits @@ -75,14 +75,20 @@ const allCookies = await CookieManager.getAll(); // Clear by name (iOS only) await CookieManager.clearByName('https://example.com', 'session'); -// Clear all cookies +// Clear Foundation on iOS; clear the shared store on Android await CookieManager.clearAll(); +// Clear Foundation and the default WebKit store on iOS +await CookieManager.clearAllStores(); + // Android only: persist cookies to storage await CookieManager.flush(); -// Remove session cookies (cookies without an expiry date) +// Remove session cookies from both iOS stores; shared Android store await CookieManager.removeSessionCookies(); + +// iOS: limit session cleanup to one store when needed +await CookieManager.removeSessionCookies({ iosCookieStore: 'webKit' }); ``` All methods return Promises and reject when an operation fails. @@ -102,27 +108,29 @@ await CookieManager.setFromResponse( The public API remains compatible with `@react-native-cookies/cookies`. -| Method | Returns | Description | -| --- | --- | --- | -| `set(url, cookie, useWebKit?)` | `Promise` | Stores a cookie. | -| `get(url, useWebKit?)` | `Promise` | Reads stored cookies matching the URL; makes no request. | -| `clearAll(useWebKit?)` | `Promise` | Clears the selected cookie store. | -| `getAll(useWebKit?)` | `Promise` | Reads all cookies (iOS; rejects on Android). | -| `clearByName(url, name, useWebKit?)` | `Promise` | Clears matching cookies by name (iOS; rejects on Android). | -| `flush()` | `Promise` | Persists cookies to storage (Android; no-op on iOS). | -| `removeSessionCookies(options?)` | `Promise` | Removes cookies without an expiry date (Android store; Foundation and default WebKit stores on iOS). | -| `setFromResponse(url, cookieHeader)` | `Promise` | Imports one raw `Set-Cookie` header value. | -| `getFromResponse(url)` | `Promise` | Deprecated; performs a GET and returns cookies from the final response. | - -`useWebKit` applies only to iOS (switches to `WKHTTPCookieStore`); on Android it is ignored because WebView and native share a single cookie store. +| Method | Platforms | Returns | Description | +| --- | --- | --- | --- | +| `set(url, cookie, useWebKit?)` | iOS, Android | `Promise` | Stores a cookie. On iOS, uses Foundation by default or default WebKit when `true`. | +| `get(url, useWebKit?)` | iOS, Android | `Promise` | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | +| `clearAll(useWebKit?)` | iOS, Android | `Promise` | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | +| `clearAllStores()` | iOS, Android | `Promise` | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | +| `getAll(useWebKit?)` | iOS | `Promise` | Reads Foundation by default or default WebKit when `true`. | +| `clearByName(url, name, useWebKit?)` | iOS | `Promise` | Clears matching cookies from Foundation by default or default WebKit when `true`. | +| `flush()` | iOS, Android | `Promise` | Explicitly persists the Android store; it is a no-op on iOS because the system manages persistence automatically. | +| `removeSessionCookies(options?)` | iOS, Android | `Promise` | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | +| `setFromResponse(url, cookieHeader)` | iOS, Android | `Promise` | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | +| `getFromResponse(url)` | iOS, Android | `Promise` | Deprecated; performs a GET and updates Foundation on iOS. | + +Exactly five methods accept `useWebKit`: `set()`, `get()`, `clearAll()`, `getAll()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. `removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. ### WebKit on iOS - iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). -- Pass `useWebKit: true` when you need cookies to sync with WKWebView. For network-only flows, omit it to use `NSHTTPCookieStorage`. -- If your app mixes both (native requests and embedded web), call the same operation twice: once with `useWebKit: true` (for WKWebView), once with `useWebKit: false` (for URLSession). +- Pass `useWebKit: true` to operate on the default WKWebView cookie store. For network-only flows, omit it to use `NSHTTPCookieStorage`. +- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()` or `getAll()` likewise requires two calls; results are returned separately and are not merged. +- Use `clearAllStores()` when logout must clear both app-accessible stores. The library cannot access a non-persistent or custom store owned by a specific WebView. - On Android the flag is ignored; WebView and native use the same store. > [!WARNING] diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index 9da5741..25df414 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -110,10 +110,19 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } override fun clearAll(useWebKit: Boolean?, promise: Promise) { + clearAllCookies(promise, returnRemovalResult = true) + } + + override fun clearAllStores(promise: Promise) { + clearAllCookies(promise, returnRemovalResult = false) + } + + private fun clearAllCookies(promise: Promise, returnRemovalResult: Boolean) { try { val cookieManager = getCookieManager() - cookieManager.removeAllCookies { - flushAndResolve(cookieManager, it, promise, "clear_all_error") + cookieManager.removeAllCookies { removed -> + val result = if (returnRemovalResult) removed else true + flushAndResolve(cookieManager, result, promise, "clear_all_error") } } catch (e: Exception) { promise.reject("clear_all_error", e) diff --git a/example/src/App.tsx b/example/src/App.tsx index f83f305..ba60251 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -107,17 +107,8 @@ export default function App() { const clearCookies = useCallback(async () => { setStatus('Clearing cookies'); - const results = await Promise.allSettled([ - CookieManager.clearAll(false), - CookieManager.clearAll(true), - ]); - - const rejected = results.filter((result) => result.status === 'rejected'); - if (rejected.length > 0) { - setStatus(`Clear finished with errors (${rejected.length})`); - } else { - setStatus('All cookies cleared'); - } + await CookieManager.clearAllStores(); + setStatus('All cookies cleared'); await refreshCookies(inspectUrl); }, [inspectUrl, refreshCookies]); diff --git a/ios/CookieManager.mm b/ios/CookieManager.mm index b9edfb7..18d203a 100644 --- a/ios/CookieManager.mm +++ b/ios/CookieManager.mm @@ -72,6 +72,12 @@ - (void)handleClearAll:(NSNumber *)useWebKit [_impl clearAll:[useWebKit boolValue] resolve:resolve reject:reject]; } +- (void)handleClearAllStores:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_impl clearAllStoresWithResolve:resolve reject:reject]; +} + - (void)handleClearByName:(NSString *)url name:(NSString *)name useWebKit:(NSNumber *)useWebKit @@ -176,6 +182,11 @@ - (void)clearAll:(NSNumber *)useWebKit [self handleClearAll:useWebKit resolve:resolve reject:reject]; } +- (void)clearAllStores:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +{ + [self handleClearAllStores:resolve reject:reject]; +} + - (void)clearByName:(NSString *)url name:(NSString *)name useWebKit:(NSNumber *)useWebKit @@ -264,6 +275,13 @@ - (void)removeSessionCookies:(BOOL)iosClearFoundation [self handleClearAll:@(useWebKit) resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(clearAllStores + : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + [self handleClearAllStores:resolve reject:reject]; +} + RCT_EXPORT_METHOD(clearByName : (NSString *)url name : (NSString *)name useWebKit diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index 50c38b4..4638057 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -161,24 +161,38 @@ public class CookieManagerImpl: NSObject { reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) return } - DispatchQueue.main.async { - let websiteDataTypes: Set = [WKWebsiteDataTypeCookies] - let dateFrom = Date(timeIntervalSince1970: 0) - WKWebsiteDataStore.default().removeData( - ofTypes: websiteDataTypes, - modifiedSince: dateFrom - ) { - resolve(true) - } + clearWebKitCookies { + resolve(true) } } else { - let cookieStorage = HTTPCookieStorage.shared - cookieStorage.cookies?.forEach { cookieStorage.deleteCookie($0) } - UserDefaults.standard.synchronize() + clearFoundationCookies() resolve(true) } } + @objc(clearAllStoresWithResolve:reject:) + public func clearAllStores( + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 11.0, *) else { + reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) + return + } + + CookieStoreClearLogic.clearAllStores( + clearFoundation: { + self.clearFoundationCookies() + }, + clearWebKit: { completion in + self.clearWebKitCookies(completion: completion) + }, + completion: { + resolve(true) + } + ) + } + @objc(clearByName:name:useWebKit:resolve:reject:) public func clearByName( url: NSString, @@ -275,6 +289,24 @@ public class CookieManagerImpl: NSObject { resolve(true) } + private func clearFoundationCookies() { + let cookieStorage = HTTPCookieStorage.shared + cookieStorage.cookies?.forEach { cookieStorage.deleteCookie($0) } + } + + @available(iOS 11.0, *) + private func clearWebKitCookies(completion: @escaping () -> Void) { + DispatchQueue.main.async { + let websiteDataTypes: Set = [WKWebsiteDataTypeCookies] + let dateFrom = Date(timeIntervalSince1970: 0) + WKWebsiteDataStore.default().removeData( + ofTypes: websiteDataTypes, + modifiedSince: dateFrom, + completionHandler: completion + ) + } + } + @objc(removeSessionCookiesWithClearFoundation:clearWebKit:resolve:reject:) public func removeSessionCookies( clearFoundation: Bool, diff --git a/ios/CookieStoreClearLogic.swift b/ios/CookieStoreClearLogic.swift new file mode 100644 index 0000000..ecb3722 --- /dev/null +++ b/ios/CookieStoreClearLogic.swift @@ -0,0 +1,12 @@ +import Foundation + +enum CookieStoreClearLogic { + static func clearAllStores( + clearFoundation: () -> Void, + clearWebKit: (_ completion: @escaping () -> Void) -> Void, + completion: @escaping () -> Void + ) { + clearFoundation() + clearWebKit(completion) + } +} diff --git a/src/NativeCookieManager.ts b/src/NativeCookieManager.ts index 8ee8062..76c0776 100644 --- a/src/NativeCookieManager.ts +++ b/src/NativeCookieManager.ts @@ -23,6 +23,7 @@ export interface Spec extends TurboModule { */ getFromResponse(url: string): Promise; clearAll(useWebKit?: boolean): Promise; + clearAllStores(): Promise; flush(): Promise; removeSessionCookies( iosClearFoundation: boolean, diff --git a/src/index.tsx b/src/index.tsx index 335987f..4aefb96 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,6 +30,7 @@ const removeSessionCookies = ( const CookieManager = { getAll: (useWebKit = false) => CookieManagerNative.getAll(useWebKit), clearAll: (useWebKit = false) => CookieManagerNative.clearAll(useWebKit), + clearAllStores: () => CookieManagerNative.clearAllStores(), get: (url: string, useWebKit = false) => CookieManagerNative.getCookies(url, useWebKit), set: (url: string, cookie: Cookie, useWebKit = false) => diff --git a/swift-tests/CookieStoreClearLogicTests/CookieStoreClearLogicTests.swift b/swift-tests/CookieStoreClearLogicTests/CookieStoreClearLogicTests.swift new file mode 100644 index 0000000..b6f5868 --- /dev/null +++ b/swift-tests/CookieStoreClearLogicTests/CookieStoreClearLogicTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import CookieStoreClearLogic + +final class CookieStoreClearLogicTests: XCTestCase { + func testClearAllStoresWaitsForWebKitAfterClearingFoundation() { + var events: [String] = [] + var webKitCompletion: (() -> Void)? + + CookieStoreClearLogic.clearAllStores( + clearFoundation: { + events.append("foundation") + }, + clearWebKit: { completion in + events.append("webKitStarted") + webKitCompletion = completion + }, + completion: { + events.append("completed") + } + ) + + XCTAssertEqual(events, ["foundation", "webKitStarted"]) + XCTAssertNotNil(webKitCompletion) + + webKitCompletion?() + XCTAssertEqual(events, ["foundation", "webKitStarted", "completed"]) + } +} From 2adadae169a855bf2cbb21fa5fc7def997936882 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 17:57:14 +0300 Subject: [PATCH 09/22] fix(android): persist getFromResponse cookies before resolve --- CHANGELOG.md | 3 ++ README.md | 37 +++++++++++-------- .../CookieManagerModule.kt | 22 +++++++++-- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bdd22..01a870f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Android applies `Set-Cookie` before each redirect and selects stored cookies for every destination URL instead of forwarding the original `Cookie` header. - Both platforms return the declared `Cookies` object shape and update their native cookie store. - On iOS, `getFromResponse()` values are now `Cookie` objects instead of raw strings; read the cookie value through `.value`. This aligns runtime behavior with the existing TypeScript contract. +- Awaiting Android `getFromResponse()` now guarantees that cookies stored from the final response or any redirect have been flushed to persistent storage; the blocking persistence work runs on a worker thread. - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. - Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. - Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews transparently retain the previous name/value-only behavior. @@ -39,6 +40,8 @@ - Documented the network and cookie-store side effects of the legacy API. - Added a concise Fetch/Axios response flow and clarified the advanced `setFromResponse()` use case. +- Clarified that direct Android `flush()` calls are redundant after library mutations and are intended only as a persistence barrier for external shared-store changes. +- Documented native persistence and expiration behavior, including Android WebView session-cookie restoration and the decision not to maintain a separate automatic cookie backup. --- diff --git a/README.md b/README.md index b713f30..5e09fa4 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,6 @@ await CookieManager.clearAll(); // Clear Foundation and the default WebKit store on iOS await CookieManager.clearAllStores(); -// Android only: persist cookies to storage -await CookieManager.flush(); - // Remove session cookies from both iOS stores; shared Android store await CookieManager.removeSessionCookies(); @@ -116,7 +113,7 @@ The public API remains compatible with `@react-native-cookies/cookies`. | `clearAllStores()` | iOS, Android | `Promise` | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | | `getAll(useWebKit?)` | iOS | `Promise` | Reads Foundation by default or default WebKit when `true`. | | `clearByName(url, name, useWebKit?)` | iOS | `Promise` | Clears matching cookies from Foundation by default or default WebKit when `true`. | -| `flush()` | iOS, Android | `Promise` | Explicitly persists the Android store; it is a no-op on iOS because the system manages persistence automatically. | +| `flush()` | iOS, Android | `Promise` | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | | `removeSessionCookies(options?)` | iOS, Android | `Promise` | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | | `setFromResponse(url, cookieHeader)` | iOS, Android | `Promise` | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | | `getFromResponse(url)` | iOS, Android | `Promise` | Deprecated; performs a GET and updates Foundation on iOS. | @@ -125,17 +122,6 @@ Exactly five methods accept `useWebKit`: `set()`, `get()`, `clearAll()`, `getAll `removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. -### WebKit on iOS - -- iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). -- Pass `useWebKit: true` to operate on the default WKWebView cookie store. For network-only flows, omit it to use `NSHTTPCookieStorage`. -- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()` or `getAll()` likewise requires two calls; results are returned separately and are not merged. -- Use `clearAllStores()` when logout must clear both app-accessible stores. The library cannot access a non-persistent or custom store owned by a specific WebView. -- On Android the flag is ignored; WebView and native use the same store. - -> [!WARNING] -> On Android, `react-native-webview`'s `incognito` mode currently clears the shared app-wide cookie store, including cookies used by React Native networking. Avoid it when your app relies on authenticated native requests. See [react-native-webview#3988](https://github.com/react-native-webview/react-native-webview/issues/3988). - ### Cookie shape ```ts @@ -153,6 +139,27 @@ type Cookie = { On Android, metadata is populated when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews fall back to legacy name/value parsing, so `domain`, `path`, and `expires` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. +### WebKit on iOS + +- iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). +- Pass `useWebKit: true` to operate on the default WKWebView cookie store. For network-only flows, omit it to use `NSHTTPCookieStorage`. +- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()` or `getAll()` likewise requires two calls; results are returned separately and are not merged. +- Use `clearAllStores()` when logout must clear both app-accessible stores. The library cannot access a non-persistent or custom store owned by a specific WebView. +- On Android the flag is ignored; WebView and native use the same store. + +> [!WARNING] +> On Android, `react-native-webview`'s `incognito` mode currently clears the shared app-wide cookie store, including cookies used by React Native networking. Avoid it when your app relies on authenticated native requests. See [react-native-webview#3988](https://github.com/react-native-webview/react-native-webview/issues/3988). + +### Persistence and expiration + +- A cookie is persistent only when the server supplies `Expires`/`Max-Age`, or when `set()` receives `expires`. Native stores enforce expiration; `flush()` does not extend a cookie's lifetime or turn a session cookie into a persistent one. +- On iOS, Foundation and WebKit manage persistence automatically. There is no public explicit flush API, so `flush()` is a no-op. +- On Android, the library automatically flushes the shared WebView cookie store after its mutations. Current WebView implementations may also restore session cookies—cookies without an expiry—after a process restart. + +On Android, mutation methods automatically flush before their Promises resolve. Calling `flush()` immediately after awaiting `set()`, `setFromResponse()`, `getFromResponse()`, `clearAll()`, `clearAllStores()`, or `removeSessionCookies()` is redundant. Use it only as an explicit persistence barrier after the shared Android store was changed outside this library. + +The library intentionally does not maintain a separate cookie backup or silently replay cookies on startup. That could resurrect expired or logged-out authentication state and would require the application to choose appropriate secure storage. Prefer server-defined persistent cookies; call `removeSessionCookies()` before the first request or WebView load when the application requires a clean session on launch. + ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index 25df414..7616393 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -166,7 +166,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : private fun flushAndResolve( cookieManager: CookieManager, - result: Boolean, + result: Any?, promise: Promise, errorCode: String ) { @@ -208,6 +208,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : ) { var currentUrl = url var redirectCount = 0 + var storedRedirectCookies = false try { while (true) { @@ -238,6 +239,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : // Apply redirect cookies before selecting cookies for the next URL. // The callback confirms that WebView's cookie store has been updated. storeResponseCookiesAndWait(currentUrl, setCookieHeaders) + storedRedirectCookies = storedRedirectCookies || setCookieHeaders.isNotEmpty() currentUrl = redirectUrl redirectCount += 1 continue @@ -245,7 +247,13 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : val responseCookies = parseResponseCookies(setCookieHeaders, currentUrl) reactApplicationContext.runOnUiQueueThread { - storeResponseCookies(currentUrl, setCookieHeaders, responseCookies, promise) + storeResponseCookies( + currentUrl, + setCookieHeaders, + responseCookies, + shouldFlush = storedRedirectCookies || setCookieHeaders.isNotEmpty(), + promise = promise + ) } return } finally { @@ -361,10 +369,11 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : responseUrl: URL, headers: List, cookies: List, + shouldFlush: Boolean, promise: Promise ) { val result = createResponseCookieList(cookies) - if (headers.isEmpty()) { + if (!shouldFlush) { promise.resolve(result) return } @@ -372,6 +381,11 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : var settled = false try { val cookieManager = getCookieManager() + if (headers.isEmpty()) { + flushAndResolve(cookieManager, result, promise, "get_from_response_error") + return + } + var remaining = headers.size for (header in headers) { @@ -379,7 +393,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : remaining -= 1 if (remaining == 0 && !settled) { settled = true - promise.resolve(result) + flushAndResolve(cookieManager, result, promise, "get_from_response_error") } } } From ae09f61e33c7a2c4df16a94c710b244a05694b09 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 18:40:17 +0300 Subject: [PATCH 10/22] fix(ios): correct case and leading-dot domain matching --- CHANGELOG.md | 2 + ios/CookieDomainLogic.swift | 12 +--- ios/CookieManager.swift | 15 +++-- .../CookieDomainLogicTests.swift | 60 +++++++++---------- 4 files changed, 38 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a870f..479e547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Awaiting Android `getFromResponse()` now guarantees that cookies stored from the final response or any redirect have been flushed to persistent storage; the blocking persistence work runs on a worker thread. - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. - Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. +- iOS `get(url, true)` and `clearByName()` now match cookie domains case-insensitively in both Foundation and WebKit flows. Leading-dot domains apply to their root host as well as subdomains, while strict domain boundaries remain enforced. - Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews transparently retain the previous name/value-only behavior. - `removeSessionCookies()` now works on iOS and resolves only after session cookies have been removed from the selected stores; persistent cookies remain untouched. Foundation and the default WebKit store are selected by default. On Android, awaiting it now also guarantees that its automatic `flush()` has completed, so an immediate shutdown cannot leave removed session cookies on disk. - iOS cookie cleanup no longer invokes the unrelated deprecated `UserDefaults.synchronize()` API. @@ -26,6 +27,7 @@ - Added Android unit coverage for the detailed cookie read, both fallback paths, empty results, attribute parsing, expiration precedence/conversion, and legacy behavior. - Added Swift coverage for Foundation-only, WebKit-only, and both-store session cleanup, persistent-cookie preservation, and asynchronous deletion completion ordering. - Added Swift coverage for full-store cleanup ordering and WebKit completion. +- Added Swift regression coverage for mixed-case, leading-dot root, parent-domain, and substring-rejection matching. ### Compatibility diff --git a/ios/CookieDomainLogic.swift b/ios/CookieDomainLogic.swift index f28f1c9..97048a5 100644 --- a/ios/CookieDomainLogic.swift +++ b/ios/CookieDomainLogic.swift @@ -16,22 +16,14 @@ enum CookieDomainLogic { return cookieDomain } - static func isCookieDomainValid(host: String, cookieDomain: String) -> Bool { + static func isMatchingDomain(originDomain: String, cookieDomain: String) -> Bool { let domainForValidation = validationDomain(from: cookieDomain) guard !domainForValidation.isEmpty else { return false } - let normalizedHost = host.lowercased() + let normalizedHost = originDomain.lowercased() let normalizedDomain = domainForValidation.lowercased() return normalizedHost == normalizedDomain || normalizedHost.hasSuffix(".\(normalizedDomain)") } - - static func isMatchingDomain(originDomain: String, cookieDomain: String) -> Bool { - if originDomain == cookieDomain { - return true - } - let parentDomain = cookieDomain.hasPrefix(".") ? cookieDomain : ".\(cookieDomain)" - return originDomain.hasSuffix(parentDomain) - } } diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index 4638057..e2a6f48 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -134,7 +134,10 @@ public class CookieManagerImpl: NSObject { WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in var cookies: [String: Any] = [:] for cookie in allCookies - where self.isMatchingDomain(originDomain: topLevelDomain, cookieDomain: cookie.domain) { + where CookieDomainLogic.isMatchingDomain( + originDomain: topLevelDomain, + cookieDomain: cookie.domain + ) { cookies[cookie.name] = self.createCookieData(cookie) } resolve(cookies) @@ -221,7 +224,7 @@ public class CookieManagerImpl: NSObject { let store = WKWebsiteDataStore.default().httpCookieStore let matchingCookies = allCookies.filter { cookie in cookie.name == name && - self.isMatchingDomain( + CookieDomainLogic.isMatchingDomain( originDomain: topLevelDomain, cookieDomain: cookie.domain ) @@ -250,7 +253,7 @@ public class CookieManagerImpl: NSObject { storage.cookies?.forEach { cookie in if cookie.name == name, let host = parsedUrl.host, - self.isMatchingDomain(originDomain: host, cookieDomain: cookie.domain) { + CookieDomainLogic.isMatchingDomain(originDomain: host, cookieDomain: cookie.domain) { storage.deleteCookie(cookie) found = true } @@ -376,7 +379,7 @@ public class CookieManagerImpl: NSObject { let httpOnly = props["httpOnly"] as? Bool ?? false if let rawDomain = domain { - if !CookieDomainLogic.isCookieDomainValid(host: topLevelDomain, cookieDomain: rawDomain) { + if !CookieDomainLogic.isMatchingDomain(originDomain: topLevelDomain, cookieDomain: rawDomain) { let reason = String(format: Self.invalidDomains, topLevelDomain, rawDomain) throw NSError(domain: "CookieManager", code: -1, userInfo: [NSLocalizedDescriptionKey: reason]) } @@ -434,10 +437,6 @@ public class CookieManagerImpl: NSObject { formatter.date(from: dateString) } - private func isMatchingDomain(originDomain: String, cookieDomain: String) -> Bool { - CookieDomainLogic.isMatchingDomain(originDomain: originDomain, cookieDomain: cookieDomain) - } - private static let notAvailableErrorMessage = "WebKit/WebKit-Components are only available with iOS11 and higher!" private static let invalidURLMissingHTTP = diff --git a/swift-tests/CookieDomainLogicTests/CookieDomainLogicTests.swift b/swift-tests/CookieDomainLogicTests/CookieDomainLogicTests.swift index 8fea916..5b2bbc3 100644 --- a/swift-tests/CookieDomainLogicTests/CookieDomainLogicTests.swift +++ b/swift-tests/CookieDomainLogicTests/CookieDomainLogicTests.swift @@ -25,71 +25,65 @@ final class CookieDomainLogicTests: XCTestCase { ) } - func testIsCookieDomainValidAllowsParentDomainWithLeadingDot() { + func testIsMatchingDomainAllowsParentDomainWithoutLeadingDot() { XCTAssertTrue( - CookieDomainLogic.isCookieDomainValid( - host: "app.trustedhealth.com", - cookieDomain: ".trustedhealth.com" + CookieDomainLogic.isMatchingDomain( + originDomain: "app.trustedhealth.com", + cookieDomain: "trustedhealth.com" ) ) } - func testIsCookieDomainValidRejectsMismatchedDomain() { - XCTAssertFalse( - CookieDomainLogic.isCookieDomainValid( - host: "app.trustedhealth.com", - cookieDomain: ".evil.com" + func testIsMatchingDomainAllowsParentDomainWithLeadingDot() { + XCTAssertTrue( + CookieDomainLogic.isMatchingDomain( + originDomain: "app.trustedhealth.com", + cookieDomain: ".trustedhealth.com" ) ) } - func testIsCookieDomainValidRejectsSubstringDomainMatch() { - XCTAssertFalse( - CookieDomainLogic.isCookieDomainValid( - host: "notexample.com", - cookieDomain: "example.com" - ) - ) - XCTAssertFalse( - CookieDomainLogic.isCookieDomainValid( - host: "badexample.com", - cookieDomain: "example.com" + func testIsMatchingDomainAllowsExactDomainWithLeadingDot() { + XCTAssertTrue( + CookieDomainLogic.isMatchingDomain( + originDomain: "example.com", + cookieDomain: ".example.com" ) ) } - func testIsCookieDomainValidIsCaseInsensitive() { + func testIsMatchingDomainIsCaseInsensitiveForExactDomain() { XCTAssertTrue( - CookieDomainLogic.isCookieDomainValid( - host: "APP.EXAMPLE.COM", - cookieDomain: "example.com" + CookieDomainLogic.isMatchingDomain( + originDomain: "Example.com", + cookieDomain: "example.COM" ) ) } - func testIsMatchingDomainAllowsParentDomainWithoutLeadingDot() { + func testIsMatchingDomainIsCaseInsensitiveForParentDomain() { XCTAssertTrue( CookieDomainLogic.isMatchingDomain( - originDomain: "app.trustedhealth.com", - cookieDomain: "trustedhealth.com" + originDomain: "App.Example.COM", + cookieDomain: ".EXAMPLE.com" ) ) } - func testIsMatchingDomainAllowsParentDomainWithLeadingDot() { - XCTAssertTrue( + func testIsMatchingDomainRejectsUnrelatedDomain() { + XCTAssertFalse( CookieDomainLogic.isMatchingDomain( originDomain: "app.trustedhealth.com", - cookieDomain: ".trustedhealth.com" + cookieDomain: "example.org" ) ) } - func testIsMatchingDomainRejectsUnrelatedDomain() { + func testIsMatchingDomainRejectsSubstringDomainMatch() { XCTAssertFalse( CookieDomainLogic.isMatchingDomain( - originDomain: "app.trustedhealth.com", - cookieDomain: "example.org" + originDomain: "NotExample.com", + cookieDomain: ".example.COM" ) ) } From cf02afbc26f13d449c721c1674dffa08745bdbb3 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 19:07:12 +0300 Subject: [PATCH 11/22] feat: add duplicate-preserving cookie array APIs --- CHANGELOG.md | 2 + Package.swift | 21 +++ README.md | 11 +- .../CookieManagerModule.kt | 78 +++++++---- .../CookieReadLogicTest.kt | 19 +++ ios/CookieCollectionLogic.swift | 20 +++ ios/CookieManager.mm | 47 +++++++ ios/CookieManager.swift | 124 ++++++++++++------ src/NativeCookieManager.ts | 2 + src/index.tsx | 4 + .../CookieCollectionLogicTests.swift | 36 +++++ 11 files changed, 299 insertions(+), 65 deletions(-) create mode 100644 ios/CookieCollectionLogic.swift create mode 100644 swift-tests/CookieCollectionLogicTests/CookieCollectionLogicTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 479e547..3deaad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added - Added `clearAllStores()` for deterministic full-store cleanup. It clears Foundation and the default WebKit store on iOS, or the shared cookie store on Android, and resolves only after native cleanup (including Android persistence) completes. Existing `clearAll(useWebKit?)` behavior is unchanged. +- Added `getAsArray()` on both platforms and iOS-only `getAllAsArray()` to preserve cookies that share a name but differ by domain or path. Existing `get()` and `getAll()` retain their upstream-compatible last-cookie-wins object shape. ### Fixed @@ -28,6 +29,7 @@ - Added Swift coverage for Foundation-only, WebKit-only, and both-store session cleanup, persistent-cookie preservation, and asynchronous deletion completion ordering. - Added Swift coverage for full-store cleanup ordering and WebKit completion. - Added Swift regression coverage for mixed-case, leading-dot root, parent-domain, and substring-rejection matching. +- Added Swift and Kotlin regression coverage for duplicate-name array reads and legacy object collapsing. ### Compatibility diff --git a/Package.swift b/Package.swift index 2089ca8..92fc62e 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( name: "CookieDomainLogic", path: "ios", exclude: [ + "CookieCollectionLogic.swift", "CookieManager.h", "CookieManager.mm", "CookieManager.swift", @@ -24,6 +25,7 @@ let package = Package( name: "CookieSessionLogic", path: "ios", exclude: [ + "CookieCollectionLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", "CookieManager.mm", @@ -36,6 +38,7 @@ let package = Package( name: "CookieStoreClearLogic", path: "ios", exclude: [ + "CookieCollectionLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", "CookieManager.mm", @@ -44,6 +47,19 @@ let package = Package( ], sources: ["CookieStoreClearLogic.swift"] ), + .target( + name: "CookieCollectionLogic", + path: "ios", + exclude: [ + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieCollectionLogic.swift"] + ), .testTarget( name: "CookieDomainLogicTests", dependencies: ["CookieDomainLogic"], @@ -59,5 +75,10 @@ let package = Package( dependencies: ["CookieStoreClearLogic"], path: "swift-tests/CookieStoreClearLogicTests" ), + .testTarget( + name: "CookieCollectionLogicTests", + dependencies: ["CookieCollectionLogic"], + path: "swift-tests/CookieCollectionLogicTests" + ), ] ) diff --git a/README.md b/README.md index 5e09fa4..5005ad9 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ await CookieManager.set('https://example.com', { const cookies = await CookieManager.get('https://example.com'); +// Preserve cookies that share a name but differ by domain or path +const cookieVariants = await CookieManager.getAsArray('https://example.com'); + // iOS only: get all cookies const allCookies = await CookieManager.getAll(); @@ -109,16 +112,18 @@ The public API remains compatible with `@react-native-cookies/cookies`. | --- | --- | --- | --- | | `set(url, cookie, useWebKit?)` | iOS, Android | `Promise` | Stores a cookie. On iOS, uses Foundation by default or default WebKit when `true`. | | `get(url, useWebKit?)` | iOS, Android | `Promise` | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | +| `getAsArray(url, useWebKit?)` | iOS, Android | `Promise>` | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | | `clearAll(useWebKit?)` | iOS, Android | `Promise` | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | | `clearAllStores()` | iOS, Android | `Promise` | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | | `getAll(useWebKit?)` | iOS | `Promise` | Reads Foundation by default or default WebKit when `true`. | +| `getAllAsArray(useWebKit?)` | iOS | `Promise>` | Reads the selected iOS store without collapsing cookies that share a name. | | `clearByName(url, name, useWebKit?)` | iOS | `Promise` | Clears matching cookies from Foundation by default or default WebKit when `true`. | | `flush()` | iOS, Android | `Promise` | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | | `removeSessionCookies(options?)` | iOS, Android | `Promise` | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | | `setFromResponse(url, cookieHeader)` | iOS, Android | `Promise` | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | | `getFromResponse(url)` | iOS, Android | `Promise` | Deprecated; performs a GET and updates Foundation on iOS. | -Exactly five methods accept `useWebKit`: `set()`, `get()`, `clearAll()`, `getAll()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. +Seven methods accept `useWebKit`: `set()`, `get()`, `getAsArray()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. `removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. @@ -137,13 +142,15 @@ type Cookie = { }; ``` +`Cookies` is keyed by cookie name, so `get()` and `getAll()` retain only the last item when multiple cookies share a name. This legacy behavior is preserved for upstream compatibility. Use `getAsArray()` or `getAllAsArray()` when `domain`/`path` variants must remain separate. + On Android, metadata is populated when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews fall back to legacy name/value parsing, so `domain`, `path`, and `expires` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. ### WebKit on iOS - iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). - Pass `useWebKit: true` to operate on the default WKWebView cookie store. For network-only flows, omit it to use `NSHTTPCookieStorage`. -- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()` or `getAll()` likewise requires two calls; results are returned separately and are not merged. +- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()`, `getAsArray()`, `getAll()`, or `getAllAsArray()` likewise requires two calls; results are returned separately and are not merged. - Use `clearAllStores()` when logout must clear both app-accessible stores. The library cannot access a non-persistent or custom store owned by a specific WebView. - On Android the flag is ignored; WebView and native use the same store. diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index 7616393..435547a 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -10,6 +10,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule import java.lang.Exception @@ -71,13 +72,20 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } try { - val cookieManager = getCookieManager() - val cookieHeaders = readCookieHeaders( - supportsDetailedRead = WebViewFeature.isFeatureSupported(WebViewFeature.GET_COOKIE_INFO), - detailedReader = { CookieManagerCompat.getCookieInfo(cookieManager, url) }, - legacyReader = { cookieManager.getCookie(url) } - ) - promise.resolve(createCookieList(cookieHeaders)) + promise.resolve(createCookieList(readCookies(url))) + } catch (e: Exception) { + promise.reject("get_cookie_error", e) + } + } + + override fun getAsArray(url: String, useWebKit: Boolean?, promise: Promise) { + if (url.isEmpty()) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + try { + promise.resolve(createCookieArray(readCookies(url))) } catch (e: Exception) { promise.reject("get_cookie_error", e) } @@ -105,6 +113,10 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : promise.reject("not_supported", GET_ALL_NOT_SUPPORTED) } + override fun getAllAsArray(useWebKit: Boolean?, promise: Promise) { + promise.reject("not_supported", GET_ALL_NOT_SUPPORTED) + } + override fun clearByName(url: String, name: String, useWebKit: Boolean?, promise: Promise) { promise.reject("not_supported", CLEAR_BY_NAME_NOT_SUPPORTED) } @@ -180,28 +192,50 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } } - private fun createCookieList(cookieReadResult: CookieReadResult): WritableMap { + private fun readCookies(url: String): List { + val cookieManager = getCookieManager() + val cookieHeaders = readCookieHeaders( + supportsDetailedRead = WebViewFeature.isFeatureSupported(WebViewFeature.GET_COOKIE_INFO), + detailedReader = { CookieManagerCompat.getCookieInfo(cookieManager, url) }, + legacyReader = { cookieManager.getCookie(url) } + ) + return parseCookieReadResult(cookieHeaders) + } + + private fun createCookieList(cookies: List): WritableMap { val allCookiesMap = Arguments.createMap() - for (cookie in parseCookieReadResult(cookieReadResult)) { - val cookieMap = Arguments.createMap() - cookieMap.putString("name", cookie.name) - cookieMap.putString("value", cookie.value) - cookieMap.putString("domain", cookie.domain) - cookieMap.putString("path", cookie.path) - cookieMap.putBoolean("secure", cookie.secure) - cookieMap.putBoolean("httpOnly", cookie.httpOnly) - cookie.expiresAt?.let { expiresAt -> - formatDate(Date(expiresAt))?.let { expires -> - cookieMap.putString("expires", expires) - } - } - allCookiesMap.putMap(cookie.name, cookieMap) + for (cookie in cookies) { + allCookiesMap.putMap(cookie.name, createCookieMap(cookie)) } return allCookiesMap } + private fun createCookieArray(cookies: List): WritableArray { + val cookieArray = Arguments.createArray() + for (cookie in cookies) { + cookieArray.pushMap(createCookieMap(cookie)) + } + return cookieArray + } + + private fun createCookieMap(cookie: ParsedCookie): WritableMap { + val cookieMap = Arguments.createMap() + cookieMap.putString("name", cookie.name) + cookieMap.putString("value", cookie.value) + cookieMap.putString("domain", cookie.domain) + cookieMap.putString("path", cookie.path) + cookieMap.putBoolean("secure", cookie.secure) + cookieMap.putBoolean("httpOnly", cookie.httpOnly) + cookie.expiresAt?.let { expiresAt -> + formatDate(Date(expiresAt))?.let { expires -> + cookieMap.putString("expires", expires) + } + } + return cookieMap + } + private fun fetchResponseCookies( url: URL, promise: Promise diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt index 85f5c54..f3aa499 100644 --- a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt @@ -149,4 +149,23 @@ class CookieReadLogicTest { assertFalse(cookie.httpOnly) } } + + @Test + fun preservesDuplicateCookieNamesForArraySerialization() { + val detailedCookies = parseCookieReadResult( + CookieReadResult.Detailed( + listOf( + "session=root; domain=example.com; path=/", + "session=account; domain=example.com; path=/account" + ) + ) + ) + val legacyCookies = parseCookieReadResult( + CookieReadResult.Legacy("session=root; session=account") + ) + + assertEquals(listOf("root", "account"), detailedCookies.map { it.value }) + assertEquals(listOf("/", "/account"), detailedCookies.map { it.path }) + assertEquals(listOf("root", "account"), legacyCookies.map { it.value }) + } } diff --git a/ios/CookieCollectionLogic.swift b/ios/CookieCollectionLogic.swift new file mode 100644 index 0000000..abd2fa0 --- /dev/null +++ b/ios/CookieCollectionLogic.swift @@ -0,0 +1,20 @@ +enum CookieCollectionLogic { + static func asArray( + _ elements: [Element], + transform: (Element) -> [String: Any] + ) -> [[String: Any]] { + elements.map(transform) + } + + static func asDictionary( + _ elements: [Element], + name: (Element) -> String, + transform: (Element) -> [String: Any] + ) -> [String: Any] { + var result: [String: Any] = [:] + for element in elements { + result[name(element)] = transform(element) + } + return result + } +} diff --git a/ios/CookieManager.mm b/ios/CookieManager.mm index 18d203a..db4aa46 100644 --- a/ios/CookieManager.mm +++ b/ios/CookieManager.mm @@ -65,6 +65,14 @@ - (void)handleGet:(NSString *)url [_impl get:url useWebKit:[useWebKit boolValue] resolve:resolve reject:reject]; } +- (void)handleGetAsArray:(NSString *)url + useWebKit:(NSNumber *)useWebKit + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_impl getAsArray:url useWebKit:[useWebKit boolValue] resolve:resolve reject:reject]; +} + - (void)handleClearAll:(NSNumber *)useWebKit resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject @@ -94,6 +102,13 @@ - (void)handleGetAll:(NSNumber *)useWebKit [_impl getAll:[useWebKit boolValue] resolve:resolve reject:reject]; } +- (void)handleGetAllAsArray:(NSNumber *)useWebKit + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_impl getAllAsArray:[useWebKit boolValue] resolve:resolve reject:reject]; +} + - (void)handleFlushWithResolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { @@ -175,6 +190,14 @@ - (void)getCookies:(NSString *)url [self handleGet:url useWebKit:useWebKit resolve:resolve reject:reject]; } +- (void)getAsArray:(NSString *)url + useWebKit:(NSNumber *)useWebKit + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self handleGetAsArray:url useWebKit:useWebKit resolve:resolve reject:reject]; +} + - (void)clearAll:(NSNumber *)useWebKit resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject @@ -203,6 +226,13 @@ - (void)getAll:(NSNumber *)useWebKit [self handleGetAll:useWebKit resolve:resolve reject:reject]; } +- (void)getAllAsArray:(NSNumber *)useWebKit + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self handleGetAllAsArray:useWebKit resolve:resolve reject:reject]; +} + - (void)flush:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self handleFlushWithResolve:resolve reject:reject]; @@ -267,6 +297,15 @@ - (void)removeSessionCookies:(BOOL)iosClearFoundation [self handleGet:url useWebKit:@(useWebKit) resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(getAsArray + : (NSString *)url useWebKit + : (BOOL)useWebKit resolve + : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + [self handleGetAsArray:url useWebKit:@(useWebKit) resolve:resolve reject:reject]; +} + RCT_EXPORT_METHOD(clearAll : (BOOL)useWebKit resolve : (RCTPromiseResolveBlock)resolve reject @@ -300,6 +339,14 @@ - (void)removeSessionCookies:(BOOL)iosClearFoundation [self handleGetAll:@(useWebKit) resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(getAllAsArray + : (BOOL)useWebKit resolve + : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + [self handleGetAllAsArray:@(useWebKit) resolve:resolve reject:reject]; +} + RCT_EXPORT_METHOD(flush : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index e2a6f48..bcb8a0a 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -115,41 +115,20 @@ public class CookieManagerImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { - guard let parsedUrl = URL(string: url as String) else { - reject("invalid_url", Self.invalidURLMissingHTTP, nil) - return + loadCookies(url: url, useWebKit: useWebKit, reject: reject) { cookies in + resolve(self.createCookieList(cookies)) } - if useWebKit { - guard #available(iOS 11.0, *) else { - reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) - return - } - - guard let topLevelDomain = parsedUrl.host, !topLevelDomain.isEmpty else { - reject("invalid_url", Self.invalidURLMissingHTTP, nil) - return - } + } - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in - var cookies: [String: Any] = [:] - for cookie in allCookies - where CookieDomainLogic.isMatchingDomain( - originDomain: topLevelDomain, - cookieDomain: cookie.domain - ) { - cookies[cookie.name] = self.createCookieData(cookie) - } - resolve(cookies) - } - } - } else { - var cookies: [String: Any] = [:] - let storageCookies = HTTPCookieStorage.shared.cookies(for: parsedUrl) ?? [] - for cookie in storageCookies { - cookies[cookie.name] = createCookieData(cookie) - } - resolve(cookies) + @objc(getAsArray:useWebKit:resolve:reject:) + public func getAsArray( + url: NSString, + useWebKit: Bool, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + loadCookies(url: url, useWebKit: useWebKit, reject: reject) { cookies in + resolve(self.createCookieArray(cookies)) } } @@ -267,6 +246,27 @@ public class CookieManagerImpl: NSObject { useWebKit: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock + ) { + loadAllCookies(useWebKit: useWebKit, reject: reject) { cookies in + resolve(self.createCookieList(cookies)) + } + } + + @objc(getAllAsArray:resolve:reject:) + public func getAllAsArray( + useWebKit: Bool, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + loadAllCookies(useWebKit: useWebKit, reject: reject) { cookies in + resolve(self.createCookieArray(cookies)) + } + } + + private func loadAllCookies( + useWebKit: Bool, + reject: @escaping RCTPromiseRejectBlock, + completion: @escaping ([HTTPCookie]) -> Void ) { if useWebKit { guard #available(iOS 11.0, *) else { @@ -275,12 +275,11 @@ public class CookieManagerImpl: NSObject { } DispatchQueue.main.async { WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in - resolve(self.createCookieList(allCookies)) + completion(allCookies) } } } else { - let cookies = HTTPCookieStorage.shared.cookies ?? [] - resolve(createCookieList(cookies)) + completion(HTTPCookieStorage.shared.cookies ?? []) } } @@ -351,12 +350,55 @@ public class CookieManagerImpl: NSObject { } } - private func createCookieList(_ cookies: [HTTPCookie]) -> [String: Any] { - var cookieList: [String: Any] = [:] - for cookie in cookies { - cookieList[cookie.name] = createCookieData(cookie) + private func loadCookies( + url: NSString, + useWebKit: Bool, + reject: @escaping RCTPromiseRejectBlock, + completion: @escaping ([HTTPCookie]) -> Void + ) { + guard let parsedUrl = URL(string: url as String) else { + reject("invalid_url", Self.invalidURLMissingHTTP, nil) + return } - return cookieList + + if useWebKit { + guard #available(iOS 11.0, *) else { + reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) + return + } + + guard let topLevelDomain = parsedUrl.host, !topLevelDomain.isEmpty else { + reject("invalid_url", Self.invalidURLMissingHTTP, nil) + return + } + + DispatchQueue.main.async { + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in + completion( + allCookies.filter { cookie in + CookieDomainLogic.isMatchingDomain( + originDomain: topLevelDomain, + cookieDomain: cookie.domain + ) + } + ) + } + } + } else { + completion(HTTPCookieStorage.shared.cookies(for: parsedUrl) ?? []) + } + } + + private func createCookieList(_ cookies: [HTTPCookie]) -> [String: Any] { + CookieCollectionLogic.asDictionary( + cookies, + name: { $0.name }, + transform: createCookieData + ) + } + + private func createCookieArray(_ cookies: [HTTPCookie]) -> [[String: Any]] { + CookieCollectionLogic.asArray(cookies, transform: createCookieData) } private func makeHTTPCookie(url: URL, props: NSDictionary) throws -> HTTPCookie { diff --git a/src/NativeCookieManager.ts b/src/NativeCookieManager.ts index 76c0776..dbe8b0c 100644 --- a/src/NativeCookieManager.ts +++ b/src/NativeCookieManager.ts @@ -17,6 +17,7 @@ export interface Spec extends TurboModule { setCookie(url: string, cookie: Cookie, useWebKit?: boolean): Promise; setFromResponse(url: string, cookie: string): Promise; getCookies(url: string, useWebKit?: boolean): Promise; + getAsArray(url: string, useWebKit?: boolean): Promise>; /** * @deprecated Make the request with `fetch`/Axios and then call `get()`. * The native name is retained for upstream API compatibility. @@ -30,6 +31,7 @@ export interface Spec extends TurboModule { iosClearWebKit: boolean ): Promise; getAll(useWebKit?: boolean): Promise; + getAllAsArray(useWebKit?: boolean): Promise>; clearByName(url: string, name: string, useWebKit?: boolean): Promise; } diff --git a/src/index.tsx b/src/index.tsx index 4aefb96..024e235 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,10 +29,14 @@ const removeSessionCookies = ( const CookieManager = { getAll: (useWebKit = false) => CookieManagerNative.getAll(useWebKit), + getAllAsArray: (useWebKit = false) => + CookieManagerNative.getAllAsArray(useWebKit), clearAll: (useWebKit = false) => CookieManagerNative.clearAll(useWebKit), clearAllStores: () => CookieManagerNative.clearAllStores(), get: (url: string, useWebKit = false) => CookieManagerNative.getCookies(url, useWebKit), + getAsArray: (url: string, useWebKit = false) => + CookieManagerNative.getAsArray(url, useWebKit), set: (url: string, cookie: Cookie, useWebKit = false) => CookieManagerNative.setCookie(url, cookie, useWebKit), clearByName: (url: string, name: string, useWebKit = false) => diff --git a/swift-tests/CookieCollectionLogicTests/CookieCollectionLogicTests.swift b/swift-tests/CookieCollectionLogicTests/CookieCollectionLogicTests.swift new file mode 100644 index 0000000..8e05f6e --- /dev/null +++ b/swift-tests/CookieCollectionLogicTests/CookieCollectionLogicTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import CookieCollectionLogic + +final class CookieCollectionLogicTests: XCTestCase { + private struct CookieFixture { + let name: String + let path: String + } + + private let cookies = [ + CookieFixture(name: "session", path: "/"), + CookieFixture(name: "session", path: "/account"), + ] + + func testArrayPreservesCookiesWithDuplicateNames() { + let result = CookieCollectionLogic.asArray(cookies) { cookie in + ["name": cookie.name, "path": cookie.path] + } + + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result[0]["path"] as? String, "/") + XCTAssertEqual(result[1]["path"] as? String, "/account") + } + + func testDictionaryRetainsLegacyLastCookieWinsBehavior() { + let result = CookieCollectionLogic.asDictionary( + cookies, + name: { $0.name }, + transform: { cookie in ["name": cookie.name, "path": cookie.path] } + ) + + XCTAssertEqual(result.count, 1) + let session = result["session"] as? [String: String] + XCTAssertEqual(session?["path"], "/account") + } +} From 431e9394d7d6f636a288079d3ef72c6c7d5abee0 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 19:56:11 +0300 Subject: [PATCH 12/22] ci: run Android unit tests --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a565f66..42e59de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,20 +158,16 @@ jobs: fi - name: Install JDK - if: env.turbo_cache_hit != 1 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: distribution: 'zulu' java-version: '17' - name: Finalize Android SDK - if: env.turbo_cache_hit != 1 run: | /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" - - name: Cache Gradle - if: env.turbo_cache_hit != 1 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: | @@ -181,6 +177,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- + - name: Run Android unit tests + working-directory: example/android + run: | + ./gradlew :preeternal_react-native-cookie-manager:testDebugUnitTest --no-daemon --console=plain + - name: Build example for Android env: JAVA_OPTS: "-XX:MaxHeapSize=6g" From d0ba8e9cd90d9e4c4955e6bd24dc5e2b0de66226 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 20:15:16 +0300 Subject: [PATCH 13/22] test(ios): add cookie store integration coverage --- .github/workflows/ci.yml | 33 +++- CHANGELOG.md | 1 + Package.swift | 24 +++ ios/CookieManager.swift | 144 +++++++----------- ios/CookieStoreAccess.swift | 124 +++++++++++++++ .../CookieStoreAccessIntegrationTests.swift | 119 +++++++++++++++ 6 files changed, 353 insertions(+), 92 deletions(-) create mode 100644 ios/CookieStoreAccess.swift create mode 100644 swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42e59de..6db8e33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,12 +204,6 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Run Swift tests - run: | - CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/clang-module-cache" \ - SWIFT_MODULECACHE_PATH="$RUNNER_TEMP/swift-module-cache" \ - swift test - - name: Cache turborepo for iOS uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: @@ -227,11 +221,36 @@ jobs: fi - name: Use appropriate Xcode version - if: env.turbo_cache_hit != 1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 with: xcode-version: ${{ env.XCODE_VERSION }} + - name: Run Swift tests + run: | + CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/clang-module-cache" \ + SWIFT_MODULECACHE_PATH="$RUNNER_TEMP/swift-module-cache" \ + swift test + + - name: Run iOS simulator tests + run: | + set -euo pipefail + + SIMULATOR_UDID=$(xcrun simctl list devices available --json | ruby -rjson -e ' + data = JSON.parse(STDIN.read) + device = data.fetch("devices").values.flatten.find do |item| + item["isAvailable"] && item["name"].start_with?("iPhone") + end + abort "No available iPhone simulator" unless device + puts device.fetch("udid") + ') + + xcodebuild \ + -scheme CookieManagerSwiftTests-Package \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -derivedDataPath "$RUNNER_TEMP/swift-package-ios" \ + -quiet \ + test + - name: Install cocoapods if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3deaad5..175b0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Added Swift coverage for full-store cleanup ordering and WebKit completion. - Added Swift regression coverage for mixed-case, leading-dot root, parent-domain, and substring-rejection matching. - Added Swift and Kotlin regression coverage for duplicate-name array reads and legacy object collapsing. +- Added Foundation and WebKit store integration tests on macOS and iOS Simulator, covering duplicate-name round trips, store selection, and completion-aware deletion/cleanup. ### Compatibility diff --git a/Package.swift b/Package.swift index 92fc62e..6ef7cc5 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "CookieManagerSwiftTests", platforms: [ + .iOS(.v15), .macOS(.v12), ], products: [], @@ -16,6 +17,7 @@ let package = Package( "CookieManager.h", "CookieManager.mm", "CookieManager.swift", + "CookieStoreAccess.swift", "CookieStoreClearLogic.swift", "CookieSessionLogic.swift", ], @@ -30,6 +32,7 @@ let package = Package( "CookieManager.h", "CookieManager.mm", "CookieManager.swift", + "CookieStoreAccess.swift", "CookieStoreClearLogic.swift", ], sources: ["CookieSessionLogic.swift"] @@ -43,6 +46,7 @@ let package = Package( "CookieManager.h", "CookieManager.mm", "CookieManager.swift", + "CookieStoreAccess.swift", "CookieSessionLogic.swift", ], sources: ["CookieStoreClearLogic.swift"] @@ -56,10 +60,25 @@ let package = Package( "CookieManager.mm", "CookieManager.swift", "CookieSessionLogic.swift", + "CookieStoreAccess.swift", "CookieStoreClearLogic.swift", ], sources: ["CookieCollectionLogic.swift"] ), + .target( + name: "CookieStoreAccess", + path: "ios", + exclude: [ + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieStoreAccess.swift"] + ), .testTarget( name: "CookieDomainLogicTests", dependencies: ["CookieDomainLogic"], @@ -80,5 +99,10 @@ let package = Package( dependencies: ["CookieCollectionLogic"], path: "swift-tests/CookieCollectionLogicTests" ), + .testTarget( + name: "CookieStoreAccessIntegrationTests", + dependencies: ["CookieStoreAccess"], + path: "swift-tests/CookieStoreAccessIntegrationTests" + ), ] ) diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index bcb8a0a..4cb7f60 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -1,5 +1,4 @@ import Foundation -import WebKit import React @objc(CookieManagerImpl) @@ -38,14 +37,9 @@ public class CookieManagerImpl: NSObject { reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) return } - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.setCookie(cookie) { - resolve(true) - } - } + CookieStoreAccess.set(cookie, in: .webKit) { resolve(true) } } else { - HTTPCookieStorage.shared.setCookie(cookie) - resolve(true) + CookieStoreAccess.set(cookie, in: .foundation) { resolve(true) } } } @@ -62,7 +56,7 @@ public class CookieManagerImpl: NSObject { } let cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": cookie], for: parsedUrl) for cookieItem in cookies { - HTTPCookieStorage.shared.setCookie(cookieItem) + CookieStoreAccess.set(cookieItem, in: .foundation) {} } resolve(true) } @@ -102,7 +96,7 @@ public class CookieManagerImpl: NSObject { var result: [String: Any] = [:] cookies.forEach { cookie in result[cookie.name] = self.createCookieData(cookie) - HTTPCookieStorage.shared.setCookie(cookie) + CookieStoreAccess.set(cookie, in: .foundation) {} } resolve(result) }.resume() @@ -187,7 +181,6 @@ public class CookieManagerImpl: NSObject { reject("invalid_url", Self.invalidURLMissingHTTP, nil) return } - var found = false if useWebKit { guard #available(iOS 11.0, *) else { reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) @@ -198,46 +191,47 @@ public class CookieManagerImpl: NSObject { return } - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in - let store = WKWebsiteDataStore.default().httpCookieStore - let matchingCookies = allCookies.filter { cookie in - cookie.name == name && - CookieDomainLogic.isMatchingDomain( - originDomain: topLevelDomain, - cookieDomain: cookie.domain - ) - } + CookieStoreAccess.loadAll(from: .webKit) { allCookies in + let matchingCookies = allCookies.filter { cookie in + cookie.name == name && + CookieDomainLogic.isMatchingDomain( + originDomain: topLevelDomain, + cookieDomain: cookie.domain + ) + } - guard !matchingCookies.isEmpty else { - resolve(false) - return - } + guard !matchingCookies.isEmpty else { + resolve(false) + return + } - let deletionGroup = DispatchGroup() - for cookie in matchingCookies { - deletionGroup.enter() - store.delete(cookie) { - deletionGroup.leave() - } + let deletionGroup = DispatchGroup() + for cookie in matchingCookies { + deletionGroup.enter() + CookieStoreAccess.delete(cookie, from: .webKit) { + deletionGroup.leave() } + } - deletionGroup.notify(queue: .main) { - resolve(true) - } + deletionGroup.notify(queue: .main) { + resolve(true) } } } else { - let storage = HTTPCookieStorage.shared - storage.cookies?.forEach { cookie in - if cookie.name == name, - let host = parsedUrl.host, - CookieDomainLogic.isMatchingDomain(originDomain: host, cookieDomain: cookie.domain) { - storage.deleteCookie(cookie) - found = true + CookieStoreAccess.loadAll(from: .foundation) { cookies in + let matchingCookies = cookies.filter { cookie in + if cookie.name == name, + let host = parsedUrl.host, + CookieDomainLogic.isMatchingDomain(originDomain: host, cookieDomain: cookie.domain) { + return true + } + return false } + matchingCookies.forEach { cookie in + CookieStoreAccess.delete(cookie, from: .foundation) {} + } + resolve(!matchingCookies.isEmpty) } - resolve(found) } } @@ -273,13 +267,9 @@ public class CookieManagerImpl: NSObject { reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) return } - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in - completion(allCookies) - } - } + CookieStoreAccess.loadAll(from: .webKit, completion: completion) } else { - completion(HTTPCookieStorage.shared.cookies ?? []) + CookieStoreAccess.loadAll(from: .foundation, completion: completion) } } @@ -292,21 +282,12 @@ public class CookieManagerImpl: NSObject { } private func clearFoundationCookies() { - let cookieStorage = HTTPCookieStorage.shared - cookieStorage.cookies?.forEach { cookieStorage.deleteCookie($0) } + CookieStoreAccess.clearAll(from: .foundation) {} } @available(iOS 11.0, *) private func clearWebKitCookies(completion: @escaping () -> Void) { - DispatchQueue.main.async { - let websiteDataTypes: Set = [WKWebsiteDataTypeCookies] - let dateFrom = Date(timeIntervalSince1970: 0) - WKWebsiteDataStore.default().removeData( - ofTypes: websiteDataTypes, - modifiedSince: dateFrom, - completionHandler: completion - ) - } + CookieStoreAccess.clearAll(from: .webKit, completion: completion) } @objc(removeSessionCookiesWithClearFoundation:clearWebKit:resolve:reject:) @@ -316,34 +297,28 @@ public class CookieManagerImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { - let storage = HTTPCookieStorage.shared + var foundationCookies: [HTTPCookie] = [] + CookieStoreAccess.loadAll(from: .foundation) { foundationCookies = $0 } CookieSessionLogic.removeSessionCookies( - foundationCookies: storage.cookies ?? [], + foundationCookies: foundationCookies, clearFoundation: clearFoundation, clearWebKit: clearWebKit, deleteFoundation: { cookie in - storage.deleteCookie(cookie) + CookieStoreAccess.delete(cookie, from: .foundation) {} }, loadWebKitCookies: { completion in guard #available(iOS 11.0, *) else { completion([]) return } - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.getAllCookies(completion) - } + CookieStoreAccess.loadAll(from: .webKit, completion: completion) }, deleteWebKitCookie: { cookie, completion in guard #available(iOS 11.0, *) else { completion() return } - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.delete( - cookie, - completionHandler: completion - ) - } + CookieStoreAccess.delete(cookie, from: .webKit, completion: completion) } ) { removed in resolve(removed) @@ -367,25 +342,24 @@ public class CookieManagerImpl: NSObject { return } - guard let topLevelDomain = parsedUrl.host, !topLevelDomain.isEmpty else { + guard parsedUrl.host?.isEmpty == false else { reject("invalid_url", Self.invalidURLMissingHTTP, nil) return } - DispatchQueue.main.async { - WKWebsiteDataStore.default().httpCookieStore.getAllCookies { allCookies in - completion( - allCookies.filter { cookie in - CookieDomainLogic.isMatchingDomain( - originDomain: topLevelDomain, - cookieDomain: cookie.domain - ) - } - ) - } - } + CookieStoreAccess.load( + for: parsedUrl, + from: .webKit, + domainMatches: CookieDomainLogic.isMatchingDomain, + completion: completion + ) } else { - completion(HTTPCookieStorage.shared.cookies(for: parsedUrl) ?? []) + CookieStoreAccess.load( + for: parsedUrl, + from: .foundation, + domainMatches: CookieDomainLogic.isMatchingDomain, + completion: completion + ) } } diff --git a/ios/CookieStoreAccess.swift b/ios/CookieStoreAccess.swift new file mode 100644 index 0000000..023c397 --- /dev/null +++ b/ios/CookieStoreAccess.swift @@ -0,0 +1,124 @@ +import Foundation +import WebKit + +enum CookieStoreKind { + case foundation + case webKit +} + +enum CookieStoreAccess { + static func set( + _ cookie: HTTPCookie, + in store: CookieStoreKind, + completion: @escaping () -> Void + ) { + switch store { + case .foundation: + HTTPCookieStorage.shared.setCookie(cookie) + completion() + case .webKit: + guard #available(iOS 11.0, macOS 10.13, *) else { + completion() + return + } + DispatchQueue.main.async { + WKWebsiteDataStore.default().httpCookieStore.setCookie( + cookie, + completionHandler: completion + ) + } + } + } + + static func loadAll( + from store: CookieStoreKind, + completion: @escaping ([HTTPCookie]) -> Void + ) { + switch store { + case .foundation: + completion(HTTPCookieStorage.shared.cookies ?? []) + case .webKit: + guard #available(iOS 11.0, macOS 10.13, *) else { + completion([]) + return + } + DispatchQueue.main.async { + WKWebsiteDataStore.default().httpCookieStore.getAllCookies(completion) + } + } + } + + static func load( + for url: URL, + from store: CookieStoreKind, + domainMatches: @escaping ( + _ originDomain: String, + _ cookieDomain: String + ) -> Bool, + completion: @escaping ([HTTPCookie]) -> Void + ) { + switch store { + case .foundation: + completion(HTTPCookieStorage.shared.cookies(for: url) ?? []) + case .webKit: + guard let host = url.host, !host.isEmpty else { + completion([]) + return + } + loadAll(from: .webKit) { cookies in + completion( + cookies.filter { cookie in + domainMatches(host, cookie.domain) + } + ) + } + } + } + + static func delete( + _ cookie: HTTPCookie, + from store: CookieStoreKind, + completion: @escaping () -> Void + ) { + switch store { + case .foundation: + HTTPCookieStorage.shared.deleteCookie(cookie) + completion() + case .webKit: + guard #available(iOS 11.0, macOS 10.13, *) else { + completion() + return + } + DispatchQueue.main.async { + WKWebsiteDataStore.default().httpCookieStore.delete( + cookie, + completionHandler: completion + ) + } + } + } + + static func clearAll( + from store: CookieStoreKind, + completion: @escaping () -> Void + ) { + switch store { + case .foundation: + let storage = HTTPCookieStorage.shared + storage.cookies?.forEach { storage.deleteCookie($0) } + completion() + case .webKit: + guard #available(iOS 11.0, macOS 10.13, *) else { + completion() + return + } + DispatchQueue.main.async { + WKWebsiteDataStore.default().removeData( + ofTypes: [WKWebsiteDataTypeCookies], + modifiedSince: Date(timeIntervalSince1970: 0), + completionHandler: completion + ) + } + } + } +} diff --git a/swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift b/swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift new file mode 100644 index 0000000..1492652 --- /dev/null +++ b/swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift @@ -0,0 +1,119 @@ +import Foundation +import XCTest +@testable import CookieStoreAccess + +final class CookieStoreAccessIntegrationTests: XCTestCase { + func testFoundationRoundTripPreservesDuplicateNamesAndWaitsForDeletion() async { + await assertRoundTrip(in: .foundation) + } + + func testWebKitRoundTripPreservesDuplicateNamesAndWaitsForDeletion() async { + await assertRoundTrip(in: .webKit) + } + + func testClearAllTargetsSelectedStoreAndWaitsForCompletion() async { + let identifier = UUID().uuidString.lowercased() + let name = "cookie_manager_\(identifier.replacingOccurrences(of: "-", with: ""))" + let host = "\(identifier).cookie-manager.invalid" + let url = URL(string: "https://\(host)/")! + + await set( + makeCookie(name: name, value: "foundation", domain: host, path: "/"), + in: .foundation + ) + await set( + makeCookie(name: name, value: "webKit", domain: host, path: "/"), + in: .webKit + ) + + await clearAll(from: .foundation) + let foundationAfterClear = await load(for: url, from: .foundation) + let webKitBeforeClear = await load(for: url, from: .webKit) + XCTAssertTrue(foundationAfterClear.filter { $0.name == name }.isEmpty) + XCTAssertEqual(webKitBeforeClear.first { $0.name == name }?.value, "webKit") + + await clearAll(from: .webKit) + let webKitAfterClear = await load(for: url, from: .webKit) + XCTAssertTrue(webKitAfterClear.filter { $0.name == name }.isEmpty) + } + + private func assertRoundTrip(in store: CookieStoreKind) async { + let identifier = UUID().uuidString.lowercased() + let name = "cookie_manager_\(identifier.replacingOccurrences(of: "-", with: ""))" + let host = "\(identifier).cookie-manager.invalid" + let url = URL(string: "https://\(host)/account/profile")! + let cookies = [ + makeCookie(name: name, value: "root", domain: host, path: "/"), + makeCookie(name: name, value: "account", domain: host, path: "/account"), + ] + + for cookie in cookies { + await set(cookie, in: store) + } + + let stored = await load(for: url, from: store).filter { $0.name == name } + XCTAssertEqual(Set(stored.map(\.value)), Set(["root", "account"])) + XCTAssertEqual(Set(stored.map(\.path)), Set(["/", "/account"])) + + for cookie in stored { + await delete(cookie, from: store) + } + + let remaining = await load(for: url, from: store).filter { $0.name == name } + XCTAssertTrue(remaining.isEmpty) + await clearAll(from: store) + } + + private func makeCookie( + name: String, + value: String, + domain: String, + path: String + ) -> HTTPCookie { + HTTPCookie(properties: [ + .name: name, + .value: value, + .domain: domain, + .path: path, + .secure: true, + ])! + } + + private func set(_ cookie: HTTPCookie, in store: CookieStoreKind) async { + await withCheckedContinuation { continuation in + CookieStoreAccess.set(cookie, in: store) { + continuation.resume() + } + } + } + + private func load(for url: URL, from store: CookieStoreKind) async -> [HTTPCookie] { + await withCheckedContinuation { continuation in + CookieStoreAccess.load( + for: url, + from: store, + domainMatches: { originDomain, cookieDomain in + originDomain.caseInsensitiveCompare(cookieDomain) == .orderedSame + } + ) { cookies in + continuation.resume(returning: cookies) + } + } + } + + private func delete(_ cookie: HTTPCookie, from store: CookieStoreKind) async { + await withCheckedContinuation { continuation in + CookieStoreAccess.delete(cookie, from: store) { + continuation.resume() + } + } + } + + private func clearAll(from store: CookieStoreKind) async { + await withCheckedContinuation { continuation in + CookieStoreAccess.clearAll(from: store) { + continuation.resume() + } + } + } +} From c114ba18695d84a3de710433ece7bfdd07abd032 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 20:18:04 +0300 Subject: [PATCH 14/22] docs: polish useWebKit description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5005ad9..86cf6b4 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ The public API remains compatible with `@react-native-cookies/cookies`. | `setFromResponse(url, cookieHeader)` | iOS, Android | `Promise` | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | | `getFromResponse(url)` | iOS, Android | `Promise` | Deprecated; performs a GET and updates Foundation on iOS. | -Seven methods accept `useWebKit`: `set()`, `get()`, `getAsArray()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. +`useWebKit` is available on `set()`, `get()`, `getAsArray()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. `removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. From 463b0fe6b8d55f14f7178ad7cd4a2ecb897a6f1f Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 21:14:13 +0300 Subject: [PATCH 15/22] feat: add getCookieHeader API --- CHANGELOG.md | 2 + Package.swift | 25 ++++ README.md | 8 +- .../CookieManagerModule.kt | 13 ++ .../CookieReadLogic.kt | 5 + .../CookieReadLogicTest.kt | 18 +++ ios/CookieHeaderLogic.swift | 80 +++++++++++++ ios/CookieManager.mm | 25 ++++ ios/CookieManager.swift | 35 ++++++ src/NativeCookieManager.ts | 1 + src/index.tsx | 2 + .../CookieHeaderLogicTests.swift | 112 ++++++++++++++++++ 12 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 ios/CookieHeaderLogic.swift create mode 100644 swift-tests/CookieHeaderLogicTests/CookieHeaderLogicTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 175b0f3..5cece14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `clearAllStores()` for deterministic full-store cleanup. It clears Foundation and the default WebKit store on iOS, or the shared cookie store on Android, and resolves only after native cleanup (including Android persistence) completes. Existing `clearAll(useWebKit?)` behavior is unchanged. - Added `getAsArray()` on both platforms and iOS-only `getAllAsArray()` to preserve cookies that share a name but differ by domain or path. Existing `get()` and `getAll()` retain their upstream-compatible last-cookie-wins object shape. +- Added `getCookieHeader(url, useWebKit?)` to read matching cookies as a ready-to-use `Cookie` request-header value. It preserves duplicate names and returns an empty string when no cookies match. ### Fixed @@ -31,6 +32,7 @@ - Added Swift regression coverage for mixed-case, leading-dot root, parent-domain, and substring-rejection matching. - Added Swift and Kotlin regression coverage for duplicate-name array reads and legacy object collapsing. - Added Foundation and WebKit store integration tests on macOS and iOS Simulator, covering duplicate-name round trips, store selection, and completion-aware deletion/cleanup. +- Added Swift coverage for request-header formatting and WebKit domain/path/secure/expiry matching, plus Android coverage for raw header passthrough and empty stores. ### Compatibility diff --git a/Package.swift b/Package.swift index 6ef7cc5..4922640 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( name: "CookieDomainLogic", path: "ios", exclude: [ + "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieManager.h", "CookieManager.mm", @@ -27,6 +28,7 @@ let package = Package( name: "CookieSessionLogic", path: "ios", exclude: [ + "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", @@ -41,6 +43,7 @@ let package = Package( name: "CookieStoreClearLogic", path: "ios", exclude: [ + "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", @@ -55,6 +58,7 @@ let package = Package( name: "CookieCollectionLogic", path: "ios", exclude: [ + "CookieHeaderLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", "CookieManager.mm", @@ -69,6 +73,7 @@ let package = Package( name: "CookieStoreAccess", path: "ios", exclude: [ + "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", @@ -79,6 +84,21 @@ let package = Package( ], sources: ["CookieStoreAccess.swift"] ), + .target( + name: "CookieHeaderLogic", + path: "ios", + exclude: [ + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreAccess.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieHeaderLogic.swift"] + ), .testTarget( name: "CookieDomainLogicTests", dependencies: ["CookieDomainLogic"], @@ -104,5 +124,10 @@ let package = Package( dependencies: ["CookieStoreAccess"], path: "swift-tests/CookieStoreAccessIntegrationTests" ), + .testTarget( + name: "CookieHeaderLogicTests", + dependencies: ["CookieHeaderLogic"], + path: "swift-tests/CookieHeaderLogicTests" + ), ] ) diff --git a/README.md b/README.md index 86cf6b4..e54b72b 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ const cookies = await CookieManager.get(url); Standard React Native networking handles cookies by default. Credentials options are only needed if your client configuration explicitly disables cookie handling. `get()` only reads the native cookie store; it does not make a request. +If a custom client or Axios adapter does not use React Native's native cookie handling, `getCookieHeader(url)` returns a ready-to-use `Cookie` request-header value. Do not add it to standard Fetch/Axios requests, where native networking already attaches cookies. + The upstream-compatible `getFromResponse(url)` remains available but is deprecated: it performs a separate GET, follows redirects, and updates the cookie store without options for headers, authentication, timeout, or cancellation. Prefer the flow above to avoid a duplicate request and its side effects. ### Manage the cookie store @@ -113,6 +115,7 @@ The public API remains compatible with `@react-native-cookies/cookies`. | `set(url, cookie, useWebKit?)` | iOS, Android | `Promise` | Stores a cookie. On iOS, uses Foundation by default or default WebKit when `true`. | | `get(url, useWebKit?)` | iOS, Android | `Promise` | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | | `getAsArray(url, useWebKit?)` | iOS, Android | `Promise>` | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | +| `getCookieHeader(url, useWebKit?)` | iOS, Android | `Promise` | Returns the selected store's matching cookies as a `Cookie` request-header value, or an empty string. | | `clearAll(useWebKit?)` | iOS, Android | `Promise` | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | | `clearAllStores()` | iOS, Android | `Promise` | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | | `getAll(useWebKit?)` | iOS | `Promise` | Reads Foundation by default or default WebKit when `true`. | @@ -123,7 +126,7 @@ The public API remains compatible with `@react-native-cookies/cookies`. | `setFromResponse(url, cookieHeader)` | iOS, Android | `Promise` | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | | `getFromResponse(url)` | iOS, Android | `Promise` | Deprecated; performs a GET and updates Foundation on iOS. | -`useWebKit` is available on `set()`, `get()`, `getAsArray()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. +`useWebKit` is available on `set()`, `get()`, `getAsArray()`, `getCookieHeader()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. `removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. @@ -150,7 +153,8 @@ On Android, metadata is populated when the installed WebView supports `GET_COOKI - iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). - Pass `useWebKit: true` to operate on the default WKWebView cookie store. For network-only flows, omit it to use `NSHTTPCookieStorage`. -- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()`, `getAsArray()`, `getAll()`, or `getAllAsArray()` likewise requires two calls; results are returned separately and are not merged. +- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()`, `getAsArray()`, `getCookieHeader()`, `getAll()`, or `getAllAsArray()` likewise requires two calls; results are returned separately and are not merged. +- `getCookieHeader(url, true)` filters default WebKit cookies by domain, path, `Secure`, and expiry. A URL alone cannot reproduce WebKit's `SameSite`, partition, or third-party request context, so do not treat it as the exact header of an embedded WebView request. - Use `clearAllStores()` when logout must clear both app-accessible stores. The library cannot access a non-persistent or custom store owned by a specific WebView. - On Android the flag is ignored; WebView and native use the same store. diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index 435547a..adc240c 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -91,6 +91,19 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } } + override fun getCookieHeader(url: String, useWebKit: Boolean?, promise: Promise) { + if (url.isEmpty()) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + try { + promise.resolve(readCookieHeader(url) { getCookieManager().getCookie(it) }) + } catch (e: Exception) { + promise.reject("get_cookie_header_error", e) + } + } + override fun getFromResponse(url: String, promise: Promise) { val parsedUrl = try { URL(url) diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt index d890283..63c0e8a 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt @@ -37,6 +37,11 @@ internal fun readCookieHeaders( return CookieReadResult.Legacy(legacyReader()) } +internal fun readCookieHeader( + url: String, + reader: (String) -> String? +): String = reader(url).orEmpty() + internal fun parseCookieReadResult( result: CookieReadResult, parsedAtMillis: Long = System.currentTimeMillis() diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt index f3aa499..4cfe00d 100644 --- a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt @@ -11,6 +11,24 @@ import java.util.Locale import java.util.TimeZone class CookieReadLogicTest { + @Test + fun returnsRawRequestCookieHeaderForUrl() { + var requestedUrl: String? = null + + val result = readCookieHeader("https://example.com/account") { url -> + requestedUrl = url + "session=root; session=account; theme=dark" + } + + assertEquals("https://example.com/account", requestedUrl) + assertEquals("session=root; session=account; theme=dark", result) + } + + @Test + fun returnsEmptyHeaderWhenStoreHasNoMatchingCookies() { + assertEquals("", readCookieHeader("https://example.com") { null }) + } + @Test fun usesDetailedReaderWhenSupported() { var legacyReaderCalled = false diff --git a/ios/CookieHeaderLogic.swift b/ios/CookieHeaderLogic.swift new file mode 100644 index 0000000..cab8fb1 --- /dev/null +++ b/ios/CookieHeaderLogic.swift @@ -0,0 +1,80 @@ +import Foundation + +enum CookieHeaderLogic { + static func headerValue(for cookies: [HTTPCookie]) -> String { + let fields = HTTPCookie.requestHeaderFields(with: cookies) + return fields.first { key, _ in + key.caseInsensitiveCompare("Cookie") == .orderedSame + }?.value ?? "" + } + + static func matchingWebKitCookies( + for url: URL, + from cookies: [HTTPCookie], + now: Date = Date() + ) -> [HTTPCookie] { + guard let host = url.host, !host.isEmpty else { + return [] + } + + let requestPath = URLComponents( + url: url, + resolvingAgainstBaseURL: false + )?.percentEncodedPath.nonEmpty ?? "/" + let scheme = url.scheme?.lowercased() + let secureRequest = scheme == "https" || scheme == "wss" + + return cookies.enumerated() + .filter { _, cookie in + domainMatches(host: host, cookieDomain: cookie.domain) && + pathMatches(requestPath: requestPath, cookiePath: cookie.path) && + (!cookie.isSecure || secureRequest) && + (cookie.expiresDate.map { $0 > now } ?? true) + } + .sorted { lhs, rhs in + let lhsLength = lhs.element.path.utf8.count + let rhsLength = rhs.element.path.utf8.count + return lhsLength == rhsLength ? lhs.offset < rhs.offset : lhsLength > rhsLength + } + .map(\.element) + } + + private static func domainMatches(host: String, cookieDomain: String) -> Bool { + let normalizedHost = host.lowercased() + let normalizedDomain = cookieDomain.lowercased() + + guard normalizedDomain.hasPrefix(".") else { + return normalizedHost == normalizedDomain + } + + let parentDomain = String(normalizedDomain.dropFirst()) + return !parentDomain.isEmpty && + (normalizedHost == parentDomain || normalizedHost.hasSuffix(".\(parentDomain)")) + } + + private static func pathMatches(requestPath: String, cookiePath: String) -> Bool { + let normalizedCookiePath = cookiePath.isEmpty ? "/" : cookiePath + + if requestPath == normalizedCookiePath { + return true + } + guard requestPath.hasPrefix(normalizedCookiePath) else { + return false + } + if normalizedCookiePath.hasSuffix("/") { + return true + } + + let boundaryIndex = requestPath.index( + requestPath.startIndex, + offsetBy: normalizedCookiePath.count + ) + return boundaryIndex < requestPath.endIndex && requestPath[boundaryIndex] == "/" + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/ios/CookieManager.mm b/ios/CookieManager.mm index db4aa46..e7aed77 100644 --- a/ios/CookieManager.mm +++ b/ios/CookieManager.mm @@ -73,6 +73,14 @@ - (void)handleGetAsArray:(NSString *)url [_impl getAsArray:url useWebKit:[useWebKit boolValue] resolve:resolve reject:reject]; } +- (void)handleGetCookieHeader:(NSString *)url + useWebKit:(NSNumber *)useWebKit + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_impl getCookieHeader:url useWebKit:[useWebKit boolValue] resolve:resolve reject:reject]; +} + - (void)handleClearAll:(NSNumber *)useWebKit resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject @@ -198,6 +206,14 @@ - (void)getAsArray:(NSString *)url [self handleGetAsArray:url useWebKit:useWebKit resolve:resolve reject:reject]; } +- (void)getCookieHeader:(NSString *)url + useWebKit:(NSNumber *)useWebKit + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self handleGetCookieHeader:url useWebKit:useWebKit resolve:resolve reject:reject]; +} + - (void)clearAll:(NSNumber *)useWebKit resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject @@ -306,6 +322,15 @@ - (void)removeSessionCookies:(BOOL)iosClearFoundation [self handleGetAsArray:url useWebKit:@(useWebKit) resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(getCookieHeader + : (NSString *)url useWebKit + : (BOOL)useWebKit resolve + : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + [self handleGetCookieHeader:url useWebKit:@(useWebKit) resolve:resolve reject:reject]; +} + RCT_EXPORT_METHOD(clearAll : (BOOL)useWebKit resolve : (RCTPromiseResolveBlock)resolve reject diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index 4cb7f60..e76554e 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -126,6 +126,41 @@ public class CookieManagerImpl: NSObject { } } + @objc(getCookieHeader:useWebKit:resolve:reject:) + public func getCookieHeader( + url: NSString, + useWebKit: Bool, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard let parsedUrl = URL(string: url as String) else { + reject("invalid_url", Self.invalidURLMissingHTTP, nil) + return + } + + if useWebKit { + guard #available(iOS 11.0, *) else { + reject("web_kit_unavailable", Self.notAvailableErrorMessage, nil) + return + } + guard parsedUrl.host?.isEmpty == false else { + reject("invalid_url", Self.invalidURLMissingHTTP, nil) + return + } + + CookieStoreAccess.loadAll(from: .webKit) { cookies in + let matchingCookies = CookieHeaderLogic.matchingWebKitCookies( + for: parsedUrl, + from: cookies + ) + resolve(CookieHeaderLogic.headerValue(for: matchingCookies)) + } + } else { + let cookies = HTTPCookieStorage.shared.cookies(for: parsedUrl) ?? [] + resolve(CookieHeaderLogic.headerValue(for: cookies)) + } + } + @objc(clearAll:resolve:reject:) public func clearAll( useWebKit: Bool, diff --git a/src/NativeCookieManager.ts b/src/NativeCookieManager.ts index dbe8b0c..c7b9042 100644 --- a/src/NativeCookieManager.ts +++ b/src/NativeCookieManager.ts @@ -18,6 +18,7 @@ export interface Spec extends TurboModule { setFromResponse(url: string, cookie: string): Promise; getCookies(url: string, useWebKit?: boolean): Promise; getAsArray(url: string, useWebKit?: boolean): Promise>; + getCookieHeader(url: string, useWebKit?: boolean): Promise; /** * @deprecated Make the request with `fetch`/Axios and then call `get()`. * The native name is retained for upstream API compatibility. diff --git a/src/index.tsx b/src/index.tsx index 024e235..82f7b2a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -37,6 +37,8 @@ const CookieManager = { CookieManagerNative.getCookies(url, useWebKit), getAsArray: (url: string, useWebKit = false) => CookieManagerNative.getAsArray(url, useWebKit), + getCookieHeader: (url: string, useWebKit = false) => + CookieManagerNative.getCookieHeader(url, useWebKit), set: (url: string, cookie: Cookie, useWebKit = false) => CookieManagerNative.setCookie(url, cookie, useWebKit), clearByName: (url: string, name: string, useWebKit = false) => diff --git a/swift-tests/CookieHeaderLogicTests/CookieHeaderLogicTests.swift b/swift-tests/CookieHeaderLogicTests/CookieHeaderLogicTests.swift new file mode 100644 index 0000000..c6ee328 --- /dev/null +++ b/swift-tests/CookieHeaderLogicTests/CookieHeaderLogicTests.swift @@ -0,0 +1,112 @@ +import Foundation +import XCTest +@testable import CookieHeaderLogic + +final class CookieHeaderLogicTests: XCTestCase { + func testFormatsDuplicateNamesWithoutCollapsingThem() { + let cookies = [ + makeCookie(name: "session", value: "root", domain: "example.com", path: "/"), + makeCookie(name: "session", value: "account", domain: "example.com", path: "/account"), + ] + + let header = CookieHeaderLogic.headerValue(for: cookies) + + XCTAssertTrue(header.contains("session=root")) + XCTAssertTrue(header.contains("session=account")) + } + + func testReturnsEmptyValueWhenThereAreNoCookies() { + XCTAssertEqual(CookieHeaderLogic.headerValue(for: []), "") + } + + func testWebKitMatchingAppliesDomainPathSecureAndExpiryRules() { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let cookies = [ + makeCookie(name: "root", domain: "example.com", path: "/"), + makeCookie(name: "account", domain: "example.com", path: "/account", secure: true), + makeCookie(name: "admin", domain: "example.com", path: "/admin"), + makeCookie( + name: "expired", + domain: "example.com", + path: "/", + expires: now.addingTimeInterval(-1) + ), + makeCookie(name: "other", domain: "other.example", path: "/"), + ] + + let result = CookieHeaderLogic.matchingWebKitCookies( + for: URL(string: "https://example.com/account/profile")!, + from: cookies, + now: now + ) + + XCTAssertEqual(result.map(\.name), ["account", "root"]) + } + + func testSecureCookieIsExcludedFromInsecureRequest() { + let cookies = [ + makeCookie(name: "plain", domain: "example.com", path: "/"), + makeCookie(name: "secure", domain: "example.com", path: "/", secure: true), + ] + + let result = CookieHeaderLogic.matchingWebKitCookies( + for: URL(string: "http://example.com/")!, + from: cookies + ) + + XCTAssertEqual(result.map(\.name), ["plain"]) + } + + func testHostOnlyCookieDoesNotMatchSubdomainButDomainCookieDoes() { + let cookies = [ + makeCookie(name: "hostOnly", domain: "example.com", path: "/"), + makeCookie(name: "domain", domain: ".example.com", path: "/"), + ] + + let result = CookieHeaderLogic.matchingWebKitCookies( + for: URL(string: "https://api.example.com/")!, + from: cookies + ) + + XCTAssertEqual(result.map(\.name), ["domain"]) + } + + func testCookiePathRequiresDirectoryBoundary() { + let cookie = makeCookie(name: "api", domain: "example.com", path: "/api") + + let matching = CookieHeaderLogic.matchingWebKitCookies( + for: URL(string: "https://example.com/api/users")!, + from: [cookie] + ) + let nonMatching = CookieHeaderLogic.matchingWebKitCookies( + for: URL(string: "https://example.com/apiv2")!, + from: [cookie] + ) + + XCTAssertEqual(matching.map(\.name), ["api"]) + XCTAssertTrue(nonMatching.isEmpty) + } + + private func makeCookie( + name: String, + value: String = "value", + domain: String, + path: String, + secure: Bool = false, + expires: Date? = nil + ) -> HTTPCookie { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: path, + ] + if secure { + properties[.secure] = true + } + if let expires { + properties[.expires] = expires + } + return HTTPCookie(properties: properties)! + } +} From 80513e577e03ee6cdb3820b73187a7e4ed52347f Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 21:34:58 +0300 Subject: [PATCH 16/22] feat(android): support clearByName --- CHANGELOG.md | 4 +- README.md | 10 +- .../CookieDeletionLogic.kt | 131 ++++++++++++ .../CookieManagerModule.kt | 49 ++++- .../CookieDeletionLogicTest.kt | 196 ++++++++++++++++++ 5 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 android/src/main/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogic.kt create mode 100644 android/src/test/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogicTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cece14..cb347b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added `clearAllStores()` for deterministic full-store cleanup. It clears Foundation and the default WebKit store on iOS, or the shared cookie store on Android, and resolves only after native cleanup (including Android persistence) completes. Existing `clearAll(useWebKit?)` behavior is unchanged. - Added `getAsArray()` on both platforms and iOS-only `getAllAsArray()` to preserve cookies that share a name but differ by domain or path. Existing `get()` and `getAll()` retain their upstream-compatible last-cookie-wins object shape. - Added `getCookieHeader(url, useWebKit?)` to read matching cookies as a ready-to-use `Cookie` request-header value. It preserves duplicate names and returns an empty string when no cookies match. +- Added Android support for `clearByName(url, name)`. When the device's Android System WebView provider supports `GET_COOKIE_INFO`, it expires every same-name domain/path variant applicable to the supplied URL, waits for all deletion callbacks, then flushes persistence. Devices with an older provider reject with `not_supported` instead of risking incomplete deletion. ### Fixed @@ -20,7 +21,7 @@ - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. - Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. - iOS `get(url, true)` and `clearByName()` now match cookie domains case-insensitively in both Foundation and WebKit flows. Leading-dot domains apply to their root host as well as subdomains, while strict domain boundaries remain enforced. -- Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews transparently retain the previous name/value-only behavior. +- Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider transparently retain the previous name/value-only behavior. - `removeSessionCookies()` now works on iOS and resolves only after session cookies have been removed from the selected stores; persistent cookies remain untouched. Foundation and the default WebKit store are selected by default. On Android, awaiting it now also guarantees that its automatic `flush()` has completed, so an immediate shutdown cannot leave removed session cookies on disk. - iOS cookie cleanup no longer invokes the unrelated deprecated `UserDefaults.synchronize()` API. @@ -33,6 +34,7 @@ - Added Swift and Kotlin regression coverage for duplicate-name array reads and legacy object collapsing. - Added Foundation and WebKit store integration tests on macOS and iOS Simulator, covering duplicate-name round trips, store selection, and completion-aware deletion/cleanup. - Added Swift coverage for request-header formatting and WebKit domain/path/secure/expiry matching, plus Android coverage for raw header passthrough and empty stores. +- Added Android coverage for host-only, domain, path, prefixed, and partitioned cookie deletion, unsupported providers, callback ordering, and rejected writes. ### Compatibility diff --git a/README.md b/README.md index e54b72b..975df06 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ const cookieVariants = await CookieManager.getAsArray('https://example.com'); // iOS only: get all cookies const allCookies = await CookieManager.getAll(); -// Clear by name (iOS only) +// Clear cookies named "session" await CookieManager.clearByName('https://example.com', 'session'); // Clear Foundation on iOS; clear the shared store on Android @@ -120,7 +120,7 @@ The public API remains compatible with `@react-native-cookies/cookies`. | `clearAllStores()` | iOS, Android | `Promise` | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | | `getAll(useWebKit?)` | iOS | `Promise` | Reads Foundation by default or default WebKit when `true`. | | `getAllAsArray(useWebKit?)` | iOS | `Promise>` | Reads the selected iOS store without collapsing cookies that share a name. | -| `clearByName(url, name, useWebKit?)` | iOS | `Promise` | Clears matching cookies from Foundation by default or default WebKit when `true`. | +| `clearByName(url, name, useWebKit?)` | iOS, Android | `Promise` | Clears same-name cookies from the selected iOS store, or variants applicable to `url` in the shared Android store. | | `flush()` | iOS, Android | `Promise` | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | | `removeSessionCookies(options?)` | iOS, Android | `Promise` | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | | `setFromResponse(url, cookieHeader)` | iOS, Android | `Promise` | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | @@ -130,6 +130,8 @@ The public API remains compatible with `@react-native-cookies/cookies`. `removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. +On Android, `clearByName()` relies on `GET_COOKIE_INFO` support in the device's Android System WebView provider. It rejects with `not_supported` on devices with an older provider. The method clears every same-name domain/path variant visible to the supplied URL. A cookie restricted to `/account` is not visible from a `/` URL, so use a matching path (and multiple calls for unrelated paths). On iOS, the method clears same-domain variants across all paths in the selected store. + ### Cookie shape ```ts @@ -147,7 +149,7 @@ type Cookie = { `Cookies` is keyed by cookie name, so `get()` and `getAll()` retain only the last item when multiple cookies share a name. This legacy behavior is preserved for upstream compatibility. Use `getAsArray()` or `getAllAsArray()` when `domain`/`path` variants must remain separate. -On Android, metadata is populated when the installed WebView supports `GET_COOKIE_INFO`. Older WebViews fall back to legacy name/value parsing, so `domain`, `path`, and `expires` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. +On Android, metadata is populated when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider fall back to legacy name/value parsing, so `domain`, `path`, and `expires` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. ### WebKit on iOS @@ -167,7 +169,7 @@ On Android, metadata is populated when the installed WebView supports `GET_COOKI - On iOS, Foundation and WebKit manage persistence automatically. There is no public explicit flush API, so `flush()` is a no-op. - On Android, the library automatically flushes the shared WebView cookie store after its mutations. Current WebView implementations may also restore session cookies—cookies without an expiry—after a process restart. -On Android, mutation methods automatically flush before their Promises resolve. Calling `flush()` immediately after awaiting `set()`, `setFromResponse()`, `getFromResponse()`, `clearAll()`, `clearAllStores()`, or `removeSessionCookies()` is redundant. Use it only as an explicit persistence barrier after the shared Android store was changed outside this library. +On Android, mutation methods automatically flush before their Promises resolve. Calling `flush()` immediately after awaiting `set()`, `setFromResponse()`, `getFromResponse()`, `clearByName()`, `clearAll()`, `clearAllStores()`, or `removeSessionCookies()` is redundant. Use it only as an explicit persistence barrier after the shared Android store was changed outside this library. The library intentionally does not maintain a separate cookie backup or silently replay cookies on startup. That could resurrect expired or logged-out authentication state and would require the application to choose appropriate secure storage. Prefer server-defined persistent cookies; call `removeSessionCookies()` before the first request or WebView load when the application requires a clean session on launch. diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogic.kt new file mode 100644 index 0000000..10f0826 --- /dev/null +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogic.kt @@ -0,0 +1,131 @@ +package com.preeternal.reactnativecookiemanager + +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +internal sealed interface CookieDeletionPlan { + data object Unsupported : CookieDeletionPlan + + data class Ready(val headers: List) : CookieDeletionPlan +} + +internal fun planCookieDeletion( + name: String, + supportsDetailedRead: Boolean, + detailedReader: () -> List +): CookieDeletionPlan { + if (!supportsDetailedRead) return CookieDeletionPlan.Unsupported + + val storedHeaders = try { + detailedReader() + } catch (_: UnsupportedOperationException) { + return CookieDeletionPlan.Unsupported + } + + return CookieDeletionPlan.Ready( + storedHeaders.mapNotNull { deletionHeader(it, name) } + ) +} + +internal fun executeCookieDeletion( + headers: List, + setter: (header: String, callback: (Boolean) -> Unit) -> Unit, + completion: (Result) -> Unit +) { + if (headers.isEmpty()) { + completion(Result.success(false)) + return + } + + val remaining = AtomicInteger(headers.size) + val completed = AtomicBoolean(false) + val failure = AtomicReference(null) + + fun finishOne() { + if (remaining.decrementAndGet() == 0 && completed.compareAndSet(false, true)) { + val error = failure.get() + if (error == null) { + completion(Result.success(true)) + } else { + completion(Result.failure(error)) + } + } + } + + for (header in headers) { + try { + setter(header) { accepted -> + if (!accepted) { + failure.compareAndSet( + null, + CookieDeletionException("Android System WebView provider rejected a cookie deletion") + ) + } + finishOne() + } + } catch (error: Exception) { + failure.compareAndSet(null, error) + finishOne() + } + } +} + +private fun deletionHeader(storedHeader: String, requestedName: String): String? { + val segments = storedHeader.split(';') + val nameValue = segments.firstOrNull()?.trim().orEmpty() + val separator = nameValue.indexOf('=') + if (separator <= 0) return null + + val storedName = nameValue.substring(0, separator).trim() + if (storedName != requestedName) return null + + val attributes = buildMap { + for (segment in segments.drop(1)) { + val attribute = segment.trim() + if (attribute.isEmpty()) continue + + val attributeSeparator = attribute.indexOf('=') + val key = if (attributeSeparator < 0) { + attribute + } else { + attribute.substring(0, attributeSeparator) + }.trim().lowercase(Locale.US) + val value = if (attributeSeparator < 0) { + null + } else { + attribute.substring(attributeSeparator + 1).trim() + } + put(key, value) + } + } + + val domain = attributes["domain"] + val path = attributes["path"] + require(!domain.isNullOrEmpty() && !path.isNullOrEmpty()) { + "GET_COOKIE_INFO returned a matching cookie without domain or path" + } + + return buildString { + append(storedName) + append("=; Path=") + append(path) + if (domain.startsWith('.')) { + append("; Domain=") + append(domain) + } + append("; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT") + if (attributes.containsKey("secure") || attributes.containsKey("partitioned")) { + append("; Secure") + } + if (attributes.containsKey("httponly")) { + append("; HttpOnly") + } + if (attributes.containsKey("partitioned")) { + append("; Partitioned") + } + } +} + +private class CookieDeletionException(message: String) : Exception(message) diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index adc240c..d83032e 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -131,7 +131,51 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } override fun clearByName(url: String, name: String, useWebKit: Boolean?, promise: Promise) { - promise.reject("not_supported", CLEAR_BY_NAME_NOT_SUPPORTED) + if (url.isEmpty()) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + val cookieManager = try { + getCookieManager() + } catch (e: Exception) { + promise.reject("clear_by_name_error", e) + return + } + + val plan = try { + planCookieDeletion( + name = name, + supportsDetailedRead = WebViewFeature.isFeatureSupported(WebViewFeature.GET_COOKIE_INFO), + detailedReader = { CookieManagerCompat.getCookieInfo(cookieManager, url) } + ) + } catch (e: Exception) { + promise.reject("clear_by_name_error", e) + return + } + + when (plan) { + CookieDeletionPlan.Unsupported -> { + promise.reject("not_supported", CLEAR_BY_NAME_NOT_SUPPORTED) + } + is CookieDeletionPlan.Ready -> executeCookieDeletion( + headers = plan.headers, + setter = { header, callback -> + cookieManager.setCookie(url, header) { accepted -> callback(accepted == true) } + } + ) { result -> + result.fold( + onSuccess = { removed -> + if (removed) { + flushAndResolve(cookieManager, true, promise, "clear_by_name_error") + } else { + promise.resolve(false) + } + }, + onFailure = { error -> promise.reject("clear_by_name_error", error) } + ) + } + } } override fun clearAll(useWebKit: Boolean?, promise: Promise) { @@ -652,7 +696,8 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : "Invalid URL: It may be missing a protocol (ex. http:// or https://)." private const val INVALID_COOKIE_VALUES = "Unable to add cookie - invalid values" private const val GET_ALL_NOT_SUPPORTED = "Get all cookies not supported for Android (iOS only)" - private const val CLEAR_BY_NAME_NOT_SUPPORTED = "Cannot remove a single cookie by name on Android" + private const val CLEAR_BY_NAME_NOT_SUPPORTED = + "clearByName requires GET_COOKIE_INFO support from the device's Android System WebView provider" private const val INVALID_DOMAINS = "Cookie URL host %s and domain %s mismatched. The cookie won't set correctly." private const val FETCH_TIMEOUT_MILLISECONDS = 60_000 diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogicTest.kt new file mode 100644 index 0000000..50c6c02 --- /dev/null +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogicTest.kt @@ -0,0 +1,196 @@ +package com.preeternal.reactnativecookiemanager + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CookieDeletionLogicTest { + @Test + fun reportsUnsupportedWithoutDetailedCookieInfo() { + var readerCalled = false + + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = false, + detailedReader = { + readerCalled = true + emptyList() + } + ) + + assertEquals(CookieDeletionPlan.Unsupported, plan) + assertFalse(readerCalled) + } + + @Test + fun reportsUnsupportedWhenProviderRejectsDetailedCookieInfo() { + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { throw UnsupportedOperationException("Not supported") } + ) + + assertEquals(CookieDeletionPlan.Unsupported, plan) + } + + @Test + fun createsExactHostOnlyAndDomainDeletionHeaders() { + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { + listOf( + "session=root; domain=www.example.com; path=/; secure; httponly", + "session=account; domain=.example.com; path=/account", + "theme=dark; domain=www.example.com; path=/" + ) + } + ) as CookieDeletionPlan.Ready + + assertEquals(2, plan.headers.size) + assertTrue(plan.headers[0].startsWith("session=; Path=/; Max-Age=0;")) + assertFalse(plan.headers[0].contains("Domain=")) + assertTrue(plan.headers[0].endsWith("; Secure; HttpOnly")) + assertTrue(plan.headers[1].contains("Path=/account; Domain=.example.com; Max-Age=0")) + } + + @Test + fun preservesAttributesRequiredToDeletePartitionedCookies() { + val plan = planCookieDeletion( + name = "partitioned", + supportsDetailedRead = true, + detailedReader = { + listOf( + "partitioned=value; domain=www.example.com; path=/; " + + "partitioned; samesite=none" + ) + } + ) as CookieDeletionPlan.Ready + + assertTrue(plan.headers.single().endsWith("; Secure; Partitioned")) + assertFalse(plan.headers.single().contains("SameSite")) + } + + @Test + fun preservesAttributesRequiredToDeleteHostPrefixedCookies() { + val plan = planCookieDeletion( + name = "__Host-session", + supportsDetailedRead = true, + detailedReader = { + listOf("__Host-session=value; domain=www.example.com; path=/; secure") + } + ) as CookieDeletionPlan.Ready + + val header = plan.headers.single() + assertTrue(header.startsWith("__Host-session=; Path=/;")) + assertFalse(header.contains("Domain=")) + assertTrue(header.endsWith("; Secure")) + } + + @Test + fun cookieNamesAreMatchedCaseSensitively() { + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { + listOf("Session=value; domain=example.com; path=/") + } + ) as CookieDeletionPlan.Ready + + assertTrue(plan.headers.isEmpty()) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsMatchingDetailedCookieWithoutIdentityAttributes() { + planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { listOf("session=value; secure") } + ) + } + + @Test + fun emptyPlanCompletesFalseWithoutCallingSetter() { + var setterCalled = false + var result: Result? = null + + executeCookieDeletion( + headers = emptyList(), + setter = { _, _ -> setterCalled = true }, + completion = { result = it } + ) + + assertFalse(setterCalled) + assertEquals(false, result?.getOrNull()) + } + + @Test + fun waitsForEveryDeletionCallback() { + val callbacks = mutableListOf<(Boolean) -> Unit>() + var result: Result? = null + + executeCookieDeletion( + headers = listOf("first", "second"), + setter = { _, callback -> callbacks.add(callback) }, + completion = { result = it } + ) + + assertEquals(2, callbacks.size) + assertEquals(null, result) + callbacks[1](true) + assertEquals(null, result) + callbacks[0](true) + assertEquals(true, result?.getOrNull()) + } + + @Test + fun reportsRejectedDeletionAfterAllCallbacks() { + val callbacks = mutableListOf<(Boolean) -> Unit>() + var result: Result? = null + + executeCookieDeletion( + headers = listOf("first", "second"), + setter = { _, callback -> callbacks.add(callback) }, + completion = { result = it } + ) + + callbacks[0](false) + assertEquals(null, result) + callbacks[1](true) + assertTrue(result?.isFailure == true) + } + + @Test + fun waitsForStartedCallbacksAfterSynchronousSetterFailure() { + var firstCallback: ((Boolean) -> Unit)? = null + var thirdCallback: ((Boolean) -> Unit)? = null + var completionCount = 0 + var result: Result? = null + var error: Throwable? = null + + executeCookieDeletion( + headers = listOf("first", "second", "third"), + setter = { header, callback -> + when (header) { + "first" -> firstCallback = callback + "second" -> throw IllegalStateException("failed") + "third" -> thirdCallback = callback + } + }, + completion = { + completionCount += 1 + result = it + error = it.exceptionOrNull() + } + ) + + assertEquals(null, result) + firstCallback?.invoke(true) + assertEquals(null, result) + thirdCallback?.invoke(true) + assertEquals(1, completionCount) + assertNotNull(error) + } +} From ec09311ad1c2d6b3f2f0b70743ad01a0b55a6e39 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 21:57:53 +0300 Subject: [PATCH 17/22] feat(android): make AndroidX WebKit version configurable --- .github/workflows/ci.yml | 14 +++++++ CHANGELOG.md | 5 ++- README.md | 33 +++++++++++++++ android/build.gradle | 42 +++++++++++++++++-- app.plugin.js | 89 ++++++++++++++++++++++++++++++++++++++++ app.plugin.test.js | 74 +++++++++++++++++++++++++++++++++ eslint.config.mjs | 10 ++++- package.json | 12 +++++- 8 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 app.plugin.js create mode 100644 app.plugin.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6db8e33..31fa648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: - name: Typecheck files run: yarn typecheck + - name: Test Expo config plugin + run: yarn test:plugin build-library: runs-on: ubuntu-latest @@ -73,6 +75,16 @@ jobs: npm install npm install --ignore-scripts "$GITHUB_WORKSPACE/$PACKAGE_TGZ" + node -e ' + const fs = require("node:fs"); + const app = JSON.parse(fs.readFileSync("app.json", "utf8")); + app.expo.plugins = [ + ...(app.expo.plugins || []), + ["@preeternal/react-native-cookie-manager", { androidWebkitVersion: "1.15.0" }] + ]; + fs.writeFileSync("app.json", JSON.stringify(app, null, 2) + "\n"); + ' + CI=1 npx expo config --json > "$EXPO_WORK_DIR/expo-config.json" 2> "$EXPO_WORK_DIR/expo-config.stderr.log" || { echo "::error::expo config failed" if [[ -f "$EXPO_WORK_DIR/expo-config.stderr.log" ]]; then @@ -91,6 +103,8 @@ jobs: CI=1 npx expo prebuild --platform android --clean --no-install 2>&1 | tee "$EXPO_WORK_DIR/prebuild.log" + grep -Fx "react_native_cookie_manager_webkit_version=1.15.0" android/gradle.properties + - name: Print Expo prebuild diagnostics if: always() run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index cb347b5..09278a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Added `getAsArray()` on both platforms and iOS-only `getAllAsArray()` to preserve cookies that share a name but differ by domain or path. Existing `get()` and `getAll()` retain their upstream-compatible last-cookie-wins object shape. - Added `getCookieHeader(url, useWebKit?)` to read matching cookies as a ready-to-use `Cookie` request-header value. It preserves duplicate names and returns an empty string when no cookies match. - Added Android support for `clearByName(url, name)`. When the device's Android System WebView provider supports `GET_COOKIE_INFO`, it expires every same-name domain/path variant applicable to the supplied URL, waits for all deletion callbacks, then flushes persistence. Devices with an older provider reject with `not_supported` instead of risking incomplete deletion. +- Added AndroidX WebKit version configuration for bare React Native and Expo builds through `react_native_cookie_manager_webkit_version` / `androidWebkitVersion`. The shared `rootProject.ext.webkitVersion` override used by `react-native-webview` is also honored. ### Fixed @@ -35,10 +36,11 @@ - Added Foundation and WebKit store integration tests on macOS and iOS Simulator, covering duplicate-name round trips, store selection, and completion-aware deletion/cleanup. - Added Swift coverage for request-header formatting and WebKit domain/path/secure/expiry matching, plus Android coverage for raw header passthrough and empty stores. - Added Android coverage for host-only, domain, path, prefixed, and partitioned cookie deletion, unsupported providers, callback ordering, and rejected writes. +- Added unit coverage for the Expo config plugin and extended the Expo prebuild smoke test to verify its generated Gradle property. ### Compatibility -- Android now depends on `androidx.webkit:webkit:1.16.0` (`minSdk 24`, `compileSdk 33+`, and Android Gradle Plugin 7.2+); the library defaults remain `minSdk 24`, `compileSdk 36`, and AGP 8.7.2. +- Android defaults to `androidx.webkit:webkit:1.16.0`; applications may request another full version through the package Gradle/Expo option or shared `rootProject.ext.webkitVersion`. Versions below `1.6.0` are rejected, while normal Gradle conflict resolution may select a higher version. The library defaults remain `minSdk 24`, `compileSdk 36`, and AGP 8.7.2. ### Deprecated @@ -51,6 +53,7 @@ - Added a concise Fetch/Axios response flow and clarified the advanced `setFromResponse()` use case. - Clarified that direct Android `flush()` calls are redundant after library mutations and are intended only as a persistence barrier for external shared-store changes. - Documented native persistence and expiration behavior, including Android WebView session-cookie restoration and the decision not to maintain a separate automatic cookie backup. +- Documented AndroidX WebKit overrides for bare React Native, `react-native-webview` interoperability, and Expo prebuild. --- diff --git a/README.md b/README.md index 975df06..8e5f548 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,39 @@ cd ios && bundle exec pod install Supports both old (bridged) and New Architecture (TurboModule) builds out of the box. Works in bare RN apps and in Expo Dev Builds (custom native build). +### AndroidX WebKit version + +Android uses `androidx.webkit:webkit:1.16.0` by default. Most applications do not need to configure it. Bare React Native apps can override the requested version in `android/gradle.properties`: + +```properties +react_native_cookie_manager_webkit_version=1.16.0 +``` + +Alternatively, an existing shared override in the root `android/build.gradle` is honored, including when it also configures `react-native-webview`: + +```gradle +rootProject.ext.webkitVersion = "1.16.0" +``` + +The package-specific `gradle.properties` value takes precedence when both are present. + +Expo apps can configure the same property during prebuild: + +```json +{ + "expo": { + "plugins": [ + [ + "@preeternal/react-native-cookie-manager", + { "androidWebkitVersion": "1.16.0" } + ] + ] + } +} +``` + +Versions older than `1.6.0` are unsupported because the library compiles against `CookieManagerCompat.getCookieInfo()`. Gradle may select a higher compatible version when another dependency requires it. + ## Usage ### After a network request diff --git a/android/build.gradle b/android/build.gradle index 7f11f12..c424141 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,9 +1,10 @@ buildscript { - ext.CookieManager = [ + ext.CookieManagerConfig = [ kotlinVersion: "2.0.21", minSdkVersion: 24, compileSdkVersion: 36, - targetSdkVersion: 36 + targetSdkVersion: 36, + webkitVersion: "1.16.0" ] ext.getExtOrDefault = { prop -> @@ -11,7 +12,7 @@ buildscript { return rootProject.ext.get(prop) } - return CookieManager[prop] + return CookieManagerConfig[prop] } repositories { @@ -32,6 +33,39 @@ apply plugin: "kotlin-android" apply plugin: "com.facebook.react" +def configuredWebkitVersion = + project.findProperty("react_native_cookie_manager_webkit_version") + ?: rootProject.findProperty("react_native_cookie_manager_webkit_version") +def cookieManagerWebkitVersion = + (configuredWebkitVersion ?: getExtOrDefault("webkitVersion")).toString() +def webkitVersionMatch = + (cookieManagerWebkitVersion =~ /^(\d+)\.(\d+)\.(\d+)((?:[-+][0-9A-Za-z.-]+)?)$/) + +if (!webkitVersionMatch.matches()) { + throw new GradleException( + "react_native_cookie_manager_webkit_version must be a full version such as 1.16.0 " + + "(got '${cookieManagerWebkitVersion}')" + ) +} + +def webkitVersionParts = [ + webkitVersionMatch.group(1).toInteger(), + webkitVersionMatch.group(2).toInteger(), + webkitVersionMatch.group(3).toInteger() +] +def webkitVersionSuffix = webkitVersionMatch.group(4) +def webkitVersionIsTooOld = + webkitVersionParts[0] < 1 || + (webkitVersionParts[0] == 1 && webkitVersionParts[1] < 6) || + (webkitVersionParts == [1, 6, 0] && webkitVersionSuffix.startsWith("-")) + +if (webkitVersionIsTooOld) { + throw new GradleException( + "AndroidX WebKit ${cookieManagerWebkitVersion} is unsupported; " + + "@preeternal/react-native-cookie-manager requires 1.6.0 or newer" + ) +} + android { namespace "com.preeternal.reactnativecookiemanager" @@ -64,7 +98,7 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation "androidx.webkit:webkit:1.16.0" + implementation "androidx.webkit:webkit:${cookieManagerWebkitVersion}" testImplementation "junit:junit:4.13.2" } diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 0000000..8d1e842 --- /dev/null +++ b/app.plugin.js @@ -0,0 +1,89 @@ +const pkg = require('./package.json'); + +let configPlugins; +try { + configPlugins = require('@expo/config-plugins'); +} catch (error) { + const errorCode = error && error.code; + if (errorCode !== 'MODULE_NOT_FOUND') { + throw error; + } + configPlugins = require('expo/config-plugins'); +} + +const { createRunOncePlugin } = configPlugins; +const withAndroidGradleProperties = + typeof configPlugins.withAndroidGradleProperties === 'function' + ? configPlugins.withAndroidGradleProperties + : configPlugins.withGradleProperties; + +if (typeof withAndroidGradleProperties !== 'function') { + throw new Error( + `${pkg.name}: incompatible Expo config-plugins API (missing Android Gradle properties helper).` + ); +} + +const PROPERTY_KEY = 'react_native_cookie_manager_webkit_version'; +const MINIMUM_VERSION = [1, 6, 0]; +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)((?:[-+][0-9A-Za-z.-]+)?)$/; + +function normalizeAndroidWebkitVersion(rawVersion) { + if (rawVersion == null) { + return null; + } + + const version = String(rawVersion).trim(); + const match = VERSION_PATTERN.exec(version); + if (!match) { + throw new Error( + `${pkg.name}: invalid androidWebkitVersion '${rawVersion}'. Expected a full version such as '1.16.0'.` + ); + } + + const parts = match.slice(1, 4).map(Number); + const suffix = match[4]; + const isTooOld = + parts[0] < MINIMUM_VERSION[0] || + (parts[0] === MINIMUM_VERSION[0] && parts[1] < MINIMUM_VERSION[1]) || + (parts.every((part, index) => part === MINIMUM_VERSION[index]) && + suffix.startsWith('-')); + + if (isTooOld) { + throw new Error( + `${pkg.name}: androidWebkitVersion must be 1.6.0 or newer (got '${version}').` + ); + } + + return version; +} + +function withCookieManagerWebkitVersion(config, props = {}) { + const version = normalizeAndroidWebkitVersion(props.androidWebkitVersion); + if (version == null) { + return config; + } + + return withAndroidGradleProperties(config, (mod) => { + const existing = mod.modResults.find( + (item) => item.type === 'property' && item.key === PROPERTY_KEY + ); + + if (existing) { + existing.value = version; + } else { + mod.modResults.push({ + type: 'property', + key: PROPERTY_KEY, + value: version, + }); + } + + return mod; + }); +} + +module.exports = createRunOncePlugin( + withCookieManagerWebkitVersion, + pkg.name, + pkg.version +); diff --git a/app.plugin.test.js b/app.plugin.test.js new file mode 100644 index 0000000..6fc3fc5 --- /dev/null +++ b/app.plugin.test.js @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const test = require('node:test'); + +const originalLoad = Module._load; +Module._load = function loadWithConfigPluginsMock(request, parent, isMain) { + if (request === '@expo/config-plugins') { + return { + createRunOncePlugin: (plugin) => plugin, + withAndroidGradleProperties: (config, action) => { + const mod = { + modResults: config.androidGradleProperties || [], + }; + const result = action(mod); + config.androidGradleProperties = result.modResults; + return config; + }, + }; + } + + return originalLoad(request, parent, isMain); +}; + +let plugin; +try { + plugin = require('./app.plugin.js'); +} finally { + Module._load = originalLoad; +} + +test('does not write the default when the option is omitted', () => { + const config = {}; + + assert.equal(plugin(config), config); + assert.equal(config.androidGradleProperties, undefined); +}); + +test('adds and updates the AndroidX WebKit Gradle property', () => { + const config = {}; + + plugin(config, { androidWebkitVersion: '1.15.0' }); + plugin(config, { androidWebkitVersion: '1.16.0' }); + + assert.deepEqual(config.androidGradleProperties, [ + { + type: 'property', + key: 'react_native_cookie_manager_webkit_version', + value: '1.16.0', + }, + ]); +}); + +test('accepts AndroidX prerelease versions above the minimum', () => { + const config = {}; + + plugin(config, { androidWebkitVersion: '1.17.0-alpha01' }); + + assert.equal(config.androidGradleProperties[0].value, '1.17.0-alpha01'); +}); + +test('rejects malformed and unsupported versions', () => { + assert.throws( + () => plugin({}, { androidWebkitVersion: 'latest' }), + /Expected a full version/ + ); + assert.throws( + () => plugin({}, { androidWebkitVersion: '1.5.0' }), + /must be 1\.6\.0 or newer/ + ); + assert.throws( + () => plugin({}, { androidWebkitVersion: '1.6.0-alpha01' }), + /must be 1\.6\.0 or newer/ + ); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 8fa4a64..5f2fc0e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,14 @@ export default defineConfig([ }, }, { - ignores: ['eslint.config.mjs', 'node_modules/', 'lib/'], + ignores: [ + 'eslint.config.mjs', + 'node_modules/', + 'lib/', + 'android/build/', + 'example/android/build/', + 'example/android/app/build/', + 'example/ios/build/', + ], }, ]); diff --git a/package.json b/package.json index 5baabfd..11fc7e9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "types": "./lib/typescript/src/index.d.ts", "default": "./lib/module/index.js" }, + "./app.plugin.js": "./app.plugin.js", + "./app.plugin": "./app.plugin.js", "./package.json": "./package.json" }, "files": [ @@ -18,6 +20,7 @@ "android", "ios", "cpp", + "app.plugin.js", "*.podspec", "react-native.config.js", "!ios/build", @@ -26,6 +29,7 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!android/src/test", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", @@ -36,7 +40,8 @@ "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", "prepare": "bob build", "typecheck": "tsc", - "lint": "eslint \"**/*.{js,ts,tsx}\"" + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test:plugin": "node --test app.plugin.test.js" }, "keywords": [ "react-native", @@ -56,6 +61,11 @@ "publishConfig": { "registry": "https://registry.npmjs.org/" }, + "expo": { + "plugins": [ + "./app.plugin.js" + ] + }, "devDependencies": { "@eslint/compat": "^2.0.3", "@eslint/eslintrc": "^3.3.5", From ec866e753688df1917b7c729dc9e0d0172c3dc3e Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 22:28:42 +0300 Subject: [PATCH 18/22] feat: support SameSite and Max-Age cookie attributes --- CHANGELOG.md | 6 +- Package.swift | 27 ++++ README.md | 14 +- .../CookieManagerModule.kt | 142 +++++++----------- .../CookieReadLogic.kt | 12 +- .../CookieSerializationLogic.kt | 87 +++++++++++ .../CookieReadLogicTest.kt | 9 ++ .../CookieSerializationLogicTest.kt | 135 +++++++++++++++++ ios/CookieAttributeLogic.swift | 89 +++++++++++ ios/CookieManager.mm | 6 + ios/CookieManager.swift | 13 +- src/NativeCookieManager.ts | 4 + src/index.tsx | 3 +- .../CookieAttributeLogicTests.swift | 126 ++++++++++++++++ .../CookieStoreAccessIntegrationTests.swift | 64 ++++++++ 15 files changed, 640 insertions(+), 97 deletions(-) create mode 100644 android/src/main/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogic.kt create mode 100644 android/src/test/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogicTest.kt create mode 100644 ios/CookieAttributeLogic.swift create mode 100644 swift-tests/CookieAttributeLogicTests/CookieAttributeLogicTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 09278a9..aed6bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Added `getCookieHeader(url, useWebKit?)` to read matching cookies as a ready-to-use `Cookie` request-header value. It preserves duplicate names and returns an empty string when no cookies match. - Added Android support for `clearByName(url, name)`. When the device's Android System WebView provider supports `GET_COOKIE_INFO`, it expires every same-name domain/path variant applicable to the supplied URL, waits for all deletion callbacks, then flushes persistence. Devices with an older provider reject with `not_supported` instead of risking incomplete deletion. - Added AndroidX WebKit version configuration for bare React Native and Expo builds through `react_native_cookie_manager_webkit_version` / `androidWebkitVersion`. The shared `rootProject.ext.webkitVersion` override used by `react-native-webview` is also honored. +- Added structured `sameSite` and `maxAge` fields to `set()` on both platforms. `maxAge` is a relative lifetime in whole seconds and takes precedence over `expires`; `SameSite=None` requires `Secure`. Reads return `sameSite` when the selected native store exposes it and continue to return the effective absolute `expires` date rather than the original `maxAge`. ### Fixed @@ -22,7 +23,8 @@ - Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. - Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. - iOS `get(url, true)` and `clearByName()` now match cookie domains case-insensitively in both Foundation and WebKit flows. Leading-dot domains apply to their root host as well as subdomains, while strict domain boundaries remain enforced. -- Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, and `httpOnly` attributes when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider transparently retain the previous name/value-only behavior. +- Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, `httpOnly`, and `sameSite` attributes when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider transparently retain the previous name/value-only behavior. +- Android `set()` now serializes `expires` directly as an absolute `Expires` attribute instead of temporarily storing an absolute timestamp in `HttpCookie.maxAge`. This preserves future and past-date behavior while allowing the new relative `maxAge` field to use its standards-defined seconds contract without accidentally turning deletion cookies into session cookies. - `removeSessionCookies()` now works on iOS and resolves only after session cookies have been removed from the selected stores; persistent cookies remain untouched. Foundation and the default WebKit store are selected by default. On Android, awaiting it now also guarantees that its automatic `flush()` has completed, so an immediate shutdown cannot leave removed session cookies on disk. - iOS cookie cleanup no longer invokes the unrelated deprecated `UserDefaults.synchronize()` API. @@ -37,6 +39,7 @@ - Added Swift coverage for request-header formatting and WebKit domain/path/secure/expiry matching, plus Android coverage for raw header passthrough and empty stores. - Added Android coverage for host-only, domain, path, prefixed, and partitioned cookie deletion, unsupported providers, callback ordering, and rejected writes. - Added unit coverage for the Expo config plugin and extended the Expo prebuild smoke test to verify its generated Gradle property. +- Added Swift and Kotlin coverage for `sameSite`, relative `maxAge`, `maxAge`/`expires` precedence, immediate deletion, invalid values, time-zone offsets, Android RFC serialization, and Foundation/WebKit store round trips. ### Compatibility @@ -54,6 +57,7 @@ - Clarified that direct Android `flush()` calls are redundant after library mutations and are intended only as a persistence barrier for external shared-store changes. - Documented native persistence and expiration behavior, including Android WebView session-cookie restoration and the decision not to maintain a separate automatic cookie backup. - Documented AndroidX WebKit overrides for bare React Native, `react-native-webview` interoperability, and Expo prebuild. +- Documented modern cookie attributes, read-back limitations, and why `partitioned` is not exposed without a reliable top-level partition context. --- diff --git a/Package.swift b/Package.swift index 4922640..1b47ff7 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( name: "CookieDomainLogic", path: "ios", exclude: [ + "CookieAttributeLogic.swift", "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieManager.h", @@ -28,6 +29,7 @@ let package = Package( name: "CookieSessionLogic", path: "ios", exclude: [ + "CookieAttributeLogic.swift", "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieDomainLogic.swift", @@ -43,6 +45,7 @@ let package = Package( name: "CookieStoreClearLogic", path: "ios", exclude: [ + "CookieAttributeLogic.swift", "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieDomainLogic.swift", @@ -58,6 +61,7 @@ let package = Package( name: "CookieCollectionLogic", path: "ios", exclude: [ + "CookieAttributeLogic.swift", "CookieHeaderLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", @@ -73,6 +77,7 @@ let package = Package( name: "CookieStoreAccess", path: "ios", exclude: [ + "CookieAttributeLogic.swift", "CookieHeaderLogic.swift", "CookieCollectionLogic.swift", "CookieDomainLogic.swift", @@ -88,6 +93,7 @@ let package = Package( name: "CookieHeaderLogic", path: "ios", exclude: [ + "CookieAttributeLogic.swift", "CookieCollectionLogic.swift", "CookieDomainLogic.swift", "CookieManager.h", @@ -99,6 +105,22 @@ let package = Package( ], sources: ["CookieHeaderLogic.swift"] ), + .target( + name: "CookieAttributeLogic", + path: "ios", + exclude: [ + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieHeaderLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreAccess.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieAttributeLogic.swift"] + ), .testTarget( name: "CookieDomainLogicTests", dependencies: ["CookieDomainLogic"], @@ -129,5 +151,10 @@ let package = Package( dependencies: ["CookieHeaderLogic"], path: "swift-tests/CookieHeaderLogicTests" ), + .testTarget( + name: "CookieAttributeLogicTests", + dependencies: ["CookieAttributeLogic"], + path: "swift-tests/CookieAttributeLogicTests" + ), ] ) diff --git a/README.md b/README.md index 8e5f548..7fd5c7a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ await CookieManager.set('https://example.com', { path: '/', secure: true, httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, }); const cookies = await CookieManager.get('https://example.com'); @@ -145,7 +147,7 @@ The public API remains compatible with `@react-native-cookies/cookies`. | Method | Platforms | Returns | Description | | --- | --- | --- | --- | -| `set(url, cookie, useWebKit?)` | iOS, Android | `Promise` | Stores a cookie. On iOS, uses Foundation by default or default WebKit when `true`. | +| `set(url, cookie, useWebKit?)` | iOS, Android | `Promise` | Stores a cookie, including `sameSite` and relative `maxAge`. On iOS, uses Foundation by default or default WebKit when `true`. | | `get(url, useWebKit?)` | iOS, Android | `Promise` | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | | `getAsArray(url, useWebKit?)` | iOS, Android | `Promise>` | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | | `getCookieHeader(url, useWebKit?)` | iOS, Android | `Promise` | Returns the selected store's matching cookies as a `Cookie` request-header value, or an empty string. | @@ -177,12 +179,18 @@ type Cookie = { expires?: string; // ISO 8601 string, e.g. 2015-05-30T12:30:00.00-05:00 secure?: boolean; httpOnly?: boolean; + sameSite?: 'lax' | 'strict' | 'none'; + maxAge?: number; // set() only: relative lifetime in whole seconds }; ``` +`maxAge` takes precedence over `expires`; `0` or a negative value expires the cookie immediately. Native stores expose the resulting absolute `expires` date when reading, not the original `maxAge`. `sameSite: 'none'` requires `secure: true`. The iOS `HTTPCookie` model represents this unrestricted policy as no explicit SameSite value, so reads may omit `sameSite` after setting `'none'`. + +`partitioned` is intentionally not a structured field: creating a partitioned cookie requires top-level site context that this API's cookie URL cannot express consistently across Android and iOS. Prefer receiving it from the server or setting it inside the relevant WebView context. + `Cookies` is keyed by cookie name, so `get()` and `getAll()` retain only the last item when multiple cookies share a name. This legacy behavior is preserved for upstream compatibility. Use `getAsArray()` or `getAllAsArray()` when `domain`/`path` variants must remain separate. -On Android, metadata is populated when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider fall back to legacy name/value parsing, so `domain`, `path`, and `expires` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. +On Android, metadata is populated when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider fall back to legacy name/value parsing, so `domain`, `path`, `expires`, and `sameSite` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. ### WebKit on iOS @@ -198,7 +206,7 @@ On Android, metadata is populated when the device's Android System WebView provi ### Persistence and expiration -- A cookie is persistent only when the server supplies `Expires`/`Max-Age`, or when `set()` receives `expires`. Native stores enforce expiration; `flush()` does not extend a cookie's lifetime or turn a session cookie into a persistent one. +- A cookie is persistent only when the server supplies `Expires`/`Max-Age`, or when `set()` receives `expires`/`maxAge`. Native stores enforce expiration; `flush()` does not extend a cookie's lifetime or turn a session cookie into a persistent one. - On iOS, Foundation and WebKit manage persistence automatically. There is no public explicit flush API, so `flush()` is a no-op. - On Android, the library automatically flushes the shared WebView cookie store after its mutations. Current WebView implementations may also restore session cookies—cookies without an expiry—after a process restart. diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index d83032e..988d589 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -42,17 +42,12 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : promise: Promise ) { val cookieString = try { - toRFC6265string(makeHTTPCookieObject(url, cookie)) + serializeCookieForSet(makeCookieSetData(url, cookie)) } catch (e: Exception) { promise.reject("cookie_set_error", e) return } - if (cookieString == null) { - promise.reject("invalid_cookie_values", INVALID_COOKIE_VALUES) - return - } - addCookies(url, cookieString, promise) } @@ -285,6 +280,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : cookieMap.putString("path", cookie.path) cookieMap.putBoolean("secure", cookie.secure) cookieMap.putBoolean("httpOnly", cookie.httpOnly) + cookie.sameSite?.let { cookieMap.putString("sameSite", it) } cookie.expiresAt?.let { expiresAt -> formatDate(Date(expiresAt))?.let { expires -> cookieMap.putString("expires", expires) @@ -420,6 +416,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : val defaultPath = defaultCookiePath(responseUrl) for (header in headers) { + val sameSite = parseSameSiteAttribute(header) val cookies = try { HttpCookie.parse(header) } catch (e: IllegalArgumentException) { @@ -447,7 +444,8 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : version = cookie.version.toString(), expiresAt = expiresAt, secure = cookie.secure, - httpOnly = HTTP_ONLY_SUPPORTED && cookie.isHttpOnly + httpOnly = HTTP_ONLY_SUPPORTED && cookie.isHttpOnly, + sameSite = sameSite ) ) } @@ -508,6 +506,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : cookieMap.putString("version", cookie.version) cookieMap.putBoolean("secure", cookie.secure) cookieMap.putBoolean("httpOnly", cookie.httpOnly) + cookie.sameSite?.let { cookieMap.putString("sameSite", it) } cookie.expiresAt?.let { expiresAt -> formatDate(Date(expiresAt))?.let { expires -> cookieMap.putString("expires", expires) @@ -530,7 +529,7 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } @Throws(Exception::class) - private fun makeHTTPCookieObject(url: String, cookie: ReadableMap): HttpCookie { + private fun makeCookieSetData(url: String, cookie: ReadableMap): CookieSetData { val parsedUrl = try { URL(url) } catch (e: Exception) { @@ -542,9 +541,10 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : throw Exception(INVALID_URL_MISSING_HTTP) } - val cookieBuilder = HttpCookie(cookie.getString("name"), cookie.getString("value")) + val validatedCookie = HttpCookie(cookie.getString("name"), cookie.getString("value")) + var domain: String? if (cookie.hasKey("domain") && !isEmpty(cookie.getString("domain"))) { - var domain = cookie.getString("domain") + domain = cookie.getString("domain") if (domain != null && domain.startsWith(".")) { domain = domain.substring(1) } @@ -552,34 +552,53 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : if (domain != null && !domainMatches(topLevelDomain, domain)) { throw Exception(String.format(INVALID_DOMAINS, topLevelDomain, domain)) } - - if (domain != null) { - cookieBuilder.domain = domain - } } else { - cookieBuilder.domain = topLevelDomain + domain = topLevelDomain } - if (cookie.hasKey("path") && !isEmpty(cookie.getString("path"))) { - cookieBuilder.path = cookie.getString("path") - } - - if (cookie.hasKey("expires") && !isEmpty(cookie.getString("expires"))) { - val date = parseDate(cookie.getString("expires")) - if (date != null) { - cookieBuilder.maxAge = date.time + val path = + if (cookie.hasKey("path") && !cookie.isNull("path")) { + cookie.getString("path")?.takeUnless { it.isEmpty() } + } else { + null + } + val secure = cookie.hasKey("secure") && cookie.getBoolean("secure") + val httpOnly = + HTTP_ONLY_SUPPORTED && cookie.hasKey("httpOnly") && cookie.getBoolean("httpOnly") + val maxAgeSeconds = + if (cookie.hasKey("maxAge") && !cookie.isNull("maxAge")) { + parseMaxAgeSeconds(cookie.getDouble("maxAge")) + } else { + null + } + val expiresAtMillis = + if ( + maxAgeSeconds == null && + cookie.hasKey("expires") && + !isEmpty(cookie.getString("expires")) + ) { + parseCookieExpires(cookie.getString("expires")) + } else { + null + } + val sameSite = + if (cookie.hasKey("sameSite") && !cookie.isNull("sameSite")) { + parseCookieSameSite(cookie.getString("sameSite") ?: "") + } else { + null } - } - - if (cookie.hasKey("secure") && cookie.getBoolean("secure")) { - cookieBuilder.secure = true - } - - if (HTTP_ONLY_SUPPORTED && cookie.hasKey("httpOnly") && cookie.getBoolean("httpOnly")) { - cookieBuilder.isHttpOnly = true - } - return cookieBuilder + return CookieSetData( + name = validatedCookie.name, + value = validatedCookie.value, + domain = domain, + path = path, + expiresAtMillis = expiresAtMillis, + maxAgeSeconds = maxAgeSeconds, + secure = secure, + httpOnly = httpOnly, + sameSite = sameSite + ) } private fun getCookieManager(): CookieManager { @@ -621,65 +640,15 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : return df } - private fun rfc1123DateFormatter(): DateFormat { - val df = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) - df.timeZone = TimeZone.getTimeZone("GMT") - return df - } - - private fun parseDate(dateString: String?, rfc1123: Boolean = false): Date? { - if (dateString.isNullOrEmpty()) return null + private fun formatDate(date: Date): String? { return try { - (if (rfc1123) rfc1123DateFormatter() else dateFormatter()).parse(dateString) - } catch (e: Exception) { - Log.i(NAME, e.message ?: "Unable to parse date") - null - } - } - - private fun formatDate(date: Date, rfc1123: Boolean = false): String? { - return try { - (if (rfc1123) rfc1123DateFormatter() else dateFormatter()).format(date) + dateFormatter().format(date) } catch (e: Exception) { Log.i(NAME, e.message ?: "Unable to format date") null } } - private fun toRFC6265string(cookie: HttpCookie?): String? { - if (cookie == null) return null - val builder = StringBuilder() - builder.append(cookie.name).append('=').append(cookie.value) - - if (!cookie.hasExpired()) { - val expiresAt = cookie.maxAge - if (expiresAt > 0) { - val dateString = formatDate(Date(expiresAt), true) - if (!isEmpty(dateString)) { - builder.append("; expires=").append(dateString) - } - } - } - - if (!isEmpty(cookie.domain)) { - builder.append("; domain=").append(cookie.domain) - } - - if (!isEmpty(cookie.path)) { - builder.append("; path=").append(cookie.path) - } - - if (cookie.secure) { - builder.append("; secure") - } - - if (HTTP_ONLY_SUPPORTED && cookie.isHttpOnly) { - builder.append("; httponly") - } - - return builder.toString() - } - private data class ResponseCookie( val name: String, val value: String, @@ -688,7 +657,8 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : val version: String, val expiresAt: Long?, val secure: Boolean, - val httpOnly: Boolean + val httpOnly: Boolean, + val sameSite: String? ) companion object { diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt index 63c0e8a..3372dc3 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt @@ -18,7 +18,8 @@ internal data class ParsedCookie( val path: String?, val expiresAt: Long?, val secure: Boolean, - val httpOnly: Boolean + val httpOnly: Boolean, + val sameSite: String? ) internal fun readCookieHeaders( @@ -71,7 +72,8 @@ internal fun parseCookieReadResult( null }, secure = cookie.secure, - httpOnly = cookie.isHttpOnly + httpOnly = cookie.isHttpOnly, + sameSite = if (hasAttributes) parseSameSiteAttribute(header) else null ) } } @@ -79,6 +81,10 @@ internal fun parseCookieReadResult( } } +internal fun parseSameSiteAttribute(header: String): String? = + SAME_SITE_ATTRIBUTE.find(header)?.groupValues?.get(1)?.trim()?.lowercase(Locale.US) + ?.takeIf { it == "lax" || it == "strict" || it == "none" } + private fun expirationTime( header: String, parsedAtMillis: Long, @@ -116,3 +122,5 @@ private val MAX_AGE_ATTRIBUTE = Regex("(?:^|;)\\s*max-age\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) private val EXPIRES_ATTRIBUTE = Regex("(?:^|;)\\s*expires\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) +private val SAME_SITE_ATTRIBUTE = + Regex("(?:^|;)\\s*samesite\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogic.kt new file mode 100644 index 0000000..7988da8 --- /dev/null +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogic.kt @@ -0,0 +1,87 @@ +package com.preeternal.reactnativecookiemanager + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +internal enum class CookieSameSite(val attributeValue: String) { + LAX("Lax"), + STRICT("Strict"), + NONE("None") +} + +internal data class CookieSetData( + val name: String, + val value: String, + val domain: String?, + val path: String?, + val expiresAtMillis: Long?, + val maxAgeSeconds: Long?, + val secure: Boolean, + val httpOnly: Boolean, + val sameSite: CookieSameSite? +) + +internal fun parseCookieSameSite(value: String): CookieSameSite = + when (value.lowercase(Locale.US)) { + "lax" -> CookieSameSite.LAX + "strict" -> CookieSameSite.STRICT + "none" -> CookieSameSite.NONE + else -> throw IllegalArgumentException("sameSite must be \"lax\", \"strict\", or \"none\"") + } + +internal fun parseMaxAgeSeconds(value: Double): Long { + if (!value.isFinite() || value % 1.0 != 0.0 || kotlin.math.abs(value) > MAX_SAFE_INTEGER) { + throw IllegalArgumentException("maxAge must be a finite safe integer number of seconds") + } + return value.toLong() +} + +internal fun parseCookieExpires(value: String?): Long? { + if (value.isNullOrEmpty()) return null + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + }.parse(value)?.time + } catch (_: Exception) { + null + } +} + +internal fun serializeCookieForSet(cookie: CookieSetData): String { + if (cookie.sameSite == CookieSameSite.NONE && !cookie.secure) { + throw IllegalArgumentException("SameSite \"none\" requires secure: true") + } + + return buildString { + append(cookie.name).append('=').append(cookie.value) + + if (cookie.maxAgeSeconds != null) { + append("; Max-Age=").append(cookie.maxAgeSeconds) + } else if (cookie.expiresAtMillis != null) { + append("; Expires=").append(formatRfc1123(Date(cookie.expiresAtMillis))) + } + + if (!cookie.domain.isNullOrEmpty()) { + append("; Domain=").append(cookie.domain) + } + if (!cookie.path.isNullOrEmpty()) { + append("; Path=").append(cookie.path) + } + if (cookie.secure) { + append("; Secure") + } + if (cookie.httpOnly) { + append("; HttpOnly") + } + cookie.sameSite?.let { append("; SameSite=").append(it.attributeValue) } + } +} + +private fun formatRfc1123(date: Date): String = + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + }.format(date) + +private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0 diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt index 4cfe00d..095836b 100644 --- a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt @@ -114,6 +114,7 @@ class CookieReadLogicTest { assertEquals(parsedAt + 3_600_000L, cookies[0].expiresAt) assertTrue(cookies[0].secure) assertTrue(cookies[0].httpOnly) + assertEquals("lax", cookies[0].sameSite) } @Test @@ -165,9 +166,17 @@ class CookieReadLogicTest { assertNull(cookie.expiresAt) assertFalse(cookie.secure) assertFalse(cookie.httpOnly) + assertNull(cookie.sameSite) } } + @Test + fun normalizesSupportedSameSiteAndIgnoresUnknownValues() { + assertEquals("none", parseSameSiteAttribute("session=abc; SameSite=None; Secure")) + assertEquals("strict", parseSameSiteAttribute("session=abc; samesite=STRICT")) + assertNull(parseSameSiteAttribute("session=abc; SameSite=Cross-Site")) + } + @Test fun preservesDuplicateCookieNamesForArraySerialization() { val detailedCookies = parseCookieReadResult( diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogicTest.kt new file mode 100644 index 0000000..19f8863 --- /dev/null +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogicTest.kt @@ -0,0 +1,135 @@ +package com.preeternal.reactnativecookiemanager + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class CookieSerializationLogicTest { + @Test + fun serializesExistingAttributesAndFutureExpiry() { + val result = serializeCookieForSet( + cookie(expiresAtMillis = 1_970_000_000_000L, secure = true, httpOnly = true) + ) + + assertEquals( + "session=value; Expires=Fri, 04 Jun 2032 22:13:20 GMT; " + + "Domain=example.com; Path=/; Secure; HttpOnly", + result + ) + } + + @Test + fun keepsPastExpiryAsADeletionInsteadOfCreatingASessionCookie() { + val result = serializeCookieForSet(cookie(expiresAtMillis = 1_000L)) + + assertEquals( + "session=value; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Domain=example.com; Path=/", + result + ) + } + + @Test + fun maxAgeUsesRelativeSecondsAndTakesPrecedenceOverExpires() { + val result = serializeCookieForSet( + cookie(expiresAtMillis = 1_970_000_000_000L, maxAgeSeconds = 60) + ) + + assertEquals( + "session=value; Max-Age=60; Domain=example.com; Path=/", + result + ) + } + + @Test + fun serializesImmediateAndPastMaxAgeDeletion() { + assertEquals( + "session=value; Max-Age=0; Domain=example.com; Path=/", + serializeCookieForSet(cookie(maxAgeSeconds = 0)) + ) + assertEquals( + "session=value; Max-Age=-1; Domain=example.com; Path=/", + serializeCookieForSet(cookie(maxAgeSeconds = -1)) + ) + } + + @Test + fun serializesModernSameSiteValues() { + assertEquals( + "session=value; Domain=example.com; Path=/; SameSite=Lax", + serializeCookieForSet(cookie(sameSite = CookieSameSite.LAX)) + ) + assertEquals( + "session=value; Domain=example.com; Path=/; Secure; SameSite=None", + serializeCookieForSet(cookie(secure = true, sameSite = CookieSameSite.NONE)) + ) + } + + @Test + fun rejectsInsecureSameSiteNone() { + assertThrows(IllegalArgumentException::class.java) { + serializeCookieForSet(cookie(sameSite = CookieSameSite.NONE)) + } + } + + @Test + fun parsesSameSiteCaseInsensitivelyAndRejectsUnknownValues() { + assertEquals(CookieSameSite.LAX, parseCookieSameSite("LAX")) + assertEquals(CookieSameSite.STRICT, parseCookieSameSite("Strict")) + assertEquals(CookieSameSite.NONE, parseCookieSameSite("none")) + assertThrows(IllegalArgumentException::class.java) { + parseCookieSameSite("cross-site") + } + } + + @Test + fun maxAgeRequiresAFiniteSafeInteger() { + assertEquals(60L, parseMaxAgeSeconds(60.0)) + assertEquals(-1L, parseMaxAgeSeconds(-1.0)) + for (value in listOf(1.5, Double.NaN, Double.POSITIVE_INFINITY, 9_007_199_254_740_992.0)) { + assertThrows(IllegalArgumentException::class.java) { + parseMaxAgeSeconds(value) + } + } + } + + @Test + fun parsesIsoExpiryOffsetsAndKeepsInvalidInputAsSessionOnly() { + assertEquals( + parseCookieExpires("2032-06-09T10:18:14.000Z"), + parseCookieExpires("2032-06-09T12:18:14.000+02:00") + ) + assertNull(parseCookieExpires("not-a-date")) + } + + @Test + fun preservesLegacyAttributeSegmentsInsideValue() { + val result = serializeCookieForSet( + cookie(value = "value; Priority=High") + ) + + assertEquals( + "session=value; Priority=High; Domain=example.com; Path=/", + result + ) + } + + private fun cookie( + value: String = "value", + expiresAtMillis: Long? = null, + maxAgeSeconds: Long? = null, + secure: Boolean = false, + httpOnly: Boolean = false, + sameSite: CookieSameSite? = null + ) = CookieSetData( + name = "session", + value = value, + domain = "example.com", + path = "/", + expiresAtMillis = expiresAtMillis, + maxAgeSeconds = maxAgeSeconds, + secure = secure, + httpOnly = httpOnly, + sameSite = sameSite + ) +} diff --git a/ios/CookieAttributeLogic.swift b/ios/CookieAttributeLogic.swift new file mode 100644 index 0000000..a6549a9 --- /dev/null +++ b/ios/CookieAttributeLogic.swift @@ -0,0 +1,89 @@ +import Foundation + +enum CookieAttributeLogic { + static func apply( + props: NSDictionary, + secure: Bool, + to cookieProperties: inout [HTTPCookiePropertyKey: Any], + parseDate: (String) -> Date? + ) throws { + if let rawMaxAge = props["maxAge"], !(rawMaxAge is NSNull) { + cookieProperties[.maximumAge] = try maxAgeString(from: rawMaxAge) + } else if let expires = props["expires"] as? String, let date = parseDate(expires) { + cookieProperties[.expires] = date + } + + guard let rawSameSite = props["sameSite"], !(rawSameSite is NSNull) else { + return + } + guard let sameSite = rawSameSite as? String else { + throw CookieAttributeError.invalidSameSite + } + + switch sameSite.lowercased() { + case "lax", "strict": + guard #available(iOS 13.0, macOS 10.15, *) else { + throw CookieAttributeError.sameSiteUnavailable + } + cookieProperties[.sameSitePolicy] = sameSite.lowercased() + case "none": + guard secure else { + throw CookieAttributeError.insecureSameSiteNone + } + // Foundation represents an unrestricted cross-site cookie with a nil + // policy and ignores an explicit "none" value. + default: + throw CookieAttributeError.invalidSameSite + } + } + + static func sameSiteValue(from cookie: HTTPCookie) -> String? { + guard #available(iOS 13.0, macOS 10.15, *) else { + return nil + } + guard let value = cookie.sameSitePolicy?.rawValue.lowercased() else { + return nil + } + return ["lax", "strict", "none"].contains(value) ? value : nil + } + + private static func maxAgeString(from rawValue: Any) throws -> String { + guard + let number = rawValue as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { + throw CookieAttributeError.invalidMaxAge + } + + let value = number.doubleValue + guard + value.isFinite, + value.rounded(.towardZero) == value, + abs(value) <= 9_007_199_254_740_991 + else { + throw CookieAttributeError.invalidMaxAge + } + + return String(Int64(value)) + } +} + +private enum CookieAttributeError: LocalizedError { + case invalidMaxAge + case invalidSameSite + case insecureSameSiteNone + case sameSiteUnavailable + + var errorDescription: String? { + switch self { + case .invalidMaxAge: + return "maxAge must be a finite safe integer number of seconds" + case .invalidSameSite: + return "sameSite must be \"lax\", \"strict\", or \"none\"" + case .insecureSameSiteNone: + return "SameSite \"none\" requires secure: true" + case .sameSiteUnavailable: + return "SameSite requires iOS 13 or newer" + } + } +} diff --git a/ios/CookieManager.mm b/ios/CookieManager.mm index e7aed77..cd2d919 100644 --- a/ios/CookieManager.mm +++ b/ios/CookieManager.mm @@ -162,6 +162,12 @@ - (void)handleRemoveSessionCookies:(BOOL)clearFoundation if (cookie.httpOnly().has_value()) { dict[@"httpOnly"] = @(cookie.httpOnly().value()); } + if (cookie.sameSite() != nil) { + dict[@"sameSite"] = cookie.sameSite(); + } + if (cookie.maxAge().has_value()) { + dict[@"maxAge"] = @(cookie.maxAge().value()); + } return dict; } diff --git a/ios/CookieManager.swift b/ios/CookieManager.swift index e76554e..b7487e4 100644 --- a/ios/CookieManager.swift +++ b/ios/CookieManager.swift @@ -425,7 +425,6 @@ public class CookieManagerImpl: NSObject { let path = (props["path"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "/" var domain = CookieDomainLogic.normalizedInputDomain(props["domain"] as? String) let version = props["version"] as? String - let expires = props["expires"] as? String let secure = props["secure"] as? Bool ?? false let httpOnly = props["httpOnly"] as? Bool ?? false @@ -449,9 +448,12 @@ public class CookieManagerImpl: NSObject { if let version { cookieProperties[.version] = version } - if let expires, let date = parseDate(expires) { - cookieProperties[.expires] = date - } + try CookieAttributeLogic.apply( + props: props, + secure: secure, + to: &cookieProperties, + parseDate: parseDate + ) if secure { cookieProperties[.secure] = secure } @@ -480,6 +482,9 @@ public class CookieManagerImpl: NSObject { if let expiresDate = cookie.expiresDate { cookieData["expires"] = formatter.string(from: expiresDate) } + if let sameSite = CookieAttributeLogic.sameSiteValue(from: cookie) { + cookieData["sameSite"] = sameSite + } return cookieData } diff --git a/src/NativeCookieManager.ts b/src/NativeCookieManager.ts index c7b9042..44d5cca 100644 --- a/src/NativeCookieManager.ts +++ b/src/NativeCookieManager.ts @@ -1,5 +1,7 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; +export type CookieSameSite = 'lax' | 'strict' | 'none'; + export type Cookie = { name: string; value: string; @@ -9,6 +11,8 @@ export type Cookie = { expires?: string; secure?: boolean; httpOnly?: boolean; + sameSite?: CookieSameSite; + maxAge?: number; }; export type Cookies = Record; diff --git a/src/index.tsx b/src/index.tsx index 82f7b2a..a37ed91 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ import { Platform } from 'react-native'; import CookieManagerNative, { type Cookie, + type CookieSameSite, type Cookies, } from './NativeCookieManager'; @@ -55,5 +56,5 @@ const CookieManager = { getFromResponse: (url: string) => CookieManagerNative.getFromResponse(url), }; -export type { Cookie, Cookies }; +export type { Cookie, CookieSameSite, Cookies }; export default CookieManager; diff --git a/swift-tests/CookieAttributeLogicTests/CookieAttributeLogicTests.swift b/swift-tests/CookieAttributeLogicTests/CookieAttributeLogicTests.swift new file mode 100644 index 0000000..fa6bb53 --- /dev/null +++ b/swift-tests/CookieAttributeLogicTests/CookieAttributeLogicTests.swift @@ -0,0 +1,126 @@ +import Foundation +import XCTest +@testable import CookieAttributeLogic + +final class CookieAttributeLogicTests: XCTestCase { + func testMaxAgeUsesRelativeSecondsAndTakesPrecedenceOverExpires() throws { + var properties = baseProperties() + let beforeCreation = Date() + + try CookieAttributeLogic.apply( + props: [ + "maxAge": 60, + "expires": "2032-06-09T10:18:14.000Z", + ], + secure: false, + to: &properties, + parseDate: { _ in Date(timeIntervalSince1970: 1_970_000_000) } + ) + + XCTAssertEqual(properties[.maximumAge] as? String, "60") + XCTAssertNil(properties[.expires]) + let cookie = try XCTUnwrap(HTTPCookie(properties: properties)) + let expiry = try XCTUnwrap(cookie.expiresDate) + XCTAssertGreaterThanOrEqual(expiry, beforeCreation.addingTimeInterval(59)) + XCTAssertLessThanOrEqual(expiry, Date().addingTimeInterval(61)) + } + + func testMaxAgeAcceptsImmediateAndPastExpiry() throws { + for maxAge in [0, -1] { + var properties = baseProperties() + + try CookieAttributeLogic.apply( + props: ["maxAge": maxAge], + secure: false, + to: &properties, + parseDate: { _ in nil } + ) + + XCTAssertEqual(properties[.maximumAge] as? String, String(maxAge)) + } + } + + func testInvalidExpiresRetainsPreviousSessionCookieBehavior() throws { + var properties = baseProperties() + + try CookieAttributeLogic.apply( + props: ["expires": "not-a-date"], + secure: false, + to: &properties, + parseDate: { _ in nil } + ) + + XCTAssertNil(properties[.expires]) + XCTAssertNil(properties[.maximumAge]) + } + + func testRejectsNonIntegerAndNonFiniteMaxAge() { + for maxAge in [1.5, Double.nan, Double.infinity] { + var properties = baseProperties() + + XCTAssertThrowsError( + try CookieAttributeLogic.apply( + props: ["maxAge": maxAge], + secure: false, + to: &properties, + parseDate: { _ in nil } + ) + ) + } + } + + func testSameSiteIsNormalizedAndReadable() throws { + for (input, expected) in [("Lax", "lax"), ("STRICT", "strict")] { + var properties = baseProperties() + + try CookieAttributeLogic.apply( + props: ["sameSite": input], + secure: false, + to: &properties, + parseDate: { _ in nil } + ) + + let cookie = try XCTUnwrap(HTTPCookie(properties: properties)) + XCTAssertEqual(CookieAttributeLogic.sameSiteValue(from: cookie), expected) + } + } + + func testSecureSameSiteNoneUsesFoundationsUnrestrictedPolicy() throws { + var properties = baseProperties() + + try CookieAttributeLogic.apply( + props: ["sameSite": "none"], + secure: true, + to: &properties, + parseDate: { _ in nil } + ) + + XCTAssertNil(properties[.sameSitePolicy]) + let cookie = try XCTUnwrap(HTTPCookie(properties: properties)) + XCTAssertNil(CookieAttributeLogic.sameSiteValue(from: cookie)) + } + + func testRejectsInsecureSameSiteNoneAndUnknownValues() { + for sameSite in ["none", "invalid"] { + var properties = baseProperties() + + XCTAssertThrowsError( + try CookieAttributeLogic.apply( + props: ["sameSite": sameSite], + secure: false, + to: &properties, + parseDate: { _ in nil } + ) + ) + } + } + + private func baseProperties() -> [HTTPCookiePropertyKey: Any] { + [ + .name: "session", + .value: "value", + .domain: "example.com", + .path: "/", + ] + } +} diff --git a/swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift b/swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift index 1492652..e38f4d9 100644 --- a/swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift +++ b/swift-tests/CookieStoreAccessIntegrationTests/CookieStoreAccessIntegrationTests.swift @@ -11,6 +11,22 @@ final class CookieStoreAccessIntegrationTests: XCTestCase { await assertRoundTrip(in: .webKit) } + func testFoundationRoundTripPreservesModernAttributes() async { + await assertModernAttributesRoundTrip(in: .foundation) + } + + func testWebKitRoundTripPreservesModernAttributes() async { + await assertModernAttributesRoundTrip(in: .webKit) + } + + func testFoundationMaxAgeZeroExpiresExistingCookie() async { + await assertMaxAgeZeroExpiresExistingCookie(in: .foundation) + } + + func testWebKitMaxAgeZeroExpiresExistingCookie() async { + await assertMaxAgeZeroExpiresExistingCookie(in: .webKit) + } + func testClearAllTargetsSelectedStoreAndWaitsForCompletion() async { let identifier = UUID().uuidString.lowercased() let name = "cookie_manager_\(identifier.replacingOccurrences(of: "-", with: ""))" @@ -64,6 +80,54 @@ final class CookieStoreAccessIntegrationTests: XCTestCase { await clearAll(from: store) } + private func assertModernAttributesRoundTrip(in store: CookieStoreKind) async { + let identifier = UUID().uuidString.lowercased() + let name = "cookie_manager_\(identifier.replacingOccurrences(of: "-", with: ""))" + let host = "\(identifier).cookie-manager.invalid" + let url = URL(string: "https://\(host)/")! + let cookie = HTTPCookie(properties: [ + .name: name, + .value: "modern", + .domain: host, + .path: "/", + .secure: true, + .maximumAge: "3600", + .sameSitePolicy: "strict", + ])! + + await set(cookie, in: store) + + let stored = await load(for: url, from: store).first { $0.name == name } + XCTAssertEqual(stored?.sameSitePolicy?.rawValue, "strict") + XCTAssertGreaterThan(stored?.expiresDate ?? .distantPast, Date()) + + if let stored { + await delete(stored, from: store) + } + await clearAll(from: store) + } + + private func assertMaxAgeZeroExpiresExistingCookie(in store: CookieStoreKind) async { + let identifier = UUID().uuidString.lowercased() + let name = "cookie_manager_\(identifier.replacingOccurrences(of: "-", with: ""))" + let host = "\(identifier).cookie-manager.invalid" + let url = URL(string: "https://\(host)/")! + + await set(makeCookie(name: name, value: "active", domain: host, path: "/"), in: store) + let expiredCookie = HTTPCookie(properties: [ + .name: name, + .value: "expired", + .domain: host, + .path: "/", + .maximumAge: "0", + ])! + await set(expiredCookie, in: store) + + let remaining = await load(for: url, from: store).filter { $0.name == name } + XCTAssertTrue(remaining.isEmpty) + await clearAll(from: store) + } + private func makeCookie( name: String, value: String, From d1612302a2c6b3562f0264de2141ba99aa0274eb Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 23:00:20 +0300 Subject: [PATCH 19/22] docs: polish api table --- .gitignore | 3 +++ README.md | 30 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 843e1ba..b5ad2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ android/generated # React Native Nitro Modules nitrogen/ + +# Local notes +/docs/local-*.md diff --git a/README.md b/README.md index 7fd5c7a..a823fb3 100644 --- a/README.md +++ b/README.md @@ -145,21 +145,21 @@ await CookieManager.setFromResponse( The public API remains compatible with `@react-native-cookies/cookies`. -| Method | Platforms | Returns | Description | -| --- | --- | --- | --- | -| `set(url, cookie, useWebKit?)` | iOS, Android | `Promise` | Stores a cookie, including `sameSite` and relative `maxAge`. On iOS, uses Foundation by default or default WebKit when `true`. | -| `get(url, useWebKit?)` | iOS, Android | `Promise` | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | -| `getAsArray(url, useWebKit?)` | iOS, Android | `Promise>` | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | -| `getCookieHeader(url, useWebKit?)` | iOS, Android | `Promise` | Returns the selected store's matching cookies as a `Cookie` request-header value, or an empty string. | -| `clearAll(useWebKit?)` | iOS, Android | `Promise` | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | -| `clearAllStores()` | iOS, Android | `Promise` | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | -| `getAll(useWebKit?)` | iOS | `Promise` | Reads Foundation by default or default WebKit when `true`. | -| `getAllAsArray(useWebKit?)` | iOS | `Promise>` | Reads the selected iOS store without collapsing cookies that share a name. | -| `clearByName(url, name, useWebKit?)` | iOS, Android | `Promise` | Clears same-name cookies from the selected iOS store, or variants applicable to `url` in the shared Android store. | -| `flush()` | iOS, Android | `Promise` | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | -| `removeSessionCookies(options?)` | iOS, Android | `Promise` | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | -| `setFromResponse(url, cookieHeader)` | iOS, Android | `Promise` | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | -| `getFromResponse(url)` | iOS, Android | `Promise` | Deprecated; performs a GET and updates Foundation on iOS. | +| Method | Platforms | Description | +| --- | --- | --- | +| `set(url, cookie, useWebKit?)`: `Promise` | iOS, Android | Stores a cookie, including `sameSite` and relative `maxAge`. On iOS, uses Foundation by default or default WebKit when `true`. | +| `get(url, useWebKit?)`: `Promise` | iOS, Android | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | +| `getAsArray(url, useWebKit?)`: `Promise>` | iOS, Android | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | +| `getCookieHeader(url, useWebKit?)`: `Promise` | iOS, Android | Returns the selected store's matching cookies as a `Cookie` request-header value, or an empty string. | +| `clearAll(useWebKit?)`: `Promise` | iOS, Android | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | +| `clearAllStores()`: `Promise` | iOS, Android | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | +| `getAll(useWebKit?)`: `Promise` | iOS | Reads Foundation by default or default WebKit when `true`. | +| `getAllAsArray(useWebKit?)`: `Promise>` | iOS | Reads the selected iOS store without collapsing cookies that share a name. | +| `clearByName(url, name, useWebKit?)`: `Promise` | iOS, Android | Clears same-name cookies from the selected iOS store, or variants applicable to `url` in the shared Android store. | +| `flush()`: `Promise` | iOS, Android | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | +| `removeSessionCookies(options?)`: `Promise` | iOS, Android | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | +| `setFromResponse(url, cookieHeader)`: `Promise` | iOS, Android | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | +| `getFromResponse(url)`: `Promise` | iOS, Android | Deprecated; performs a GET and updates Foundation on iOS. | `useWebKit` is available on `set()`, `get()`, `getAsArray()`, `getCookieHeader()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. From efa73edb4e888168dd434f423342722ea47a1022 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 23:02:38 +0300 Subject: [PATCH 20/22] docs: bold font for api methods --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a823fb3..b0c14e7 100644 --- a/README.md +++ b/README.md @@ -147,19 +147,19 @@ The public API remains compatible with `@react-native-cookies/cookies`. | Method | Platforms | Description | | --- | --- | --- | -| `set(url, cookie, useWebKit?)`: `Promise` | iOS, Android | Stores a cookie, including `sameSite` and relative `maxAge`. On iOS, uses Foundation by default or default WebKit when `true`. | -| `get(url, useWebKit?)`: `Promise` | iOS, Android | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | -| `getAsArray(url, useWebKit?)`: `Promise>` | iOS, Android | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | -| `getCookieHeader(url, useWebKit?)`: `Promise` | iOS, Android | Returns the selected store's matching cookies as a `Cookie` request-header value, or an empty string. | -| `clearAll(useWebKit?)`: `Promise` | iOS, Android | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | -| `clearAllStores()`: `Promise` | iOS, Android | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | -| `getAll(useWebKit?)`: `Promise` | iOS | Reads Foundation by default or default WebKit when `true`. | -| `getAllAsArray(useWebKit?)`: `Promise>` | iOS | Reads the selected iOS store without collapsing cookies that share a name. | -| `clearByName(url, name, useWebKit?)`: `Promise` | iOS, Android | Clears same-name cookies from the selected iOS store, or variants applicable to `url` in the shared Android store. | -| `flush()`: `Promise` | iOS, Android | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | -| `removeSessionCookies(options?)`: `Promise` | iOS, Android | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | -| `setFromResponse(url, cookieHeader)`: `Promise` | iOS, Android | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | -| `getFromResponse(url)`: `Promise` | iOS, Android | Deprecated; performs a GET and updates Foundation on iOS. | +| **`set(url, cookie, useWebKit?)`**: `Promise` | iOS, Android | Stores a cookie, including `sameSite` and relative `maxAge`. On iOS, uses Foundation by default or default WebKit when `true`. | +| **`get(url, useWebKit?)`**: `Promise` | iOS, Android | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | +| **`getAsArray(url, useWebKit?)`**: `Promise>` | iOS, Android | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | +| **`getCookieHeader(url, useWebKit?)`**: `Promise` | iOS, Android | Returns the selected store's matching cookies as a `Cookie` request-header value, or an empty string. | +| **`clearAll(useWebKit?)`**: `Promise` | iOS, Android | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | +| **`clearAllStores()`**: `Promise` | iOS, Android | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | +| **`getAll(useWebKit?)`**: `Promise` | iOS | Reads Foundation by default or default WebKit when `true`. | +| **`getAllAsArray(useWebKit?)`**: `Promise>` | iOS | Reads the selected iOS store without collapsing cookies that share a name. | +| **`clearByName(url, name, useWebKit?)`**: `Promise` | iOS, Android | Clears same-name cookies from the selected iOS store, or variants applicable to `url` in the shared Android store. | +| **`flush()`**: `Promise` | iOS, Android | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | +| **`removeSessionCookies(options?)`**: `Promise` | iOS, Android | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | +| **`setFromResponse(url, cookieHeader)`**: `Promise` | iOS, Android | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | +| **`getFromResponse(url)`**: `Promise` | iOS, Android | Deprecated; performs a GET and updates Foundation on iOS. | `useWebKit` is available on `set()`, `get()`, `getAsArray()`, `getCookieHeader()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. From fa0a97b581a1d79706cfb5f4a0d1e86fad95ee18 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Tue, 21 Jul 2026 23:22:00 +0300 Subject: [PATCH 21/22] tests: add device checks --- CHANGELOG.md | 3 +- README.md | 2 +- example/metro.config.js | 36 +++ example/src/App.tsx | 197 +++++++++++++ example/src/deviceSmokeTests.ts | 486 ++++++++++++++++++++++++++++++++ 5 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 example/src/deviceSmokeTests.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aed6bc2..8735e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ --- -## v6.4.0 (Unreleased) +## v6.4.0: Reliable native cookies and modern attributes (Unreleased) ### Added @@ -40,6 +40,7 @@ - Added Android coverage for host-only, domain, path, prefixed, and partitioned cookie deletion, unsupported providers, callback ordering, and rejected writes. - Added unit coverage for the Expo config plugin and extended the Expo prebuild smoke test to verify its generated Gradle property. - Added Swift and Kotlin coverage for `sameSite`, relative `maxAge`, `maxAge`/`expires` precedence, immediate deletion, invalid values, time-zone offsets, Android RFC serialization, and Foundation/WebKit store round trips. +- Added example app device checks: a one-tap public API smoke test and a two-phase prepare → force-stop → verify flow for persistent-cookie restoration without an extra manual `flush()`. ### Compatibility diff --git a/README.md b/README.md index b0c14e7..0c29585 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ The public API remains compatible with `@react-native-cookies/cookies`. | **`getAll(useWebKit?)`**: `Promise` | iOS | Reads Foundation by default or default WebKit when `true`. | | **`getAllAsArray(useWebKit?)`**: `Promise>` | iOS | Reads the selected iOS store without collapsing cookies that share a name. | | **`clearByName(url, name, useWebKit?)`**: `Promise` | iOS, Android | Clears same-name cookies from the selected iOS store, or variants applicable to `url` in the shared Android store. | -| **`flush()`**: `Promise` | iOS, Android | Explicit persistence barrier for the Android store; normally unnecessary after library mutations. It is a no-op on iOS because the system manages persistence automatically. | +| **`flush()`**: `Promise` | iOS, Android | Explicit Android persistence barrier for external shared-store changes. Library mutations persist automatically on both platforms; this method is a no-op on iOS. | | **`removeSessionCookies(options?)`**: `Promise` | iOS, Android | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | | **`setFromResponse(url, cookieHeader)`**: `Promise` | iOS, Android | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | | **`getFromResponse(url)`**: `Promise` | iOS, Android | Deprecated; performs a GET and updates Foundation on iOS. | diff --git a/example/metro.config.js b/example/metro.config.js index aa9a381..b3bc617 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -1,4 +1,5 @@ const path = require('path'); +const { URL } = require('url'); const { getDefaultConfig } = require('@react-native/metro-config'); const { withMetroConfig } = require('react-native-monorepo-config'); @@ -15,4 +16,39 @@ const config = withMetroConfig(getDefaultConfig(__dirname), { dirname: __dirname, }); +const defaultEnhanceMiddleware = config.server?.enhanceMiddleware; + +config.server = { + ...config.server, + enhanceMiddleware: (middleware, server) => { + const enhancedMiddleware = defaultEnhanceMiddleware + ? defaultEnhanceMiddleware(middleware, server) + : middleware; + + return (request, response, next) => { + const requestURL = new URL(request.url || '/', 'http://localhost'); + + if (requestURL.pathname === '/__cookie_manager_smoke__') { + const value = requestURL.searchParams.get('value'); + if (!value || !/^\d+$/.test(value)) { + response.statusCode = 400; + response.end('Expected a numeric value'); + return; + } + + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.setHeader( + 'Set-Cookie', + `cm_smoke_network=${value}; Path=/; Max-Age=300; SameSite=Lax` + ); + response.end(JSON.stringify({ ok: true })); + return; + } + + enhancedMiddleware(request, response, next); + }; + }, +}; + module.exports = config; diff --git a/example/src/App.tsx b/example/src/App.tsx index ba60251..29932e0 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -11,6 +11,12 @@ import { import CookieManager, { type Cookie, } from '@preeternal/react-native-cookie-manager'; +import { + preparePersistenceTest, + runDeviceSmokeTests, + type DeviceSmokeReport, + verifyPersistenceTest, +} from './deviceSmokeTests'; const DEFAULT_DOMAIN = '.example.com'; @@ -22,11 +28,61 @@ const buildUrlForDomain = (domain: string): string => { return `https://${host}`; }; +const reportSummary = (report: DeviceSmokeReport): string => { + const failed = report.checks.filter( + (check) => check.status === 'failed' + ).length; + const skipped = report.checks.filter( + (check) => check.status === 'skipped' + ).length; + + if (failed > 0) { + return `FAILED (${failed} failed)`; + } + + return skipped > 0 ? `PASSED (${skipped} skipped)` : 'PASSED'; +}; + +const TestReport = ({ + report, + testID, +}: { + report: DeviceSmokeReport; + testID: string; +}) => ( + + + {reportSummary(report)} + + {report.checks.map((check) => ( + + {check.status === 'passed' + ? '✓' + : check.status === 'skipped' + ? '○' + : '✗'}{' '} + {check.name} + {check.detail ? ` — ${check.detail}` : ''} + + ))} + +); + export default function App() { const [domainInput, setDomainInput] = useState(DEFAULT_DOMAIN); const [output, setOutput] = useState('{}'); const [status, setStatus] = useState('Ready'); const [cookieIndex, setCookieIndex] = useState(1); + const [deviceReport, setDeviceReport] = useState( + null + ); + const [deviceTestsRunning, setDeviceTestsRunning] = useState(false); + const [persistenceReport, setPersistenceReport] = + useState(null); + const [persistenceStatus, setPersistenceStatus] = + useState('Start with step 1'); + const [persistenceTestRunning, setPersistenceTestRunning] = + useState(false); const inspectUrl = useMemo(() => { const normalizedDomain = normalizeDomainInput(domainInput); @@ -125,6 +181,74 @@ export default function App() { }); }, [inspectUrl, refreshCookies]); + const handleDeviceTestsPress = useCallback(() => { + setDeviceTestsRunning(true); + setDeviceReport(null); + setStatus('Running device smoke tests'); + + runDeviceSmokeTests() + .then((report) => { + setDeviceReport(report); + setStatus( + report.passed + ? 'Device smoke tests PASSED' + : 'Device smoke tests FAILED' + ); + }) + .catch((error) => { + setStatus(`Device smoke tests failed: ${String(error)}`); + }) + .finally(() => { + setDeviceTestsRunning(false); + refreshCookies(inspectUrl).catch(() => undefined); + }); + }, [inspectUrl, refreshCookies]); + + const handlePreparePersistencePress = useCallback(() => { + setPersistenceTestRunning(true); + setPersistenceReport(null); + setPersistenceStatus('Preparing cookies'); + + preparePersistenceTest() + .then((report) => { + setPersistenceReport(report); + setPersistenceStatus( + report.passed + ? 'PREPARED — force-close the app now' + : 'Preparation FAILED — fix the failed checks and retry step 1' + ); + }) + .catch((error) => { + setPersistenceStatus(`Preparation failed: ${String(error)}`); + }) + .finally(() => { + setPersistenceTestRunning(false); + }); + }, []); + + const handleVerifyPersistencePress = useCallback(() => { + setPersistenceTestRunning(true); + setPersistenceReport(null); + setPersistenceStatus('Verifying restored cookies'); + + verifyPersistenceTest() + .then((report) => { + setPersistenceReport(report); + setPersistenceStatus( + report.passed + ? 'Persistence test PASSED; test cookies were removed' + : 'Persistence test FAILED; see the checks below' + ); + }) + .catch((error) => { + setPersistenceStatus(`Verification failed: ${String(error)}`); + }) + .finally(() => { + setPersistenceTestRunning(false); + refreshCookies(inspectUrl).catch(() => undefined); + }); + }, [inspectUrl, refreshCookies]); + useEffect(() => { handleRefreshPress(); }, [handleRefreshPress]); @@ -153,6 +277,67 @@ export default function App() {