navigation-controller.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import type { WebContents, LoadURLOptions } from 'electron/main';
  2. import { EventEmitter } from 'events';
  3. // JavaScript implementation of Chromium's NavigationController.
  4. // Instead of relying on Chromium for history control, we completely do history
  5. // control on user land, and only rely on WebContents.loadURL for navigation.
  6. // This helps us avoid Chromium's various optimizations so we can ensure renderer
  7. // process is restarted every time.
  8. export class NavigationController extends EventEmitter {
  9. currentIndex: number = -1;
  10. inPageIndex: number = -1;
  11. pendingIndex: number = -1;
  12. history: string[] = [];
  13. constructor (private webContents: WebContents) {
  14. super();
  15. this.clearHistory();
  16. // webContents may have already navigated to a page.
  17. if (this.webContents._getURL()) {
  18. this.currentIndex++;
  19. this.history.push(this.webContents._getURL());
  20. }
  21. this.webContents.on('navigation-entry-committed' as any, (event: Electron.Event, url: string, inPage: boolean, replaceEntry: boolean) => {
  22. if (this.inPageIndex > -1 && !inPage) {
  23. // Navigated to a new page, clear in-page mark.
  24. this.inPageIndex = -1;
  25. } else if (this.inPageIndex === -1 && inPage && !replaceEntry) {
  26. // Started in-page navigations.
  27. this.inPageIndex = this.currentIndex;
  28. }
  29. if (this.pendingIndex >= 0) {
  30. // Go to index.
  31. this.currentIndex = this.pendingIndex;
  32. this.pendingIndex = -1;
  33. this.history[this.currentIndex] = url;
  34. } else if (replaceEntry) {
  35. // Non-user initialized navigation.
  36. this.history[this.currentIndex] = url;
  37. } else {
  38. // Normal navigation. Clear history.
  39. this.history = this.history.slice(0, this.currentIndex + 1);
  40. this.currentIndex++;
  41. this.history.push(url);
  42. }
  43. });
  44. }
  45. loadURL (url: string, options?: LoadURLOptions): Promise<void> {
  46. if (options == null) {
  47. options = {};
  48. }
  49. const p = new Promise<void>((resolve, reject) => {
  50. const resolveAndCleanup = () => {
  51. removeListeners();
  52. resolve();
  53. };
  54. const rejectAndCleanup = (errorCode: number, errorDescription: string, url: string) => {
  55. const err = new Error(`${errorDescription} (${errorCode}) loading '${typeof url === 'string' ? url.substr(0, 2048) : url}'`);
  56. Object.assign(err, { errno: errorCode, code: errorDescription, url });
  57. removeListeners();
  58. reject(err);
  59. };
  60. const finishListener = () => {
  61. resolveAndCleanup();
  62. };
  63. const failListener = (event: Electron.Event, errorCode: number, errorDescription: string, validatedURL: string, isMainFrame: boolean) => {
  64. if (isMainFrame) {
  65. rejectAndCleanup(errorCode, errorDescription, validatedURL);
  66. }
  67. };
  68. let navigationStarted = false;
  69. const navigationListener = (event: Electron.Event, url: string, isSameDocument: boolean, isMainFrame: boolean) => {
  70. if (isMainFrame) {
  71. if (navigationStarted && !isSameDocument) {
  72. // the webcontents has started another unrelated navigation in the
  73. // main frame (probably from the app calling `loadURL` again); reject
  74. // the promise
  75. // We should only consider the request aborted if the "navigation" is
  76. // actually navigating and not simply transitioning URL state in the
  77. // current context. E.g. pushState and `location.hash` changes are
  78. // considered navigation events but are triggered with isSameDocument.
  79. // We can ignore these to allow virtual routing on page load as long
  80. // as the routing does not leave the document
  81. return rejectAndCleanup(-3, 'ERR_ABORTED', url);
  82. }
  83. navigationStarted = true;
  84. }
  85. };
  86. const stopLoadingListener = () => {
  87. // By the time we get here, either 'finish' or 'fail' should have fired
  88. // if the navigation occurred. However, in some situations (e.g. when
  89. // attempting to load a page with a bad scheme), loading will stop
  90. // without emitting finish or fail. In this case, we reject the promise
  91. // with a generic failure.
  92. // TODO(jeremy): enumerate all the cases in which this can happen. If
  93. // the only one is with a bad scheme, perhaps ERR_INVALID_ARGUMENT
  94. // would be more appropriate.
  95. rejectAndCleanup(-2, 'ERR_FAILED', url);
  96. };
  97. const removeListeners = () => {
  98. this.webContents.removeListener('did-finish-load', finishListener);
  99. this.webContents.removeListener('did-fail-load', failListener);
  100. this.webContents.removeListener('did-start-navigation', navigationListener);
  101. this.webContents.removeListener('did-stop-loading', stopLoadingListener);
  102. this.webContents.removeListener('destroyed', stopLoadingListener);
  103. };
  104. this.webContents.on('did-finish-load', finishListener);
  105. this.webContents.on('did-fail-load', failListener);
  106. this.webContents.on('did-start-navigation', navigationListener);
  107. this.webContents.on('did-stop-loading', stopLoadingListener);
  108. this.webContents.on('destroyed', stopLoadingListener);
  109. });
  110. // Add a no-op rejection handler to silence the unhandled rejection error.
  111. p.catch(() => {});
  112. this.pendingIndex = -1;
  113. this.webContents._loadURL(url, options);
  114. this.webContents.emit('load-url', url, options);
  115. return p;
  116. }
  117. getURL () {
  118. if (this.currentIndex === -1) {
  119. return '';
  120. } else {
  121. return this.history[this.currentIndex];
  122. }
  123. }
  124. stop () {
  125. this.pendingIndex = -1;
  126. return this.webContents._stop();
  127. }
  128. reload () {
  129. this.pendingIndex = this.currentIndex;
  130. return this.webContents._loadURL(this.getURL(), {});
  131. }
  132. reloadIgnoringCache () {
  133. this.pendingIndex = this.currentIndex;
  134. return this.webContents._loadURL(this.getURL(), {
  135. extraHeaders: 'pragma: no-cache\n',
  136. reloadIgnoringCache: true
  137. });
  138. }
  139. canGoBack () {
  140. return this.getActiveIndex() > 0;
  141. }
  142. canGoForward () {
  143. return this.getActiveIndex() < this.history.length - 1;
  144. }
  145. canGoToIndex (index: number) {
  146. return index >= 0 && index < this.history.length;
  147. }
  148. canGoToOffset (offset: number) {
  149. return this.canGoToIndex(this.currentIndex + offset);
  150. }
  151. clearHistory () {
  152. this.history = [];
  153. this.currentIndex = -1;
  154. this.pendingIndex = -1;
  155. this.inPageIndex = -1;
  156. }
  157. goBack () {
  158. if (!this.canGoBack()) {
  159. return;
  160. }
  161. this.pendingIndex = this.getActiveIndex() - 1;
  162. if (this.inPageIndex > -1 && this.pendingIndex >= this.inPageIndex) {
  163. return this.webContents._goBack();
  164. } else {
  165. return this.webContents._loadURL(this.history[this.pendingIndex], {});
  166. }
  167. }
  168. goForward () {
  169. if (!this.canGoForward()) {
  170. return;
  171. }
  172. this.pendingIndex = this.getActiveIndex() + 1;
  173. if (this.inPageIndex > -1 && this.pendingIndex >= this.inPageIndex) {
  174. return this.webContents._goForward();
  175. } else {
  176. return this.webContents._loadURL(this.history[this.pendingIndex], {});
  177. }
  178. }
  179. goToIndex (index: number) {
  180. if (!this.canGoToIndex(index)) {
  181. return;
  182. }
  183. this.pendingIndex = index;
  184. return this.webContents._loadURL(this.history[this.pendingIndex], {});
  185. }
  186. goToOffset (offset: number) {
  187. if (!this.canGoToOffset(offset)) {
  188. return;
  189. }
  190. const pendingIndex = this.currentIndex + offset;
  191. if (this.inPageIndex > -1 && pendingIndex >= this.inPageIndex) {
  192. this.pendingIndex = pendingIndex;
  193. return this.webContents._goToOffset(offset);
  194. } else {
  195. return this.goToIndex(pendingIndex);
  196. }
  197. }
  198. getActiveIndex () {
  199. if (this.pendingIndex === -1) {
  200. return this.currentIndex;
  201. } else {
  202. return this.pendingIndex;
  203. }
  204. }
  205. length () {
  206. return this.history.length;
  207. }
  208. }