Files
easy-wallet/frontend/src/services/api.js
Davide Grilli 079b650d60 fix(android-storage): add writable directory fallback for wallet JSON files
- Resolve Android storage dir at runtime (Documents/Data) and reuse it for saves
- List/read wallets across candidate dirs with dedupe by latest mtime
- Validate wallet filenames to prevent invalid paths
- Update UI hints for Desktop vs Android storage locations
2026-03-10 15:35:46 +01:00

246 lines
7.8 KiB
JavaScript

/**
* Platform API — single import for all components.
*
* On Electron: delegates to window.electronAPI (IPC, preload.cjs).
* On Android (Capacitor) or browser: uses crypto-service.js + Capacitor Filesystem.
*/
import {
generateHdWallet, encryptHdWallet, decryptHdWallet,
generateP2pk, generateP2pkh, generateP2sh, generateP2wpkh, generateP2tr,
encryptSingleWallet, decryptSingleWallet,
} from './crypto-service.js'
// Electron exposes window.electronAPI via preload.cjs before React loads.
const isElectron = typeof window !== 'undefined' && window.electronAPI != null
// ── Android storage (Capacitor Filesystem) ─────────────────────────────────
const WALLET_DIRS = {
hd: 'wallet-generator/hdwallet',
single: 'wallet-generator/single',
}
let _fs = null
let _storageDirectory = null
async function fs() {
if (!_fs) {
const { Filesystem, Directory } = await import('@capacitor/filesystem')
_fs = { Filesystem, Directory }
}
return _fs
}
function walletDir(kind) {
const dir = WALLET_DIRS[kind]
if (!dir) throw new Error(`Unsupported wallet kind: '${kind}'.`)
return dir
}
function normalizeFilename(filename) {
if (typeof filename !== 'string') throw new Error('Filename must be a string.')
const trimmed = filename.trim()
if (!trimmed) throw new Error('Filename is required.')
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed === '.' || trimmed === '..') {
throw new Error('Invalid filename.')
}
return trimmed
}
function unique(arr) {
return [...new Set(arr.filter(Boolean))]
}
function toMtime(value) {
if (!value) return 0
if (typeof value === 'number') return value
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? 0 : parsed
}
function isMissingFileError(err) {
const msg = String(err?.message || err || '').toLowerCase()
return (
msg.includes('not found') ||
msg.includes('does not exist') ||
msg.includes('no such file') ||
msg.includes('directory at')
)
}
function directoryLabel(directory, Directory) {
if (directory === Directory.Documents) return 'Documents'
if (directory === Directory.Data) return 'Data'
return 'Storage'
}
async function directoryCandidates() {
const { Directory } = await fs()
return unique([Directory.Documents, Directory.Data])
}
async function resolveWritableDirectory() {
if (_storageDirectory) return _storageDirectory
const { Filesystem } = await fs()
let lastErr = null
for (const directory of await directoryCandidates()) {
try {
await Filesystem.mkdir({ path: 'wallet-generator', directory, recursive: true })
_storageDirectory = directory
return directory
} catch (err) {
lastErr = err
}
}
throw lastErr || new Error('No writable storage directory available.')
}
function orderedDirectories(preferred, all) {
if (!preferred) return all
return [preferred, ...all.filter(d => d !== preferred)]
}
async function androidSaveWallet(filename, data, kind) {
const { Filesystem, Directory } = await fs()
const safeName = normalizeFilename(filename)
const dirPath = walletDir(kind)
const path = `${dirPath}/${safeName}`
const directory = await resolveWritableDirectory()
await Filesystem.mkdir({ path: dirPath, directory, recursive: true })
await Filesystem.writeFile({
path,
data: JSON.stringify(data, null, 2),
directory,
encoding: 'utf8',
})
return `${directoryLabel(directory, Directory)}/${path}`
}
async function androidListWallets() {
const { Filesystem } = await fs()
const out = { hd: [], single: [] }
const preferred = await resolveWritableDirectory().catch(() => null)
const dirs = orderedDirectories(preferred, await directoryCandidates())
for (const [kind, dir] of Object.entries(WALLET_DIRS)) {
const byName = new Map()
try {
for (const directory of dirs) {
try {
await Filesystem.mkdir({ path: dir, directory, recursive: true })
const { files = [] } = await Filesystem.readdir({ path: dir, directory })
files
.filter(f => (f.type === 'file' || !f.type) && f.name?.toLowerCase?.().endsWith('.json'))
.forEach(f => {
const item = {
name: f.name,
size: f.size || 0,
mtimeMs: toMtime(f.mtime),
}
const prev = byName.get(item.name)
if (!prev || item.mtimeMs > prev.mtimeMs) byName.set(item.name, item)
})
} catch {
// Try next storage directory.
}
}
} finally {
out[kind] = Array.from(byName.values()).sort((a, b) => b.mtimeMs - a.mtimeMs)
}
}
return out
}
async function androidReadWallet(kind, filename) {
const { Filesystem } = await fs()
const path = `${walletDir(kind)}/${normalizeFilename(filename)}`
const preferred = await resolveWritableDirectory().catch(() => null)
const dirs = orderedDirectories(preferred, await directoryCandidates())
let lastErr = null
for (const directory of dirs) {
try {
const { data } = await Filesystem.readFile({ path, directory, encoding: 'utf8' })
return JSON.parse(data)
} catch (err) {
lastErr = err
if (!isMissingFileError(err)) throw err
}
}
throw lastErr || new Error('Wallet file not found.')
}
// ── Exported API ─────────────────────────────────────────────────────────────
export const api = {
hdGenerate: async (args) => {
if (isElectron) return window.electronAPI.hdGenerate(args)
return generateHdWallet(args)
},
hdEncrypt: async ({ wallet, password }) => {
if (isElectron) return window.electronAPI.hdEncrypt({ wallet, password })
return encryptHdWallet(wallet, password)
},
hdDecrypt: async ({ wallet, password }) => {
if (isElectron) return window.electronAPI.hdDecrypt({ wallet, password })
return decryptHdWallet(wallet, password)
},
p2pk: async (args) => {
if (isElectron) return window.electronAPI.p2pk(args)
return generateP2pk(args.network, args.compressed)
},
p2pkh: async (args) => {
if (isElectron) return window.electronAPI.p2pkh(args)
return generateP2pkh(args.network, args.compressed)
},
p2sh: async (args) => {
if (isElectron) return window.electronAPI.p2sh(args)
return generateP2sh(args.network, args.m, args.n, args.compressed)
},
p2wpkh: async (args) => {
if (isElectron) return window.electronAPI.p2wpkh(args)
return generateP2wpkh(args.network, args.compressed)
},
p2tr: async (args) => {
if (isElectron) return window.electronAPI.p2tr(args)
return generateP2tr(args.network)
},
singleEncrypt: async ({ wallet, password }) => {
if (isElectron) return window.electronAPI.singleEncrypt({ wallet, password })
return encryptSingleWallet(wallet, password)
},
singleDecrypt: async ({ wallet, password }) => {
if (isElectron) return window.electronAPI.singleDecrypt({ wallet, password })
return decryptSingleWallet(wallet, password)
},
saveWallet: async (filename, data, kind) => {
if (isElectron) return window.electronAPI.saveWallet(filename, data, kind)
return androidSaveWallet(filename, data, kind)
},
listWallets: async () => {
if (isElectron) return window.electronAPI.listWallets()
return androidListWallets()
},
readWallet: async ({ kind, filename }) => {
if (isElectron) return window.electronAPI.readWallet({ kind, filename })
return androidReadWallet(kind, filename)
},
getWalletDir: async () => {
if (isElectron) return window.electronAPI.getWalletDir()
const { Directory } = await fs()
const directory = await resolveWritableDirectory()
return `${directoryLabel(directory, Directory)}/wallet-generator`
},
}