clawdex-mobile 1.3.2 → 2.0.0

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 (48) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/npm-release.yml +18 -0
  3. package/AGENTS.md +3 -3
  4. package/README.md +101 -541
  5. package/apps/mobile/.env.example +1 -2
  6. package/apps/mobile/App.tsx +261 -68
  7. package/apps/mobile/app.json +31 -5
  8. package/apps/mobile/assets/brand/splash-icon-white.png +0 -0
  9. package/apps/mobile/eas.json +30 -0
  10. package/apps/mobile/package.json +22 -21
  11. package/apps/mobile/plugins/withAndroidCleartextTraffic.js +14 -0
  12. package/apps/mobile/src/api/__tests__/ws.test.ts +44 -6
  13. package/apps/mobile/src/api/chatMapping.ts +48 -8
  14. package/apps/mobile/src/api/client.ts +6 -0
  15. package/apps/mobile/src/api/types.ts +11 -0
  16. package/apps/mobile/src/api/ws.ts +52 -10
  17. package/apps/mobile/src/bridgeUrl.ts +105 -0
  18. package/apps/mobile/src/components/ActivityBar.tsx +32 -13
  19. package/apps/mobile/src/components/ChatHeader.tsx +3 -2
  20. package/apps/mobile/src/components/ChatInput.tsx +246 -91
  21. package/apps/mobile/src/components/ChatMessage.tsx +108 -4
  22. package/apps/mobile/src/config.ts +11 -29
  23. package/apps/mobile/src/hooks/useVoiceRecorder.ts +264 -0
  24. package/apps/mobile/src/navigation/DrawerContent.tsx +18 -8
  25. package/apps/mobile/src/screens/GitScreen.tsx +1 -1
  26. package/apps/mobile/src/screens/MainScreen.tsx +906 -268
  27. package/apps/mobile/src/screens/OnboardingScreen.tsx +1132 -0
  28. package/apps/mobile/src/screens/PrivacyScreen.tsx +1 -1
  29. package/apps/mobile/src/screens/SettingsScreen.tsx +65 -1
  30. package/apps/mobile/src/screens/TerminalScreen.tsx +1 -1
  31. package/apps/mobile/src/screens/TermsScreen.tsx +1 -1
  32. package/docs/app-review-notes.md +7 -2
  33. package/docs/eas-builds.md +91 -0
  34. package/docs/realtime-streaming-limitations.md +84 -0
  35. package/docs/setup-and-operations.md +239 -0
  36. package/docs/troubleshooting.md +121 -0
  37. package/docs/voice-transcription.md +87 -0
  38. package/package.json +8 -16
  39. package/scripts/setup-secure-dev.sh +122 -8
  40. package/scripts/setup-wizard.sh +342 -122
  41. package/scripts/start-bridge-secure.sh +7 -1
  42. package/scripts/sync-versions.js +63 -0
  43. package/services/rust-bridge/.env.example +1 -1
  44. package/services/rust-bridge/Cargo.lock +1104 -23
  45. package/services/rust-bridge/Cargo.toml +3 -1
  46. package/services/rust-bridge/package.json +1 -1
  47. package/services/rust-bridge/src/main.rs +587 -12
  48. package/apps/mobile/metro.config.js +0 -3
@@ -1,4 +1,6 @@
1
1
  import { Ionicons } from '@expo/vector-icons';
