navigation-controller.ts 8.5 KB

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