@kortix/ui 0.14.9 → 0.14.10

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 (72) hide show
  1. package/dist/_vendor/kortix-shared.js +55 -2
  2. package/dist/_vendor/shared/artifact-comments.d.ts +235 -0
  3. package/dist/_vendor/shared/channel-descriptors.d.ts +96 -0
  4. package/dist/_vendor/shared/channel-message.d.ts +13 -2
  5. package/dist/_vendor/shared/index.d.ts +1 -0
  6. package/dist/apps/app-preview-pool.js +19 -2
  7. package/dist/artifacts/artifact-frame.js +7 -3
  8. package/dist/customize/sections/component/channel-access-panel.js +4 -4
  9. package/dist/customize/sections/component/telegram-channel-row.js +7 -5
  10. package/dist/customize/sections/view/agent-editor-catalog.js +5 -0
  11. package/dist/customize/sections/view/channels-view.d.ts +2 -1
  12. package/dist/customize/sections/view/channels-view.js +12 -7
  13. package/dist/customize/settings/rail.js +9 -0
  14. package/dist/customize/settings/settings-tabs.d.ts +1 -1
  15. package/dist/customize/settings/settings-tabs.js +1 -0
  16. package/dist/customize/settings/tabs/chrome-connections-tab.d.ts +6 -0
  17. package/dist/customize/settings/tabs/chrome-connections-tab.js +99 -0
  18. package/dist/customize/shared/catalog/catalog-empty-state.d.ts +3 -3
  19. package/dist/customize/shared/catalog/catalog-empty-state.js +6 -4
  20. package/dist/files/renderers/image-frame.d.ts +3 -1
  21. package/dist/files/renderers/image-frame.js +2 -2
  22. package/dist/i18n/en.d.ts +189 -0
  23. package/dist/i18n/en.js +190 -1
  24. package/dist/internal/apps-and-extensions/apps-modal.d.ts +9 -0
  25. package/dist/internal/apps-and-extensions/apps-modal.js +107 -0
  26. package/dist/internal/chrome/browser-icon.d.ts +24 -0
  27. package/dist/internal/chrome/browser-icon.js +58 -0
  28. package/dist/internal/chrome/chrome-card-state.d.ts +16 -0
  29. package/dist/internal/chrome/chrome-card-state.js +9 -0
  30. package/dist/internal/chrome/chrome-connect.d.ts +69 -0
  31. package/dist/internal/chrome/chrome-connect.js +178 -0
  32. package/dist/internal/chrome/chrome-mock.d.ts +4 -0
  33. package/dist/internal/chrome/chrome-mock.js +15 -0
  34. package/dist/internal/chrome/chrome-names.d.ts +15 -0
  35. package/dist/internal/chrome/chrome-names.js +35 -0
  36. package/dist/internal/chrome/extension.d.ts +47 -0
  37. package/dist/internal/chrome/extension.js +91 -0
  38. package/dist/internal/chrome/use-chrome-offered.d.ts +11 -0
  39. package/dist/internal/chrome/use-chrome-offered.js +22 -0
  40. package/dist/internal/connector-logo/connector-logo-source.d.ts +2 -1
  41. package/dist/internal/connector-logo/connector-logo-source.js +7 -6
  42. package/dist/internal/icons/edge.d.ts +5 -0
  43. package/dist/internal/icons/edge.js +10 -0
  44. package/dist/internal/lib/browser-noise/rules/extensions.js +17 -3
  45. package/dist/internal/lib/detect-os.d.ts +43 -0
  46. package/dist/internal/lib/detect-os.js +70 -0
  47. package/dist/internal/lib/use-project-feature-flags.js +5 -1
  48. package/dist/internal/stores/apps-modal-store.d.ts +8 -0
  49. package/dist/internal/stores/apps-modal-store.js +6 -0
  50. package/dist/internal/stores/composer-prefill-store.d.ts +12 -5
  51. package/dist/markdown/setup-links/approval-decision.js +3 -1
  52. package/dist/primitives/empty-state.d.ts +2 -2
  53. package/dist/primitives/empty-state.js +6 -5
  54. package/dist/primitives/mermaid-renderer.js +1 -1
  55. package/dist/session/panel/session-audit-shared.js +3 -1
  56. package/dist/session/participants/session-participants.d.ts +3 -1
  57. package/dist/session/participants/session-participants.js +2 -2
  58. package/dist/session/prompts/approval-request.js +2 -0
  59. package/dist/session/thread/session-thread.d.ts +13 -1
  60. package/dist/session/thread/session-thread.js +15 -5
  61. package/dist/session/thread/session-transcript-class.d.ts +5 -1
  62. package/dist/session/thread/session-transcript-class.js +5 -1
  63. package/dist/session/tool/tools/image-gen-tool.js +4 -3
  64. package/dist/session/turn/user-message.d.ts +7 -5
  65. package/dist/session/turn/user-message.js +14 -8
  66. package/dist/styles.css +1 -1
  67. package/dist/workspace/sessions/session-label-badges.js +2 -1
  68. package/dist/workspace/sidebar/footer/project-chrome-nav.d.ts +10 -0
  69. package/dist/workspace/sidebar/footer/project-chrome-nav.js +56 -0
  70. package/dist/workspace/sidebar/project-sidebar.js +5 -1
  71. package/package.json +43 -3
  72. package/skills/kortix-ui/references/modules.md +1 -1
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { platformConfig } from '@kortix/sdk';
4
+ import { useChromeConnections } from '@kortix/sdk/react';
5
+ import { AppWindowIcon, ArrowUpRightIcon, LinkSimpleIcon, PuzzlePieceIcon, SignInIcon, } from '@phosphor-icons/react';
6
+ import { useMutation, useQuery } from '@tanstack/react-query';
7
+ import { useState, useSyncExternalStore } from 'react';
8
+ import { Button } from '../../primitives/button.js';
9
+ import Loading from '../../primitives/loading.js';
10
+ import { Modal, ModalContent, ModalDescription, ModalTitle } from '../../primitives/modal.js';
11
+ import { BeamsShader } from '../../primitives/paper-wallpaper-shaders.js';
12
+ import { errorToast } from '../../primitives/toast.js';
13
+ import { SolidCheckIcon } from '../icons/solid-check-icon.js';
14
+ import { useCopy } from '../hooks/use-copy.js';
15
+ import { useTranslations } from '../../i18n/index.js';
16
+ import { isDesktop } from '../lib/desktop.js';
17
+ import { cn } from '../lib/utils.js';
18
+ import { BrowserIcon, BrowserLogoTile } from './browser-icon.js';
19
+ import { apiOrigin, CHROME_INSTALL_DOCS_PATH, CHROME_STORE_LISTED, chromeSettingsLink, connectExtension, isChromiumBrowser, KORTIX_EXTENSION_STORE_URL, pingExtension, } from './extension.js';
20
+ const subscribeNever = () => () => { };
21
+ function detectEnvironment() {
22
+ return isChromiumBrowser(navigator.userAgent) && !isDesktop() ? 'supported' : 'unsupported';
23
+ }
24
+ /**
25
+ * `fresh`: the person adds ANOTHER browser. This browser's own extension state
26
+ * does not count: no ping, and only a browser that was not in the list when the
27
+ * dialog opened completes it.
28
+ */
29
+ export function useChromeConnect({ fresh = false } = {}) {
30
+ const t = useTranslations('chrome');
31
+ const environment = useSyncExternalStore(subscribeNever, detectEnvironment, () => 'checking');
32
+ const ping = useQuery({
33
+ queryKey: ['chrome-extension-ping'],
34
+ queryFn: () => pingExtension(),
35
+ enabled: environment === 'supported' && !fresh,
36
+ refetchInterval: 2_000,
37
+ staleTime: 0,
38
+ gcTime: 0,
39
+ });
40
+ const connections = useChromeConnections({ refetchInterval: 3_000 });
41
+ // The browsers that existed when the steps appeared. A browser outside it is
42
+ // the one this person is connecting now.
43
+ const [baseline, setBaseline] = useState(null);
44
+ if (baseline === null && connections.isSuccess) {
45
+ setBaseline(new Set(connections.data.map((chrome) => chrome.id)));
46
+ }
47
+ const status = fresh ? null : (ping.data ?? null);
48
+ const connected = connections.data?.find((chrome) => baseline && !baseline.has(chrome.id)) ??
49
+ // The extension keeps its browser id after a sign-out: trust it only while signed in.
50
+ (status?.connected && status.chromeId
51
+ ? connections.data?.find((chrome) => chrome.id === status.chromeId && chrome.online)
52
+ : undefined);
53
+ const connect = useMutation({
54
+ // A retry would open the sign-in window again.
55
+ retry: false,
56
+ mutationFn: () => connectExtension(apiOrigin(platformConfig().backendUrl || '', window.location.origin)),
57
+ onSuccess: (result) => {
58
+ if (!result.ok) {
59
+ // `unavailable`: the page could not reach the extension.
60
+ errorToast(t(result.error === 'unavailable' ? 'extensionMissing' : 'connectFailed'));
61
+ }
62
+ void ping.refetch();
63
+ void connections.refetch();
64
+ },
65
+ });
66
+ return {
67
+ environment,
68
+ /** The first ping has not answered yet. */
69
+ checking: environment === 'checking' || (environment === 'supported' && !fresh && !ping.isFetched),
70
+ // A page opened before the install cannot reach the extension until it
71
+ // reloads. The extension's own tab finishes the sign-in, and the browser
72
+ // then shows up in the list, so a connected browser completes every step.
73
+ installed: status !== null || Boolean(connected),
74
+ signedIn: status?.connected === true || Boolean(connected),
75
+ connected,
76
+ connect,
77
+ };
78
+ }
79
+ /** The Chrome mark on a small white tile, for a button label. */
80
+ export function ChromeButtonMark() {
81
+ return _jsx(BrowserLogoTile, { brand: "chrome", className: "size-5 shrink-0 ring-0", iconClassName: "size-3.5" });
82
+ }
83
+ function StepTile({ done, icon: StepIcon, brand }) {
84
+ // The last step shows the connected browser's own mark.
85
+ if (done && brand)
86
+ return _jsx(BrowserLogoTile, { brand: brand, className: "size-10" });
87
+ return (_jsx("span", { className: cn('flex size-10 shrink-0 items-center justify-center rounded-sm', done ? 'bg-kortix-green/15' : 'bg-muted text-muted-foreground'), children: done ? _jsx(SolidCheckIcon, { className: "text-kortix-green size-5" }) : _jsx(StepIcon, { className: "size-5" }) }));
88
+ }
89
+ export function ChromeOnlineDot({ online, className }) {
90
+ const t = useTranslations('chrome');
91
+ return (_jsx("span", { role: "img", "aria-label": online ? t('online') : t('offline'), className: cn('inline-block size-2 shrink-0 rounded-full', online ? 'bg-kortix-green' : 'bg-muted-foreground', className) }));
92
+ }
93
+ /** Copies the link that opens Settings → Kortix in Chrome, for a browser that cannot run the extension. */
94
+ export function ChromeCopyLink({ size = 'sm', className }) {
95
+ const t = useTranslations('chrome');
96
+ const { copy, copied } = useCopy({ toast: false });
97
+ return (_jsxs(Button, { variant: "secondary", size: size, className: cn('gap-1.5', className), onClick: async () => {
98
+ if (!(await copy(chromeSettingsLink(window.location.origin))))
99
+ errorToast(t('copyFailed'));
100
+ }, children: [copied ? _jsx(SolidCheckIcon, { className: "size-3.5 shrink-0" }) : _jsx(LinkSimpleIcon, { className: "size-3.5 shrink-0" }), copied ? t('linkCopied') : t('copyLink')] }));
101
+ }
102
+ /**
103
+ * The action that moves this browser forward: Connect once the extension is
104
+ * there, otherwise Connect plus "How to install" (or "Add to Chrome" once the
105
+ * Web Store listing is public). Renders nothing for a connected browser.
106
+ */
107
+ export function ChromeInstallActions({ state, size = 'sm', className, showGuide = true, }) {
108
+ const t = useTranslations('chrome');
109
+ const { checking, installed, connected, connect } = state;
110
+ const [openedStore, setOpenedStore] = useState(false);
111
+ if (connected)
112
+ return null;
113
+ const full = size === 'lg' ? 'w-full' : '';
114
+ const connectButton = (_jsxs(Button, { size: size, className: full, disabled: connect.isPending, onClick: () => connect.mutate(), children: [connect.isPending ? _jsx(Loading, { className: "size-4 shrink-0" }) : null, connect.isPending ? t('connecting') : t('connect')] }));
115
+ if (installed)
116
+ return _jsx("div", { className: className, children: connectButton });
117
+ if (CHROME_STORE_LISTED) {
118
+ return (_jsxs("div", { className: cn('space-y-2', className), children: [_jsx(Button, { asChild: true, size: size, className: full, "aria-disabled": checking, children: _jsxs("a", { href: KORTIX_EXTENSION_STORE_URL, target: "_blank", rel: "noreferrer", onClick: () => setOpenedStore(true), children: [_jsx(ChromeButtonMark, {}), t('addToChrome')] }) }), openedStore ? (_jsx("p", { className: "text-muted-foreground text-center text-xs text-pretty", children: t('finishInChrome') })) : null] }));
119
+ }
120
+ return (_jsxs("div", { className: cn(size === 'lg' ? 'flex flex-col gap-2' : 'flex flex-wrap items-center gap-x-3 gap-y-2', className), children: [connectButton, showGuide ? (_jsx(Button, { asChild: true, variant: "ghost", size: size, className: cn('gap-1.5', size === 'sm' && 'text-muted-foreground'), children: _jsxs("a", { href: CHROME_INSTALL_DOCS_PATH, target: "_blank", rel: "noreferrer", children: [t('howToInstall'), _jsx(ArrowUpRightIcon, { className: "size-3.5 shrink-0" })] }) })) : null] }));
121
+ }
122
+ /**
123
+ * The three steps and the one action that moves them. Used in the connect
124
+ * dialog and, with no browser connected yet, inline in Settings → Kortix in
125
+ * Chrome (`showActions={false}`: the page header holds the action there).
126
+ */
127
+ export function ChromeConnectSteps({ state, onDone, showActions = true, }) {
128
+ const t = useTranslations('chrome');
129
+ const { environment, installed, signedIn, connected } = state;
130
+ if (environment === 'unsupported') {
131
+ return (_jsxs("div", { className: "mt-auto space-y-3", children: [_jsx("p", { className: "text-muted-foreground text-sm text-pretty", children: t('unsupported') }), showActions ? _jsx(ChromeCopyLink, { size: "lg", className: "w-full" }) : null] }));
132
+ }
133
+ const steps = [
134
+ {
135
+ key: 'install',
136
+ icon: PuzzlePieceIcon,
137
+ done: installed,
138
+ description: installed ? (t('steps.install.done')) : CHROME_STORE_LISTED ? (t('steps.install.description')) : (_jsxs(_Fragment, { children: [t('steps.install.descriptionUnlisted'), showActions ? null : (_jsxs(_Fragment, { children: [' ', _jsx("a", { href: CHROME_INSTALL_DOCS_PATH, target: "_blank", rel: "noreferrer", className: "text-foreground underline underline-offset-2", children: t('howToInstall') })] }))] })),
139
+ },
140
+ {
141
+ key: 'connect',
142
+ icon: SignInIcon,
143
+ done: signedIn,
144
+ description: t(signedIn ? 'steps.connect.done' : 'steps.connect.description'),
145
+ },
146
+ {
147
+ key: 'done',
148
+ icon: AppWindowIcon,
149
+ done: Boolean(connected),
150
+ brand: connected?.brand,
151
+ description: connected ? (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx(ChromeOnlineDot, { online: connected.online }), _jsx("span", { className: "truncate", children: connected.name })] })) : (t('steps.done.description')),
152
+ },
153
+ ];
154
+ return (_jsxs(_Fragment, { children: [_jsx("ol", { className: "divide-border divide-y", children: steps.map(({ key, icon, done, brand, description }) => (_jsxs("li", { className: "flex items-center gap-3 py-3", children: [_jsx(StepTile, { done: done, icon: icon, brand: brand }), _jsxs("div", { className: "min-w-0 flex-1 space-y-0.5", children: [_jsx("p", { className: "text-sm font-medium", children: t(`steps.${key}.title`) }), _jsx("div", { className: "text-muted-foreground line-clamp-2 text-xs text-pretty", children: description })] })] }, key))) }), showActions || connected ? (_jsx("div", { className: "mt-auto space-y-2", children: connected ? (onDone ? (_jsx(Button, { size: "lg", className: "w-full", onClick: onDone, children: t('done') })) : null) : (_jsx(ChromeInstallActions, { state: state, size: "lg" })) })) : null] }));
155
+ }
156
+ /**
157
+ * "Add another browser": the other browser, or another Chrome profile, signs
158
+ * itself in. This page cannot do that for it, so the dialog says where to go
159
+ * and waits for the new browser to show up in the list.
160
+ */
161
+ function ChromeAddAnother({ onDone }) {
162
+ const t = useTranslations('chrome');
163
+ const { connected } = useChromeConnect({ fresh: true });
164
+ return (_jsxs(_Fragment, { children: [_jsx("ol", { className: "divide-border divide-y", children: _jsxs("li", { className: "flex items-center gap-3 py-3", children: [_jsx(StepTile, { done: Boolean(connected), icon: AppWindowIcon, brand: connected?.brand }), _jsxs("div", { className: "min-w-0 flex-1 space-y-0.5", children: [_jsx("p", { className: "text-sm font-medium", children: t('another.title') }), _jsx("div", { className: "text-muted-foreground line-clamp-2 text-xs text-pretty", children: connected ? (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx(ChromeOnlineDot, { online: connected.online }), _jsx("span", { className: "truncate", children: connected.name })] })) : (t('another.waiting')) })] })] }) }), _jsx("div", { className: "mt-auto space-y-2", children: connected ? (onDone ? (_jsx(Button, { size: "lg", className: "w-full", onClick: onDone, children: t('done') })) : null) : (_jsx(ChromeCopyLink, { size: "lg", className: "w-full" })) })] }));
165
+ }
166
+ function ChromeConnectBody({ onDone }) {
167
+ const state = useChromeConnect();
168
+ return _jsx(ChromeConnectSteps, { state: state, onDone: onDone });
169
+ }
170
+ /**
171
+ * "Add Kortix to Chrome": add the Kortix extension to Chrome, sign it in, and
172
+ * watch the browser appear. `fresh`: add ANOTHER browser. Same frame as
173
+ * `ComputerConnectModal`.
174
+ */
175
+ export function ChromeConnectModal({ open, onOpenChange, fresh = false, }) {
176
+ const t = useTranslations('chrome');
177
+ return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, children: _jsx(ModalContent, { className: "space-y-0 lg:max-w-3xl", closeClassName: "max-sm:bg-background border-0 overflow-hidden", children: _jsxs("div", { className: "grid sm:grid-cols-5 lg:min-h-128", children: [_jsxs("div", { "aria-hidden": "true", className: "dark bg-background relative isolate flex h-48 items-center justify-center overflow-hidden rounded-t-xl border-b sm:col-span-2 sm:h-auto sm:rounded-tr-none sm:border-r sm:border-b-0 lg:rounded-bl-xl", children: [_jsx(BeamsShader, {}), _jsxs("span", { className: "bg-foreground text-background relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium", children: [_jsx(BrowserIcon, { brand: "chrome", tone: "current", className: "size-4" }), t('pill')] })] }), _jsxs("div", { className: "flex min-w-0 flex-col gap-5 p-5 sm:col-span-3 lg:p-8", children: [_jsxs("header", { className: "space-y-1.5 pr-10", children: [_jsx(ModalTitle, { className: "text-lg font-medium text-balance", children: t('connectTitle') }), _jsx(ModalDescription, { className: "text-pretty", children: t(fresh ? 'another.description' : 'connectDescription') })] }), open ? (fresh ? (_jsx(ChromeAddAnother, { onDone: () => onOpenChange(false) })) : (_jsx(ChromeConnectBody, { onDone: () => onOpenChange(false) }))) : null] })] }) }) }));
178
+ }
@@ -0,0 +1,4 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ export declare function ChromeMock({ className }: {
3
+ className?: string;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CursorIcon } from '@phosphor-icons/react';
3
+ import { useTranslations } from '../../i18n/index.js';
4
+ import { cn } from '../lib/utils.js';
5
+ /**
6
+ * What the person sees while a session works in their chrome: the Kortix tab
7
+ * group, the page with a halo, the agent cursor and the control bar. Static,
8
+ * decorative, built from tokens. Blue marks agent state only. It mirrors the
9
+ * extension's welcome page (`apps/chrome-extension/src/pages/welcome.html`).
10
+ */
11
+ const MOCK_URL = 'shop.example.com/returns';
12
+ export function ChromeMock({ className }) {
13
+ const t = useTranslations('appsModal.mock');
14
+ return (_jsx("div", { "aria-hidden": "true", className: cn('bg-muted rounded-md p-3 sm:p-4', className), children: _jsxs("div", { className: "bg-background overflow-hidden rounded-sm border", children: [_jsx("div", { className: "bg-card flex items-center gap-2 border-b px-2 py-1.5", children: _jsxs("span", { className: "bg-kortix-base/15 flex min-w-0 items-center gap-1.5 rounded-sm px-2 py-0.5 text-xs", children: [_jsx("span", { className: "bg-kortix-base size-1.5 shrink-0 rounded-full" }), _jsx("span", { className: "truncate", children: t('group') })] }) }), _jsx("div", { className: "border-b px-2 py-1.5", children: _jsx("span", { className: "bg-muted text-muted-foreground block truncate rounded-sm px-2 py-0.5 font-mono text-xs", children: MOCK_URL }) }), _jsxs("div", { className: "relative space-y-3 px-3 pt-3 pb-14 sm:px-4 sm:pt-4", children: [_jsx("span", { className: "bg-muted block h-2 w-24 rounded-sm" }), _jsxs("div", { className: "flex items-center justify-between gap-3 rounded-sm border p-3", children: [_jsxs("span", { className: "space-y-1.5", children: [_jsx("span", { className: "bg-muted block h-2 w-20 rounded-sm" }), _jsx("span", { className: "bg-muted block h-2 w-12 rounded-sm" })] }), _jsxs("span", { className: "border-ring ring-ring/15 relative rounded-sm border px-2 py-1 text-xs ring-3", children: [t('button'), _jsxs("span", { className: "absolute top-full right-1 -mt-0.5 flex flex-col items-end", children: [_jsx(CursorIcon, { weight: "fill", className: "text-kortix-base size-4" }), _jsx("span", { className: "bg-popover -mt-0.5 rounded-sm border px-1.5 py-0.5 text-xs", children: "Kortix" })] })] })] }), _jsxs("span", { className: "bg-popover absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full border px-3 py-1 text-xs whitespace-nowrap", children: [_jsx("span", { className: "bg-kortix-base size-1.5 rounded-full" }), t('working'), _jsx("span", { className: "text-muted-foreground", children: t('takeOver') }), _jsx("span", { className: "text-muted-foreground", children: t('stop') })] })] })] }) }));
15
+ }
@@ -0,0 +1,15 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ import type { ChromeConnection } from '@kortix/sdk';
3
+ /**
4
+ * Two browsers can share a default name ("Chromium on macOS"). Where they do,
5
+ * the list tells them apart: the profile label when it has one, else a count
6
+ * in connection order. The stored name never changes; this is display only.
7
+ */
8
+ export declare function chromeDisplayNames(list: readonly ChromeConnection[]): Map<string, string>;
9
+ /** "N browsers · M online" for the Apps modal. */
10
+ export declare function chromeCounts(list: readonly ChromeConnection[]): {
11
+ count: number;
12
+ online: number;
13
+ };
14
+ /** Offline for longer than `thresholdMs`: only then is "last seen" worth saying. */
15
+ export declare function offlineLongerThan(lastSeenAt: string | null, thresholdMs: number, now?: number): boolean;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Two browsers can share a default name ("Chromium on macOS"). Where they do,
3
+ * the list tells them apart: the profile label when it has one, else a count
4
+ * in connection order. The stored name never changes; this is display only.
5
+ */
6
+ export function chromeDisplayNames(list) {
7
+ const byName = new Map();
8
+ for (const chrome of list)
9
+ byName.set(chrome.name, [...(byName.get(chrome.name) ?? []), chrome]);
10
+ const out = new Map();
11
+ for (const chrome of list) {
12
+ const same = byName.get(chrome.name) ?? [];
13
+ if (same.length < 2) {
14
+ out.set(chrome.id, chrome.name);
15
+ }
16
+ else if (chrome.profileLabel && !chrome.name.includes(chrome.profileLabel)) {
17
+ out.set(chrome.id, `${chrome.name} · ${chrome.profileLabel}`);
18
+ }
19
+ else {
20
+ out.set(chrome.id, `${chrome.name} #${same.indexOf(chrome) + 1}`);
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+ /** "N browsers · M online" for the Apps modal. */
26
+ export function chromeCounts(list) {
27
+ return { count: list.length, online: list.filter((chrome) => chrome.online).length };
28
+ }
29
+ /** Offline for longer than `thresholdMs`: only then is "last seen" worth saying. */
30
+ export function offlineLongerThan(lastSeenAt, thresholdMs, now = Date.now()) {
31
+ if (!lastSeenAt)
32
+ return false;
33
+ const seen = Date.parse(lastSeenAt);
34
+ return Number.isFinite(seen) && now - seen > thresholdMs;
35
+ }
@@ -0,0 +1,47 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ /**
3
+ * The Kortix Chrome extension, seen from the web app.
4
+ *
5
+ * A page talks to the extension through `chrome.runtime.sendMessage(id, …)`
6
+ * (`externally_connectable`). Chrome exposes `chrome.runtime` to a page only
7
+ * when the extension is installed and lists the page's origin. Safari, Firefox
8
+ * and the desktop shell never expose it, so `pingExtension` answers `null`.
9
+ */
10
+ export declare const KORTIX_EXTENSION_ID: string;
11
+ export declare const KORTIX_EXTENSION_STORE_URL: string;
12
+ /**
13
+ * The Web Store listing is public. While `false`, no surface offers "Add to
14
+ * Chrome": each one shows "Connect" and a "How to install" link to the docs.
15
+ * Flip it with the listing, nothing else changes.
16
+ */
17
+ export declare const CHROME_STORE_LISTED = false;
18
+ /** Where "How to install" points: the docs page that explains Load unpacked. */
19
+ export declare const CHROME_INSTALL_DOCS_PATH = "/docs/connect/chrome";
20
+ /** Opens Settings → Kortix in Chrome. The link a person copies to open it in Chrome. */
21
+ export declare function chromeSettingsLink(origin: string): string;
22
+ export interface ExtensionStatus {
23
+ version: string;
24
+ /** The extension holds a Kortix sign-in and a browser id. */
25
+ connected: boolean;
26
+ apiUrl: string | null;
27
+ chromeId: string | null;
28
+ }
29
+ export type ExtensionConnectResult = {
30
+ ok: true;
31
+ chromeId: string;
32
+ } | {
33
+ ok: false;
34
+ error: string;
35
+ };
36
+ /** Chrome, Edge, Brave, Arc and Opera on a computer. Not Electron, not Chrome on iOS. */
37
+ export declare function isChromiumBrowser(userAgent: string): boolean;
38
+ /**
39
+ * The API origin the extension signs in to. The web app's backend URL can be
40
+ * root-relative (`/v1`, same-origin deployments), so resolve it against the
41
+ * page. The extension appends `/v1/...` itself. `''` when there is none.
42
+ */
43
+ export declare function apiOrigin(backendUrl: string, pageOrigin: string): string;
44
+ /** The extension's state, or `null`: not installed, not Chromium, or no answer in 1.5 s. */
45
+ export declare function pingExtension(timeoutMs?: number): Promise<ExtensionStatus | null>;
46
+ /** Starts the extension's Kortix sign-in. Resolves when the person finishes it. */
47
+ export declare function connectExtension(apiUrl: string): Promise<ExtensionConnectResult>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The Kortix Chrome extension, seen from the web app.
3
+ *
4
+ * A page talks to the extension through `chrome.runtime.sendMessage(id, …)`
5
+ * (`externally_connectable`). Chrome exposes `chrome.runtime` to a page only
6
+ * when the extension is installed and lists the page's origin. Safari, Firefox
7
+ * and the desktop shell never expose it, so `pingExtension` answers `null`.
8
+ */
9
+ export const KORTIX_EXTENSION_ID = process.env.NEXT_PUBLIC_KORTIX_EXTENSION_ID || 'gefbbnjgoiljpbnalgafbhbfdhikgjek';
10
+ export const KORTIX_EXTENSION_STORE_URL = `https://chromewebstore.google.com/detail/${KORTIX_EXTENSION_ID}`;
11
+ /**
12
+ * The Web Store listing is public. While `false`, no surface offers "Add to
13
+ * Chrome": each one shows "Connect" and a "How to install" link to the docs.
14
+ * Flip it with the listing, nothing else changes.
15
+ */
16
+ export const CHROME_STORE_LISTED = false;
17
+ /** Where "How to install" points: the docs page that explains Load unpacked. */
18
+ export const CHROME_INSTALL_DOCS_PATH = '/docs/connect/chrome';
19
+ /** Opens Settings → Kortix in Chrome. The link a person copies to open it in Chrome. */
20
+ export function chromeSettingsLink(origin) {
21
+ return `${origin}/settings/chrome`;
22
+ }
23
+ const PING_TIMEOUT_MS = 1_500;
24
+ // Sign-in happens in a Chrome window the person must finish.
25
+ const CONNECT_TIMEOUT_MS = 120_000;
26
+ /** Chrome, Edge, Brave, Arc and Opera on a computer. Not Electron, not Chrome on iOS. */
27
+ export function isChromiumBrowser(userAgent) {
28
+ return /Chrome\/\d/.test(userAgent) && !/Electron\//.test(userAgent);
29
+ }
30
+ /**
31
+ * The API origin the extension signs in to. The web app's backend URL can be
32
+ * root-relative (`/v1`, same-origin deployments), so resolve it against the
33
+ * page. The extension appends `/v1/...` itself. `''` when there is none.
34
+ */
35
+ export function apiOrigin(backendUrl, pageOrigin) {
36
+ if (!backendUrl)
37
+ return '';
38
+ try {
39
+ return new URL(backendUrl, pageOrigin).origin;
40
+ }
41
+ catch {
42
+ return '';
43
+ }
44
+ }
45
+ function send(message, timeoutMs, parse) {
46
+ const runtime = globalThis.chrome?.runtime;
47
+ if (!runtime?.sendMessage)
48
+ return Promise.resolve(null);
49
+ return new Promise((resolve) => {
50
+ const timer = setTimeout(() => resolve(null), timeoutMs);
51
+ const done = (value) => {
52
+ clearTimeout(timer);
53
+ resolve(value);
54
+ };
55
+ try {
56
+ runtime.sendMessage(KORTIX_EXTENSION_ID, message, (response) => {
57
+ // Reading `lastError` marks it handled. Set when the extension is absent.
58
+ done(runtime.lastError ? null : parse(response));
59
+ });
60
+ }
61
+ catch {
62
+ done(null);
63
+ }
64
+ });
65
+ }
66
+ /** The extension's state, or `null`: not installed, not Chromium, or no answer in 1.5 s. */
67
+ export function pingExtension(timeoutMs = PING_TIMEOUT_MS) {
68
+ return send({ type: 'kortix.ping' }, timeoutMs, (response) => {
69
+ const r = response;
70
+ if (!r || r.ok !== true)
71
+ return null;
72
+ return {
73
+ version: String(r.version ?? ''),
74
+ connected: r.connected === true,
75
+ apiUrl: typeof r.apiUrl === 'string' ? r.apiUrl : null,
76
+ chromeId: typeof r.chromeId === 'string' ? r.chromeId : null,
77
+ };
78
+ });
79
+ }
80
+ /** Starts the extension's Kortix sign-in. Resolves when the person finishes it. */
81
+ export async function connectExtension(apiUrl) {
82
+ const result = await send({ type: 'kortix.connect', apiUrl }, CONNECT_TIMEOUT_MS, (response) => {
83
+ const r = response;
84
+ if (r?.ok === true)
85
+ return { ok: true, chromeId: String(r.chromeId ?? '') };
86
+ if (r)
87
+ return { ok: false, error: typeof r.error === 'string' ? r.error : 'failed' };
88
+ return null;
89
+ });
90
+ return result ?? { ok: false, error: 'unavailable' };
91
+ }
@@ -0,0 +1,11 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ /**
3
+ * Whether this platform offers Kortix in Chrome at all. Until launch the API
4
+ * answers 404 on /v1/chrome/* where KORTIX_CHROME_ENABLED is off (staging,
5
+ * prod), so the account-level surfaces — Settings → Kortix in Chrome and its
6
+ * command-palette entry — show only once the browser list answers. Project
7
+ * surfaces read the project's `chrome` flag instead.
8
+ *
9
+ * `enabled`: ask only while the surface that needs the answer is open.
10
+ */
11
+ export declare function useChromeOffered(enabled?: boolean): boolean;
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { listChromeConnections } from '@kortix/sdk';
3
+ import { chromeKeys } from '@kortix/sdk/react';
4
+ import { useQuery } from '@tanstack/react-query';
5
+ /**
6
+ * Whether this platform offers Kortix in Chrome at all. Until launch the API
7
+ * answers 404 on /v1/chrome/* where KORTIX_CHROME_ENABLED is off (staging,
8
+ * prod), so the account-level surfaces — Settings → Kortix in Chrome and its
9
+ * command-palette entry — show only once the browser list answers. Project
10
+ * surfaces read the project's `chrome` flag instead.
11
+ *
12
+ * `enabled`: ask only while the surface that needs the answer is open.
13
+ */
14
+ export function useChromeOffered(enabled = true) {
15
+ return useQuery({
16
+ queryKey: chromeKeys.list(),
17
+ queryFn: listChromeConnections,
18
+ enabled,
19
+ staleTime: 60_000,
20
+ retry: false,
21
+ }).isSuccess;
22
+ }
@@ -1,4 +1,5 @@
1
1
  /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ import { type ChannelPlatformId } from '../../_vendor/shared/channel-descriptors.js';
2
3
  /**
3
4
  * Which logo a connector shows. ONE rule for every surface — catalogue card,
4
5
  * app page, connector page, Connected list, account rows, pickers, channel
@@ -17,7 +18,7 @@
17
18
  * would learn it (the API holds the same line, `connector-icon.ts`).
18
19
  * 4. Else the neutral fallback glyph.
19
20
  */
20
- export type NativeConnectorApp = 'slack' | 'teams' | 'email' | 'telegram' | 'computer';
21
+ export type NativeConnectorApp = ChannelPlatformId | 'computer';
21
22
  /** Whatever a surface knows about the connector; every field optional. */
22
23
  export interface ConnectorLogoSource {
23
24
  /** Connector slug (`kortix_email`, `computer`) or catalogue id (`native:email`). */
@@ -1,5 +1,6 @@
1
+ import { CHANNEL_PLATFORM_IDS } from '../../_vendor/kortix-shared.js';
1
2
  import { faviconUrlForHostname } from '../../lib/favicon.js';
2
- const NATIVE_APPS = ['slack', 'teams', 'email', 'telegram', 'computer'];
3
+ const NATIVE_APPS = [...CHANNEL_PLATFORM_IDS, 'computer'];
3
4
  function isNative(value) {
4
5
  return !!value && NATIVE_APPS.includes(value);
5
6
  }
@@ -11,13 +12,13 @@ export function nativeConnectorApp(source) {
11
12
  if (source.provider === 'channel' && isNative(source.platform))
12
13
  return source.platform;
13
14
  const slug = (source.slug ?? '').toLowerCase();
14
- const match = /^(?:native:|kortix_)(slack|teams|email|telegram|computer)$/.exec(slug);
15
- if (match)
16
- return match[1];
15
+ const named = /^(?:native:|kortix_)([a-z]+)$/.exec(slug)?.[1];
16
+ if (isNative(named))
17
+ return named;
17
18
  if (slug === 'computer' || slug === 'computers')
18
19
  return 'computer';
19
- const file = /\/connector-icons\/(slack|teams|email|telegram|computer)\.svg$/.exec(source.icon ?? '');
20
- return file ? file[1] : null;
20
+ const file = /\/connector-icons\/([a-z]+)\.svg$/.exec(source.icon ?? '')?.[1];
21
+ return isNative(file) ? file : null;
21
22
  }
22
23
  export function resolveConnectorLogo(source) {
23
24
  const native = nativeConnectorApp(source);
@@ -0,0 +1,5 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ /** Edge brand mark, full colour. Source: gilbarbara/logos (CC0), `logos:microsoft-edge`. */
3
+ export declare const Edge: ({ className }: {
4
+ className?: string;
5
+ }) => import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
4
+ import { cn } from '@kortix/ui';
5
+ /** Edge brand mark, full colour. Source: gilbarbara/logos (CC0), `logos:microsoft-edge`. */
6
+ export const Edge = ({ className }) => {
7
+ // Gradient ids are per instance, so two marks on one page never share a definition.
8
+ const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '');
9
+ return (_jsxs("svg", { viewBox: "0 0 256 256", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", className: cn('size-4', className), children: [_jsxs("defs", { children: [_jsxs("radialGradient", { id: `${uid}SVGHFrboeCQ`, cx: "161.83", cy: "788.401", r: "95.38", gradientTransform: "matrix(.9999 0 0 .9498 -4.622 -570.387)", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: ".72", stopOpacity: "0" }), _jsx("stop", { offset: ".95", stopOpacity: ".53" }), _jsx("stop", { offset: "1" })] }), _jsxs("radialGradient", { id: `${uid}SVGVG94vbPK`, cx: "-773.636", cy: "746.715", r: "143.24", gradientTransform: "matrix(.15 -.9898 .8 .12 -410.718 -656.341)", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: ".76", stopOpacity: "0" }), _jsx("stop", { offset: ".95", stopOpacity: ".5" }), _jsx("stop", { offset: "1" })] }), _jsxs("radialGradient", { id: `${uid}SVGw2d8JdCl`, cx: "230.593", cy: "-106.038", r: "202.43", gradientTransform: "matrix(-.04 .9998 -2.1299 -.07998 -190.775 -191.635)", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: "0", stopColor: "#35c1f1" }), _jsx("stop", { offset: ".11", stopColor: "#34c1ed" }), _jsx("stop", { offset: ".23", stopColor: "#2fc2df" }), _jsx("stop", { offset: ".31", stopColor: "#2bc3d2" }), _jsx("stop", { offset: ".67", stopColor: "#36c752" })] }), _jsxs("radialGradient", { id: `${uid}SVGIqWItcPR`, cx: "536.357", cy: "-117.703", r: "97.34", gradientTransform: "matrix(.28 .9598 -.78 .23 -1.928 -410.318)", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: "0", stopColor: "#66eb6e" }), _jsx("stop", { offset: "1", stopColor: "#66eb6e", stopOpacity: "0" })] }), _jsxs("linearGradient", { id: `${uid}SVGktZSUcGK`, x1: "63.334", x2: "241.617", y1: "757.83", y2: "757.83", gradientTransform: "translate(-4.63 -580.81)", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: "0", stopColor: "#0c59a4" }), _jsx("stop", { offset: "1", stopColor: "#114a8b" })] }), _jsxs("linearGradient", { id: `${uid}SVGaPqZBebW`, x1: "157.401", x2: "46.028", y1: "680.556", y2: "801.868", gradientTransform: "translate(-4.63 -580.81)", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: "0", stopColor: "#1b9de2" }), _jsx("stop", { offset: ".16", stopColor: "#1595df" }), _jsx("stop", { offset: ".67", stopColor: "#0680d7" }), _jsx("stop", { offset: "1", stopColor: "#0078d4" })] })] }), _jsx("path", { fill: `url(#${uid}SVGktZSUcGK)`, d: "M231 190.5c-3.4 1.8-6.9 3.4-10.5 4.7c-11.5 4.3-23.6 6.5-35.9 6.5c-47.3 0-88.5-32.5-88.5-74.3c.1-11.4 6.4-21.9 16.4-27.3c-42.8 1.8-53.8 46.4-53.8 72.5c0 73.9 68.1 81.4 82.8 81.4c7.9 0 19.8-2.3 27-4.6l1.3-.4c27.6-9.5 51-28.1 66.6-52.8c1.2-1.9.6-4.3-1.2-5.5c-1.3-.8-2.9-.9-4.2-.2" }), _jsx("path", { fill: `url(#${uid}SVGHFrboeCQ)`, d: "M231 190.5c-3.4 1.8-6.9 3.4-10.5 4.7c-11.5 4.3-23.6 6.5-35.9 6.5c-47.3 0-88.5-32.5-88.5-74.3c.1-11.4 6.4-21.9 16.4-27.3c-42.8 1.8-53.8 46.4-53.8 72.5c0 73.9 68.1 81.4 82.8 81.4c7.9 0 19.8-2.3 27-4.6l1.3-.4c27.6-9.5 51-28.1 66.6-52.8c1.2-1.9.6-4.3-1.2-5.5c-1.3-.8-2.9-.9-4.2-.2", opacity: ".35" }), _jsx("path", { fill: `url(#${uid}SVGaPqZBebW)`, d: "M105.7 241.4c-8.9-5.5-16.6-12.8-22.7-21.3c-26.3-36-18.4-86.5 17.6-112.8c3.8-2.7 7.7-5.2 11.9-7.2c3.1-1.5 8.4-4.1 15.5-4c10.1.1 19.6 4.9 25.7 13c4 5.4 6.3 11.9 6.4 18.7c0-.2 24.5-79.6-80-79.6c-43.9 0-80 41.7-80 78.2c-.2 19.3 4 38.5 12.1 56c27.6 58.8 94.8 87.6 156.4 67.1c-21.1 6.6-44.1 3.7-62.9-8.1" }), _jsx("path", { fill: `url(#${uid}SVGVG94vbPK)`, d: "M105.7 241.4c-8.9-5.5-16.6-12.8-22.7-21.3c-26.3-36-18.4-86.5 17.6-112.8c3.8-2.7 7.7-5.2 11.9-7.2c3.1-1.5 8.4-4.1 15.5-4c10.1.1 19.6 4.9 25.7 13c4 5.4 6.3 11.9 6.4 18.7c0-.2 24.5-79.6-80-79.6c-43.9 0-80 41.7-80 78.2c-.2 19.3 4 38.5 12.1 56c27.6 58.8 94.8 87.6 156.4 67.1c-21.1 6.6-44.1 3.7-62.9-8.1", opacity: ".41" }), _jsx("path", { fill: `url(#${uid}SVGw2d8JdCl)`, d: "M152.3 148.9c-.8 1-3.3 2.5-3.3 5.7c0 2.6 1.7 5.1 4.7 7.2c14.4 10 41.5 8.7 41.6 8.7c10.7 0 21.1-2.9 30.3-8.3c18.8-11 30.4-31.1 30.4-52.9c.3-22.4-8-37.3-11.3-43.9C223.5 23.9 177.7 0 128 0C58 0 1 56.2 0 126.2c.5-36.5 36.8-66 80-66c3.5 0 23.5.3 42 10.1c16.3 8.6 24.9 18.9 30.8 29.2c6.2 10.7 7.3 24.1 7.3 29.5c0 5.3-2.7 13.3-7.8 19.9" }), _jsx("path", { fill: `url(#${uid}SVGIqWItcPR)`, d: "M152.3 148.9c-.8 1-3.3 2.5-3.3 5.7c0 2.6 1.7 5.1 4.7 7.2c14.4 10 41.5 8.7 41.6 8.7c10.7 0 21.1-2.9 30.3-8.3c18.8-11 30.4-31.1 30.4-52.9c.3-22.4-8-37.3-11.3-43.9C223.5 23.9 177.7 0 128 0C58 0 1 56.2 0 126.2c.5-36.5 36.8-66 80-66c3.5 0 23.5.3 42 10.1c16.3 8.6 24.9 18.9 30.8 29.2c6.2 10.7 7.3 24.1 7.3 29.5c0 5.3-2.7 13.3-7.8 19.9" })] }));
10
+ };
@@ -615,8 +615,9 @@ export function isCaptchaInterceptorNoise(input) {
615
615
  }
616
616
  /**
617
617
  * An injected wallet provider rejecting a pending request with the EIP-1474
618
- * default internal error, `{ code: -32603, message: 'Internal JSON-RPC error.' }`
619
- * (the wording of the wallet RPC error library). The rejected value is a plain
618
+ * default internal error, `{ code: -32603, message: 'Internal JSON-RPC error.' }`,
619
+ * or the exact missing-account error captured in KRTX-2150 (code 4001).
620
+ * Both use the wallet provider's own wording. The rejected value is a plain
620
621
  * object with no `stack`, so Sentry reports a frameless synthetic
621
622
  * "Object captured as promise rejection with keys: code, message" and the
622
623
  * extension-stack anchor above cannot see it. Better Stack, Kortix Frontend
@@ -633,7 +634,11 @@ function isWalletInternalRpcRejectionNoise(input) {
633
634
  const value = serialized;
634
635
  if (Object.keys(value).sort().join(',') !== 'code,message')
635
636
  return false;
636
- if (value.code !== -32603 || value.message !== 'Internal JSON-RPC error.')
637
+ // KRTX-2150 recurred with a second frameless wallet payload (2026-10-29).
638
+ // Match the exact pair, not EIP-1193 code 4001 alone (user rejection).
639
+ const internal = value.code === -32603 && value.message === 'Internal JSON-RPC error.';
640
+ const missingAccount = value.code === 4001 && value.message === 'wallet must has at least one account';
641
+ if (!internal && !missingAccount)
637
642
  return false;
638
643
  return !sourcesOf(input).some((source) => isBrowserBundleSource(source) || isFirstPartyResolvedSource(source));
639
644
  }
@@ -677,6 +682,15 @@ function isWalletProviderDisconnectedRejectionNoise(input) {
677
682
  return !sourcesOf(input).some((source) => isBrowserBundleSource(source) || isFirstPartyResolvedSource(source));
678
683
  }
679
684
  export const EXTENSION_RULES = [
685
+ {
686
+ // Talisman's injected page.js rejects before wallet onboarding (KRTX-3678).
687
+ // Require its exact message and response frame; page.js alone is ambiguous.
688
+ id: 'talisman-onboarding',
689
+ appliesTo: 'sentry',
690
+ match: ({ message, frames }) => normalizeString(message) === 'Talisman extension has not been configured yet. Please continue with onboarding.' &&
691
+ !frames.some((frame) => isFirstPartyResolvedSource(frame.filename)) &&
692
+ frames.some((frame) => frame.filename === 'app:///page.js' && frame.function === 'ne.handleResponse'),
693
+ },
680
694
  {
681
695
  id: 'injected-app-source',
682
696
  appliesTo: 'both',
@@ -0,0 +1,43 @@
1
+ /** @internal Kortix apps only. No semver: it may change in any release. */
2
+ /**
3
+ * Platform detection for the /download page and the per-platform redirect
4
+ * routes.
5
+ *
6
+ * Detection runs on the SERVER, from the request's user-agent header, so the
7
+ * page paints the correct filled button (and, on a phone, the mobile card
8
+ * first) on first render. A client-side `useEffect` swap would let a visitor click the wrong
9
+ * installer during the flash — the exact failure this page exists to prevent.
10
+ *
11
+ * Architecture is deliberately NOT detected: macOS ships a single universal
12
+ * .dmg, and macOS user agents misreport arch anyway (they always claim Intel).
13
+ */
14
+ export type DesktopOs = 'macos' | 'windows' | 'linux';
15
+ export type MobileOs = 'ios' | 'android';
16
+ export type Platform = DesktopOs | MobileOs;
17
+ /** Fixed row order. Detection never reorders rows; it only picks the filled button. */
18
+ export declare const DESKTOP_ORDER: readonly DesktopOs[];
19
+ export declare const MOBILE_ORDER: readonly MobileOs[];
20
+ /** Map an explicit `?platform=` value or `/download/<segment>` to a platform. */
21
+ export declare function normalizePlatform(raw: string | null | undefined): Platform | null;
22
+ /**
23
+ * Best-effort platform from a user-agent string.
24
+ *
25
+ * THE ORDER OF THESE CHECKS IS LOAD-BEARING. Two user agents carry another
26
+ * platform's token:
27
+ * iPad "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)" contains "mac"
28
+ * Android "Mozilla/5.0 (Linux; Android 14; Pixel 8)" contains "linux"
29
+ * So iOS is tested before macOS, and Android before Linux. `detect-os.test.ts`
30
+ * pins both; if you reorder these lines, those tests fail.
31
+ *
32
+ * KNOWN LIMITATION: an iPad with "Request Desktop Website" enabled sends a UA
33
+ * byte-identical to a Mac's. No server can separate them — real detection needs
34
+ * `navigator.maxTouchPoints`, which is not in any request header. That visitor
35
+ * gets the Desktop card first; the Mobile card is still on the page, one tap
36
+ * away. Fixing it client-side would reintroduce the wrong-file-click window
37
+ * this module exists to close.
38
+ *
39
+ * Defaults to macOS: the largest desktop segment for this product, and every
40
+ * other platform stays one click away on the page.
41
+ */
42
+ export declare function detectPlatform(userAgent: string | null | undefined): Platform;
43
+ export declare function isMobilePlatform(platform: Platform): platform is MobileOs;