diff --git a/.changeset/nav-policy-load-start.md b/.changeset/nav-policy-load-start.md
new file mode 100644
index 0000000..6fbf9ba
--- /dev/null
+++ b/.changeset/nav-policy-load-start.md
@@ -0,0 +1,16 @@
+---
+"mobile": patch
+---
+
+Clause-②: no
+Metadata-protocol: none
+
+The navigation policy is also enforced when a top-frame load starts, so every
+top-frame load follows it. A page the policy does not load in the WebView is
+stopped and goes where the policy sends it, as a link does: a web page to the
+in-app browser, a blocked page nowhere. The WebView then goes back to the
+server, to the route the Console was on. If the server's page goes there again
+before the Console is ready, the app says it can't show the page, with Try
+again and Switch organization.
+
+No native module and no new permission: an over-the-air update is enough.
diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx
index 4e4d581..549c44b 100644
--- a/__tests__/components/ConsoleWebView.test.tsx
+++ b/__tests__/components/ConsoleWebView.test.tsx
@@ -14,17 +14,22 @@ import { BRIDGE_RESOLVE_GLOBAL, CONNECTION_REMOVED_EVENT } from "~/lib/shell/bri
const mockWebView = {
props: null as any,
+ /** Each mounted WebView's latest props, in mount order: what its native side calls. */
+ instances: [] as any[],
mounts: 0,
injectJavaScript: jest.fn(),
reload: jest.fn(),
goBack: jest.fn(),
clearHistory: jest.fn(),
+ stopLoading: jest.fn(),
};
jest.mock("react-native-webview", () => {
const React = require("react");
const { View } = require("react-native");
const WebView = React.forwardRef(function MockWebView(props: any, ref: any) {
+ const [index] = React.useState(() => mockWebView.instances.length);
+ mockWebView.instances[index] = props;
mockWebView.props = props;
React.useEffect(() => {
mockWebView.mounts += 1;
@@ -34,6 +39,7 @@ jest.mock("react-native-webview", () => {
reload: mockWebView.reload,
goBack: mockWebView.goBack,
clearHistory: mockWebView.clearHistory,
+ stopLoading: mockWebView.stopLoading,
}));
return ;
});
@@ -151,11 +157,13 @@ const lastInjected = (): string => {
beforeEach(() => {
mockIsConnected = true;
mockWebView.props = null;
+ mockWebView.instances = [];
mockWebView.mounts = 0;
mockWebView.injectJavaScript.mockReset();
mockWebView.reload.mockReset();
mockWebView.goBack.mockReset();
mockWebView.clearHistory.mockReset();
+ mockWebView.stopLoading.mockReset();
});
describe("ConsoleWebView", () => {
@@ -880,6 +888,160 @@ describe("ConsoleWebView — saved connections", () => {
});
});
+describe("ConsoleWebView — the navigation policy at load start", () => {
+ const ELSEWHERE = "https://pages.elsewhere.example/form";
+
+ function loadEndAt(url: string, loading = false) {
+ act(() => mockWebView.props.onLoadEnd({ nativeEvent: { url, loading } }));
+ }
+
+ /** The Console, loaded and ready, on `route`. */
+ async function onConsolePage(
+ route = "/apps/crm/lead/9",
+ overrides: Partial = {}
+ ) {
+ const rendered = renderConsole(overrides);
+ loadStart(`${SERVER}/_console/apps/crm`);
+ loadEndAt(`${SERVER}/_console/apps/crm`);
+ await consoleReady();
+ await postEvent("routeChanged", { route });
+ mockWebView.injectJavaScript.mockReset();
+ return rendered;
+ }
+
+ afterEach(() => jest.useRealTimers());
+
+ it("stops a top-frame load the policy does not keep in the WebView, opens it where the policy sends it, and goes back to the server where the Console was", async () => {
+ const { props } = await onConsolePage();
+ loadStart(ELSEWHERE);
+ expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
+ expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
+ expect(props.onOpenExternal).toHaveBeenCalledWith(ELSEWHERE);
+ // A new WebView on the server, at the route the Console was on, behind the cover.
+ expect(mockWebView.mounts).toBe(2);
+ expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/crm/lead/9` });
+ expect(screen.getByText("Loading…")).toBeTruthy();
+ });
+
+ it("judges a load reported only at its end the same way, and never counts that load end as the Console's", async () => {
+ jest.useFakeTimers();
+ const { props } = await onConsolePage();
+ loadEndAt(ELSEWHERE);
+ expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
+ expect(props.onOpenExternal).toHaveBeenCalledWith(ELSEWHERE);
+ expect(mockWebView.mounts).toBe(2);
+ // The new WebView's Console has not loaded: no ready timeout lifts the cover.
+ act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS * 2));
+ expect(screen.getByText("Loading…")).toBeTruthy();
+ });
+
+ it("stops a load the policy blocks, opening nothing", async () => {
+ const { props } = await onConsolePage();
+ loadStart("data:text/html,page");
+ expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
+ expect(props.onOpenExternal).not.toHaveBeenCalled();
+ expect(mockWebView.mounts).toBe(2);
+ expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/crm/lead/9` });
+ });
+
+ it("keeps the server's own pages: a document, an in-app history change, a route a native screen owns", async () => {
+ const { props } = await onConsolePage();
+ // Android reports an in-app history change as a load start: the URL decides.
+ loadStart(`${SERVER}/_console/apps/crm/lead/10`);
+ loadStart(`${SERVER}/_console/approvals/7`);
+ loadStart(`${SERVER}/api/v1/auth/callback/corp?code=abc`);
+ loadEndAt(`${SERVER}:443/_console/home`);
+ loadStart("about:blank");
+ expect(mockWebView.stopLoading).not.toHaveBeenCalled();
+ expect(props.onOpenExternal).not.toHaveBeenCalled();
+ expect(props.onOpenNative).not.toHaveBeenCalled();
+ expect(mockWebView.mounts).toBe(1);
+ expect(screen.queryByText("Loading…")).toBeNull();
+ });
+
+ it("keeps a trusted host", async () => {
+ const { props } = await onConsolePage("/apps/crm", { trustedHosts: ["login.partner.example"] });
+ loadStart("https://login.partner.example/authorize");
+ loadEndAt("https://login.partner.example/authorize");
+ expect(mockWebView.stopLoading).not.toHaveBeenCalled();
+ expect(props.onOpenExternal).not.toHaveBeenCalled();
+ expect(mockWebView.mounts).toBe(1);
+ });
+
+ it("shows the failure page when the server's page goes there again before a Console is ready", async () => {
+ const { props } = await onConsolePage();
+ loadStart(ELSEWHERE);
+ expect(mockWebView.mounts).toBe(2);
+ // The new WebView's page goes there again before its Console is ready.
+ loadStart(`${SERVER}/_console/apps/crm/lead/9`);
+ loadStart(ELSEWHERE);
+ expect(mockWebView.stopLoading).toHaveBeenCalledTimes(2);
+ expect(screen.getByTestId("shell-offline-fallback")).toBeTruthy();
+ expect(screen.getByText("Can't show this page in the app")).toBeTruthy();
+ // Opened once, re-created once.
+ expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
+ expect(mockWebView.mounts).toBe(2);
+ // Retry is the user's: one new WebView, which stops at the same page again.
+ fireEvent.press(screen.getByTestId("shell-retry"));
+ expect(mockWebView.mounts).toBe(3);
+ loadStart(ELSEWHERE);
+ expect(screen.getByTestId("shell-offline-fallback")).toBeTruthy();
+ expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
+ });
+
+ it("goes back to the server again once a Console has been ready in between", async () => {
+ const { props } = await onConsolePage();
+ loadStart(ELSEWHERE);
+ loadEndAt(`${SERVER}/_console/apps/crm/lead/9`);
+ await consoleReady();
+ loadStart(ELSEWHERE);
+ expect(screen.queryByTestId("shell-offline-fallback")).toBeNull();
+ expect(props.onOpenExternal).toHaveBeenCalledTimes(2);
+ expect(mockWebView.mounts).toBe(3);
+ });
+
+ it("drops the reports of the WebView it replaces", async () => {
+ jest.useFakeTimers();
+ const { props } = await onConsolePage();
+ loadStart(ELSEWHERE);
+ expect(mockWebView.mounts).toBe(2);
+ const replaced = mockWebView.instances[0];
+ act(() => {
+ replaced.onLoadEnd({ nativeEvent: { url: ELSEWHERE, loading: false } });
+ replaced.onNavigationStateChange({ url: ELSEWHERE, loading: false, canGoBack: true });
+ });
+ expect(props.onOpenExternal).toHaveBeenCalledTimes(1);
+ expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
+ expect(mockWebView.mounts).toBe(2);
+ expect(screen.queryByTestId("shell-offline-fallback")).toBeNull();
+ act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS * 2));
+ expect(screen.getByText("Loading…")).toBeTruthy();
+ });
+
+ it("never judges a report from the WebView a switch replaced against the new server", async () => {
+ const { props, view } = await onConsolePage();
+ view.rerender(
+
+ );
+ expect(mockWebView.mounts).toBe(2);
+ // The WebView left behind was rendered once more, for the new server, before it was replaced.
+ const replaced = mockWebView.instances[0];
+ act(() =>
+ replaced.onLoadEnd({ nativeEvent: { url: `${SERVER}/_console/home`, loading: false } })
+ );
+ expect(mockWebView.stopLoading).not.toHaveBeenCalled();
+ expect(props.onOpenExternal).not.toHaveBeenCalled();
+ expect(mockWebView.mounts).toBe(2);
+ // The new server's own pages stay; the one left is another site now.
+ loadStart("https://erp.example.com/_console/home");
+ expect(mockWebView.mounts).toBe(2);
+ loadStart(`${SERVER}/_console/home`);
+ expect(props.onOpenExternal).toHaveBeenCalledWith(`${SERVER}/_console/home`);
+ expect(mockWebView.mounts).toBe(3);
+ expect(mockWebView.props.source).toEqual({ uri: "https://erp.example.com/_console/home" });
+ });
+});
+
describe("ConsoleWebView — sign-in excursion", () => {
const LOGIN = `${SERVER}/_console/login?redirect=%2Fapps%2Fcrm`;
const IDP = "https://login.idp-corp.example/oauth2/authorize?client_id=acme";
@@ -975,6 +1137,49 @@ describe("ConsoleWebView — sign-in excursion", () => {
);
});
+ it("keeps the excursion's pages at load start and load end, and returns by its own rule", async () => {
+ const { props } = await inExcursion();
+ loadStart("https://mfa.idp-corp.example/verify");
+ loadEndAt("https://mfa.idp-corp.example/verify");
+ loadStart("https://federation.partner.example/saml");
+ expect(screen.getByTestId("shell-excursion-host")).toHaveTextContent(
+ "federation.partner.example"
+ );
+ expect(mockWebView.stopLoading).not.toHaveBeenCalled();
+ expect(props.onOpenExternal).not.toHaveBeenCalled();
+ // Back on the server: the excursion returns, nothing is stopped.
+ loadStart(CALLBACK);
+ loadEndAt(`${SERVER}/_console/home`);
+ expect(screen.queryByTestId("shell-excursion-bar")).toBeNull();
+ expect(mockWebView.stopLoading).not.toHaveBeenCalled();
+ expect(mockWebView.mounts).toBe(1);
+ // Outside the excursion, the provider's page is another site again.
+ loadStart("https://login.idp-corp.example/session");
+ expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1);
+ expect(props.onOpenExternal).toHaveBeenCalledWith("https://login.idp-corp.example/session");
+ expect(mockWebView.mounts).toBe(2);
+ });
+
+ it("leaves a plain http page during the excursion to the excursion's own end", async () => {
+ const { props } = await inExcursion();
+ loadStart("http://login.idp-corp.example/next");
+ expect(screen.getByTestId("shell-excursion-notice-insecure")).toBeTruthy();
+ expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/login` });
+ expect(mockWebView.mounts).toBe(2);
+ expect(props.onOpenExternal).not.toHaveBeenCalled();
+ });
+
+ it("opens nothing for the provider's page after 取消", async () => {
+ const { props } = await inExcursion();
+ fireEvent.press(screen.getByTestId("shell-excursion-cancel"));
+ expect(mockWebView.mounts).toBe(2);
+ const replaced = mockWebView.instances[0];
+ act(() => replaced.onLoadEnd({ nativeEvent: { url: IDP, loading: false } }));
+ expect(props.onOpenExternal).not.toHaveBeenCalled();
+ expect(mockWebView.stopLoading).not.toHaveBeenCalled();
+ expect(mockWebView.mounts).toBe(2);
+ });
+
describe("the bridge", () => {
it("answers no call while an excursion page is on screen, whatever origin it names", async () => {
const { props } = await inExcursion();
diff --git a/__tests__/lib/shell/navigation-policy.test.ts b/__tests__/lib/shell/navigation-policy.test.ts
index c4003be..7461caa 100644
--- a/__tests__/lib/shell/navigation-policy.test.ts
+++ b/__tests__/lib/shell/navigation-policy.test.ts
@@ -1,5 +1,6 @@
import {
decideNavigation,
+ decideTopFrameLoad,
isSignInSurface,
type NavigationContext,
} from "~/lib/shell/navigation-policy";
@@ -96,6 +97,57 @@ describe("decideNavigation", () => {
});
});
+describe("decideTopFrameLoad", () => {
+ it("keeps the server's own pages, a route a native screen owns included", () => {
+ expect(decideTopFrameLoad("https://acme.example.com/_console/apps/crm", ctx)).toEqual({
+ kind: "stay",
+ });
+ expect(decideTopFrameLoad("https://acme.example.com/_console/approvals/9", ctx)).toEqual({
+ kind: "stay",
+ });
+ expect(decideTopFrameLoad("https://acme.example.com/api/v1/auth/callback/x", ctx)).toEqual({
+ kind: "stay",
+ });
+ expect(decideTopFrameLoad("https://acme.example.com:443/_console/home", ctx)).toEqual({
+ kind: "stay",
+ });
+ });
+
+ it("keeps what the policy loads in the top frame: trusted hosts, about:, blob:", () => {
+ expect(decideTopFrameLoad("https://login.acme-idp.example/authorize", ctx)).toEqual({
+ kind: "stay",
+ });
+ expect(decideTopFrameLoad("about:blank", ctx)).toEqual({ kind: "stay" });
+ expect(decideTopFrameLoad("blob:https://acme.example.com/abc", ctx)).toEqual({ kind: "stay" });
+ });
+
+ it("stops any other page, to open it where the policy sends it", () => {
+ expect(decideTopFrameLoad("https://docs.example.org/page", ctx)).toEqual({
+ kind: "stop",
+ open: "https://docs.example.org/page",
+ });
+ expect(decideTopFrameLoad("http://acme.example.com/_console/home", ctx)).toEqual({
+ kind: "stop",
+ open: "http://acme.example.com/_console/home",
+ });
+ });
+
+ it("stops a page the policy blocks, opening nothing", () => {
+ expect(decideTopFrameLoad("data:text/html,x", ctx)).toEqual({
+ kind: "stop",
+ open: null,
+ });
+ expect(decideTopFrameLoad("file:///etc/passwd", ctx)).toEqual({ kind: "stop", open: null });
+ expect(decideTopFrameLoad("not a url", ctx)).toEqual({ kind: "stop", open: null });
+ });
+
+ it("judges every load as the top frame's, whatever the context says", () => {
+ expect(
+ decideTopFrameLoad("https://maps.example.org/embed", { ...ctx, isTopFrame: false })
+ ).toEqual({ kind: "stop", open: "https://maps.example.org/embed" });
+ });
+});
+
describe("isSignInSurface", () => {
const server = "https://acme.example.com";
diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx
index 0e30ba1..c55606f 100644
--- a/components/shell/ConsoleWebView.tsx
+++ b/components/shell/ConsoleWebView.tsx
@@ -28,7 +28,7 @@ import {
routeFromConsoleUrl,
safeConsoleRoute,
} from "~/lib/shell/console-url";
-import { decideNavigation, SIGN_IN_ROUTE } from "~/lib/shell/navigation-policy";
+import { decideNavigation, decideTopFrameLoad, SIGN_IN_ROUTE } from "~/lib/shell/navigation-policy";
import {
EXCURSION_TIMEOUT_MS,
IDLE_EXCURSION,
@@ -190,8 +190,14 @@ export const ConsoleWebView = forwardRef {
+ const caller = documentRef.current;
+ const step = applyExcursion({ type: "location", url, loaded, now: Date.now() });
+ // An excursion that ended here has re-created the WebView already.
+ // The failure page has no top frame.
+ if (documentRef.current !== caller || loadRef.current.phase === "failed") return;
+ // A page of the active sign-in excursion: the excursion follows it.
+ if (step.state.active !== null && step.state.location === url) return;
+ const decision = decideTopFrameLoad(url, {
+ serverUrl,
+ rules,
+ availableScreens,
+ trustedHosts,
+ });
+ if (decision.kind === "stay") return;
+ webviewRef.current?.stopLoading();
+ const again = stoppedRef.current;
+ addBreadcrumb({
+ category: "shell",
+ message: "top-frame load stopped by the navigation policy",
+ data: { host: parseUrl(url)?.host ?? null, again },
+ });
+ if (again) {
+ fail({ kind: "policy" });
+ return;
+ }
+ stoppedRef.current = true;
+ if (decision.open !== null) onOpenExternal(decision.open);
+ dispatch({ type: "recreate" });
+ },
+ [
+ serverUrl,
+ rules,
+ availableScreens,
+ trustedHosts,
+ applyExcursion,
+ fail,
+ onOpenExternal,
+ dispatch,
+ ]
+ );
+
+ // A report from a WebView that is being re-created is not the top
+ // frame's: once the re-create is decided, the old WebView's reports are
+ // dropped.
const onLoadStart = useCallback(
(e: { nativeEvent: { url?: string } }) => {
+ if (documentRef.current !== instance) return;
const url = e.nativeEvent.url ?? "";
- if (url) applyExcursion({ type: "location", url, loaded: false, now: Date.now() });
+ if (url) observeTopFrame(url, false);
},
- [applyExcursion]
+ [instance, observeTopFrame]
);
const onLoadEnd = useCallback(
(e: { nativeEvent: { url?: string; loading: boolean } }) => {
+ if (documentRef.current !== instance) return;
const { loading } = e.nativeEvent;
const url = e.nativeEvent.url ?? "";
- if (url) applyExcursion({ type: "location", url, loaded: !loading, now: Date.now() });
+ if (url) observeTopFrame(url, !loading);
+ // This load sent the WebView back to the server: it is not the Console's.
+ if (documentRef.current !== instance) return;
// `loading` is still true when a load is superseded by a redirect.
// react-native-webview also calls this after `onError`; the load
// state ignores it on a failed load.
@@ -631,17 +697,18 @@ export const ConsoleWebView = forwardRef {
+ if (documentRef.current !== instance) return;
canGoBackRef.current = nav.canGoBack;
// Fallback only: once the Console posts `routeChanged`, it decides.
const route = nav.url ? routeFromConsoleUrl(serverUrl, nav.url) : null;
dispatch({ type: "navigationState", route });
},
- [serverUrl, dispatch]
+ [instance, serverUrl, dispatch]
);
if (failure) {
diff --git a/components/shell/OfflineFallback.tsx b/components/shell/OfflineFallback.tsx
index 1f61b40..6107f49 100644
--- a/components/shell/OfflineFallback.tsx
+++ b/components/shell/OfflineFallback.tsx
@@ -1,14 +1,16 @@
import React from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
-import { CloudOff, ServerCrash } from "lucide-react-native";
+import { CloudOff, ServerCrash, ShieldAlert } from "lucide-react-native";
import { Button } from "~/components/ui/Button";
import { useLayoutDirection } from "~/lib/rtl";
import { useThemeColors } from "~/lib/theme-colors";
export type LoadFailure =
| { kind: "network"; description?: string }
- | { kind: "server"; statusCode?: number };
+ | { kind: "server"; statusCode?: number }
+ /** The server's page keeps going to a page the navigation policy does not load in the WebView. */
+ | { kind: "policy" };
export interface OfflineFallbackProps {
failure: LoadFailure;
@@ -20,7 +22,8 @@ export interface OfflineFallbackProps {
/**
* Native page shown instead of a blank WebView when the Console cannot load —
- * no network, DNS/TLS failure, or a 5xx from the server.
+ * no network, DNS/TLS failure, a 5xx from the server, or a server page that
+ * keeps going to a page the navigation policy does not load in the WebView.
*/
export function OfflineFallback({
failure,
@@ -31,7 +34,8 @@ export function OfflineFallback({
const { t } = useTranslation();
const colors = useThemeColors();
const direction = useLayoutDirection();
- const Icon = failure.kind === "network" ? CloudOff : ServerCrash;
+ const Icon =
+ failure.kind === "network" ? CloudOff : failure.kind === "policy" ? ShieldAlert : ServerCrash;
return (
- {failure.kind === "network" ? t("shell.offlineTitle") : t("shell.serverErrorTitle")}
+ {failure.kind === "network"
+ ? t("shell.offlineTitle")
+ : failure.kind === "policy"
+ ? t("shell.policyTitle")
+ : t("shell.serverErrorTitle")}
{failure.kind === "network"
? t("shell.offlineMessage")
- : t("shell.serverErrorMessage", {
- code: failure.statusCode ?? "—",
- })}
+ : failure.kind === "policy"
+ ? t("shell.policyMessage")
+ : t("shell.serverErrorMessage", {
+ code: failure.statusCode ?? "—",
+ })}
{serverUrl}
diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md
index 074683a..319884d 100644
--- a/docs/HYBRID-SHELL.md
+++ b/docs/HYBRID-SHELL.md
@@ -330,7 +330,8 @@ In the shell:
## Navigation policy
Every navigation the page attempts goes through `decideNavigation`
-(`lib/shell/navigation-policy.ts`):
+(`lib/shell/navigation-policy.ts`). The policy is enforced when a navigation
+is requested and again when a top-frame load starts.
| Target | Decision |
| ----------------------------------------------------------- | ---------------- |
diff --git a/lib/shell/navigation-policy.ts b/lib/shell/navigation-policy.ts
index e4322b0..d263282 100644
--- a/lib/shell/navigation-policy.ts
+++ b/lib/shell/navigation-policy.ts
@@ -10,6 +10,10 @@
* - `mailto:` / `tel:` / `sms:` / app schemes → hand to the OS;
* - `javascript:` / `file:` / top-frame `data:` → block.
*
+ * The policy is enforced when a navigation is requested ({@link decideNavigation})
+ * and again when a top-frame load starts ({@link decideTopFrameLoad}), so every
+ * top-frame load follows it.
+ *
* This is the policy outside a sign-in excursion, and it does not change
* during one. `lib/shell/sign-in-excursion.ts` sits on top of it. An
* off-server https page that this policy sends to the system browser may
@@ -112,3 +116,34 @@ export function decideNavigation(url: string, ctx: NavigationContext): Navigatio
return { kind: "external", url };
}
+
+/**
+ * What the shell does with a top-frame load the WebView reports as started (or
+ * ended), judged on the URL it reports, never on the event alone: on Android
+ * every history change of a page reports a load start too.
+ *
+ * - `stay`: the policy loads `url` in the WebView, or `url` is on the server's
+ * origin. A Console route a native screen owns stays as well: it is the
+ * server's own page, and handing a route to a native screen is the answer to
+ * a navigation request, not to a load.
+ * - `stop`: anything else. The shell stops the load and goes back to the
+ * server; `open` is where the policy sends `url` (the in-app browser or the
+ * OS, as for a request), or `null` when it blocks it.
+ *
+ * A page of an active sign-in excursion is the excursion's to judge
+ * (`sign-in-excursion.ts`), before this.
+ */
+export type TopFrameLoadDecision = { kind: "stay" } | { kind: "stop"; open: string | null };
+
+export function decideTopFrameLoad(url: string, ctx: NavigationContext): TopFrameLoadDecision {
+ const decision = decideNavigation(url, { ...ctx, isTopFrame: true });
+ switch (decision.kind) {
+ case "load":
+ case "native":
+ return { kind: "stay" };
+ case "external":
+ return { kind: "stop", open: decision.url };
+ default:
+ return { kind: "stop", open: null };
+ }
+}
diff --git a/locales/ar.json b/locales/ar.json
index 10091a9..7c21790 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -64,6 +64,8 @@
"offlineMessage": "تحقق من اتصالك. ستُعاد تحميل الصفحة تلقائيًا عند عودة الاتصال.",
"serverErrorTitle": "الخادم لا يستجيب",
"serverErrorMessage": "أجاب الخادم بالخطأ {{code}}. حاول مرة أخرى بعد قليل.",
+ "policyTitle": "تعذّر عرض هذه الصفحة في التطبيق",
+ "policyMessage": "تنتقل صفحة الخادم باستمرار إلى عنوان لا يفتحه التطبيق. حاول مرة أخرى أو بدّل المؤسسة.",
"retry": "حاول مرة أخرى"
},
"excursion": {
diff --git a/locales/de.json b/locales/de.json
index a1b5002..5eb3680 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -64,6 +64,8 @@
"offlineMessage": "Prüfen Sie Ihre Verbindung. Die Seite lädt automatisch neu, sobald Sie wieder online sind.",
"serverErrorTitle": "Der Server antwortet nicht",
"serverErrorMessage": "Der Server hat mit Fehler {{code}} geantwortet. Versuchen Sie es gleich noch einmal.",
+ "policyTitle": "Diese Seite kann in der App nicht angezeigt werden",
+ "policyMessage": "Die Seite des Servers wechselt immer wieder zu einer Adresse, die die App nicht öffnet. Versuchen Sie es erneut oder wechseln Sie die Organisation.",
"retry": "Erneut versuchen"
},
"excursion": {
diff --git a/locales/en.json b/locales/en.json
index 9814522..1ac3051 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -64,6 +64,8 @@
"offlineMessage": "Check your connection. The page reloads on its own when you're back online.",
"serverErrorTitle": "The server isn't responding",
"serverErrorMessage": "The server answered with error {{code}}. Try again in a moment.",
+ "policyTitle": "Can't show this page in the app",
+ "policyMessage": "The server's page keeps going to an address the app doesn't open. Try again, or switch organization.",
"retry": "Try again"
},
"excursion": {
diff --git a/locales/es.json b/locales/es.json
index 03fddbb..44d12a5 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -64,6 +64,8 @@
"offlineMessage": "Compruebe su conexión. La página se recargará sola cuando vuelva a estar en línea.",
"serverErrorTitle": "El servidor no responde",
"serverErrorMessage": "El servidor respondió con el error {{code}}. Inténtelo de nuevo en un momento.",
+ "policyTitle": "No se puede mostrar esta página en la app",
+ "policyMessage": "La página del servidor sigue yendo a una dirección que la app no abre. Inténtelo de nuevo o cambie de organización.",
"retry": "Intentar de nuevo"
},
"excursion": {
diff --git a/locales/fr.json b/locales/fr.json
index 98679b7..d8f293e 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -64,6 +64,8 @@
"offlineMessage": "Vérifiez votre connexion. La page se recharge d'elle-même dès que vous êtes de nouveau en ligne.",
"serverErrorTitle": "Le serveur ne répond pas",
"serverErrorMessage": "Le serveur a répondu avec l'erreur {{code}}. Réessayez dans un instant.",
+ "policyTitle": "Impossible d'afficher cette page dans l'application",
+ "policyMessage": "La page du serveur continue d'aller vers une adresse que l'application n'ouvre pas. Réessayez ou changez d'organisation.",
"retry": "Réessayer"
},
"excursion": {
diff --git a/locales/ja.json b/locales/ja.json
index 393fa24..45c9f03 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -64,6 +64,8 @@
"offlineMessage": "接続を確認してください。オンラインに戻るとページが自動的に再読み込みされます。",
"serverErrorTitle": "サーバーが応答しません",
"serverErrorMessage": "サーバーがエラー {{code}} を返しました。しばらくしてからもう一度お試しください。",
+ "policyTitle": "このページはアプリで表示できません",
+ "policyMessage": "サーバーのページが、アプリで開かないアドレスへ移動し続けています。もう一度お試しいただくか、組織を切り替えてください。",
"retry": "再試行"
},
"excursion": {
diff --git a/locales/ko.json b/locales/ko.json
index bb8cc2c..6f811a5 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -64,6 +64,8 @@
"offlineMessage": "연결을 확인하세요. 다시 온라인 상태가 되면 페이지가 자동으로 새로고침됩니다.",
"serverErrorTitle": "서버가 응답하지 않습니다",
"serverErrorMessage": "서버에서 오류 {{code}}을(를) 반환했습니다. 잠시 후 다시 시도하세요.",
+ "policyTitle": "이 페이지는 앱에서 표시할 수 없습니다",
+ "policyMessage": "서버 페이지가 앱에서 열지 않는 주소로 계속 이동합니다. 다시 시도하거나 조직을 전환하세요.",
"retry": "다시 시도"
},
"excursion": {
diff --git a/locales/pt.json b/locales/pt.json
index 8586db1..da771d5 100644
--- a/locales/pt.json
+++ b/locales/pt.json
@@ -64,6 +64,8 @@
"offlineMessage": "Verifique sua conexão. A página recarrega sozinha quando você voltar a ficar online.",
"serverErrorTitle": "O servidor não está respondendo",
"serverErrorMessage": "O servidor respondeu com o erro {{code}}. Tente novamente em instantes.",
+ "policyTitle": "Não é possível mostrar esta página no app",
+ "policyMessage": "A página do servidor continua indo para um endereço que o app não abre. Tente novamente ou troque de organização.",
"retry": "Tentar novamente"
},
"excursion": {
diff --git a/locales/ru.json b/locales/ru.json
index a05eb3e..c894ed5 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -64,6 +64,8 @@
"offlineMessage": "Проверьте подключение. Страница перезагрузится сама, когда сеть появится.",
"serverErrorTitle": "Сервер не отвечает",
"serverErrorMessage": "Сервер вернул ошибку {{code}}. Попробуйте ещё раз чуть позже.",
+ "policyTitle": "Эту страницу нельзя показать в приложении",
+ "policyMessage": "Страница сервера раз за разом переходит на адрес, который приложение не открывает. Попробуйте снова или смените организацию.",
"retry": "Попробовать снова"
},
"excursion": {
diff --git a/locales/zh.json b/locales/zh.json
index 76bc25d..c0a8862 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -64,6 +64,8 @@
"offlineMessage": "请检查网络连接。恢复联网后页面会自动重新加载。",
"serverErrorTitle": "服务器无响应",
"serverErrorMessage": "服务器返回错误 {{code}},请稍后重试。",
+ "policyTitle": "无法在应用内显示此页面",
+ "policyMessage": "服务器的页面一直跳转到应用不打开的地址。请重试,或切换组织。",
"retry": "重试"
},
"excursion": {