1
+ import * as vscode from 'vscode' ;
2
+
3
+ import * as fs from 'fs' ;
4
+ import * as path from 'path' ;
5
+
6
+ import * as archiver from 'archiver' ;
7
+ import * as WebSocket from 'ws' ;
8
+ import * as request from 'request' ;
9
+
10
+ import { TracingConsentCache } from './tracing-consent' ;
11
+
12
+ export interface Ctx {
13
+ readonly extensionContext : vscode . ExtensionContext ,
14
+ readonly extensionConfig : vscode . WorkspaceConfiguration ,
15
+ readonly extensionOut : vscode . OutputChannel
16
+ }
17
+
18
+ export class Tracer {
19
+ private readonly ctx : Ctx
20
+
21
+ private projectId : string
22
+ private machineId : string
23
+ private sessionId : string
24
+
25
+ private tracingConsent : TracingConsentCache
26
+
27
+ private remoteTracingUrl ?: string
28
+
29
+ constructor ( ctx : Ctx ) {
30
+ this . ctx = ctx ;
31
+
32
+ this . tracingConsent = new TracingConsentCache ( ctx . extensionContext . workspaceState ) ;
33
+
34
+ this . machineId = ( ( ) => {
35
+ const machineIdKey = 'tracing.machineId' ;
36
+ function persisted ( value : string ) : string {
37
+ ctx . extensionConfig . update ( machineIdKey , value , vscode . ConfigurationTarget . Global )
38
+ return value
39
+ }
40
+
41
+ const machineId = ctx . extensionConfig . get < string | null > ( machineIdKey )
42
+ if ( machineId != null ) return machineId ;
43
+
44
+ // vscode.env.machineId is a dummy value if telemetry is off - cannot be used
45
+ const vscodeMachineId = vscode . workspace . getConfiguration ( ) . get < string > ( 'telemetry.machineId' )
46
+ if ( vscodeMachineId !== undefined ) return persisted ( vscodeMachineId )
47
+
48
+ function uuidv4 ( ) {
49
+ // https://stackoverflow.com/a/2117523
50
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' . replace ( / [ x y ] / g, function ( c ) {
51
+ var r = Math . random ( ) * 16 | 0 , v = c == 'x' ? r : ( r & 0x3 | 0x8 ) ;
52
+ return v . toString ( 16 ) ;
53
+ } ) ;
54
+ }
55
+
56
+ return persisted ( uuidv4 ( ) )
57
+ } ) ( ) ;
58
+
59
+ this . projectId = vscode . workspace . name !== undefined ? vscode . workspace . name : 'no-project' ;
60
+ this . sessionId = new Date ( ) . toISOString ( ) ;
61
+ }
62
+
63
+ initializeAsyncWorkspaceDump ( ) {
64
+ const remoteWorkspaceDumpUrl = this . ctx . extensionConfig . get < string > ( 'remoteWorkspaceDumpUrl' ) ;
65
+ if ( remoteWorkspaceDumpUrl === undefined ) return ;
66
+
67
+ try {
68
+ this . asyncUploadWorkspaceDump ( remoteWorkspaceDumpUrl ) ;
69
+ } catch ( err ) {
70
+ this . logError ( 'error during workspace dump' , safeError ( err ) ) ;
71
+ }
72
+ }
73
+
74
+ createLspOutputChannel ( ) : vscode . OutputChannel | undefined {
75
+ const remoteTracingUrl = this . ctx . extensionConfig . get < string > ( 'remoteTracingUrl' ) ;
76
+ if ( ! remoteTracingUrl ) return undefined ;
77
+
78
+ if ( this . tracingConsent . get ( ) === 'no-answer' ) {
79
+ vscode . window . showInformationMessage (
80
+ 'Do you want to help EPFL develop this plugin by uploading your usage data? ' +
81
+ 'PLEASE BE AWARE that this will upload all of your keystrokes and all of your code, ' +
82
+ 'among other things.' ,
83
+ 'yes' , 'no'
84
+ ) . then ( ( value : string | undefined ) => {
85
+ if ( value === 'yes' || value === 'no' ) this . tracingConsent . set ( value ) ;
86
+ } ) ;
87
+ }
88
+
89
+ const localLspOutputChannel = vscode . window . createOutputChannel ( 'Dotty LSP Communication' )
90
+ try {
91
+ return this . createRemoteLspOutputChannel ( remoteTracingUrl , localLspOutputChannel ) ;
92
+ } catch ( err ) {
93
+ this . logError ( 'error during remote output channel creation' , safeError ( err ) ) ;
94
+ return localLspOutputChannel ;
95
+ }
96
+ }
97
+
98
+ private asyncUploadWorkspaceDump ( url : string ) {
99
+ const storagePath = this . ctx . extensionContext . storagePath ;
100
+ const rootPath = vscode . workspace . rootPath ;
101
+ if ( storagePath === undefined || rootPath === undefined ) {
102
+ this . logError ( 'Cannot start workspace dump b/c of workspace state:' , { storagePath, rootPath } ) ;
103
+ return ;
104
+ }
105
+
106
+ if ( ! fs . existsSync ( storagePath ) ) fs . mkdirSync ( storagePath ) ;
107
+ const outputPath = path . join ( storagePath , 'workspace-dump.zip' ) ;
108
+ if ( fs . existsSync ( outputPath ) ) fs . unlinkSync ( outputPath ) ;
109
+ let output = fs . createWriteStream ( outputPath ) ;
110
+ output . on ( 'end' , ( ) => {
111
+ this . ctx . extensionOut . appendLine ( 'zip - data has been drained' ) ;
112
+ } ) ;
113
+
114
+ const zip = archiver ( 'zip' ) ;
115
+ zip . on ( 'error' , ( err ) => this . logError ( 'zip error' , safeError ( err ) ) ) ;
116
+ zip . on ( 'warning' , ( err ) => this . logError ( 'zip warning' , safeError ( err ) ) ) ;
117
+ zip . on ( 'entry' , ( entry ) => {
118
+ this . ctx . extensionOut . appendLine ( `zip - entry: ${ entry . name } ` ) ;
119
+ } ) ;
120
+ zip . on ( 'finish' , ( ) => {
121
+ this . ctx . extensionOut . appendLine ( 'zip - finished' ) ;
122
+ fs . createReadStream ( outputPath ) . pipe (
123
+ request . put ( url , {
124
+ qs : {
125
+ client : this . machineId ,
126
+ project : this . projectId ,
127
+ session : this . sessionId
128
+ }
129
+ } )
130
+ . on ( 'error' , ( err ) => this . logError ( 'zip upload connection error' , url , safeError ( err ) ) )
131
+ . on ( 'complete' , ( resp ) => {
132
+ if ( ! ( resp . statusCode >= 200 && resp . statusCode < 300 ) ) {
133
+ this . logError ( 'zip upload http error' , url , resp . statusCode , resp . body ) ;
134
+ } else {
135
+ this . ctx . extensionOut . appendLine ( 'zip - http upload finished' ) ;
136
+ }
137
+ } )
138
+ ) ;
139
+ } ) ;
140
+ zip . pipe ( output ) ;
141
+ zip . glob ( './**/*.{scala,sbt}' , { cwd : rootPath } ) ;
142
+ zip . finalize ( ) ;
143
+ }
144
+
145
+ private createRemoteLspOutputChannel (
146
+ remoteTracingUrl : string ,
147
+ localOutputChannel : vscode . OutputChannel
148
+ ) : vscode . OutputChannel {
149
+ const socketHeaders = {
150
+ 'X-DLS-Project-ID' : this . projectId ,
151
+ 'X-DLS-Client-ID' : this . machineId ,
152
+ 'X-DLS-Session-ID' : this . sessionId ,
153
+ } ;
154
+
155
+ const socket = new WebSocket ( remoteTracingUrl , { headers : socketHeaders } ) ;
156
+
157
+ const timer = setInterval (
158
+ ( ) => {
159
+ if ( socket . readyState === WebSocket . OPEN ) {
160
+ socket . send ( '' ) ;
161
+ } else if ( socket . readyState === WebSocket . CLOSED ) {
162
+ clearInterval ( timer ) ;
163
+ }
164
+ } ,
165
+ 10 * 1000 /*ms*/ ,
166
+ )
167
+
168
+ socket . onerror = ( event ) => {
169
+ this . logErrorWithoutNotifying (
170
+ 'socket error' ,
171
+ remoteTracingUrl ,
172
+ new SafeJsonifier ( event , ( event ) => ( {
173
+ error : safeError ( event . error ) ,
174
+ message : event . message ,
175
+ type : event . type
176
+ } ) )
177
+ ) ;
178
+ vscode . window . showWarningMessage ( 'An error occured in Dotty LSP remote tracing connection.' ) ;
179
+ }
180
+
181
+ socket . onclose = ( event ) => {
182
+ this . logErrorWithoutNotifying (
183
+ 'socket closed' ,
184
+ remoteTracingUrl ,
185
+ new SafeJsonifier ( event , ( event ) => ( {
186
+ wasClean : event . wasClean ,
187
+ code : event . code ,
188
+ reason : event . reason
189
+ } ) )
190
+ ) ;
191
+ vscode . window . showWarningMessage ( 'Dotty LSP remote tracing connection was dropped.' ) ;
192
+ }
193
+
194
+ let log : string = '' ;
195
+ return {
196
+ name : 'websocket' ,
197
+
198
+ append : ( value : string ) => {
199
+ localOutputChannel . append ( value ) ;
200
+ if ( this . tracingConsent . get ( ) === 'no' ) return ;
201
+ log += value ;
202
+ } ,
203
+
204
+ appendLine : ( value : string ) => {
205
+ localOutputChannel . appendLine ( value )
206
+ if ( this . tracingConsent . get ( ) === 'no' ) {
207
+ log = '' ;
208
+ return ;
209
+ }
210
+
211
+ log += value ;
212
+ log += '\n' ;
213
+ if ( this . tracingConsent . get ( ) === 'yes' && socket . readyState === WebSocket . OPEN ) {
214
+ socket . send ( log , ( err ) => {
215
+ if ( err ) {
216
+ this . logError ( 'socket send error' , err )
217
+ }
218
+ } ) ;
219
+ log = '' ;
220
+ }
221
+ } ,
222
+
223
+ clear ( ) { } ,
224
+ show ( ) { } ,
225
+ hide ( ) { } ,
226
+ dispose ( ) {
227
+ socket . close ( ) ;
228
+ localOutputChannel . dispose ( ) ;
229
+ }
230
+ } ;
231
+ }
232
+
233
+ private silenceErrors : boolean = false ;
234
+ private logErrorWithoutNotifying ( message : string , ...rest : any [ ] ) {
235
+ const msg = `[Dotty LSP Tracer] ${ message } ` ;
236
+ // unwrap SafeJsonifier, for some reason Electron logs the result
237
+ // of .toJSON, unlike browsers
238
+ console . error ( msg , ...rest . map ( ( a ) => a instanceof SafeJsonifier ? a . value : a ) ) ;
239
+ function cautiousStringify ( a : any ) : string {
240
+ try {
241
+ return JSON . stringify ( a , undefined , 4 ) ;
242
+ } catch ( err ) {
243
+ console . error ( 'cannot stringify' , err , a ) ;
244
+ return a . toString ( ) ;
245
+ }
246
+ }
247
+ this . ctx . extensionOut . appendLine ( [ msg ] . concat ( rest . map ( cautiousStringify ) ) . join ( ' ' ) ) ;
248
+ }
249
+ private logError ( message : string , ...rest : any [ ] ) {
250
+ this . logErrorWithoutNotifying ( message , ...rest ) ;
251
+ if ( ! this . silenceErrors ) {
252
+ vscode . window . showErrorMessage (
253
+ 'An error occured which prevents sending usage data to EPFL. ' +
254
+ 'Please copy the text from "Dotty Language Client" output (View > Output) and send it to your TA.' ,
255
+ 'Silence further errors'
256
+ ) . then ( ( result ) => {
257
+ if ( result !== undefined ) {
258
+ this . silenceErrors = true ;
259
+ }
260
+ } )
261
+ }
262
+ }
263
+ }
264
+
265
+ function safeError ( e : Error ) : SafeJsonifier < Error > {
266
+ return new SafeJsonifier ( e , ( e ) => e . toString ( ) ) ;
267
+ }
268
+
269
+ class SafeJsonifier < T > {
270
+ value : T
271
+ valueToObject : ( t : T ) => { }
272
+
273
+ constructor ( value : T , valueToObject : ( t : T ) => { } ) {
274
+ this . value = value ;
275
+ this . valueToObject = valueToObject ;
276
+ }
277
+
278
+ toJSON ( ) {
279
+ return this . valueToObject ( this . value ) ;
280
+ }
281
+ }
0 commit comments