@octane-xplat/platform 0.1.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.
Files changed (45) hide show
  1. package/package.json +32 -0
  2. package/src/a11y.native.ts +14 -0
  3. package/src/a11y.web.ts +19 -0
  4. package/src/biometrics.native.ts +24 -0
  5. package/src/biometrics.web.ts +21 -0
  6. package/src/clipboard.native.ts +17 -0
  7. package/src/clipboard.web.ts +18 -0
  8. package/src/deep-links.native.ts +39 -0
  9. package/src/deep-links.web.ts +23 -0
  10. package/src/device.native.ts +11 -0
  11. package/src/device.web.ts +13 -0
  12. package/src/files.native.ts +24 -0
  13. package/src/files.web.ts +34 -0
  14. package/src/haptics.native.ts +24 -0
  15. package/src/haptics.web.ts +16 -0
  16. package/src/index.ts +27 -0
  17. package/src/lifecycle.native.ts +1 -0
  18. package/src/lifecycle.native.tsrx +38 -0
  19. package/src/lifecycle.web.ts +2 -0
  20. package/src/lifecycle.web.tsrx +24 -0
  21. package/src/locale.native.ts +9 -0
  22. package/src/locale.web.ts +12 -0
  23. package/src/media.native.ts +15 -0
  24. package/src/media.web.ts +10 -0
  25. package/src/notifications.native.ts +18 -0
  26. package/src/notifications.web.ts +18 -0
  27. package/src/permissions.native.ts +20 -0
  28. package/src/permissions.web.ts +38 -0
  29. package/src/safe-area.native.ts +1 -0
  30. package/src/safe-area.native.tsrx +27 -0
  31. package/src/safe-area.web.ts +1 -0
  32. package/src/safe-area.web.tsrx +34 -0
  33. package/src/screen.native.ts +1 -0
  34. package/src/screen.native.tsrx +25 -0
  35. package/src/screen.web.ts +1 -0
  36. package/src/screen.web.tsrx +26 -0
  37. package/src/secure-storage.native.ts +17 -0
  38. package/src/secure-storage.web.ts +10 -0
  39. package/src/share.native.ts +13 -0
  40. package/src/share.web.ts +28 -0
  41. package/src/storage.native.ts +10 -0
  42. package/src/storage.web.ts +6 -0
  43. package/src/system-bars.native.ts +22 -0
  44. package/src/system-bars.web.ts +14 -0
  45. package/src/types.ts +71 -0
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@octane-xplat/platform",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./*": "./src/*"
8
+ },
9
+ "dependencies": {
10
+ "@nativescript/biometrics": "1.3.1",
11
+ "@nativescript/haptics": "3.1.0",
12
+ "@nativescript/imagepicker": "5.0.0",
13
+ "@nativescript/local-notifications": "7.0.0",
14
+ "@nativescript/secure-storage": "4.0.2",
15
+ "@nativescript/social-share": "2.3.0",
16
+ "nativescript-clipboard": "2.1.1"
17
+ },
18
+ "devDependencies": {
19
+ "@nativescript/core": "9.1.2",
20
+ "octane": "0.4.0"
21
+ },
22
+ "peerDependencies": {
23
+ "octane": ">=0.1.51 <1"
24
+ },
25
+ "description": "Headless platform capabilities for Octane xplat — web + NativeScript leaf pairs",
26
+ "files": [
27
+ "src"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
@@ -0,0 +1,14 @@
1
+ // A11y announce — native leaf. UIAccessibility announcement notification on
2
+ // iOS; View.announceForAccessibility on Android (posted on the content view).
3
+ import { Application } from '@nativescript/core';
4
+
5
+ export function announce(text: string): void {
6
+ if (Application.ios) {
7
+ UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, text);
8
+ return;
9
+ }
10
+ if (Application.android) {
11
+ const view = Application.android.foregroundActivity?.findViewById?.(16908290 /* android.R.id.content */);
12
+ view?.announceForAccessibility?.(text);
13
+ }
14
+ }
@@ -0,0 +1,19 @@
1
+ // A11y announce — web leaf. A polite aria-live region receives the text;
2
+ // screen readers speak the DOM mutation.
3
+ let region: HTMLElement | null = null;
4
+
5
+ export function announce(text: string): void {
6
+ if (!region) {
7
+ region = document.createElement('div');
8
+ region.setAttribute('aria-live', 'polite');
9
+ region.setAttribute('role', 'status');
10
+ region.style.cssText =
11
+ 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);';
12
+ document.body.appendChild(region);
13
+ }
14
+ region.textContent = '';
15
+ // Two writes in one frame collapse — force the SR to see a change.
16
+ requestAnimationFrame(() => {
17
+ if (region) region.textContent = text;
18
+ });
19
+ }
@@ -0,0 +1,24 @@
1
+ // Biometrics — FaceID/TouchID/fingerprint via @nativescript/biometrics.
2
+ import { BiometricAuth } from '@nativescript/biometrics';
3
+ import type { Capability } from './types';
4
+ import type { BiometricsImpl } from './types';
5
+
6
+ const bio = new BiometricAuth();
7
+
8
+ export const biometrics: Capability<BiometricsImpl> = {
9
+ supported: true,
10
+ async ensure() {
11
+ const r = await bio.available();
12
+ return r.any ? 'granted' : 'unsupported';
13
+ },
14
+ impl: {
15
+ async verify(reason) {
16
+ try {
17
+ const r = await bio.verifyBiometric({ title: reason });
18
+ return r.code === 0;
19
+ } catch {
20
+ return false;
21
+ }
22
+ },
23
+ },
24
+ };
@@ -0,0 +1,21 @@
1
+ // Biometrics — web leaf. Platform authenticator presence is the honest
2
+ // signal; an actual WebAuthn ceremony is an auth-flow concern, not this seam.
3
+ import type { BiometricsImpl, Capability } from './types';
4
+
5
+ const pkc = () => (globalThis as any).PublicKeyCredential;
6
+
7
+ export const biometrics: Capability<BiometricsImpl> = {
8
+ supported: typeof pkc() !== 'undefined',
9
+ async ensure() {
10
+ if (!pkc()) return 'unsupported';
11
+ const ok = await pkc().isUserVerifyingPlatformAuthenticatorAvailable();
12
+ return ok ? 'granted' : 'unsupported';
13
+ },
14
+ impl: {
15
+ // A bare verify() has no ceremony target — WebAuthn needs a challenge.
16
+ // Report presence; wire real attestation when an auth flow exists.
17
+ async verify(_reason: string) {
18
+ return false;
19
+ },
20
+ },
21
+ };
@@ -0,0 +1,17 @@
1
+ // Clipboard — pasteboard via nativescript-clipboard (sync API wrapped async
2
+ // to keep the shared contract awaitable on both sides).
3
+ import { getTextSync, setTextSync } from 'nativescript-clipboard';
4
+
5
+ export const clipboard = {
6
+ async write(text: string): Promise<boolean> {
7
+ setTextSync(text);
8
+ return true;
9
+ },
10
+ async read(): Promise<string | null> {
11
+ try {
12
+ return getTextSync();
13
+ } catch {
14
+ return null;
15
+ }
16
+ },
17
+ };
@@ -0,0 +1,18 @@
1
+ // Clipboard — async permission-gated on web; read may be denied silently.
2
+ export const clipboard = {
3
+ async write(text: string): Promise<boolean> {
4
+ try {
5
+ await navigator.clipboard.writeText(text);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ },
11
+ async read(): Promise<string | null> {
12
+ try {
13
+ return await navigator.clipboard.readText();
14
+ } catch {
15
+ return null;
16
+ }
17
+ },
18
+ };
@@ -0,0 +1,39 @@
1
+ // Deep links — native leaf. iOS delivers openUrl + launchOptions; Android
2
+ // delivers an intent on resume/newIntent. Handlers get the raw URL string —
3
+ // the app maps it onto its route table.
4
+ import { Application } from '@nativescript/core';
5
+
6
+ type LinkHandler = (url: string) => void;
7
+ const handlers = new Set<LinkHandler>();
8
+ let wired = false;
9
+ let initial: string | null = null;
10
+
11
+ function wire() {
12
+ if (wired) return;
13
+ wired = true;
14
+ if (Application.ios) {
15
+ Application.on('openUrl', (args: any) => {
16
+ const url = args.url?.absoluteString ?? String(args.url ?? '');
17
+ for (const h of handlers) h(url);
18
+ });
19
+ }
20
+ if (Application.android) {
21
+ Application.on(Application.resumeEvent, () => {
22
+ const intent = Application.android.foregroundActivity?.getIntent?.();
23
+ const url = intent?.getDataString?.();
24
+ if (url) for (const h of handlers) h(url);
25
+ });
26
+ }
27
+ }
28
+
29
+ export function onDeepLink(cb: LinkHandler): () => void {
30
+ wire();
31
+ handlers.add(cb);
32
+ return () => handlers.delete(cb);
33
+ }
34
+
35
+ export function consumeInitialUrl(): string | null {
36
+ const u = initial;
37
+ initial = null;
38
+ return u;
39
+ }
@@ -0,0 +1,23 @@
1
+ // Deep links — web leaf. The URL IS the link: consumeInitialUrl returns the
2
+ // current path; onDeepLink listens for popstate (back/forward = link events).
3
+ type LinkHandler = (url: string) => void;
4
+ const handlers = new Set<LinkHandler>();
5
+ let wired = false;
6
+
7
+ function wire() {
8
+ if (wired) return;
9
+ wired = true;
10
+ window.addEventListener('popstate', () => {
11
+ for (const h of handlers) h(location.pathname + location.search);
12
+ });
13
+ }
14
+
15
+ export function onDeepLink(cb: LinkHandler): () => void {
16
+ wire();
17
+ handlers.add(cb);
18
+ return () => handlers.delete(cb);
19
+ }
20
+
21
+ export function consumeInitialUrl(): string | null {
22
+ return location.pathname === '/' ? null : location.pathname + location.search;
23
+ }
@@ -0,0 +1,11 @@
1
+ import { Device } from '@nativescript/core';
2
+ import type { DeviceInfo } from './types';
3
+
4
+ export const device: DeviceInfo = {
5
+ os: Device.os.toLowerCase() === 'ios' ? 'ios' : 'android',
6
+ osVersion: Device.osVersion,
7
+ model: Device.model,
8
+ manufacturer: Device.manufacturer,
9
+ language: Device.language.split('-')[0] ?? '',
10
+ region: Device.region,
11
+ };
@@ -0,0 +1,13 @@
1
+ import type { DeviceInfo } from './types';
2
+
3
+ const ua = navigator.userAgent;
4
+ const osVersion = ua.match(/(?:Mac OS X|Windows NT|Android|OS) ([\d._]+)/)?.[1]?.replace(/_/g, '.') ?? '';
5
+
6
+ export const device: DeviceInfo = {
7
+ os: 'web',
8
+ osVersion,
9
+ model: 'browser',
10
+ manufacturer: '',
11
+ language: navigator.language.split('-')[0] ?? '',
12
+ region: navigator.language.split('-')[1] ?? '',
13
+ };
@@ -0,0 +1,24 @@
1
+ // Files — native leaf. FileRef wraps a real filesystem path under the app's
2
+ // documents folder; picking delegates to the imagepicker/file picker seam.
3
+ import { File, knownFolders, path } from '@nativescript/core';
4
+ import type { FileRef } from './types';
5
+
6
+ const docs = () => knownFolders.documents();
7
+
8
+ export const files = {
9
+ /** Not implemented on native yet — file pickers are plugin-shaped;
10
+ * media.pickImage covers the common case. Returns null. */
11
+ async pick(_accept?: string): Promise<FileRef | null> {
12
+ return null;
13
+ },
14
+ async readText(ref: FileRef): Promise<string> {
15
+ return File.fromPath(ref.uri).readText();
16
+ },
17
+ async writeText(name: string, text: string): Promise<FileRef> {
18
+ const p = path.join(docs().path, name);
19
+ const f = File.fromPath(p);
20
+ f.writeTextSync(text);
21
+ return { name, uri: p };
22
+ },
23
+ release(_ref: FileRef): void {},
24
+ };
@@ -0,0 +1,34 @@
1
+ // Files — web leaf. Opaque FileRef: a blob/object URL plus a name; reads go
2
+ // through FileReader/fetch on the ref's URL.
3
+ import type { FileRef } from './types';
4
+
5
+ export const files = {
6
+ async pick(accept = '*/*'): Promise<FileRef | null> {
7
+ return new Promise((resolve) => {
8
+ const input = document.createElement('input');
9
+ input.type = 'file';
10
+ input.accept = accept;
11
+ input.onchange = () => {
12
+ const f = input.files?.[0];
13
+ resolve(f ? { name: f.name, uri: URL.createObjectURL(f) } : null);
14
+ };
15
+ input.oncancel = () => resolve(null);
16
+ input.click();
17
+ });
18
+ },
19
+ async readText(ref: FileRef): Promise<string> {
20
+ return (await fetch(ref.uri)).text();
21
+ },
22
+ /** "Write" on web = a download. Returns the object URL. */
23
+ async writeText(name: string, text: string): Promise<FileRef> {
24
+ const uri = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
25
+ const a = document.createElement('a');
26
+ a.href = uri;
27
+ a.download = name;
28
+ a.click();
29
+ return { name, uri };
30
+ },
31
+ release(ref: FileRef): void {
32
+ URL.revokeObjectURL(ref.uri);
33
+ },
34
+ };
@@ -0,0 +1,24 @@
1
+ // Haptics — Taptic Engine (iOS) / Vibrator (Android) via @nativescript/haptics.
2
+ import { Haptics, HapticImpactType, HapticNotificationType } from '@nativescript/haptics';
3
+ import type { Capability } from './types';
4
+ import type { HapticsImpl } from './types';
5
+
6
+ export const haptics: Capability<HapticsImpl> = {
7
+ supported: true,
8
+ ensure: async () => 'granted',
9
+ impl: {
10
+ impact: (style = 'light') =>
11
+ Haptics.impact(
12
+ style === 'heavy' ? HapticImpactType.HEAVY
13
+ : style === 'medium' ? HapticImpactType.MEDIUM
14
+ : HapticImpactType.LIGHT,
15
+ ),
16
+ notification: (kind) =>
17
+ Haptics.notification(
18
+ kind === 'error' ? HapticNotificationType.ERROR
19
+ : kind === 'warning' ? HapticNotificationType.WARNING
20
+ : HapticNotificationType.SUCCESS,
21
+ ),
22
+ selection: () => Haptics.selection(),
23
+ },
24
+ };
@@ -0,0 +1,16 @@
1
+ // Haptics — web leaf. navigator.vibrate exists on Android Chrome only;
2
+ // iOS Safari and desktop report unsupported via the Capability contract.
3
+ import type { Capability, HapticsImpl } from './types';
4
+
5
+ const vibrate = (ms: number | number[]) =>
6
+ typeof navigator !== 'undefined' && 'vibrate' in navigator && navigator.vibrate(ms);
7
+
8
+ export const haptics: Capability<HapticsImpl> = {
9
+ supported: typeof navigator !== 'undefined' && 'vibrate' in navigator,
10
+ ensure: async () => ('vibrate' in navigator ? 'granted' : 'unsupported'),
11
+ impl: {
12
+ impact: (style) => vibrate(style === 'heavy' ? 30 : style === 'medium' ? 20 : 10),
13
+ notification: (kind) => vibrate(kind === 'error' ? [50, 60, 50] : kind === 'warning' ? [30, 40, 30] : 15),
14
+ selection: () => vibrate(5),
15
+ },
16
+ };
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Single index — each specifier resolves its own leaf through the suffix
2
+ // chain (.web under web conditions, .native/.ios/.android under native).
3
+ // Hook-bearing services live in .tsrx files with .ts shims so tsc's
4
+ // moduleSuffixes can reach them (docs/module-resolution.md).
5
+ export type {
6
+ AppState, BiometricsImpl, Capability, DeviceInfo, FileRef, HapticsImpl,
7
+ Insets, Locale, NotificationsImpl, PermissionKind, SecureStore,
8
+ ShareResult, WindowSize,
9
+ } from './types';
10
+ export { device } from './device';
11
+ export { storage } from './storage';
12
+ export { clipboard } from './clipboard';
13
+ export { share } from './share';
14
+ export { haptics } from './haptics';
15
+ export { secureStorage } from './secure-storage';
16
+ export { files } from './files';
17
+ export { notifications } from './notifications';
18
+ export { permissions } from './permissions';
19
+ export { systemBars } from './system-bars';
20
+ export { announce } from './a11y';
21
+ export { locale } from './locale';
22
+ export { media } from './media';
23
+ export { biometrics } from './biometrics';
24
+ export { onDeepLink, consumeInitialUrl } from './deep-links';
25
+ export { useAppState, useBackHandler } from './lifecycle';
26
+ export { useSafeAreaInsets } from './safe-area';
27
+ export { useWindowSize } from './screen';
@@ -0,0 +1 @@
1
+ export { useAppState, useBackHandler } from './lifecycle.native.tsrx';
@@ -0,0 +1,38 @@
1
+ // App lifecycle — native leaf. Application suspend/resume/exit events map
2
+ // onto the shared AppState vocabulary; activityBackPressed feeds
3
+ // useBackHandler (Android hardware back).
4
+ import { useEffect, useState } from 'octane';
5
+ import { Application } from '@nativescript/core';
6
+ import type { AppState } from './types';
7
+
8
+ export function useAppState(): AppState {
9
+ const [state, setState] = useState<AppState>('active');
10
+ useEffect(() => {
11
+ const onSuspend = () => setState('background');
12
+ const onResume = () => setState('active');
13
+ const onExit = () => setState('inactive');
14
+ Application.on(Application.suspendEvent, onSuspend);
15
+ Application.on(Application.resumeEvent, onResume);
16
+ Application.on(Application.exitEvent, onExit);
17
+ return () => {
18
+ Application.off(Application.suspendEvent, onSuspend);
19
+ Application.off(Application.resumeEvent, onResume);
20
+ Application.off(Application.exitEvent, onExit);
21
+ };
22
+ }, []);
23
+ return state;
24
+ }
25
+
26
+ /** Android hardware back — handler returns true when it consumed the press. */
27
+ export function useBackHandler(fn: () => boolean): void {
28
+ useEffect(() => {
29
+ if (!Application.android) return;
30
+ const cb = (e: { cancel: boolean }) => {
31
+ if (fn()) e.cancel = true;
32
+ };
33
+ Application.android.on(Application.AndroidApplication.activityBackPressedEvent, cb);
34
+ return () => {
35
+ Application.android.off(Application.AndroidApplication.activityBackPressedEvent, cb);
36
+ };
37
+ }, [fn]);
38
+ }
@@ -0,0 +1,2 @@
1
+ // tsc shim — moduleSuffixes doesn't reach .tsrx (see docs/module-resolution.md).
2
+ export { useAppState, useBackHandler } from './lifecycle.web.tsrx';
@@ -0,0 +1,24 @@
1
+ // App lifecycle — web leaf. visibilitychange ↔ 'active'/'background';
2
+ // pagehide maps to 'inactive' (the unload-approaching state).
3
+ import { useEffect, useState } from 'octane';
4
+ import type { AppState } from './types';
5
+
6
+ export function useAppState(): AppState {
7
+ const [state, setState] = useState<AppState>('active');
8
+ useEffect(() => {
9
+ const onVis = () => setState(document.visibilityState === 'visible' ? 'active' : 'background');
10
+ const onHide = () => setState('inactive');
11
+ document.addEventListener('visibilitychange', onVis);
12
+ window.addEventListener('pagehide', onHide);
13
+ window.addEventListener('pageshow', onVis);
14
+ return () => {
15
+ document.removeEventListener('visibilitychange', onVis);
16
+ window.removeEventListener('pagehide', onHide);
17
+ window.removeEventListener('pageshow', onVis);
18
+ };
19
+ }, []);
20
+ return state;
21
+ }
22
+
23
+ /** Browser back is already real history — nothing to intercept on web. */
24
+ export function useBackHandler(_fn: () => boolean): void {}
@@ -0,0 +1,9 @@
1
+ // Locale — Device.language/region on native.
2
+ import { Device } from '@nativescript/core';
3
+ import type { Locale } from './types';
4
+
5
+ export const locale: Locale = {
6
+ tag: `${Device.language}-${Device.region}`,
7
+ language: Device.language.split('-')[0] ?? '',
8
+ region: Device.region,
9
+ };
@@ -0,0 +1,12 @@
1
+ // Locale — navigator.language on web ("en-US" → tag + parts).
2
+ export interface Locale {
3
+ tag: string;
4
+ language: string;
5
+ region: string;
6
+ }
7
+
8
+ export const locale: Locale = (() => {
9
+ const tag = navigator.language ?? '';
10
+ const [language = '', region = ''] = tag.split('-');
11
+ return { tag, language, region };
12
+ })();
@@ -0,0 +1,15 @@
1
+ // Media picking — @nativescript/imagepicker presents the system picker and
2
+ // yields ImageAssets; the FileRef uri is the asset's file path.
3
+ import { create as createImagePicker } from '@nativescript/imagepicker';
4
+ import type { FileRef } from './types';
5
+
6
+ export const media = {
7
+ async pickImage(): Promise<FileRef | null> {
8
+ const picker = createImagePicker({ mode: 'single' });
9
+ await picker.authorize();
10
+ const [asset] = await picker.present();
11
+ if (!asset) return null;
12
+ const uri = (asset as any).ios ?? (asset as any).android ?? '';
13
+ return { name: 'image', uri: String(uri) };
14
+ },
15
+ };
@@ -0,0 +1,10 @@
1
+ // Media picking — web leaf. <input type=file accept="image/*"> → FileRef
2
+ // (object URL). Multi-select stays out of v1.
3
+ import type { FileRef } from './types';
4
+ import { files } from './files';
5
+
6
+ export const media = {
7
+ async pickImage(): Promise<FileRef | null> {
8
+ return files.pick('image/*');
9
+ },
10
+ };
@@ -0,0 +1,18 @@
1
+ // Notifications — local notifications via plugin; push (APNs/FCM) is an
2
+ // app-level concern, not this seam.
3
+ import { LocalNotifications } from '@nativescript/local-notifications';
4
+ import type { Capability } from './types';
5
+ import type { NotificationsImpl } from './types';
6
+
7
+ export const notifications: Capability<NotificationsImpl> = {
8
+ supported: true,
9
+ async ensure() {
10
+ const granted = await LocalNotifications.requestPermission();
11
+ return granted ? 'granted' : 'denied';
12
+ },
13
+ impl: {
14
+ notify(title, body) {
15
+ LocalNotifications.schedule([{ id: Date.now() % 2147483647, title, body: body ?? '' }]);
16
+ },
17
+ },
18
+ };
@@ -0,0 +1,18 @@
1
+ // Notifications — Web Notification API. ensure() maps permission state;
2
+ // notify() posts an immediate local notification (no push).
3
+ import type { Capability, NotificationsImpl } from './types';
4
+
5
+ export const notifications: Capability<NotificationsImpl> = {
6
+ supported: typeof Notification !== 'undefined',
7
+ async ensure() {
8
+ if (typeof Notification === 'undefined') return 'unsupported';
9
+ if (Notification.permission === 'granted') return 'granted';
10
+ if (Notification.permission === 'denied') return 'denied';
11
+ return (await Notification.requestPermission()) === 'granted' ? 'granted' : 'denied';
12
+ },
13
+ impl: {
14
+ notify(title, body) {
15
+ if (Notification.permission === 'granted') new Notification(title, { body });
16
+ },
17
+ },
18
+ };
@@ -0,0 +1,20 @@
1
+ // Runtime permissions — native leaf. Maps the shared kind union onto the
2
+ // owning plugin's request call; add kinds as services land.
3
+ import { LocalNotifications } from '@nativescript/local-notifications';
4
+ import type { PermissionKind } from './types';
5
+
6
+ export const permissions = {
7
+ async ensure(kind: PermissionKind): Promise<'granted' | 'denied' | 'unsupported'> {
8
+ switch (kind) {
9
+ case 'notifications':
10
+ return (await LocalNotifications.requestPermission()) ? 'granted' : 'denied';
11
+ // camera/photos/location have dedicated plugins — the owning
12
+ // service leaf (media, geolocation) owns its own ensure(); this
13
+ // generic seam reports them unsupported until wired.
14
+ case 'camera':
15
+ case 'photos':
16
+ case 'location':
17
+ return 'unsupported';
18
+ }
19
+ },
20
+ };
@@ -0,0 +1,38 @@
1
+ // Runtime permissions — web leaf. Browsers grant most capabilities per-API
2
+ // (clipboard/notification have their own ensure()); this generic seam covers
3
+ // feature-detection for the rest.
4
+ export type PermissionKind = 'notifications' | 'camera' | 'photos' | 'location';
5
+
6
+ export const permissions = {
7
+ async ensure(kind: PermissionKind): Promise<'granted' | 'denied' | 'unsupported'> {
8
+ switch (kind) {
9
+ case 'notifications': {
10
+ if (typeof Notification === 'undefined') return 'unsupported';
11
+ if (Notification.permission === 'granted') return 'granted';
12
+ if (Notification.permission === 'denied') return 'denied';
13
+ return (await Notification.requestPermission()) === 'granted' ? 'granted' : 'denied';
14
+ }
15
+ case 'camera':
16
+ case 'photos': {
17
+ if (!navigator.mediaDevices?.getUserMedia) return 'unsupported';
18
+ try {
19
+ const s = await navigator.mediaDevices.getUserMedia({ video: true });
20
+ s.getTracks().forEach((t) => t.stop());
21
+ return 'granted';
22
+ } catch {
23
+ return 'denied';
24
+ }
25
+ }
26
+ case 'location': {
27
+ if (!('geolocation' in navigator)) return 'unsupported';
28
+ return new Promise((resolve) =>
29
+ navigator.geolocation.getCurrentPosition(
30
+ () => resolve('granted'),
31
+ (e) => resolve(e.code === e.PERMISSION_DENIED ? 'denied' : 'unsupported'),
32
+ { timeout: 15000 },
33
+ ),
34
+ );
35
+ }
36
+ }
37
+ },
38
+ };
@@ -0,0 +1 @@
1
+ export { useSafeAreaInsets } from './safe-area.native.tsrx';
@@ -0,0 +1,27 @@
1
+ // Safe-area insets — native leaf. Reads the window's safe area after layout;
2
+ // falls back to zero before it exists.
3
+ import { useEffect, useState } from 'octane';
4
+ import type { Insets } from './types';
5
+ import { Application } from '@nativescript/core';
6
+
7
+
8
+ function readInsets(): Insets {
9
+ if (Application.ios) {
10
+ const v = Application.ios.rootController?.view;
11
+ const i = v?.safeAreaInsets;
12
+ if (i) return { top: i.top, bottom: i.bottom, left: i.left, right: i.right };
13
+ }
14
+ // Android: WindowInsets need an attached view — return 0 until a screen
15
+ // supplies its own; orientation change re-reads.
16
+ return { top: 0, bottom: 0, left: 0, right: 0 };
17
+ }
18
+
19
+ export function useSafeAreaInsets(): Insets {
20
+ const [insets, setInsets] = useState<Insets>(readInsets);
21
+ useEffect(() => {
22
+ const update = () => setInsets(readInsets());
23
+ Application.on(Application.orientationChangedEvent, update);
24
+ return () => Application.off(Application.orientationChangedEvent, update);
25
+ }, []);
26
+ return insets;
27
+ }
@@ -0,0 +1 @@
1
+ export { useSafeAreaInsets } from './safe-area.web.tsrx';
@@ -0,0 +1,34 @@
1
+ // Safe-area insets — web leaf. env(safe-area-inset-*) only exists in CSS;
2
+ // measure it once through a probe element.
3
+ import { useEffect, useState } from 'octane';
4
+ import type { Insets } from './types';
5
+
6
+
7
+ function measure(): Insets {
8
+ const probe = document.createElement('div');
9
+ probe.style.cssText =
10
+ 'position:fixed;top:0;left:0;pointer-events:none;visibility:hidden;' +
11
+ 'padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom);' +
12
+ 'padding-left:env(safe-area-inset-left);padding-right:env(safe-area-inset-right);';
13
+ document.body.appendChild(probe);
14
+ const cs = getComputedStyle(probe);
15
+ const insets = {
16
+ top: parseFloat(cs.paddingTop) || 0,
17
+ bottom: parseFloat(cs.paddingBottom) || 0,
18
+ left: parseFloat(cs.paddingLeft) || 0,
19
+ right: parseFloat(cs.paddingRight) || 0,
20
+ };
21
+ probe.remove();
22
+ return insets;
23
+ }
24
+
25
+ export function useSafeAreaInsets(): Insets {
26
+ const [insets, setInsets] = useState<Insets>({ top: 0, bottom: 0, left: 0, right: 0 });
27
+ useEffect(() => {
28
+ const update = () => setInsets(measure());
29
+ update();
30
+ window.addEventListener('resize', update);
31
+ return () => window.removeEventListener('resize', update);
32
+ }, []);
33
+ return insets;
34
+ }
@@ -0,0 +1 @@
1
+ export { useWindowSize } from './screen.native.tsrx';
@@ -0,0 +1,25 @@
1
+ // Window size + orientation — native leaf. Screen.mainScreen gives DIPs;
2
+ // Application.orientationChangedEvent drives updates.
3
+ import { useEffect, useState } from 'octane';
4
+ import type { WindowSize } from './types';
5
+ import { Application, Screen } from '@nativescript/core';
6
+
7
+
8
+ function read(): WindowSize {
9
+ const s = Screen.mainScreen;
10
+ return {
11
+ width: s.widthDIPs,
12
+ height: s.heightDIPs,
13
+ orientation: s.widthDIPs >= s.heightDIPs ? 'landscape' : 'portrait',
14
+ };
15
+ }
16
+
17
+ export function useWindowSize(): WindowSize {
18
+ const [size, setSize] = useState<WindowSize>(read);
19
+ useEffect(() => {
20
+ const update = () => setSize(read());
21
+ Application.on(Application.orientationChangedEvent, update);
22
+ return () => Application.off(Application.orientationChangedEvent, update);
23
+ }, []);
24
+ return size;
25
+ }
@@ -0,0 +1 @@
1
+ export { useWindowSize } from './screen.web.tsrx';
@@ -0,0 +1,26 @@
1
+ // Window size + orientation — web leaf.
2
+ import { useEffect, useState } from 'octane';
3
+ import type { WindowSize } from './types';
4
+
5
+
6
+ function read(): WindowSize {
7
+ return {
8
+ width: window.innerWidth,
9
+ height: window.innerHeight,
10
+ orientation: window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait',
11
+ };
12
+ }
13
+
14
+ export function useWindowSize(): WindowSize {
15
+ const [size, setSize] = useState<WindowSize>(read);
16
+ useEffect(() => {
17
+ const update = () => setSize(read());
18
+ window.addEventListener('resize', update);
19
+ window.addEventListener('orientationchange', update);
20
+ return () => {
21
+ window.removeEventListener('resize', update);
22
+ window.removeEventListener('orientationchange', update);
23
+ };
24
+ }, []);
25
+ return size;
26
+ }
@@ -0,0 +1,17 @@
1
+ // Secure storage — Keychain (iOS) / Keystore (Android) via plugin.
2
+ import { SecureStorage } from '@nativescript/secure-storage';
3
+ import type { Capability } from './types';
4
+ import type { SecureStore } from './types';
5
+
6
+ const store = new SecureStorage();
7
+
8
+ export const secureStorage: Capability<SecureStore> = {
9
+ supported: true,
10
+ // No runtime permission needed — Keychain/Keystore is device-owned.
11
+ ensure: async () => 'granted',
12
+ impl: {
13
+ get: (key) => store.get({ key }).then((v) => v ?? null),
14
+ set: (key, value) => store.set({ key, value }),
15
+ remove: (key) => store.remove({ key }),
16
+ },
17
+ };
@@ -0,0 +1,10 @@
1
+ // Secure storage — web has no equivalent trust boundary (IndexedDB is not
2
+ // secure enclave storage). Declared unsupported per the Capability contract;
3
+ // callers must branch on `supported` rather than catching.
4
+ import type { Capability, SecureStore } from './types';
5
+
6
+ export const secureStorage: Capability<SecureStore> = {
7
+ supported: false,
8
+ ensure: async () => 'unsupported',
9
+ impl: null,
10
+ };
@@ -0,0 +1,13 @@
1
+ // Share — native share sheet via @nativescript/social-share.
2
+ import { shareText, shareUrl } from '@nativescript/social-share';
3
+
4
+ export const share = {
5
+ async text(text: string, subject?: string): Promise<'shared'> {
6
+ shareText(text, subject);
7
+ return 'shared';
8
+ },
9
+ async url(url: string, title?: string): Promise<'shared'> {
10
+ shareUrl(url, title ?? url);
11
+ return 'shared';
12
+ },
13
+ };
@@ -0,0 +1,28 @@
1
+ // Share — web leaf. navigator.share where present (mobile Safari/Chrome);
2
+ // desktop degrades to clipboard copy so the call still does something useful.
3
+ export const share = {
4
+ async text(text: string, subject?: string): Promise<'shared' | 'copied' | 'unavailable'> {
5
+ const nav = navigator as any;
6
+ if (nav.share) {
7
+ await nav.share({ text, title: subject });
8
+ return 'shared';
9
+ }
10
+ if (nav.clipboard) {
11
+ await nav.clipboard.writeText(text);
12
+ return 'copied';
13
+ }
14
+ return 'unavailable';
15
+ },
16
+ async url(url: string, title?: string): Promise<'shared' | 'copied' | 'unavailable'> {
17
+ const nav = navigator as any;
18
+ if (nav.share) {
19
+ await nav.share({ url, title });
20
+ return 'shared';
21
+ }
22
+ if (nav.clipboard) {
23
+ await nav.clipboard.writeText(url);
24
+ return 'copied';
25
+ }
26
+ return 'unavailable';
27
+ },
28
+ };
@@ -0,0 +1,10 @@
1
+ import { ApplicationSettings } from '@nativescript/core';
2
+
3
+ /** Sync KV seam — NS ApplicationSettings (NSUserDefaults) on native,
4
+ * the web leaf uses the DOM's string store. String-only for v1;
5
+ * serialize objects at the edge. */
6
+ export const storage = {
7
+ getString: (k: string): string | null => ApplicationSettings.getString(k) ?? null,
8
+ setString: (k: string, v: string): void => { ApplicationSettings.setString(k, v); },
9
+ remove: (k: string): void => { ApplicationSettings.remove(k); },
10
+ };
@@ -0,0 +1,6 @@
1
+ /** Sync KV seam — localStorage on web. */
2
+ export const storage = {
3
+ getString: (k: string): string | null => localStorage.getItem(k),
4
+ setString: (k: string, v: string): void => { localStorage.setItem(k, v); },
5
+ remove: (k: string): void => { localStorage.removeItem(k); },
6
+ };
@@ -0,0 +1,22 @@
1
+ // Status/nav bars — native leaf. iOS status-bar style + Android
2
+ // navigation-bar color via the application window.
3
+ import { Application, Color } from '@nativescript/core';
4
+
5
+ export const systemBars = {
6
+ setStatusBarStyle(style: 'light' | 'dark'): void {
7
+ if (Application.ios) {
8
+ const app = Application.ios.nativeApp;
9
+ // NS 9: per-page statusBarStyle is the preferred seam; fall back to
10
+ // the app-level setter on older systems.
11
+ (app as any)?.setStatusBarStyle?.(style === 'light' ? 1 : 0);
12
+ }
13
+ },
14
+ setColor(color: string): void {
15
+ const c = new Color(color);
16
+ if (Application.android?.startActivity) {
17
+ const win = Application.android.startActivity.getWindow();
18
+ win.setStatusBarColor(c.android);
19
+ win.setNavigationBarColor(c.android);
20
+ }
21
+ },
22
+ };
@@ -0,0 +1,14 @@
1
+ // Status/nav bars — web leaf. No status bar on web, but theme-color is the
2
+ // honest equivalent: it tints mobile browser chrome.
3
+ export const systemBars = {
4
+ setStatusBarStyle(_style: 'light' | 'dark'): void {},
5
+ setColor(color: string): void {
6
+ let meta = document.querySelector('meta[name="theme-color"]');
7
+ if (!meta) {
8
+ meta = document.createElement('meta');
9
+ meta.setAttribute('name', 'theme-color');
10
+ document.head.appendChild(meta);
11
+ }
12
+ meta.setAttribute('content', color);
13
+ },
14
+ };
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Shared contract shapes — imported by BOTH leaves. Types must live here,
2
+ // never in a leaf: `import … from './x.web'` inside x.native.ts drags the
3
+ // web implementation into the native typecheck program.
4
+
5
+ /** Optional capability — never throws for absence (docs/platform-services.md). */
6
+ export interface Capability<T> {
7
+ supported: boolean;
8
+ ensure(): Promise<'granted' | 'denied' | 'unsupported'>;
9
+ /** usable iff supported && ensured */
10
+ impl: T | null;
11
+ }
12
+
13
+ export type AppState = 'active' | 'background' | 'inactive';
14
+
15
+ export interface DeviceInfo {
16
+ os: 'web' | 'ios' | 'android';
17
+ osVersion: string;
18
+ model: string;
19
+ manufacturer: string;
20
+ language: string;
21
+ region: string;
22
+ }
23
+
24
+ export interface Insets {
25
+ top: number;
26
+ bottom: number;
27
+ left: number;
28
+ right: number;
29
+ }
30
+
31
+ export interface WindowSize {
32
+ width: number;
33
+ height: number;
34
+ orientation: 'portrait' | 'landscape';
35
+ }
36
+
37
+ export interface FileRef {
38
+ name: string;
39
+ /** blob:/object URL on web, filesystem path on native — opaque */
40
+ uri: string;
41
+ }
42
+
43
+ export interface Locale {
44
+ tag: string;
45
+ language: string;
46
+ region: string;
47
+ }
48
+
49
+ export interface SecureStore {
50
+ get(key: string): Promise<string | null>;
51
+ set(key: string, value: string): Promise<boolean>;
52
+ remove(key: string): Promise<boolean>;
53
+ }
54
+
55
+ export interface HapticsImpl {
56
+ impact(style?: 'light' | 'medium' | 'heavy'): void;
57
+ notification(kind: 'success' | 'warning' | 'error'): void;
58
+ selection(): void;
59
+ }
60
+
61
+ export interface NotificationsImpl {
62
+ notify(title: string, body?: string): void;
63
+ }
64
+
65
+ export interface BiometricsImpl {
66
+ verify(reason: string): Promise<boolean>;
67
+ }
68
+
69
+ export type PermissionKind = 'notifications' | 'camera' | 'photos' | 'location';
70
+
71
+ export type ShareResult = 'shared' | 'copied' | 'unavailable';