2
+ import { BlurView } from 'expo-blur';
3
+ import { useEffect, useState } from 'react';
2
4
  import {
3
5
  ActivityIndicator,
4
6
  type NativeSyntheticEvent,
@@ -8,10 +10,12 @@ import {
8
10
  StyleSheet,
9
11
  Text,
10
12
  TextInput,
13
+ type TextLayoutEventData,
11
14
  type TextInputKeyPressEventData,
12
15
  View,
13
16
  } from 'react-native';
14
17
 
18
+ import type { VoiceState } from '../hooks/useVoiceRecorder';
15
19
  import { colors, radius, spacing } from '../theme';
16
20
 
17
21
  interface ChatInputProps {
@@ -27,6 +31,10 @@ interface ChatInputProps {
27
31
  showStopButton?: boolean;
28
32
  isStopping?: boolean;
29
33
  placeholder?: string;
34
+ onVoiceToggle?: () => void;
35
+ voiceState?: VoiceState;
36
+ safeAreaBottomInset?: number;
37
+ keyboardVisible?: boolean;
30
38
  }
31
39
 
32
40
  export function ChatInput({
@@ -42,98 +50,226 @@ export function ChatInput({
42
50
  showStopButton = false,
43
51
  isStopping = false,
44
52
  placeholder = 'Message Codex...',
53
+ onVoiceToggle,
54
+ voiceState = 'idle',
55
+ safeAreaBottomInset = 0,
56
+ keyboardVisible = false,
45
57
  }: ChatInputProps) {
46
- const canSend = value.trim().length > 0 && !isLoading;
58
+ const INPUT_TEXT_LINE_HEIGHT = 20;
59
+ const INPUT_TEXT_VERTICAL_PADDING = Platform.OS === 'ios' ? 2 : 0;
60
+ const INPUT_TEXT_MIN_HEIGHT = 20;
61
+ const INPUT_TEXT_MAX_HEIGHT = 96;
62
+ const [inputHeight, setInputHeight] = useState(INPUT_TEXT_MIN_HEIGHT);
63
+ const [inputWidth, setInputWidth] = useState(0);
64
+ const updateInputHeight = (height: number) => {
65
+ const nextHeight = Math.max(
66
+ INPUT_TEXT_MIN_HEIGHT,
67
+ Math.min(INPUT_TEXT_MAX_HEIGHT, Math.ceil(height))
68
+ );
69
+ setInputHeight((previousHeight) =>
70
+ previousHeight === nextHeight ? previousHeight : nextHeight
71
+ );
72
+ };
73
+
74
+ useEffect(() => {
75
+ if (!value && inputHeight !== INPUT_TEXT_MIN_HEIGHT) {
76
+ setInputHeight(INPUT_TEXT_MIN_HEIGHT);
77
+ }
78
+ }, [inputHeight, value]);
79
+
80
+ const canSend = value.trim().length > 0 && voiceState === 'idle';
47
81
  const canStop = Boolean(showStopButton && onStop);
48
- const shouldShowActionButton = canStop || canSend || isLoading;
82
+ const showVoiceButton = Boolean(onVoiceToggle);
83
+ const showSendButton = canSend || isLoading;
84
+ const inputScrollEnabled = inputHeight >= INPUT_TEXT_MAX_HEIGHT;
85
+ const shouldShowActionButton =
86
+ canStop || showSendButton || showVoiceButton || voiceState !== 'idle';
87
+ const baseBottomPadding =
88
+ Platform.OS === 'ios'
89
+ ? keyboardVisible
90
+ ? spacing.sm
91
+ : spacing.lg
92
+ : spacing.md;
93
+ const extraBottomInset = keyboardVisible ? 0 : safeAreaBottomInset;
49
94
 
50
95
  return (
51
- <View style={styles.container}>
52
- {attachments.length > 0 ? (
53
- <ScrollView
54
- horizontal
55
- showsHorizontalScrollIndicator={false}
56
- contentContainerStyle={styles.attachmentListContent}
57
- style={styles.attachmentList}
58
- >
59
- {attachments.map((attachment, index) => (
60
- <Pressable
61
- key={`${attachment.id}-${String(index)}`}
62
- onPress={
63
- onRemoveAttachment
64
- ? () => onRemoveAttachment(attachment.id)
65
- : undefined
66
- }
67
- style={({ pressed }) => [
68
- styles.attachmentChip,
69
- pressed && styles.attachmentChipPressed,
70
- ]}
71
- >
72
- <Ionicons name="attach-outline" size={12} color={colors.textMuted} />
73
- <Text style={styles.attachmentChipText} numberOfLines={1}>
74
- {attachment.label}
75
- </Text>
76
- {onRemoveAttachment ? (
77
- <Ionicons name="close-outline" size={12} color={colors.textMuted} />
78
- ) : null}
79
- </Pressable>
80
- ))}
81
- </ScrollView>
82
- ) : null}
96
+ <View style={styles.shell}>
97
+ <BlurView
98
+ intensity={26}
99
+ tint={Platform.OS === 'ios' ? 'systemUltraThinMaterialDark' : 'dark'}
100
+ blurMethod="dimezisBlurViewSdk31Plus"
101
+ style={StyleSheet.absoluteFill}
102
+ />
103
+ <View
104
+ style={[
105
+ styles.container,
106
+ {
107
+ paddingBottom:
108
+ baseBottomPadding + extraBottomInset,
109
+ },
110
+ ]}
111
+ >
112
+ {attachments.length > 0 ? (
113
+ <ScrollView
114
+ horizontal
115
+ showsHorizontalScrollIndicator={false}
116
+ contentContainerStyle={styles.attachmentListContent}
117
+ style={styles.attachmentList}
118
+ >
119
+ {attachments.map((attachment, index) => (
120
+ <Pressable
121
+ key={`${attachment.id}-${String(index)}`}
122
+ onPress={
123
+ onRemoveAttachment
124
+ ? () => onRemoveAttachment(attachment.id)
125
+ : undefined
126
+ }
127
+ style={({ pressed }) => [
128
+ styles.attachmentChip,
129
+ pressed && styles.attachmentChipPressed,
130
+ ]}
131
+ >
132
+ <Ionicons name="attach-outline" size={12} color={colors.textMuted} />
133
+ <Text style={styles.attachmentChipText} numberOfLines={1}>
134
+ {attachment.label}
135
+ </Text>
136
+ {onRemoveAttachment ? (
137
+ <Ionicons name="close-outline" size={12} color={colors.textMuted} />
138
+ ) : null}
139
+ </Pressable>
140
+ ))}
141
+ </ScrollView>
142
+ ) : null}
83
143
 
84
- <View style={styles.row}>
85
- <Pressable
86
- onPress={onAttachPress}
87
- style={({ pressed }) => [styles.plusBtn, pressed && styles.plusBtnPressed]}
88
- >
89
- <Ionicons name="add" size={20} color={colors.textMuted} />
90
- </Pressable>
144
+ <View style={styles.row}>
145
+ <Pressable
146
+ onPress={onAttachPress}
147
+ style={({ pressed }) => [styles.plusBtn, pressed && styles.plusBtnPressed]}
148
+ >
149
+ <Ionicons name="add" size={20} color={colors.textMuted} />
150
+ </Pressable>
91
151
 
92
- <View style={styles.inputWrapper}>
93
- <TextInput
94
- style={styles.input}
95
- value={value}
96
- onChangeText={onChangeText}
97
- onFocus={onFocus}
98
- placeholder={placeholder}
99
- placeholderTextColor={colors.textMuted}
100
- multiline
101
- onKeyPress={(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
102
- const keyEvent = e.nativeEvent as TextInputKeyPressEventData & {
103
- shiftKey?: boolean;
104
- };
105
- if (
106
- Platform.OS === 'web' &&
107
- keyEvent.key === 'Enter' &&
108
- !keyEvent.shiftKey
109
- ) {
110
- e.preventDefault();
111
- if (canSend) onSubmit();
112
- }
113
- }}
114
- />
115
- {shouldShowActionButton ? (
116
- <Pressable
117
- onPress={canStop ? onStop : canSend ? onSubmit : undefined}
118
- style={styles.sendBtn}
119
- disabled={canStop ? isStopping : !canSend}
152
+ <View style={styles.inputWrapper}>
153
+ <Text
154
+ pointerEvents="none"
155
+ accessibilityElementsHidden
156
+ importantForAccessibility="no-hide-descendants"
157
+ style={[
158
+ styles.inputMeasure,
159
+ {
160
+ width: inputWidth,
161
+ lineHeight: INPUT_TEXT_LINE_HEIGHT,
162
+ paddingVertical: INPUT_TEXT_VERTICAL_PADDING,
163
+ },
164
+ ]}
165
+ onTextLayout={(event: NativeSyntheticEvent<TextLayoutEventData>) => {
166
+ if (inputWidth <= 0) {
167
+ return;
168
+ }
169
+ const lineCount = Math.max(1, event.nativeEvent.lines.length);
170
+ const measuredHeight =
171
+ lineCount * INPUT_TEXT_LINE_HEIGHT + INPUT_TEXT_VERTICAL_PADDING * 2;
172
+ updateInputHeight(measuredHeight);
173
+ }}
120
174
  >
121
- {canStop ? (
122
- <View style={styles.stopButtonContent}>
123
- <Ionicons name="square" size={10} color={colors.textPrimary} />
124
- <ActivityIndicator
125
- size="small"
126
- color={colors.textMuted}
127
- style={styles.stopButtonSpinner}
128
- />
129
- </View>
130
- ) : isLoading ? (
131
- <ActivityIndicator size="small" color={colors.textMuted} />
132
- ) : (
133
- <Ionicons name="arrow-up" size={14} color={colors.textPrimary} />
134
- )}
135
- </Pressable>
136
- ) : null}
175
+ {value.length > 0 ? `${value}\u200b` : ' '}
176
+ </Text>
177
+ <TextInput
178
+ style={[styles.input, { height: inputHeight }]}
179
+ value={value}
180
+ onChangeText={onChangeText}
181
+ onLayout={(event) => {
182
+ const nextWidth = Math.floor(event.nativeEvent.layout.width);
183
+ setInputWidth((previousWidth) =>
184
+ previousWidth === nextWidth ? previousWidth : nextWidth
185
+ );
186
+ }}
187
+ onChange={(event: NativeSyntheticEvent<unknown>) => {
188
+ const nativeEvent = event.nativeEvent as {
189
+ contentSize?: { height?: number };
190
+ };
191
+ const contentHeight = nativeEvent.contentSize?.height;
192
+ if (typeof contentHeight === 'number' && Number.isFinite(contentHeight)) {
193
+ updateInputHeight(contentHeight);
194
+ }
195
+ }}
196
+ onFocus={onFocus}
197
+ placeholder={placeholder}
198
+ placeholderTextColor={colors.textMuted}
199
+ multiline
200
+ scrollEnabled={inputScrollEnabled}
201
+ onContentSizeChange={(event) => {
202
+ updateInputHeight(event.nativeEvent.contentSize.height);
203
+ }}
204
+ onKeyPress={(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
205
+ const keyEvent = e.nativeEvent as TextInputKeyPressEventData & {
206
+ shiftKey?: boolean;
207
+ };
208
+ if (
209
+ Platform.OS === 'web' &&
210
+ keyEvent.key === 'Enter' &&
211
+ !keyEvent.shiftKey
212
+ ) {
213
+ e.preventDefault();
214
+ if (canSend) onSubmit();
215
+ }
216
+ }}
217
+ />
218
+ {shouldShowActionButton ? (
219
+ <View style={styles.actionButtons}>
220
+ {showVoiceButton || voiceState !== 'idle' ? (
221
+ voiceState === 'transcribing' ? (
222
+ <View style={styles.sendBtn}>
223
+ <ActivityIndicator size="small" color={colors.textMuted} />
224
+ </View>
225
+ ) : voiceState === 'recording' ? (
226
+ <Pressable
227
+ onPress={onVoiceToggle}
228
+ style={[styles.sendBtn, styles.micBtnRecording]}
229
+ >
230
+ <Ionicons name="mic" size={14} color={colors.error} />
231
+ </Pressable>
232
+ ) : (
233
+ <Pressable
234
+ onPress={onVoiceToggle}
235
+ style={styles.sendBtn}
236
+ >
237
+ <Ionicons name="mic-outline" size={14} color={colors.textMuted} />
238
+ </Pressable>
239
+ )
240
+ ) : null}
241
+ {canStop ? (
242
+ <Pressable
243
+ onPress={onStop}
244
+ style={styles.sendBtn}
245
+ disabled={isStopping}
246
+ >
247
+ <View style={styles.stopButtonContent}>
248
+ <Ionicons name="square" size={10} color={colors.textPrimary} />
249
+ <ActivityIndicator
250
+ size="small"
251
+ color={colors.textMuted}
252
+ style={styles.stopButtonSpinner}
253
+ />
254
+ </View>
255
+ </Pressable>
256
+ ) : null}
257
+ {showSendButton ? (
258
+ <Pressable
259
+ onPress={canSend ? onSubmit : undefined}
260
+ style={styles.sendBtn}
261
+ disabled={!canSend}
262
+ >
263
+ {isLoading && !canSend ? (
264
+ <ActivityIndicator size="small" color={colors.textMuted} />
265
+ ) : (
266
+ <Ionicons name="arrow-up" size={14} color={colors.textPrimary} />
267
+ )}
268
+ </Pressable>
269
+ ) : null}
270
+ </View>
271
+ ) : null}
272
+ </View>
137
273
  </View>
138
274
  </View>
139
275
  </View>
@@ -141,14 +277,16 @@ export function ChatInput({
141
277
  }
142
278
 
143
279
  const styles = StyleSheet.create({
280
+ shell: {
281
+ overflow: 'hidden',
282
+ borderTopWidth: StyleSheet.hairlineWidth,
283
+ borderTopColor: colors.borderLight,
284
+ },
144
285
  container: {
145
286
  gap: spacing.xs,
146
287
  paddingHorizontal: spacing.lg,
147
288
  paddingVertical: spacing.md,
148
- paddingBottom: Platform.OS === 'ios' ? spacing.lg : spacing.md,
149
- backgroundColor: colors.bgMain,
150
- borderTopWidth: StyleSheet.hairlineWidth,
151
- borderTopColor: colors.borderLight,
289
+ backgroundColor: 'rgba(6, 9, 13, 0.42)',
152
290
  },
153
291
  row: {
154
292
  flexDirection: 'row',
@@ -213,7 +351,21 @@ const styles = StyleSheet.create({
213
351
  fontSize: 14,
214
352
  lineHeight: 20,
215
353
  paddingVertical: Platform.OS === 'ios' ? 2 : 0,
216
- textAlignVertical: 'center',
354
+ textAlignVertical: 'top',
355
+ },
356
+ inputMeasure: {
357
+ position: 'absolute',
358
+ opacity: 0,
359
+ color: colors.textPrimary,
360
+ fontSize: 14,
361
+ left: spacing.md,
362
+ top: spacing.xs,
363
+ },
364
+ actionButtons: {
365
+ flexDirection: 'row',
366
+ alignItems: 'center',
367
+ marginLeft: spacing.xs,
368
+ gap: spacing.xs,
217
369
  },
218
370
  sendBtn: {
219
371
  width: 28,
@@ -222,7 +374,10 @@ const styles = StyleSheet.create({
222
374
  backgroundColor: colors.bgItem,
223
375
  alignItems: 'center',
224
376
  justifyContent: 'center',
225
- marginLeft: spacing.xs,
377
+ },
378
+ micBtnRecording: {
379
+ borderWidth: 1.5,
380
+ borderColor: colors.error,
226
381
  },
227
382
  stopButtonContent: {
228
383
  width: 20,
@@ -1,13 +1,14 @@
1
1
  import { Ionicons } from '@expo/vector-icons';
2
- import { Platform, StyleSheet, Text, View, Image } from 'react-native';
2
+ import { memo } from 'react';
3
+ import { Image, Linking, Platform, StyleSheet, Text, View } from 'react-native';
3
4
  import Markdown, { type RenderRules } from 'react-native-markdown-display';
4
5
  import Animated, { FadeInUp, Layout } from 'react-native-reanimated';
5
6
 
6
- import type { ChatMessage } from '../api/types';
7
+ import type { ChatMessage as ApiChatMessage } from '../api/types';
7
8
  import { colors, radius, spacing, typography } from '../theme';
8
9
 
9
10
  interface ChatMessageProps {
10
- message: ChatMessage;
11
+ message: ApiChatMessage;
11
12
  }
12
13
 
13
14
  interface TimelineEntry {
@@ -15,7 +16,7 @@ interface TimelineEntry {
15
16
  details: string[];
16
17
  }
17
18
 
18
- export function ChatMessage({ message }: ChatMessageProps) {
19
+ function ChatMessageComponent({ message }: ChatMessageProps) {
19
20
  const isUser = message.role === 'user';
20
21
 
21
22
  if (isUser) {
@@ -97,6 +98,28 @@ export function ChatMessage({ message }: ChatMessageProps) {
97
98
  );
98
99
  }
99
100
 
101
+ function areChatMessagePropsEqual(
102
+ prevProps: ChatMessageProps,
103
+ nextProps: ChatMessageProps
104
+ ): boolean {
105
+ const previous = prevProps.message;
106
+ const next = nextProps.message;
107
+
108
+ if (previous === next) {
109
+ return true;
110
+ }
111
+
112
+ return (
113
+ previous.id === next.id &&
114
+ previous.role === next.role &&
115
+ previous.content === next.content &&
116
+ previous.createdAt === next.createdAt
117
+ );
118
+ }
119
+
120
+ export const ChatMessage = memo(ChatMessageComponent, areChatMessagePropsEqual);
121
+ ChatMessage.displayName = 'ChatMessage';
122
+
100
123
  const monoFont = Platform.select({ ios: 'Menlo', default: 'monospace' });
101
124
 
102
125
  const markdownStyles = StyleSheet.create({
@@ -164,6 +187,35 @@ const markdownStyles = StyleSheet.create({
164
187
  });
165
188
 
166
189
  const markdownRules: RenderRules = {
190
+ link: (node, children, _parent, styles, onLinkPress) => {
191
+ const href = readMarkdownAttr(node.attributes.href);
192
+ if (!href) {
193
+ return (
194
+ <Text key={node.key} style={styles.link}>
195
+ {children}
196
+ </Text>
197
+ );
198
+ }
199
+
200
+ const localFileReference = toLocalFileReferenceLabel(href);
201
+ if (localFileReference) {
202
+ return (
203
+ <Text key={node.key} style={styles.code_inline}>
204
+ {localFileReference}
205
+ </Text>
206
+ );
207
+ }
208
+
209
+ return (
210
+ <Text
211
+ key={node.key}
212
+ style={styles.link}
213
+ onPress={() => openMarkdownLink(href, onLinkPress)}
214
+ >
215
+ {children}
216
+ </Text>
217
+ );
218
+ },
167
219
  image: (
168
220
  node,
169
221
  _children,
@@ -284,6 +336,58 @@ function readMarkdownAttr(value: unknown): string | null {
284
336
  return typeof value === 'string' && value.trim().length > 0 ? value : null;
285
337
  }
286
338
 
339
+ function openMarkdownLink(
340
+ href: string,
341
+ onLinkPress?: (url: string) => boolean
342
+ ): void {
343
+ const shouldOpen = onLinkPress ? onLinkPress(href) !== false : true;
344
+ if (!shouldOpen) {
345
+ return;
346
+ }
347
+ void Linking.openURL(href).catch(() => {});
348
+ }
349
+
350
+ function toLocalFileReferenceLabel(href: string): string | null {
351
+ let normalizedHref = href.trim();
352
+ if (!normalizedHref) {
353
+ return null;
354
+ }
355
+
356
+ try {
357
+ normalizedHref = decodeURIComponent(normalizedHref);
358
+ } catch {
359
+ // Keep original href when decode fails.
360
+ }
361
+
362
+ if (normalizedHref.startsWith('file://')) {
363
+ normalizedHref = normalizedHref.replace(/^file:\/\//, '');
364
+ }
365
+
366
+ const isPosixPath = normalizedHref.startsWith('/');
367
+ const isWindowsPath = /^[A-Za-z]:[\\/]/.test(normalizedHref);
368
+ if (!isPosixPath && !isWindowsPath) {
369
+ return null;
370
+ }
371
+
372
+ const anchorLineMatch = normalizedHref.match(/#L(\d+)(?:C\d+)?$/i);
373
+ const suffixLineMatch = normalizedHref.match(/:(\d+)(?::\d+)?$/);
374
+
375
+ const line = anchorLineMatch?.[1] ?? suffixLineMatch?.[1] ?? null;
376
+ let pathOnly = normalizedHref;
377
+ if (anchorLineMatch) {
378
+ pathOnly = normalizedHref.slice(0, normalizedHref.length - anchorLineMatch[0].length);
379
+ } else if (suffixLineMatch) {
380
+ pathOnly = normalizedHref.slice(0, normalizedHref.length - suffixLineMatch[0].length);
381
+ }
382
+
383
+ const basename = pathOnly.split(/[\\/]/).filter(Boolean).pop();
384
+ if (!basename) {
385
+ return line ? `line ${line}` : null;
386
+ }
387
+
388
+ return line ? `${basename}:${line}` : basename;
389
+ }
390
+
287
391
  function parseTimelineEntries(content: string): TimelineEntry[] | null {
288
392
  if (!content.includes('•')) {
289
393
  return null;
@@ -1,7 +1,10 @@
1
- const hostBridgeUrl =
2
- process.env.EXPO_PUBLIC_HOST_BRIDGE_URL?.replace(/\/$/, '') ??
3
- process.env.EXPO_PUBLIC_MAC_BRIDGE_URL?.replace(/\/$/, '') ??
4
- 'http://127.0.0.1:8787';
1
+ import { isInsecureRemoteUrl, normalizeBridgeUrlInput } from './bridgeUrl';
2
+
3
+ const legacyHostBridgeUrl = normalizeBridgeUrlInput(
4
+ process.env.EXPO_PUBLIC_HOST_BRIDGE_URL ??
5
+ process.env.EXPO_PUBLIC_MAC_BRIDGE_URL ??
6
+ ''
7
+ );
5
8
  const hostBridgeToken =
6
9
  process.env.EXPO_PUBLIC_HOST_BRIDGE_TOKEN?.trim() ||
7
10
  process.env.EXPO_PUBLIC_MAC_BRIDGE_TOKEN?.trim() ||
@@ -19,16 +22,17 @@ const externalStatusFullSyncDebounceMs = parseNonNegativeIntEnv(
19
22
  450
20
23
  );
21
24
 
22
- if (isInsecureRemoteUrl(hostBridgeUrl) && !allowInsecureRemoteBridge) {
25
+ if (legacyHostBridgeUrl && isInsecureRemoteUrl(legacyHostBridgeUrl) && !allowInsecureRemoteBridge) {
23
26
  console.warn(
24
- 'EXPO_PUBLIC_HOST_BRIDGE_URL uses http:// for a non-local host. Prefer https:// for remote host bridge access.'
27
+ 'Using build-time bridge URL fallback from env. Configure bridge URL in-app from onboarding/settings when possible.'
25
28
  );
26
29
  }
27
30
 
28
31
  export const env = {
29
- hostBridgeUrl,
32
+ legacyHostBridgeUrl,
30
33
  hostBridgeToken,
31
34
  allowWsQueryTokenAuth,
35
+ allowInsecureRemoteBridge,
32
36
  externalStatusFullSyncDebounceMs,
33
37
  privacyPolicyUrl,
34
38
  termsOfServiceUrl
@@ -51,25 +55,3 @@ function parseNonNegativeIntEnv(value: string | undefined, fallback: number): nu
51
55
 
52
56
  return parsed;
53
57
  }
54
-
55
- function isInsecureRemoteUrl(url: string): boolean {
56
- try {
57
- const parsed = new URL(url);
58
- if (parsed.protocol !== 'http:') {
59
- return false;
60
- }
61
-
62
- return !isLocalHost(parsed.hostname);
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
- function isLocalHost(hostname: string): boolean {
69
- const normalized = hostname.trim().toLowerCase();
70
- return (
71
- normalized === 'localhost' ||
72
- normalized === '127.0.0.1' ||
73
- normalized === '::1'
74
- );
75
- }