From 66dc461c0fd0a74554492fea5e4b42a70796eeaa Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 14:36:18 +0200 Subject: [PATCH] feat: resolve mobile git native module --- apps/mobile/package.json | 1 + apps/mobile/src/MobileApp.tsx | 3 +- apps/mobile/src/mobileExpoNativeGitModule.ts | 6 ++++ apps/mobile/src/mobileNativeGitModule.test.ts | 29 ++++++++++++++++ apps/mobile/src/mobileNativeGitModule.ts | 34 +++++++++++++++++++ docs/ABSTRACTIONS.md | 2 +- docs/MOBILE_PROGRESS.md | 7 ++++ ...o-native-module-boundary-for-mobile-git.md | 31 +++++++++++++++++ pnpm-lock.yaml | 3 ++ src/App.test.tsx | 6 +++- 10 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/mobileExpoNativeGitModule.ts create mode 100644 apps/mobile/src/mobileNativeGitModule.test.ts create mode 100644 apps/mobile/src/mobileNativeGitModule.ts create mode 100644 docs/adr/0115-expo-native-module-boundary-for-mobile-git.md diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ea540154..bd97fcb8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -19,6 +19,7 @@ "expo-auth-session": "~55.0.15", "expo-dev-client": "~55.0.32", "expo-file-system": "55.0.17", + "expo-modules-core": "55.0.24", "expo-secure-store": "~55.0.13", "expo-status-bar": "~55.0.5", "expo-web-browser": "~55.0.14", diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index 1d166d11..5fe623f1 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -66,6 +66,7 @@ import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader' import type { MobileNotePropertyPatch } from './mobileNoteProperties' import { useMobileGitSyncFlow } from './useMobileGitSyncFlow' import { createNativeMobileGitTransport } from './mobileNativeGitTransport' +import { loadExpoMobileGitNativeModule } from './mobileExpoNativeGitModule' export function MobileApp() { const { width } = useWindowDimensions() @@ -73,7 +74,7 @@ export function MobileApp() { const showsProperties = width >= 1000 const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), []) const gitCredentialStorage = useMemo(() => createNativeMobileGitCredentialStorage(), []) - const gitTransport = useMemo(() => createNativeMobileGitTransport(), []) + const gitTransport = useMemo(() => createNativeMobileGitTransport(loadExpoMobileGitNativeModule()), []) const gitHubOAuthClientIdState = useMemo(() => currentMobileGitHubOAuthClientIdState(), []) const vaultMetadataStorage = useMemo(() => createNativeMobileVaultMetadataStorage(), []) const [activeVaultMetadata, setActiveVaultMetadata] = useState(defaultMobileVaultMetadata) diff --git a/apps/mobile/src/mobileExpoNativeGitModule.ts b/apps/mobile/src/mobileExpoNativeGitModule.ts new file mode 100644 index 00000000..d5c5607d --- /dev/null +++ b/apps/mobile/src/mobileExpoNativeGitModule.ts @@ -0,0 +1,6 @@ +import { requireOptionalNativeModule } from 'expo-modules-core' +import { loadMobileGitNativeModule } from './mobileNativeGitModule' + +export function loadExpoMobileGitNativeModule() { + return loadMobileGitNativeModule({ loadModule: requireOptionalNativeModule }) +} diff --git a/apps/mobile/src/mobileNativeGitModule.test.ts b/apps/mobile/src/mobileNativeGitModule.test.ts new file mode 100644 index 00000000..e37e3ae0 --- /dev/null +++ b/apps/mobile/src/mobileNativeGitModule.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import { + loadMobileGitNativeModule, + mobileGitNativeModuleName, + type MobileGitNativeModuleLoader, +} from './mobileNativeGitModule' +import type { MobileGitNativeModule } from './mobileNativeGitTransport' + +describe('loadMobileGitNativeModule', () => { + it('loads the Tolaria Git native module when both operations exist', () => { + const nativeModule: MobileGitNativeModule = { + pull: vi.fn(), + push: vi.fn(), + } + const loadModule = nativeModuleLoader(nativeModule) + + expect(loadMobileGitNativeModule({ loadModule })).toBe(nativeModule) + }) + + it('returns null when the native module is missing or incomplete', () => { + expect(loadMobileGitNativeModule({ loadModule: () => null })).toBeNull() + expect(loadMobileGitNativeModule({ loadModule: nativeModuleLoader({ pull: vi.fn() }) })).toBeNull() + }) +}) + +function nativeModuleLoader(module: unknown): MobileGitNativeModuleLoader { + return (moduleName: string) => + moduleName === mobileGitNativeModuleName ? module as ModuleType : null +} diff --git a/apps/mobile/src/mobileNativeGitModule.ts b/apps/mobile/src/mobileNativeGitModule.ts new file mode 100644 index 00000000..6341b695 --- /dev/null +++ b/apps/mobile/src/mobileNativeGitModule.ts @@ -0,0 +1,34 @@ +import type { MobileGitNativeModule } from './mobileNativeGitTransport' + +export const mobileGitNativeModuleName = 'TolariaGit' + +export type MobileGitNativeModuleLoader = ( + moduleName: string +) => ModuleType | null + +export function loadMobileGitNativeModule({ + loadModule, +}: { + loadModule: MobileGitNativeModuleLoader +}) { + return asMobileGitNativeModule( + loadModule>(mobileGitNativeModuleName), + ) +} + +function asMobileGitNativeModule(module: Partial | null) { + return hasNativeGitOperation({ module, operation: 'pull' }) + && hasNativeGitOperation({ module, operation: 'push' }) + ? module as MobileGitNativeModule + : null +} + +function hasNativeGitOperation({ + module, + operation, +}: { + module: Partial | null + operation: keyof MobileGitNativeModule +}) { + return typeof module?.[operation] === 'function' +} diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 7b4ef9d2..86d5ce95 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -466,7 +466,7 @@ interface PulseCommit { The mobile app routes Git pull/push through `MobileGitTransport`. React Native UI and sync state code depend only on this TypeScript contract; the native implementation is introduced behind `createNativeMobileGitTransport`. -Until a native module is wired into Expo development builds, the native transport adapter returns a clear unavailable-module failure. This keeps authentication, sync status, retry UI, and future libgit2/Rust work testable without pretending that a JavaScript fallback is production Git. +`createNativeMobileGitTransport` resolves the optional Expo native module named `TolariaGit` through `expo-modules-core`. Until that module is wired into Expo development builds, the native transport adapter returns a clear unavailable-module failure. This keeps authentication, sync status, retry UI, and future libgit2/Rust work testable without pretending that a JavaScript fallback is production Git. The native boundary request is intentionally narrow for the first spike: active vault id, app-managed vault directory name, and remote URL. Credentials stay behind SecureStore/OAuth/native Git credential callbacks, and remote URLs must not embed tokens. diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 53e33b58..1d8609d7 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -103,6 +103,8 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Added the app-managed vault directory name to the native Git transport request so the future native module can locate the repository without deriving paths from display names. - Added TenTap horizontal-rule serialization so common divider output persists as canonical Markdown instead of blocking the draft. - Centralized mobile editor HTML entity decoding and added numeric entity / non-breaking-space support for paragraph, code, image/link, and table serialization. +- Added the optional Expo native-module resolver for `TolariaGit`, so the JavaScript Git transport automatically binds to future development builds that include the native module and keeps the current unavailable-module failure everywhere else. +- Created [ADR-0115](./adr/0115-expo-native-module-boundary-for-mobile-git.md) for the mobile Git native module discovery boundary. ## Next Action @@ -396,6 +398,11 @@ Continue Phase 4 with editor durability: - `pnpm --filter @tolaria/mobile typecheck` passed after centralized mobile HTML entity decoding. - CodeScene after centralized mobile HTML entity decoding: `apps/mobile/src/mobileHtmlEntities.ts`, `apps/mobile/src/mobileHtmlEntities.test.ts`, `apps/mobile/src/mobileEditorHtmlMarkdown.ts`, `apps/mobile/src/mobileEditorTableMarkdown.ts`, and `apps/mobile/src/mobileEditorDraft.test.ts` scored `10`. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after centralized mobile HTML entity decoding. +- `pnpm --filter @tolaria/mobile test -- src/mobileNativeGitModule.test.ts src/mobileNativeGitTransport.test.ts` passed after adding the optional `TolariaGit` native module resolver: 46 files / 152 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after adding the optional `TolariaGit` native module resolver. +- CodeScene after adding the optional `TolariaGit` native module resolver: `apps/mobile/src/mobileNativeGitModule.ts`, `apps/mobile/src/mobileNativeGitModule.test.ts`, `apps/mobile/src/mobileExpoNativeGitModule.ts`, `apps/mobile/src/mobileNativeGitTransport.ts`, and `apps/mobile/src/MobileApp.tsx` scored `10`. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding the optional `TolariaGit` native module resolver. +- `pnpm test -- src/App.test.tsx --silent` passed after extending the flaky desktop Git setup dialog wait that was blocking verified mobile commits in the full hook: 34 tests. ## Risks / Watch Items diff --git a/docs/adr/0115-expo-native-module-boundary-for-mobile-git.md b/docs/adr/0115-expo-native-module-boundary-for-mobile-git.md new file mode 100644 index 00000000..a179844e --- /dev/null +++ b/docs/adr/0115-expo-native-module-boundary-for-mobile-git.md @@ -0,0 +1,31 @@ +# ADR-0115: Expo Native Module Boundary for Mobile Git + +Date: 2026-05-05 + +## Status + +Accepted + +## Context + +Tolaria mobile needs a real Git implementation inside the iOS and later Android app. The JavaScript UI already has app-local vault storage, Git credential state, GitHub OAuth, and a `MobileGitTransport` contract, but pull/push cannot be implemented safely in JavaScript alone. + +The mobile strategy keeps over-the-air JavaScript updates important, but native dependencies still require development-client, TestFlight, or App Store builds. Git is one of those native capabilities: the JavaScript layer should expose a narrow, stable boundary while native code handles repository operations and credentials. + +Expo SDK 55 provides `expo-modules-core` and `requireOptionalNativeModule`, which lets JavaScript discover a native module when it exists and degrade cleanly when running in Expo Go or a build that does not contain the module. + +## Decision + +Use an Expo native module named `TolariaGit` as the JavaScript-facing native Git boundary. + +The JavaScript app imports `expo-modules-core` directly and resolves `TolariaGit` through `requireOptionalNativeModule`. `createNativeMobileGitTransport` binds to the module when it exposes both `pull` and `push`; otherwise it returns the explicit unavailable-module failure already used by the sync UI. + +The native request stays narrow: vault id, app-managed vault directory name, and remote URL. Credentials remain behind SecureStore/OAuth/native Git credential callbacks; remote URLs must not contain tokens. + +## Consequences + +- Expo Go remains useful for UI/editor work, but Git transport requires a development-client or production build that includes `TolariaGit`. +- OTA updates can change JavaScript sync planning, UI, retry behavior, and request shaping, but native Git implementation changes require a binary update. +- The future iOS module can use Rust/libgit2 or Swift/libgit2 behind the same TypeScript contract. +- Android can later implement the same `TolariaGit` module name and request/result shape. +- Unit tests can validate module discovery and transport normalization without native build output. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec327119..58e1fdff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: expo-file-system: specifier: 55.0.17 version: 55.0.17(expo@55.0.20)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)) + expo-modules-core: + specifier: 55.0.24 + version: 55.0.24(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) expo-secure-store: specifier: ~55.0.13 version: 55.0.13(expo@55.0.20) diff --git a/src/App.test.tsx b/src/App.test.tsx index 344a6c10..c1fd931b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1254,7 +1254,11 @@ describe('App', () => { render() - expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument() + expect(await screen.findByText( + 'Enable Git for this vault?', + {}, + { timeout: SLOW_APP_READY_TIMEOUT_MS }, + )).toBeInTheDocument() fireEvent.click(screen.getByTestId('status-vault-trigger')) fireEvent.click(screen.getByTestId('vault-menu-item-Git Vault'))