DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The login window has no automation in it, which is the only reason Sign in with Google works

A lot of the rental portals Notifio monitors will not show you search results unless you are signed in. Kamernet is the clearest case, and plenty of others hide the parts that matter: the full address, the contact route, sometimes the listing itself.

So the app needs a session on a site it does not own. There are three ways to get one, and two of them are the kind of thing you regret.

The two we did not do

Ask for the password. A username and password field in the app, typed into the portal on the user's behalf. This is the shortest path and it is disqualified on its own terms: a desktop app would be storing a credential for a third-party account, and the user would be asked to trust it with exactly the thing that nobody should be handing to a small app they downloaded ten minutes ago. It also breaks on contact with reality. Half these portals offer Google or Facebook sign-in, several have two-factor, and a password field cannot drive either.

Read the cookies out of the user's real browser. Technically possible, and the blast radius is absurd. On macOS it means a keychain prompt and access to the cookie store for every site the person has ever visited, in order to obtain a session for one rental portal. Asking for a thousand sites' cookies to get one is not a proportionate request, and I would not grant it either.

Open a window and let them log in. This is the one. The app opens a browser window, the user logs in the way they always do, and the app keeps that session for scraping. No credential ever passes through our code, because there is nowhere in our code for one to pass through.

No automation in the window, deliberately

The comment above the function is the whole design note:

// ---------------------------------------------------------------------------
// Login window: real BrowserWindow, no Playwright flags, passes Google OAuth
// ---------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

Notifio reads pages with Playwright, so the obvious move is to open the login page in a Playwright-controlled page too, and reuse one browser for everything. That does not work, and the reason is not subtle: Google blocks sign-in from browsers it can tell are being driven programmatically. It is stated policy, not an accident, and nothing about it is worth trying to work around. If a user cannot click "Sign in with Google", the feature is broken for a large share of the people who need it.

So login happens in a plain Electron BrowserWindow. Not instrumented, not scripted, nothing listening for a form submit. It is a window with a URL in it, and the only thing the app does is open it:

const { hostname } = new URL(url);

// A dedicated persistent partition per hostname: cookies stay isolated from
// the app and from other portals, and survive the window being reopened.
const partition = `persist:login-${hostname.replace(/\./g, '_')}`;

loginWindow = new BrowserWindow({
  width: 1100,
  height: 800,
  title: `Log in to ${hostname}`,
  autoHideMenuBar: true,
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
    partition,
  },
});
await loginWindow.loadURL(url);
Enter fullscreen mode Exit fullscreen mode

That means there are two browsers in this product doing two jobs. One is automated and reads pages. One is not automated at all and exists so a human can do a thing no automation should be doing. Trying to collapse them into one would have cost the feature.

One partition per host, not one for logins

persist:login-<hostname> is the detail I would defend hardest after the previous one.

The lazy version is a single persist:login partition for every portal. It works, and it quietly builds a browser profile in which five rental sites share a cookie jar. Consequences, in order of how much they would annoy me:

  • "Log out of this site" becomes impossible to scope. The user wants to disconnect one portal, and the only lever is a jar that holds all of them.
  • A site that sets a broadly scoped cookie, or a shared identity provider, can touch another portal's session.
  • Nothing is isolated for debugging. A session problem on one site is indistinguishable from a session problem caused by a different site.

One partition per hostname makes every one of those a non-question. Logging out of a portal resets that portal. persist: rather than a memory partition is what makes the session survive closing the window and quitting the app, which is the behaviour the user expects, because it is how every browser they have ever used behaves.

The user says when they are done

The app never tries to detect that a login finished. There is no listener watching for a redirect to the dashboard, no polling for a logged-in marker. The window opens, and then there is a button:

Browser window opened. Log in there, then click "I'm logged in".
Enter fullscreen mode Exit fullscreen mode

Pressing it is what triggers the handoff. This is a deliberate refusal to be clever. "Has this person finished logging in" is unanswerable on an arbitrary site: the success URL differs everywhere, two-factor adds steps, some portals drop you on an onboarding wizard. A detector that fires early captures a half-built session, and the user then gets a login error on a site they are convinced they are logged into, which is the single most annoying bug class a tool like this can have.

A human knows when they are logged in. Asking them costs one click.

The confirm step is also where a nice bit of coupling pays off:

// A fresh login usually also clears any anti-bot wall, so resume this site.
monitor.clearBackoff(url);
Enter fullscreen mode Exit fullscreen mode

A site that has been refusing us is often refusing us because the session went stale, and a site under a backoff ladder is a site we have agreed to leave alone for a while. Logging in is new information, so the ladder is discarded rather than waited out. Without this, you log in successfully and then watch the row say "retry in 15m", which is technically correct and reads like a broken app. The ladder itself is in our retry ladder was keyed by the wrong thing.

The bridge, which exists because of a testing rule

There is one structural problem left. The code handling the "open a login window" request is an Express route inside the app, and that module is not allowed to import electron.

That rule is the app's entire testing strategy, and I wrote it up in our Electron app has no test framework, and the only rule is that testable files may not import electron. Importing electron outside an Electron process throws, so any module that does it can never be run by a plain script. Keeping the rule means a route handler cannot open a window, which is the thing the route is for.

The seam is an event bridge, three handlers, registered once:

class LoginBridge extends EventEmitter {
  /** Called once by main.ts to register the Electron-side handlers. */
  register(handlers: { open: OpenHandler; confirm: ConfirmHandler; reset: ResetHandler }): void { ... }

  async requestOpen(url: string): Promise<void> { ... }
  async requestConfirm(hostname: string): Promise<{ loggedIn: boolean }> { ... }
  async requestReset(hostname: string): Promise<void> { ... }
}

// Singleton, imported by both main.ts and server.ts.
export const loginBridge = new LoginBridge();
Enter fullscreen mode Exit fullscreen mode

server.ts asks for an outcome. main.ts owns every line that touches Electron. And because the dependency now points the right way, the route has a second path for development:

if (process.env.ELECTRON_APP) {
  await loginBridge.requestOpen(url);
} else {
  await openLoginPage(url);
}
Enter fullscreen mode Exit fullscreen mode

Outside Electron the whole flow still runs, in a Playwright window instead. The login features can be exercised from a plain script, which is exactly what the rule was for, and the bridge is what a testability constraint looks like once it has stopped being a test concern and become an architecture.

The remaining seam

The session has to get from the Electron window to the scraper, and that handoff has one genuinely nasty bug in it involving how Electron and Playwright each spell sameSite. That one already has its own post: Electron says 'unspecified', Playwright wants 'Lax', and the session cookies vanished.

Which portals need a session at all is written on each portal's page, for instance Pararius, SpareRoom and WG-Gesucht, each with a note on login, rendering and what auto-reply can currently do there. The flow end to end is on the help page, and the app is on the download page.

Top comments (0)