|
| 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(/[xy]/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