@nextclaw/ui 0.12.8 → 0.12.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/assets/ChannelsList-Ita2Zm1_.js +8 -0
- package/dist/assets/{DocBrowser-BMxf9CIK.js → DocBrowser-6ReNjvzF.js} +1 -1
- package/dist/assets/DocBrowser-BNwbPHf4.js +1 -0
- package/dist/assets/{DocBrowserContext-Ce28gRXt.js → DocBrowserContext-B6SpA7Qs.js} +1 -1
- package/dist/assets/{LogoBadge-o92MOA2L.js → LogoBadge-ByNLYg65.js} +1 -1
- package/dist/assets/MarketplacePage-CjX2MWww.js +1 -0
- package/dist/assets/{MarketplacePage-BySqkYDh.js → MarketplacePage-D0sDlYX4.js} +1 -1
- package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +40 -0
- package/dist/assets/{ModelConfig-IrmzoslW.js → ModelConfig-BzZenCH-.js} +1 -1
- package/dist/assets/{ProviderScopedModelInput-CmTIzgI7.js → ProviderScopedModelInput-Da7khnBA.js} +1 -1
- package/dist/assets/{ProvidersList-8_Kalfwl.js → ProvidersList-BbVzRxjY.js} +1 -1
- package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +1 -0
- package/dist/assets/RuntimeConfig-F_XKGgLm.js +1 -0
- package/dist/assets/{SearchConfig-DNBR-UbE.js → SearchConfig-BGkzXQP-.js} +1 -1
- package/dist/assets/{SecretsConfig-Ba1RPJaG.js → SecretsConfig-D281Rotl.js} +2 -2
- package/dist/assets/{SessionsConfig-Doqp5ghH.js → SessionsConfig-ChHQ7M5c.js} +2 -2
- package/dist/assets/{app-query-client-DniXoIN5.js → app-query-client-VnFElj4E.js} +1 -1
- package/dist/assets/{book-open-DocgeQtR.js → book-open-BdcxxoQu.js} +1 -1
- package/dist/assets/chat-page-Doe0yTtB.js +58 -0
- package/dist/assets/chat-session-display-cw78aiI_.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-BvKvh1R8.js → chunk-JZWAC4HX-DK5HPmIK.js} +1 -1
- package/dist/assets/{client-CVqPF5ie.js → client-_i4MU2bB.js} +1 -1
- package/dist/assets/{config-Bop2oB18.js → config-DtIQwrHF.js} +1 -1
- package/dist/assets/{createLucideIcon-DVv8taGY.js → createLucideIcon-BSeTgkZW.js} +1 -1
- package/dist/assets/desktop-update-config-Dpcf4BKG.js +1 -0
- package/dist/assets/{dist-Da5Gm_pO.js → dist-6TrrnPCR.js} +1 -1
- package/dist/assets/{dist-DmAlInRu.js → dist-ccBFUi-o.js} +1 -1
- package/dist/assets/download-BhDxnyvU.js +1 -0
- package/dist/assets/{external-link-DFjw3x1B.js → external-link-BgErLCNT.js} +1 -1
- package/dist/assets/{hash-DJtaCejM.js → hash-Bl7dr_UG.js} +1 -1
- package/dist/assets/i18n-eDHeDY0n.js +1 -0
- package/dist/assets/index-CF9xve0E.js +6 -0
- package/dist/assets/index-FgA52VBt.css +1 -0
- package/dist/assets/{infiniteQueryBehavior-DHSEQ3OH.js → infiniteQueryBehavior-ZDS92Qpp.js} +1 -1
- package/dist/assets/loader-circle-ACM1s51e.js +1 -0
- package/dist/assets/{logos-DEFUIR12.js → logos-x89HbrZ4.js} +1 -1
- package/dist/assets/{page-layout-Da3i3r6G.js → page-layout-vZnghcFy.js} +1 -1
- package/dist/assets/play-CFUwCA2E.js +1 -0
- package/dist/assets/plus-rYsv72JG.js +1 -0
- package/dist/assets/{popover-C_mWOFzI.js → popover-Bg1VoTZ6.js} +1 -1
- package/dist/assets/{refresh-ccw-D6HkNtfz.js → refresh-ccw-DT98i__E.js} +1 -1
- package/dist/assets/{refresh-cw-DRcvRrnc.js → refresh-cw-C47QSEwg.js} +1 -1
- package/dist/assets/{rotate-cw-BmDKfXtH.js → rotate-cw-JtFzpNn6.js} +1 -1
- package/dist/assets/{save-DHGmi2e9.js → save-3S6-H3Xw.js} +1 -1
- package/dist/assets/search-3kFR_zh9.js +1 -0
- package/dist/assets/{security-config-CbXfPZzr.js → security-config-BWaiARNk.js} +1 -1
- package/dist/assets/{select-Caud8QvU.js → select-DJ2MUjBB.js} +1 -1
- package/dist/assets/skeleton-ByQepn0M.js +1 -0
- package/dist/assets/{status-dot-DurKKSwA.js → status-dot-vbanNPFU.js} +1 -1
- package/dist/assets/{switch-0rmPBRKI.js → switch-BsLtHOH-.js} +1 -1
- package/dist/assets/{tabs-custom-5JLVL6v8.js → tabs-custom-D3HYMt6k.js} +1 -1
- package/dist/assets/{trash-2-C6caKPoz.js → trash-2-G48scll7.js} +1 -1
- package/dist/assets/{use-infinite-scroll-loader-dwnaa_qi.js → use-infinite-scroll-loader-DkNhD-42.js} +1 -1
- package/dist/assets/{useConfirmDialog-mMeWD_yo.js → useConfirmDialog-BkvTN-vd.js} +1 -1
- package/dist/assets/{useMutation-BmxxvCNf.js → useMutation-CBWjE2uj.js} +1 -1
- package/dist/assets/x-ByDbItbq.js +1 -0
- package/dist/index.html +95 -21
- package/dist/manifest.webmanifest +30 -0
- package/dist/offline.html +102 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/sw.js +80 -0
- package/index.html +73 -1
- package/package.json +6 -6
- package/public/manifest.webmanifest +30 -0
- package/public/offline.html +102 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/sw.js +80 -0
- package/src/api/server-path.ts +27 -4
- package/src/api/types.ts +17 -10
- package/src/app.tsx +9 -0
- package/src/components/chat/ChatSidebar.test.tsx +43 -1
- package/src/components/chat/ChatSidebar.tsx +24 -0
- package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
- package/src/components/chat/adapters/file-operation/card.ts +9 -0
- package/src/components/chat/adapters/file-operation/diff.ts +14 -0
- package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +107 -206
- package/src/components/chat/chat-conversation-panel.tsx +412 -0
- package/src/components/chat/chat-page-shell.tsx +1 -1
- package/src/components/chat/chat-session-workspace-file-preview.test.tsx +91 -0
- package/src/components/chat/chat-session-workspace-file-preview.tsx +307 -0
- package/src/components/chat/chat-session-workspace-panel-nav.tsx +197 -0
- package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
- package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
- package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
- package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +12 -0
- package/src/components/chat/managers/chat-session-list.manager.ts +7 -0
- package/src/components/chat/ncp/ncp-chat-page.tsx +7 -7
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +35 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +17 -0
- package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +54 -11
- package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
- package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
- package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
- package/src/components/chat/stores/chat-thread.store.ts +24 -0
- package/src/components/config/RuntimeConfig.tsx +141 -2
- package/src/components/layout/AppLayout.tsx +1 -1
- package/src/components/providers/ThemeProvider.tsx +5 -0
- package/src/hooks/server-path/use-server-path-read.ts +20 -0
- package/src/lib/chat-message.ts +14 -3
- package/src/lib/i18n.chat.ts +12 -1
- package/src/lib/i18n.pwa.ts +62 -0
- package/src/lib/i18n.ts +2 -2
- package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
- package/src/pwa/components/pwa-install-entry.tsx +205 -0
- package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
- package/src/pwa/managers/pwa-install.manager.ts +232 -0
- package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
- package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
- package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
- package/src/pwa/pwa-install-banner.storage.ts +55 -0
- package/src/pwa/pwa.types.ts +22 -0
- package/src/pwa/register-pwa.ts +14 -0
- package/src/pwa/stores/pwa.store.ts +17 -0
- package/src/vite-env.d.ts +9 -0
- package/dist/assets/ChannelsList-KIQIxluX.js +0 -8
- package/dist/assets/DocBrowser-CyDgAtO9.js +0 -1
- package/dist/assets/MarketplacePage-C0olZaek.js +0 -1
- package/dist/assets/McpMarketplacePage-DqKaiXO9.js +0 -40
- package/dist/assets/RemoteAccessPage-CyQlSjPf.js +0 -1
- package/dist/assets/RuntimeConfig-Bk0uYBhf.js +0 -1
- package/dist/assets/chat-page-Bph8M5zo.js +0 -58
- package/dist/assets/chat-session-display-CoN3Wmn-.js +0 -1
- package/dist/assets/desktop-update-config-1KBrqLBC.js +0 -1
- package/dist/assets/i18n-CwHZ-9vt.js +0 -1
- package/dist/assets/index-DafCdM4F.css +0 -1
- package/dist/assets/index-DdksE6U3.js +0 -6
- package/dist/assets/loader-circle-PsSP0H9n.js +0 -1
- package/dist/assets/play-DBQbBxTA.js +0 -1
- package/dist/assets/plus-DUOVbsyQ.js +0 -1
- package/dist/assets/search-MChQRYR1.js +0 -1
- package/dist/assets/skeleton-B-4vRq_Z.js +0 -1
- package/dist/assets/x-DuMhMATD.js +0 -1
- package/src/components/chat/ChatConversationPanel.tsx +0 -256
- package/src/components/chat/chat-child-session-panel.tsx +0 -270
- /package/dist/assets/{config-hints-BZoDjXye.js → config-hints-BhTmc9P1.js} +0 -0
- /package/dist/assets/{config-layout-DmlGaay2.js → config-layout-CHs0mAaR.js} +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { PwaInstallBanner, PwaInstallCard, PwaUpdateBanner } from '@/pwa/components/pwa-install-entry';
|
|
4
|
+
import { usePwaStore, createInitialPwaState } from '@/pwa/stores/pwa.store';
|
|
5
|
+
|
|
6
|
+
describe('PwaInstallCard', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
usePwaStore.setState(createInitialPwaState());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders install action when prompt install is available', () => {
|
|
12
|
+
usePwaStore.setState({
|
|
13
|
+
initialized: true,
|
|
14
|
+
installability: 'available',
|
|
15
|
+
installMethod: 'prompt',
|
|
16
|
+
blockedReason: null,
|
|
17
|
+
dismissedInstallPrompt: false,
|
|
18
|
+
updateAvailable: false,
|
|
19
|
+
registrationFailed: false
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
render(<PwaInstallCard />);
|
|
23
|
+
|
|
24
|
+
expect(screen.getByRole('button', { name: 'Install NextClaw' })).toBeTruthy();
|
|
25
|
+
expect(screen.getByText('Installable')).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders desktop host description when suppressed', () => {
|
|
29
|
+
usePwaStore.setState({
|
|
30
|
+
initialized: true,
|
|
31
|
+
installability: 'suppressed',
|
|
32
|
+
installMethod: 'none',
|
|
33
|
+
blockedReason: 'desktop-host',
|
|
34
|
+
dismissedInstallPrompt: false,
|
|
35
|
+
updateAvailable: false,
|
|
36
|
+
registrationFailed: false
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
render(<PwaInstallCard />);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByText('Desktop Host Active')).toBeTruthy();
|
|
42
|
+
expect(screen.getByText(/already running inside the Electron desktop host/i)).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not render update banner before installation', () => {
|
|
46
|
+
usePwaStore.setState({
|
|
47
|
+
initialized: true,
|
|
48
|
+
installability: 'available',
|
|
49
|
+
installMethod: 'manual',
|
|
50
|
+
blockedReason: null,
|
|
51
|
+
dismissedInstallPrompt: false,
|
|
52
|
+
updateAvailable: true,
|
|
53
|
+
registrationFailed: false
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const { container } = render(<PwaUpdateBanner />);
|
|
57
|
+
|
|
58
|
+
expect(container.textContent).toBe('');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders update banner only for installed pwa', () => {
|
|
62
|
+
usePwaStore.setState({
|
|
63
|
+
initialized: true,
|
|
64
|
+
installability: 'installed',
|
|
65
|
+
installMethod: 'none',
|
|
66
|
+
blockedReason: null,
|
|
67
|
+
dismissedInstallPrompt: false,
|
|
68
|
+
updateAvailable: true,
|
|
69
|
+
registrationFailed: false
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
render(<PwaUpdateBanner />);
|
|
73
|
+
|
|
74
|
+
expect(screen.getByText('NextClaw Update Ready')).toBeTruthy();
|
|
75
|
+
expect(screen.getByRole('button', { name: 'Refresh Now' })).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders install banner when prompt install is available and not dismissed', () => {
|
|
79
|
+
usePwaStore.setState({
|
|
80
|
+
initialized: true,
|
|
81
|
+
installability: 'available',
|
|
82
|
+
installMethod: 'prompt',
|
|
83
|
+
blockedReason: null,
|
|
84
|
+
dismissedInstallPrompt: false,
|
|
85
|
+
updateAvailable: false,
|
|
86
|
+
registrationFailed: false
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
render(<PwaInstallBanner />);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByText('Pin NextClaw as an App')).toBeTruthy();
|
|
92
|
+
expect(screen.getByRole('button', { name: 'Install NextClaw' })).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not render install banner after dismissal', () => {
|
|
96
|
+
usePwaStore.setState({
|
|
97
|
+
initialized: true,
|
|
98
|
+
installability: 'available',
|
|
99
|
+
installMethod: 'prompt',
|
|
100
|
+
blockedReason: null,
|
|
101
|
+
dismissedInstallPrompt: true,
|
|
102
|
+
updateAvailable: false,
|
|
103
|
+
registrationFailed: false
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const { container } = render(<PwaInstallBanner />);
|
|
107
|
+
|
|
108
|
+
expect(container.textContent).toBe('');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Button } from '@/components/ui/button';
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
3
|
+
import { usePwaStore } from '@/pwa/stores/pwa.store';
|
|
4
|
+
import { pwaInstallManager } from '@/pwa/managers/pwa-install.manager';
|
|
5
|
+
import { pwaRuntimeManager } from '@/pwa/managers/pwa-runtime.manager';
|
|
6
|
+
import { t } from '@/lib/i18n';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
import { Download, RefreshCw, Smartphone, X } from 'lucide-react';
|
|
9
|
+
|
|
10
|
+
function InstallStatusBadge() {
|
|
11
|
+
const installability = usePwaStore((state) => state.installability);
|
|
12
|
+
|
|
13
|
+
const badgeClassName =
|
|
14
|
+
installability === 'installed'
|
|
15
|
+
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
|
16
|
+
: installability === 'available'
|
|
17
|
+
? 'bg-amber-50 text-amber-700 border-amber-200'
|
|
18
|
+
: 'bg-gray-100 text-gray-600 border-gray-200';
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<span className={cn('inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium', badgeClassName)}>
|
|
22
|
+
{resolveInstallabilityLabel(installability)}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveInstallabilityLabel(installability: string): string {
|
|
28
|
+
if (installability === 'available') {
|
|
29
|
+
return t('pwaInstallStatusAvailable');
|
|
30
|
+
}
|
|
31
|
+
if (installability === 'installed') {
|
|
32
|
+
return t('pwaInstallStatusInstalled');
|
|
33
|
+
}
|
|
34
|
+
if (installability === 'suppressed') {
|
|
35
|
+
return t('pwaInstallStatusDesktopHost');
|
|
36
|
+
}
|
|
37
|
+
return t('pwaInstallStatusUnavailable');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveCardDescription(
|
|
41
|
+
installability: string,
|
|
42
|
+
installMethod: string,
|
|
43
|
+
blockedReason: string | null
|
|
44
|
+
) {
|
|
45
|
+
if (installability === 'installed') {
|
|
46
|
+
return t('pwaInstallCardInstalled');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (installability === 'suppressed') {
|
|
50
|
+
return t('pwaInstallCardSuppressed');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (installability === 'available' && installMethod === 'prompt') {
|
|
54
|
+
return t('pwaInstallCardPrompt');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (installability === 'available') {
|
|
58
|
+
return t('pwaInstallCardManual');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (blockedReason === 'insecure-context') {
|
|
62
|
+
return t('pwaInstallCardInsecureContext');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (blockedReason === 'dev-server') {
|
|
66
|
+
return t('pwaInstallCardDevServer');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return t('pwaInstallCardUnsupported');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function PwaInstallCard() {
|
|
73
|
+
const installability = usePwaStore((state) => state.installability);
|
|
74
|
+
const installMethod = usePwaStore((state) => state.installMethod);
|
|
75
|
+
const blockedReason = usePwaStore((state) => state.blockedReason);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Card>
|
|
79
|
+
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
<CardTitle>{t('pwaInstallTitle')}</CardTitle>
|
|
82
|
+
<CardDescription>{t('pwaInstallDescription')}</CardDescription>
|
|
83
|
+
</div>
|
|
84
|
+
<InstallStatusBadge />
|
|
85
|
+
</CardHeader>
|
|
86
|
+
<CardContent className="space-y-4">
|
|
87
|
+
<div className="rounded-2xl border border-gray-200 bg-gray-50/90 p-4">
|
|
88
|
+
<p className="text-sm leading-6 text-gray-700">
|
|
89
|
+
{resolveCardDescription(installability, installMethod, blockedReason)}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{installability === 'available' && installMethod === 'prompt' ? (
|
|
94
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
95
|
+
<Button
|
|
96
|
+
type="button"
|
|
97
|
+
className="gap-2"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
void pwaInstallManager.promptInstall();
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<Download className="h-4 w-4" />
|
|
103
|
+
{t('pwaInstallAction')}
|
|
104
|
+
</Button>
|
|
105
|
+
<p className="text-sm text-gray-500">{t('pwaInstallPromptHint')}</p>
|
|
106
|
+
</div>
|
|
107
|
+
) : null}
|
|
108
|
+
|
|
109
|
+
{installability === 'available' && installMethod === 'manual' ? (
|
|
110
|
+
<div className="flex items-start gap-3 rounded-2xl border border-dashed border-gray-300 bg-white p-4">
|
|
111
|
+
<Smartphone className="mt-0.5 h-4 w-4 text-gray-500" />
|
|
112
|
+
<p className="text-sm leading-6 text-gray-600">{t('pwaInstallManualHint')}</p>
|
|
113
|
+
</div>
|
|
114
|
+
) : null}
|
|
115
|
+
</CardContent>
|
|
116
|
+
</Card>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function PwaInstallBanner() {
|
|
121
|
+
const installability = usePwaStore((state) => state.installability);
|
|
122
|
+
const installMethod = usePwaStore((state) => state.installMethod);
|
|
123
|
+
const dismissedInstallPrompt = usePwaStore((state) => state.dismissedInstallPrompt);
|
|
124
|
+
|
|
125
|
+
if (installability !== 'available' || installMethod !== 'prompt' || dismissedInstallPrompt) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="fixed bottom-5 right-5 z-50 w-[min(420px,calc(100vw-2rem))] rounded-[26px] border border-gray-200 bg-white/95 p-5 shadow-2xl backdrop-blur-xl">
|
|
131
|
+
<div className="flex items-start justify-between gap-4">
|
|
132
|
+
<div className="space-y-1">
|
|
133
|
+
<p className="text-sm font-semibold text-gray-900">{t('pwaInstallBannerTitle')}</p>
|
|
134
|
+
<p className="text-sm leading-6 text-gray-600">{t('pwaInstallBannerDescription')}</p>
|
|
135
|
+
</div>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
aria-label={t('pwaInstallDismiss')}
|
|
139
|
+
className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
140
|
+
onClick={() => {
|
|
141
|
+
pwaInstallManager.dismissInstallPrompt();
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<X className="h-4 w-4" />
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="mt-4 flex items-center gap-3">
|
|
148
|
+
<Button
|
|
149
|
+
type="button"
|
|
150
|
+
size="sm"
|
|
151
|
+
className="gap-2"
|
|
152
|
+
onClick={() => {
|
|
153
|
+
void pwaInstallManager.promptInstall();
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<Download className="h-4 w-4" />
|
|
157
|
+
{t('pwaInstallAction')}
|
|
158
|
+
</Button>
|
|
159
|
+
<Button
|
|
160
|
+
type="button"
|
|
161
|
+
size="sm"
|
|
162
|
+
variant="ghost"
|
|
163
|
+
onClick={() => {
|
|
164
|
+
pwaInstallManager.dismissInstallPrompt();
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{t('pwaInstallDismiss')}
|
|
168
|
+
</Button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function PwaUpdateBanner() {
|
|
175
|
+
const updateAvailable = usePwaStore((state) => state.updateAvailable);
|
|
176
|
+
const installability = usePwaStore((state) => state.installability);
|
|
177
|
+
|
|
178
|
+
if (!updateAvailable || installability !== 'installed') {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div className="fixed top-5 right-5 z-50 w-[min(420px,calc(100vw-2rem))] rounded-[26px] border border-gray-200 bg-white/95 p-5 shadow-2xl backdrop-blur-xl">
|
|
184
|
+
<div className="flex items-start justify-between gap-4">
|
|
185
|
+
<div className="space-y-1">
|
|
186
|
+
<p className="text-sm font-semibold text-gray-900">{t('pwaUpdateBannerTitle')}</p>
|
|
187
|
+
<p className="text-sm leading-6 text-gray-600">{t('pwaUpdateBannerDescription')}</p>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="mt-4">
|
|
191
|
+
<Button
|
|
192
|
+
type="button"
|
|
193
|
+
size="sm"
|
|
194
|
+
className="gap-2"
|
|
195
|
+
onClick={() => {
|
|
196
|
+
void pwaRuntimeManager.applyUpdate();
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<RefreshCw className="h-4 w-4" />
|
|
200
|
+
{t('pwaUpdateAction')}
|
|
201
|
+
</Button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PwaInstallManager } from '@/pwa/managers/pwa-install.manager';
|
|
3
|
+
import {
|
|
4
|
+
PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY,
|
|
5
|
+
PWA_INSTALL_BANNER_LEGACY_UNTIL_STORAGE_KEY
|
|
6
|
+
} from '@/pwa/pwa-install-banner.storage';
|
|
7
|
+
import { usePwaStore, createInitialPwaState } from '@/pwa/stores/pwa.store';
|
|
8
|
+
|
|
9
|
+
function createMatchMedia(matches = false): typeof window.matchMedia {
|
|
10
|
+
return vi.fn().mockImplementation(() => ({
|
|
11
|
+
matches,
|
|
12
|
+
media: '(display-mode: standalone)',
|
|
13
|
+
onchange: null,
|
|
14
|
+
addListener: vi.fn(),
|
|
15
|
+
removeListener: vi.fn(),
|
|
16
|
+
addEventListener: vi.fn(),
|
|
17
|
+
removeEventListener: vi.fn(),
|
|
18
|
+
dispatchEvent: vi.fn()
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createBeforeInstallPromptEvent(outcome: 'accepted' | 'dismissed' = 'accepted'): BeforeInstallPromptEvent {
|
|
23
|
+
const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent;
|
|
24
|
+
event.preventDefault = vi.fn();
|
|
25
|
+
event.prompt = vi.fn();
|
|
26
|
+
event.userChoice = Promise.resolve({ outcome, platform: 'web' });
|
|
27
|
+
return event;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('PwaInstallManager', () => {
|
|
31
|
+
let manager: PwaInstallManager;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
manager = new PwaInstallManager();
|
|
35
|
+
usePwaStore.setState(createInitialPwaState());
|
|
36
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
37
|
+
writable: true,
|
|
38
|
+
value: createMatchMedia(false)
|
|
39
|
+
});
|
|
40
|
+
Object.defineProperty(window, 'isSecureContext', {
|
|
41
|
+
configurable: true,
|
|
42
|
+
value: true
|
|
43
|
+
});
|
|
44
|
+
Object.defineProperty(window, 'nextclawDesktop', {
|
|
45
|
+
configurable: true,
|
|
46
|
+
value: undefined
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(navigator, 'serviceWorker', {
|
|
49
|
+
configurable: true,
|
|
50
|
+
value: {}
|
|
51
|
+
});
|
|
52
|
+
window.localStorage.clear();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
manager.resetForTests();
|
|
57
|
+
window.localStorage.clear();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('marks desktop host as suppressed', () => {
|
|
61
|
+
Object.defineProperty(window, 'nextclawDesktop', {
|
|
62
|
+
configurable: true,
|
|
63
|
+
value: {}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
manager.start();
|
|
67
|
+
|
|
68
|
+
const state = usePwaStore.getState();
|
|
69
|
+
expect(state.installability).toBe('suppressed');
|
|
70
|
+
expect(state.blockedReason).toBe('desktop-host');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('marks installed when display mode is standalone', () => {
|
|
74
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
75
|
+
writable: true,
|
|
76
|
+
value: createMatchMedia(true)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
manager.start();
|
|
80
|
+
|
|
81
|
+
const state = usePwaStore.getState();
|
|
82
|
+
expect(state.installability).toBe('installed');
|
|
83
|
+
expect(state.installMethod).toBe('none');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to manual install when prompt event is unavailable', () => {
|
|
87
|
+
manager.start();
|
|
88
|
+
|
|
89
|
+
const state = usePwaStore.getState();
|
|
90
|
+
expect(state.installability).toBe('available');
|
|
91
|
+
expect(state.installMethod).toBe('manual');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('marks insecure non-local origins as unsupported', () => {
|
|
95
|
+
Object.defineProperty(window, 'isSecureContext', {
|
|
96
|
+
configurable: true,
|
|
97
|
+
value: false
|
|
98
|
+
});
|
|
99
|
+
Object.defineProperty(window, 'location', {
|
|
100
|
+
configurable: true,
|
|
101
|
+
value: new URL('http://192.168.1.7:5174')
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
manager.start();
|
|
105
|
+
|
|
106
|
+
const state = usePwaStore.getState();
|
|
107
|
+
expect(state.installability).toBe('unsupported');
|
|
108
|
+
expect(state.blockedReason).toBe('insecure-context');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('keeps install banner dismissed after beforeinstallprompt fires again', () => {
|
|
112
|
+
manager.start();
|
|
113
|
+
window.dispatchEvent(createBeforeInstallPromptEvent());
|
|
114
|
+
|
|
115
|
+
manager.dismissInstallPrompt();
|
|
116
|
+
expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
|
|
117
|
+
|
|
118
|
+
window.dispatchEvent(createBeforeInstallPromptEvent());
|
|
119
|
+
|
|
120
|
+
expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
|
|
121
|
+
expect(window.localStorage.getItem(PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY)).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps install banner dismissed after user dismisses the native install prompt', async () => {
|
|
125
|
+
manager.start();
|
|
126
|
+
const promptEvent = createBeforeInstallPromptEvent('dismissed');
|
|
127
|
+
window.dispatchEvent(promptEvent);
|
|
128
|
+
|
|
129
|
+
const outcome = await manager.promptInstall();
|
|
130
|
+
|
|
131
|
+
expect(outcome).toBe('dismissed');
|
|
132
|
+
expect(promptEvent.prompt).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
|
|
134
|
+
expect(window.localStorage.getItem(PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY)).toBe('1');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('hydrates dismissed state from persisted dismiss flag on fresh store init', () => {
|
|
138
|
+
window.localStorage.setItem(
|
|
139
|
+
PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY,
|
|
140
|
+
'1'
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
usePwaStore.setState(createInitialPwaState());
|
|
144
|
+
|
|
145
|
+
expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('migrates legacy snooze timestamps into the permanent dismiss flag', () => {
|
|
149
|
+
window.localStorage.setItem(
|
|
150
|
+
PWA_INSTALL_BANNER_LEGACY_UNTIL_STORAGE_KEY,
|
|
151
|
+
String(Date.now() + 60_000)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
usePwaStore.setState(createInitialPwaState());
|
|
155
|
+
|
|
156
|
+
expect(usePwaStore.getState().dismissedInstallPrompt).toBe(true);
|
|
157
|
+
expect(window.localStorage.getItem(PWA_INSTALL_BANNER_DISMISS_STORAGE_KEY)).toBe('1');
|
|
158
|
+
expect(window.localStorage.getItem(PWA_INSTALL_BANNER_LEGACY_UNTIL_STORAGE_KEY)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { usePwaStore, createInitialPwaState } from '@/pwa/stores/pwa.store';
|
|
2
|
+
import type { PwaInstallBlockedReason, PwaInstallMethod, PwaInstallPromptOutcome, PwaInstallabilityState } from '@/pwa/pwa.types';
|
|
3
|
+
import { pwaRuntimeManager } from '@/pwa/managers/pwa-runtime.manager';
|
|
4
|
+
import {
|
|
5
|
+
clearPwaInstallBannerDismissal,
|
|
6
|
+
dismissPwaInstallBanner,
|
|
7
|
+
isPwaInstallBannerDismissed
|
|
8
|
+
} from '@/pwa/pwa-install-banner.storage';
|
|
9
|
+
import { t } from '@/lib/i18n';
|
|
10
|
+
import { toast } from 'sonner';
|
|
11
|
+
|
|
12
|
+
type InstallabilityResolution = {
|
|
13
|
+
installability: PwaInstallabilityState;
|
|
14
|
+
installMethod: PwaInstallMethod;
|
|
15
|
+
blockedReason: PwaInstallBlockedReason;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class PwaInstallManager {
|
|
19
|
+
private started = false;
|
|
20
|
+
private deferredPrompt: BeforeInstallPromptEvent | null = null;
|
|
21
|
+
private displayModeMediaQuery: MediaQueryList | null = null;
|
|
22
|
+
|
|
23
|
+
start = () => {
|
|
24
|
+
if (this.started || typeof window === 'undefined') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.started = true;
|
|
29
|
+
window.addEventListener('beforeinstallprompt', this.handleBeforeInstallPrompt as EventListener);
|
|
30
|
+
window.addEventListener('appinstalled', this.handleAppInstalled);
|
|
31
|
+
|
|
32
|
+
if (typeof window.matchMedia === 'function') {
|
|
33
|
+
this.displayModeMediaQuery = window.matchMedia('(display-mode: standalone)');
|
|
34
|
+
this.bindDisplayModeListener(this.displayModeMediaQuery, this.handleDisplayModeChanged);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.refreshState();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
stop = () => {
|
|
41
|
+
if (!this.started || typeof window === 'undefined') {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
window.removeEventListener('beforeinstallprompt', this.handleBeforeInstallPrompt as EventListener);
|
|
46
|
+
window.removeEventListener('appinstalled', this.handleAppInstalled);
|
|
47
|
+
if (this.displayModeMediaQuery) {
|
|
48
|
+
this.unbindDisplayModeListener(this.displayModeMediaQuery, this.handleDisplayModeChanged);
|
|
49
|
+
this.displayModeMediaQuery = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.deferredPrompt = null;
|
|
53
|
+
this.started = false;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
resetForTests = () => {
|
|
57
|
+
this.stop();
|
|
58
|
+
usePwaStore.setState(createInitialPwaState());
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
refreshState = () => {
|
|
62
|
+
const resolution = this.resolveInstallability();
|
|
63
|
+
usePwaStore.setState((state) => ({
|
|
64
|
+
...state,
|
|
65
|
+
initialized: true,
|
|
66
|
+
installability: resolution.installability,
|
|
67
|
+
installMethod: resolution.installMethod,
|
|
68
|
+
blockedReason: resolution.blockedReason,
|
|
69
|
+
dismissedInstallPrompt: isPwaInstallBannerDismissed()
|
|
70
|
+
}));
|
|
71
|
+
void pwaRuntimeManager.syncUpdateAvailability();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
dismissInstallPrompt = () => {
|
|
75
|
+
dismissPwaInstallBanner();
|
|
76
|
+
usePwaStore.setState({
|
|
77
|
+
dismissedInstallPrompt: true
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
promptInstall = async (): Promise<PwaInstallPromptOutcome> => {
|
|
82
|
+
const resolution = this.resolveInstallability();
|
|
83
|
+
if (resolution.installability !== 'available') {
|
|
84
|
+
return 'unavailable';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (resolution.installMethod === 'manual' || !this.deferredPrompt) {
|
|
88
|
+
return 'manual';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.deferredPrompt.prompt();
|
|
92
|
+
const result = await this.deferredPrompt.userChoice;
|
|
93
|
+
this.deferredPrompt = null;
|
|
94
|
+
if (result.outcome === 'accepted') {
|
|
95
|
+
clearPwaInstallBannerDismissal();
|
|
96
|
+
this.refreshState();
|
|
97
|
+
toast.success(t('pwaInstallAccepted'));
|
|
98
|
+
return 'accepted';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.dismissInstallPrompt();
|
|
102
|
+
this.refreshState();
|
|
103
|
+
return 'dismissed';
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
private handleBeforeInstallPrompt = (event: BeforeInstallPromptEvent) => {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
this.deferredPrompt = event;
|
|
109
|
+
this.refreshState();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
private handleAppInstalled = () => {
|
|
113
|
+
this.deferredPrompt = null;
|
|
114
|
+
clearPwaInstallBannerDismissal();
|
|
115
|
+
usePwaStore.setState({
|
|
116
|
+
dismissedInstallPrompt: true
|
|
117
|
+
});
|
|
118
|
+
toast.success(t('pwaInstalledToast'));
|
|
119
|
+
this.refreshState();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
private handleDisplayModeChanged = () => {
|
|
123
|
+
this.refreshState();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
private resolveInstallability = (): InstallabilityResolution => {
|
|
127
|
+
if (this.isDevelopmentServer()) {
|
|
128
|
+
return {
|
|
129
|
+
installability: 'unsupported',
|
|
130
|
+
installMethod: 'none',
|
|
131
|
+
blockedReason: 'dev-server'
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (this.hasDesktopHost()) {
|
|
136
|
+
return {
|
|
137
|
+
installability: 'suppressed',
|
|
138
|
+
installMethod: 'none',
|
|
139
|
+
blockedReason: 'desktop-host'
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.isStandalone()) {
|
|
144
|
+
return {
|
|
145
|
+
installability: 'installed',
|
|
146
|
+
installMethod: 'none',
|
|
147
|
+
blockedReason: null
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!this.hasInstallSurfaceSupport()) {
|
|
152
|
+
return {
|
|
153
|
+
installability: 'unsupported',
|
|
154
|
+
installMethod: 'none',
|
|
155
|
+
blockedReason: 'missing-browser-support'
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!this.isEligibleInstallContext()) {
|
|
160
|
+
return {
|
|
161
|
+
installability: 'unsupported',
|
|
162
|
+
installMethod: 'none',
|
|
163
|
+
blockedReason: 'insecure-context'
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
installability: 'available',
|
|
169
|
+
installMethod: this.deferredPrompt ? 'prompt' : 'manual',
|
|
170
|
+
blockedReason: null
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
private hasDesktopHost = (): boolean => {
|
|
175
|
+
return typeof window !== 'undefined' && Boolean(window.nextclawDesktop);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
private hasInstallSurfaceSupport = (): boolean => {
|
|
179
|
+
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && 'serviceWorker' in navigator;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
private isEligibleInstallContext = (): boolean => {
|
|
183
|
+
if (typeof window === 'undefined') {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return window.isSecureContext || this.isTrustedLocalhost(window.location.hostname);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
private isTrustedLocalhost = (hostname: string): boolean => {
|
|
190
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
private isDevelopmentServer = (): boolean => {
|
|
194
|
+
return typeof window !== 'undefined' && import.meta.env.DEV && !import.meta.env.VITEST;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
private isStandalone = (): boolean => {
|
|
198
|
+
if (typeof window === 'undefined') {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean };
|
|
203
|
+
const matchesStandalone =
|
|
204
|
+
typeof window.matchMedia === 'function' && window.matchMedia('(display-mode: standalone)').matches;
|
|
205
|
+
return matchesStandalone || navigatorWithStandalone.standalone === true;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
private bindDisplayModeListener = (query: MediaQueryList, listener: () => void) => {
|
|
209
|
+
if ('addEventListener' in query) {
|
|
210
|
+
query.addEventListener('change', listener);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const legacyQuery = query as MediaQueryList & {
|
|
214
|
+
addListener?: (callback: (event: MediaQueryListEvent) => void) => void;
|
|
215
|
+
};
|
|
216
|
+
legacyQuery.addListener?.(listener);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
private unbindDisplayModeListener = (query: MediaQueryList, listener: () => void) => {
|
|
220
|
+
if ('removeEventListener' in query) {
|
|
221
|
+
query.removeEventListener('change', listener);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const legacyQuery = query as MediaQueryList & {
|
|
225
|
+
removeListener?: (callback: (event: MediaQueryListEvent) => void) => void;
|
|
226
|
+
};
|
|
227
|
+
legacyQuery.removeListener?.(listener);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const pwaInstallManager = new PwaInstallManager();
|