feat: add mobile git sync transport
This commit is contained in:
@@ -10,5 +10,15 @@ config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
]
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (moduleName === 'isomorphic-git') {
|
||||
return {
|
||||
filePath: path.resolve(workspaceRoot, 'node_modules/isomorphic-git/index.js'),
|
||||
type: 'sourceFile',
|
||||
}
|
||||
}
|
||||
|
||||
return context.resolveRequest(context, moduleName, platform)
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
"dependencies": {
|
||||
"@10play/tentap-editor": "^1.0.1",
|
||||
"@tolaria/markdown": "workspace:*",
|
||||
"expo": "~55.0.20",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~55.0.23",
|
||||
"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-file-system": "55.0.19",
|
||||
"expo-modules-core": "55.0.25",
|
||||
"expo-secure-store": "~55.0.13",
|
||||
"expo-status-bar": "~55.0.5",
|
||||
"expo-web-browser": "~55.0.14",
|
||||
"expo-status-bar": "~55.0.6",
|
||||
"expo-web-browser": "~55.0.15",
|
||||
"isomorphic-git": "^1.37.6",
|
||||
"phosphor-react-native": "^3.0.6",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.6",
|
||||
|
||||
@@ -74,8 +74,7 @@ import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader'
|
||||
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
|
||||
import { useMobileGitSyncFlow } from './useMobileGitSyncFlow'
|
||||
import { useMobileAiSettingsFlow } from './useMobileAiSettingsFlow'
|
||||
import { createNativeMobileGitTransport } from './mobileNativeGitTransport'
|
||||
import { loadExpoMobileGitNativeModule } from './mobileExpoNativeGitModule'
|
||||
import { createNativeIsomorphicMobileGitTransport } from './mobileNativeIsomorphicGitTransport'
|
||||
import { applyMobileRawNoteContent } from './mobileRawNoteProjection'
|
||||
import {
|
||||
createMobileSidebarSections,
|
||||
@@ -96,7 +95,7 @@ export function MobileApp() {
|
||||
const aiSettingsStorage = useMemo(() => createNativeMobileAiSettingsStorage(), [])
|
||||
const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), [])
|
||||
const gitCredentialStorage = useMemo(() => createNativeMobileGitCredentialStorage(), [])
|
||||
const gitTransport = useMemo(() => createNativeMobileGitTransport(loadExpoMobileGitNativeModule()), [])
|
||||
const gitTransport = useMemo(() => createNativeIsomorphicMobileGitTransport(gitCredentialStorage), [gitCredentialStorage])
|
||||
const gitHubOAuthClientIdState = useMemo(() => currentMobileGitHubOAuthClientIdState(), [])
|
||||
const vaultMetadataStorage = useMemo(() => createNativeMobileVaultMetadataStorage(), [])
|
||||
const [activeVaultMetadata, setActiveVaultMetadata] = useState(defaultMobileVaultMetadata)
|
||||
@@ -118,12 +117,22 @@ export function MobileApp() {
|
||||
)
|
||||
const selectedEditorMode = editorModeByNoteId[selectedNote.id] ?? 'rich'
|
||||
const selectedSaveState = saveStateByNoteId[selectedNote.id] ?? idleMobileEditorSaveState
|
||||
const reloadSyncedVault = useCallback(() => {
|
||||
void loadDemoVaultNotes(activeVaultMetadata)
|
||||
.then((notes) => {
|
||||
setAvailableNotes(notes)
|
||||
setCompactNavigation((state) => selectLoadedNote(state, notes, state.selectedNoteId))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [activeVaultMetadata])
|
||||
const gitSyncFlow = useMobileGitSyncFlow({
|
||||
createGitHubOAuthSession: createNativeMobileGitHubOAuthSessionFromEnvironment,
|
||||
credentialStorage: gitCredentialStorage,
|
||||
gitTransport,
|
||||
onSynced: reloadSyncedVault,
|
||||
vault: activeVaultMetadata,
|
||||
})
|
||||
const markGitLocalChanges = gitSyncFlow.markLocalChanges
|
||||
const aiSettingsFlow = useMobileAiSettingsFlow({
|
||||
secretStorage: aiProviderSecretStorage,
|
||||
settingsStorage: aiSettingsStorage,
|
||||
@@ -137,10 +146,11 @@ export function MobileApp() {
|
||||
setSaveStateByNoteId((state) => ({ ...state, [noteId]: saveState }))
|
||||
},
|
||||
onSavedDraft: (draft) => {
|
||||
markGitLocalChanges()
|
||||
setAvailableNotes((notes) => applySavedMobileEditorDraft({ draft, notes }))
|
||||
},
|
||||
}),
|
||||
[activeVaultMetadata],
|
||||
[activeVaultMetadata, markGitLocalChanges],
|
||||
)
|
||||
|
||||
const applyLoadedVaultRuntime = useCallback(({ activeVault, notes, selectedNoteId }: MobileVaultRuntime) => {
|
||||
@@ -196,13 +206,19 @@ export function MobileApp() {
|
||||
? { label: 'Saved', state: 'saved' }
|
||||
: { label: 'Save failed', state: 'failed' },
|
||||
}))
|
||||
if (result.status === 'saved') {
|
||||
markGitLocalChanges()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setSaveStateByNoteId((state) => ({ ...state, [noteId]: { label: 'Save failed', state: 'failed' } }))
|
||||
})
|
||||
}, [activeVaultMetadata, selectedNote.id])
|
||||
}, [activeVaultMetadata, markGitLocalChanges, selectedNote.id])
|
||||
const deleteFlow = useMobileNoteDeleteFlow({
|
||||
deleteNote: (noteId) => deleteDemoVaultNote(noteId, activeVaultMetadata),
|
||||
deleteNote: async (noteId) => {
|
||||
await deleteDemoVaultNote(noteId, activeVaultMetadata)
|
||||
markGitLocalChanges()
|
||||
},
|
||||
loadNotes: () => loadDemoVaultNotes(activeVaultMetadata),
|
||||
notes: availableNotes,
|
||||
onNotesLoaded: setAvailableNotes,
|
||||
@@ -212,6 +228,7 @@ export function MobileApp() {
|
||||
const createFlow = useMobileNoteCreateFlow({
|
||||
createNote: (title) => createDemoVaultNote({ title, vaultMetadata: activeVaultMetadata }),
|
||||
onCreated: (note) => {
|
||||
markGitLocalChanges()
|
||||
setAvailableNotes((notes) => [note, ...notes.filter((item) => item.id !== note.id)])
|
||||
selectNoteId(note.id)
|
||||
},
|
||||
@@ -219,11 +236,17 @@ export function MobileApp() {
|
||||
const propertiesFlow = useMobileNotePropertiesFlow({
|
||||
loadNotes: () => loadDemoVaultNotes(activeVaultMetadata),
|
||||
onNotesLoaded: setAvailableNotes,
|
||||
saveFrontmatter: (noteId, metadata) => saveDemoVaultNoteFrontmatter({
|
||||
metadata,
|
||||
noteId,
|
||||
vaultMetadata: activeVaultMetadata,
|
||||
}),
|
||||
saveFrontmatter: async (noteId, metadata) => {
|
||||
const result = await saveDemoVaultNoteFrontmatter({
|
||||
metadata,
|
||||
noteId,
|
||||
vaultMetadata: activeVaultMetadata,
|
||||
})
|
||||
if (result.status === 'saved') {
|
||||
markGitLocalChanges()
|
||||
}
|
||||
return result
|
||||
},
|
||||
selectedNote,
|
||||
})
|
||||
const toggleSelectedArchive = useCallback(() => {
|
||||
|
||||
16
apps/mobile/src/mobileDemoVault.test.ts
Normal file
16
apps/mobile/src/mobileDemoVault.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
|
||||
|
||||
describe('mobile demo vault loading', () => {
|
||||
it('does not seed demo notes into remote-backed vaults', () => {
|
||||
expect(shouldSeedDemoVault({
|
||||
id: 'personal',
|
||||
name: 'Personal Journal',
|
||||
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps local-only vaults seeded for first-run simulator QA', () => {
|
||||
expect(shouldSeedDemoVault({ id: 'personal', name: 'Personal Journal' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,12 +13,15 @@ import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
|
||||
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
|
||||
import { seedMobileVaultIfEmpty } from './mobileVaultSeed'
|
||||
import type { MobileVaultFile } from './mobileVaultStorage'
|
||||
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
|
||||
|
||||
export async function loadDemoVaultNotes(vaultMetadata = defaultMobileVaultMetadata) {
|
||||
const storage = createNativeMobileVaultStorage()
|
||||
const demoVault = createDemoVaultConfig(vaultMetadata)
|
||||
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
|
||||
await addMissingDemoVaultFiles({ files: demoVaultFiles(), storage, vault: demoVault })
|
||||
if (shouldSeedDemoVault(vaultMetadata)) {
|
||||
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
|
||||
await addMissingDemoVaultFiles({ files: demoVaultFiles(), storage, vault: demoVault })
|
||||
}
|
||||
|
||||
return createStoredMobileVaultRepository({ storage, vault: demoVault }).listNotes()
|
||||
}
|
||||
@@ -80,6 +83,7 @@ function createDemoVaultConfig(vaultMetadata: MobileVaultMetadata) {
|
||||
return createMobileVaultConfigFromMetadata(vaultMetadata)
|
||||
}
|
||||
|
||||
|
||||
function createDemoVaultStorageContext(vaultMetadata: MobileVaultMetadata) {
|
||||
return {
|
||||
storage: createNativeMobileVaultStorage(),
|
||||
|
||||
5
apps/mobile/src/mobileDemoVaultSeedPolicy.ts
Normal file
5
apps/mobile/src/mobileDemoVaultSeedPolicy.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
|
||||
export function shouldSeedDemoVault(vaultMetadata: MobileVaultMetadata) {
|
||||
return !vaultMetadata.remoteUrl?.trim()
|
||||
}
|
||||
310
apps/mobile/src/mobileExpoGitFileSystem.ts
Normal file
310
apps/mobile/src/mobileExpoGitFileSystem.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import { createMobileVaultConfigFromMetadata } from './mobileVaultMetadata'
|
||||
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
import type { ExpoMobileVaultFileInfo, ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
|
||||
import type { PromiseFsClient } from 'isomorphic-git'
|
||||
|
||||
type MobileGitStat = {
|
||||
ctime: Date
|
||||
ctimeMs: number
|
||||
dev: number
|
||||
gid: number
|
||||
ino: number
|
||||
isDirectory: () => boolean
|
||||
isFile: () => boolean
|
||||
isSymbolicLink: () => boolean
|
||||
mode: number
|
||||
mtime: Date
|
||||
mtimeMs: number
|
||||
size: number
|
||||
uid: number
|
||||
}
|
||||
|
||||
type MobileGitReadOptions = 'utf8' | { encoding?: 'base64' | 'utf8' }
|
||||
type MobileGitWriteOptions = { encoding?: 'base64' | 'utf8' }
|
||||
type MobileGitFileData = string | Uint8Array
|
||||
type ExistingPathInput = {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}
|
||||
|
||||
export type MobileExpoGitFileSystemContext = {
|
||||
dir: string
|
||||
fs: PromiseFsClient
|
||||
}
|
||||
|
||||
export function createMobileExpoGitFileSystem({
|
||||
fileSystem,
|
||||
vault,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
vault: MobileVaultMetadata
|
||||
}): MobileExpoGitFileSystemContext {
|
||||
const rootUri = mobileExpoGitVaultRootUri({ fileSystem, vault })
|
||||
const dir = '/vault'
|
||||
|
||||
return {
|
||||
dir,
|
||||
fs: {
|
||||
promises: {
|
||||
chmod: async () => {},
|
||||
lstat: (path: string) => statPath({ fileSystem, path, rootUri }),
|
||||
mkdir: (path: string) => mkdirPath({ fileSystem, path, rootUri }),
|
||||
readFile: (path: string, options?: MobileGitReadOptions) => readFile({ fileSystem, options, path, rootUri }),
|
||||
readlink: async () => {
|
||||
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
|
||||
},
|
||||
readdir: (path: string) => readDirectory({ fileSystem, path, rootUri }),
|
||||
rmdir: (path: string) => removeDirectory({ fileSystem, path, rootUri }),
|
||||
stat: (path: string) => statPath({ fileSystem, path, rootUri }),
|
||||
symlink: async () => {
|
||||
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
|
||||
},
|
||||
unlink: (path: string) => unlinkPath({ fileSystem, path, rootUri }),
|
||||
writeFile: (path: string, data: MobileGitFileData, options?: MobileGitWriteOptions) =>
|
||||
writeFile({ data, fileSystem, options, path, rootUri }),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function mobileExpoGitVaultRootUri({
|
||||
fileSystem,
|
||||
vault,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
vault: MobileVaultMetadata
|
||||
}) {
|
||||
if (!fileSystem.documentDirectory) {
|
||||
throw new Error('Expo FileSystem documentDirectory is unavailable')
|
||||
}
|
||||
|
||||
const vaultConfig = createMobileVaultConfigFromMetadata(vault)
|
||||
return appendUri({
|
||||
root: fileSystem.documentDirectory,
|
||||
segments: ['vaults', vaultConfig.storage.directoryName || vaultConfig.id],
|
||||
})
|
||||
}
|
||||
|
||||
async function mkdirPath({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
const uri = uriForPath({ path, rootUri })
|
||||
const info = await fileSystem.getInfoAsync(uri)
|
||||
if (info.exists && !info.isDirectory) {
|
||||
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${path}`)
|
||||
}
|
||||
|
||||
if (!info.exists) {
|
||||
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function readDirectory({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
await existingDirectory({ fileSystem, path, rootUri })
|
||||
|
||||
return fileSystem.readDirectoryAsync(uriForPath({ path, rootUri }))
|
||||
}
|
||||
|
||||
async function readFile({
|
||||
fileSystem,
|
||||
options,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
options?: MobileGitReadOptions
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
const info = await existingInfo({ fileSystem, path, rootUri })
|
||||
if (info.isDirectory) {
|
||||
throw createFileSystemError('EISDIR', `Path is a directory: ${path}`)
|
||||
}
|
||||
|
||||
const uri = uriForPath({ path, rootUri })
|
||||
return readAsUtf8(options)
|
||||
? fileSystem.readAsStringAsync(uri, { encoding: 'utf8' })
|
||||
: Buffer.from(await fileSystem.readAsStringAsync(uri, { encoding: 'base64' }), 'base64')
|
||||
}
|
||||
|
||||
async function writeFile({
|
||||
data,
|
||||
fileSystem,
|
||||
options,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
data: MobileGitFileData
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
options?: MobileGitWriteOptions
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
await ensureParentDirectory({ fileSystem, path, rootUri })
|
||||
|
||||
if (typeof data === 'string' && writeAsUtf8(options)) {
|
||||
await fileSystem.writeAsStringAsync(uriForPath({ path, rootUri }), data, { encoding: 'utf8' })
|
||||
return
|
||||
}
|
||||
|
||||
await fileSystem.writeAsStringAsync(
|
||||
uriForPath({ path, rootUri }),
|
||||
dataToBase64(data),
|
||||
{ encoding: 'base64' },
|
||||
)
|
||||
}
|
||||
|
||||
async function unlinkPath({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
await existingFile({ fileSystem, path, rootUri })
|
||||
|
||||
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
|
||||
}
|
||||
|
||||
async function removeDirectory({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
await existingDirectory({ fileSystem, path, rootUri })
|
||||
|
||||
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
|
||||
}
|
||||
|
||||
async function statPath({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}): Promise<MobileGitStat> {
|
||||
return statForInfo(await existingInfo({ fileSystem, path, rootUri }))
|
||||
}
|
||||
|
||||
async function existingInfo({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: ExistingPathInput) {
|
||||
const info = await fileSystem.getInfoAsync(uriForPath({ path, rootUri }))
|
||||
if (!info.exists) {
|
||||
throw createFileSystemError('ENOENT', `Path does not exist: ${path}`)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
async function existingDirectory(input: ExistingPathInput) {
|
||||
const info = await existingInfo(input)
|
||||
if (!info.isDirectory) {
|
||||
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${input.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function existingFile(input: ExistingPathInput) {
|
||||
const info = await existingInfo(input)
|
||||
if (info.isDirectory) {
|
||||
throw createFileSystemError('EISDIR', `Path is a directory: ${input.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function statForInfo(info: ExpoMobileVaultFileInfo): MobileGitStat {
|
||||
const modifiedMs = Math.round((info.modificationTime ?? 0) * 1000)
|
||||
const modified = new Date(modifiedMs)
|
||||
const isDirectory = info.isDirectory === true
|
||||
|
||||
return {
|
||||
ctime: modified,
|
||||
ctimeMs: modifiedMs,
|
||||
dev: 0,
|
||||
gid: 0,
|
||||
ino: 0,
|
||||
isDirectory: () => isDirectory,
|
||||
isFile: () => !isDirectory,
|
||||
isSymbolicLink: () => false,
|
||||
mode: isDirectory ? 0o040000 : 0o100644,
|
||||
mtime: modified,
|
||||
mtimeMs: modifiedMs,
|
||||
size: info.size ?? 0,
|
||||
uid: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureParentDirectory({
|
||||
fileSystem,
|
||||
path,
|
||||
rootUri,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
path: string
|
||||
rootUri: string
|
||||
}) {
|
||||
const segments = normalizedSegments(path)
|
||||
const parentSegments = segments.slice(0, -1)
|
||||
if (parentSegments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await fileSystem.makeDirectoryAsync(appendUri({ root: rootUri, segments: parentSegments }), { intermediates: true })
|
||||
}
|
||||
|
||||
function uriForPath({ path, rootUri }: { path: string; rootUri: string }) {
|
||||
return appendUri({ root: rootUri, segments: normalizedSegments(path) })
|
||||
}
|
||||
|
||||
function normalizedSegments(path: string) {
|
||||
const pathWithoutRoot = path.replace(/^\/+vault\/?/, '')
|
||||
const segments = pathWithoutRoot.replaceAll('\\', '/').split('/').filter(Boolean)
|
||||
if (segments.some(isUnsafeSegment)) {
|
||||
throw createFileSystemError('EINVAL', `Unsafe mobile Git path: ${path}`)
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function isUnsafeSegment(segment: string) {
|
||||
return segment === '.' || segment === '..' || segment.includes('/')
|
||||
}
|
||||
|
||||
function readAsUtf8(options?: MobileGitReadOptions) {
|
||||
return options === 'utf8' || (typeof options === 'object' && options.encoding === 'utf8')
|
||||
}
|
||||
|
||||
function writeAsUtf8(options?: MobileGitWriteOptions) {
|
||||
return options?.encoding === 'utf8'
|
||||
}
|
||||
|
||||
function dataToBase64(data: MobileGitFileData) {
|
||||
return typeof data === 'string'
|
||||
? Buffer.from(data, 'utf8').toString('base64')
|
||||
: Buffer.from(data).toString('base64')
|
||||
}
|
||||
|
||||
function appendUri(input: { root: string; segments: string[] }) {
|
||||
const base = input.root.replace(/\/+$/, '')
|
||||
const path = input.segments.filter(Boolean).join('/')
|
||||
|
||||
return path ? `${base}/${path}` : base
|
||||
}
|
||||
|
||||
function createFileSystemError(code: string, message: string) {
|
||||
const error = new Error(message)
|
||||
return Object.assign(error, { code })
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultSto
|
||||
export type ExpoMobileVaultFileInfo = {
|
||||
exists: boolean
|
||||
isDirectory?: boolean
|
||||
modificationTime?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type ExpoMobileVaultFileSystem = {
|
||||
@@ -11,15 +13,21 @@ export type ExpoMobileVaultFileSystem = {
|
||||
documentDirectory: string | null
|
||||
getInfoAsync: (uri: string) => Promise<ExpoMobileVaultFileInfo>
|
||||
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
|
||||
readAsStringAsync: (uri: string) => Promise<string>
|
||||
readAsStringAsync: (uri: string, options?: ExpoMobileVaultReadOptions) => Promise<string>
|
||||
readDirectoryAsync: (uri: string) => Promise<string[]>
|
||||
writeAsStringAsync: (uri: string, content: string) => Promise<void>
|
||||
writeAsStringAsync: (uri: string, content: string, options?: ExpoMobileVaultWriteOptions) => Promise<void>
|
||||
}
|
||||
|
||||
type VaultPathInput = {
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ExpoMobileVaultReadOptions = {
|
||||
encoding?: 'base64' | 'utf8'
|
||||
}
|
||||
|
||||
export type ExpoMobileVaultWriteOptions = ExpoMobileVaultReadOptions
|
||||
|
||||
type DirectoryListingInput = {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
rootUri: string
|
||||
|
||||
@@ -58,6 +58,7 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
|
||||
let isAvailable = false
|
||||
|
||||
return {
|
||||
loadRecord: async () => null,
|
||||
loadState: async () => isAvailable ? { state: 'available' } : { state: 'missing' },
|
||||
remove: async () => {
|
||||
isAvailable = false
|
||||
@@ -70,6 +71,7 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
|
||||
|
||||
function noopCredentialStorage(): MobileGitCredentialStorage {
|
||||
return {
|
||||
loadRecord: async () => null,
|
||||
loadState: async () => ({ state: 'missing' }),
|
||||
remove: async () => {},
|
||||
saveRecord: async () => {},
|
||||
|
||||
@@ -35,6 +35,7 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
|
||||
const records = new Map<string, ReturnType<typeof createMobileGitCredentialRecord>>()
|
||||
|
||||
return {
|
||||
loadRecord: async (requirement) => records.get(`${requirement.strategy}:${requirement.host}`) ?? null,
|
||||
loadState: async (requirement) => records.has(`${requirement.strategy}:${requirement.host}`)
|
||||
? { state: 'available' }
|
||||
: { state: 'missing' },
|
||||
@@ -49,6 +50,9 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
|
||||
|
||||
function failingCredentialStorage(): MobileGitCredentialStorage {
|
||||
return {
|
||||
loadRecord: async () => {
|
||||
throw new Error('should not load credentials')
|
||||
},
|
||||
loadState: async () => {
|
||||
throw new Error('should not load credentials')
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MobileGitCredentialRecord = {
|
||||
}
|
||||
|
||||
export type MobileGitCredentialStorage = {
|
||||
loadRecord: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialRecord | null>
|
||||
loadState: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialState>
|
||||
remove: (requirement: MobileVaultAuthRequirement) => Promise<void>
|
||||
saveRecord: (record: MobileGitCredentialRecord) => Promise<void>
|
||||
|
||||
@@ -55,6 +55,7 @@ function baseActionInput(events: string[]) {
|
||||
return {
|
||||
activeOperation: null,
|
||||
credentialStorage: {
|
||||
loadRecord: async () => null,
|
||||
loadState: async () => ({ state: 'missing' as const }),
|
||||
remove: async () => {},
|
||||
saveRecord: async () => {},
|
||||
|
||||
@@ -20,6 +20,7 @@ export function runMobileGitSyncFlowAction({
|
||||
createGitHubOAuthSession,
|
||||
gitSyncPlan,
|
||||
gitTransport,
|
||||
onSynced,
|
||||
refreshCredentials,
|
||||
setActiveOperation,
|
||||
setFailure,
|
||||
@@ -30,6 +31,7 @@ export function runMobileGitSyncFlowAction({
|
||||
createGitHubOAuthSession: () => MobileGitHubOAuthSession
|
||||
gitSyncPlan: MobileGitSyncPlan
|
||||
gitTransport: MobileGitTransport
|
||||
onSynced?: () => void
|
||||
refreshCredentials: () => void
|
||||
setActiveOperation: (operation: MobileGitOperation | null) => void
|
||||
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
|
||||
@@ -55,6 +57,7 @@ export function runMobileGitSyncFlowAction({
|
||||
if (action.state === 'transport') {
|
||||
runTransportAction({
|
||||
gitTransport,
|
||||
onSynced,
|
||||
operation: action.operation,
|
||||
remote: action.remote,
|
||||
setActiveOperation,
|
||||
@@ -103,6 +106,7 @@ function runAuthenticationAction({
|
||||
|
||||
function runTransportAction({
|
||||
gitTransport,
|
||||
onSynced,
|
||||
operation,
|
||||
remote,
|
||||
setActiveOperation,
|
||||
@@ -110,6 +114,7 @@ function runTransportAction({
|
||||
vault,
|
||||
}: {
|
||||
gitTransport: MobileGitTransport
|
||||
onSynced?: () => void
|
||||
operation: 'pull' | 'push'
|
||||
remote: Parameters<typeof runMobileGitTransportOperation>[0]['remote']
|
||||
setActiveOperation: (operation: MobileGitOperation | null) => void
|
||||
@@ -127,7 +132,10 @@ function runTransportAction({
|
||||
.then((result) => {
|
||||
if (result.state === 'failed') {
|
||||
setFailure({ message: result.message, operation })
|
||||
return
|
||||
}
|
||||
|
||||
onSynced?.()
|
||||
})
|
||||
.catch(() => setFailure({ message: 'Mobile Git sync failed.', operation }))
|
||||
.finally(() => setActiveOperation(null))
|
||||
|
||||
@@ -10,11 +10,13 @@ import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
export function createMobileGitSyncPlanForVault({
|
||||
credentials = { state: 'missing' },
|
||||
failure,
|
||||
hasLocalChanges = false,
|
||||
operation,
|
||||
vault,
|
||||
}: {
|
||||
credentials?: MobileGitCredentialState
|
||||
failure?: { message: string; operation: MobileGitOperation }
|
||||
hasLocalChanges?: boolean
|
||||
operation?: MobileGitOperation
|
||||
vault: MobileVaultMetadata
|
||||
}): MobileGitSyncPlan {
|
||||
@@ -26,6 +28,7 @@ export function createMobileGitSyncPlanForVault({
|
||||
return createMobileGitSyncPlan({
|
||||
credentials,
|
||||
failure,
|
||||
hasLocalChanges,
|
||||
operation,
|
||||
sync: result.config.sync,
|
||||
})
|
||||
|
||||
@@ -16,9 +16,21 @@ export type MobileGitTransportResult =
|
||||
state: 'failed'
|
||||
}
|
||||
|
||||
export type MobileGitRepositoryStatus =
|
||||
| {
|
||||
hasLocalChanges: boolean
|
||||
isRepository: boolean
|
||||
state: 'available'
|
||||
}
|
||||
| {
|
||||
message: string
|
||||
state: 'failed'
|
||||
}
|
||||
|
||||
export type MobileGitTransport = {
|
||||
pull: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
|
||||
push: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
|
||||
status?: (request: MobileGitTransportRequest) => Promise<MobileGitRepositoryStatus>
|
||||
}
|
||||
|
||||
export function createUnavailableMobileGitTransport(): MobileGitTransport {
|
||||
|
||||
161
apps/mobile/src/mobileIsomorphicGitTransport.test.ts
Normal file
161
apps/mobile/src/mobileIsomorphicGitTransport.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { StatusRow } from 'isomorphic-git'
|
||||
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
|
||||
import type { ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
|
||||
import {
|
||||
createIsomorphicMobileGitTransport,
|
||||
type MobileIsoGitClient,
|
||||
} from './mobileIsomorphicGitTransport'
|
||||
import type { MobileGitTransportRequest } from './mobileGitTransport'
|
||||
|
||||
describe('isomorphic mobile git transport', () => {
|
||||
it('clones a remote vault when the app-local directory is not a git repository', async () => {
|
||||
const { events, transport } = transportFixture({ hasRepository: false })
|
||||
|
||||
await expect(transport.pull(request())).resolves.toEqual({ state: 'completed' })
|
||||
|
||||
expect(events).toEqual(['clone:https://github.com/refactoringhq/tolaria.git'])
|
||||
})
|
||||
|
||||
it('pulls an existing app-local repository', async () => {
|
||||
const { events, transport } = transportFixture({ hasRepository: true })
|
||||
|
||||
await expect(transport.pull(request())).resolves.toEqual({ state: 'completed' })
|
||||
|
||||
expect(events).toEqual(['pull'])
|
||||
})
|
||||
|
||||
it('autocommits changed and deleted files before pushing', async () => {
|
||||
const events: string[] = []
|
||||
const transport = createIsomorphicMobileGitTransport({
|
||||
credentialStorage: credentialStorage(),
|
||||
fileSystem: fileSystem({ hasRepository: true }),
|
||||
gitClient: gitClient({
|
||||
events,
|
||||
statusRows: [
|
||||
['changed.md', 1, 2, 1],
|
||||
['deleted.md', 1, 0, 1],
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(transport.push(request())).resolves.toEqual({ state: 'completed' })
|
||||
|
||||
expect(events).toEqual(['add:changed.md', 'remove:deleted.md', 'commit', 'push'])
|
||||
})
|
||||
|
||||
it('reports dirty repository state for sync planning', async () => {
|
||||
const transport = createIsomorphicMobileGitTransport({
|
||||
credentialStorage: credentialStorage(),
|
||||
fileSystem: fileSystem({ hasRepository: true }),
|
||||
gitClient: gitClient({ statusRows: [['changed.md', 1, 2, 1]] }),
|
||||
})
|
||||
|
||||
await expect(transport.status?.(request())).resolves.toEqual({
|
||||
hasLocalChanges: true,
|
||||
isRepository: true,
|
||||
state: 'available',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function transportFixture({
|
||||
hasRepository,
|
||||
statusRows,
|
||||
}: {
|
||||
hasRepository: boolean
|
||||
statusRows?: StatusRow[]
|
||||
}) {
|
||||
const events: string[] = []
|
||||
const transport = createIsomorphicMobileGitTransport({
|
||||
credentialStorage: credentialStorage(),
|
||||
fileSystem: fileSystem({ hasRepository }),
|
||||
gitClient: gitClient({ events, statusRows }),
|
||||
})
|
||||
|
||||
return { events, transport }
|
||||
}
|
||||
|
||||
function gitClient({
|
||||
events = [],
|
||||
statusRows = [],
|
||||
}: {
|
||||
events?: string[]
|
||||
statusRows?: StatusRow[]
|
||||
} = {}): MobileIsoGitClient {
|
||||
return {
|
||||
add: async ({ filepath }) => {
|
||||
events.push(`add:${filepath}`)
|
||||
},
|
||||
clone: async ({ url }) => {
|
||||
events.push(`clone:${url}`)
|
||||
},
|
||||
commit: async () => {
|
||||
events.push('commit')
|
||||
return 'commit-oid'
|
||||
},
|
||||
pull: async () => {
|
||||
events.push('pull')
|
||||
},
|
||||
push: async () => {
|
||||
events.push('push')
|
||||
return { error: null, ok: true }
|
||||
},
|
||||
remove: async ({ filepath }) => {
|
||||
events.push(`remove:${filepath}`)
|
||||
},
|
||||
statusMatrix: async () => statusRows,
|
||||
}
|
||||
}
|
||||
|
||||
function credentialStorage(): MobileGitCredentialStorage {
|
||||
return {
|
||||
loadRecord: async () => ({
|
||||
host: 'github.com',
|
||||
kind: 'githubOAuthToken',
|
||||
secret: {
|
||||
accessToken: 'github-token',
|
||||
tokenType: 'bearer',
|
||||
},
|
||||
strategy: 'githubOAuth',
|
||||
storedAt: '2026-05-12T12:00:00.000Z',
|
||||
}),
|
||||
loadState: async () => ({ state: 'available' }),
|
||||
remove: async () => {},
|
||||
saveRecord: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
function fileSystem({ hasRepository }: { hasRepository: boolean }): ExpoMobileVaultFileSystem {
|
||||
return {
|
||||
deleteAsync: async () => {},
|
||||
documentDirectory: 'file:///docs/',
|
||||
getInfoAsync: async (uri) => ({
|
||||
exists: uri === 'file:///docs/vaults/personal-journal' || (hasRepository && uri.endsWith('/.git')),
|
||||
isDirectory: true,
|
||||
modificationTime: 0,
|
||||
size: 0,
|
||||
}),
|
||||
makeDirectoryAsync: async () => {},
|
||||
readAsStringAsync: async () => '',
|
||||
readDirectoryAsync: async () => [],
|
||||
writeAsStringAsync: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
function request(): MobileGitTransportRequest {
|
||||
return {
|
||||
remote: {
|
||||
authStrategy: 'githubOAuth',
|
||||
host: 'github.com',
|
||||
owner: 'refactoringhq',
|
||||
repository: 'tolaria',
|
||||
url: 'https://github.com/refactoringhq/tolaria.git',
|
||||
},
|
||||
vault: {
|
||||
id: 'personal',
|
||||
name: 'Personal Journal',
|
||||
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
|
||||
},
|
||||
}
|
||||
}
|
||||
256
apps/mobile/src/mobileIsomorphicGitTransport.ts
Normal file
256
apps/mobile/src/mobileIsomorphicGitTransport.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import * as git from 'isomorphic-git'
|
||||
import http from 'isomorphic-git/http/web'
|
||||
import type { AuthCallback, FsClient, HttpClient, StatusRow } from 'isomorphic-git'
|
||||
import type { ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
|
||||
import { createMobileExpoGitFileSystem } from './mobileExpoGitFileSystem'
|
||||
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
|
||||
import type { MobileGitRemote } from './mobileGitRemote'
|
||||
import type {
|
||||
MobileGitRepositoryStatus,
|
||||
MobileGitTransport,
|
||||
MobileGitTransportRequest,
|
||||
MobileGitTransportResult,
|
||||
} from './mobileGitTransport'
|
||||
|
||||
export type MobileIsoGitClient = {
|
||||
add: (input: { dir: string; filepath: string; fs: FsClient }) => Promise<void>
|
||||
clone: (input: { depth?: number; dir: string; fs: FsClient; http: HttpClient; onAuth: AuthCallback; singleBranch: true; url: string }) => Promise<void>
|
||||
commit: (input: {
|
||||
author: { email: string; name: string }
|
||||
dir: string
|
||||
fs: FsClient
|
||||
message: string
|
||||
}) => Promise<string>
|
||||
pull: (input: {
|
||||
author: { email: string; name: string }
|
||||
dir: string
|
||||
fastForwardOnly: true
|
||||
fs: FsClient
|
||||
http: HttpClient
|
||||
onAuth: AuthCallback
|
||||
singleBranch: true
|
||||
}) => Promise<void>
|
||||
push: (input: { dir: string; fs: FsClient; http: HttpClient; onAuth: AuthCallback }) => Promise<{ ok: boolean; error: string | null }>
|
||||
remove: (input: { dir: string; filepath: string; fs: FsClient }) => Promise<void>
|
||||
statusMatrix: (input: { dir: string; fs: FsClient }) => Promise<StatusRow[]>
|
||||
}
|
||||
|
||||
export function createIsomorphicMobileGitTransport({
|
||||
credentialStorage,
|
||||
fileSystem,
|
||||
gitClient = defaultGitClient,
|
||||
httpClient = http,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
gitClient?: MobileIsoGitClient
|
||||
httpClient?: HttpClient
|
||||
}): MobileGitTransport {
|
||||
return {
|
||||
pull: (request) => runGitOperation(() => pullOrClone({ credentialStorage, fileSystem, gitClient, httpClient, request })),
|
||||
push: (request) => runGitOperation(() => commitAndPush({ credentialStorage, fileSystem, gitClient, httpClient, request })),
|
||||
status: (request) => repositoryStatus({ fileSystem, gitClient, request }),
|
||||
}
|
||||
}
|
||||
|
||||
async function pullOrClone({
|
||||
credentialStorage,
|
||||
fileSystem,
|
||||
gitClient,
|
||||
httpClient,
|
||||
request,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
gitClient: MobileIsoGitClient
|
||||
httpClient: HttpClient
|
||||
request: MobileGitTransportRequest
|
||||
}) {
|
||||
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
|
||||
const onAuth = await authForRequest({ credentialStorage, remote: request.remote })
|
||||
|
||||
if (await isGitRepository(context)) {
|
||||
await gitClient.pull({
|
||||
author: mobileGitAuthor(),
|
||||
dir: context.dir,
|
||||
fastForwardOnly: true,
|
||||
fs: context.fs,
|
||||
http: httpClient,
|
||||
onAuth,
|
||||
singleBranch: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await gitClient.clone({
|
||||
depth: 1,
|
||||
dir: context.dir,
|
||||
fs: context.fs,
|
||||
http: httpClient,
|
||||
onAuth,
|
||||
singleBranch: true,
|
||||
url: request.remote.url,
|
||||
})
|
||||
}
|
||||
|
||||
async function commitAndPush({
|
||||
credentialStorage,
|
||||
fileSystem,
|
||||
gitClient,
|
||||
httpClient,
|
||||
request,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
gitClient: MobileIsoGitClient
|
||||
httpClient: HttpClient
|
||||
request: MobileGitTransportRequest
|
||||
}) {
|
||||
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
|
||||
if (!await isGitRepository(context)) {
|
||||
throw new Error('Pull the remote vault before pushing mobile changes.')
|
||||
}
|
||||
|
||||
await commitLocalChanges({ context, gitClient })
|
||||
const result = await gitClient.push({
|
||||
dir: context.dir,
|
||||
fs: context.fs,
|
||||
http: httpClient,
|
||||
onAuth: await authForRequest({ credentialStorage, remote: request.remote }),
|
||||
})
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? 'Git push failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async function repositoryStatus({
|
||||
fileSystem,
|
||||
gitClient,
|
||||
request,
|
||||
}: {
|
||||
fileSystem: ExpoMobileVaultFileSystem
|
||||
gitClient: MobileIsoGitClient
|
||||
request: MobileGitTransportRequest
|
||||
}): Promise<MobileGitRepositoryStatus> {
|
||||
try {
|
||||
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
|
||||
if (!await isGitRepository(context)) {
|
||||
return { hasLocalChanges: false, isRepository: false, state: 'available' }
|
||||
}
|
||||
|
||||
return {
|
||||
hasLocalChanges: hasChangedRows(await gitClient.statusMatrix({ dir: context.dir, fs: context.fs })),
|
||||
isRepository: true,
|
||||
state: 'available',
|
||||
}
|
||||
} catch {
|
||||
return { message: 'Could not inspect mobile Git repository state.', state: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
async function commitLocalChanges({
|
||||
context,
|
||||
gitClient,
|
||||
}: {
|
||||
context: MobileExpoGitContext
|
||||
gitClient: MobileIsoGitClient
|
||||
}) {
|
||||
const matrix = await gitClient.statusMatrix({ dir: context.dir, fs: context.fs })
|
||||
const changedRows = matrix.filter(isChangedRow)
|
||||
await Promise.all(changedRows.map((row) => stageStatusRow({ context, gitClient, row })))
|
||||
|
||||
if (changedRows.length > 0) {
|
||||
await gitClient.commit({
|
||||
author: mobileGitAuthor(),
|
||||
dir: context.dir,
|
||||
fs: context.fs,
|
||||
message: `Mobile sync ${new Date().toISOString()}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function stageStatusRow({
|
||||
context,
|
||||
gitClient,
|
||||
row,
|
||||
}: {
|
||||
context: MobileExpoGitContext
|
||||
gitClient: MobileIsoGitClient
|
||||
row: StatusRow
|
||||
}) {
|
||||
const filepath = row[0]
|
||||
if (row[2] === 0) {
|
||||
await gitClient.remove({ dir: context.dir, filepath, fs: context.fs })
|
||||
return
|
||||
}
|
||||
|
||||
await gitClient.add({ dir: context.dir, filepath, fs: context.fs })
|
||||
}
|
||||
|
||||
async function authForRequest({
|
||||
credentialStorage,
|
||||
remote,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
remote: MobileGitRemote
|
||||
}): Promise<AuthCallback> {
|
||||
if (remote.authStrategy !== 'githubOAuth') {
|
||||
throw new Error('Mobile Git sync currently supports GitHub OAuth remotes only.')
|
||||
}
|
||||
|
||||
const record = await credentialStorage.loadRecord({ host: remote.host, strategy: remote.authStrategy })
|
||||
if (!record?.secret?.accessToken) {
|
||||
throw new Error('GitHub credentials are missing. Connect GitHub before syncing.')
|
||||
}
|
||||
|
||||
return () => ({ username: record.secret?.accessToken })
|
||||
}
|
||||
|
||||
async function isGitRepository(context: MobileExpoGitContext) {
|
||||
try {
|
||||
await context.fs.promises.stat(`${context.dir}/.git`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function runGitOperation(operation: () => Promise<void>): Promise<MobileGitTransportResult> {
|
||||
try {
|
||||
await operation()
|
||||
return { state: 'completed' }
|
||||
} catch (error) {
|
||||
return { message: gitFailureMessage(error), state: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
function hasChangedRows(rows: StatusRow[]) {
|
||||
return rows.some(isChangedRow)
|
||||
}
|
||||
|
||||
function isChangedRow(row: StatusRow) {
|
||||
return row[1] !== row[2] || row[2] !== row[3]
|
||||
}
|
||||
|
||||
function mobileGitAuthor() {
|
||||
return {
|
||||
email: 'mobile@tolaria.local',
|
||||
name: 'Tolaria Mobile',
|
||||
}
|
||||
}
|
||||
|
||||
function gitFailureMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : 'Mobile Git sync failed.'
|
||||
}
|
||||
|
||||
type MobileExpoGitContext = ReturnType<typeof createMobileExpoGitFileSystem>
|
||||
|
||||
const defaultGitClient: MobileIsoGitClient = {
|
||||
add: git.add,
|
||||
clone: git.clone,
|
||||
commit: git.commit,
|
||||
pull: git.pull,
|
||||
push: git.push,
|
||||
remove: git.remove,
|
||||
statusMatrix: git.statusMatrix,
|
||||
}
|
||||
10
apps/mobile/src/mobileNativeIsomorphicGitTransport.ts
Normal file
10
apps/mobile/src/mobileNativeIsomorphicGitTransport.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as ExpoFileSystem from 'expo-file-system/legacy'
|
||||
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
|
||||
import { createIsomorphicMobileGitTransport } from './mobileIsomorphicGitTransport'
|
||||
|
||||
export function createNativeIsomorphicMobileGitTransport(credentialStorage: MobileGitCredentialStorage) {
|
||||
return createIsomorphicMobileGitTransport({
|
||||
credentialStorage,
|
||||
fileSystem: ExpoFileSystem,
|
||||
})
|
||||
}
|
||||
@@ -17,10 +17,15 @@ describe('createMobileSecureGitCredentialStorage', () => {
|
||||
}))
|
||||
|
||||
await expect(storage.loadState(requirement)).resolves.toEqual({ state: 'available' })
|
||||
await expect(storage.loadRecord(requirement)).resolves.toMatchObject({
|
||||
host: 'github.com',
|
||||
kind: 'githubOAuthToken',
|
||||
})
|
||||
|
||||
await storage.remove(requirement)
|
||||
|
||||
await expect(storage.loadState(requirement)).resolves.toEqual({ state: 'missing' })
|
||||
await expect(storage.loadRecord(requirement)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('treats malformed secure-store content as missing credentials', async () => {
|
||||
|
||||
@@ -17,6 +17,8 @@ export function createMobileSecureGitCredentialStorage(
|
||||
secureStore: MobileSecureStore,
|
||||
): MobileGitCredentialStorage {
|
||||
return {
|
||||
loadRecord: async (requirement) =>
|
||||
parseMobileGitCredentialRecord(await secureStore.getItemAsync(createMobileGitCredentialKey(requirement))),
|
||||
loadState: async (requirement) => mobileGitCredentialState({
|
||||
record: parseMobileGitCredentialRecord(await secureStore.getItemAsync(createMobileGitCredentialKey(requirement))),
|
||||
requirement,
|
||||
|
||||
@@ -7,53 +7,161 @@ import { runMobileGitSyncFlowAction, type MobileGitSyncFlowFailure } from './mob
|
||||
import type { MobileGitTransport } from './mobileGitTransport'
|
||||
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
|
||||
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||
import { createMobileVaultConfig } from './mobileVaultConfig'
|
||||
|
||||
export function useMobileGitSyncFlow({
|
||||
createGitHubOAuthSession,
|
||||
credentialStorage,
|
||||
gitTransport,
|
||||
onSynced,
|
||||
vault,
|
||||
}: {
|
||||
createGitHubOAuthSession: () => MobileGitHubOAuthSession
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
gitTransport: MobileGitTransport
|
||||
onSynced?: () => void
|
||||
vault: MobileVaultMetadata
|
||||
}) {
|
||||
const [failure, setFailure] = useState<MobileGitSyncFlowFailure | null>(null)
|
||||
const [credentials, setCredentials] = useState<MobileGitCredentialState>({ state: 'missing' })
|
||||
const [activeOperation, setActiveOperation] = useState<MobileGitOperation | null>(null)
|
||||
const { credentials, refreshCredentials } = useMobileGitCredentials({ credentialStorage, vault })
|
||||
const repositoryStatus = useMobileGitRepositoryStatus({ gitTransport, vault })
|
||||
const gitSyncPlan = useMemo(
|
||||
() => createMobileGitSyncPlanForVault({
|
||||
credentials,
|
||||
...(failure ? { failure } : {}),
|
||||
hasLocalChanges: repositoryStatus.hasLocalChanges,
|
||||
...(activeOperation ? { operation: activeOperation } : {}),
|
||||
vault,
|
||||
}),
|
||||
[activeOperation, credentials, failure, repositoryStatus.hasLocalChanges, vault],
|
||||
)
|
||||
const runPrimaryAction = useMobileGitPrimaryAction({
|
||||
activeOperation,
|
||||
createGitHubOAuthSession,
|
||||
credentialStorage,
|
||||
gitSyncPlan,
|
||||
gitTransport,
|
||||
onSynced,
|
||||
refreshCredentials,
|
||||
repositoryStatus,
|
||||
setActiveOperation,
|
||||
setFailure,
|
||||
vault,
|
||||
})
|
||||
|
||||
useEffect(refreshCredentials, [refreshCredentials])
|
||||
useEffect(repositoryStatus.refresh, [repositoryStatus.refresh])
|
||||
|
||||
return {
|
||||
gitSyncPlan,
|
||||
markLocalChanges: repositoryStatus.markLocalChanges,
|
||||
refreshRepositoryStatus: repositoryStatus.refresh,
|
||||
runPrimaryAction,
|
||||
}
|
||||
}
|
||||
|
||||
function useMobileGitCredentials({
|
||||
credentialStorage,
|
||||
vault,
|
||||
}: {
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
vault: MobileVaultMetadata
|
||||
}) {
|
||||
const [credentials, setCredentials] = useState<MobileGitCredentialState>({ state: 'missing' })
|
||||
const refreshCredentials = useCallback(() => {
|
||||
void loadMobileGitCredentialStateForVault({ credentialStorage, vault })
|
||||
.then(setCredentials)
|
||||
.catch(() => setCredentials({ state: 'missing' }))
|
||||
}, [credentialStorage, vault])
|
||||
const gitSyncPlan = useMemo(
|
||||
() => createMobileGitSyncPlanForVault({
|
||||
credentials,
|
||||
...(failure ? { failure } : {}),
|
||||
...(activeOperation ? { operation: activeOperation } : {}),
|
||||
vault,
|
||||
}),
|
||||
[activeOperation, credentials, failure, vault],
|
||||
)
|
||||
const runPrimaryAction = useCallback(() => {
|
||||
|
||||
return { credentials, refreshCredentials }
|
||||
}
|
||||
|
||||
function useMobileGitRepositoryStatus({
|
||||
gitTransport,
|
||||
vault,
|
||||
}: {
|
||||
gitTransport: MobileGitTransport
|
||||
vault: MobileVaultMetadata
|
||||
}) {
|
||||
const [hasLocalChanges, setHasLocalChanges] = useState(false)
|
||||
const refresh = useCallback(() => {
|
||||
const remote = mobileGitRemoteForVault(vault)
|
||||
if (!gitTransport.status || !remote) {
|
||||
setHasLocalChanges(false)
|
||||
return
|
||||
}
|
||||
|
||||
void gitTransport.status({ remote, vault })
|
||||
.then((status) => setHasLocalChanges(status.state === 'available' && status.hasLocalChanges))
|
||||
.catch(() => setHasLocalChanges(false))
|
||||
}, [gitTransport, vault])
|
||||
const markLocalChanges = useCallback(() => setHasLocalChanges(true), [])
|
||||
|
||||
return { hasLocalChanges, markLocalChanges, refresh, setHasLocalChanges }
|
||||
}
|
||||
|
||||
function useMobileGitPrimaryAction({
|
||||
activeOperation,
|
||||
createGitHubOAuthSession,
|
||||
credentialStorage,
|
||||
gitSyncPlan,
|
||||
gitTransport,
|
||||
onSynced,
|
||||
refreshCredentials,
|
||||
repositoryStatus,
|
||||
setActiveOperation,
|
||||
setFailure,
|
||||
vault,
|
||||
}: {
|
||||
activeOperation: MobileGitOperation | null
|
||||
createGitHubOAuthSession: () => MobileGitHubOAuthSession
|
||||
credentialStorage: MobileGitCredentialStorage
|
||||
gitSyncPlan: ReturnType<typeof createMobileGitSyncPlanForVault>
|
||||
gitTransport: MobileGitTransport
|
||||
onSynced?: () => void
|
||||
refreshCredentials: () => void
|
||||
repositoryStatus: ReturnType<typeof useMobileGitRepositoryStatus>
|
||||
setActiveOperation: (operation: MobileGitOperation | null) => void
|
||||
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
|
||||
vault: MobileVaultMetadata
|
||||
}) {
|
||||
return useCallback(() => {
|
||||
runMobileGitSyncFlowAction({
|
||||
activeOperation,
|
||||
credentialStorage,
|
||||
createGitHubOAuthSession,
|
||||
gitSyncPlan,
|
||||
gitTransport,
|
||||
onSynced: () => {
|
||||
repositoryStatus.setHasLocalChanges(false)
|
||||
repositoryStatus.refresh()
|
||||
onSynced?.()
|
||||
},
|
||||
refreshCredentials,
|
||||
setActiveOperation,
|
||||
setFailure,
|
||||
vault,
|
||||
})
|
||||
}, [activeOperation, createGitHubOAuthSession, credentialStorage, gitSyncPlan, gitTransport, refreshCredentials, vault])
|
||||
|
||||
useEffect(refreshCredentials, [refreshCredentials])
|
||||
|
||||
return {
|
||||
}, [
|
||||
activeOperation,
|
||||
createGitHubOAuthSession,
|
||||
credentialStorage,
|
||||
gitSyncPlan,
|
||||
runPrimaryAction,
|
||||
}
|
||||
gitTransport,
|
||||
onSynced,
|
||||
refreshCredentials,
|
||||
repositoryStatus,
|
||||
setActiveOperation,
|
||||
setFailure,
|
||||
vault,
|
||||
])
|
||||
}
|
||||
|
||||
function mobileGitRemoteForVault(vault: MobileVaultMetadata) {
|
||||
const result = createMobileVaultConfig(vault)
|
||||
return result.ok && result.config.sync.state === 'remoteReady'
|
||||
? result.config.sync.remote
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -464,11 +464,11 @@ interface PulseCommit {
|
||||
|
||||
### Mobile Git Transport
|
||||
|
||||
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`.
|
||||
The mobile app routes Git pull/push through `MobileGitTransport`. React Native UI and sync state code depend only on this TypeScript contract; transport implementations can be JavaScript or native as long as they keep credentials behind the credential storage boundary and never place tokens in remote URLs.
|
||||
|
||||
`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.
|
||||
`createIsomorphicMobileGitTransport` is the current usable transport. It uses `isomorphic-git` plus an Expo FileSystem adapter rooted at the app-managed vault directory. For GitHub HTTPS remotes, it loads the OAuth token from SecureStore, clones the repository when `.git` is missing, uses fast-forward-only pull for existing repositories, and autocommits changed/deleted files before push.
|
||||
|
||||
The native boundary request is intentionally narrow for the first spike: active vault id, app-managed vault directory name, remote URL, remote host, and required auth strategy. Credentials stay behind SecureStore/OAuth/native Git credential callbacks, and remote URLs must not embed tokens.
|
||||
`createNativeMobileGitTransport` still resolves the optional Expo native module named `TolariaGit` through `expo-modules-core`. That native boundary remains the later replacement path for SSH, non-GitHub remotes, conflict resolution, and performance work that proves unsuitable for the JavaScript transport.
|
||||
|
||||
### Auto-Sync
|
||||
|
||||
|
||||
@@ -124,15 +124,18 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
|
||||
- Tightened relationship writes so mobile relationship additions save canonical wikilink refs, dedupe by canonical target, resolve aliases/plain titles for chip display/opening, and avoid introducing new loose id/string values from the properties UI.
|
||||
- Demoted saved views from the primary mobile sidebar until real persisted view definitions exist; fixture nested-view definitions remain available to tests but are no longer exposed as if they were desktop-equivalent saved views.
|
||||
- Continued the properties quality pass by grouping the panel into system, relationships, custom properties, info, and history; adding read-only derived inverse relationship groups; and demoting custom scalar properties to read-only until typed mobile controls exist.
|
||||
- Added the first usable mobile Git transport with `isomorphic-git` and an Expo FileSystem adapter: GitHub OAuth remotes can clone into app-local vault storage, fast-forward pull, autocommit local changes, and push.
|
||||
- Disabled demo vault seeding for remote-backed vaults so cloned real vaults are not polluted with fixture notes.
|
||||
- Created [ADR-0116](./adr/0116-isomorphic-git-for-mobile-real-vault-sync.md) for the JavaScript Git transport decision and its native-replacement boundary.
|
||||
- Set the booted iPad simulator keyboard preferences to Italian (`it_IT@sw=QWERTY;hw=Automatic`).
|
||||
|
||||
## Next Action
|
||||
|
||||
Continue Phase 4 as a quality remediation pass before new feature work:
|
||||
|
||||
1. Run iPad simulator QA over sidebar filters, favorites, properties sections, relationship add/remove, raw wikilink autocomplete, and AI/settings input states.
|
||||
2. Add real persisted view definition loading before re-exposing Views in the sidebar.
|
||||
3. Implement usable native Git support so a real remote-backed vault can be cloned/synced locally.
|
||||
1. Run simulator QA for the real-vault sync path: configure a GitHub remote, connect OAuth, clone, inspect loaded notes, edit locally, push, and relaunch.
|
||||
2. Run iPad simulator QA over sidebar filters, favorites, properties sections, relationship add/remove, raw wikilink autocomplete, AI/settings input states, and Git status transitions.
|
||||
3. Add real persisted view definition loading before re-exposing Views in the sidebar.
|
||||
4. Install a simulator runtime matching the active Xcode SDK, or switch Xcode to one matching the installed iOS 17.5/18.6 runtimes, then retry the iOS development-client build.
|
||||
|
||||
## Verification Log
|
||||
@@ -444,6 +447,12 @@ Continue Phase 4 as a quality remediation pass before new feature work:
|
||||
- CodeScene after the direct-create/properties editability update: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileEditablePropertyPickers.tsx`, `apps/mobile/src/MobileEditorAdapter.tsx`, `apps/mobile/src/MobileEditorBreadcrumb.tsx`, `apps/mobile/src/MobilePropertiesPanel.tsx`, `apps/mobile/src/mobileNoteProperties.ts`, `apps/mobile/src/mobilePropertyPicker.ts`, `apps/mobile/src/styles/breadcrumbStyles.ts`, `apps/mobile/src/styles/commonStyles.ts`, `apps/mobile/src/styles/editorStyles.ts`, `apps/mobile/src/styles/noteListStyles.ts`, `apps/mobile/src/styles/propertyChipStyles.ts`, and `apps/mobile/src/useMobileNoteCreateFlow.ts` scored `10`; `apps/mobile/src/styles.ts` reported no scorable score.
|
||||
- CodeScene pre-commit safeguard passed after splitting the new styles.
|
||||
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after the direct-create/properties editability update.
|
||||
- `pnpm --filter @tolaria/mobile exec expo install --check` passed after aligning Expo SDK 55 patch dependencies and adding the mobile Git transport dependencies.
|
||||
- `pnpm --filter @tolaria/mobile typecheck` passed after the isomorphic-git mobile sync transport.
|
||||
- `pnpm --filter @tolaria/mobile test` passed after the isomorphic-git mobile sync transport: 59 files / 191 tests.
|
||||
- CodeScene after the isomorphic-git mobile sync transport: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileIsomorphicGitTransport.ts`, `apps/mobile/src/mobileExpoGitFileSystem.ts`, `apps/mobile/src/useMobileGitSyncFlow.ts`, `apps/mobile/src/mobileGitSyncFlowAction.ts`, `apps/mobile/src/mobileGitTransport.ts`, `apps/mobile/src/mobileIsomorphicGitTransport.test.ts`, `apps/mobile/src/mobileNativeIsomorphicGitTransport.ts`, `apps/mobile/src/mobileDemoVaultSeedPolicy.ts`, `apps/mobile/src/mobileDemoVault.test.ts`, `apps/mobile/src/mobileSecureGitCredentialStorage.ts`, `apps/mobile/src/mobileDemoVault.ts`, `apps/mobile/src/mobileGitCredentialStorage.ts`, and the touched credential/sync tests scored `10`; `apps/mobile/src/mobileGitSyncRuntimePlan.ts` reported no scorable score.
|
||||
- CodeScene pre-commit safeguard passed after the isomorphic-git mobile sync transport.
|
||||
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-git-sync` passed after the isomorphic-git mobile sync transport.
|
||||
|
||||
## Risks / Watch Items
|
||||
|
||||
|
||||
29
docs/adr/0116-isomorphic-git-for-mobile-real-vault-sync.md
Normal file
29
docs/adr/0116-isomorphic-git-for-mobile-real-vault-sync.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# ADR-0116: Isomorphic-Git for Mobile Real-Vault Sync
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria mobile needs a path to clone and sync a real vault before the native `TolariaGit` module is available. The Expo development-client build is still blocked locally by an Xcode simulator runtime mismatch, but the product needs simulator-testable vault sync now so iPad and iPhone workflow quality can be evaluated against real Markdown content.
|
||||
|
||||
The existing mobile architecture already has app-local vault storage through Expo FileSystem, OAuth through Expo AuthSession, and GitHub tokens stored in SecureStore. What is missing is a Git transport that can use those boundaries without shelling out to terminal Git.
|
||||
|
||||
## Decision
|
||||
|
||||
Use `isomorphic-git` as the first mobile Git implementation behind the existing `MobileGitTransport` contract. Add a small Expo FileSystem adapter that maps the app-managed vault directory to the promise-style filesystem API expected by `isomorphic-git`.
|
||||
|
||||
The first production path supports GitHub HTTPS remotes authenticated by the stored OAuth token. Pull clones the repository into app-local vault storage when `.git` is missing, then uses fast-forward-only pull for existing repositories. Push stages changed/deleted files, creates an automatic mobile checkpoint commit, and pushes through the same OAuth credential callback.
|
||||
|
||||
Keep the native `TolariaGit` boundary available for later replacement if libgit2/Rust becomes necessary for performance, SSH, conflict handling, or full desktop parity.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Real GitHub-backed vaults can be cloned and exercised in the simulator without waiting for a native build.
|
||||
- OTA updates can improve the JavaScript transport and sync UX, as long as no new native dependency is introduced.
|
||||
- The first sync path is GitHub OAuth over HTTPS only; SSH and arbitrary Git hosts remain a later native/credential-management slice.
|
||||
- Conflict resolution remains deliberately out of scope for this slice. Pull is fast-forward-only so divergent histories fail visibly instead of inventing mobile merge behavior.
|
||||
- The demo seed path must stay disabled for remote-backed vaults, otherwise cloned vaults would be polluted with fixture notes.
|
||||
658
pnpm-lock.yaml
generated
658
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user