@livx.cc/native-kit 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/NativeKit.ts +4 -0
- package/src/core/web-adapter.ts +230 -0
- package/src/index.ts +3 -0
- package/src/modules/fs.ts +109 -0
- package/src/modules/scanner.ts +63 -0
package/package.json
CHANGED
package/src/core/NativeKit.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { CalendarModule } from '../modules/calendar';
|
|
|
9
9
|
import { ClipboardModule } from '../modules/clipboard';
|
|
10
10
|
import { ContactsModule } from '../modules/contacts';
|
|
11
11
|
import { DeviceModule } from '../modules/device';
|
|
12
|
+
import { FsModule } from '../modules/fs';
|
|
12
13
|
import { GeoModule } from '../modules/geo';
|
|
13
14
|
import { HapticsModule } from '../modules/haptics';
|
|
14
15
|
import { HealthModule } from '../modules/health';
|
|
@@ -22,6 +23,7 @@ import { OAuthModule } from '../modules/oauth';
|
|
|
22
23
|
import { PhotosModule } from '../modules/photos';
|
|
23
24
|
import { PushModule } from '../modules/push';
|
|
24
25
|
import { ReviewsModule } from '../modules/reviews';
|
|
26
|
+
import { ScannerModule } from '../modules/scanner';
|
|
25
27
|
import { ScreenModule } from '../modules/screen';
|
|
26
28
|
import { ShareModule } from '../modules/share';
|
|
27
29
|
import { StorageModule } from '../modules/storage';
|
|
@@ -73,6 +75,7 @@ export class NativeKit {
|
|
|
73
75
|
public readonly screen = new ScreenModule(this);
|
|
74
76
|
public readonly keyboard = new KeyboardModule(this);
|
|
75
77
|
public readonly storage = new StorageModule(this);
|
|
78
|
+
public readonly fs = new FsModule(this);
|
|
76
79
|
public readonly toast = new ToastModule(this);
|
|
77
80
|
public readonly ui = new UiModule(this);
|
|
78
81
|
public readonly device = new DeviceModule(this);
|
|
@@ -89,6 +92,7 @@ export class NativeKit {
|
|
|
89
92
|
public readonly health = new HealthModule(this);
|
|
90
93
|
public readonly media = new MediaModule(this);
|
|
91
94
|
public readonly contacts = new ContactsModule(this);
|
|
95
|
+
public readonly scanner = new ScannerModule(this);
|
|
92
96
|
public readonly calendar = new CalendarModule(this);
|
|
93
97
|
public readonly app = new AppModule(this);
|
|
94
98
|
public readonly browser = new BrowserModule(this);
|
package/src/core/web-adapter.ts
CHANGED
|
@@ -8,6 +8,14 @@ function base64ToBuffer(b64: string): ArrayBuffer {
|
|
|
8
8
|
return out.buffer;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Encode an ArrayBuffer → raw base64 (no `data:` prefix). */
|
|
12
|
+
function bufferToBase64(buf: ArrayBuffer): string {
|
|
13
|
+
const bytes = new Uint8Array(buf);
|
|
14
|
+
let bin = '';
|
|
15
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
16
|
+
return btoa(bin);
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/** Map our lock vocabulary to valid ScreenOrientation `OrientationLockType` values. */
|
|
12
20
|
function toWebOrientation(o: string): any {
|
|
13
21
|
switch (o) {
|
|
@@ -30,6 +38,8 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
30
38
|
private listeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
31
39
|
private geoWatchId: number | null = null;
|
|
32
40
|
private motionHandler: ((e: DeviceMotionEvent) => void) | null = null;
|
|
41
|
+
/** Tear-down for an in-progress scanner.scan loop (stops the camera, removes the overlay). */
|
|
42
|
+
private scanCancel: (() => void) | null = null;
|
|
33
43
|
|
|
34
44
|
detect(): boolean {
|
|
35
45
|
return typeof window !== 'undefined';
|
|
@@ -44,6 +54,9 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
44
54
|
orientation: (screen as any)?.orientation ? 'web' : 'none', // Screen Orientation API (lock needs fullscreen)
|
|
45
55
|
storage: 'web',
|
|
46
56
|
secureStorage: 'none',
|
|
57
|
+
// OPFS (navigator.storage.getDirectory) backs read/write/list; pickFile falls back to
|
|
58
|
+
// <input type=file> even without OPFS, so 'web' whenever either surface exists.
|
|
59
|
+
fs: n.storage?.getDirectory || typeof document !== 'undefined' ? 'web' : 'none',
|
|
47
60
|
toast: 'web',
|
|
48
61
|
statusBar: 'none',
|
|
49
62
|
device: 'web',
|
|
@@ -64,6 +77,8 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
64
77
|
calendar: 'none',
|
|
65
78
|
camera: 'web', // <input capture> — mobile browsers open the camera
|
|
66
79
|
media: (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
|
|
80
|
+
// BarcodeDetector (Chrome/Android) decodes; needs a getUserMedia stream to feed it. Both → 'web'.
|
|
81
|
+
scanner: typeof (window as any).BarcodeDetector !== 'undefined' && (navigator as any).mediaDevices?.getUserMedia ? 'web' : 'none',
|
|
67
82
|
app: 'web', // openUrl via window.open; openSettings unsupported
|
|
68
83
|
browser: 'web', // new tab/window
|
|
69
84
|
billing: 'none', // no IAP in a plain browser — wire a web checkout yourself
|
|
@@ -310,11 +325,37 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
310
325
|
case 'calendar.createEvent':
|
|
311
326
|
throw new KitError('UNSUPPORTED', 'No calendar access on web');
|
|
312
327
|
|
|
328
|
+
case 'scanner.scan':
|
|
329
|
+
return this.scanBarcode(p.formats, p.camera === 'front' ? 'user' : 'environment') as Promise<T>;
|
|
330
|
+
case 'scanner.cancel':
|
|
331
|
+
this.scanCancel?.();
|
|
332
|
+
return undefined as T;
|
|
333
|
+
|
|
313
334
|
case 'photos.pick':
|
|
314
335
|
return this.pickImageFile(false, !!p.dataUrl, p.maxSize) as Promise<T>;
|
|
315
336
|
case 'camera.capture':
|
|
316
337
|
return this.pickImageFile(true, !!p.dataUrl, p.maxSize) as Promise<T>;
|
|
317
338
|
|
|
339
|
+
case 'fs.read':
|
|
340
|
+
return this.opfsRead(p.path, p.encoding === 'base64' ? 'base64' : 'utf8') as Promise<T>;
|
|
341
|
+
case 'fs.write':
|
|
342
|
+
return this.opfsWrite(p.path, p.data, p.encoding === 'base64' ? 'base64' : 'utf8', false, !!p.recursive) as Promise<T>;
|
|
343
|
+
case 'fs.append':
|
|
344
|
+
return this.opfsWrite(p.path, p.data, p.encoding === 'base64' ? 'base64' : 'utf8', true, true) as Promise<T>;
|
|
345
|
+
case 'fs.delete':
|
|
346
|
+
return this.opfsDelete(p.path) as Promise<T>;
|
|
347
|
+
case 'fs.list':
|
|
348
|
+
return this.opfsList(p.path) as Promise<T>;
|
|
349
|
+
case 'fs.mkdir':
|
|
350
|
+
return this.opfsMkdir(p.path) as Promise<T>;
|
|
351
|
+
case 'fs.stat':
|
|
352
|
+
return this.opfsStat(p.path) as Promise<T>;
|
|
353
|
+
case 'fs.getUri':
|
|
354
|
+
// OPFS files have no URL the page can hand around — be honest rather than fake one.
|
|
355
|
+
throw new KitError('UNSUPPORTED', 'No file URI on web (OPFS is opaque) — read the bytes instead');
|
|
356
|
+
case 'fs.pickFile':
|
|
357
|
+
return this.pickDocuments(p.types, !!p.multiple) as Promise<T>;
|
|
358
|
+
|
|
318
359
|
case 'media.configureAudio':
|
|
319
360
|
return undefined as T; // browser owns the audio session — nothing to tune
|
|
320
361
|
|
|
@@ -416,6 +457,96 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
416
457
|
this.listeners.get(event)?.forEach((cb) => cb(payload));
|
|
417
458
|
}
|
|
418
459
|
|
|
460
|
+
/** Map our small format enum → BarcodeDetector `formats` strings. Omit/`'all'` = detector default. */
|
|
461
|
+
private toDetectorFormats(formats?: string | string[]): string[] | undefined {
|
|
462
|
+
const want = (Array.isArray(formats) ? formats : formats ? [formats] : []).filter((f) => f && f !== 'all');
|
|
463
|
+
if (!want.length) return undefined;
|
|
464
|
+
const map: Record<string, string> = { qr: 'qr_code', ean13: 'ean_13', code128: 'code_128' };
|
|
465
|
+
return want.map((f) => map[f] ?? f);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Reverse map a detector format string → our enum (best-effort). */
|
|
469
|
+
private fromDetectorFormat(f: string): 'qr' | 'ean13' | 'code128' | 'unknown' {
|
|
470
|
+
if (f === 'qr_code') return 'qr';
|
|
471
|
+
if (f === 'ean_13') return 'ean13';
|
|
472
|
+
if (f === 'code_128') return 'code128';
|
|
473
|
+
return 'unknown';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Open a full-screen camera overlay, run a BarcodeDetector loop, resolve the FIRST hit then tear
|
|
478
|
+
* down. Resolves `{ cancelled: true }` if the user taps the close button or `scanner.cancel` fires.
|
|
479
|
+
*/
|
|
480
|
+
private scanBarcode(
|
|
481
|
+
formats: string | string[] | undefined,
|
|
482
|
+
facingMode: 'environment' | 'user'
|
|
483
|
+
): Promise<{ value: string; format: string; bounds?: any } | { cancelled: true }> {
|
|
484
|
+
const Detector = (window as any).BarcodeDetector;
|
|
485
|
+
const getUserMedia = (navigator as any).mediaDevices?.getUserMedia?.bind((navigator as any).mediaDevices);
|
|
486
|
+
if (!Detector || !getUserMedia) throw new KitError('UNSUPPORTED', 'BarcodeDetector / camera unavailable in this browser');
|
|
487
|
+
|
|
488
|
+
return new Promise(async (resolve, reject) => {
|
|
489
|
+
let stream: MediaStream | null = null;
|
|
490
|
+
let raf = 0;
|
|
491
|
+
let done = false;
|
|
492
|
+
const wrap = document.createElement('div');
|
|
493
|
+
const video = document.createElement('video');
|
|
494
|
+
|
|
495
|
+
const teardown = () => {
|
|
496
|
+
if (done) return;
|
|
497
|
+
done = true;
|
|
498
|
+
cancelAnimationFrame(raf);
|
|
499
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
500
|
+
wrap.remove();
|
|
501
|
+
// Only clear if it's still OUR handler — a newer scan may have replaced it.
|
|
502
|
+
if (this.scanCancel === cancelHandler) this.scanCancel = null;
|
|
503
|
+
};
|
|
504
|
+
const cancelHandler = () => { teardown(); resolve({ cancelled: true }); };
|
|
505
|
+
this.scanCancel = cancelHandler;
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
const detector = new Detector(this.toDetectorFormats(formats) ? { formats: this.toDetectorFormats(formats) } : undefined);
|
|
509
|
+
stream = await getUserMedia({ video: { facingMode } });
|
|
510
|
+
wrap.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column';
|
|
511
|
+
video.setAttribute('playsinline', '');
|
|
512
|
+
video.muted = true;
|
|
513
|
+
video.style.cssText = 'flex:1;width:100%;height:100%;object-fit:cover';
|
|
514
|
+
(video as any).srcObject = stream;
|
|
515
|
+
const close = document.createElement('button');
|
|
516
|
+
close.textContent = 'Cancel';
|
|
517
|
+
close.style.cssText = 'position:absolute;top:max(12px,env(safe-area-inset-top));right:16px;z-index:1;background:rgba(0,0,0,.5);color:#fff;border:0;border-radius:18px;padding:8px 16px;font:15px system-ui';
|
|
518
|
+
close.onclick = () => { teardown(); resolve({ cancelled: true }); };
|
|
519
|
+
wrap.appendChild(video);
|
|
520
|
+
wrap.appendChild(close);
|
|
521
|
+
document.body.appendChild(wrap);
|
|
522
|
+
await video.play();
|
|
523
|
+
|
|
524
|
+
const tick = async () => {
|
|
525
|
+
if (done) return;
|
|
526
|
+
try {
|
|
527
|
+
const codes = await detector.detect(video);
|
|
528
|
+
if (codes?.length) {
|
|
529
|
+
const c = codes[0];
|
|
530
|
+
const b = c.boundingBox;
|
|
531
|
+
teardown();
|
|
532
|
+
resolve({
|
|
533
|
+
value: c.rawValue,
|
|
534
|
+
format: this.fromDetectorFormat(c.format),
|
|
535
|
+
bounds: b ? { x: b.x, y: b.y, width: b.width, height: b.height } : undefined,
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
} catch { /* transient detect error — keep looping */ }
|
|
540
|
+
raf = requestAnimationFrame(tick);
|
|
541
|
+
};
|
|
542
|
+
raf = requestAnimationFrame(tick);
|
|
543
|
+
} catch (e: any) {
|
|
544
|
+
teardown();
|
|
545
|
+
reject(new KitError(e?.name === 'NotAllowedError' ? 'DENIED' : 'NATIVE_ERROR', e?.message ?? 'scan failed'));
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
419
550
|
private pickImageFile(
|
|
420
551
|
capture: boolean,
|
|
421
552
|
wantDataUrl = false,
|
|
@@ -455,6 +586,105 @@ export class WebAdapter implements NativeKitAdapter {
|
|
|
455
586
|
});
|
|
456
587
|
}
|
|
457
588
|
|
|
589
|
+
// ── filesystem (Origin Private File System) ─────────────────────────
|
|
590
|
+
// OPFS is one private root per origin — our `dir` enum has no meaning here, so paths resolve
|
|
591
|
+
// straight off the root. Slash-separated paths walk nested dirs.
|
|
592
|
+
|
|
593
|
+
private async opfsRoot(): Promise<any> {
|
|
594
|
+
const dir = (navigator as any).storage?.getDirectory;
|
|
595
|
+
if (!dir) throw new KitError('UNSUPPORTED', 'No OPFS in this browser');
|
|
596
|
+
return (navigator as any).storage.getDirectory();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Split a path into clean segments, rejecting any `..` so it can't escape the OPFS root. */
|
|
600
|
+
private opfsSegments(path: string): string[] {
|
|
601
|
+
const parts = String(path).split('/').filter(Boolean);
|
|
602
|
+
if (parts.some((seg) => seg === '..')) throw new KitError('NATIVE_ERROR', `Path traversal rejected: ${path}`);
|
|
603
|
+
return parts;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Walk `path` to its parent dir handle + leaf name. `create` makes intermediate dirs. */
|
|
607
|
+
private async opfsResolveParent(path: string, create: boolean): Promise<{ parent: any; name: string }> {
|
|
608
|
+
const parts = this.opfsSegments(path);
|
|
609
|
+
const name = parts.pop();
|
|
610
|
+
if (!name) throw new KitError('NATIVE_ERROR', 'Empty path');
|
|
611
|
+
let dir = await this.opfsRoot();
|
|
612
|
+
for (const seg of parts) dir = await dir.getDirectoryHandle(seg, { create });
|
|
613
|
+
return { parent: dir, name };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private async opfsRead(path: string, encoding: 'utf8' | 'base64'): Promise<string> {
|
|
617
|
+
const { parent, name } = await this.opfsResolveParent(path, false);
|
|
618
|
+
const handle = await parent.getFileHandle(name).catch(() => { throw new KitError('NATIVE_ERROR', `No such file: ${path}`); });
|
|
619
|
+
const file = await handle.getFile();
|
|
620
|
+
if (encoding === 'base64') return bufferToBase64(await file.arrayBuffer());
|
|
621
|
+
return file.text();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private async opfsWrite(path: string, data: string, encoding: 'utf8' | 'base64', append: boolean, recursive: boolean): Promise<{ uri: string }> {
|
|
625
|
+
const { parent, name } = await this.opfsResolveParent(path, recursive);
|
|
626
|
+
const handle = await parent.getFileHandle(name, { create: true });
|
|
627
|
+
const writable = await handle.createWritable({ keepExistingData: append });
|
|
628
|
+
if (append) writable.seek?.((await handle.getFile()).size);
|
|
629
|
+
const payload: BlobPart = encoding === 'base64' ? base64ToBuffer(data) : data;
|
|
630
|
+
await writable.write(payload);
|
|
631
|
+
await writable.close();
|
|
632
|
+
return { uri: `opfs:/${path}` };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private async opfsDelete(path: string): Promise<void> {
|
|
636
|
+
const { parent, name } = await this.opfsResolveParent(path, false);
|
|
637
|
+
await parent.removeEntry(name, { recursive: false }).catch(() => {}); // no-throw if absent
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private async opfsList(path: string): Promise<Array<{ name: string; type: 'file' | 'dir' }>> {
|
|
641
|
+
let dir = await this.opfsRoot();
|
|
642
|
+
for (const seg of this.opfsSegments(path)) dir = await dir.getDirectoryHandle(seg);
|
|
643
|
+
const out: Array<{ name: string; type: 'file' | 'dir' }> = [];
|
|
644
|
+
for await (const [name, handle] of (dir as any).entries()) {
|
|
645
|
+
out.push({ name, type: handle.kind === 'directory' ? 'dir' : 'file' });
|
|
646
|
+
}
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private async opfsMkdir(path: string): Promise<void> {
|
|
651
|
+
let dir = await this.opfsRoot();
|
|
652
|
+
for (const seg of this.opfsSegments(path)) dir = await dir.getDirectoryHandle(seg, { create: true });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private async opfsStat(path: string): Promise<{ name: string; type: 'file' | 'dir'; size?: number; mtime?: number }> {
|
|
656
|
+
const { parent, name } = await this.opfsResolveParent(path, false);
|
|
657
|
+
const fileHandle = await parent.getFileHandle(name).catch(() => null);
|
|
658
|
+
if (fileHandle) {
|
|
659
|
+
const f = await fileHandle.getFile();
|
|
660
|
+
return { name, type: 'file', size: f.size, mtime: f.lastModified };
|
|
661
|
+
}
|
|
662
|
+
await parent.getDirectoryHandle(name).catch(() => { throw new KitError('NATIVE_ERROR', `No such path: ${path}`); });
|
|
663
|
+
return { name, type: 'dir' };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Document picker via a hidden `<input type=file>` (works everywhere; reuses the photos pattern). */
|
|
667
|
+
private pickDocuments(types?: string[], multiple = false): Promise<Array<{ name: string; mimeType: string; size: number; base64: string }>> {
|
|
668
|
+
const toPicked = async (file: File) => ({
|
|
669
|
+
name: file.name,
|
|
670
|
+
mimeType: file.type || 'application/octet-stream',
|
|
671
|
+
size: file.size,
|
|
672
|
+
base64: bufferToBase64(await file.arrayBuffer()),
|
|
673
|
+
});
|
|
674
|
+
return new Promise((resolve) => {
|
|
675
|
+
const input = document.createElement('input');
|
|
676
|
+
input.type = 'file';
|
|
677
|
+
if (multiple) input.multiple = true;
|
|
678
|
+
if (types?.length) input.accept = types.join(',');
|
|
679
|
+
input.onchange = async () => {
|
|
680
|
+
const files = Array.from(input.files ?? []);
|
|
681
|
+
resolve(await Promise.all(files.map(toPicked)));
|
|
682
|
+
};
|
|
683
|
+
input.oncancel = () => resolve([]);
|
|
684
|
+
input.click();
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
458
688
|
/** Minimal action sheet — fixed bottom panel, resolves option index or null on cancel. */
|
|
459
689
|
private showActionSheet(title: string | undefined, options: string[], cancel: string): Promise<number | null> {
|
|
460
690
|
return new Promise((resolve) => {
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type { ImpactStyle, NotifyType } from './modules/haptics';
|
|
|
23
23
|
export type { SharePayload, ShareFile } from './modules/share';
|
|
24
24
|
export type { Orientation, OrientationLock } from './modules/screen';
|
|
25
25
|
export type { KeyboardInfo } from './modules/keyboard';
|
|
26
|
+
export type { FsDirectory, FsEncoding, FsEntry, PickedFile } from './modules/fs';
|
|
26
27
|
export type { DeviceInfo } from './modules/device';
|
|
27
28
|
export type { ScheduleOptions } from './modules/notifications';
|
|
28
29
|
export type { PushMessage, PushPlatform, PushToken } from './modules/push';
|
|
@@ -34,6 +35,8 @@ export type { ActionOptions, AlertOptions, ConfirmOptions, SafeAreaInsets } from
|
|
|
34
35
|
export type { MotionSample } from './modules/motion';
|
|
35
36
|
export type { UpdateStatus, UpdatesOptions } from './modules/updates';
|
|
36
37
|
export type { PickedContact } from './modules/contacts';
|
|
38
|
+
export type { ScanFormat, ScanOptions, ScanResult, ScanCancelled } from './modules/scanner';
|
|
39
|
+
export { isScanResult } from './modules/scanner';
|
|
37
40
|
export type { CalendarEventOptions } from './modules/calendar';
|
|
38
41
|
export type { BrowserOptions } from './modules/browser';
|
|
39
42
|
export type { OAuthAuthorizeParams, OAuthResult } from './modules/oauth';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
/** App-scoped storage roots — paths are resolved relative to one of these.
|
|
4
|
+
* - `documents`: user-visible (iOS Documents — iCloud-backed-up · Android getExternalFilesDir —
|
|
5
|
+
* NOT auto-backed-up, lives on shared/external storage).
|
|
6
|
+
* - `data`: app-private (iOS Application Support — backed up · Android getFilesDir — included in
|
|
7
|
+
* Auto Backup by default, subject to the app's backup rules). Default.
|
|
8
|
+
* - `cache`: evictable, the OS may purge it and it isn't backed up (iOS Caches · Android getCacheDir). */
|
|
9
|
+
export type FsDirectory = 'documents' | 'data' | 'cache';
|
|
10
|
+
|
|
11
|
+
/** Text vs binary payload encoding. `utf8` reads/writes a string; `base64` a raw byte blob. */
|
|
12
|
+
export type FsEncoding = 'utf8' | 'base64';
|
|
13
|
+
|
|
14
|
+
/** One entry returned by {@link FsModule.list} / {@link FsModule.stat}. */
|
|
15
|
+
export interface FsEntry {
|
|
16
|
+
/** Base name (list) or full relative path (stat input echo). */
|
|
17
|
+
name: string;
|
|
18
|
+
type: 'file' | 'dir';
|
|
19
|
+
/** Bytes for a file; omitted/0 for a dir. */
|
|
20
|
+
size?: number;
|
|
21
|
+
/** Last-modified epoch ms. */
|
|
22
|
+
mtime?: number;
|
|
23
|
+
/** Native URI (`file://` on native, omitted on web). */
|
|
24
|
+
uri?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A document chosen via {@link FsModule.pickFile}. `base64` carries the bytes inline. */
|
|
28
|
+
export interface PickedFile {
|
|
29
|
+
name: string;
|
|
30
|
+
mimeType: string;
|
|
31
|
+
size: number;
|
|
32
|
+
base64?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FsReadOptions { dir?: FsDirectory; encoding?: FsEncoding }
|
|
36
|
+
export interface FsWriteOptions { dir?: FsDirectory; encoding?: FsEncoding; recursive?: boolean }
|
|
37
|
+
export interface FsDirOptions { dir?: FsDirectory }
|
|
38
|
+
export interface FsMkdirOptions { dir?: FsDirectory; recursive?: boolean }
|
|
39
|
+
export interface FsPickOptions {
|
|
40
|
+
/** MIME types / extensions to allow (e.g. `['application/pdf', 'image/*']`). Omit = any. */
|
|
41
|
+
types?: string[];
|
|
42
|
+
/** Allow multi-select (default false). */
|
|
43
|
+
multiple?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Native filesystem + document picker — ONE API across platforms.
|
|
48
|
+
*
|
|
49
|
+
* Read/write/list/stat files under three app-scoped roots ({@link FsDirectory}) plus a system
|
|
50
|
+
* document picker. No runtime permission is needed: every root lives inside the app sandbox and
|
|
51
|
+
* the picker hands back security-scoped (iOS) / SAF (Android) URIs the user explicitly chose.
|
|
52
|
+
*
|
|
53
|
+
* Native (iOS `FileManager`, Android `java.io.File`) → `capability === 'native'`. Web maps file
|
|
54
|
+
* I/O to the Origin Private File System (`'web'` where present, else `'none'`) and `pickFile` to
|
|
55
|
+
* the File System Access API / a hidden `<input type=file>` fallback. Branch on {@link capability}.
|
|
56
|
+
*/
|
|
57
|
+
export class FsModule {
|
|
58
|
+
constructor(private kit: NativeKit) {}
|
|
59
|
+
|
|
60
|
+
/** 'native' on a shell · 'web' where OPFS exists · else 'none'. */
|
|
61
|
+
get capability() {
|
|
62
|
+
return this.kit.capability('fs');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read a file. `utf8` (default) → string; `base64` → base64 string. */
|
|
66
|
+
read(path: string, opts: FsReadOptions = {}): Promise<string> {
|
|
67
|
+
return this.kit.invoke('fs.read', { path, ...opts });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Write `data` to `path` (overwrites). `recursive` creates missing parent dirs. → `{ uri }`. */
|
|
71
|
+
write(path: string, data: string, opts: FsWriteOptions = {}): Promise<{ uri: string }> {
|
|
72
|
+
return this.kit.invoke('fs.write', { path, data, ...opts });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Append `data` to `path` (creates it if absent). */
|
|
76
|
+
append(path: string, data: string, opts: FsWriteOptions = {}): Promise<void> {
|
|
77
|
+
return this.kit.invoke('fs.append', { path, data, ...opts });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Delete a file or empty directory. No-throw if it doesn't exist. */
|
|
81
|
+
delete(path: string, opts: FsDirOptions = {}): Promise<void> {
|
|
82
|
+
return this.kit.invoke('fs.delete', { path, ...opts });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** List a directory's immediate children. */
|
|
86
|
+
list(path: string, opts: FsDirOptions = {}): Promise<FsEntry[]> {
|
|
87
|
+
return this.kit.invoke('fs.list', { path, ...opts });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Create a directory. `recursive` (default true) makes intermediate dirs. */
|
|
91
|
+
mkdir(path: string, opts: FsMkdirOptions = {}): Promise<void> {
|
|
92
|
+
return this.kit.invoke('fs.mkdir', { path, ...opts });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Stat a file/dir → `{ name, type, size, mtime, uri }`. */
|
|
96
|
+
stat(path: string, opts: FsDirOptions = {}): Promise<FsEntry> {
|
|
97
|
+
return this.kit.invoke('fs.stat', { path, ...opts });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve a path to a URI suitable for `kit.share` (`file://` native, content:// Android). */
|
|
101
|
+
getUri(path: string, opts: FsDirOptions = {}): Promise<string> {
|
|
102
|
+
return this.kit.invoke('fs.getUri', { path, ...opts });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Open the system document picker. Resolves with the chosen files (bytes inline as base64). */
|
|
106
|
+
pickFile(opts: FsPickOptions = {}): Promise<PickedFile[]> {
|
|
107
|
+
return this.kit.invoke('fs.pickFile', opts);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { NativeKit } from '../core/NativeKit';
|
|
2
|
+
|
|
3
|
+
/** Symbologies the scanner can decode. `'all'` (default) accepts every supported format. */
|
|
4
|
+
export type ScanFormat = 'qr' | 'ean13' | 'code128' | 'all';
|
|
5
|
+
|
|
6
|
+
/** A successfully decoded code. */
|
|
7
|
+
export interface ScanResult {
|
|
8
|
+
/** Decoded payload (the string the barcode/QR encodes). */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Detected symbology — one of {@link ScanFormat} minus `'all'`, or `'unknown'`. */
|
|
11
|
+
format: ScanFormat | 'unknown';
|
|
12
|
+
/** Bounding box of the code in the camera preview (CSS px), when the platform reports it. */
|
|
13
|
+
bounds?: { x: number; y: number; width: number; height: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Scan resolved without a code because the user dismissed the scanner. */
|
|
17
|
+
export interface ScanCancelled {
|
|
18
|
+
cancelled: true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScanOptions {
|
|
22
|
+
/** Symbology filter — a single format or a list. Omit / `'all'` = any supported code. */
|
|
23
|
+
formats?: ScanFormat | ScanFormat[];
|
|
24
|
+
/** Which camera to open. Default `'back'`. */
|
|
25
|
+
camera?: 'back' | 'front';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when a {@link ScanResult.value} is present (not the cancelled shape). */
|
|
29
|
+
export function isScanResult(r: ScanResult | ScanCancelled): r is ScanResult {
|
|
30
|
+
return (r as ScanCancelled).cancelled !== true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Camera barcode / QR decoding — ONE API across platforms.
|
|
35
|
+
*
|
|
36
|
+
* {@link scan} opens a native full-screen capture UI, returns the FIRST decoded code, then
|
|
37
|
+
* auto-dismisses. If the user dismisses it first, it resolves to `{ cancelled: true }` (NOT a
|
|
38
|
+
* rejection) — branch with {@link isScanResult}. {@link cancel} dismisses an in-progress scan
|
|
39
|
+
* programmatically, which makes that same pending {@link scan} resolve `{ cancelled: true }`.
|
|
40
|
+
*
|
|
41
|
+
* Native (iOS `AVCaptureMetadataOutput`, Android ZXing capture Activity) reports
|
|
42
|
+
* `capability === 'native'`. Web maps to the `BarcodeDetector` API where present (Chrome/Android
|
|
43
|
+
* → `'web'`); where absent (Safari/Firefox today) it's `'none'` and {@link scan} throws
|
|
44
|
+
* `KitError('UNSUPPORTED')` — no heavy JS decoder fallback. Branch on {@link capability}.
|
|
45
|
+
*/
|
|
46
|
+
export class ScannerModule {
|
|
47
|
+
constructor(private kit: NativeKit) {}
|
|
48
|
+
|
|
49
|
+
/** 'native' on a shell · 'web' where BarcodeDetector exists · else 'none'. */
|
|
50
|
+
get capability() {
|
|
51
|
+
return this.kit.capability('scanner');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Open the scanner; resolve the first code, or `{ cancelled: true }` if dismissed. */
|
|
55
|
+
scan(opts: ScanOptions = {}): Promise<ScanResult | ScanCancelled> {
|
|
56
|
+
return this.kit.invoke('scanner.scan', opts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Dismiss an in-progress {@link scan} (its promise then resolves `{ cancelled: true }`). */
|
|
60
|
+
cancel(): Promise<void> {
|
|
61
|
+
return this.kit.invoke('scanner.cancel');
|
|
62
|
+
}
|
|
63
|
+
}
|