UNPKG

21.3 kBPlain TextView Raw
1// Copyright (c) 2017 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import * as Common from '../../core/common/common.js';
6import * as Platform from '../../core/platform/platform.js';
7import * as SDK from '../../core/sdk/sdk.js';
8import * as Protocol from '../../generated/protocol.js';
9import * as Workspace from '../workspace/workspace.js';
10
11import type {FileSystem} from './FileSystemWorkspaceBinding.js';
12import {FileSystemWorkspaceBinding} from './FileSystemWorkspaceBinding.js';
13import {PersistenceBinding, PersistenceImpl} from './PersistenceImpl.js';
14
15let networkPersistenceManagerInstance: NetworkPersistenceManager|null;
16
17export class NetworkPersistenceManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements
18 SDK.TargetManager.Observer {
19 private bindings: WeakMap<Workspace.UISourceCode.UISourceCode, PersistenceBinding>;
20 private readonly originalResponseContentPromises: WeakMap<Workspace.UISourceCode.UISourceCode, Promise<string|null>>;
21 private savingForOverrides: WeakSet<Workspace.UISourceCode.UISourceCode>;
22 private readonly savingSymbol: symbol;
23 private enabledSetting: Common.Settings.Setting<boolean>;
24 private readonly workspace: Workspace.Workspace.WorkspaceImpl;
25 private readonly networkUISourceCodeForEncodedPath: Map<string, Workspace.UISourceCode.UISourceCode>;
26 private readonly interceptionHandlerBound:
27 (interceptedRequest: SDK.NetworkManager.InterceptedRequest) => Promise<void>;
28 private readonly updateInterceptionThrottler: Common.Throttler.Throttler;
29 private projectInternal: Workspace.Workspace.Project|null;
30 private readonly activeProject: Workspace.Workspace.Project|null;
31 private activeInternal: boolean;
32 private enabled: boolean;
33 private eventDescriptors: Common.EventTarget.EventDescriptor[];
34
35 private constructor(workspace: Workspace.Workspace.WorkspaceImpl) {
36 super();
37 this.bindings = new WeakMap();
38 this.originalResponseContentPromises = new WeakMap();
39 this.savingForOverrides = new WeakSet();
40 this.savingSymbol = Symbol('SavingForOverrides');
41
42 this.enabledSetting = Common.Settings.Settings.instance().moduleSetting('persistenceNetworkOverridesEnabled');
43 this.enabledSetting.addChangeListener(this.enabledChanged, this);
44
45 this.workspace = workspace;
46
47 this.networkUISourceCodeForEncodedPath = new Map();
48 this.interceptionHandlerBound = this.interceptionHandler.bind(this);
49 this.updateInterceptionThrottler = new Common.Throttler.Throttler(50);
50
51 this.projectInternal = null;
52 this.activeProject = null;
53
54 this.activeInternal = false;
55 this.enabled = false;
56
57 this.workspace.addEventListener(Workspace.Workspace.Events.ProjectAdded, event => {
58 this.onProjectAdded(event.data);
59 });
60 this.workspace.addEventListener(Workspace.Workspace.Events.ProjectRemoved, event => {
61 this.onProjectRemoved(event.data);
62 });
63
64 PersistenceImpl.instance().addNetworkInterceptor(this.canHandleNetworkUISourceCode.bind(this));
65
66 this.eventDescriptors = [];
67 this.enabledChanged();
68
69 SDK.TargetManager.TargetManager.instance().observeTargets(this);
70 }
71
72 targetAdded(): void {
73 this.updateActiveProject();
74 }
75 targetRemoved(): void {
76 this.updateActiveProject();
77 }
78
79 static instance(opts: {
80 forceNew: boolean|null,
81 workspace: Workspace.Workspace.WorkspaceImpl|null,
82 } = {forceNew: null, workspace: null}): NetworkPersistenceManager {
83 const {forceNew, workspace} = opts;
84 if (!networkPersistenceManagerInstance || forceNew) {
85 if (!workspace) {
86 throw new Error('Missing workspace for NetworkPersistenceManager');
87 }
88 networkPersistenceManagerInstance = new NetworkPersistenceManager(workspace);
89 }
90
91 return networkPersistenceManagerInstance;
92 }
93
94 active(): boolean {
95 return this.activeInternal;
96 }
97
98 project(): Workspace.Workspace.Project|null {
99 return this.projectInternal;
100 }
101
102 originalContentForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<string|null>|null {
103 const binding = this.bindings.get(uiSourceCode);
104 if (!binding) {
105 return null;
106 }
107 const fileSystemUISourceCode = binding.fileSystem;
108 return this.originalResponseContentPromises.get(fileSystemUISourceCode) || null;
109 }
110
111 private async enabledChanged(): Promise<void> {
112 if (this.enabled === this.enabledSetting.get()) {
113 return;
114 }
115 this.enabled = this.enabledSetting.get();
116 if (this.enabled) {
117 this.eventDescriptors = [
118 Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
119 Workspace.Workspace.Events.UISourceCodeRenamed,
120 event => {
121 this.uiSourceCodeRenamedListener(event);
122 }),
123 Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
124 Workspace.Workspace.Events.UISourceCodeAdded,
125 event => {
126 this.uiSourceCodeAdded(event);
127 }),
128 Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
129 Workspace.Workspace.Events.UISourceCodeRemoved,
130 event => {
131 this.uiSourceCodeRemovedListener(event);
132 }),
133 Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
134 Workspace.Workspace.Events.WorkingCopyCommitted,
135 event => this.onUISourceCodeWorkingCopyCommitted(event.data.uiSourceCode)),
136 ];
137 await this.updateActiveProject();
138 } else {
139 Common.EventTarget.removeEventListeners(this.eventDescriptors);
140 await this.updateActiveProject();
141 }
142 }
143
144 private async uiSourceCodeRenamedListener(
145 event: Common.EventTarget.EventTargetEvent<Workspace.Workspace.UISourceCodeRenamedEvent>): Promise<void> {
146 const uiSourceCode = event.data.uiSourceCode;
147 await this.onUISourceCodeRemoved(uiSourceCode);
148 await this.onUISourceCodeAdded(uiSourceCode);
149 }
150
151 private async uiSourceCodeRemovedListener(
152 event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): Promise<void> {
153 await this.onUISourceCodeRemoved(event.data);
154 }
155
156 private async uiSourceCodeAdded(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>):
157 Promise<void> {
158 await this.onUISourceCodeAdded(event.data);
159 }
160
161 private async updateActiveProject(): Promise<void> {
162 const wasActive = this.activeInternal;
163 this.activeInternal = Boolean(
164 this.enabledSetting.get() && SDK.TargetManager.TargetManager.instance().mainTarget() && this.projectInternal);
165 if (this.activeInternal === wasActive) {
166 return;
167 }
168
169 if (this.activeInternal && this.projectInternal) {
170 await Promise.all(
171 this.projectInternal.uiSourceCodes().map(uiSourceCode => this.filesystemUISourceCodeAdded(uiSourceCode)));
172
173 const networkProjects = this.workspace.projectsForType(Workspace.Workspace.projectTypes.Network);
174 for (const networkProject of networkProjects) {
175 await Promise.all(
176 networkProject.uiSourceCodes().map(uiSourceCode => this.networkUISourceCodeAdded(uiSourceCode)));
177 }
178 } else if (this.projectInternal) {
179 await Promise.all(
180 this.projectInternal.uiSourceCodes().map(uiSourceCode => this.filesystemUISourceCodeRemoved(uiSourceCode)));
181 this.networkUISourceCodeForEncodedPath.clear();
182 }
183 PersistenceImpl.instance().refreshAutomapping();
184 }
185
186 private encodedPathFromUrl(url: string): string {
187 if (!this.activeInternal || !this.projectInternal) {
188 return '';
189 }
190 let urlPath = Common.ParsedURL.ParsedURL.urlWithoutHash(url.replace(/^https?:\/\//, ''));
191 if (urlPath.endsWith('/') && urlPath.indexOf('?') === -1) {
192 urlPath = urlPath + 'index.html';
193 }
194 let encodedPathParts = encodeUrlPathToLocalPathParts(urlPath);
195 const projectPath = FileSystemWorkspaceBinding.fileSystemPath(this.projectInternal.id());
196 const encodedPath = encodedPathParts.join('/');
197 if (projectPath.length + encodedPath.length > 200) {
198 const domain = encodedPathParts[0];
199 const encodedFileName = encodedPathParts[encodedPathParts.length - 1];
200 const shortFileName = encodedFileName ? encodedFileName.substr(0, 10) + '-' : '';
201 const extension = Common.ParsedURL.ParsedURL.extractExtension(urlPath);
202 const extensionPart = extension ? '.' + extension.substr(0, 10) : '';
203 encodedPathParts = [
204 domain,
205 'longurls',
206 shortFileName + Platform.StringUtilities.hashCode(encodedPath).toString(16) + extensionPart,
207 ];
208 }
209 return encodedPathParts.join('/');
210
211 function encodeUrlPathToLocalPathParts(urlPath: string): string[] {
212 const encodedParts = [];
213 for (const pathPart of fileNamePartsFromUrlPath(urlPath)) {
214 if (!pathPart) {
215 continue;
216 }
217 // encodeURI() escapes all the unsafe filename characters except /:?*
218 let encodedName = encodeURI(pathPart).replace(/[\/:\?\*]/g, match => '%' + match[0].charCodeAt(0).toString(16));
219 // Windows does not allow a small set of filenames.
220 if (RESERVED_FILENAMES.has(encodedName.toLowerCase())) {
221 encodedName = encodedName.split('').map(char => '%' + char.charCodeAt(0).toString(16)).join('');
222 }
223 // Windows does not allow the file to end in a space or dot (space should already be encoded).
224 const lastChar = encodedName.charAt(encodedName.length - 1);
225 if (lastChar === '.') {
226 encodedName = encodedName.substr(0, encodedName.length - 1) + '%2e';
227 }
228 encodedParts.push(encodedName);
229 }
230 return encodedParts;
231 }
232
233 function fileNamePartsFromUrlPath(urlPath: string): string[] {
234 urlPath = Common.ParsedURL.ParsedURL.urlWithoutHash(urlPath);
235 const queryIndex = urlPath.indexOf('?');
236 if (queryIndex === -1) {
237 return urlPath.split('/');
238 }
239 if (queryIndex === 0) {
240 return [urlPath];
241 }
242 const endSection = urlPath.substr(queryIndex);
243 const parts = urlPath.substr(0, urlPath.length - endSection.length).split('/');
244 parts[parts.length - 1] += endSection;
245 return parts;
246 }
247 }
248
249 private decodeLocalPathToUrlPath(path: string): string {
250 try {
251 return unescape(path);
252 } catch (e) {
253 console.error(e);
254 }
255 return path;
256 }
257
258 private async unbind(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
259 const binding = this.bindings.get(uiSourceCode);
260 if (binding) {
261 this.bindings.delete(binding.network);
262 this.bindings.delete(binding.fileSystem);
263 await PersistenceImpl.instance().removeBinding(binding);
264 }
265 }
266
267 private async bind(
268 networkUISourceCode: Workspace.UISourceCode.UISourceCode,
269 fileSystemUISourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
270 if (this.bindings.has(networkUISourceCode)) {
271 await this.unbind(networkUISourceCode);
272 }
273 if (this.bindings.has(fileSystemUISourceCode)) {
274 await this.unbind(fileSystemUISourceCode);
275 }
276 const binding = new PersistenceBinding(networkUISourceCode, fileSystemUISourceCode);
277 this.bindings.set(networkUISourceCode, binding);
278 this.bindings.set(fileSystemUISourceCode, binding);
279 await PersistenceImpl.instance().addBinding(binding);
280 const uiSourceCodeOfTruth =
281 this.savingForOverrides.has(networkUISourceCode) ? networkUISourceCode : fileSystemUISourceCode;
282 const [{content}, encoded] =
283 await Promise.all([uiSourceCodeOfTruth.requestContent(), uiSourceCodeOfTruth.contentEncoded()]);
284 PersistenceImpl.instance().syncContent(uiSourceCodeOfTruth, content || '', encoded);
285 }
286
287 private onUISourceCodeWorkingCopyCommitted(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
288 this.saveUISourceCodeForOverrides(uiSourceCode);
289 }
290
291 canSaveUISourceCodeForOverrides(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
292 return this.activeInternal && uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network &&
293 !this.bindings.has(uiSourceCode) && !this.savingForOverrides.has(uiSourceCode);
294 }
295
296 async saveUISourceCodeForOverrides(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
297 if (!this.canSaveUISourceCodeForOverrides(uiSourceCode)) {
298 return;
299 }
300 this.savingForOverrides.add(uiSourceCode);
301 let encodedPath = this.encodedPathFromUrl(uiSourceCode.url());
302 const content = (await uiSourceCode.requestContent()).content || '';
303 const encoded = await uiSourceCode.contentEncoded();
304 const lastIndexOfSlash = encodedPath.lastIndexOf('/');
305 const encodedFileName = encodedPath.substr(lastIndexOfSlash + 1);
306 encodedPath = encodedPath.substr(0, lastIndexOfSlash);
307 if (this.projectInternal) {
308 await this.projectInternal.createFile(encodedPath, encodedFileName, content, encoded);
309 }
310 this.fileCreatedForTest(encodedPath, encodedFileName);
311 this.savingForOverrides.delete(uiSourceCode);
312 }
313
314 private fileCreatedForTest(_path: string, _fileName: string): void {
315 }
316
317 private patternForFileSystemUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): string {
318 const relativePathParts = FileSystemWorkspaceBinding.relativePath(uiSourceCode);
319 if (relativePathParts.length < 2) {
320 return '';
321 }
322 if (relativePathParts[1] === 'longurls' && relativePathParts.length !== 2) {
323 return 'http?://' + relativePathParts[0] + '/*';
324 }
325 return 'http?://' + this.decodeLocalPathToUrlPath(relativePathParts.join('/'));
326 }
327
328 private async onUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
329 await this.networkUISourceCodeAdded(uiSourceCode);
330 await this.filesystemUISourceCodeAdded(uiSourceCode);
331 }
332
333 private canHandleNetworkUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
334 return this.activeInternal && !uiSourceCode.url().startsWith('snippet://');
335 }
336
337 private async networkUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
338 if (uiSourceCode.project().type() !== Workspace.Workspace.projectTypes.Network ||
339 !this.canHandleNetworkUISourceCode(uiSourceCode)) {
340 return;
341 }
342 const url = Common.ParsedURL.ParsedURL.urlWithoutHash(uiSourceCode.url());
343 this.networkUISourceCodeForEncodedPath.set(this.encodedPathFromUrl(url), uiSourceCode);
344
345 const project = this.projectInternal as FileSystem;
346 const fileSystemUISourceCode =
347 project.uiSourceCodeForURL(project.fileSystemPath() + '/' + this.encodedPathFromUrl(url));
348 if (fileSystemUISourceCode) {
349 await this.bind(uiSourceCode, fileSystemUISourceCode);
350 }
351 }
352
353 private async filesystemUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
354 if (!this.activeInternal || uiSourceCode.project() !== this.projectInternal) {
355 return;
356 }
357 this.updateInterceptionPatterns();
358
359 const relativePath = FileSystemWorkspaceBinding.relativePath(uiSourceCode);
360 const networkUISourceCode = this.networkUISourceCodeForEncodedPath.get(relativePath.join('/'));
361 if (networkUISourceCode) {
362 await this.bind(networkUISourceCode, uiSourceCode);
363 }
364 }
365
366 private updateInterceptionPatterns(): void {
367 this.updateInterceptionThrottler.schedule(innerUpdateInterceptionPatterns.bind(this));
368
369 function innerUpdateInterceptionPatterns(this: NetworkPersistenceManager): Promise<void> {
370 if (!this.activeInternal || !this.projectInternal) {
371 return SDK.NetworkManager.MultitargetNetworkManager.instance().setInterceptionHandlerForPatterns(
372 [], this.interceptionHandlerBound);
373 }
374 const patterns = new Set<string>();
375 const indexFileName = 'index.html';
376 for (const uiSourceCode of this.projectInternal.uiSourceCodes()) {
377 const pattern = this.patternForFileSystemUISourceCode(uiSourceCode);
378 patterns.add(pattern);
379 if (pattern.endsWith('/' + indexFileName)) {
380 patterns.add(pattern.substr(0, pattern.length - indexFileName.length));
381 }
382 }
383
384 return SDK.NetworkManager.MultitargetNetworkManager.instance().setInterceptionHandlerForPatterns(
385 Array.from(patterns).map(
386 pattern =>
387 ({urlPattern: pattern, interceptionStage: Protocol.Network.InterceptionStage.HeadersReceived})),
388 this.interceptionHandlerBound);
389 }
390 }
391
392 private async onUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
393 await this.networkUISourceCodeRemoved(uiSourceCode);
394 await this.filesystemUISourceCodeRemoved(uiSourceCode);
395 }
396
397 private async networkUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
398 if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network) {
399 await this.unbind(uiSourceCode);
400 this.networkUISourceCodeForEncodedPath.delete(this.encodedPathFromUrl(uiSourceCode.url()));
401 }
402 }
403
404 private async filesystemUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
405 if (uiSourceCode.project() !== this.projectInternal) {
406 return;
407 }
408 this.updateInterceptionPatterns();
409 this.originalResponseContentPromises.delete(uiSourceCode);
410 await this.unbind(uiSourceCode);
411 }
412
413 private async setProject(project: Workspace.Workspace.Project|null): Promise<void> {
414 if (project === this.projectInternal) {
415 return;
416 }
417
418 if (this.projectInternal) {
419 await Promise.all(
420 this.projectInternal.uiSourceCodes().map(uiSourceCode => this.filesystemUISourceCodeRemoved(uiSourceCode)));
421 }
422
423 this.projectInternal = project;
424
425 if (this.projectInternal) {
426 await Promise.all(
427 this.projectInternal.uiSourceCodes().map(uiSourceCode => this.filesystemUISourceCodeAdded(uiSourceCode)));
428 }
429
430 await this.updateActiveProject();
431 this.dispatchEventToListeners(Events.ProjectChanged, this.projectInternal);
432 }
433
434 private async onProjectAdded(project: Workspace.Workspace.Project): Promise<void> {
435 if (project.type() !== Workspace.Workspace.projectTypes.FileSystem ||
436 FileSystemWorkspaceBinding.fileSystemType(project) !== 'overrides') {
437 return;
438 }
439 const fileSystemPath = FileSystemWorkspaceBinding.fileSystemPath(project.id());
440 if (!fileSystemPath) {
441 return;
442 }
443 if (this.projectInternal) {
444 this.projectInternal.remove();
445 }
446
447 await this.setProject(project);
448 }
449
450 private async onProjectRemoved(project: Workspace.Workspace.Project): Promise<void> {
451 if (project === this.projectInternal) {
452 await this.setProject(null);
453 }
454 }
455
456 private async interceptionHandler(interceptedRequest: SDK.NetworkManager.InterceptedRequest): Promise<void> {
457 const method = interceptedRequest.request.method;
458 if (!this.activeInternal || (method !== 'GET' && method !== 'POST')) {
459 return;
460 }
461 const proj = this.projectInternal as FileSystem;
462 const path = proj.fileSystemPath() + '/' + this.encodedPathFromUrl(interceptedRequest.request.url);
463 const fileSystemUISourceCode = proj.uiSourceCodeForURL(path);
464 if (!fileSystemUISourceCode) {
465 return;
466 }
467
468 let mimeType = '';
469 if (interceptedRequest.responseHeaders) {
470 const responseHeaders = SDK.NetworkManager.NetworkManager.lowercaseHeaders(interceptedRequest.responseHeaders);
471 mimeType = responseHeaders['content-type'];
472 }
473
474 if (!mimeType) {
475 const expectedResourceType =
476 Common.ResourceType.resourceTypes[interceptedRequest.resourceType] || Common.ResourceType.resourceTypes.Other;
477 mimeType = fileSystemUISourceCode.mimeType();
478 if (Common.ResourceType.ResourceType.fromMimeType(mimeType) !== expectedResourceType) {
479 mimeType = expectedResourceType.canonicalMimeType();
480 }
481 }
482 const project = fileSystemUISourceCode.project() as FileSystem;
483
484 this.originalResponseContentPromises.set(
485 fileSystemUISourceCode, interceptedRequest.responseBody().then(response => {
486 if (response.error || response.content === null) {
487 return null;
488 }
489 if (response.encoded) {
490 const text = atob(response.content);
491 const data = new Uint8Array(text.length);
492 for (let i = 0; i < text.length; ++i) {
493 data[i] = text.charCodeAt(i);
494 }
495 return new TextDecoder('utf-8').decode(data);
496 }
497 return response.content;
498 }));
499
500 const blob = await project.requestFileBlob(fileSystemUISourceCode);
501 if (blob) {
502 interceptedRequest.continueRequestWithContent(new Blob([blob], {type: mimeType}));
503 }
504 }
505}
506
507const RESERVED_FILENAMES = new Set<string>([
508 'con', 'prn', 'aux', 'nul', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7',
509 'com8', 'com9', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
510]);
511
512// TODO(crbug.com/1167717): Make this a const enum again
513// eslint-disable-next-line rulesdir/const_enum
514export enum Events {
515 ProjectChanged = 'ProjectChanged',
516}
517
518export type EventTypes = {
519 [Events.ProjectChanged]: Workspace.Workspace.Project|null,
520};