1// @ts-check 2 3import { config } from "./config.js"; 4import { DB } from "./db.js"; 5import { Router } from "./router.js"; 6import { AppServiceWorker } from "./serviceworker.js"; 7import { Settings } from "./settings.js"; 8import { Shortcuts } from "./shortcuts.js"; 9import { UpdateChecker } from "./update_checker.js"; 10import { Docs } from "../collections/docs.js"; 11import { Entries } from "../collections/entries.js"; 12import { SettingsStore } from "../lib/settings_store.js"; 13import { Events } from "../lib/events.js"; 14import { LocalStorageStore } from "../lib/local_storage_store.js"; 15import { $ } from "../lib/util.js"; 16import { Doc } from "../models/doc.js"; 17import { unsupportedBrowser } from "../templates/error_tmpl.js"; 18import { AppDocument } from "../views/layout/document.js"; 19import { Mobile } from "../views/layout/mobile.js"; 20import { News } from "../views/misc/news.js"; 21import { Notice } from "../views/misc/notice.js"; 22import { Notif } from "../views/misc/notif.js"; 23import { Tip } from "../views/misc/tip.js"; 24import { Updates } from "../views/misc/updates.js"; 25 26/** 27 * A doc as it appears in the manifest, before it becomes a `Doc`. 28 * 29 * @typedef {Record<string, unknown>} DocData 30 */ 31 32/** 33 * The application singleton: the live state the rest of the app reads off, and 34 * the boot sequence that builds it. Classes are imported where they are used 35 * rather than registered here. 36 */ 37export class App extends Events { 38 /** 39 * The manifest of every available doc, set by docs.js.erb. Deleted once the 40 * docs have been read into the collections. 41 * 42 * @type {DocData[] | undefined} 43 */ 44 DOCS; 45 46 /** 47 * In single-doc mode, the one doc being shown, read off the body. Deleted 48 * once it has been read. 49 * 50 * @type {DocData | undefined} 51 */ 52 DOC; 53 54 /** 55 * The `window.onerror` handler that was installed before ours, if any. 56 * 57 * @type {unknown} 58 */ 59 previousErrorHandler; 60 61 /** 62 * The stores and mode of the most recent IndexedDB transaction, tracked by 63 * app/db.js so that a hung transaction can be reported. 64 * 65 * @type {[string | string[], IDBTransactionMode] | undefined} 66 */ 67 lastIDBTransaction; 68 69 /** Wires up the app and boots it. Called once the document is ready. */ 70 init() { 71 try { 72 this.initErrorTracking(); 73 } catch (error) {} 74 if (!this.browserCheck()) { 75 return; 76 } 77 78 this.el = $("._app"); 79 this.localStorage = new LocalStorageStore(); 80 if (AppServiceWorker.isEnabled()) { 81 this.serviceWorker = new AppServiceWorker(); 82 } 83 this.settings = new Settings(); 84 this.db = new DB(); 85 86 this.settings.initLayout(); 87 88 this.docs = new Docs(); 89 this.disabledDocs = new Docs(); 90 this.entries = new Entries(); 91 92 this.router = new Router(); 93 this.shortcuts = new Shortcuts(); 94 this.document = new AppDocument(); 95 if (this.isMobile()) { 96 this.mobile = new Mobile(); 97 } 98 99 if (document.body.hasAttribute("data-doc")) { 100 this.DOC = JSON.parse(document.body.getAttribute("data-doc")); 101 this.bootOne(); 102 } else if (this.DOCS) { 103 this.bootAll(); 104 } else { 105 this.onBootError(); 106 } 107 } 108 109 /** 110 * @returns {boolean} Whether to carry on booting. Replaces the page with a 111 * warning when the browser is too old. 112 */ 113 browserCheck() { 114 if (this.isSupportedBrowser()) { 115 return true; 116 } 117 document.body.innerHTML = /** @type {string} */ ( 118 unsupportedBrowser 119 ); 120 this.hideLoadingScreen(); 121 return false; 122 } 123 124 /** Wires up Sentry and the global error handlers. */ 125 initErrorTracking() { 126 // Show a warning message and don't track errors when the app is loaded 127 // from a domain other than our own, because things are likely to break. 128 // (e.g. cross-domain requests) 129 if (this.isInvalidLocation()) { 130 new Notif("InvalidLocation"); 131 } else { 132 if (config.sentry_dsn) { 133 Raven.config(config.sentry_dsn, { 134 release: config.release, 135 whitelistUrls: [/devdocs/], 136 includePaths: [/devdocs/], 137 ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/], 138 tags: { 139 mode: this.isSingleDoc() ? "single" : "full", 140 iframe: (window.top !== window).toString(), 141 electron: (!!window.process?.versions?.electron).toString(), 142 },
143 shouldSendCallback: () => { 144 try { 145 if (this.isAndroidWebview()) { 146 return false; 147 } 148 } catch (error) {} 149 return true; 150 }, 151 dataCallback(data) { 152 try { 153 data.user ||= {}; 154 Object.assign(data.user, app.settings.dump()); 155 if (data.user.docs) { 156 data.user.docs = data.user.docs.split("/"); 157 } 158 if (app.lastIDBTransaction) { 159 data.user.lastIDBTransaction = app.lastIDBTransaction; 160 } 161 data.tags.scriptCount = document.scripts.length; 162 } catch (error) {} 163 return data; 164 }, 165 }).install(); 166 } 167 this.previousErrorHandler = onerror; 168 window.onerror = this.onWindowError.bind(this); 169 SettingsStore.onBlocked = this.onStorageBlocked; 170 } 171 } 172 173 /** Boots in single-doc mode, with only the doc named on the body. */ 174 bootOne() { 175 this.doc = new Doc(this.DOC); 176 this.docs.reset([this.doc]); 177 this.doc.load(this.start.bind(this), this.onBootError.bind(this), { 178 readCache: true, 179 }); 180 new Notice("singleDoc", this.doc); 181 delete this.DOC; 182 } 183 184 /** Boots with every doc in the manifest, enabled or not. */ 185 async bootAll() { 186 const docs = this.settings.getDocs(); 187 for (var doc of this.DOCS) { 188 (docs.includes(/** @type {string} */ (doc.slug)) ? this.docs : this.disabledDocs).add(doc); 189 } 190 delete this.DOCS; 191 this.migrateDocs(); 192 await this.migrateToLatestVersions(); 193 this.docs.load(this.start.bind(this), this.onBootError.bind(this), { 194 readCache: true, 195 writeCache: true, 196 }); 197 } 198 199 /** Builds the search index from the loaded docs and starts routing. */ 200 start() { 201 let doc; 202 for (doc of this.docs.all()) { 203 this.entries.add(doc.toEntry()); 204 } 205 for (doc of this.disabledDocs.all()) { 206 this.entries.add(doc.toEntry()); 207 } 208 for (doc of this.docs.all()) { 209 this.initDoc(doc); 210 } 211 this.trigger("ready"); 212 this.router.start(); 213 this.hideLoadingScreen(); 214 setTimeout(() => { 215 if (!this.doc) { 216 this.welcomeBack(); 217 } 218 return this.removeEvent("ready bootError"); 219 }, 50); 220 } 221 222 /** 223 * Adds a doc's types and entries to the search index. 224 * 225 * @param {Doc} doc 226 */ 227 initDoc(doc) { 228 for (var type of doc.types.all()) { 229 doc.entries.add(type.toEntry()); 230 } 231 this.entries.add(doc.entries.all()); 232 } 233 234 /** Re-points enabled slugs that have since been renamed or reorganized. */ 235 migrateDocs() { 236 let needsSaving; 237 for (var slug of this.settings.getDocs()) { 238 if (!this.docs.findBy("slug", slug)) { 239 var doc; 240 241 needsSaving = true; 242 if (slug === "webpack~2") { 243 doc = this.disabledDocs.findBy("slug", "webpack"); 244 } 245 if (slug === "angular~4_typescript") { 246 doc = this.disabledDocs.findBy("slug", "angular"); 247 } 248 if (slug === "angular~2_typescript") { 249 doc = this.disabledDocs.findBy("slug", "angular~2"); 250 } 251 if (!doc) { 252 doc = this.disabledDocs.findBy("slug_without_version", slug); 253 } 254 if (doc) { 255 this.disabledDocs.remove(doc); 256 this.docs.add(doc); 257 } 258 } 259 } 260 261 if (needsSaving) { 262 this.saveDocs(); 263 } 264 } 265 266 /** 267 * With the "latest version" preference enabled, replace the enabled docs for 268 * which a newer version is available with that version. 269 */ 270 async migrateToLatestVersions() { 271 if (!this.settings.get("autoLatestVersion")) { 272 return; 273 } 274 275 const allDocs = this.docs.all().concat(this.disabledDocs.all()); 276 // The same version can supersede several enabled docs, so it's only loaded 277 // once, e.g. when both CMake 3.9 and CMake 3.10 are enabled. 278 const migrations = new Map(); 279 280 for (const outdated of this.docs.all()) { 281 const latest = outdated.findLatestVersion(allDocs); 282 if (latest === outdated) { 283 continue; 284 } 285 if (!migrations.has(latest)) { 286 migrations.set(latest, []); 287 } 288 migrations.get(latest).push(outdated); 289 } 290 291 const loaded = await this.loadLatestVersions([...migrations.keys()]); 292 let needsSaving; 293 294 for (const [latest, outdatedDocs] of migrations) { 295 if (!loaded.has(latest)) { 296 continue; 297 } 298 for (const outdated of outdatedDocs) { 299 this.docs.remove(outdated); 300 this.disabledDocs.add(outdated); 301 } 302 if (!this.docs.contains(latest)) { 303 this.disabledDocs.remove(latest); 304 this.docs.add(latest); 305 } 306 needsSaving = true; 307 } 308 309 if (needsSaving) { 310 this.docs.sort(); 311 this.saveDocs(); 312 } 313 } 314 315 /** 316 * Saving drops the offline data of the docs that are disabled, so the index 317 * of their latest version has to load before they are replaced. Loads no 318 * more docs at once than Docs#load does. 319 * 320 * @param {Doc[]} docs 321 * @returns {Promise<Set<unknown>>} The docs whose index loaded. 322 */ 323 async loadLatestVersions(docs) { 324 const loaded = new Set(); 325 let i = 0; 326 327 const next = async () => { 328 while (i < docs.length) { 329 const doc = docs[i++]; 330 const success = await new Promise((resolve) => 331 doc.load( 332 () => resolve(true), 333 () => resolve(false), 334 { readCache: true, writeCache: true }, 335 ), 336 ); 337 if (success) { 338 loaded.add(doc); 339 } 340 } 341 }; 342 343 await Promise.all( 344 Array.from( 345 { length: Math.min(docs.length, Docs.CONCURRENCY) }, 346 next, 347 ), 348 ); 349 350 return loaded; 351 } 352 353 /** 354 * Turns a doc on, loading its index and installing it when the user has 355 * asked for that. 356 * 357 * @param {Doc} doc 358 * @param {() => void} _onSuccess 359 * @param {() => void} onError 360 */ 361 enableDoc(doc, _onSuccess, onError) { 362 if (this.docs.contains(doc)) { 363 return; 364 } 365 366 const onSuccess = () => { 367 if (this.docs.contains(doc)) { 368 return; 369 } 370 this.disabledDocs.remove(doc); 371 this.docs.add(doc); 372 this.docs.sort(); 373 this.initDoc(doc); 374 this.saveDocs(); 375 if (app.settings.get("autoInstall")) { 376 doc.install(_onSuccess, onError); 377 } else { 378 _onSuccess(); 379 } 380 }; 381 382 doc.load(onSuccess, onError, { writeCache: true }); 383 } 384 385 /** Stores the enabled docs and brings the offline database in line. */ 386 saveDocs() { 387 this.settings.setDocs(this.docs.all().map((doc) => doc.slug)); 388 this.db.migrate(); 389 return this.serviceWorker != null
390 ? this.serviceWorker.updateInBackground() 391 : undefined; 392 } 393 394 /** 395 * Shows what's new since the user's last visit, and starts the update checks. 396 * 397 * Upstream also asks the fifth-time visitor to share the app; that prompt is 398 * left out, since its links point at devdocs.io rather than at this site. 399 */ 400 welcomeBack() { 401 let visitCount = this.settings.get("count"); 402 this.settings.set("count", ++visitCount); 403 new News(); 404 new Updates(); 405 return (this.updateChecker = new UpdateChecker()); 406 } 407 408 /** Reloads the app, keeping the current path. */ 409 reboot() { 410 if (location.pathname !== "/" && location.pathname !== "/settings") { 411 window.location.href = `/#${location.pathname}`; 412 } else { 413 window.location.href = "/"; 414 } 415 } 416 417 /** Drops the cached indexes and reloads the app. */ 418 reload() { 419 this.db.clearIndexes(() => { 420 if (this.serviceWorker) { 421 this.serviceWorker.reload(); 422 } else { 423 this.reboot(); 424 } 425 }); 426 } 427 428 /** Clears every trace of the app and returns to the index. */ 429 reset() { 430 this.localStorage.reset(); 431 this.settings.reset(); 432 if (this.db != null) { 433 this.db.reset(); 434 } 435 if (this.serviceWorker != null) { 436 this.serviceWorker.update(); 437 } 438 window.location.href = "/"; 439 } 440 441 /** 442 * Shows a tip, unless the user has already seen it. 443 * 444 * @param {string} tip 445 */ 446 showTip(tip) { 447 if (this.isSingleDoc()) { 448 return; 449 } 450 const tips = this.settings.getTips(); 451 if (!tips.includes(tip)) { 452 tips.push(tip); 453 this.settings.setTips(tips); 454 new Tip(tip); 455 } 456 } 457 458 /** Takes the boot screen down. */ 459 hideLoadingScreen() { 460 if ($.overlayScrollbarsEnabled()) { 461 document.body.classList.add("_overlay-scrollbars"); 462 } 463 document.documentElement.classList.remove("_booting"); 464 } 465 466 /** @param {...unknown} args */ 467 onBootError(...args) { 468 this.trigger("bootError"); 469 this.hideLoadingScreen(); 470 } 471 472 /** Warns the user that the offline database has outgrown its quota. Once. */ 473 onQuotaExceeded() { 474 if (this.quotaExceeded) { 475 return; 476 } 477 this.quotaExceeded = true; 478 new Notif("QuotaExceeded", { autoHide: null }); 479 } 480 481 /** 482 * Warns the user that storage is blocked, so preferences won't stick. Once. 483 * 484 * @param {string} key 485 * @param {unknown} value What was written. 486 * @param {unknown} actual What was read back. 487 */ 488 onStorageBlocked(key, value, actual) { 489 if (this.storageBlocked) { 490 return; 491 } 492 this.storageBlocked = true; 493 new Notif("StorageBlocked", { autoHide: null }); 494 Raven.captureMessage(`StorageBlocked/${key}`, { 495 level: "warning", 496 extra: { value, actual }, 497 }); 498 } 499 500 /** @param {...unknown} args The `window.onerror` arguments. */ 501 onWindowError(...args) { 502 if (this.storageBlocked) { 503 return; 504 } 505 if (this.isAppError(args[0], /** @type {string} */ (args[1]))) { 506 if (typeof this.previousErrorHandler === "function") { 507 this.previousErrorHandler(...args); 508 } 509 this.hideLoadingScreen(); 510 if (!this.errorNotif) { 511 this.errorNotif = new Notif("Error"); 512 } 513 this.errorNotif.show(); 514 } 515 } 516 517 /** 518 * @param {unknown} error 519 * @param {string} [file] Where the error came from. 520 * @returns {boolean} Whether the error came from the app rather than an 521 * external script. 522 */ 523 isAppError(error, file) { 524 // Ignore errors from external scripts. 525 return file && file.includes("devdocs") && file.endsWith(".js"); 526 } 527 528 /** @returns {boolean} Whether the browser has everything the app needs. */ 529 isSupportedBrowser() { 530 try { 531 const features = { 532 bind: !!Function.prototype.bind, 533 pushState: !!history.pushState, 534 matchMedia: !!window.matchMedia, 535 insertAdjacentHTML: !!document.body.insertAdjacentHTML, 536 defaultPrevented: 537 document.createEvent("CustomEvent").defaultPrevented === false, 538 cssVariables: !!CSS.supports?.("(--t: 0)"), 539 }; 540 541 for (var key in features) { 542 var value = features[key]; 543 if (!value) { 544 Raven.captureMessage(`unsupported/${key}`, { level: "info" }); 545 return false;
546 } 547 } 548 549 return true; 550 } catch (error) { 551 Raven.captureMessage("unsupported/exception", { 552 level: "info", 553 extra: { error }, 554 }); 555 return false; 556 } 557 } 558 559 /** @returns {boolean} Whether the app is showing one doc rather than all of them. */ 560 isSingleDoc() { 561 return document.body.hasAttribute("data-doc"); 562 } 563 564 /** @returns {boolean} Whether to use the phone layout. Decided once. */ 565 isMobile() { 566 return this._isMobile != null 567 ? this._isMobile 568 : (this._isMobile = Mobile.detect()); 569 } 570 571 /** @returns {boolean} Whether the app is inside an Android webview. Decided once. */ 572 isAndroidWebview() { 573 return this._isAndroidWebview != null 574 ? this._isAndroidWebview 575 : (this._isAndroidWebview = Mobile.detectAndroidWebview()); 576 } 577 578 /** @returns {boolean} Whether the app is being served from someone else's domain. */ 579 isInvalidLocation() { 580 return ( 581 config.env === "production" && 582 !location.host.startsWith(config.production_host) 583 ); 584 } 585} 586 587export const app = new App();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.