From bde2960c8a673c8dc05377931c0910ec0879d3b0 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Fri, 4 Sep 2026 13:20:52 +0100 Subject: [PATCH 1/7] feat(native): one door to app-local plugins, with the fallback made mandatory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every call to an app-local Capacitor plugin has to survive the same thing: the JS half ships over the air onto binaries built months earlier, where the plugin is not there. Capacitor answers a missing native method with a rejected promise, not a compile error, so a forgotten try/catch is a crash on a user's device that no type and no test sees. nativeCapability() closes three hazards in one place instead of at each call site: a missing plugin (older binary), a platform with no native half (registerPlugin hands back a working proxy on web, where invoking it rejects), and the thenable trap — the proxy answers ANY property with a native-method wrapper, .then included, so resolving a promise WITH a plugin leaves it pending forever. That one shipped twice, in 1.0.44 and 1.0.45-1.0.47; here the proxy cannot escape the closure it is handed to, so it is unreachable by construction rather than by review. The fallback is a function, never a bare value, so every call site must state what 'this device can't do it' means — the omission that turns a missing plugin into a crash. It receives the rejection for callers that report it. Migrates all three existing call sites (ClipboardDetect, InstallReferrer, PushProvisioning) with no behaviour change, and adds an eslint rule so a new one cannot bypass the wrapper. addCardToWallet gains a platform gate it was missing. Version-gated capabilities stay bespoke: canRestartInPlace() in capgo-updater.ts reads the native plugin's own version, which is a different question from whether the plugin exists. --- eslint.config.js | 14 ++ src/utils/__tests__/native-capability.test.ts | 145 ++++++++++++++++++ src/utils/clipboard-detect.ts | 27 ++-- src/utils/deferred-link.ts | 23 +-- src/utils/native-capability.ts | 78 ++++++++++ src/utils/push-provisioning.ts | 26 ++-- 6 files changed, 271 insertions(+), 42 deletions(-) create mode 100644 src/utils/__tests__/native-capability.test.ts create mode 100644 src/utils/native-capability.ts diff --git a/eslint.config.js b/eslint.config.js index 15f5d8c01c..5d67ea7b29 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,13 @@ const USE_SEARCH_PARAMS_IMPORT_RESTRICTION = { "Don't read query params with useSearchParams — use useQueryStates from 'nuqs' (typed parsers, URL as state). See CLAUDE.md 'URL as State'. DS 10 ratchet: existing files are allowlisted; new files must use nuqs.", } +const REGISTER_PLUGIN_IMPORT_RESTRICTION = { + name: '@capacitor/core', + importNames: ['registerPlugin'], + message: + "Don't call registerPlugin directly — declare the plugin with nativeCapability() from '@/utils/native-capability' and reach it through .call(invoke, onUnavailable). This JS ships over the air onto binaries built months earlier, where the plugin simply does not exist and Capacitor answers a missing native method with a rejected promise, not a compile error: a forgotten try/catch is a crash on a user's device that no type and no test sees. The wrapper also gates on platform and keeps the proxy inside the closure, so it can never be returned across an await (the .then trap that shipped in 1.0.44 and 1.0.45–1.0.47).", +} + const QUERY_STRING_PUSH_MESSAGE = "Don't build a query string by hand for router.push/replace — write URL state with useQueryStates from 'nuqs' (its setter updates the params in place; pathname-only navigation is fine). See CLAUDE.md 'URL as State'. DS 10 ratchet: existing files are allowlisted; new files must use nuqs." @@ -225,6 +232,7 @@ module.exports = [ ...RESTRICTED_IMPORT_PATHS, USE_SEARCH_PARAMS_IMPORT_RESTRICTION, TAILWIND_MERGE_IMPORT_RESTRICTION, + REGISTER_PLUGIN_IMPORT_RESTRICTION, ], }, ], @@ -260,6 +268,12 @@ module.exports = [ files: ['src/utils/tw.ts', 'src/utils/__tests__/tw.test.ts'], rules: { 'no-restricted-imports': 'off' }, }, + { + // The wrapper itself is the one legal registerPlugin caller — it is + // what every other call site is required to go through. + files: ['src/utils/native-capability.ts'], + rules: { 'no-restricted-imports': 'off' }, + }, { // Capacitor hardware back: different bug class (canGoBack + minimizeApp). files: ['src/hooks/useNativePlugins.ts'], diff --git a/src/utils/__tests__/native-capability.test.ts b/src/utils/__tests__/native-capability.test.ts new file mode 100644 index 0000000000..88df250484 --- /dev/null +++ b/src/utils/__tests__/native-capability.test.ts @@ -0,0 +1,145 @@ +// nativeCapability is the single door to app-local Capacitor plugins, so its +// job is the failure paths: a plugin the running binary predates, a platform +// with no native half, and the registerPlugin proxy's thenable trap. +import { nativeCapability } from '../native-capability' +import { isAndroidNative, isIOSNative } from '../capacitor' +import { createPluginProxy, expectToSettle } from '../__mocks__/capacitor-plugin-proxy' + +const pluginImplementation: Record = {} + +jest.mock('@capacitor/core', () => ({ + // The real registerPlugin returns a Proxy, not a plain object. Mocking it + // as a plain object is what hides the thenable trap, so the house mock is + // used here too. + registerPlugin: jest.fn(() => + jest.requireActual('../__mocks__/capacitor-plugin-proxy').createPluginProxy(pluginImplementation, 'TestPlugin') + ), +})) + +jest.mock('../capacitor', () => ({ + isIOSNative: jest.fn(() => false), + isAndroidNative: jest.fn(() => false), +})) + +const mockIsIOSNative = isIOSNative as jest.MockedFunction +const mockIsAndroidNative = isAndroidNative as jest.MockedFunction + +interface TestPlugin { + doThing(): Promise<{ value: string }> +} + +describe('nativeCapability', () => { + beforeEach(() => { + jest.clearAllMocks() + for (const key of Object.keys(pluginImplementation)) delete pluginImplementation[key] + mockIsIOSNative.mockReturnValue(false) + mockIsAndroidNative.mockReturnValue(false) + }) + + it('returns the native answer on a supported platform', async () => { + mockIsIOSNative.mockReturnValue(true) + pluginImplementation.doThing = jest.fn(async () => ({ value: 'native' })) + + const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) + + await expect( + capability.call( + async (p) => (await p.doThing()).value, + () => 'fallback' + ) + ).resolves.toBe('native') + }) + + it('falls back without touching the plugin on an unsupported platform', async () => { + mockIsAndroidNative.mockReturnValue(true) + const doThing = jest.fn() + pluginImplementation.doThing = doThing + + const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) + + await expect( + capability.call( + async (p) => (await p.doThing()).value, + () => 'fallback' + ) + ).resolves.toBe('fallback') + // Not merely "answered fallback": on web the proxy exists and invoking + // it rejects, so the gate has to stop the call, not catch it. + expect(doThing).not.toHaveBeenCalled() + }) + + it('falls back when the running binary predates the plugin', async () => { + mockIsIOSNative.mockReturnValue(true) + // The method is simply absent — exactly an older binary running OTA'd + // JS. The proxy answers with an Unimplemented rejection, not undefined. + + const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) + + await expect( + capability.call( + async (p) => (await p.doThing()).value, + () => 'fallback' + ) + ).resolves.toBe('fallback') + }) + + it('hands the rejection to the fallback, for callers that report it', async () => { + mockIsIOSNative.mockReturnValue(true) + pluginImplementation.doThing = jest.fn(async () => { + throw new Error('user cancelled') + }) + + const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) + const result = await capability.call( + async (p) => (await p.doThing()).value, + (error) => (error instanceof Error ? error.message : 'unknown') + ) + + expect(result).toBe('user cancelled') + }) + + it('settles rather than hanging, even though the plugin is a proxy', async () => { + mockIsIOSNative.mockReturnValue(true) + pluginImplementation.doThing = jest.fn(async () => ({ value: 'native' })) + + const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) + + // The trap this closes: resolving a promise WITH the plugin makes the + // runtime probe proxy.then, which dispatches a native call that never + // invokes either callback — the promise stays pending forever. The + // proxy cannot escape `call`, so the result is always a plain value. + await expect( + expectToSettle( + capability.call( + (p) => p.doThing(), + () => ({ value: 'fallback' }) + ) + ) + ).resolves.toEqual({ + value: 'native', + }) + }) + + it('reports platform support from the declared platforms', () => { + const iosOnly = nativeCapability('TestPlugin', { platforms: ['ios'] }) + const both = nativeCapability('TestPlugin', { platforms: ['ios', 'android'] }) + + expect(iosOnly.isSupportedPlatform()).toBe(false) + + mockIsAndroidNative.mockReturnValue(true) + expect(iosOnly.isSupportedPlatform()).toBe(false) + expect(both.isSupportedPlatform()).toBe(true) + + mockIsAndroidNative.mockReturnValue(false) + mockIsIOSNative.mockReturnValue(true) + expect(iosOnly.isSupportedPlatform()).toBe(true) + }) + + it('keeps the house proxy semantics the mock exists to enforce', async () => { + // Guards the guard: if createPluginProxy ever stopped rejecting for + // absent methods, every "older binary" test above would pass vacuously. + const proxy = createPluginProxy>({}, 'TestPlugin') + + await expect((proxy as unknown as TestPlugin).doThing()).rejects.toThrow('is not implemented') + }) +}) diff --git a/src/utils/clipboard-detect.ts b/src/utils/clipboard-detect.ts index 6b1af3e441..218611faa9 100644 --- a/src/utils/clipboard-detect.ts +++ b/src/utils/clipboard-detect.ts @@ -1,5 +1,4 @@ -import { registerPlugin } from '@capacitor/core' -import { isIOSNative } from './capacitor' +import { nativeCapability } from './native-capability' interface ClipboardDetectPlugin { hasStrings(): Promise<{ value: boolean }> @@ -8,7 +7,7 @@ interface ClipboardDetectPlugin { // App-local iOS plugin (ios/App/App/ClipboardDetectPlugin.swift); no web/Android // implementation exists — callers must treat "unavailable" as false. -const ClipboardDetect = registerPlugin('ClipboardDetect') +const ClipboardDetect = nativeCapability('ClipboardDetect', { platforms: ['ios'] }) /** * iOS-native only: prompt-free "is there text on the clipboard?" check via @@ -18,13 +17,10 @@ const ClipboardDetect = registerPlugin('ClipboardDetect') * (OTA'd JS), so the caller simply doesn't offer the paste shortcut there. */ export async function clipboardHasStrings(): Promise { - if (!isIOSNative()) return false - try { - const { value } = await ClipboardDetect.hasStrings() - return value - } catch { - return false - } + return ClipboardDetect.call( + async (plugin) => (await plugin.hasStrings()).value, + () => false + ) } /** @@ -35,11 +31,8 @@ export async function clipboardHasStrings(): Promise { * the prompt. False on other platforms and binaries without the method. */ export async function clipboardHasProbableWebUrl(): Promise { - if (!isIOSNative()) return false - try { - const { value } = await ClipboardDetect.hasProbableWebUrl() - return value - } catch { - return false - } + return ClipboardDetect.call( + async (plugin) => (await plugin.hasProbableWebUrl()).value, + () => false + ) } diff --git a/src/utils/deferred-link.ts b/src/utils/deferred-link.ts index aba61b528b..bc6b80ba91 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -3,13 +3,13 @@ // native app. android rides the Play Install Referrer; iOS rides a clipboard // hand-off written on the store-bounce tap and read once on first launch. // TASK-20772 — the download modal (TASK-20769) is the eventual web consumer. -import { registerPlugin } from '@capacitor/core' import posthog from 'posthog-js' import { ANALYTICS_EVENTS, DEFERRED_LINK_OUTCOMES, type DeferredLinkOutcome } from '@/constants/analytics.consts' import { PLAY_STORE_URL } from '@/constants/general.consts' import { isValidLocale } from '@/i18n/config' import { type AppLocale, resolveLocaleOrNull } from '@/i18n/app/config' import { isAndroidNative, isIOSNative } from './capacitor' +import { nativeCapability } from './native-capability' import { getFromCookie, saveToCookie, sanitizeRedirectURL } from './cookie-url.utils' import { toInviteCode } from './invite-code.utils' import { deepLinkToNativePath } from './native-routes' @@ -52,19 +52,20 @@ export interface DeferredPayload { dest?: string } -// app-local android plugin (InstallReferrerPlugin.java); throws "not -// implemented" on iOS/web and on older binaries running OTA'd JS — callers -// catch and treat as null. -const InstallReferrer = registerPlugin<{ getReferrer(): Promise<{ referrer: string | null }> }>('InstallReferrer') +// app-local android plugin (InstallReferrerPlugin.java); absent on iOS/web and +// on older binaries running OTA'd JS, which the capability turns into null. +const InstallReferrer = nativeCapability<{ getReferrer(): Promise<{ referrer: string | null }> }>('InstallReferrer', { + platforms: ['android'], +}) /** raw play install referrer string, or null anywhere it can't be read. */ export async function readInstallReferrer(): Promise { - try { - return (await InstallReferrer.getReferrer()).referrer ?? null - } catch { - // older binary without the plugin, iOS/web, or referrer service unavailable - return null - } + // null covers all of: older binary without the plugin, iOS/web, and the + // referrer service being unavailable on a busy first boot. + return InstallReferrer.call( + async (plugin) => (await plugin.getReferrer()).referrer ?? null, + () => null + ) } // --------------------------------------------------------------------------- diff --git a/src/utils/native-capability.ts b/src/utils/native-capability.ts new file mode 100644 index 0000000000..ebca981cfd --- /dev/null +++ b/src/utils/native-capability.ts @@ -0,0 +1,78 @@ +import { registerPlugin } from '@capacitor/core' +import { isAndroidNative, isIOSNative } from './capacitor' + +/** + * The one place app-local Capacitor plugins are reached, because every call to + * one has to survive the same thing: **the JS half ships over the air onto + * binaries built months earlier.** A plugin the current tree takes for granted + * simply is not there on an older shell, and Capacitor's answer to a missing + * native method is a rejected promise, not a compile error — so a forgotten + * try/catch is a crash on a user's device that no test and no type sees. + * + * Three separate hazards get closed here instead of at each call site: + * + * 1. **Missing plugin.** Older binary, or a platform with no implementation: + * the call rejects, and `call()` answers with the caller's fallback. + * 2. **Wrong platform.** `registerPlugin` happily hands back a proxy on web, + * where invoking it rejects. The platform gate means no call is attempted. + * 3. **The thenable trap.** The proxy answers ANY property with a native-method + * wrapper, `.then` included, so resolving a promise WITH a plugin leaves it + * pending forever (shipped twice — getPreferences in 1.0.44, the Crisp + * helper in 1.0.45–1.0.47). Here the proxy never escapes the closure it is + * handed to, so it cannot be returned across an await by construction. + * + * Version-gated capabilities — where the plugin EXISTS but an older native half + * behaves differently — are a different problem and stay bespoke; see + * `canRestartInPlace()` in capgo-updater.ts, which reads the native plugin's own + * version rather than trusting package.json. + */ + +type NativePlatform = 'ios' | 'android' + +export interface NativeCapability { + /** + * Runs one native call, or answers with `onUnavailable` when it cannot. + * + * The fallback is a function, never a bare value, so every call site has to + * say what "this device can't do it" means — the omission that turns a + * missing plugin into a crash. It receives the rejection for the cases that + * want to report it. + */ + call(invoke: (plugin: T) => Promise, onUnavailable: (error: unknown) => R): Promise + /** Whether a native implementation could exist here at all. No native call. */ + isSupportedPlatform(): boolean +} + +/** + * Declares an app-local plugin and the platforms whose binaries implement it. + * + * `platforms` is about where the native code was WRITTEN, not where it happens + * to be installed: an iOS-only plugin declares `['ios']`, and an older iOS + * binary that predates it still falls back through `call()`. + */ +export function nativeCapability( + name: string, + { platforms }: { platforms: NativePlatform[] } +): NativeCapability { + const plugin = registerPlugin(name) + + const isSupportedPlatform = () => + (platforms.includes('ios') && isIOSNative()) || (platforms.includes('android') && isAndroidNative()) + + return { + isSupportedPlatform, + async call(invoke, onUnavailable) { + if (!isSupportedPlatform()) return onUnavailable(new Error(`${name} is not available on this platform`)) + try { + return await invoke(plugin) + } catch (error) { + // Every rejection is the same answer to the caller: a missing + // plugin, an unimplemented method on this platform, and a + // genuine native error are indistinguishable here and all mean + // "you don't get this". Callers that need the detail read it + // off the error they are handed. + return onUnavailable(error) + } + }, + } +} diff --git a/src/utils/push-provisioning.ts b/src/utils/push-provisioning.ts index 85d380cac5..5e2675354f 100644 --- a/src/utils/push-provisioning.ts +++ b/src/utils/push-provisioning.ts @@ -1,5 +1,4 @@ -import { registerPlugin } from '@capacitor/core' -import { isAndroidNative, isIOSNative } from './capacitor' +import { nativeCapability } from './native-capability' /** * PostHog launch gate for native wallet push provisioning (doctrine: @@ -46,7 +45,9 @@ interface PushProvisioningPlugin { // src/meawallet/java). Binaries built without the MeaWallet SDK — and OTA'd JS // on older binaries — don't have it, so every caller treats "unavailable" as // false and falls back to the manual add-to-wallet carousel. -const PushProvisioning = registerPlugin('PushProvisioning') +const PushProvisioning = nativeCapability('PushProvisioning', { + platforms: ['ios', 'android'], +}) /** * Can this device do one-tap wallet provisioning for this card? False on web, @@ -55,12 +56,10 @@ const PushProvisioning = registerPlugin('PushProvisionin * where the UI should keep the manual carousel (or hide the row). */ export async function getPushProvisioningAvailability(last4?: string): Promise { - if (!isIOSNative() && !isAndroidNative()) return { available: false, alreadyInWallet: false } - try { - return await PushProvisioning.isAvailable({ last4 }) - } catch { - return { available: false, alreadyInWallet: false } - } + return PushProvisioning.call( + (plugin) => plugin.isAvailable({ last4 }), + () => ({ available: false, alreadyInWallet: false }) + ) } /** @@ -69,9 +68,8 @@ export async function getPushProvisioningAvailability(last4?: string): Promise

{ - try { - return await PushProvisioning.addCard(args) - } catch (e) { - return { added: false, error: e instanceof Error ? e.message : 'unavailable' } - } + return PushProvisioning.call( + (plugin) => plugin.addCard(args), + (error) => ({ added: false, error: error instanceof Error ? error.message : 'unavailable' }) + ) } From b2f286ad8aebec0cf6800bca240378355dd07587 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Fri, 4 Sep 2026 14:33:48 +0100 Subject: [PATCH 2/7] fix(native): take a method name, not a callback holding the plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip was right that the callback signature did not actually close the thenable trap it claimed to. call(async (plugin) => plugin, fallback) is type-correct, and the async function assimilates the proxy's .then while resolving — so it hangs before reaching the catch or the fallback. There is no runtime guard available: the hang happens before any of our code runs again, so the invariant has to hold at the type level. call now takes a method name, its options and the fallback. The plugin is never handed out, so returning it is not something a caller can express. The call sites read better for it, and two @ts-expect-error assertions guard the invariant — typecheck is a CI job, so if either stops erroring the hole is back. Also fixes the add-card tests, which passed only because the availability suite above them left mockIsAndroidNative true and clearAllMocks does not reset return values. Run alone they took the unsupported-platform fallback and never invoked addCard at all. Both platform answers are now set explicitly, the native cases assert addCard was actually called, and a third case covers the web fallback the platform gate added. --- src/utils/__tests__/native-capability.test.ts | 71 ++++++++++--------- src/utils/__tests__/push-provisioning.test.ts | 19 +++++ src/utils/clipboard-detect.ts | 14 ++-- src/utils/deferred-link.ts | 12 ++-- src/utils/native-capability.ts | 45 +++++++++--- src/utils/push-provisioning.ts | 13 ++-- 6 files changed, 107 insertions(+), 67 deletions(-) diff --git a/src/utils/__tests__/native-capability.test.ts b/src/utils/__tests__/native-capability.test.ts index 88df250484..7cf8ac25fb 100644 --- a/src/utils/__tests__/native-capability.test.ts +++ b/src/utils/__tests__/native-capability.test.ts @@ -25,7 +25,7 @@ const mockIsIOSNative = isIOSNative as jest.MockedFunction const mockIsAndroidNative = isAndroidNative as jest.MockedFunction interface TestPlugin { - doThing(): Promise<{ value: string }> + doThing(options?: undefined): Promise<{ value: string }> } describe('nativeCapability', () => { @@ -42,12 +42,9 @@ describe('nativeCapability', () => { const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) - await expect( - capability.call( - async (p) => (await p.doThing()).value, - () => 'fallback' - ) - ).resolves.toBe('native') + await expect(capability.call('doThing', undefined, () => ({ value: 'fallback' }))).resolves.toEqual({ + value: 'native', + }) }) it('falls back without touching the plugin on an unsupported platform', async () => { @@ -57,12 +54,9 @@ describe('nativeCapability', () => { const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) - await expect( - capability.call( - async (p) => (await p.doThing()).value, - () => 'fallback' - ) - ).resolves.toBe('fallback') + await expect(capability.call('doThing', undefined, () => ({ value: 'fallback' }))).resolves.toEqual({ + value: 'fallback', + }) // Not merely "answered fallback": on web the proxy exists and invoking // it rejects, so the gate has to stop the call, not catch it. expect(doThing).not.toHaveBeenCalled() @@ -75,12 +69,9 @@ describe('nativeCapability', () => { const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) - await expect( - capability.call( - async (p) => (await p.doThing()).value, - () => 'fallback' - ) - ).resolves.toBe('fallback') + await expect(capability.call('doThing', undefined, () => ({ value: 'fallback' }))).resolves.toEqual({ + value: 'fallback', + }) }) it('hands the rejection to the fallback, for callers that report it', async () => { @@ -90,12 +81,11 @@ describe('nativeCapability', () => { }) const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) - const result = await capability.call( - async (p) => (await p.doThing()).value, - (error) => (error instanceof Error ? error.message : 'unknown') - ) + const result = await capability.call('doThing', undefined, (error) => ({ + value: error instanceof Error ? error.message : 'unknown', + })) - expect(result).toBe('user cancelled') + expect(result).toEqual({ value: 'user cancelled' }) }) it('settles rather than hanging, even though the plugin is a proxy', async () => { @@ -106,18 +96,31 @@ describe('nativeCapability', () => { // The trap this closes: resolving a promise WITH the plugin makes the // runtime probe proxy.then, which dispatches a native call that never - // invokes either callback — the promise stays pending forever. The - // proxy cannot escape `call`, so the result is always a plain value. + // invokes either callback — the promise stays pending forever. await expect( - expectToSettle( - capability.call( - (p) => p.doThing(), - () => ({ value: 'fallback' }) - ) + expectToSettle(capability.call('doThing', undefined, () => ({ value: 'fallback' }))) + ).resolves.toEqual({ value: 'native' }) + }) + + it('gives callers no way to get the proxy back out', () => { + const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) + + // The reason `call` takes a method NAME. With a callback signature, + // `call(async (plugin) => plugin, fallback)` type-checks and then hangs + // forever: the async function assimilates the proxy's .then while + // resolving, so it never reaches the catch or the fallback. There is no + // runtime guard for it — the hang happens before any code of ours runs + // again — so the invariant has to hold at the type level, and typecheck + // is a CI job. If this stops erroring, the hole is back. + // @ts-expect-error a callback is not a method name + expect(() => + capability.call( + async (plugin: TestPlugin) => plugin, + () => undefined ) - ).resolves.toEqual({ - value: 'native', - }) + ).toBeDefined() + // @ts-expect-error 'notAMethod' is not on TestPlugin + expect(() => capability.call('notAMethod', undefined, () => undefined)).toBeDefined() }) it('reports platform support from the declared platforms', () => { diff --git a/src/utils/__tests__/push-provisioning.test.ts b/src/utils/__tests__/push-provisioning.test.ts index a8823db3ba..c914336f61 100644 --- a/src/utils/__tests__/push-provisioning.test.ts +++ b/src/utils/__tests__/push-provisioning.test.ts @@ -62,7 +62,14 @@ describe('getPushProvisioningAvailability', () => { describe('addCardToWallet', () => { beforeEach(() => { + // Both platform answers reset AND a supported one enabled explicitly: + // clearAllMocks keeps mockReturnValue, so these tests used to pass only + // because the availability suite above happened to leave Android true. + // Run alone, each took the unsupported-platform fallback and never + // reached addCard at all. jest.clearAllMocks() + mockIsIOSNative.mockReturnValue(false) + mockIsAndroidNative.mockReturnValue(true) }) it('passes through the native result', async () => { @@ -71,6 +78,7 @@ describe('addCardToWallet', () => { added: true, last4: '1234', }) + expect(addCard).toHaveBeenCalled() }) it('never throws — plugin errors come back as { added: false, error }', async () => { @@ -79,5 +87,16 @@ describe('addCardToWallet', () => { added: false, error: 'boom', }) + expect(addCard).toHaveBeenCalled() + }) + + it('is unavailable on web without touching the plugin', async () => { + mockIsAndroidNative.mockReturnValue(false) + + await expect(addCardToWallet({ cardId: 'c', cardSecret: 's' })).resolves.toEqual({ + added: false, + error: 'PushProvisioning is not available on this platform', + }) + expect(addCard).not.toHaveBeenCalled() }) }) diff --git a/src/utils/clipboard-detect.ts b/src/utils/clipboard-detect.ts index 218611faa9..391acbd257 100644 --- a/src/utils/clipboard-detect.ts +++ b/src/utils/clipboard-detect.ts @@ -1,8 +1,8 @@ import { nativeCapability } from './native-capability' interface ClipboardDetectPlugin { - hasStrings(): Promise<{ value: boolean }> - hasProbableWebUrl(): Promise<{ value: boolean }> + hasStrings(options?: undefined): Promise<{ value: boolean }> + hasProbableWebUrl(options?: undefined): Promise<{ value: boolean }> } // App-local iOS plugin (ios/App/App/ClipboardDetectPlugin.swift); no web/Android @@ -17,10 +17,7 @@ const ClipboardDetect = nativeCapability('ClipboardDetect * (OTA'd JS), so the caller simply doesn't offer the paste shortcut there. */ export async function clipboardHasStrings(): Promise { - return ClipboardDetect.call( - async (plugin) => (await plugin.hasStrings()).value, - () => false - ) + return (await ClipboardDetect.call('hasStrings', undefined, () => ({ value: false }))).value } /** @@ -31,8 +28,5 @@ export async function clipboardHasStrings(): Promise { * the prompt. False on other platforms and binaries without the method. */ export async function clipboardHasProbableWebUrl(): Promise { - return ClipboardDetect.call( - async (plugin) => (await plugin.hasProbableWebUrl()).value, - () => false - ) + return (await ClipboardDetect.call('hasProbableWebUrl', undefined, () => ({ value: false }))).value } diff --git a/src/utils/deferred-link.ts b/src/utils/deferred-link.ts index bc6b80ba91..d7b3fbe882 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -54,18 +54,16 @@ export interface DeferredPayload { // app-local android plugin (InstallReferrerPlugin.java); absent on iOS/web and // on older binaries running OTA'd JS, which the capability turns into null. -const InstallReferrer = nativeCapability<{ getReferrer(): Promise<{ referrer: string | null }> }>('InstallReferrer', { - platforms: ['android'], -}) +const InstallReferrer = nativeCapability<{ getReferrer(options?: undefined): Promise<{ referrer: string | null }> }>( + 'InstallReferrer', + { platforms: ['android'] } +) /** raw play install referrer string, or null anywhere it can't be read. */ export async function readInstallReferrer(): Promise { // null covers all of: older binary without the plugin, iOS/web, and the // referrer service being unavailable on a busy first boot. - return InstallReferrer.call( - async (plugin) => (await plugin.getReferrer()).referrer ?? null, - () => null - ) + return (await InstallReferrer.call('getReferrer', undefined, () => ({ referrer: null }))).referrer ?? null } // --------------------------------------------------------------------------- diff --git a/src/utils/native-capability.ts b/src/utils/native-capability.ts index ebca981cfd..0c4a2854c8 100644 --- a/src/utils/native-capability.ts +++ b/src/utils/native-capability.ts @@ -9,7 +9,7 @@ import { isAndroidNative, isIOSNative } from './capacitor' * native method is a rejected promise, not a compile error — so a forgotten * try/catch is a crash on a user's device that no test and no type sees. * - * Three separate hazards get closed here instead of at each call site: + * Three hazards get closed here instead of at each call site: * * 1. **Missing plugin.** Older binary, or a platform with no implementation: * the call rejects, and `call()` answers with the caller's fallback. @@ -18,8 +18,15 @@ import { isAndroidNative, isIOSNative } from './capacitor' * 3. **The thenable trap.** The proxy answers ANY property with a native-method * wrapper, `.then` included, so resolving a promise WITH a plugin leaves it * pending forever (shipped twice — getPreferences in 1.0.44, the Crisp - * helper in 1.0.45–1.0.47). Here the proxy never escapes the closure it is - * handed to, so it cannot be returned across an await by construction. + * helper in 1.0.45–1.0.47). + * + * (3) is why `call` takes a METHOD NAME rather than a callback holding the + * plugin. A callback signature looks safer than it is: `call(async (plugin) => + * plugin, fallback)` type-checks, and the async function assimilates the + * proxy's `.then` while resolving — so it hangs *before* any catch or fallback + * can run, which is exactly the failure this module claims to make impossible. + * With the plugin never handed out, returning it is not something a caller can + * express. * * Version-gated capabilities — where the plugin EXISTS but an older native half * behaves differently — are a different problem and stay bespoke; see @@ -29,16 +36,31 @@ import { isAndroidNative, isIOSNative } from './capacitor' type NativePlatform = 'ios' | 'android' +type AsyncMethod = (options?: never) => Promise + +/** Only the plugin's async methods are callable — nothing else is addressable. */ +type MethodName = { + [K in keyof T]: T[K] extends (...args: never[]) => Promise ? K : never +}[keyof T] + +type Method> = Extract Promise> +type MethodOptions> = Parameters>[0] +type MethodResult> = Awaited>> + export interface NativeCapability { /** - * Runs one native call, or answers with `onUnavailable` when it cannot. + * Invokes one native method, or answers with `onUnavailable` when it cannot. * * The fallback is a function, never a bare value, so every call site has to * say what "this device can't do it" means — the omission that turns a * missing plugin into a crash. It receives the rejection for the cases that * want to report it. */ - call(invoke: (plugin: T) => Promise, onUnavailable: (error: unknown) => R): Promise + call>( + method: K, + options: MethodOptions, + onUnavailable: (error: unknown) => MethodResult + ): Promise> /** Whether a native implementation could exist here at all. No native call. */ isSupportedPlatform(): boolean } @@ -59,12 +81,19 @@ export function nativeCapability( const isSupportedPlatform = () => (platforms.includes('ios') && isIOSNative()) || (platforms.includes('android') && isAndroidNative()) + // One cast at the boundary: the per-method generics above are what call + // sites are checked against, and expressing them inside the implementation + // buys nothing a reader can use. return { isSupportedPlatform, - async call(invoke, onUnavailable) { + async call(method: MethodName, options: unknown, onUnavailable: (error: unknown) => unknown) { if (!isSupportedPlatform()) return onUnavailable(new Error(`${name} is not available on this platform`)) try { - return await invoke(plugin) + // .call(plugin, …) so the proxy stays the receiver: reading the + // method off it detaches `this`, and the native bridge wrapper + // is not guaranteed to be bound. + const invoke = plugin[method] as unknown as AsyncMethod + return await invoke.call(plugin, options as never) } catch (error) { // Every rejection is the same answer to the caller: a missing // plugin, an unimplemented method on this platform, and a @@ -74,5 +103,5 @@ export function nativeCapability( return onUnavailable(error) } }, - } + } as NativeCapability } diff --git a/src/utils/push-provisioning.ts b/src/utils/push-provisioning.ts index 5e2675354f..fad8a51049 100644 --- a/src/utils/push-provisioning.ts +++ b/src/utils/push-provisioning.ts @@ -56,10 +56,7 @@ const PushProvisioning = nativeCapability('PushProvision * where the UI should keep the manual carousel (or hide the row). */ export async function getPushProvisioningAvailability(last4?: string): Promise { - return PushProvisioning.call( - (plugin) => plugin.isAvailable({ last4 }), - () => ({ available: false, alreadyInWallet: false }) - ) + return PushProvisioning.call('isAvailable', { last4 }, () => ({ available: false, alreadyInWallet: false })) } /** @@ -68,8 +65,8 @@ export async function getPushProvisioningAvailability(last4?: string): Promise

{ - return PushProvisioning.call( - (plugin) => plugin.addCard(args), - (error) => ({ added: false, error: error instanceof Error ? error.message : 'unavailable' }) - ) + return PushProvisioning.call('addCard', args, (error) => ({ + added: false, + error: error instanceof Error ? error.message : 'unavailable', + })) } From b4474d8e105b7c42352b292015bd1bc001d0091c Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Fri, 4 Sep 2026 15:03:49 +0100 Subject: [PATCH 3/7] test(native): make the proxy-invariant assertion compile-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mistakes in the guard I added for Chip's finding, both caught by CI. @ts-expect-error binds to the next LINE, and prettier wraps a long argument list — so the directive landed on `expect(() =>` while the error sat two lines below, and typecheck reported the directive itself as unused. Fixing that exposed the second: the assertion was executing. A two-argument call leaves onUnavailable undefined, so the unsupported-platform branch threw an unhandled rejection and took Node down with it. Nothing here should run — the whole assertion is that it does not compile. Both calls now sit in a function that is declared and never invoked, with each directive directly above its call. TypeScript still checks the body, so the invariant is still enforced by the typecheck job. --- src/utils/__tests__/native-capability.test.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/utils/__tests__/native-capability.test.ts b/src/utils/__tests__/native-capability.test.ts index 7cf8ac25fb..a3a92a963a 100644 --- a/src/utils/__tests__/native-capability.test.ts +++ b/src/utils/__tests__/native-capability.test.ts @@ -112,15 +112,25 @@ describe('nativeCapability', () => { // runtime guard for it — the hang happens before any code of ours runs // again — so the invariant has to hold at the type level, and typecheck // is a CI job. If this stops erroring, the hole is back. - // @ts-expect-error a callback is not a method name - expect(() => - capability.call( - async (plugin: TestPlugin) => plugin, - () => undefined - ) - ).toBeDefined() - // @ts-expect-error 'notAMethod' is not on TestPlugin - expect(() => capability.call('notAMethod', undefined, () => undefined)).toBeDefined() + const callback = async (plugin: TestPlugin) => plugin + const fallback = () => undefined + + // Declared and never invoked. TypeScript still checks the body, which + // is the whole assertion — running it would crash: the two-argument + // call leaves onUnavailable undefined, and the point is precisely that + // this does not compile. + // + // Each directive sits directly above its CALL because it binds to the + // next LINE, and prettier wraps a long argument list — which parks the + // error below the comment and reports the directive itself as unused. + const rejectedByTheCompiler = () => { + // @ts-expect-error a callback is not a method name + void capability.call(callback, fallback) + // @ts-expect-error 'notAMethod' is not a method on TestPlugin + void capability.call('notAMethod', undefined, fallback) + } + + expect(typeof rejectedByTheCompiler).toBe('function') }) it('reports platform support from the declared platforms', () => { From 0ec97817592f158d0ba4afd91058aa81f787d654 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Fri, 4 Sep 2026 15:47:23 +0100 Subject: [PATCH 4/7] fix(native): close the lint bypass, require a live bridge, fix the message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, one of which meant the rule did not do what the PR claimed. The DS 10 allowlist block REPLACES no-restricted-imports rather than extending it, so the plugin ban only ever applied to files outside that list. Every allowlisted production file — src/hooks/useLogin.tsx among them — could import registerPlugin directly and walk straight around the single-door invariant. Carried the restriction into that override and verified by probing: the ban now fires in useLogin.tsx. isIOSNative/isAndroidNative fall back to a user-agent sniff that deliberately reports a capacitor-flavoured web build as native, so a Vercel preview opened on an Android phone passed isSupportedPlatform() and reached the web plugin proxy — the exact off-native call the gate exists to stop. Gate on isNativeBridge() as well, which is what "will a native API really work" already means elsewhere in capacitor.ts. The lint message still told developers to write .call(invoke, onUnavailable), which this branch replaced. Following it produced code that does not typecheck. Test mocks gain isNativeBridge, and a new case covers the native-looking platform with no bridge. --- eslint.config.js | 12 +++++++-- src/utils/__tests__/clipboard-detect.test.ts | 1 + src/utils/__tests__/deferred-link.test.ts | 1 + src/utils/__tests__/native-capability.test.ts | 25 ++++++++++++++++++- src/utils/__tests__/push-provisioning.test.ts | 1 + src/utils/native-capability.ts | 13 +++++++--- 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 5d67ea7b29..493b16e02e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -48,7 +48,7 @@ const REGISTER_PLUGIN_IMPORT_RESTRICTION = { name: '@capacitor/core', importNames: ['registerPlugin'], message: - "Don't call registerPlugin directly — declare the plugin with nativeCapability() from '@/utils/native-capability' and reach it through .call(invoke, onUnavailable). This JS ships over the air onto binaries built months earlier, where the plugin simply does not exist and Capacitor answers a missing native method with a rejected promise, not a compile error: a forgotten try/catch is a crash on a user's device that no type and no test sees. The wrapper also gates on platform and keeps the proxy inside the closure, so it can never be returned across an await (the .then trap that shipped in 1.0.44 and 1.0.45–1.0.47).", + "Don't call registerPlugin directly — declare the plugin with nativeCapability() from '@/utils/native-capability' and reach it through .call(method, options, onUnavailable). This JS ships over the air onto binaries built months earlier, where the plugin simply does not exist and Capacitor answers a missing native method with a rejected promise, not a compile error: a forgotten try/catch is a crash on a user's device that no type and no test sees. The wrapper also gates on platform and keeps the proxy inside the closure, so it can never be returned across an await (the .then trap that shipped in 1.0.44 and 1.0.45–1.0.47).", } const QUERY_STRING_PUSH_MESSAGE = @@ -369,7 +369,15 @@ module.exports = [ 'src/hooks/useSendFlowOrigin.ts', ], rules: { - 'no-restricted-imports': ['error', { paths: RESTRICTED_IMPORT_PATHS }], + // REGISTER_PLUGIN_IMPORT_RESTRICTION rides along: this block REPLACES + // the base rule rather than extending it, so leaving it out would let + // every allowlisted file import registerPlugin directly and walk + // around the single-door invariant. The DS 10 exemption is about + // useSearchParams, not about native plugins. + 'no-restricted-imports': [ + 'error', + { paths: [...RESTRICTED_IMPORT_PATHS, REGISTER_PLUGIN_IMPORT_RESTRICTION] }, + ], }, }, { diff --git a/src/utils/__tests__/clipboard-detect.test.ts b/src/utils/__tests__/clipboard-detect.test.ts index 3f7efb6c4d..6accdf0892 100644 --- a/src/utils/__tests__/clipboard-detect.test.ts +++ b/src/utils/__tests__/clipboard-detect.test.ts @@ -12,6 +12,7 @@ jest.mock('@capacitor/core', () => ({ jest.mock('../capacitor', () => ({ isIOSNative: jest.fn(() => false), + isNativeBridge: jest.fn(() => true), })) const mockIsIOSNative = isIOSNative as jest.MockedFunction diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts index 4a1a0f3739..575aa12ae4 100644 --- a/src/utils/__tests__/deferred-link.test.ts +++ b/src/utils/__tests__/deferred-link.test.ts @@ -31,6 +31,7 @@ jest.mock('../capacitor', () => ({ getPlatform: jest.fn(() => 'web'), isAndroidNative: jest.fn(() => false), isIOSNative: jest.fn(() => false), + isNativeBridge: jest.fn(() => true), })) jest.mock('../clipboard-detect', () => ({ diff --git a/src/utils/__tests__/native-capability.test.ts b/src/utils/__tests__/native-capability.test.ts index a3a92a963a..e4acac419d 100644 --- a/src/utils/__tests__/native-capability.test.ts +++ b/src/utils/__tests__/native-capability.test.ts @@ -2,7 +2,7 @@ // job is the failure paths: a plugin the running binary predates, a platform // with no native half, and the registerPlugin proxy's thenable trap. import { nativeCapability } from '../native-capability' -import { isAndroidNative, isIOSNative } from '../capacitor' +import { isAndroidNative, isIOSNative, isNativeBridge } from '../capacitor' import { createPluginProxy, expectToSettle } from '../__mocks__/capacitor-plugin-proxy' const pluginImplementation: Record = {} @@ -19,10 +19,14 @@ jest.mock('@capacitor/core', () => ({ jest.mock('../capacitor', () => ({ isIOSNative: jest.fn(() => false), isAndroidNative: jest.fn(() => false), + // Defaults true so the platform mocks stay the subject of each test; the + // bridge gate gets its own case below. + isNativeBridge: jest.fn(() => true), })) const mockIsIOSNative = isIOSNative as jest.MockedFunction const mockIsAndroidNative = isAndroidNative as jest.MockedFunction +const mockIsNativeBridge = isNativeBridge as jest.MockedFunction interface TestPlugin { doThing(options?: undefined): Promise<{ value: string }> @@ -34,6 +38,7 @@ describe('nativeCapability', () => { for (const key of Object.keys(pluginImplementation)) delete pluginImplementation[key] mockIsIOSNative.mockReturnValue(false) mockIsAndroidNative.mockReturnValue(false) + mockIsNativeBridge.mockReturnValue(true) }) it('returns the native answer on a supported platform', async () => { @@ -133,6 +138,24 @@ describe('nativeCapability', () => { expect(typeof rejectedByTheCompiler).toBe('function') }) + it('needs a live bridge, not just a native-looking platform', () => { + // isIOSNative/isAndroidNative fall back to a user-agent sniff, which + // deliberately reports a capacitor-flavoured web build as native — a + // Vercel preview opened on an Android phone. There is no bridge there, + // so the plugin proxy would reject; platform alone is not enough. + mockIsIOSNative.mockReturnValue(true) + mockIsNativeBridge.mockReturnValue(false) + const doThing = jest.fn() + pluginImplementation.doThing = doThing + + const capability = nativeCapability('TestPlugin', { platforms: ['ios'] }) + + expect(capability.isSupportedPlatform()).toBe(false) + return expect(capability.call('doThing', undefined, () => ({ value: 'fallback' }))) + .resolves.toEqual({ value: 'fallback' }) + .then(() => expect(doThing).not.toHaveBeenCalled()) + }) + it('reports platform support from the declared platforms', () => { const iosOnly = nativeCapability('TestPlugin', { platforms: ['ios'] }) const both = nativeCapability('TestPlugin', { platforms: ['ios', 'android'] }) diff --git a/src/utils/__tests__/push-provisioning.test.ts b/src/utils/__tests__/push-provisioning.test.ts index c914336f61..d45afa93d0 100644 --- a/src/utils/__tests__/push-provisioning.test.ts +++ b/src/utils/__tests__/push-provisioning.test.ts @@ -18,6 +18,7 @@ jest.mock('@capacitor/core', () => ({ jest.mock('../capacitor', () => ({ isIOSNative: jest.fn(() => false), isAndroidNative: jest.fn(() => false), + isNativeBridge: jest.fn(() => true), })) const mockIsIOSNative = isIOSNative as jest.MockedFunction diff --git a/src/utils/native-capability.ts b/src/utils/native-capability.ts index 0c4a2854c8..0f332df64e 100644 --- a/src/utils/native-capability.ts +++ b/src/utils/native-capability.ts @@ -1,5 +1,5 @@ import { registerPlugin } from '@capacitor/core' -import { isAndroidNative, isIOSNative } from './capacitor' +import { isAndroidNative, isIOSNative, isNativeBridge } from './capacitor' /** * The one place app-local Capacitor plugins are reached, because every call to @@ -13,8 +13,12 @@ import { isAndroidNative, isIOSNative } from './capacitor' * * 1. **Missing plugin.** Older binary, or a platform with no implementation: * the call rejects, and `call()` answers with the caller's fallback. - * 2. **Wrong platform.** `registerPlugin` happily hands back a proxy on web, - * where invoking it rejects. The platform gate means no call is attempted. + * 2. **Wrong platform, or no bridge.** `registerPlugin` happily hands back a + * proxy on web, where invoking it rejects. The gate requires a LIVE bridge + * as well as the right platform: isIOSNative/isAndroidNative fall back to a + * user-agent sniff, which deliberately reports a capacitor-flavoured web + * build (a Vercel preview opened on an Android phone) as native even though + * no bridge exists — so platform alone would let a call through there. * 3. **The thenable trap.** The proxy answers ANY property with a native-method * wrapper, `.then` included, so resolving a promise WITH a plugin leaves it * pending forever (shipped twice — getPreferences in 1.0.44, the Crisp @@ -79,7 +83,8 @@ export function nativeCapability( const plugin = registerPlugin(name) const isSupportedPlatform = () => - (platforms.includes('ios') && isIOSNative()) || (platforms.includes('android') && isAndroidNative()) + isNativeBridge() && + ((platforms.includes('ios') && isIOSNative()) || (platforms.includes('android') && isAndroidNative())) // One cast at the boundary: the per-method generics above are what call // sites are checked against, and expressing them inside the implementation From f4c259dfc16a99f29f9adb5ac85f23a4f3dfba8e Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Fri, 4 Sep 2026 15:58:47 +0100 Subject: [PATCH 5/7] fix(lint): narrow the import-rule overrides instead of switching them off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip found a second override disabling the plugin ban, after the DS 10 one: the tailwind exemption sets no-restricted-imports to off for src/utils/tw.ts, which switches off every restriction there, not just tailwind-merge. My own native-capability.ts exemption had the same shape. Fixing them one at a time would leave the next exemption free to reopen it silently. Collect the restrictions in BASE_IMPORT_RESTRICTIONS and add restrictedImportsExcept(...), so an override names the single restriction it lifts and keeps the rest. A blanket 'off' no longer appears anywhere in the config, and the barrel-import ban now applies in tw.ts and native-capability.ts too, where it had been silently dropped. Verified by probing a registerPlugin import into each previously-exempt path — tw.ts, useLogin.tsx (DS 10 allowlist) and haptics.ts all reject it now, while native-capability.ts still accepts it and tw.ts still imports tailwind-merge. --- eslint.config.js | 55 ++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 493b16e02e..c59f8354c9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -51,6 +51,25 @@ const REGISTER_PLUGIN_IMPORT_RESTRICTION = { "Don't call registerPlugin directly — declare the plugin with nativeCapability() from '@/utils/native-capability' and reach it through .call(method, options, onUnavailable). This JS ships over the air onto binaries built months earlier, where the plugin simply does not exist and Capacitor answers a missing native method with a rejected promise, not a compile error: a forgotten try/catch is a crash on a user's device that no type and no test sees. The wrapper also gates on platform and keeps the proxy inside the closure, so it can never be returned across an await (the .then trap that shipped in 1.0.44 and 1.0.45–1.0.47).", } +// Every import restriction in one list, plus a way to drop exactly one. +// +// Overrides used to write `'no-restricted-imports': 'off'`, which disables the +// WHOLE rule — so the tailwind-merge exemption for tw.ts also switched off the +// barrel-import and registerPlugin bans there, and any future exemption would +// silently do the same to whatever was added since. Naming the one restriction +// being lifted keeps an exemption about the thing it is for. +const BASE_IMPORT_RESTRICTIONS = [ + ...RESTRICTED_IMPORT_PATHS, + USE_SEARCH_PARAMS_IMPORT_RESTRICTION, + TAILWIND_MERGE_IMPORT_RESTRICTION, + REGISTER_PLUGIN_IMPORT_RESTRICTION, +] + +const restrictedImportsExcept = (...lifted) => [ + 'error', + { paths: BASE_IMPORT_RESTRICTIONS.filter((restriction) => !lifted.includes(restriction)) }, +] + const QUERY_STRING_PUSH_MESSAGE = "Don't build a query string by hand for router.push/replace — write URL state with useQueryStates from 'nuqs' (its setter updates the params in place; pathname-only navigation is fine). See CLAUDE.md 'URL as State'. DS 10 ratchet: existing files are allowlisted; new files must use nuqs." @@ -225,17 +244,7 @@ module.exports = [ 'react/no-unknown-property': ['error', { ignore: ['jsx', 'global'] }], // Ban barrel imports (see BANNED_BARREL_PATHS) + useSearchParams (DS 10). - 'no-restricted-imports': [ - 'error', - { - paths: [ - ...RESTRICTED_IMPORT_PATHS, - USE_SEARCH_PARAMS_IMPORT_RESTRICTION, - TAILWIND_MERGE_IMPORT_RESTRICTION, - REGISTER_PLUGIN_IMPORT_RESTRICTION, - ], - }, - ], + 'no-restricted-imports': restrictedImportsExcept(), // Ban self-imports — CLAUDE.md import rules. Confirmed firing on synthetic test. 'import-x/no-self-import': 'error', @@ -264,15 +273,16 @@ module.exports = [ }, { // The wrapper itself (and its census test) are the only legal raw - // tailwind-merge importers. + // tailwind-merge importers. Only THAT restriction is lifted: a blanket + // off also switched the registerPlugin ban off here. files: ['src/utils/tw.ts', 'src/utils/__tests__/tw.test.ts'], - rules: { 'no-restricted-imports': 'off' }, + rules: { 'no-restricted-imports': restrictedImportsExcept(TAILWIND_MERGE_IMPORT_RESTRICTION) }, }, { // The wrapper itself is the one legal registerPlugin caller — it is // what every other call site is required to go through. files: ['src/utils/native-capability.ts'], - rules: { 'no-restricted-imports': 'off' }, + rules: { 'no-restricted-imports': restrictedImportsExcept(REGISTER_PLUGIN_IMPORT_RESTRICTION) }, }, { // Capacitor hardware back: different bug class (canGoBack + minimizeApp). @@ -369,15 +379,14 @@ module.exports = [ 'src/hooks/useSendFlowOrigin.ts', ], rules: { - // REGISTER_PLUGIN_IMPORT_RESTRICTION rides along: this block REPLACES - // the base rule rather than extending it, so leaving it out would let - // every allowlisted file import registerPlugin directly and walk - // around the single-door invariant. The DS 10 exemption is about - // useSearchParams, not about native plugins. - 'no-restricted-imports': [ - 'error', - { paths: [...RESTRICTED_IMPORT_PATHS, REGISTER_PLUGIN_IMPORT_RESTRICTION] }, - ], + // Only the DS 10 restrictions are lifted. This block REPLACES the + // base rule rather than extending it, so a bare RESTRICTED_IMPORT_PATHS + // let every allowlisted file import registerPlugin directly and walk + // around the single-door invariant. + 'no-restricted-imports': restrictedImportsExcept( + USE_SEARCH_PARAMS_IMPORT_RESTRICTION, + TAILWIND_MERGE_IMPORT_RESTRICTION + ), }, }, { From 1d184ca6c92e1f3e16e416efc5de19d2dd2c71be Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Fri, 4 Sep 2026 16:28:35 +0100 Subject: [PATCH 6/7] fix(lint): ban the registerPlugin CALL, not just the named import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no-restricted-imports only sees the named import. Capacitor 8 exposes the same registrar as Capacitor.registerPlugin, and the rule cannot inspect a dynamic import at all — so either route reached a raw plugin proxy with none of the platform, bridge or fallback handling the wrapper exists to make mandatory. Added a no-restricted-syntax selector on the CALL, however the function was obtained. Probed all three routes into an allowlisted file: named import, Capacitor.registerPlugin and await import('@capacitor/core') are each rejected now. That rule had the same blanket-off problem the import rule did — three overrides switched it off wholesale for one selector each — so restrictedSyntaxExcept mirrors restrictedImportsExcept. No blanket 'off' remains for either rule anywhere in the config, and useSafeBack, haptics and useNativePlugins keep exactly the exemption each was written for. --- eslint.config.js | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index c59f8354c9..099e4a5f20 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -159,6 +159,17 @@ const RESTRICTED_SYNTAX_BASE = [ message: 'Never return a Capacitor plugin object across an await/then boundary — resolving a promise with it probes .then, which the plugin proxy turns into a native call that never settles the promise. Wrap it: `return { Plugin }` and destructure at the call site. See src/utils/crisp.ts and src/utils/auth-token.ts.', }, + { + // The import ban only stops the NAMED import. Capacitor 8 also exposes + // the same registrar as Capacitor.registerPlugin, and no-restricted-imports + // cannot see a dynamic import at all — either route reaches a raw plugin + // proxy with none of the platform/bridge/fallback handling the wrapper + // exists to make mandatory. Ban the CALL, however the function was + // obtained. + selector: "CallExpression[callee.name='registerPlugin'], CallExpression[callee.property.name='registerPlugin']", + message: + "Don't call registerPlugin — declare the plugin with nativeCapability() from '@/utils/native-capability' and reach it through .call(method, options, onUnavailable). This JS ships over the air onto binaries built months earlier, where the plugin does not exist and Capacitor answers a missing native method with a rejected promise, not a compile error. The wrapper also requires a live bridge and keeps the proxy inside the closure, so it can never be returned across an await.", + }, { // The --safe-* tokens (globals.css) are the only place the Android < 15 // zeroing and Capacitor's native inset injection land; a raw env() read @@ -170,6 +181,14 @@ const RESTRICTED_SYNTAX_BASE = [ }, ] +// Same shape as restrictedImportsExcept: an override names the selector it +// lifts instead of switching the whole rule off, so an exemption for one bug +// class cannot silently drop the registerPlugin ban (or anything added later). +const restrictedSyntaxExcept = (...lifted) => [ + 'error', + ...RESTRICTED_SYNTAX_BASE.filter((rule) => !lifted.some((needle) => rule.selector.includes(needle))), +] + const SAFE_AREA_ENV_SELECTOR = 'safe-area-inset' module.exports = [ @@ -261,15 +280,15 @@ module.exports = [ }, }, { - // The hook itself wraps router.back() — exempt. + // The hook itself wraps router.back() — exempt from THAT selector only. files: ['src/hooks/useSafeBack.ts', 'src/hooks/__tests__/useSafeBack.test.ts'], - rules: { 'no-restricted-syntax': 'off' }, + rules: { 'no-restricted-syntax': restrictedSyntaxExcept("callee.property.name='back'") }, }, { // The one module allowed to touch the Vibration API: it is the web // fallback behind the haptics helpers everything else must use. files: ['src/utils/haptics.ts'], - rules: { 'no-restricted-syntax': 'off' }, + rules: { 'no-restricted-syntax': restrictedSyntaxExcept("callee.property.name='vibrate'") }, }, { // The wrapper itself (and its census test) are the only legal raw @@ -282,12 +301,15 @@ module.exports = [ // The wrapper itself is the one legal registerPlugin caller — it is // what every other call site is required to go through. files: ['src/utils/native-capability.ts'], - rules: { 'no-restricted-imports': restrictedImportsExcept(REGISTER_PLUGIN_IMPORT_RESTRICTION) }, + rules: { + 'no-restricted-imports': restrictedImportsExcept(REGISTER_PLUGIN_IMPORT_RESTRICTION), + 'no-restricted-syntax': restrictedSyntaxExcept("callee.name='registerPlugin'"), + }, }, { // Capacitor hardware back: different bug class (canGoBack + minimizeApp). files: ['src/hooks/useNativePlugins.ts'], - rules: { 'no-restricted-syntax': 'off' }, + rules: { 'no-restricted-syntax': restrictedSyntaxExcept("callee.property.name='back'") }, }, { // PublicProfile is the one place we intentionally keep an isInternalReferrer + From 8e5462b92907fd2a9de1363bf5513e546209ee64 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Fri, 4 Sep 2026 16:58:32 +0100 Subject: [PATCH 7/7] fix(lint): stop the DS 10 override lifting the tailwind-merge ban too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DS 10 allowlist needs useSearchParams lifted and nothing else. Its pre-existing list was a bare RESTRICTED_IMPORT_PATHS, which also dropped the tailwind-merge restriction by omission — invisible until the lifts became explicit, and I preserved it verbatim rather than noticing it. Stock tailwind-merge does not know the DS token groups and silently deletes DS classes, so that exemption was never intended. Verified free to tighten: none of the 45 allowlisted files imports tailwind-merge directly, and both tw.ts and useLogin.tsx still lint clean. --- eslint.config.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 099e4a5f20..dde906ee0f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -405,10 +405,13 @@ module.exports = [ // base rule rather than extending it, so a bare RESTRICTED_IMPORT_PATHS // let every allowlisted file import registerPlugin directly and walk // around the single-door invariant. - 'no-restricted-imports': restrictedImportsExcept( - USE_SEARCH_PARAMS_IMPORT_RESTRICTION, - TAILWIND_MERGE_IMPORT_RESTRICTION - ), + // useSearchParams ONLY. The pre-existing list here was a bare + // RESTRICTED_IMPORT_PATHS, which also dropped the tailwind-merge + // ban by omission — invisible until the lifts became explicit. + // Stock tailwind-merge does not know the DS token groups and + // silently deletes DS classes, so that exemption was never + // intended. + 'no-restricted-imports': restrictedImportsExcept(USE_SEARCH_PARAMS_IMPORT_RESTRICTION), }, }, {