diff --git a/esbuild/config.ts b/esbuild/config.ts index 26ff7f7e..3e9333f4 100644 --- a/esbuild/config.ts +++ b/esbuild/config.ts @@ -32,6 +32,10 @@ export const options: BuildOptions = { in: path.join(SRC_DIR, 'content', 'keyAutoAdd', 'testWallet.ts'), out: path.join('content', 'keyAutoAdd', 'testWallet'), }, + { + in: path.join(SRC_DIR, 'content', 'keyAutoAdd', 'fynbos.ts'), + out: path.join('content', 'keyAutoAdd', 'fynbos'), + }, { in: path.join(SRC_DIR, 'content', 'polyfill.ts'), out: path.join('polyfill', 'polyfill'), diff --git a/src/background/services/keyAutoAdd.ts b/src/background/services/keyAutoAdd.ts index 2a08df54..ee8aa350 100644 --- a/src/background/services/keyAutoAdd.ts +++ b/src/background/services/keyAutoAdd.ts @@ -177,8 +177,10 @@ export function walletAddressToProvider(walletAddress: WalletAddress): { switch (host) { case 'ilp.rafiki.money': return { url: 'https://rafiki.money/settings/developer-keys' }; - // case 'eu1.fynbos.me': // fynbos dev - // case 'fynbos.me': // fynbos production + case 'eu1.fynbos.me': + return { url: 'https://eu1.fynbos.dev/settings/keys' }; + case 'fynbos.me': + return { url: 'https://wallet.fynbos.app/settings/keys' }; default: throw new ErrorWithKey('connectWalletKeyService_error_notImplemented'); } diff --git a/src/content/keyAutoAdd/fynbos.ts b/src/content/keyAutoAdd/fynbos.ts new file mode 100644 index 00000000..25be9bf8 --- /dev/null +++ b/src/content/keyAutoAdd/fynbos.ts @@ -0,0 +1,129 @@ +// cSpell:ignore nextjs +import { errorWithKey, ErrorWithKey, sleep } from '@/shared/helpers'; +import { + KeyAutoAdd, + LOGIN_WAIT_TIMEOUT, + type StepRun as Run, +} from './lib/keyAutoAdd'; +import { isTimedOut, waitForElement, waitForURL } from './lib/helpers'; +// #region: Steps + +type IndexRouteResponse = { + isUser: boolean; + walletInfo: { + walletID: string; + url: string; + }; +}; + +const waitForLogin: Run = async ( + { keyAddUrl }, + { skip, setNotificationSize }, +) => { + let alreadyLoggedIn = window.location.href.startsWith(keyAddUrl); + if (!alreadyLoggedIn) setNotificationSize('notification'); + try { + sleep(2000); + alreadyLoggedIn = await waitForURL( + (url) => (url.origin + url.pathname).startsWith(keyAddUrl), + { timeout: LOGIN_WAIT_TIMEOUT }, + ); + setNotificationSize('fullscreen'); + } catch (error) { + if (isTimedOut(error)) { + throw new ErrorWithKey('connectWalletKeyService_error_timeoutLogin'); + } + throw new Error(error); + } + + if (alreadyLoggedIn) { + skip(errorWithKey('connectWalletKeyService_error_skipAlreadyLoggedIn')); + } +}; + +const findWallet: Run<{ walletId: string }> = async ( + { walletAddressUrl }, + { setNotificationSize }, +) => { + setNotificationSize('fullscreen'); + const url = `/?_data=${encodeURIComponent('routes/_index')}`; + const res = await fetch(url, { + method: 'GET', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + mode: 'cors', + credentials: 'include', + }); + const data: IndexRouteResponse = await res.json(); + if (!data?.walletInfo?.url) { + throw new ErrorWithKey('connectWalletKeyService_error_accountNotFound'); + } + + if (data.walletInfo.url === walletAddressUrl) { + return { walletId: data.walletInfo.walletID }; + } + + throw new ErrorWithKey('connectWalletKeyService_error_accountNotFound'); +}; + +const findForm: Run<{ + form: HTMLFormElement; + nickNameField: HTMLInputElement; + publicKeyField: HTMLTextAreaElement; +}> = async () => { + const pathname = '/settings/keys/add-public'; + const link = await waitForElement(`a[href="${pathname}"]`); + link.click(); + await waitForURL((url) => url.pathname === pathname); + + const form = await waitForElement('form#add-public-key'); + const nickNameField = await waitForElement( + 'input#applicationName', + { root: form }, + ); + const publicKeyField = await waitForElement( + 'textarea#publicKey', + { root: form }, + ); + return { form, nickNameField, publicKeyField }; +}; + +const addKey: Run = async ({ publicKey, nickName }, { output }) => { + const { form, nickNameField, publicKeyField } = output(findForm); + + nickNameField.focus(); + nickNameField.value = nickName; + nickNameField.blur(); + + publicKeyField.focus(); + publicKeyField.value = publicKey; + publicKeyField.blur(); + + const submitButton = await waitForElement( + 'button[type="submit"]', + { root: form }, + ); + submitButton.click(); + + await waitForURL((url) => url.pathname === '/settings/keys'); +}; +// #endregion + +// #region: Helpers +// anything? +// #endregion + +// #region: Main +new KeyAutoAdd([ + { + name: 'Waiting for you to login', + run: waitForLogin, + maxDuration: LOGIN_WAIT_TIMEOUT, + }, + { name: 'Finding wallet', run: findWallet }, + { name: 'Finding form to add public key', run: findForm }, + { name: 'Adding key', run: addKey }, +]).init(); +// #endregion diff --git a/src/content/keyAutoAdd/lib/helpers.ts b/src/content/keyAutoAdd/lib/helpers.ts index 6b80375b..7709f957 100644 --- a/src/content/keyAutoAdd/lib/helpers.ts +++ b/src/content/keyAutoAdd/lib/helpers.ts @@ -3,10 +3,63 @@ import { withResolvers } from '@/shared/helpers'; +class TimeoutError extends Error { + name = 'TimeoutError'; + constructor(message: string, { cause }: { cause: Event }) { + super(message, { cause }); + } +} + interface WaitForOptions { timeout: number; } +interface WaitForElementOptions extends WaitForOptions { + root: HTMLElement | HTMLHtmlElement | Document; + /** + * Once a selector is matched, you can request an additional check to ensure + * this is the element you're looking for. + */ + match: (el: HTMLElement) => boolean; +} + +export function waitForElement( + selector: string, + { + root = document, + timeout = 10 * 1000, + match = () => true, + }: Partial = {}, +): Promise { + const { resolve, reject, promise } = withResolvers(); + if (document.querySelector(selector)) { + resolve(document.querySelector(selector)!); + return promise; + } + + const abortSignal = AbortSignal.timeout(timeout); + abortSignal.addEventListener('abort', (e) => { + observer.disconnect(); + reject( + new TimeoutError(`Timeout waiting for element: {${selector}}`, { + cause: e, + }), + ); + }); + + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el && match(el)) { + observer.disconnect(); + resolve(el); + } + }); + + observer.observe(root, { childList: true, subtree: true }); + + return promise; +} + interface WaitForURLOptions extends WaitForOptions {} export async function waitForURL( @@ -23,7 +76,7 @@ export async function waitForURL( const abortSignal = AbortSignal.timeout(timeout); abortSignal.addEventListener('abort', (e) => { observer.disconnect(); - reject(e); + reject(new TimeoutError(`Timeout waiting for URL`, { cause: e })); }); let url = window.location.href; @@ -44,10 +97,5 @@ export async function waitForURL( } export function isTimedOut(e: any) { - return ( - e instanceof Event && - e.type === 'abort' && - e.currentTarget instanceof AbortSignal && - e.currentTarget.reason?.name === 'TimeoutError' - ); + return e instanceof TimeoutError; } diff --git a/src/manifest.json b/src/manifest.json index a4d06797..cfe1cf9d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -22,6 +22,11 @@ "matches": ["https://rafiki.money/*/*"], "js": ["content/keyAutoAdd/testWallet.js"], "run_at": "document_end" + }, + { + "matches": ["https://eu1.fynbos.dev/*", "https://wallet.fynbos.app/*"], + "js": ["content/keyAutoAdd/fynbos.js"], + "run_at": "document_end" } ], "background": {