akanjs 2.3.9 → 2.3.10-rc.1

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.
@@ -85,6 +85,7 @@ const localCsrUrl = (ip: string, target: AkanMobileTargetConfig, appInfo: AppSca
85
85
  const port = resolveLocalCsrPort(appInfo);
86
86
  const params = new URLSearchParams({ csr: "true", akanMobileTarget: target.name });
87
87
  if (basePath) params.set("akanMobileBasePath", basePath);
88
+ if (target.indexPath) params.set("akanMobileIndexPath", target.indexPath);
88
89
  return `http://${ip}:${port}/${pathname}?${params}`;
89
90
  };
90
91
 
@@ -97,8 +98,20 @@ export const withBase = (
97
98
  const appInfo = appData;
98
99
  if (!appInfo) throw new Error("withBase requires apps/<app>/akan.app.json metadata.");
99
100
  const target = resolveTarget(appInfo, targetName);
101
+ const {
102
+ name: _name,
103
+ basePath: _basePath,
104
+ indexPath: _indexPath,
105
+ version: _version,
106
+ buildNum: _buildNum,
107
+ assets: _assets,
108
+ permissions: _permissions,
109
+ deepLinks: _deepLinks,
110
+ files: _files,
111
+ ...capacitorTarget
112
+ } = target;
100
113
  const baseConfig: CapacitorConfig = {
101
- ...target,
114
+ ...capacitorTarget,
102
115
  appId: target.appId,
103
116
  appName: target.appName,
104
117
  webDir: "dist",
@@ -16,6 +16,7 @@ export type CapacitorAppModule = {
16
16
  addListener: (eventName: string, listenerFunc: (...args: unknown[]) => void) => Promise<unknown> | unknown;
17
17
  removeAllListeners: () => Promise<void> | void;
18
18
  exitApp?: () => Promise<void> | void;
19
+ getLaunchUrl?: () => Promise<{ url?: string | null }>;
19
20
  getInfo: () => Promise<{ id: string; version: string; build: string; [key: string]: unknown }>;
20
21
  };
21
22
  };
package/client/router.ts CHANGED
@@ -4,6 +4,14 @@ import { getBasePathFromPathname, Logger, parseAkanI18nEnv, parseBasePaths } fro
4
4
  export interface RouteOptions {
5
5
  scrollToTop?: boolean;
6
6
  }
7
+ export interface RouteManifest {
8
+ routes: string[];
9
+ }
10
+ export interface DeepLinkOptions extends RouteOptions {
11
+ resetStack?: boolean;
12
+ }
13
+
14
+ const DEEP_LINK_STACK_STEP_DELAY = 450;
7
15
 
8
16
  export interface RouterInstance {
9
17
  push: (href: string, routeOptions?: RouteOptions) => void;
@@ -20,6 +28,8 @@ interface InternalRouterInstance {
20
28
  interface RouterOptions {
21
29
  prefix?: string;
22
30
  lang?: string;
31
+ routeManifest?: RouteManifest | string[];
32
+ indexPath?: string;
23
33
  }
24
34
  interface SsrServerRouterOption extends RouterOptions {
25
35
  type: "ssr";
@@ -72,13 +82,44 @@ function getServerRequestContext() {
72
82
  const getConfiguredBasePaths = () => new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
73
83
 
74
84
  const shouldExposeBasePath = () => getEnv().operationMode === "local";
75
- const CSR_RUNTIME_SEARCH_PARAMS = ["csr", "akanMobileTarget", "akanMobileBasePath"] as const;
85
+ const CSR_RUNTIME_SEARCH_PARAMS = ["csr", "akanMobileTarget", "akanMobileBasePath", "akanMobileIndexPath"] as const;
76
86
 
77
87
  const getLocaleFromPathname = (pathname: string) => {
78
88
  const [firstSegment] = pathname.split("/").filter(Boolean);
79
89
  return parseAkanI18nEnv().locales.find((locale) => locale === firstSegment);
80
90
  };
81
91
 
92
+ const normalizeRoutePath = (path: string | undefined) => {
93
+ const normalized = path?.trim();
94
+ if (!normalized) return undefined;
95
+ const [pathWithoutHash, hash = ""] = normalized.split("#");
96
+ const [pathname, search = ""] = pathWithoutHash.split("?");
97
+ const routePath = `/${pathname.replace(/^\/+|\/+$/g, "")}`;
98
+ return `${routePath === "/" ? "/" : routePath}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
99
+ };
100
+
101
+ const splitHref = (href: string) => {
102
+ const [hrefWithoutHash, hash = ""] = href.split("#");
103
+ const [path, search = ""] = hrefWithoutHash.split("?");
104
+ return { path: normalizeRoutePath(path) ?? "/", search, hash };
105
+ };
106
+
107
+ const normalizeRouteManifestPath = (href: string, prefix = "") => {
108
+ const { path } = splitHref(href);
109
+ const segments = path.split("/").filter(Boolean);
110
+ if (segments[0] === ":lang") segments.shift();
111
+ if (prefix && segments[0] === prefix) segments.shift();
112
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
113
+ };
114
+
115
+ export const normalizeDeepLinkHref = (href: string, origin = globalThis.window?.location?.origin ?? "http://localhost") => {
116
+ const url = new URL(href, origin);
117
+ if (url.protocol === "http:" || url.protocol === "https:") return `${url.pathname}${url.search}${url.hash}`;
118
+ const hostPath = url.hostname ? `/${url.hostname}` : "";
119
+ const pathname = `${hostPath}${url.pathname === "/" ? "" : url.pathname}`;
120
+ return `${normalizeRoutePath(pathname) ?? "/"}${url.search}${url.hash}`;
121
+ };
122
+
82
123
  const getServerBasePath = (reqPathname: string, lang: string, headerBasePath: string | undefined, fallback: string) => {
83
124
  return (
84
125
  getBasePathFromPathname(reqPathname, {
@@ -117,6 +158,9 @@ class Router {
117
158
  isInitialized = false;
118
159
  #prefix = "";
119
160
  #lang = parseAkanI18nEnv().defaultLocale;
161
+ #routePaths = new Set<string>();
162
+ #indexPath = "/";
163
+ #historyIdx = 0;
120
164
  #instance: InternalRouterInstance = {
121
165
  push: (href: string) => {
122
166
  const { href: fullHref } = this.#getPathInfo(href);
@@ -137,6 +181,17 @@ class Router {
137
181
 
138
182
  this.#prefix = options.prefix ?? "";
139
183
  this.#lang = options.lang ?? parseAkanI18nEnv().defaultLocale;
184
+ this.#routePaths = new Set(
185
+ (Array.isArray(options.routeManifest) ? options.routeManifest : (options.routeManifest?.routes ?? []))
186
+ .map((path) => normalizeRouteManifestPath(path, this.#prefix))
187
+ .filter(Boolean),
188
+ );
189
+ this.#indexPath = splitHref(options.indexPath ?? "/").path;
190
+ if (this.#routePaths.size > 0 && !this.#routePaths.has(this.#indexPath)) {
191
+ Logger.log(`[router] indexPath '${this.#indexPath}' was not found in route manifest. Falling back to '/'.`);
192
+ this.#indexPath = "/";
193
+ }
194
+ this.#ensureHistoryState();
140
195
  if (options.type === "csr") this.#initCsrClientRouter(options);
141
196
  else if (options.side === "server") this.#initSsrServerRouter(options);
142
197
  else this.#initSsrClientRouter(options);
@@ -206,6 +261,31 @@ class Router {
206
261
  if (!this.isInitialized) throw new Error("Router is not initialized");
207
262
  }
208
263
 
264
+ #ensureHistoryState() {
265
+ if (getEnv().side === "server") return;
266
+ const history = globalThis.window?.history;
267
+ if (!history?.replaceState) return;
268
+ const state = (history.state ?? {}) as Record<string, unknown>;
269
+ const akanState = (state.__akanRouter ?? {}) as { idx?: number };
270
+ this.#historyIdx = Number.isInteger(akanState.idx) ? (akanState.idx as number) : this.#historyIdx;
271
+ history.replaceState({ ...state, __akanRouter: { ...akanState, idx: this.#historyIdx } }, "", globalThis.window.location.href);
272
+ if (!globalThis.window.addEventListener) return;
273
+ globalThis.window.addEventListener("popstate", (event) => {
274
+ const nextState = ((event.state as Record<string, unknown> | null)?.__akanRouter ?? {}) as { idx?: number };
275
+ if (Number.isInteger(nextState.idx)) this.#historyIdx = nextState.idx as number;
276
+ });
277
+ }
278
+
279
+ #rememberHistoryState(kind: "push" | "replace") {
280
+ if (getEnv().side === "server") return;
281
+ const history = globalThis.window?.history;
282
+ if (!history) return;
283
+ if (kind === "push") this.#historyIdx += 1;
284
+ const state = (history.state ?? {}) as Record<string, unknown>;
285
+ const nextState = { ...state, __akanRouter: { ...((state.__akanRouter ?? {}) as object), idx: this.#historyIdx } };
286
+ if (history.replaceState) history.replaceState(nextState, "", globalThis.window.location.href);
287
+ }
288
+
209
289
  #getPathInfo(href: string, prefix = this.#prefix) {
210
290
  return getPathInfo(href, this.#lang, prefix);
211
291
  }
@@ -239,18 +319,69 @@ class Router {
239
319
  const { path, search, hash } = this.#getPathInfo(href);
240
320
  globalThis.__AKAN_DEV_SYNC_NAVIGATION__?.(`${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`, kind);
241
321
  }
322
+ #getExistingSegmentStack(path: string) {
323
+ const segments = path.split("/").filter(Boolean);
324
+ if (segments.length === 0) return this.#routePaths.has("/") ? ["/"] : [];
325
+ return segments
326
+ .map((_, index) => `/${segments.slice(0, index + 1).join("/")}`)
327
+ .filter((candidate) => this.#routePaths.has(candidate));
328
+ }
329
+ resolveDeepLinkStack(href: string) {
330
+ this.#checkInitialized();
331
+ const normalizedHref = normalizeDeepLinkHref(href);
332
+ const { path, search, hash } = splitHref(normalizedHref);
333
+ if (this.#routePaths.size > 0 && !this.#routePaths.has(path)) {
334
+ Logger.log(`[router] deep link target '${path}' was not found in route manifest.`);
335
+ return [];
336
+ }
337
+ const stack = this.#routePaths.size > 0 ? this.#getExistingSegmentStack(path) : [path];
338
+ const baseStack =
339
+ stack.length > 1 || this.#indexPath === path || stack.includes(this.#indexPath) ? stack : [this.#indexPath, ...stack];
340
+ const dedupedStack = baseStack.filter((candidate, index) => baseStack.indexOf(candidate) === index);
341
+ const target = `${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
342
+ return dedupedStack.map((candidate, index) => (index === dedupedStack.length - 1 ? target : candidate));
343
+ }
344
+ enterDeepLink(href: string, options: DeepLinkOptions = {}) {
345
+ this.#checkInitialized();
346
+ if (!options.resetStack) {
347
+ this.push(normalizeDeepLinkHref(href), options);
348
+ return true;
349
+ }
350
+ const stack = this.resolveDeepLinkStack(href);
351
+ if (stack.length === 0) return false;
352
+ const { resetStack: _resetStack, ...routeOptions } = options;
353
+ this.replace(stack[0], routeOptions);
354
+ stack.slice(1).forEach((target, index) => {
355
+ setTimeout(() => this.push(target, routeOptions), DEEP_LINK_STACK_STEP_DELAY * (index + 1));
356
+ });
357
+ return true;
358
+ }
242
359
  push(href: string, routeOptions?: RouteOptions) {
243
360
  this.#checkInitialized();
244
361
  this.#instance.push(href, routeOptions);
362
+ this.#rememberHistoryState("push");
245
363
  this.#postDevSyncNavigation("push", href);
246
364
  return undefined as never;
247
365
  }
248
366
  replace(href: string, routeOptions?: RouteOptions) {
249
367
  this.#checkInitialized();
250
368
  this.#instance.replace(href, routeOptions);
369
+ this.#rememberHistoryState("replace");
251
370
  this.#postDevSyncNavigation("replace", href);
252
371
  return undefined as never;
253
372
  }
373
+ canGoBack() {
374
+ if (getEnv().side === "server") return false;
375
+ return this.#historyIdx > 0;
376
+ }
377
+ backOrFallback(fallbackHref = this.#indexPath, routeOptions?: RouteOptions) {
378
+ if (this.canGoBack()) {
379
+ this.back(routeOptions);
380
+ return undefined as never;
381
+ }
382
+ this.replace(fallbackHref, routeOptions);
383
+ return undefined as never;
384
+ }
254
385
  back(routeOptions?: RouteOptions) {
255
386
  if (getEnv().side === "server") throw new Error("back is only available in client side");
256
387
 
package/index.ts CHANGED
@@ -33,10 +33,15 @@ export interface AkanMobileTargetAssets {
33
33
  splash?: string;
34
34
  }
35
35
 
36
- export interface AkanMobileTargetLinks {
36
+ export interface AkanMobileTargetDeepLinks {
37
37
  schemes?: string[];
38
- associatedDomains?: string[];
39
- androidHosts?: string[];
38
+ domains?: string[];
39
+ ios?: {
40
+ teamId?: string;
41
+ };
42
+ android?: {
43
+ sha256CertFingerprints?: string[];
44
+ };
40
45
  }
41
46
 
42
47
  export interface AkanMobileTargetFiles {
@@ -54,13 +59,14 @@ export interface AkanCapacitorLikeConfig {
54
59
  export interface AkanMobileTargetConfig extends AkanCapacitorLikeConfig {
55
60
  name: string;
56
61
  basePath?: string;
62
+ indexPath?: string;
57
63
  appName: string;
58
64
  appId: string;
59
65
  version: string;
60
66
  buildNum: number;
61
67
  assets?: AkanMobileTargetAssets;
62
68
  permissions?: MobilePermission[];
63
- links?: AkanMobileTargetLinks;
69
+ deepLinks?: AkanMobileTargetDeepLinks;
64
70
  files?: AkanMobileTargetFiles;
65
71
  }
66
72
 
@@ -83,6 +89,7 @@ export interface AppConfigResult {
83
89
  i18n: AkanI18nConfig;
84
90
  publicEnv: string[];
85
91
  mobile: AkanMobileConfig;
92
+ secrets: string[];
86
93
  }
87
94
 
88
95
  export interface LibConfigResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.3.9",
3
+ "version": "2.3.10-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -49,7 +49,7 @@ export class LocaleWebProxy implements WebProxy {
49
49
  (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
50
50
  );
51
51
 
52
- if (!isInternalProxyRequest(requestUrl) && pathnameIsMissingLocale) {
52
+ if (!isInternalProxyRequest(requestUrl) && !isWellKnownRequest(pathname) && pathnameIsMissingLocale) {
53
53
  return Response.redirect(
54
54
  new URL(`/${getLocale(request)}/${pathname.slice(1)}${targetUrl.search}`, getPublicRequestUrl(request)),
55
55
  307,
@@ -86,3 +86,7 @@ function getPublicRequestUrl(request: Bun.BunRequest): URL {
86
86
  function isInternalProxyRequest(requestUrl: URL): boolean {
87
87
  return requestUrl.pathname === "/__rsc";
88
88
  }
89
+
90
+ function isWellKnownRequest(pathname: string): boolean {
91
+ return pathname === "/.well-known" || pathname.startsWith("/.well-known/");
92
+ }
package/server/types.tsx CHANGED
@@ -99,8 +99,17 @@ export type BaseBuildArtifact = {
99
99
  branches: string[];
100
100
  i18n: AkanI18nConfig;
101
101
  imageConfig: AkanImageConfig;
102
+ deepLinkAssociations?: MobileDeepLinkAssociation[];
102
103
  };
103
104
 
105
+ export interface MobileDeepLinkAssociation {
106
+ targetName: string;
107
+ appId: string;
108
+ domains: string[];
109
+ iosTeamId?: string;
110
+ androidSha256CertFingerprints?: string[];
111
+ }
112
+
104
113
  export interface CssAsset {
105
114
  cssUrl: string;
106
115
  cssRelPath: string;
@@ -56,6 +56,8 @@ import type { BaseBuildArtifact, HttpRoutes, RenderState } from "./types";
56
56
  const RESERVED_BASE_PATHS = new Set(["admin"]);
57
57
  const CLIENT_CLOSED_REQUEST_STATUS = 499;
58
58
  export const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES = 2 * 1024 * 1024;
59
+ const APPLE_APP_SITE_ASSOCIATION_PATH = "/.well-known/apple-app-site-association";
60
+ const ANDROID_ASSET_LINKS_PATH = "/.well-known/assetlinks.json";
59
61
 
60
62
  export function createRscRedirectResponse(
61
63
  location: string,
@@ -459,6 +461,14 @@ export class WebRouter {
459
461
  new Response(JSON.stringify(this.#routeCache.merged.clientManifest, null, 2), {
460
462
  headers: { "Content-Type": "application/json; charset=utf-8" },
461
463
  }),
464
+ [APPLE_APP_SITE_ASSOCIATION_PATH]: () =>
465
+ WebRouter.#deepLinkAssociationResponse(APPLE_APP_SITE_ASSOCIATION_PATH, this.#artifact, {
466
+ cacheControl: this.#prodMode ? "public, max-age=3600" : "no-store",
467
+ }) ?? new Response("Not Found", { status: 404 }),
468
+ [ANDROID_ASSET_LINKS_PATH]: () =>
469
+ WebRouter.#deepLinkAssociationResponse(ANDROID_ASSET_LINKS_PATH, this.#artifact, {
470
+ cacheControl: this.#prodMode ? "public, max-age=3600" : "no-store",
471
+ }) ?? new Response("Not Found", { status: 404 }),
462
472
  "/*": async (req) => {
463
473
  const url = new URL(req.url);
464
474
  if (WebRouter.#isImageOptimizerPath(url.pathname)) {
@@ -1046,6 +1056,49 @@ export class WebRouter {
1046
1056
  return new Response(file.stream(), { headers });
1047
1057
  }
1048
1058
 
1059
+ static #deepLinkAssociationResponse(
1060
+ pathname: string,
1061
+ artifact: BaseBuildArtifact,
1062
+ { cacheControl }: { cacheControl: string },
1063
+ ) {
1064
+ if (pathname !== APPLE_APP_SITE_ASSOCIATION_PATH && pathname !== ANDROID_ASSET_LINKS_PATH) return null;
1065
+ const associations = artifact.deepLinkAssociations ?? [];
1066
+ if (pathname === APPLE_APP_SITE_ASSOCIATION_PATH) {
1067
+ const details = associations
1068
+ .filter((association) => association.domains.length > 0 && association.iosTeamId)
1069
+ .map((association) => ({
1070
+ appIDs: [`${association.iosTeamId}.${association.appId}`],
1071
+ components: [{ "/": "/*" }],
1072
+ }));
1073
+ if (details.length === 0) return null;
1074
+ return WebRouter.#jsonResponse({ applinks: { apps: [], details } }, cacheControl);
1075
+ }
1076
+ const assetLinks = associations
1077
+ .filter(
1078
+ (association) =>
1079
+ association.domains.length > 0 && (association.androidSha256CertFingerprints?.length ?? 0) > 0,
1080
+ )
1081
+ .map((association) => ({
1082
+ relation: ["delegate_permission/common.handle_all_urls"],
1083
+ target: {
1084
+ namespace: "android_app",
1085
+ package_name: association.appId,
1086
+ sha256_cert_fingerprints: association.androidSha256CertFingerprints,
1087
+ },
1088
+ }));
1089
+ if (assetLinks.length === 0) return null;
1090
+ return WebRouter.#jsonResponse(assetLinks, cacheControl);
1091
+ }
1092
+
1093
+ static #jsonResponse(body: unknown, cacheControl: string) {
1094
+ return new Response(JSON.stringify(body, null, 2), {
1095
+ headers: {
1096
+ "Content-Type": "application/json; charset=utf-8",
1097
+ "Cache-Control": cacheControl,
1098
+ },
1099
+ });
1100
+ }
1101
+
1049
1102
  static #bytesResponse(
1050
1103
  _req: Request,
1051
1104
  bytes: Uint8Array,
@@ -13,6 +13,9 @@ export type CapacitorAppModule = {
13
13
  addListener: (eventName: string, listenerFunc: (...args: unknown[]) => void) => Promise<unknown> | unknown;
14
14
  removeAllListeners: () => Promise<void> | void;
15
15
  exitApp?: () => Promise<void> | void;
16
+ getLaunchUrl?: () => Promise<{
17
+ url?: string | null;
18
+ }>;
16
19
  getInfo: () => Promise<{
17
20
  id: string;
18
21
  version: string;
@@ -1,6 +1,12 @@
1
1
  export interface RouteOptions {
2
2
  scrollToTop?: boolean;
3
3
  }
4
+ export interface RouteManifest {
5
+ routes: string[];
6
+ }
7
+ export interface DeepLinkOptions extends RouteOptions {
8
+ resetStack?: boolean;
9
+ }
4
10
  export interface RouterInstance {
5
11
  push: (href: string, routeOptions?: RouteOptions) => void;
6
12
  replace: (href: string, routeOptions?: RouteOptions) => void;
@@ -10,6 +16,8 @@ export interface RouterInstance {
10
16
  interface RouterOptions {
11
17
  prefix?: string;
12
18
  lang?: string;
19
+ routeManifest?: RouteManifest | string[];
20
+ indexPath?: string;
13
21
  }
14
22
  interface SsrServerRouterOption extends RouterOptions {
15
23
  type: "ssr";
@@ -41,6 +49,7 @@ export declare class AkanNotFoundError extends Error {
41
49
  readonly digest = "AKAN_NOT_FOUND";
42
50
  constructor();
43
51
  }
52
+ export declare const normalizeDeepLinkHref: (href: string, origin?: string) => string;
44
53
  declare global {
45
54
  var __AKAN_ROUTER__: Router | undefined;
46
55
  var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
@@ -57,8 +66,12 @@ declare class Router {
57
66
  #private;
58
67
  isInitialized: boolean;
59
68
  init(options: SsrClientRouterOption | SsrServerRouterOption | CSRClientRouterOption): void;
69
+ resolveDeepLinkStack(href: string): string[];
70
+ enterDeepLink(href: string, options?: DeepLinkOptions): boolean;
60
71
  push(href: string, routeOptions?: RouteOptions): never;
61
72
  replace(href: string, routeOptions?: RouteOptions): never;
73
+ canGoBack(): boolean;
74
+ backOrFallback(fallbackHref?: string, routeOptions?: RouteOptions): never;
62
75
  back(routeOptions?: RouteOptions): never;
63
76
  refresh(): never;
64
77
  redirect(href: string, options?: RedirectOptions): never;
package/types/index.d.ts CHANGED
@@ -32,10 +32,15 @@ export interface AkanMobileTargetAssets {
32
32
  icon?: string;
33
33
  splash?: string;
34
34
  }
35
- export interface AkanMobileTargetLinks {
35
+ export interface AkanMobileTargetDeepLinks {
36
36
  schemes?: string[];
37
- associatedDomains?: string[];
38
- androidHosts?: string[];
37
+ domains?: string[];
38
+ ios?: {
39
+ teamId?: string;
40
+ };
41
+ android?: {
42
+ sha256CertFingerprints?: string[];
43
+ };
39
44
  }
40
45
  export interface AkanMobileTargetFiles {
41
46
  ios?: Record<string, string>;
@@ -50,13 +55,14 @@ export interface AkanCapacitorLikeConfig {
50
55
  export interface AkanMobileTargetConfig extends AkanCapacitorLikeConfig {
51
56
  name: string;
52
57
  basePath?: string;
58
+ indexPath?: string;
53
59
  appName: string;
54
60
  appId: string;
55
61
  version: string;
56
62
  buildNum: number;
57
63
  assets?: AkanMobileTargetAssets;
58
64
  permissions?: MobilePermission[];
59
- links?: AkanMobileTargetLinks;
65
+ deepLinks?: AkanMobileTargetDeepLinks;
60
66
  files?: AkanMobileTargetFiles;
61
67
  }
62
68
  export interface AkanMobileConfig extends AkanCapacitorLikeConfig {
@@ -77,6 +83,7 @@ export interface AppConfigResult {
77
83
  i18n: AkanI18nConfig;
78
84
  publicEnv: string[];
79
85
  mobile: AkanMobileConfig;
86
+ secrets: string[];
80
87
  }
81
88
  export interface LibConfigResult {
82
89
  externalLibs: string[];
@@ -59,7 +59,15 @@ export type BaseBuildArtifact = {
59
59
  branches: string[];
60
60
  i18n: AkanI18nConfig;
61
61
  imageConfig: AkanImageConfig;
62
+ deepLinkAssociations?: MobileDeepLinkAssociation[];
62
63
  };
64
+ export interface MobileDeepLinkAssociation {
65
+ targetName: string;
66
+ appId: string;
67
+ domains: string[];
68
+ iosTeamId?: string;
69
+ androidSha256CertFingerprints?: string[];
70
+ }
63
71
  export interface CssAsset {
64
72
  cssUrl: string;
65
73
  cssRelPath: string;
@@ -3,6 +3,9 @@ import { type Location, type PageState, type TransitionStyle } from "akanjs/clie
3
3
  import type { AkanTheme } from "akanjs/fetch";
4
4
  import type { SerializedSignal } from "akanjs/signal";
5
5
  import { type HTMLAttributes, type ReactNode, type RefObject } from "react";
6
+ declare global {
7
+ var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
8
+ }
6
9
  export declare const Client: {
7
10
  (): import("react/jsx-runtime").JSX.Element;
8
11
  Wrapper: ({ children, theme, lang, path, dictionary, signals, reconnect, }: ClientWrapperProps) => import("react/jsx-runtime").JSX.Element;
@@ -9,6 +9,7 @@ declare global {
9
9
  __AKAN_MOBILE_TARGET__?: {
10
10
  name: string;
11
11
  basePath?: string;
12
+ indexPath?: string;
12
13
  };
13
14
  }
14
15
  }
package/ui/System/CSR.tsx CHANGED
@@ -133,7 +133,15 @@ const CSRWrapper = ({
133
133
  document.documentElement.classList.add("akan-mobile-document");
134
134
  document.body.classList.add("akan-mobile-document");
135
135
  }
136
- if (!router.isInitialized) router.init({ type: "csr", lang, prefix, router: reactRouter });
136
+ if (!router.isInitialized)
137
+ router.init({
138
+ type: "csr",
139
+ lang,
140
+ prefix,
141
+ router: reactRouter,
142
+ routeManifest: pathRoutes.map((pathRoute) => pathRoute.path),
143
+ indexPath: window.__AKAN_MOBILE_TARGET__?.indexPath,
144
+ });
137
145
  st.do.setCsrLoaded(true);
138
146
  const onVisibilityChange = () => debugFrame("document.visibility", { visibilityState: document.visibilityState });
139
147
  const onPageHide = (event: PageTransitionEvent) => debugFrame("window.pagehide", { persisted: event.persisted });
@@ -42,6 +42,10 @@ import { Messages } from "./Messages";
42
42
  import { Reconnect } from "./Reconnect";
43
43
  import { getFrameCssVars } from "./frameCssVars";
44
44
 
45
+ declare global {
46
+ var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
47
+ }
48
+
45
49
  export const Client = () => {
46
50
  return <></>;
47
51
  };
@@ -35,7 +35,7 @@ type CsrRouteModuleEntry = CsrRouteModuleLoader | { loader: CsrRouteModuleLoader
35
35
 
36
36
  declare global {
37
37
  interface Window {
38
- __AKAN_MOBILE_TARGET__?: { name: string; basePath?: string };
38
+ __AKAN_MOBILE_TARGET__?: { name: string; basePath?: string; indexPath?: string };
39
39
  }
40
40
  }
41
41
 
@@ -274,7 +274,8 @@ function initializeMobileTargetFromSearch() {
274
274
  if (!name) return;
275
275
 
276
276
  const basePath = params.get("akanMobileBasePath")?.replace(/^\/+|\/+$/g, "") ?? "";
277
- window.__AKAN_MOBILE_TARGET__ = { name, basePath };
277
+ const indexPath = params.get("akanMobileIndexPath") ?? undefined;
278
+ window.__AKAN_MOBILE_TARGET__ = { name, basePath, ...(indexPath ? { indexPath } : {}) };
278
279
  }
279
280
 
280
281
  function validateRouteModuleExports(key: string, mod: RouteModule) {
@@ -10,6 +10,7 @@ import {
10
10
  getPathInfo,
11
11
  type LocationState,
12
12
  type NavigationIntent,
13
+ normalizeDeepLinkHref,
13
14
  type PageState,
14
15
  type PathRoute,
15
16
  type RouteGuide,
@@ -61,7 +62,7 @@ const STACK_VELOCITY_DISMISS_THRESHOLD = 0.45;
61
62
  const STACK_SETTLE_MIN_DURATION = 90;
62
63
  const STACK_SETTLE_MAX_DURATION = 260;
63
64
  const ANDROID_SCALE_TRANSITION_DURATION = 220;
64
- const CSR_RUNTIME_SEARCH_PARAMS = ["csr", "akanMobileTarget", "akanMobileBasePath"] as const;
65
+ const CSR_RUNTIME_SEARCH_PARAMS = ["csr", "akanMobileTarget", "akanMobileBasePath", "akanMobileIndexPath"] as const;
65
66
 
66
67
  const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
67
68
 
@@ -1240,6 +1241,8 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1240
1241
  keyboardVisible: keyboardFrame.visible,
1241
1242
  router,
1242
1243
  });
1244
+ const handledDeepLinkRef = useRef<{ href: string; handledAt: number; resetStack: boolean } | null>(null);
1245
+ const didResetDeepLinkStackRef = useRef(false);
1243
1246
 
1244
1247
  useEffect(() => {
1245
1248
  if (pageContentRef.current) pageContentRef.current.scrollTop = getScrollTop(location);
@@ -1256,6 +1259,74 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1256
1259
  };
1257
1260
  }, [keyboardFrame.height, keyboardFrame.visible, resolvedLocation.pathRoute.path, router]);
1258
1261
 
1262
+ useEffect(() => {
1263
+ const isMobileTarget = Boolean(window.__AKAN_MOBILE_TARGET__);
1264
+ if (Device.getDevice().info.platform === "web" && !isMobileTarget) return;
1265
+ let removeListener: (() => void) | undefined;
1266
+ let disposed = false;
1267
+ const mountedAt = Date.now();
1268
+
1269
+ const enterDeepLinkWhenReady = (href: string, resetStack: boolean, attempt = 0) => {
1270
+ if (!clientRouter.isInitialized) {
1271
+ if (attempt < 40) window.setTimeout(() => enterDeepLinkWhenReady(href, resetStack, attempt + 1), 50);
1272
+ else debugFrame("native.deepLink.skipped", { href, reason: "router-not-ready" });
1273
+ return;
1274
+ }
1275
+ clientRouter.enterDeepLink(href, { resetStack, scrollToTop: true });
1276
+ };
1277
+
1278
+ const handleDeepLink = (url: string | null | undefined, resetStack: boolean) => {
1279
+ if (!url) return;
1280
+ const href = normalizeDeepLinkHref(url);
1281
+ const now = Date.now();
1282
+ const lastHandled = handledDeepLinkRef.current;
1283
+ const shouldResetStack = resetStack || (!lastHandled && now - mountedAt < 5000);
1284
+ if (lastHandled?.href === href && now - lastHandled.handledAt < 1000 && (!shouldResetStack || lastHandled.resetStack))
1285
+ return;
1286
+ handledDeepLinkRef.current = { href, handledAt: now, resetStack: shouldResetStack };
1287
+ debugFrame("native.deepLink", {
1288
+ href,
1289
+ resetStack: shouldResetStack,
1290
+ sourceResetStack: resetStack,
1291
+ historyIdx: history.current.idx,
1292
+ mountedForMs: now - mountedAt,
1293
+ routerReady: clientRouter.isInitialized,
1294
+ });
1295
+ if (shouldResetStack) didResetDeepLinkStackRef.current = true;
1296
+ enterDeepLinkWhenReady(href, shouldResetStack);
1297
+ };
1298
+
1299
+ void loadCapacitorApp()
1300
+ .then(({ App }) => {
1301
+ debugFrame("native.deepLink.listener", { platform: Device.getDevice().info.platform, isMobileTarget });
1302
+ const listener = App.addListener("appUrlOpen", (event: unknown) => {
1303
+ handleDeepLink((event as { url?: string | null } | undefined)?.url, false);
1304
+ });
1305
+
1306
+ void Promise.resolve(listener).then((handle) => {
1307
+ const remove =
1308
+ typeof (handle as { remove?: unknown } | undefined)?.remove === "function"
1309
+ ? () => void (handle as { remove: () => Promise<void> | void }).remove()
1310
+ : undefined;
1311
+ if (disposed) remove?.();
1312
+ else removeListener = remove;
1313
+ });
1314
+
1315
+ void App.getLaunchUrl?.()
1316
+ .then((launch) => {
1317
+ debugFrame("native.deepLink.launchUrl", { url: launch?.url ?? null });
1318
+ handleDeepLink(launch?.url, true);
1319
+ })
1320
+ .catch((error) => debugFrame("native.deepLink.launchUrlError", { error: String(error) }));
1321
+ })
1322
+ .catch((error) => debugFrame("native.deepLink.listenerError", { error: String(error) }));
1323
+
1324
+ return () => {
1325
+ disposed = true;
1326
+ removeListener?.();
1327
+ };
1328
+ }, []);
1329
+
1259
1330
  useEffect(() => {
1260
1331
  if (Device.getDevice().info.platform === "web") return;
1261
1332
  let removeListener: (() => void) | undefined;
@@ -1277,6 +1348,15 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1277
1348
  nativeBackState.router.back();
1278
1349
  return;
1279
1350
  }
1351
+ const fallbackPath = window.__AKAN_MOBILE_TARGET__?.indexPath ?? "/";
1352
+ if (didResetDeepLinkStackRef.current) {
1353
+ void App.exitApp?.();
1354
+ return;
1355
+ }
1356
+ if (nativeBackState.path !== fallbackPath) {
1357
+ clientRouter.backOrFallback(fallbackPath, { scrollToTop: false });
1358
+ return;
1359
+ }
1280
1360
  void App.exitApp?.();
1281
1361
  });
1282
1362