RedditLoginBridgeStyle.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import Foundation
  2. enum RedditLoginBridgeStyle {
  3. static let messageHandlerName = "reddoraLogin"
  4. /// Intercepts Log In and blocks Reddit's embedded auth modal, which cannot complete
  5. /// credential sign-in reliably in WKWebView ("Server error. Try again later.").
  6. static let injectionScript = """
  7. (function() {
  8. if (window.__reddoraLoginBridgeInstalled) return;
  9. window.__reddoraLoginBridgeInstalled = true;
  10. const LOGIN_PATTERN = /log\\s*in/i;
  11. const HEADER_SELECTORS = 'header, [role="banner"], #header, faceplate-header, nav, shreddit-header-action';
  12. const CLICKABLE_SELECTORS = 'button, a, [role="button"], faceplate-tracker, faceplate-button';
  13. const AUTH_MODAL_SELECTORS = [
  14. 'auth-flow-modal',
  15. '[data-testid="login-modal"]',
  16. '#credentials-login-popup',
  17. 'shreddit-signup-drawer',
  18. 'faceplate-modal[slot="login"]',
  19. 'faceplate-modal[open]'
  20. ].join(', ');
  21. const boundLoginTriggers = new WeakSet();
  22. function nodeLabel(node) {
  23. if (!(node instanceof Element)) return '';
  24. return (
  25. (node.textContent || '') + ' ' +
  26. (node.getAttribute('aria-label') || '') + ' ' +
  27. (node.getAttribute('title') || '') + ' ' +
  28. (node.getAttribute('href') || '')
  29. ).trim();
  30. }
  31. function isLoginPage() {
  32. const host = window.location.hostname.toLowerCase();
  33. return (host === 'www.reddit.com' || host === 'reddit.com' || host === 'new.reddit.com')
  34. && /^\\/login\\/?$/i.test(window.location.pathname);
  35. }
  36. function requestInAppLogin() {
  37. if (isLoginPage()) return;
  38. if (window.__reddoraLoginPanelRequested) return;
  39. window.__reddoraLoginPanelRequested = true;
  40. setTimeout(function() {
  41. window.__reddoraLoginPanelRequested = false;
  42. }, 1000);
  43. if (
  44. window.webkit &&
  45. window.webkit.messageHandlers &&
  46. window.webkit.messageHandlers.reddoraLogin
  47. ) {
  48. window.webkit.messageHandlers.reddoraLogin.postMessage('open');
  49. }
  50. }
  51. function interceptLoginEvent(event) {
  52. if (isLoginPage()) return false;
  53. if (eventMatchesLoginTrigger(event)) {
  54. event.preventDefault();
  55. event.stopImmediatePropagation();
  56. window.__reddoraCloseRedditAuthModal && window.__reddoraCloseRedditAuthModal();
  57. requestInAppLogin();
  58. return true;
  59. }
  60. const loginButton = document.getElementById('login-button');
  61. if (loginButton && eventMatchesButtonBounds(event, loginButton)) {
  62. event.preventDefault();
  63. event.stopImmediatePropagation();
  64. window.__reddoraCloseRedditAuthModal && window.__reddoraCloseRedditAuthModal();
  65. requestInAppLogin();
  66. return true;
  67. }
  68. return false;
  69. }
  70. function eventMatchesButtonBounds(event, button) {
  71. if (!(button instanceof Element)) return false;
  72. if (!(event instanceof MouseEvent) && event.type !== 'pointerdown' && event.type !== 'mousedown') {
  73. return false;
  74. }
  75. if (button.contains(event.target)) return false;
  76. const rect = button.getBoundingClientRect();
  77. const x = event.clientX;
  78. const y = event.clientY;
  79. return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
  80. }
  81. function eventMatchesLoginTrigger(event) {
  82. const path = event.composedPath ? event.composedPath() : [event.target];
  83. for (let i = 0; i < path.length; i++) {
  84. if (resolveLoginTrigger(path[i])) return true;
  85. }
  86. return false;
  87. }
  88. function resolveLoginTrigger(node) {
  89. if (!(node instanceof Element)) return null;
  90. if (node.id === 'login-button') return node;
  91. if (node.getAttribute('data-testid') === 'login-button') return node;
  92. const clickable = node.matches(CLICKABLE_SELECTORS)
  93. ? node
  94. : node.closest(CLICKABLE_SELECTORS);
  95. if (!clickable) return null;
  96. if (clickable.id === 'login-button') return clickable;
  97. if (clickable.getAttribute('data-testid') === 'login-button') return clickable;
  98. const href = (clickable.getAttribute('href') || '').toLowerCase();
  99. if (href.includes('/login')) return clickable;
  100. const label = nodeLabel(clickable);
  101. if (!LOGIN_PATTERN.test(label)) return null;
  102. if (clickable.closest(HEADER_SELECTORS)) return clickable;
  103. const rect = clickable.getBoundingClientRect();
  104. if (rect.top >= 0 && rect.top < 96 && rect.width > 0 && rect.height > 0) {
  105. return clickable;
  106. }
  107. return null;
  108. }
  109. function isLoginTriggerElement(node) {
  110. return resolveLoginTrigger(node) !== null;
  111. }
  112. function isAuthModal(element) {
  113. if (!(element instanceof Element)) return false;
  114. if (element.matches(
  115. 'auth-flow-modal, [data-testid="login-modal"], #credentials-login-popup, shreddit-signup-drawer'
  116. )) {
  117. return true;
  118. }
  119. if (element.matches('shreddit-overlay-display')) {
  120. return !!element.querySelector(
  121. 'shreddit-signup-drawer, auth-flow-modal, input[type="password"]'
  122. );
  123. }
  124. if (element.matches('faceplate-modal')) {
  125. const label = nodeLabel(element);
  126. return LOGIN_PATTERN.test(label) || !!element.querySelector('input[type="password"]');
  127. }
  128. return false;
  129. }
  130. window.__reddoraCloseRedditAuthModal = function() {
  131. document.querySelectorAll(AUTH_MODAL_SELECTORS).forEach(function(element) {
  132. if (!isAuthModal(element)) return;
  133. const closeButton = element.querySelector(
  134. '[aria-label="Close"], [aria-label="close"], button[close]'
  135. );
  136. if (closeButton) {
  137. closeButton.click();
  138. } else {
  139. element.remove();
  140. }
  141. });
  142. };
  143. function bindDirectLoginListeners(root) {
  144. function walk(node) {
  145. if (!(node instanceof Element)) return;
  146. const trigger = resolveLoginTrigger(node);
  147. if (trigger && !boundLoginTriggers.has(trigger)) {
  148. boundLoginTriggers.add(trigger);
  149. ['pointerdown', 'mousedown', 'click'].forEach(function(type) {
  150. trigger.addEventListener(type, interceptLoginEvent, true);
  151. });
  152. }
  153. if (node.shadowRoot) walk(node.shadowRoot);
  154. const children = node.children;
  155. for (let i = 0; i < children.length; i++) {
  156. walk(children[i]);
  157. }
  158. }
  159. walk(root || document.documentElement);
  160. }
  161. function installAuthModalBlocker() {
  162. if (isLoginPage()) return;
  163. const STYLE_ID = 'reddora-auth-block-style';
  164. if (!document.getElementById(STYLE_ID)) {
  165. const style = document.createElement('style');
  166. style.id = STYLE_ID;
  167. style.textContent = [
  168. 'auth-flow-modal,',
  169. '[data-testid="login-modal"],',
  170. '#credentials-login-popup,',
  171. 'shreddit-signup-drawer {',
  172. ' display: none !important;',
  173. ' pointer-events: none !important;',
  174. '}'
  175. ].join('\\n');
  176. (document.head || document.documentElement).appendChild(style);
  177. }
  178. const observer = new MutationObserver(function() {
  179. if (isLoginPage()) return;
  180. bindDirectLoginListeners(document.documentElement);
  181. let foundAuthModal = false;
  182. document.querySelectorAll(AUTH_MODAL_SELECTORS).forEach(function(element) {
  183. if (!isAuthModal(element)) return;
  184. foundAuthModal = true;
  185. element.remove();
  186. });
  187. if (foundAuthModal) {
  188. requestInAppLogin();
  189. }
  190. });
  191. observer.observe(document.documentElement, { childList: true, subtree: true });
  192. }
  193. ['pointerdown', 'mousedown', 'click'].forEach(function(type) {
  194. document.addEventListener(type, interceptLoginEvent, true);
  195. });
  196. bindDirectLoginListeners(document.documentElement);
  197. installAuthModalBlocker();
  198. [250, 750, 1500, 3000].forEach(function(delay) {
  199. setTimeout(function() {
  200. bindDirectLoginListeners(document.documentElement);
  201. }, delay);
  202. });
  203. })();
  204. """
  205. static let closeAuthModalScript = "window.__reddoraCloseRedditAuthModal && window.__reddoraCloseRedditAuthModal();"
  206. }