@@ -7,8 +7,11 @@ import * as archiver from 'archiver';
77import * as WebSocket from 'ws' ;
88import * as request from 'request' ;
99
10+ import { extensionName } from './extension' ;
1011import { TracingConsentCache } from './tracing-consent' ;
1112
13+ const consentCommandName = `${ extensionName } .adjust-consent` ;
14+
1215export interface Ctx {
1316 readonly extensionContext : vscode . ExtensionContext ,
1417 readonly extensionConfig : vscode . WorkspaceConfiguration ,
@@ -24,13 +27,20 @@ export class Tracer {
2427
2528 private tracingConsent : TracingConsentCache
2629
27- private remoteTracingUrl ?: string
30+ private readonly remoteTracingUrl : string | undefined
31+ private readonly remoteWorkspaceDumpUrl : string | undefined
32+ private get isTracingEnabled ( ) : boolean {
33+ return Boolean ( this . remoteWorkspaceDumpUrl || this . remoteTracingUrl ) ;
34+ }
2835
2936 constructor ( ctx : Ctx ) {
3037 this . ctx = ctx ;
3138
3239 this . tracingConsent = new TracingConsentCache ( ctx . extensionContext . workspaceState ) ;
3340
41+ this . remoteWorkspaceDumpUrl = this . ctx . extensionConfig . get < string > ( 'remoteWorkspaceDumpUrl' ) ;
42+ this . remoteTracingUrl = this . ctx . extensionConfig . get < string > ( 'remoteTracingUrl' ) ;
43+
3444 this . machineId = ( ( ) => {
3545 const machineIdKey = 'tracing.machineId' ;
3646 function persisted ( value : string ) : string {
@@ -60,35 +70,82 @@ export class Tracer {
6070 this . sessionId = new Date ( ) . toISOString ( ) ;
6171 }
6272
63- initializeAsyncWorkspaceDump ( ) {
64- const remoteWorkspaceDumpUrl = this . ctx . extensionConfig . get < string > ( 'remoteWorkspaceDumpUrl' ) ;
65- if ( remoteWorkspaceDumpUrl === undefined ) return ;
73+ run ( ) : { lspOutputChannel ?: vscode . OutputChannel } {
74+ const consentCommandDisposable = vscode . commands . registerCommand ( consentCommandName , ( ) => this . askForTracingConsent ( ) ) ;
75+ if ( this . isTracingEnabled && this . tracingConsent . get ( ) === 'no-answer' ) this . askForTracingConsent ( ) ;
76+ this . initializeAsyncWorkspaceDump ( ) ;
77+ const lspOutputChannel = this . createLspOutputChannel ( ) ;
78+ const statusBarItem = this . createStatusBarItem ( ) ;
79+ for ( const disposable of [ consentCommandDisposable , lspOutputChannel , statusBarItem ] ) {
80+ if ( disposable ) this . ctx . extensionContext . subscriptions . push ( disposable ) ;
81+ }
82+ return { lspOutputChannel } ;
83+ }
84+
85+ private askForTracingConsent ( ) : void {
86+ vscode . window . showInformationMessage (
87+ 'Do you want to help EPFL develop Dotty LSP plugin by uploading your LSP communication? ' +
88+ 'PLEASE BE AWARE that the data sent contains your entire codebase and ALL the IDE actions, ' +
89+ 'including every single keystroke.' ,
90+ 'yes' , 'no'
91+ ) . then ( ( value : string | undefined ) => {
92+ if ( value === 'yes' || value === 'no' ) this . tracingConsent . set ( value ) ;
93+ } ) ;
94+ }
6695
67- try {
68- this . asyncUploadWorkspaceDump ( remoteWorkspaceDumpUrl ) ;
69- } catch ( err ) {
70- this . logError ( 'error during workspace dump' , safeError ( err ) ) ;
96+ private initializeAsyncWorkspaceDump ( ) {
97+ if ( this . remoteWorkspaceDumpUrl === undefined ) return ;
98+ // convince TS that this is a string
99+ const definedUrl : string = this . remoteWorkspaceDumpUrl ;
100+
101+ const doInitialize = ( ) => {
102+ try {
103+ this . asyncUploadWorkspaceDump ( definedUrl ) ;
104+ } catch ( err ) {
105+ this . logError ( 'error during workspace dump' , safeError ( err ) ) ;
106+ }
107+ } ;
108+
109+ if ( this . tracingConsent . get ( ) === 'yes' ) {
110+ doInitialize ( )
111+ } else {
112+ let didInitialize = false ;
113+ this . tracingConsent . subscribe ( ( ) => {
114+ if ( didInitialize ) return ;
115+ didInitialize = true ;
116+ doInitialize ( ) ;
117+ } )
71118 }
72119 }
73120
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- } ) ;
121+ private createStatusBarItem ( ) : vscode . StatusBarItem | undefined {
122+ if ( ! this . isTracingEnabled ) return undefined ;
123+ const item = vscode . window . createStatusBarItem ( vscode . StatusBarAlignment . Left , 0 )
124+ item . command = consentCommandName ;
125+ const renderStatusBarItem = ( ) => {
126+ item . text = ( ( ) => {
127+ const desc = this . tracingConsent . get ( ) === 'yes' ? 'ON' : 'OFF' ;
128+ return `$(radio-tower) Dotty trace: ${ desc } ` ;
129+ } ) ( ) ;
130+
131+ item . tooltip = ( ( ) => {
132+ const desc = this . tracingConsent . get ( ) === 'yes' ? 'consented' : 'not consented' ;
133+ return `This workspace is configured for remote tracing of Dotty LSP and you have ${ desc } to it. ` +
134+ 'Click to adjust your consent.' ;
135+ } ) ( ) ;
87136 }
137+ renderStatusBarItem ( ) ;
138+ this . tracingConsent . subscribe ( renderStatusBarItem ) ;
139+ item . show ( ) ;
140+ return item ;
141+ }
142+
143+ private createLspOutputChannel ( ) : vscode . OutputChannel | undefined {
144+ if ( ! this . remoteTracingUrl ) return undefined ;
88145
89146 const localLspOutputChannel = vscode . window . createOutputChannel ( 'Dotty LSP Communication' )
90147 try {
91- return this . createRemoteLspOutputChannel ( remoteTracingUrl , localLspOutputChannel ) ;
148+ return this . createRemoteLspOutputChannel ( this . remoteTracingUrl , localLspOutputChannel ) ;
92149 } catch ( err ) {
93150 this . logError ( 'error during remote output channel creation' , safeError ( err ) ) ;
94151 return localLspOutputChannel ;
@@ -97,6 +154,7 @@ export class Tracer {
97154
98155 private asyncUploadWorkspaceDump ( url : string ) {
99156 const storagePath = this . ctx . extensionContext . storagePath ;
157+ // TODO: handle multi-root workspaces
100158 const rootPath = vscode . workspace . rootPath ;
101159 if ( storagePath === undefined || rootPath === undefined ) {
102160 this . logError ( 'Cannot start workspace dump b/c of workspace state:' , { storagePath, rootPath } ) ;
@@ -106,7 +164,7 @@ export class Tracer {
106164 if ( ! fs . existsSync ( storagePath ) ) fs . mkdirSync ( storagePath ) ;
107165 const outputPath = path . join ( storagePath , 'workspace-dump.zip' ) ;
108166 if ( fs . existsSync ( outputPath ) ) fs . unlinkSync ( outputPath ) ;
109- let output = fs . createWriteStream ( outputPath ) ;
167+ const output = fs . createWriteStream ( outputPath ) ;
110168 output . on ( 'end' , ( ) => {
111169 this . ctx . extensionOut . appendLine ( 'zip - data has been drained' ) ;
112170 } ) ;
@@ -138,57 +196,81 @@ export class Tracer {
138196 ) ;
139197 } ) ;
140198 zip . pipe ( output ) ;
141- zip . glob ( './**/*.{scala,sbt}' , { cwd : rootPath } ) ;
199+ zip . glob ( './**/*.{scala,sc,sbt,java}' , { cwd : rootPath } ) ;
200+ zip . glob ( './**/.dotty-ide{.json,-artifact}' , { cwd : rootPath } ) ;
142201 zip . finalize ( ) ;
143202 }
144203
145204 private createRemoteLspOutputChannel (
146205 remoteTracingUrl : string ,
147206 localOutputChannel : vscode . OutputChannel
148207 ) : 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- } ;
208+ const createSocket = ( ) => {
209+ const socket = new WebSocket ( remoteTracingUrl , {
210+ headers : {
211+ 'X-DLS-Project-ID' : this . projectId ,
212+ 'X-DLS-Client-ID' : this . machineId ,
213+ 'X-DLS-Session-ID' : this . sessionId ,
214+ } ,
215+ } ) ;
216+
217+ const timer = setInterval (
218+ ( ) => {
219+ if ( socket . readyState === WebSocket . OPEN ) {
220+ socket . send ( '' ) ;
221+ } else if ( socket . readyState === WebSocket . CLOSED ) {
222+ clearInterval ( timer ) ;
223+ }
224+ } ,
225+ 10 * 1000 /*ms*/ ,
226+ )
227+
228+ socket . onerror = ( event ) => {
229+ this . logErrorWithoutNotifying (
230+ 'socket error' ,
231+ remoteTracingUrl ,
232+ new SafeJsonifier ( event , ( event ) => ( {
233+ error : safeError ( event . error ) ,
234+ message : event . message ,
235+ type : event . type
236+ } ) )
237+ ) ;
238+ vscode . window . showWarningMessage ( 'An error occured in Dotty LSP remote tracing connection.' ) ;
239+ }
154240
155- const socket = new WebSocket ( remoteTracingUrl , { headers : socketHeaders } ) ;
241+ socket . onclose = ( event ) => {
242+ this . logErrorWithoutNotifying (
243+ 'socket closed' ,
244+ remoteTracingUrl ,
245+ new SafeJsonifier ( event , ( event ) => ( {
246+ wasClean : event . wasClean ,
247+ code : event . code ,
248+ reason : event . reason
249+ } ) )
250+ ) ;
251+ vscode . window . showWarningMessage ( 'Dotty LSP remote tracing connection was dropped.' ) ;
252+ }
156253
157- const timer = setInterval (
158- ( ) => {
159- if ( socket . readyState === WebSocket . OPEN ) {
160- socket . send ( '' ) ;
161- } else if ( socket . readyState === WebSocket . CLOSED ) {
162- clearInterval ( timer ) ;
254+ return socket ;
255+ } ;
256+
257+ let alreadyCreated = false ;
258+ let socket : WebSocket ;
259+ // note: creating socket lazily is important for correctness
260+ // if the user did not initially give his consent on IDE start, but gives it afterwards
261+ // we only want to start a connection and upload data *after* being given consent
262+ const withSocket : ( thunk : ( socket : WebSocket ) => any ) => void = ( thunk ) => {
263+ // only try to create the socket _once_ to avoid endlessly looping
264+ if ( ! alreadyCreated ) {
265+ alreadyCreated = true ;
266+ try {
267+ socket = createSocket ( ) ;
268+ } catch ( err ) {
269+ this . logError ( 'socket create error' , safeError ( err ) ) ;
163270 }
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- }
271+ }
180272
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.' ) ;
273+ if ( socket ) thunk ( socket ) ;
192274 }
193275
194276 let log : string = '' ;
@@ -210,21 +292,23 @@ export class Tracer {
210292
211293 log += value ;
212294 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- }
295+ if ( this . tracingConsent . get ( ) === 'yes' ) withSocket ( ( socket ) => {
296+ if ( socket . readyState === WebSocket . OPEN ) {
297+ socket . send ( log , ( err ) => {
298+ if ( err ) {
299+ this . logError ( 'socket send error' , err )
300+ }
301+ } ) ;
302+ log = '' ;
303+ }
304+ } ) ;
221305 } ,
222306
223307 clear ( ) { } ,
224308 show ( ) { } ,
225309 hide ( ) { } ,
226310 dispose ( ) {
227- socket . close ( ) ;
311+ if ( socket ) socket . close ( ) ;
228312 localOutputChannel . dispose ( ) ;
229313 }
230314 } ;
0 commit comments