feat: resolve mobile git native module

This commit is contained in:
lucaronin
2026-05-05 14:36:18 +02:00
parent 7e0914c147
commit 66dc461c0f
10 changed files with 119 additions and 3 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
import { requireOptionalNativeModule } from 'expo-modules-core'
import { loadMobileGitNativeModule } from './mobileNativeGitModule'
export function loadExpoMobileGitNativeModule() {
return loadMobileGitNativeModule({ loadModule: requireOptionalNativeModule })
}

View File

@@ -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 <ModuleType,>(moduleName: string) =>
moduleName === mobileGitNativeModuleName ? module as ModuleType : null
}

View File

@@ -0,0 +1,34 @@
import type { MobileGitNativeModule } from './mobileNativeGitTransport'
export const mobileGitNativeModuleName = 'TolariaGit'
export type MobileGitNativeModuleLoader = <ModuleType>(
moduleName: string
) => ModuleType | null
export function loadMobileGitNativeModule({
loadModule,
}: {
loadModule: MobileGitNativeModuleLoader
}) {
return asMobileGitNativeModule(
loadModule<Partial<MobileGitNativeModule>>(mobileGitNativeModuleName),
)
}
function asMobileGitNativeModule(module: Partial<MobileGitNativeModule> | null) {
return hasNativeGitOperation({ module, operation: 'pull' })
&& hasNativeGitOperation({ module, operation: 'push' })
? module as MobileGitNativeModule
: null
}
function hasNativeGitOperation({
module,
operation,
}: {
module: Partial<MobileGitNativeModule> | null
operation: keyof MobileGitNativeModule
}) {
return typeof module?.[operation] === 'function'
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

3
pnpm-lock.yaml generated
View File

@@ -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)

View File

@@ -1254,7 +1254,11 @@ describe('App', () => {
render(<App />)
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'))