@pmate/bridge 0.1.10 → 0.1.12

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/README.md CHANGED
@@ -26,3 +26,49 @@ Desktop hosts expose a preload bridge separately from mobile WebViews. Applicati
26
26
  `getRuntimeInfo()` returns `environment` (`browser`, `electron`, or `native`), desktop `platform`, `appVersion`, `electronVersion` when supplied by the host, and `capabilities.openNotificationSettings`. Detection uses host injection, never the user agent; it is not an authorization boundary. Native device details remain available through `useDeviceInfo()`.
27
27
 
28
28
  `openNotificationSettings()` routes to the desktop host or native settings protocol and rejects on unavailable/failed operations. Electron notification permissions are managed by the OS: browser `Notification.permission` does not describe the main-process notification permission. Do not display browser permission prompts or claim OS permission is granted based on notification API support.
29
+
30
+ ## Startup splash
31
+
32
+ ### Background
33
+
34
+ A native launch may keep its splash above session restoration and first-screen rendering. The web application reports business readiness instead of relying on a fixed timer or FCP. This optional capability is enabled by the host; it does not add a splash to other apps or browsers.
35
+
36
+ ```tsx
37
+ import { startup, useStartupReady } from '@pmate/bridge'
38
+
39
+ // In React: signal only after the intended screen is committed and can paint.
40
+ const splash = useStartupReady(screenReady)
41
+ // Or explicitly:
42
+ await startup.ready()
43
+ ```
44
+
45
+ `startup.getState()` and `startup.ready()` resolve `{ supported, visible }`. The first ready call closes the native launch splash; repeat calls, OAuth returns and foreground transitions cannot reopen it. Auth must report ready when an interactive login form or terminal error is rendered, and must not report ready during a silent redirect. Non-native environments and hosts explicitly rejecting this optional method return unsupported; other failures are surfaced. Hosts enforce their trusted-page bridge boundary and provide an actionable startup failure when readiness never arrives.
46
+
47
+ ## Navigation (optional React Router integration)
48
+
49
+ ### Background
50
+
51
+ Most links should stay within the current WebView and use the application's Router. A few hierarchical pages benefit from native transitions and interactive back gestures. The independent `@pmate/bridge/navigation` entry integrates React Router without importing it from the main Bridge entry. Install React and React Router `^6.22.1 || ^7.0.0` in the consuming application; React Router is an optional peer for consumers that do not use this entry.
52
+
53
+ ```tsx
54
+ import { useNavigation } from "@pmate/bridge/navigation"
55
+
56
+ function ChatLink() {
57
+ const navigation = useNavigation() // inside your React Router
58
+ return <button onClick={() => {
59
+ void navigation.navigate("/chat/123", { presentation: "push" }).catch(showError)
60
+ }}>Open chat</button>
61
+ }
62
+ ```
63
+
64
+ - `navigate(to, options?)` defaults to `presentation: "current"`, preserving React Router To/options, relative routes, replace and history state.
65
+ - `navigate("/chat/123?tab=messages#latest", { presentation: "push" })` requests a new native page, preserving the Router basename/hash href. Native push requires a root-relative application path (not an external URL or route-relative path); it accepts no Router-only options such as `state`, `relative` or `replace`. New pages must restore everything they need from their URL and shared application session.
66
+ - `replace(to, options?)` replaces the current Router entry.
67
+ - `back()` navigates back in the current Router only.
68
+ - `close()` pops the current native page. In browsers, older hosts or the native root, it invokes Router back. Applications must choose a suitable destination for a root back button rather than relying on history always containing a previous entry.
69
+
70
+ All methods return `Promise<void>` so callers can handle native failures. The fallback is used only when there is no bridge, `navigation.getState` explicitly reports unsupported, or an older host explicitly reports that method missing. Unexpected errors, malformed responses and failed native actions reject; they never trigger a second Router navigation. Repeated in-flight native pushes reject.
71
+
72
+ Native protocol: `navigation.getState` returns `{ supported, canClose }`; `navigation.push({url})` confirms `{opened:true}` and `navigation.close()` confirms `{closed:true}`. URL validation and document/frame origin checks also run natively. iOS hosts wrap their root `PmateWebViewController` in `PmatePageNavigationController`; Merry does this by default. Other iOS integrations without this wrapper report unsupported and retain Router behavior. Android pages use separate Activities in the existing task. Closing restores the retained parent WebView. This API does not synchronize business state between documents or convert existing Router links automatically.
73
+
74
+ Run `bun test packages/bridge/tests`, `pnpm exec tsc -p packages/bridge/tsconfig.json`, and `pnpm --filter @pmate/bridge exec playwright test` from the workspace.
package/package.json CHANGED
@@ -1,25 +1,35 @@
1
1
  {
2
2
  "name": "@pmate/bridge",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "files": [
5
5
  "src",
6
6
  "global.d.ts"
7
7
  ],
8
8
  "types": "./global.d.ts",
9
9
  "peerDependencies": {
10
- "react": "*"
10
+ "react": "*",
11
+ "react-router": "^6.22.1 || ^7.0.0"
11
12
  },
12
13
  "peerDependenciesMeta": {
13
14
  "react": {
14
15
  "optional": true
16
+ },
17
+ "react-router": {
18
+ "optional": true
15
19
  }
16
20
  },
17
21
  "devDependencies": {
18
22
  "@types/react": "^19.2.7",
19
- "react": "^19.1.1"
23
+ "react": "^19.1.1",
24
+ "react-router": "^6.22.1",
25
+ "@playwright/test": "1.60.0",
26
+ "react-dom": "^19.1.1",
27
+ "@remix-run/router": "^1.23.3",
28
+ "vite": "^7.3.1"
20
29
  },
21
30
  "main": "src/index.ts",
22
31
  "exports": {
23
- ".": "./src/index.ts"
32
+ ".": "./src/index.ts",
33
+ "./navigation": "./src/navigation/index.ts"
24
34
  }
25
35
  }
