botschat 0.1.10 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +20 -1
- package/packages/api/src/do/connection-do.ts +142 -24
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +7 -0
- package/packages/api/src/routes/auth.ts +85 -9
- package/packages/api/src/routes/channels.ts +3 -2
- package/packages/api/src/routes/dev-auth.ts +45 -0
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/routes/upload.ts +73 -38
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +218 -0
- package/packages/plugin/dist/src/channel.d.ts +6 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +71 -15
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
- package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
- package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
- package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
- package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
- package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
- package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
- package/packages/web/dist/index.html +6 -4
- package/packages/web/dist/sw.js +158 -1
- package/packages/web/index.html +4 -2
- package/packages/web/package.json +4 -1
- package/packages/web/src/App.tsx +117 -1
- package/packages/web/src/api.ts +21 -1
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/ChatWindow.tsx +302 -70
- package/packages/web/src/components/CronSidebar.tsx +89 -24
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +55 -7
- package/packages/web/src/components/MessageContent.tsx +71 -9
- package/packages/web/src/components/MobileLayout.tsx +28 -118
- package/packages/web/src/components/SessionTabs.tsx +41 -2
- package/packages/web/src/components/Sidebar.tsx +88 -66
- package/packages/web/src/e2e.ts +26 -5
- package/packages/web/src/firebase.ts +215 -3
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/index.css +10 -2
- package/packages/web/src/main.tsx +24 -2
- package/packages/web/src/push.ts +205 -0
- package/packages/web/src/ws.ts +20 -8
- package/scripts/dev.sh +158 -26
- package/scripts/mock-openclaw.mjs +382 -0
- package/scripts/test-e2e-chat.ts +2 -2
- package/scripts/test-e2e-live.ts +1 -1
- package/wrangler.toml +3 -0
- package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
|
@@ -7,14 +7,24 @@
|
|
|
7
7
|
* VITE_FIREBASE_API_KEY=AIzaSy...
|
|
8
8
|
* VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
|
9
9
|
* VITE_FIREBASE_PROJECT_ID=your-project-id
|
|
10
|
+
*
|
|
11
|
+
* For Capacitor iOS/Android, also set:
|
|
12
|
+
* VITE_GOOGLE_IOS_CLIENT_ID=xxx.apps.googleusercontent.com
|
|
13
|
+
* VITE_GOOGLE_WEB_CLIENT_ID=xxx.apps.googleusercontent.com
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import { Capacitor } from "@capacitor/core";
|
|
12
17
|
import { initializeApp, type FirebaseApp } from "firebase/app";
|
|
13
18
|
import {
|
|
14
19
|
getAuth,
|
|
15
20
|
GoogleAuthProvider,
|
|
16
21
|
GithubAuthProvider,
|
|
22
|
+
OAuthProvider,
|
|
17
23
|
signInWithPopup,
|
|
24
|
+
signInWithCredential,
|
|
25
|
+
indexedDBLocalPersistence,
|
|
26
|
+
inMemoryPersistence,
|
|
27
|
+
setPersistence,
|
|
18
28
|
type Auth,
|
|
19
29
|
} from "firebase/auth";
|
|
20
30
|
|
|
@@ -22,6 +32,8 @@ const firebaseConfig = {
|
|
|
22
32
|
apiKey: import.meta.env.VITE_FIREBASE_API_KEY as string,
|
|
23
33
|
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string,
|
|
24
34
|
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID as string,
|
|
35
|
+
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID as string,
|
|
36
|
+
appId: import.meta.env.VITE_FIREBASE_APP_ID as string,
|
|
25
37
|
};
|
|
26
38
|
|
|
27
39
|
let app: FirebaseApp | null = null;
|
|
@@ -32,6 +44,14 @@ export function isFirebaseConfigured(): boolean {
|
|
|
32
44
|
return !!(firebaseConfig.apiKey && firebaseConfig.authDomain && firebaseConfig.projectId);
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
/** Ensure the Firebase app is initialized (for FCM, independent of OAuth). */
|
|
48
|
+
export function ensureFirebaseApp(): FirebaseApp | null {
|
|
49
|
+
if (app) return app;
|
|
50
|
+
if (!isFirebaseConfigured()) return null;
|
|
51
|
+
app = initializeApp(firebaseConfig);
|
|
52
|
+
return app;
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
function getFirebaseAuth(): Auth {
|
|
36
56
|
if (!auth) {
|
|
37
57
|
if (!isFirebaseConfigured()) {
|
|
@@ -39,6 +59,12 @@ function getFirebaseAuth(): Auth {
|
|
|
39
59
|
}
|
|
40
60
|
app = initializeApp(firebaseConfig);
|
|
41
61
|
auth = getAuth(app);
|
|
62
|
+
|
|
63
|
+
// In Capacitor native, WKWebView's IndexedDB can hang Firebase Auth.
|
|
64
|
+
// Use in-memory persistence to avoid this.
|
|
65
|
+
if (Capacitor.isNativePlatform()) {
|
|
66
|
+
setPersistence(auth, inMemoryPersistence).catch(() => {});
|
|
67
|
+
}
|
|
42
68
|
}
|
|
43
69
|
return auth;
|
|
44
70
|
}
|
|
@@ -48,13 +74,121 @@ export type FirebaseSignInResult = {
|
|
|
48
74
|
email: string;
|
|
49
75
|
displayName: string | null;
|
|
50
76
|
photoURL: string | null;
|
|
51
|
-
provider: "google" | "github";
|
|
77
|
+
provider: "google" | "github" | "apple";
|
|
52
78
|
};
|
|
53
79
|
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Native Google Sign-In via @capgo/capacitor-social-login
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
let _socialLoginInitialized = false;
|
|
85
|
+
|
|
86
|
+
/** Race a promise against a timeout. */
|
|
87
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
88
|
+
return new Promise<T>((resolve, reject) => {
|
|
89
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
90
|
+
promise.then(
|
|
91
|
+
(v) => { clearTimeout(timer); resolve(v); },
|
|
92
|
+
(e) => { clearTimeout(timer); reject(e); },
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
54
97
|
/**
|
|
55
|
-
*
|
|
98
|
+
* Initialize the native social login plugin (called once).
|
|
99
|
+
* On web (non-Capacitor) this is a no-op.
|
|
100
|
+
*/
|
|
101
|
+
async function ensureNativeGoogleInit(): Promise<void> {
|
|
102
|
+
if (!Capacitor.isNativePlatform() || _socialLoginInitialized) return;
|
|
103
|
+
|
|
104
|
+
const { SocialLogin } = await import("@capgo/capacitor-social-login");
|
|
105
|
+
|
|
106
|
+
const iosClientId = import.meta.env.VITE_GOOGLE_IOS_CLIENT_ID as string | undefined;
|
|
107
|
+
const webClientId = import.meta.env.VITE_GOOGLE_WEB_CLIENT_ID as string | undefined;
|
|
108
|
+
const platform = Capacitor.getPlatform();
|
|
109
|
+
|
|
110
|
+
console.log("[NativeGoogleSignIn] initialize: platform =", platform, "iOSClientId =", iosClientId?.substring(0, 20) + "...", "webClientId =", webClientId?.substring(0, 20) + "...");
|
|
111
|
+
|
|
112
|
+
await withTimeout(
|
|
113
|
+
SocialLogin.initialize({
|
|
114
|
+
google: {
|
|
115
|
+
webClientId: webClientId || undefined,
|
|
116
|
+
iOSClientId: iosClientId || undefined,
|
|
117
|
+
iOSServerClientId: webClientId || undefined,
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
10000,
|
|
121
|
+
"SocialLogin.initialize",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
_socialLoginInitialized = true;
|
|
125
|
+
console.log("[NativeGoogleSignIn] initialized OK");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Perform native Google Sign-In on iOS/Android, then exchange the Google
|
|
130
|
+
* credential for a Firebase ID token via `signInWithCredential`.
|
|
131
|
+
*/
|
|
132
|
+
async function nativeGoogleSignIn(): Promise<FirebaseSignInResult> {
|
|
133
|
+
console.log("[NativeGoogleSignIn] Step 1: ensureNativeGoogleInit");
|
|
134
|
+
await ensureNativeGoogleInit();
|
|
135
|
+
|
|
136
|
+
console.log("[NativeGoogleSignIn] Step 2: calling SocialLogin.login()");
|
|
137
|
+
const { SocialLogin } = await import("@capgo/capacitor-social-login");
|
|
138
|
+
|
|
139
|
+
// SocialLogin.login() opens native Google UI — user picks account.
|
|
140
|
+
// No timeout here because user interaction takes variable time.
|
|
141
|
+
const res = await SocialLogin.login({
|
|
142
|
+
provider: "google",
|
|
143
|
+
options: { scopes: ["email", "profile"] },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
console.log("[NativeGoogleSignIn] Step 3: SocialLogin.login() returned, responseType =", res?.result?.responseType);
|
|
147
|
+
|
|
148
|
+
const googleResult = res.result;
|
|
149
|
+
|
|
150
|
+
// Narrow the union: online mode returns idToken + profile, offline returns serverAuthCode
|
|
151
|
+
if (googleResult.responseType !== "online") {
|
|
152
|
+
throw new Error(`Google Sign-In returned '${googleResult.responseType}' response; expected 'online'. Full result: ${JSON.stringify(res)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const googleIdToken = googleResult.idToken;
|
|
156
|
+
console.log("[NativeGoogleSignIn] Step 4: idToken present =", !!googleIdToken, ", length =", googleIdToken?.length ?? 0);
|
|
157
|
+
|
|
158
|
+
if (!googleIdToken) {
|
|
159
|
+
throw new Error("Google Sign-In did not return an idToken. Ensure Web Client ID (iOSServerClientId) is correct.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Send the Google ID token directly to the backend — backend now verifies
|
|
163
|
+
// both Firebase ID tokens and native Google ID tokens.
|
|
164
|
+
// (Firebase signInWithCredential hangs in WKWebView on real devices.)
|
|
165
|
+
console.log("[NativeGoogleSignIn] Step 5: Skipping Firebase client, sending Google ID token directly to backend");
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
idToken: googleIdToken,
|
|
169
|
+
email: googleResult.profile.email ?? "",
|
|
170
|
+
displayName: googleResult.profile.name ?? null,
|
|
171
|
+
photoURL: googleResult.profile.imageUrl ?? null,
|
|
172
|
+
provider: "google",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Public API
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sign in with Google.
|
|
182
|
+
* - Web: Firebase popup
|
|
183
|
+
* - Native (iOS/Android): Native Google Sign-In → Firebase credential
|
|
56
184
|
*/
|
|
57
185
|
export async function signInWithGoogle(): Promise<FirebaseSignInResult> {
|
|
186
|
+
// Native: use @capgo/capacitor-social-login + Firebase signInWithCredential
|
|
187
|
+
if (Capacitor.isNativePlatform()) {
|
|
188
|
+
return nativeGoogleSignIn();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Web: use Firebase popup (works fine in browsers)
|
|
58
192
|
const firebaseAuth = getFirebaseAuth();
|
|
59
193
|
const provider = new GoogleAuthProvider();
|
|
60
194
|
provider.addScope("email");
|
|
@@ -73,7 +207,10 @@ export async function signInWithGoogle(): Promise<FirebaseSignInResult> {
|
|
|
73
207
|
}
|
|
74
208
|
|
|
75
209
|
/**
|
|
76
|
-
* Sign in with GitHub
|
|
210
|
+
* Sign in with GitHub.
|
|
211
|
+
* - Web: Firebase popup
|
|
212
|
+
* - Native: Firebase popup (GitHub OAuth works in WKWebView with some config)
|
|
213
|
+
* TODO: Implement native GitHub OAuth if popup doesn't work on native.
|
|
77
214
|
*/
|
|
78
215
|
export async function signInWithGitHub(): Promise<FirebaseSignInResult> {
|
|
79
216
|
const firebaseAuth = getFirebaseAuth();
|
|
@@ -91,3 +228,78 @@ export async function signInWithGitHub(): Promise<FirebaseSignInResult> {
|
|
|
91
228
|
provider: "github",
|
|
92
229
|
};
|
|
93
230
|
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Apple Sign-In
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Native Apple Sign-In on iOS via @capgo/capacitor-social-login,
|
|
238
|
+
* then exchange for Firebase credential.
|
|
239
|
+
*/
|
|
240
|
+
async function nativeAppleSignIn(): Promise<FirebaseSignInResult> {
|
|
241
|
+
console.log("[NativeAppleSignIn] Step 1: initializing");
|
|
242
|
+
const { SocialLogin } = await import("@capgo/capacitor-social-login");
|
|
243
|
+
|
|
244
|
+
if (!_socialLoginInitialized) {
|
|
245
|
+
await withTimeout(
|
|
246
|
+
SocialLogin.initialize({ apple: {} }),
|
|
247
|
+
10000,
|
|
248
|
+
"SocialLogin.initialize(apple)",
|
|
249
|
+
);
|
|
250
|
+
_socialLoginInitialized = true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log("[NativeAppleSignIn] Step 2: calling SocialLogin.login()");
|
|
254
|
+
const res = await SocialLogin.login({
|
|
255
|
+
provider: "apple",
|
|
256
|
+
options: { scopes: ["email", "name"] },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
console.log("[NativeAppleSignIn] Step 3: SocialLogin.login() returned", JSON.stringify(res).substring(0, 200));
|
|
260
|
+
|
|
261
|
+
const appleResult = res.result as any;
|
|
262
|
+
const appleIdToken = appleResult.idToken;
|
|
263
|
+
if (!appleIdToken) {
|
|
264
|
+
throw new Error("Apple Sign-In did not return an idToken");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Send the Apple ID token directly to the backend — skip Firebase client
|
|
268
|
+
// (signInWithCredential hangs in WKWebView on real devices, same as Google)
|
|
269
|
+
console.log("[NativeAppleSignIn] Step 4: Skipping Firebase client, sending Apple ID token directly to backend");
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
idToken: appleIdToken,
|
|
273
|
+
email: appleResult.profile?.email ?? appleResult.email ?? "",
|
|
274
|
+
displayName: appleResult.profile?.name ?? appleResult.fullName?.givenName ?? null,
|
|
275
|
+
photoURL: null,
|
|
276
|
+
provider: "apple",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Sign in with Apple.
|
|
282
|
+
* - Web: Firebase popup with Apple OAuthProvider
|
|
283
|
+
* - Native iOS: Native Apple Sign-In via @capgo/capacitor-social-login → Firebase credential
|
|
284
|
+
*/
|
|
285
|
+
export async function signInWithApple(): Promise<FirebaseSignInResult> {
|
|
286
|
+
if (Capacitor.isNativePlatform()) {
|
|
287
|
+
return nativeAppleSignIn();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const firebaseAuth = getFirebaseAuth();
|
|
291
|
+
const provider = new OAuthProvider("apple.com");
|
|
292
|
+
provider.addScope("email");
|
|
293
|
+
provider.addScope("name");
|
|
294
|
+
|
|
295
|
+
const result = await signInWithPopup(firebaseAuth, provider);
|
|
296
|
+
const idToken = await result.user.getIdToken();
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
idToken,
|
|
300
|
+
email: result.user.email ?? "",
|
|
301
|
+
displayName: result.user.displayName,
|
|
302
|
+
photoURL: result.user.photoURL,
|
|
303
|
+
provider: "apple",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground/background detection — notifies the ConnectionDO via WebSocket
|
|
3
|
+
* so it knows whether to send push notifications.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Capacitor } from "@capacitor/core";
|
|
7
|
+
import type { BotsChatWSClient } from "./ws";
|
|
8
|
+
import { dlog } from "./debug-log";
|
|
9
|
+
|
|
10
|
+
export function setupForegroundDetection(wsClient: BotsChatWSClient): () => void {
|
|
11
|
+
const notifyForeground = () => {
|
|
12
|
+
wsClient.send({ type: "foreground.enter" });
|
|
13
|
+
dlog.info("Foreground", "Entered foreground");
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const notifyBackground = () => {
|
|
17
|
+
wsClient.send({ type: "foreground.leave" });
|
|
18
|
+
dlog.info("Foreground", "Entered background");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (Capacitor.isNativePlatform()) {
|
|
22
|
+
let cleanup: (() => void) | null = null;
|
|
23
|
+
|
|
24
|
+
import("@capacitor/app").then(({ App }) => {
|
|
25
|
+
const handle = App.addListener("appStateChange", ({ isActive }) => {
|
|
26
|
+
if (isActive) notifyForeground();
|
|
27
|
+
else notifyBackground();
|
|
28
|
+
});
|
|
29
|
+
cleanup = () => handle.then((h) => h.remove());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Report initial foreground state once WS is connected
|
|
33
|
+
notifyForeground();
|
|
34
|
+
|
|
35
|
+
return () => cleanup?.();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Web: Use Page Visibility API
|
|
39
|
+
const handleVisibilityChange = () => {
|
|
40
|
+
if (document.hidden) notifyBackground();
|
|
41
|
+
else notifyForeground();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
45
|
+
|
|
46
|
+
if (!document.hidden) notifyForeground();
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -186,8 +186,9 @@ code:not(pre code) {
|
|
|
186
186
|
outline-offset: 1px;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
/* Transitions for theme switching
|
|
190
|
-
body
|
|
189
|
+
/* Transitions for theme switching — only on opt-in elements, NOT on body
|
|
190
|
+
(body transitions cause visible jitter during keyboard/layout changes on iOS) */
|
|
191
|
+
.theme-transition {
|
|
191
192
|
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
192
193
|
}
|
|
193
194
|
|
|
@@ -209,6 +210,13 @@ body, .theme-transition {
|
|
|
209
210
|
}
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/* Prevent long URLs, passwords, and inline code from overflowing message width */
|
|
214
|
+
.prose a,
|
|
215
|
+
.prose code:not(pre code) {
|
|
216
|
+
overflow-wrap: break-word;
|
|
217
|
+
word-break: break-all;
|
|
218
|
+
}
|
|
219
|
+
|
|
212
220
|
/* Mobile screen transition */
|
|
213
221
|
.mobile-screen-enter {
|
|
214
222
|
animation: slideInRight 0.2s ease-out;
|
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import ReactDOM from "react-dom/client";
|
|
3
|
+
import { Capacitor } from "@capacitor/core";
|
|
3
4
|
import App from "./App";
|
|
4
5
|
import "./index.css";
|
|
5
6
|
import { initAnalytics } from "./analytics";
|
|
6
7
|
|
|
7
8
|
initAnalytics();
|
|
8
9
|
|
|
10
|
+
// ---- Capacitor native platform setup ----
|
|
11
|
+
if (Capacitor.isNativePlatform()) {
|
|
12
|
+
// Configure status bar and keyboard for native app
|
|
13
|
+
import("@capacitor/status-bar").then(({ StatusBar, Style }) => {
|
|
14
|
+
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
|
|
15
|
+
StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => {});
|
|
16
|
+
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {});
|
|
17
|
+
});
|
|
18
|
+
import("@capacitor/keyboard").then(({ Keyboard }) => {
|
|
19
|
+
Keyboard.setAccessoryBarVisible({ isVisible: true }).catch(() => {});
|
|
20
|
+
// Write keyboard height to CSS variable so MobileLayout can shrink accordingly.
|
|
21
|
+
// This works with Keyboard.resize = "none" to avoid body-resize jitter.
|
|
22
|
+
Keyboard.addListener("keyboardWillShow", (info) => {
|
|
23
|
+
document.documentElement.style.setProperty("--keyboard-height", `${info.keyboardHeight}px`);
|
|
24
|
+
});
|
|
25
|
+
Keyboard.addListener("keyboardWillHide", () => {
|
|
26
|
+
document.documentElement.style.setProperty("--keyboard-height", "0px");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
10
32
|
<React.StrictMode>
|
|
11
33
|
<App />
|
|
12
34
|
</React.StrictMode>,
|
|
13
35
|
);
|
|
14
36
|
|
|
15
|
-
// Register service worker for PWA support (
|
|
16
|
-
if ("serviceWorker" in navigator) {
|
|
37
|
+
// Register service worker for PWA support — skip in Capacitor (native has its own caching)
|
|
38
|
+
if (!Capacitor.isNativePlatform() && "serviceWorker" in navigator) {
|
|
17
39
|
window.addEventListener("load", () => {
|
|
18
40
|
navigator.serviceWorker.register("/sw.js").catch(() => {
|
|
19
41
|
// SW registration failed — non-critical, app still works
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push notification initialization for Web (FCM) and Native (Capacitor).
|
|
3
|
+
*
|
|
4
|
+
* - Web: Firebase Cloud Messaging + Service Worker
|
|
5
|
+
* - iOS/Android: @capacitor/push-notifications
|
|
6
|
+
*
|
|
7
|
+
* For E2E encrypted messages, the E2E key is synced to IndexedDB so the
|
|
8
|
+
* Service Worker (web) or native handler can decrypt before showing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Capacitor } from "@capacitor/core";
|
|
12
|
+
import { pushApi } from "./api";
|
|
13
|
+
import { dlog } from "./debug-log";
|
|
14
|
+
import { E2eService } from "./e2e";
|
|
15
|
+
|
|
16
|
+
let initialized = false;
|
|
17
|
+
|
|
18
|
+
// ---- IndexedDB helpers for SW E2E key sync ----
|
|
19
|
+
|
|
20
|
+
const IDB_NAME = "botschat-sw";
|
|
21
|
+
const IDB_STORE = "keys";
|
|
22
|
+
const IDB_KEY = "e2e_key";
|
|
23
|
+
|
|
24
|
+
function openDB(): Promise<IDBDatabase> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const req = indexedDB.open(IDB_NAME, 1);
|
|
27
|
+
req.onupgradeneeded = () => {
|
|
28
|
+
req.result.createObjectStore(IDB_STORE);
|
|
29
|
+
};
|
|
30
|
+
req.onsuccess = () => resolve(req.result);
|
|
31
|
+
req.onerror = () => reject(req.error);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sync the current E2E key to IndexedDB so the Service Worker can decrypt. */
|
|
36
|
+
export async function syncE2eKeyToSW(): Promise<void> {
|
|
37
|
+
try {
|
|
38
|
+
const db = await openDB();
|
|
39
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
40
|
+
const store = tx.objectStore(IDB_STORE);
|
|
41
|
+
|
|
42
|
+
// Read the cached key from localStorage (base64-encoded Uint8Array)
|
|
43
|
+
const cachedKeyB64 = localStorage.getItem("botschat_e2e_key_cache");
|
|
44
|
+
if (cachedKeyB64) {
|
|
45
|
+
// Decode base64 to Uint8Array and store in IDB
|
|
46
|
+
const binary = atob(cachedKeyB64);
|
|
47
|
+
const key = new Uint8Array(binary.length);
|
|
48
|
+
for (let i = 0; i < binary.length; i++) {
|
|
49
|
+
key[i] = binary.charCodeAt(i);
|
|
50
|
+
}
|
|
51
|
+
store.put(key, IDB_KEY);
|
|
52
|
+
} else {
|
|
53
|
+
store.delete(IDB_KEY);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await new Promise<void>((resolve, reject) => {
|
|
57
|
+
tx.oncomplete = () => resolve();
|
|
58
|
+
tx.onerror = () => reject(tx.error);
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
dlog.warn("Push", "Failed to sync E2E key to SW IndexedDB", err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear the E2E key from SW IndexedDB (call on logout or key clear). */
|
|
66
|
+
export async function clearE2eKeyFromSW(): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
const db = await openDB();
|
|
69
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
70
|
+
tx.objectStore(IDB_STORE).delete(IDB_KEY);
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
tx.oncomplete = () => resolve();
|
|
73
|
+
tx.onerror = () => reject(tx.error);
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore — best effort
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- Push initialization ----
|
|
81
|
+
|
|
82
|
+
export async function initPushNotifications(): Promise<void> {
|
|
83
|
+
if (initialized) return;
|
|
84
|
+
|
|
85
|
+
// Sync E2E key so push notifications can be decrypted
|
|
86
|
+
await syncE2eKeyToSW();
|
|
87
|
+
|
|
88
|
+
// Subscribe to E2E key changes to keep SW in sync
|
|
89
|
+
E2eService.subscribe(() => {
|
|
90
|
+
syncE2eKeyToSW().catch(() => {});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (Capacitor.isNativePlatform()) {
|
|
94
|
+
await initNativePush();
|
|
95
|
+
} else {
|
|
96
|
+
await initWebPush();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
initialized = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Web Push (Firebase Cloud Messaging) ----
|
|
103
|
+
|
|
104
|
+
async function initWebPush(): Promise<void> {
|
|
105
|
+
try {
|
|
106
|
+
if (!("Notification" in self)) {
|
|
107
|
+
dlog.warn("Push", "Notifications not supported in this browser");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const permission = await Notification.requestPermission();
|
|
112
|
+
if (permission !== "granted") {
|
|
113
|
+
dlog.warn("Push", "Notification permission denied");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { getMessaging, getToken, onMessage } = await import("firebase/messaging");
|
|
118
|
+
const { ensureFirebaseApp } = await import("./firebase");
|
|
119
|
+
|
|
120
|
+
const firebaseApp = ensureFirebaseApp();
|
|
121
|
+
if (!firebaseApp) {
|
|
122
|
+
dlog.warn("Push", "Firebase not configured (missing env vars)");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const messaging = getMessaging(firebaseApp);
|
|
127
|
+
|
|
128
|
+
// Get service worker registration
|
|
129
|
+
const registration = await navigator.serviceWorker.getRegistration();
|
|
130
|
+
if (!registration) {
|
|
131
|
+
dlog.warn("Push", "No service worker registration found");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as string;
|
|
136
|
+
if (!vapidKey) {
|
|
137
|
+
dlog.warn("Push", "VITE_FIREBASE_VAPID_KEY not set — skipping web push");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fcmToken = await getToken(messaging, {
|
|
142
|
+
vapidKey,
|
|
143
|
+
serviceWorkerRegistration: registration,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (fcmToken) {
|
|
147
|
+
dlog.info("Push", `FCM token obtained (${fcmToken.slice(0, 20)}...)`);
|
|
148
|
+
await pushApi.register(fcmToken, "web");
|
|
149
|
+
dlog.info("Push", "Token registered with backend");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Suppress foreground notifications (WS already delivers the message)
|
|
153
|
+
onMessage(messaging, (_payload) => {
|
|
154
|
+
dlog.info("Push", "Foreground FCM message received (suppressed)");
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
dlog.error("Push", "Web push init failed", err);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- Native Push (Capacitor) ----
|
|
162
|
+
|
|
163
|
+
async function initNativePush(): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
const { PushNotifications } = await import("@capacitor/push-notifications");
|
|
166
|
+
|
|
167
|
+
const permResult = await PushNotifications.requestPermissions();
|
|
168
|
+
if (permResult.receive !== "granted") {
|
|
169
|
+
dlog.warn("Push", "Native push permission denied");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await PushNotifications.register();
|
|
174
|
+
|
|
175
|
+
PushNotifications.addListener("registration", async (token) => {
|
|
176
|
+
dlog.info("Push", `Native push token: ${token.value.slice(0, 20)}...`);
|
|
177
|
+
const platform = Capacitor.getPlatform() as "ios" | "android";
|
|
178
|
+
await pushApi.register(token.value, platform);
|
|
179
|
+
dlog.info("Push", "Native token registered with backend");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
PushNotifications.addListener("registrationError", (error) => {
|
|
183
|
+
dlog.error("Push", "Native push registration failed", error);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Data-only messages arrive here in foreground — suppress (WS handles it)
|
|
187
|
+
PushNotifications.addListener("pushNotificationReceived", (_notification) => {
|
|
188
|
+
dlog.info("Push", "Foreground native notification (suppressed)");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// User tapped a notification (app was in background)
|
|
192
|
+
PushNotifications.addListener("pushNotificationActionPerformed", (action) => {
|
|
193
|
+
dlog.info("Push", "Notification tapped", action);
|
|
194
|
+
// TODO: navigate to specific session from action.notification.data
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
dlog.error("Push", "Native push init failed", err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Unregister push token (call on logout). */
|
|
202
|
+
export async function unregisterPush(): Promise<void> {
|
|
203
|
+
initialized = false;
|
|
204
|
+
await clearE2eKeyFromSW();
|
|
205
|
+
}
|
package/packages/web/src/ws.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** WebSocket client for connecting to the BotsChat ConnectionDO. */
|
|
2
2
|
|
|
3
|
+
import { Capacitor } from "@capacitor/core";
|
|
3
4
|
import { dlog } from "./debug-log";
|
|
4
5
|
import { E2eService } from "./e2e";
|
|
5
6
|
import { getToken, tryRefreshAccessToken } from "./api";
|
|
@@ -33,10 +34,16 @@ export class BotsChatWSClient {
|
|
|
33
34
|
|
|
34
35
|
connect(): void {
|
|
35
36
|
this.intentionalClose = false;
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
37
|
+
|
|
38
|
+
// In Capacitor (native app), WebView runs from capacitor:// so we must
|
|
39
|
+
// use the full production WebSocket URL.
|
|
40
|
+
let url: string;
|
|
41
|
+
if (Capacitor.isNativePlatform()) {
|
|
42
|
+
url = `wss://console.botschat.app/api/ws/${this.opts.userId}/${this.opts.sessionId}`;
|
|
43
|
+
} else {
|
|
44
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
45
|
+
url = `${protocol}//${window.location.host}/api/ws/${this.opts.userId}/${this.opts.sessionId}`;
|
|
46
|
+
}
|
|
40
47
|
|
|
41
48
|
dlog.info("WS", `Connecting to ${url}`);
|
|
42
49
|
this.ws = new WebSocket(url);
|
|
@@ -164,14 +171,19 @@ export class BotsChatWSClient {
|
|
|
164
171
|
// E2E Encryption for user messages
|
|
165
172
|
if (msg.type === "user.message" && E2eService.hasKey() && typeof msg.text === "string") {
|
|
166
173
|
try {
|
|
167
|
-
|
|
174
|
+
// Use the existing messageId as contextId for encryption nonce,
|
|
175
|
+
// so decryption on the plugin side uses the same ID.
|
|
176
|
+
const existingId = (msg.messageId as string) || undefined;
|
|
177
|
+
const { ciphertext, messageId } = await E2eService.encrypt(msg.text, existingId);
|
|
168
178
|
msg.text = ciphertext;
|
|
169
|
-
|
|
179
|
+
// Only set messageId if we didn't have one — preserve the original
|
|
180
|
+
// so message IDs stay consistent between local state and server.
|
|
181
|
+
if (!existingId) {
|
|
182
|
+
msg.messageId = messageId;
|
|
183
|
+
}
|
|
170
184
|
msg.encrypted = true;
|
|
171
185
|
} catch (err) {
|
|
172
186
|
dlog.error("E2E", "Encryption failed", err);
|
|
173
|
-
// Fail? or send as plaintext?
|
|
174
|
-
// Security first: if key exists but encrypt fails, abort.
|
|
175
187
|
return;
|
|
176
188
|
}
|
|
177
189
|
}
|