navigation-controller.ts 8.2 KB

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