package/src/index.ts CHANGED
@@ -32,3 +32,5 @@ export type * from "./types"
32
32
 
33
33
  export { getRuntimeInfo, openNotificationSettings } from "./runtime"
34
34
  export type { RuntimeInfo } from "./runtime"
35
+ export { startup, useStartupReady } from "./startup"
36
+ export type { StartupState } from "./startup"
@@ -0,0 +1,62 @@
1
+ import type { NavigateFunction, NavigateOptions, To } from "react-router"
2
+
3
+ export type NavigationOptions = NavigateOptions & { presentation?: "current" }
4
+ export type PushOptions = { presentation: "push" }
5
+ export interface Navigation {
6
+ navigate(to: To, options?: NavigationOptions): Promise<void>
7
+ navigate(to: string, options: PushOptions): Promise<void>
8
+ replace(to: To, options?: Omit<NavigateOptions, "replace">): Promise<void>
9
+ back(): Promise<void>
10
+ close(): Promise<void>
11
+ }
12
+ export type NavigationState = { supported: boolean; canClose: boolean }
13
+ type Bridge = { call(name: string, params?: unknown): Promise<unknown> }
14
+
15
+ function bridge(): Bridge | undefined {
16
+ return typeof window === "undefined" ? undefined : (window as Window & { PmateBridge?: Bridge }).PmateBridge
17
+ }
18
+
19
+ async function state(host: Bridge): Promise<NavigationState> {
20
+ try {
21
+ const result = await host.call("navigation.getState") as NavigationState
22
+ if (typeof result?.supported !== "boolean" || typeof result.canClose !== "boolean") throw new Error("Invalid navigation bridge response")
23
+ return result
24
+ } catch (error) {
25
+ if (error instanceof Error && error.message === "Bridge function not found: navigation.getState") return { supported: false, canClose: false }
26
+ throw error
27
+ }
28
+ }
29
+
30
+ /** Router integration is supplied by the hook; the native protocol never imports React Router. */
31
+ export function createNavigation(routerNavigate: NavigateFunction, rootHref: string): Navigation {
32
+ let opening = false
33
+ return {
34
+ async navigate(to: To, options?: NavigationOptions | PushOptions) {
35
+ const { presentation = "current", ...routerOptions } = options ?? {}
36
+ if (presentation === "current") { await routerNavigate(to, routerOptions); return }
37
+ if (presentation !== "push") throw new Error("Invalid navigation presentation")
38
+ // A native page must be reconstructible from its URL, unlike history.state.
39
+ if (Object.keys(routerOptions).length) throw new Error("Native push does not accept Router options")
40
+ if (typeof to !== "string" || !to.startsWith("/") || to.startsWith("//") || to.includes("\\")) throw new Error("Native push requires an absolute application path starting with /")
41
+ if (opening) throw new Error("A native page is already opening")
42
+ opening = true
43
+ try {
44
+ const host = bridge()
45
+ if (!host || !(await state(host)).supported) { await routerNavigate(to); return }
46
+ const url = new URL(`${rootHref.replace(/\/?$/, "/")}${to.slice(1)}`, window.location.href)
47
+ if (url.origin !== window.location.origin || url.username || url.password) throw new Error("Native navigation must stay within the application origin")
48
+ const result = await host.call("navigation.push", { url: url.href }) as { opened: boolean }
49
+ if (result?.opened !== true) throw new Error("Native page opening was not confirmed")
50
+ } finally { opening = false }
51
+ },
52
+ async replace(to, options) { await routerNavigate(to, { ...options, replace: true }) },
53
+ async back() { await routerNavigate(-1) },
54
+ async close() {
55
+ const host = bridge()
56
+ const current = host ? await state(host) : undefined
57
+ if (!host || !current?.supported || !current.canClose) { await routerNavigate(-1); return }
58
+ const result = await host.call("navigation.close") as { closed: boolean }
59
+ if (result?.closed !== true) throw new Error("Native page closing was not confirmed")
60
+ },
61
+ }
62
+ }
@@ -0,0 +1,11 @@
1
+ import { useMemo } from "react"
2
+ import { useHref, useNavigate } from "react-router"
3
+ import { createNavigation } from "./core"
4
+ export type { Navigation, NavigationOptions, NavigationState, PushOptions } from "./core"
5
+
6
+ /** Must be used inside the application's React Router, including its basename/hash configuration. */
7
+ export function useNavigation() {
8
+ const navigate = useNavigate()
9
+ const rootHref = useHref("/")
10
+ return useMemo(() => createNavigation(navigate, rootHref), [navigate, rootHref])
11
+ }
package/src/startup.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ export type StartupState = { supported: boolean; visible: boolean }
4
+ type StartupWindow = Window & { PmateBridge?: { call: (name: string) => Promise<unknown> } }
5
+ const unsupported: StartupState = { supported: false, visible: false }
6
+
7
+ async function callStartup(name: "startup.getState" | "startup.ready"): Promise<StartupState> {
8
+ const bridge = typeof window === "undefined" ? undefined : (window as StartupWindow).PmateBridge
9
+ if (!bridge) return unsupported
10
+ try {
11
+ const state = await bridge.call(name) as StartupState
12
+ if (typeof state?.supported !== "boolean" || typeof state.visible !== "boolean") throw new Error("Invalid startup bridge response")
13
+ return state
14
+ } catch (error) {
15
+ // Older hosts explicitly reject this optional capability. Other failures matter.
16
+ if (error instanceof Error && error.message === `Bridge function not found: ${name}`) return unsupported
17
+ throw error
18
+ }
19
+ }
20
+
21
+ /** Startup belongs to the native launch, not to a document or OAuth navigation. */
22
+ export const startup = {
23
+ getState: () => callStartup("startup.getState"),
24
+ ready: () => callStartup("startup.ready"),
25
+ }
26
+
27
+ /** Report business readiness after React commits and the browser can paint it. */
28
+ export function useStartupReady(ready: boolean): StartupState {
29
+ const [state, setState] = useState<StartupState>(unsupported)
30
+ useEffect(() => {
31
+ let active = true
32
+ const read = () => { void startup.getState().then(value => { if (active) setState(value) }).catch(() => console.error("STARTUP_STATE_FAILED")) }
33
+ window.addEventListener("pmate:bridge-ready", read)
34
+ read()
35
+ return () => { active = false; window.removeEventListener("pmate:bridge-ready", read) }
36
+ }, [])
37
+ useEffect(() => {
38
+ if (!ready || !state.supported || !state.visible) return
39
+ let active = true
40
+ let nextFrame = 0
41
+ const frame = requestAnimationFrame(() => {
42
+ nextFrame = requestAnimationFrame(() => {
43
+ if (!active) return
44
+ void startup.ready().then(value => { if (active) setState(value) }).catch(() => console.error("STARTUP_READY_FAILED"))
45
+ })
46
+ })
47
+ return () => { active = false; cancelAnimationFrame(frame); cancelAnimationFrame(nextFrame) }
48
+ }, [ready, state.supported, state.visible])
49
+ return state
50
+ }