1
+ import * as _ from 'lodash' ;
2
+ import * as React from 'react' ;
3
+ import { computed , observable , action , autorun , flow } from 'mobx' ;
4
+ import { observer , inject , disposeOnUnmount } from 'mobx-react' ;
5
+
6
+ import { styled } from '../../../styles' ;
7
+
8
+ import { Interceptor } from '../../../model/interception/interceptors' ;
9
+ import { ProxyStore } from '../../../model/proxy-store' ;
10
+ import { AccountStore } from '../../../model/account/account-store' ;
11
+ import { EventsStore } from '../../../model/events/events-store' ;
12
+ import { RulesStore } from '../../../model/rules/rules-store' ;
13
+ import { FridaActivationOptions , FridaHost , FridaTarget } from '../../../model/interception/frida' ;
14
+
15
+ import { getDetailedInterceptorMetadata } from '../../../services/server-api' ;
16
+
17
+ import { TextInput } from '../../common/inputs' ;
18
+ import { Icon } from '../../../icons' ;
19
+ import { InterceptionTargetList } from './intercept-target-list' ;
20
+ import { IconButton } from '../../common/icon-button' ;
21
+
22
+ const ConfigContainer = styled . div `
23
+ user-select: text;
24
+ max-height: 440px;
25
+
26
+ height: 100%;
27
+ width: 100%;
28
+ display: flex;
29
+ flex-direction: column;
30
+ justify-content: start;
31
+
32
+ > p {
33
+ line-height: 1.2;
34
+
35
+ &:not(:last-child) {
36
+ margin-bottom: 5px;
37
+ }
38
+
39
+ &:not(:first-child) {
40
+ margin-top: 5px;
41
+ }
42
+ }
43
+
44
+ a[href] {
45
+ color: ${ p => p . theme . linkColor } ;
46
+
47
+ &:visited {
48
+ color: ${ p => p . theme . visitedLinkColor } ;
49
+ }
50
+ }
51
+ ` ;
52
+
53
+ const BackAndSearchBlock = styled . div `
54
+ margin: 5px -15px 0;
55
+
56
+ display: flex;
57
+ flex-direction: row;
58
+ align-items: stretch;
59
+
60
+ z-index: 1;
61
+ box-shadow: 0 0 5px 2px rgba(0,0,0,${ p => p . theme . boxShadowAlpha } );
62
+
63
+ ` ;
64
+
65
+ const BackButton = styled ( IconButton ) . attrs ( ( ) => ( {
66
+ icon : [ 'fas' , 'arrow-left' ] ,
67
+ title : 'Jump to this request on the View page'
68
+ } ) ) `
69
+ font-size: ${ p => p . theme . textSize } ;
70
+ padding: 2px 10px 0;
71
+ ` ;
72
+
73
+ const SearchBox = styled ( TextInput ) `
74
+ flex-grow: 1;
75
+
76
+ border: none;
77
+ border-radius: 0;
78
+ padding: 10px 10px 8px;
79
+ ` ;
80
+
81
+ const Footer = styled . p `
82
+ margin-top: auto;
83
+ font-size: 85%;
84
+ font-style: italic;
85
+ ` ;
86
+
87
+ @inject ( 'proxyStore' )
88
+ @inject ( 'rulesStore' )
89
+ @inject ( 'eventsStore' )
90
+ @inject ( 'accountStore' )
91
+ @observer
92
+ class FridaConfig extends React . Component < {
93
+ proxyStore ?: ProxyStore ,
94
+ rulesStore ?: RulesStore ,
95
+ eventsStore ?: EventsStore ,
96
+ accountStore ?: AccountStore ,
97
+
98
+ interceptor : Interceptor ,
99
+ activateInterceptor : ( options : FridaActivationOptions ) => Promise < void > ,
100
+ reportStarted : ( ) => void ,
101
+ reportSuccess : ( ) => void ,
102
+ closeSelf : ( ) => void
103
+ } > {
104
+
105
+ @computed private get fridaHosts ( ) : Array < FridaHost > {
106
+ return this . props . interceptor . metadata ?. hosts || [ ] ;
107
+ }
108
+
109
+ @observable fridaTargets : Array < FridaTarget > = [ ] ;
110
+
111
+ updateTargets = flow ( function * ( this : FridaConfig ) {
112
+ if ( ! this . selectedHost ) {
113
+ this . fridaTargets = [ ] ;
114
+ return ;
115
+ }
116
+
117
+ const result : {
118
+ targets : FridaTarget [ ]
119
+ } | undefined = (
120
+ yield getDetailedInterceptorMetadata ( this . props . interceptor . id , this . selectedHost ?. id )
121
+ ) ;
122
+
123
+ this . fridaTargets = result ?. targets ?? [ ] ;
124
+ } . bind ( this ) ) ;
125
+
126
+
127
+ @observable private inProgressHostIds : string [ ] = [ ] ;
128
+ @observable private inProgressTargetIds : string [ ] = [ ] ;
129
+
130
+ async componentDidMount ( ) {
131
+ if ( this . fridaHosts . length === 1 && this . fridaHosts [ 0 ] . state === 'available' ) {
132
+ this . selectHost ( this . fridaHosts [ 0 ] . id ) ;
133
+ }
134
+
135
+ disposeOnUnmount ( this , autorun ( ( ) => {
136
+ if ( this . selectedHostId && ! this . fridaHosts . some ( host => host . id === this . selectedHostId ) ) {
137
+ this . deselectHost ( ) ;
138
+ }
139
+ } ) ) ;
140
+
141
+ this . updateTargets ( ) ;
142
+ const updateInterval = setInterval ( this . updateTargets , 2000 ) ;
143
+ disposeOnUnmount ( this , ( ) => clearInterval ( updateInterval ) ) ;
144
+ }
145
+
146
+ @computed
147
+ get deviceClassName ( ) {
148
+ const interceptorId = this . props . interceptor . id ;
149
+ if ( interceptorId === 'android-frida' ) {
150
+ return 'Android' ;
151
+ } else if ( interceptorId === 'ios-frida' ) {
152
+ return 'iOS' ;
153
+ } else {
154
+ throw new Error ( `Unknown Frida interceptor type: ${ interceptorId } ` ) ;
155
+ }
156
+ }
157
+
158
+ @observable selectedHostId : string | undefined ;
159
+
160
+ @computed
161
+ get selectedHost ( ) {
162
+ if ( ! this . selectedHostId ) return ;
163
+ const hosts = this . fridaHosts ;
164
+ return this . fridaHosts . find ( host => host . id === this . selectedHostId && host . state !== 'unavailable' ) ;
165
+ }
166
+
167
+ @action . bound
168
+ selectHost ( hostId : string ) {
169
+ this . selectedHostId = hostId ;
170
+
171
+ const host = this . selectedHost ;
172
+ if ( host ?. state === 'available' ) {
173
+ this . searchInput = '' ;
174
+ this . updateTargets ( ) ;
175
+ } else if ( host ?. state === 'launch-required' ) {
176
+ this . inProgressHostIds . push ( hostId ) ;
177
+ this . props . activateInterceptor ( {
178
+ action : 'launch' ,
179
+ hostId
180
+ } ) . finally ( action ( ( ) => {
181
+ _ . pull ( this . inProgressHostIds , hostId ) ;
182
+ } ) ) ;
183
+ } else if ( host ?. state === 'setup-required' ) {
184
+ this . inProgressHostIds . push ( hostId ) ;
185
+ this . props . activateInterceptor ( {
186
+ action : 'setup' ,
187
+ hostId
188
+ } ) . finally ( action ( ( ) => {
189
+ _ . pull ( this . inProgressHostIds , hostId ) ;
190
+ } ) ) ;
191
+ } else {
192
+ return ;
193
+ }
194
+ }
195
+
196
+ @action . bound
197
+ deselectHost ( ) {
198
+ this . selectedHostId = undefined ;
199
+ }
200
+
201
+ @action . bound
202
+ interceptTarget ( targetId : string ) {
203
+ const host = this . selectedHost ;
204
+
205
+ if ( ! host ) return ;
206
+
207
+ this . inProgressTargetIds . push ( targetId ) ;
208
+ this . props . activateInterceptor ( {
209
+ action : 'intercept' ,
210
+ hostId : host . id ,
211
+ targetId
212
+ } ) . finally ( action ( ( ) => {
213
+ _ . pull ( this . inProgressTargetIds , targetId ) ;
214
+ } ) ) ;
215
+ }
216
+
217
+ @observable searchInput : string = '' ;
218
+
219
+ @action . bound
220
+ onSearchChange ( event : React . ChangeEvent < HTMLInputElement > ) {
221
+ this . searchInput = event . currentTarget . value ;
222
+ }
223
+
224
+ render ( ) {
225
+ const selectedHost = this . selectedHost ;
226
+
227
+ const docsFooter = < Footer >
228
+ For more information, see the in-depth < a
229
+ href = "https://httptoolkit.com/docs/guides/frida/"
230
+ > Frida interception guide</ a > .
231
+ </ Footer > ;
232
+
233
+ if ( selectedHost ) {
234
+ const lowercaseSearchInput = this . searchInput . toLowerCase ( ) ;
235
+ const targets = _ . sortBy (
236
+ this . fridaTargets
237
+ . filter ( ( { name } ) => name . toLowerCase ( ) . includes ( lowercaseSearchInput ) ) ,
238
+ ( target ) => target . name . toLowerCase ( )
239
+ ) ;
240
+
241
+ return < ConfigContainer >
242
+ < BackAndSearchBlock >
243
+ < BackButton onClick = { this . deselectHost } />
244
+ < SearchBox
245
+ value = { this . searchInput }
246
+ onChange = { this . onSearchChange }
247
+ placeholder = 'Search for a target...'
248
+ autoFocus = { true }
249
+ />
250
+ </ BackAndSearchBlock >
251
+ < InterceptionTargetList
252
+ spinnerText = 'Scanning for apps to intercept...'
253
+ targets = { targets . map ( target => {
254
+ const { id, name } = target ;
255
+ const activating = this . inProgressTargetIds . includes ( id ) ;
256
+
257
+ return {
258
+ id,
259
+ title : `${ this . deviceClassName } app: ${ name } (${ id } )` ,
260
+ status : activating
261
+ ? 'activating'
262
+ : 'available' ,
263
+ content : < p >
264
+ { name }
265
+ </ p >
266
+ } ;
267
+ } )
268
+ }
269
+ interceptTarget = { this . interceptTarget }
270
+ ellipseDirection = 'right'
271
+ />
272
+ { docsFooter }
273
+ </ ConfigContainer > ;
274
+ }
275
+
276
+ return < ConfigContainer >
277
+ < InterceptionTargetList
278
+ spinnerText = { `Waiting for ${ this . deviceClassName } devices to attach to...` }
279
+ targets = { this . fridaHosts . map ( host => {
280
+ const { id, name, state } = host ;
281
+ const activating = this . inProgressHostIds . includes ( id ) ;
282
+
283
+ return {
284
+ id,
285
+ title : `${ this . deviceClassName } device ${ name } in state ${ state } ` ,
286
+ status : activating
287
+ ? 'activating'
288
+ : state === 'unavailable'
289
+ ? 'unavailable'
290
+ // Available here means clickable - interceptable/setupable/launchable
291
+ : 'available' ,
292
+ content : < p >
293
+ {
294
+ activating
295
+ ? < Icon icon = { [ 'fas' , 'spinner' ] } spin />
296
+ : id . includes ( "emulator-" )
297
+ ? < Icon icon = { [ 'far' , 'window-maximize' ] } />
298
+ : id . match ( / \d + \. \d + \. \d + \. \d + : \d + / )
299
+ ? < Icon icon = { [ 'fas' , 'network-wired' ] } />
300
+ : < Icon icon = { [ 'fas' , 'mobile-alt' ] } />
301
+ } { name } < br /> { state }
302
+ </ p >
303
+ } ;
304
+ } ) }
305
+ interceptTarget = { this . selectHost }
306
+ ellipseDirection = 'right'
307
+ />
308
+ { docsFooter }
309
+ </ ConfigContainer > ;
310
+ }
311
+
312
+ onSuccess = ( ) => {
313
+ this . props . reportSuccess ( ) ;
314
+ } ;
315
+
316
+ }
317
+
318
+ export const FridaCustomUi = {
319
+ columnWidth : 1 ,
320
+ rowHeight : 2 ,
321
+ configComponent : FridaConfig
322
+ } ;
0 commit comments