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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/npm-release.yml +18 -0
- package/AGENTS.md +3 -3
- package/README.md +101 -541
- package/apps/mobile/.env.example +1 -2
- package/apps/mobile/App.tsx +261 -68
- package/apps/mobile/app.json +31 -5
- package/apps/mobile/assets/brand/splash-icon-white.png +0 -0
- package/apps/mobile/eas.json +30 -0
- package/apps/mobile/package.json +22 -21
- package/apps/mobile/plugins/withAndroidCleartextTraffic.js +14 -0
- package/apps/mobile/src/api/__tests__/ws.test.ts +44 -6
- package/apps/mobile/src/api/chatMapping.ts +48 -8
- package/apps/mobile/src/api/client.ts +6 -0
- package/apps/mobile/src/api/types.ts +11 -0
- package/apps/mobile/src/api/ws.ts +52 -10
- package/apps/mobile/src/bridgeUrl.ts +105 -0
- package/apps/mobile/src/components/ActivityBar.tsx +32 -13
- package/apps/mobile/src/components/ChatHeader.tsx +3 -2
- package/apps/mobile/src/components/ChatInput.tsx +246 -91
- package/apps/mobile/src/components/ChatMessage.tsx +108 -4
- package/apps/mobile/src/config.ts +11 -29
- package/apps/mobile/src/hooks/useVoiceRecorder.ts +264 -0
- package/apps/mobile/src/navigation/DrawerContent.tsx +18 -8
- package/apps/mobile/src/screens/GitScreen.tsx +1 -1
- package/apps/mobile/src/screens/MainScreen.tsx +906 -268
- package/apps/mobile/src/screens/OnboardingScreen.tsx +1132 -0
- package/apps/mobile/src/screens/PrivacyScreen.tsx +1 -1
- package/apps/mobile/src/screens/SettingsScreen.tsx +65 -1
- package/apps/mobile/src/screens/TerminalScreen.tsx +1 -1
- package/apps/mobile/src/screens/TermsScreen.tsx +1 -1
- package/docs/app-review-notes.md +7 -2
- package/docs/eas-builds.md +91 -0
- package/docs/realtime-streaming-limitations.md +84 -0
- package/docs/setup-and-operations.md +239 -0
- package/docs/troubleshooting.md +121 -0
- package/docs/voice-transcription.md +87 -0
- package/package.json +8 -16
- package/scripts/setup-secure-dev.sh +122 -8
- package/scripts/setup-wizard.sh +342 -122
- package/scripts/start-bridge-secure.sh +7 -1
- package/scripts/sync-versions.js +63 -0
- package/services/rust-bridge/.env.example +1 -1
- package/services/rust-bridge/Cargo.lock +1104 -23
- package/services/rust-bridge/Cargo.toml +3 -1
- package/services/rust-bridge/package.json +1 -1
- package/services/rust-bridge/src/main.rs +587 -12
- package/apps/mobile/metro.config.js +0 -3
package/apps/mobile/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawdex-mobile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"start": "expo start",
|
|
8
|
-
"ios": "expo
|
|
9
|
-
"android": "expo
|
|
8
|
+
"ios": "expo run:ios",
|
|
9
|
+
"android": "expo run:android",
|
|
10
10
|
"lint": "eslint App.tsx src --ext .ts,.tsx",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
12
|
"build": "expo export --platform all --output-dir dist",
|
|
@@ -14,36 +14,37 @@
|
|
|
14
14
|
"test:watch": "jest --watch"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@expo/metro-runtime": "
|
|
17
|
+
"@expo/metro-runtime": "~55.0.6",
|
|
18
18
|
"@expo/vector-icons": "15.0.3",
|
|
19
|
-
"
|
|
20
|
-
"expo": "
|
|
21
|
-
"expo-blur": "
|
|
22
|
-
"expo-
|
|
23
|
-
"expo-
|
|
24
|
-
"expo-
|
|
25
|
-
"expo-
|
|
26
|
-
"
|
|
27
|
-
"react
|
|
28
|
-
"react-
|
|
29
|
-
"react-native
|
|
19
|
+
"expo": "~55.0.3",
|
|
20
|
+
"expo-audio": "~55.0.8",
|
|
21
|
+
"expo-blur": "~55.0.8",
|
|
22
|
+
"expo-camera": "~55.0.9",
|
|
23
|
+
"expo-document-picker": "~55.0.8",
|
|
24
|
+
"expo-file-system": "~55.0.9",
|
|
25
|
+
"expo-image-picker": "~55.0.9",
|
|
26
|
+
"expo-linear-gradient": "~55.0.8",
|
|
27
|
+
"react": "19.2.0",
|
|
28
|
+
"react-dom": "19.2.0",
|
|
29
|
+
"react-native": "0.83.2",
|
|
30
|
+
"react-native-gesture-handler": "~2.30.0",
|
|
30
31
|
"react-native-markdown-display": "7.0.2",
|
|
31
|
-
"react-native-reanimated": "4.
|
|
32
|
+
"react-native-reanimated": "4.2.1",
|
|
32
33
|
"react-native-safe-area-context": "5.6.2",
|
|
33
|
-
"react-native-screens": "4.
|
|
34
|
+
"react-native-screens": "~4.23.0",
|
|
34
35
|
"react-native-web": "0.21.2",
|
|
35
|
-
"react-native-worklets": "0.
|
|
36
|
+
"react-native-worklets": "0.7.2"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
|
-
"@types/jest": "
|
|
39
|
+
"@types/jest": "29.5.14",
|
|
39
40
|
"@types/node": "^22.10.1",
|
|
40
|
-
"@types/react": "~19.
|
|
41
|
+
"@types/react": "~19.2.10",
|
|
41
42
|
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
|
42
43
|
"@typescript-eslint/parser": "^8.18.1",
|
|
43
44
|
"eslint": "^10.0.0",
|
|
44
45
|
"globals": "^16.5.0",
|
|
45
46
|
"jest": "^29.7.0",
|
|
46
|
-
"jest-expo": "
|
|
47
|
+
"jest-expo": "~55.0.9",
|
|
47
48
|
"typescript": "~5.9.2"
|
|
48
49
|
},
|
|
49
50
|
"jest": {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const { AndroidConfig, withAndroidManifest } = require('@expo/config-plugins');
|
|
2
|
+
|
|
3
|
+
function withAndroidCleartextTraffic(config) {
|
|
4
|
+
return withAndroidManifest(config, (modConfig) => {
|
|
5
|
+
const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(
|
|
6
|
+
modConfig.modResults
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
mainApplication.$['android:usesCleartextTraffic'] = 'true';
|
|
10
|
+
return modConfig;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = withAndroidCleartextTraffic;
|
|
@@ -74,18 +74,30 @@ describe('HostBridgeWsClient', () => {
|
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
it('supports
|
|
78
|
-
if (Platform.OS !== 'web') {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
77
|
+
it('supports query token auth fallback when enabled', () => {
|
|
82
78
|
const client = new HostBridgeWsClient('http://localhost:8787', {
|
|
83
79
|
authToken: 'token-xyz',
|
|
84
80
|
allowQueryTokenAuth: true,
|
|
85
81
|
});
|
|
86
82
|
client.connect();
|
|
87
83
|
|
|
88
|
-
|
|
84
|
+
if (Platform.OS === 'web') {
|
|
85
|
+
expect(global.WebSocket).toHaveBeenCalledWith('ws://localhost:8787/rpc?token=token-xyz');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Platform.OS === 'android') {
|
|
90
|
+
expect(global.WebSocket).toHaveBeenCalledWith('ws://localhost:8787/rpc?token=token-xyz');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(global.WebSocket).toHaveBeenCalledWith(
|
|
95
|
+
'ws://localhost:8787/rpc?token=token-xyz',
|
|
96
|
+
undefined,
|
|
97
|
+
{
|
|
98
|
+
headers: { Authorization: 'Bearer token-xyz' },
|
|
99
|
+
}
|
|
100
|
+
);
|
|
89
101
|
});
|
|
90
102
|
|
|
91
103
|
it('onEvent emits rpc notifications', () => {
|
|
@@ -222,6 +234,32 @@ describe('HostBridgeWsClient', () => {
|
|
|
222
234
|
await expect(waitPromise).resolves.toBeUndefined();
|
|
223
235
|
});
|
|
224
236
|
|
|
237
|
+
it('waitForTurnCompletion resolves from codex event using source parent_thread_id', async () => {
|
|
238
|
+
const client = new HostBridgeWsClient('http://localhost:8787');
|
|
239
|
+
client.connect();
|
|
240
|
+
|
|
241
|
+
const waitPromise = client.waitForTurnCompletion('thr_5', 'turn_5', 100);
|
|
242
|
+
latestMockSocket().simulateMessage(
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
method: 'codex/event/task_complete',
|
|
245
|
+
params: {
|
|
246
|
+
msg: {
|
|
247
|
+
type: 'task_complete',
|
|
248
|
+
source: {
|
|
249
|
+
subagent: {
|
|
250
|
+
thread_spawn: {
|
|
251
|
+
parent_thread_id: 'thr_5',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
await expect(waitPromise).resolves.toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
225
263
|
it('deduplicates notifications by eventId', () => {
|
|
226
264
|
const client = new HostBridgeWsClient('http://localhost:8787');
|
|
227
265
|
const listener = jest.fn();
|
|
@@ -82,15 +82,32 @@ function unixSecondsToIso(value: number | undefined): string {
|
|
|
82
82
|
return new Date(value * 1000).toISOString();
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function normalizeLifecycleStatus(value: string | null | undefined): string | null {
|
|
86
|
+
if (!value) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
91
|
+
return normalized.length > 0 ? normalized : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
function mapRawStatus(status: unknown, turns: RawTurn[] | undefined): ChatStatus {
|
|
86
95
|
const statusRecord = toRecord(status);
|
|
87
|
-
const statusType =
|
|
96
|
+
const statusType = normalizeLifecycleStatus(
|
|
97
|
+
readString(statusRecord?.type) ?? readString(status)
|
|
98
|
+
);
|
|
88
99
|
const hasTurns = Array.isArray(turns) && turns.length > 0;
|
|
89
100
|
const lastTurn = hasTurns ? turns[turns.length - 1] : null;
|
|
90
|
-
const lastTurnStatus = readString(lastTurn?.status);
|
|
91
|
-
const isIdleLikeStatus = statusType === 'idle' || statusType === '
|
|
92
|
-
|
|
93
|
-
if (
|
|
101
|
+
const lastTurnStatus = normalizeLifecycleStatus(readString(lastTurn?.status));
|
|
102
|
+
const isIdleLikeStatus = statusType === 'idle' || statusType === 'notloaded';
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
lastTurnStatus === 'inprogress' ||
|
|
106
|
+
lastTurnStatus === 'running' ||
|
|
107
|
+
lastTurnStatus === 'active' ||
|
|
108
|
+
lastTurnStatus === 'queued' ||
|
|
109
|
+
lastTurnStatus === 'pending'
|
|
110
|
+
) {
|
|
94
111
|
// Some thread/read payloads can return stale turn state while the thread
|
|
95
112
|
// itself is already idle/notLoaded. Prefer the thread lifecycle in that case.
|
|
96
113
|
if (isIdleLikeStatus) {
|
|
@@ -99,18 +116,41 @@ function mapRawStatus(status: unknown, turns: RawTurn[] | undefined): ChatStatus
|
|
|
99
116
|
return 'running';
|
|
100
117
|
}
|
|
101
118
|
|
|
102
|
-
if (
|
|
119
|
+
if (
|
|
120
|
+
lastTurnStatus === 'failed' ||
|
|
121
|
+
lastTurnStatus === 'interrupted' ||
|
|
122
|
+
lastTurnStatus === 'error' ||
|
|
123
|
+
lastTurnStatus === 'aborted'
|
|
124
|
+
) {
|
|
103
125
|
return 'error';
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
lastTurnStatus === 'completed' ||
|
|
130
|
+
lastTurnStatus === 'complete' ||
|
|
131
|
+
lastTurnStatus === 'success' ||
|
|
132
|
+
lastTurnStatus === 'succeeded'
|
|
133
|
+
) {
|
|
107
134
|
return 'complete';
|
|
108
135
|
}
|
|
109
136
|
|
|
110
|
-
if (
|
|
137
|
+
if (
|
|
138
|
+
statusType === 'systemerror' ||
|
|
139
|
+
statusType === 'error' ||
|
|
140
|
+
statusType === 'failed'
|
|
141
|
+
) {
|
|
111
142
|
return 'error';
|
|
112
143
|
}
|
|
113
144
|
|
|
145
|
+
if (
|
|
146
|
+
statusType === 'running' ||
|
|
147
|
+
statusType === 'inprogress' ||
|
|
148
|
+
statusType === 'queued' ||
|
|
149
|
+
statusType === 'pending'
|
|
150
|
+
) {
|
|
151
|
+
return 'running';
|
|
152
|
+
}
|
|
153
|
+
|
|
114
154
|
if (statusType === 'active') {
|
|
115
155
|
// Some backends keep a thread "active" while loaded in memory even when no
|
|
116
156
|
// turn is running. If there is no in-progress turn, avoid false "working" UI.
|
|
@@ -32,6 +32,8 @@ import type {
|
|
|
32
32
|
LocalImageInput,
|
|
33
33
|
UploadAttachmentRequest,
|
|
34
34
|
UploadAttachmentResponse,
|
|
35
|
+
VoiceTranscribeRequest,
|
|
36
|
+
VoiceTranscribeResponse,
|
|
35
37
|
ModelOption,
|
|
36
38
|
ReasoningEffort,
|
|
37
39
|
ModelReasoningEffortOption,
|
|
@@ -477,6 +479,10 @@ export class HostBridgeApiClient {
|
|
|
477
479
|
return this.ws.request<UploadAttachmentResponse>('bridge/attachments/upload', body);
|
|
478
480
|
}
|
|
479
481
|
|
|
482
|
+
transcribeVoice(body: VoiceTranscribeRequest): Promise<VoiceTranscribeResponse> {
|
|
483
|
+
return this.ws.request<VoiceTranscribeResponse>('bridge/voice/transcribe', body);
|
|
484
|
+
}
|
|
485
|
+
|
|
480
486
|
async listModels(includeHidden = false): Promise<ModelOption[]> {
|
|
481
487
|
const response = await this.ws.request<AppServerModelListResponse>('model/list', {
|
|
482
488
|
cursor: null,
|
|
@@ -310,6 +310,17 @@ export interface RunEvent {
|
|
|
310
310
|
detail?: string;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
export interface VoiceTranscribeRequest {
|
|
314
|
+
dataBase64: string;
|
|
315
|
+
prompt?: string;
|
|
316
|
+
fileName?: string;
|
|
317
|
+
mimeType?: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface VoiceTranscribeResponse {
|
|
321
|
+
text: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
313
324
|
export interface RpcNotification {
|
|
314
325
|
method: string;
|
|
315
326
|
params: Record<string, unknown> | null;
|
|
@@ -311,8 +311,12 @@ export class HostBridgeWsClient {
|
|
|
311
311
|
await new Promise<void>((resolve, reject) => {
|
|
312
312
|
const WebSocketCtor = globalThis.WebSocket as unknown as ReactNativeWebSocketConstructor;
|
|
313
313
|
const socketUrl = this.socketUrl();
|
|
314
|
+
const shouldUseHeaderAuth =
|
|
315
|
+
Boolean(this.authToken) &&
|
|
316
|
+
Platform.OS !== 'web' &&
|
|
317
|
+
!(Platform.OS === 'android' && this.allowQueryTokenAuth);
|
|
314
318
|
const socket =
|
|
315
|
-
|
|
319
|
+
shouldUseHeaderAuth
|
|
316
320
|
? new WebSocketCtor(socketUrl, undefined, {
|
|
317
321
|
headers: {
|
|
318
322
|
Authorization: `Bearer ${this.authToken}`,
|
|
@@ -640,7 +644,7 @@ export class HostBridgeWsClient {
|
|
|
640
644
|
: this.baseUrl.replace('http://', 'ws://');
|
|
641
645
|
const base = `${wsBase}/rpc`;
|
|
642
646
|
|
|
643
|
-
if (!this.authToken ||
|
|
647
|
+
if (!this.authToken || !this.allowQueryTokenAuth) {
|
|
644
648
|
return base;
|
|
645
649
|
}
|
|
646
650
|
|
|
@@ -690,8 +694,8 @@ function toTurnCompletionSnapshot(value: unknown): TurnCompletionSnapshot | null
|
|
|
690
694
|
return null;
|
|
691
695
|
}
|
|
692
696
|
|
|
693
|
-
const threadId = readString(params.threadId) ?? readString(params.thread_id);
|
|
694
697
|
const turn = toRecord(params.turn);
|
|
698
|
+
const threadId = extractNotificationThreadId(params, turn);
|
|
695
699
|
const turnId =
|
|
696
700
|
readString(turn?.id) ?? readString(params.turnId) ?? readString(params.turn_id);
|
|
697
701
|
if (!threadId) {
|
|
@@ -752,13 +756,7 @@ function toCodexEventSnapshot(
|
|
|
752
756
|
return null;
|
|
753
757
|
}
|
|
754
758
|
|
|
755
|
-
const threadId =
|
|
756
|
-
readString(msg?.thread_id) ??
|
|
757
|
-
readString(msg?.threadId) ??
|
|
758
|
-
readString(params?.threadId) ??
|
|
759
|
-
readString(params?.thread_id) ??
|
|
760
|
-
readString(params?.conversationId) ??
|
|
761
|
-
readString(msg?.conversation_id);
|
|
759
|
+
const threadId = extractNotificationThreadId(params, msg);
|
|
762
760
|
|
|
763
761
|
return {
|
|
764
762
|
type,
|
|
@@ -766,6 +764,50 @@ function toCodexEventSnapshot(
|
|
|
766
764
|
};
|
|
767
765
|
}
|
|
768
766
|
|
|
767
|
+
function extractNotificationThreadId(
|
|
768
|
+
params: Record<string, unknown> | null,
|
|
769
|
+
msgArg?: Record<string, unknown> | null
|
|
770
|
+
): string | null {
|
|
771
|
+
if (!params && !msgArg) {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const msg = msgArg ?? toRecord(params?.msg);
|
|
776
|
+
const threadRecord =
|
|
777
|
+
toRecord(params?.thread) ??
|
|
778
|
+
toRecord(params?.threadState) ??
|
|
779
|
+
toRecord(params?.thread_state) ??
|
|
780
|
+
toRecord(msg?.thread);
|
|
781
|
+
const sourceRecord = toRecord(params?.source) ?? toRecord(msg?.source);
|
|
782
|
+
const subagentThreadSpawnRecord = toRecord(
|
|
783
|
+
toRecord(sourceRecord?.subagent)?.thread_spawn
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
return (
|
|
787
|
+
readString(msg?.thread_id) ??
|
|
788
|
+
readString(msg?.threadId) ??
|
|
789
|
+
readString(msg?.conversation_id) ??
|
|
790
|
+
readString(msg?.conversationId) ??
|
|
791
|
+
readString(params?.thread_id) ??
|
|
792
|
+
readString(params?.threadId) ??
|
|
793
|
+
readString(params?.conversation_id) ??
|
|
794
|
+
readString(params?.conversationId) ??
|
|
795
|
+
readString(threadRecord?.id) ??
|
|
796
|
+
readString(threadRecord?.thread_id) ??
|
|
797
|
+
readString(threadRecord?.threadId) ??
|
|
798
|
+
readString(threadRecord?.conversation_id) ??
|
|
799
|
+
readString(threadRecord?.conversationId) ??
|
|
800
|
+
readString(sourceRecord?.thread_id) ??
|
|
801
|
+
readString(sourceRecord?.threadId) ??
|
|
802
|
+
readString(sourceRecord?.conversation_id) ??
|
|
803
|
+
readString(sourceRecord?.conversationId) ??
|
|
804
|
+
readString(sourceRecord?.parent_thread_id) ??
|
|
805
|
+
readString(sourceRecord?.parentThreadId) ??
|
|
806
|
+
readString(subagentThreadSpawnRecord?.parent_thread_id) ??
|
|
807
|
+
null
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
769
811
|
function normalizeCodexEventType(value: string | null): string | null {
|
|
770
812
|
if (!value) {
|
|
771
813
|
return null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']);
|
|
2
|
+
|
|
3
|
+
export function normalizeBridgeUrlInput(value: string): string | null {
|
|
4
|
+
if (typeof value !== 'string') {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
if (!trimmed) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let parsed: URL;
|
|
14
|
+
try {
|
|
15
|
+
parsed = new URL(trimmed);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!parsed.hostname || parsed.username || parsed.password) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const normalizedProtocol =
|
|
29
|
+
parsed.protocol === 'ws:' ? 'http:' : parsed.protocol === 'wss:' ? 'https:' : parsed.protocol;
|
|
30
|
+
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
|
31
|
+
|
|
32
|
+
parsed.protocol = normalizedProtocol;
|
|
33
|
+
parsed.pathname = normalizedPath || '';
|
|
34
|
+
parsed.search = '';
|
|
35
|
+
parsed.hash = '';
|
|
36
|
+
parsed.username = '';
|
|
37
|
+
parsed.password = '';
|
|
38
|
+
|
|
39
|
+
return parsed.toString().replace(/\/$/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isInsecureRemoteUrl(url: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(url);
|
|
45
|
+
if (parsed.protocol !== 'http:') {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return !isLikelyPrivateHost(parsed.hostname);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function toBridgeHealthUrl(baseUrl: string): string {
|
|
56
|
+
return `${baseUrl.replace(/\/$/, '')}/health`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLikelyPrivateHost(hostname: string): boolean {
|
|
60
|
+
const normalized = hostname.trim().toLowerCase();
|
|
61
|
+
const host =
|
|
62
|
+
normalized.startsWith('[') && normalized.endsWith(']')
|
|
63
|
+
? normalized.slice(1, -1)
|
|
64
|
+
: normalized;
|
|
65
|
+
if (!host) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
host === 'localhost' ||
|
|
71
|
+
host === '127.0.0.1' ||
|
|
72
|
+
host === '::1' ||
|
|
73
|
+
host.endsWith('.local')
|
|
74
|
+
) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (host.includes(':')) {
|
|
79
|
+
return (
|
|
80
|
+
host.startsWith('fc') ||
|
|
81
|
+
host.startsWith('fd') ||
|
|
82
|
+
host.startsWith('fe80:')
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const octets = host.split('.');
|
|
87
|
+
if (octets.length !== 4) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const [firstStr, secondStr] = octets;
|
|
92
|
+
const first = Number.parseInt(firstStr, 10);
|
|
93
|
+
const second = Number.parseInt(secondStr, 10);
|
|
94
|
+
if (!Number.isInteger(first) || !Number.isInteger(second)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
first === 10 ||
|
|
100
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
101
|
+
(first === 192 && second === 168) ||
|
|
102
|
+
(first === 169 && second === 254) ||
|
|
103
|
+
(first === 100 && second >= 64 && second <= 127)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import { BlurView } from 'expo-blur';
|
|
2
3
|
import { useEffect, useState } from 'react';
|
|
3
|
-
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
|
4
|
+
import { ActivityIndicator, Platform, StyleSheet, Text, View } from 'react-native';
|
|
4
5
|
|
|
5
6
|
import { colors, spacing, typography } from '../theme';
|
|
6
7
|
|
|
@@ -46,29 +47,47 @@ export function ActivityBar({ title, detail, tone }: ActivityBarProps) {
|
|
|
46
47
|
const text = `${title}${suffix}${dots}`;
|
|
47
48
|
|
|
48
49
|
return (
|
|
49
|
-
<
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
<BlurView
|
|
51
|
+
intensity={42}
|
|
52
|
+
tint={Platform.OS === 'ios' ? 'systemUltraThinMaterialDark' : 'dark'}
|
|
53
|
+
blurMethod="dimezisBlurViewSdk31Plus"
|
|
54
|
+
style={styles.container}
|
|
55
|
+
>
|
|
56
|
+
<View style={styles.content}>
|
|
57
|
+
{tone === 'running' ? (
|
|
58
|
+
<ActivityIndicator size="small" color={color} />
|
|
59
|
+
) : (
|
|
60
|
+
<Ionicons name={ICON_BY_TONE[tone]} size={13} color={color} />
|
|
61
|
+
)}
|
|
62
|
+
<Text style={styles.text} numberOfLines={1}>
|
|
63
|
+
{text}
|
|
64
|
+
</Text>
|
|
65
|
+
</View>
|
|
66
|
+
</BlurView>
|
|
59
67
|
);
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
const styles = StyleSheet.create({
|
|
63
71
|
container: {
|
|
72
|
+
borderRadius: 10,
|
|
73
|
+
overflow: 'hidden',
|
|
74
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
75
|
+
borderColor: 'rgba(255, 255, 255, 0.12)',
|
|
76
|
+
backgroundColor: 'rgba(18, 22, 28, 0.16)',
|
|
77
|
+
marginHorizontal: spacing.lg,
|
|
78
|
+
marginBottom: spacing.xs / 2,
|
|
79
|
+
},
|
|
80
|
+
content: {
|
|
64
81
|
flexDirection: 'row',
|
|
65
82
|
alignItems: 'center',
|
|
66
83
|
gap: spacing.xs,
|
|
67
|
-
|
|
68
|
-
paddingVertical:
|
|
84
|
+
paddingHorizontal: spacing.sm + 2,
|
|
85
|
+
paddingVertical: 3,
|
|
69
86
|
},
|
|
70
87
|
text: {
|
|
71
88
|
...typography.caption,
|
|
89
|
+
fontSize: 11,
|
|
90
|
+
lineHeight: 15,
|
|
72
91
|
fontWeight: '600',
|
|
73
92
|
color: colors.textPrimary,
|
|
74
93
|
flex: 1,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
-
import { Pressable,
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
3
4
|
import { BrandMark } from './BrandMark';
|
|
4
5
|
import { colors, spacing, typography } from '../theme';
|
|
5
6
|
|
|
@@ -22,7 +23,7 @@ export function ChatHeader({
|
|
|
22
23
|
|
|
23
24
|
return (
|
|
24
25
|
<View style={styles.headerContainer}>
|
|
25
|
-
<SafeAreaView>
|
|
26
|
+
<SafeAreaView edges={['top', 'left', 'right']}>
|
|
26
27
|
<View style={styles.header}>
|
|
27
28
|
<Pressable onPress={onOpenDrawer} hitSlop={8} style={styles.menuBtn}>
|
|
28
29
|
<Ionicons name="menu" size={22} color={colors.textPrimary} />
|