chore: remove retired native login paths

This commit is contained in:
jjbrunton committed 2026-08-08 16:55:04 +01:00
1 parent ea92a1a92b
commit a36723a325
6 files changed
+18 -775

No files matched your search

+16 -47
View File
@@ -1,57 +1,26 @@
# Deploying RedditBot to Dokploy
# Dokploy deployment
This guide explains how to deploy the RedditBot to Dokploy without needing to manually transfer your local browser session.
RedditBot now uses extension dumps as its only account-authentication path. The container does not expose VNC, start Google Chrome, submit Reddit credentials, or sniff Matrix tokens.
## Automated Login Feature
## Environment
The bot has been updated to support automated login using environment variables. This means when you deploy to a fresh server (like Dokploy), the bot will detect it's not logged in and automatically authenticate using the credentials you provide.
Configure only the application secrets required by the Compose file:
## Deployment Steps
- `OPENROUTER_API_KEY`
- `WEB_DASHBOARD_TOKEN`
1. **Create a New Application in Dokploy**:
* Select "Docker Compose" as the deployment method (recommended) or "Dockerfile".
Do not configure Reddit usernames, passwords, display servers, CDP endpoints, or remote-browser ports.
2. **Environment Variables**:
* Go to the "Environment" tab of your application.
* Add the following variables:
* `OPENROUTER_API_KEY`: Your OpenRouter API key.
* `REDDIT_USERNAME`: Your Reddit username.
* `REDDIT_PASSWORD`: Your Reddit password.
* `BROWSER_HEADLESS`: `false` (required for interactive noVNC login).
* `VNC_PASSWORD`: A strong password for the interactive VNC session.
* `NOVNC_PUBLIC_URL`: `http://<dokploy-host>:16080`.
## Account activation
3. **Volumes (Persistence)**:
* The `docker-compose.yml` is already configured to mount a `./data` volume.
* Dokploy usually handles volumes automatically for Docker Compose projects, or you may need to define a persistent volume path if you want data to survive container recreations (which you do, to avoid logging in every time).
* Make sure the `/app/data` directory is persisted.
1. Deploy the service and open the dashboard.
2. Install the extension from `extensions/reddit-session-exporter` in your local Chrome profile.
3. Sign in to Reddit locally, open Reddit Chat, and wait for the extension badge to show `OK`.
4. Export `redditbot-session.json` and upload it under **Settings > Access**.
5. Delete the local JSON file after the dashboard verifies it.
4. **Deploy**:
* Push your code to your Git repository connected to Dokploy.
* Click "Deploy".
The dump contains both Reddit cookies and a Matrix bearer token. Until both identities verify, the bot, response checks, chat, media sync, and dashboard start control remain paused. If either credential expires, import a new dump.
## noVNC networking
## Persistence
The Compose configuration publishes noVNC directly on host port `16080`.
Do not route it through a Dokploy/Traefik domain: the upstream proxy strips
the WebSocket Upgrade request that noVNC requires, producing `rfb.js:688` with
WebSocket code `1011` and “Failed to connect to downstream server”.
Set `NOVNC_PUBLIC_URL` to `http://<dokploy-host>:16080`, then open that URL in
a separate tab from the dashboard. If the dashboard itself is HTTPS, browsers
will not embed this HTTP endpoint in its iframe; the separate-tab link is the
intended route. Restrict inbound TCP `16080` at the Dokploy host firewall to
your own IP or VPN subnet.
## How it works
1. On the first run, the bot will see the `data/browser-session` folder is empty/invalid.
2. It will read `REDDIT_USERNAME` and `REDDIT_PASSWORD`.
3. It will launch a headless browser, navigate to Reddit, and log in.
4. The session cookies and storage will be saved to `data/browser-session`.
5. On subsequent restarts, it will use the saved session (as long as the `/app/data` volume is persisted).
## Troubleshooting
* **Login Failed**: Check the application logs. If Reddit presents a captcha or 2FA, the automated login will fail. You may need to temporarily run locally to solve the captcha, then copy the session (though this feature avoids that for standard accounts).
* **Verification**: The logs will show "Credentials found in config/env. Attempting automated login..." followed by "Login successful!".
The `reddit_bot_data` volume retains the database, the imported Playwright cookie profile, and the verified Matrix credential across deployments. Back up this volume as sensitive account data.
@@ -13,7 +13,7 @@ const sections: ReadonlyArray<{ id: SettingsSection; label: string; description:
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
{ id: 'access', label: 'Access', description: 'Reddit browser session' },
{ id: 'access', label: 'Access', description: 'Extension session dump' },
];
function errorMessage(error: Error | null): string {
@@ -26,7 +26,7 @@ export function SettingsPage({ section, onSectionChange }: SettingsPageProps) {
return (
<div className="page settings-page">
<header className="page-header">
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit browser access.</p></div>
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit account access.</p></div>
</header>
<nav aria-label="Settings sections" className="settings-navigation">
{sections.map((item) => (
-560
View File
@@ -1,560 +0,0 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
export class RedditMessagePage {
private dryRun: boolean = false;
constructor(
private page: Page,
private logger: Logger
) { }
setDryRun(enabled: boolean): void {
this.dryRun = enabled;
if (enabled) {
this.logger.info('Dry run mode enabled - messages will be filled but not sent');
}
}
async sendMessage(recipient: string, subject: string, body: string): Promise<boolean> {
try {
this.logger.info(`Sending message to u/${recipient}`);
// Navigate to compose page
await this.page.goto('https://www.reddit.com/message/compose/', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// Wait for the form to load - try multiple possible selectors
await this.page.waitForSelector('faceplate-text-input[name="message-recipient-input"], input[name="to"], #to, [data-testid="message-to"], input[placeholder*="Username"]', {
timeout: 30000,
});
// Wait for React/Hydration
await this.page.waitForTimeout(2000);
// --- Recipient ---
const recipientFilled = await this.fillField(
['faceplate-text-input[name="message-recipient-input"]', 'input[name="to"]', '#to', '[data-testid="message-to"]'],
recipient
);
if (!recipientFilled) {
// Fallback: try to find by label
const labels = await this.page.$$('label');
let found = false;
for (const label of labels) {
const text = await label.textContent();
if (text && (text.includes('to') || text.includes('To'))) {
const id = await label.getAttribute('for');
if (id) {
const input = await this.page.$(`#${id}`);
if (input) {
await input.click();
await input.fill(recipient);
found = true;
break;
}
}
}
}
if (!found) {
throw new Error('Could not find recipient input field');
}
}
// Wait a moment for Reddit's validation to kick in
await this.page.waitForTimeout(1500);
// Check for "unable to send message" error immediately after recipient is filled
// This happens when the user has blocked messages, doesn't exist, or doesn't accept message requests
const recipientError = await this.checkRecipientError();
if (recipientError) {
this.logger.warn(`Cannot message u/${recipient}: ${recipientError}`);
return false;
}
// --- Subject ---
const subjectFilled = await this.fillField(
['faceplate-text-input[name="message-title"]', 'input[name="subject"]', '#subject'],
subject
);
if (!subjectFilled) {
throw new Error('Could not find subject input field');
}
// --- Body ---
let bodyFilled = await this.fillField(
['faceplate-textarea-input[name="message-content"]', 'textarea[name="text"]', 'textarea#text', 'textarea.md', '[contenteditable="true"]', '[data-testid="markdown-input"]'],
body,
true // use typeWithDelay for body
);
if (!bodyFilled) {
throw new Error('Could not find message body input');
}
// Wait a bit before submitting
await this.page.waitForTimeout(1000);
// Dry run mode - don't actually send
if (this.dryRun) {
this.logger.info(`[DRY RUN] Message filled out for u/${recipient} - NOT sending`);
this.logger.info(`[DRY RUN] Subject: ${subject}`);
this.logger.info(`[DRY RUN] Body: ${body.slice(0, 100)}...`);
this.logger.info(`[DRY RUN] Pausing 10 seconds so you can review the form...`);
await this.page.waitForTimeout(10000);
return true;
}
// Find and click submit button - prioritized by specificity to user provided snippet
const submitButton =
(await this.page.$('button[type="submit"]:has-text("Send")')) ||
(await this.page.$('button.button-primary:has-text("Send")')) ||
(await this.page.$('button:has(span:has-text("Send"))')) ||
(await this.page.$('button[type="submit"]')) ||
(await this.page.$('button:has-text("Send")')) ||
(await this.page.$('input[type="submit"]'));
let clicked = false;
if (!submitButton) {
clicked = await this.clickSendButtonViaShadowDom();
if (!clicked) {
throw new Error('Could not find submit button');
}
}
if (submitButton) {
// Wait for button to be enabled (up to 5 seconds)
// Sometimes validation takes a moment
try {
await submitButton.waitForElementState('enabled', { timeout: 5000 });
} catch (e) {
this.logger.warn('Submit button still disabled after filling fields. Attempting to trigger validation...');
// Try to trigger validation by blurring/focusing the last field
await this.page.keyboard.press('Tab');
await this.page.waitForTimeout(500);
await this.page.keyboard.press('Shift+Tab');
await this.page.waitForTimeout(500);
// One last check
const isEnabled = await submitButton.isEnabled();
if (!isEnabled) {
this.logger.warn('Button still disabled. Attempting force click...');
await submitButton.click({ force: true });
clicked = true;
// If force click fails/doesn't work, we proceed to success check anyway
// (sometimes it works even if reported disabled in shadow dom scenarios)
} else {
await submitButton.click();
clicked = true;
}
}
}
if (submitButton && !clicked) {
await submitButton.click();
clicked = true;
}
if (!clicked) {
clicked = await this.clickSendButtonViaShadowDom();
}
// Wait for success indication
try {
await Promise.race([
this.page.waitForURL(/\/message\/sent/, { timeout: 15000 }),
this.page.waitForSelector('.success, [class*="success"], [class*="Success"]', { timeout: 15000 }),
// Check for the specific success message in the new UI
this.page.waitForFunction(() => {
const confirmation = document.querySelector('.message-sent-confirmation-text');
return confirmation && !confirmation.hasAttribute('hidden') && confirmation.textContent?.trim().length > 0;
}, null, { timeout: 15000 })
]);
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
} catch {
// Check if we're on a success page or got any error
const currentUrl = this.page.url();
if (currentUrl.includes('/message/sent') || currentUrl.includes('success')) {
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
}
// Also check if the success message is visible now (in case race failed slightly early)
const successMsg = await this.page.$('.message-sent-confirmation-text:not([hidden])');
if (successMsg && (await successMsg.isVisible())) {
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
}
// Check for error messages
// Be more specific to avoid false positives (require visibility and content)
const errorElement = await this.page.$('.error, [class*="error"], [class*="Error"], [role="alert"]');
if (errorElement && await errorElement.isVisible()) {
const errorText = await errorElement.textContent();
if (errorText && errorText.trim().length > 0) {
throw new Error(`Reddit error: ${errorText}`);
}
}
// If no explicit error found but also no success found:
// Assume success if we are still on the same page but the input is cleared?
// Or just log warning.
// For now, let's trust the user report that it works and be less aggressive with throwing.
this.logger.warn('Message send status unclear (no success indicator found, but no error either). Assuming success if no error alert appeared.');
return true;
}
} catch (error) {
this.logger.error(`Failed to send message to u/${recipient}:`, error);
return false;
}
}
private async clickSendButtonViaShadowDom(): Promise<boolean> {
try {
const clicked = await this.page.evaluate(() => {
function collectAllElements(root: ParentNode): Element[] {
const elements: Element[] = [];
if (!root) return elements;
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let current = treeWalker.currentNode as Element | null;
while (current) {
elements.push(current);
const maybeShadow = (current as Element & { shadowRoot?: ShadowRoot }).shadowRoot;
if (maybeShadow) {
elements.push(...collectAllElements(maybeShadow));
}
current = treeWalker.nextNode() as Element | null;
}
return elements;
}
function isSendButton(el: Element): boolean {
const text = (el.textContent || '').trim().toLowerCase();
const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
const value = (el instanceof HTMLInputElement ? el.value : '').trim().toLowerCase();
const label = [text, aria, value].join(' ');
return /\bsend\b/.test(label);
}
const elements = collectAllElements(document);
for (const el of elements) {
if (el.tagName === 'BUTTON' && isSendButton(el)) {
(el as HTMLElement).click();
return true;
}
if (el.tagName === 'INPUT') {
const input = el as HTMLInputElement;
if (input.type === 'submit' && isSendButton(el)) {
input.click();
return true;
}
}
}
return false;
});
if (clicked) {
this.logger.info('Clicked send button via shadow DOM fallback');
}
return clicked;
} catch (error) {
this.logger.warn('Failed to click send button via shadow DOM fallback:', error);
return false;
}
}
private async fillField(selectors: string[], value: string, useDelay = false): Promise<boolean> {
for (const selector of selectors) {
try {
const element = await this.page.$(selector);
if (element) {
// First, check if this is a faceplate-* Shadow DOM component
const isShadowComponent = await element.evaluate((el) => {
return el.tagName.toLowerCase().startsWith('faceplate-') && !!el.shadowRoot;
});
if (isShadowComponent) {
// Handle Shadow DOM components specially
this.logger.debug?.(`Found shadow DOM component: ${selector}`);
// Click the outer element to focus it
await element.click();
await this.page.waitForTimeout(100);
if (useDelay) {
// For typing with delay, we need to focus the inner input first
await element.evaluate((el) => {
const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLElement;
inner?.focus();
});
await this.page.waitForTimeout(100);
await this.typeWithDelay(value);
} else {
// Directly set value via evaluate and dispatch events
const success = await element.evaluate((el, val: string) => {
const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null;
if (!inner) return false;
// Focus the element
inner.focus();
// Clear existing value
inner.value = '';
// Set new value
inner.value = val;
// Dispatch events that Reddit's validation listens for
inner.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
inner.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
inner.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, composed: true }));
// Blur to trigger any onBlur validation
inner.blur();
return true;
}, value);
if (!success) {
this.logger.warn?.(`Failed to set value via shadow DOM for ${selector}`);
continue;
}
}
// Verify the value was set
const verifiedValue = await element.evaluate((el) => {
const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null;
return inner?.value || '';
});
if (verifiedValue === value) {
this.logger.debug?.(`Successfully filled ${selector} with value`);
return true;
} else {
this.logger.warn?.(`Value mismatch after setting ${selector}: expected "${value.substring(0, 20)}...", got "${verifiedValue.substring(0, 20)}..."`);
// Still return true if we at least set something
if (verifiedValue.length > 0) return true;
}
} else {
// Standard element handling (non-shadow DOM)
await element.click();
const targetHandle = await element.evaluateHandle((el) => {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement).isContentEditable) {
return el;
}
return el.querySelector('input, textarea, [contenteditable="true"]');
});
const target = targetHandle.asElement() || element;
if (useDelay) {
await target.click();
await this.typeWithDelay(value);
} else {
try {
await target.fill(value);
} catch {
// Last resort fallback - use element directly since target may not be evaluatable
await element.evaluate((el, val: string) => {
const targetEl = (el.querySelector('input, textarea, [contenteditable="true"]') || el) as HTMLElement;
if (targetEl instanceof HTMLInputElement || targetEl instanceof HTMLTextAreaElement) {
targetEl.value = val;
} else if (targetEl.isContentEditable) {
targetEl.textContent = val;
}
targetEl.dispatchEvent(new Event('input', { bubbles: true }));
targetEl.dispatchEvent(new Event('change', { bubbles: true }));
}, value);
}
}
return true;
}
}
} catch (e) {
this.logger.warn?.(`Error filling field with selector ${selector}:`, e);
// Continue to next selector
}
}
return false;
}
private async typeWithDelay(text: string): Promise<void> {
// Type with human-like delays
for (const char of text) {
await this.page.keyboard.type(char, { delay: 20 + Math.random() * 30 });
}
}
async isLoggedIn(): Promise<boolean> {
try {
await this.page.goto('https://www.reddit.com', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// Wait for page to stabilize
await this.page.waitForTimeout(2000);
const visibleIndicators = [
'[data-testid="user-drawer-button"]',
'button[aria-label*="User menu"]',
];
for (const selector of visibleIndicators) {
const el = this.page.locator(selector).first();
if (await el.isVisible().catch(() => false)) {
return true;
}
}
const attributeIndicators = [
'reddit-header-large[is-logged-in="true"]',
'shreddit-post-overflow-menu[current-user-id]',
];
for (const selector of attributeIndicators) {
const el = await this.page.$(selector);
if (el) {
return true;
}
}
return await this.page.evaluate(async () => {
try {
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return false;
const data = await res.json();
return Boolean(data && data.name);
} catch {
return false;
}
});
} catch (error) {
this.logger.error('Failed to check login status:', error);
return false;
}
}
/**
* Check if Reddit is showing an error after entering the recipient
* (e.g., "You are unable to send a message request to this account")
*/
private async checkRecipientError(): Promise<string | null> {
const errorPatterns = [
'unable to send a message',
'unable to send message',
'cannot send message',
'can\'t send message',
'user not found',
'user does not exist',
'doesn\'t accept direct messages',
'blocked',
'something went wrong',
];
// Retry for up to 4 seconds to allow async validation to complete
const maxRetries = 8;
for (let i = 0; i < maxRetries; i++) {
try {
// 1. Check specific Shadow DOM path (most reliable for new UI)
const specificError = await this.page.evaluate(() => {
const input = document.querySelector('faceplate-text-input[name="message-recipient-input"]');
if (!input || !input.shadowRoot) return null;
const helper = input.shadowRoot.querySelector('faceplate-form-helper-text');
if (!helper) return null;
// Check deep shadow text
if (helper.shadowRoot) {
const inner = helper.shadowRoot.querySelector('#helper-text');
if (inner && inner.textContent) return inner.textContent;
}
// Check light text
return helper.textContent;
});
if (specificError) {
const lowerText = specificError.toLowerCase();
if (errorPatterns.some(p => lowerText.includes(p))) {
return specificError.trim();
}
}
// 2. Check standard DOM selectors
const errorSelectors = [
'.error',
'[class*="error"]',
'[class*="Error"]',
'[role="alert"]',
'span[style*="color: red"]',
'faceplate-text-input[name="message-recipient-input"] ~ *',
];
for (const selector of errorSelectors) {
const elements = await this.page.$$(selector);
for (const el of elements) {
const text = await el.textContent();
if (text) {
const lowerText = text.toLowerCase();
if (errorPatterns.some(p => lowerText.includes(p))) {
return text.trim();
}
}
}
}
// 3. Fallback: Full Shadow DOM Crawl (expensive but thorough)
// Only do this on later retries to save time initially? Or just do it.
const errorFromShadow = await this.page.evaluate((patterns: string[]) => {
function collectAllElements(root: ParentNode): Element[] {
const elements: Element[] = [];
if (!root) return elements;
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let current = treeWalker.currentNode as Element | null;
while (current) {
elements.push(current);
const maybeShadow = (current as Element & { shadowRoot?: ShadowRoot }).shadowRoot;
if (maybeShadow) {
elements.push(...collectAllElements(maybeShadow));
}
current = treeWalker.nextNode() as Element | null;
}
return elements;
}
const allElements = collectAllElements(document);
for (const el of allElements) {
const text = (el.textContent || '').toLowerCase();
if (text.length > 200 || text.length < 5) continue; // Filter reasonable length
for (const pattern of patterns) {
if (text.includes(pattern)) {
return (el.textContent || '').trim();
}
}
}
return null;
}, errorPatterns);
if (errorFromShadow) return errorFromShadow;
} catch (error) {
this.logger.warn('Error checking for recipient error:', error);
}
// Wait before next retry
await this.page.waitForTimeout(500);
}
return null;
}
}
-102
View File
@@ -1,102 +0,0 @@
import http, { type Server } from 'node:http';
import { pathToFileURL } from 'node:url';
export interface NativeLoginGateOptions {
publicVncUrl: string;
onComplete: () => void;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] ?? character);
}
function loginPage(publicVncUrl: string): string {
const safeVncUrl = escapeHtml(publicVncUrl);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reddit login required</title>
<style>
body { margin: 0; font: 16px system-ui, sans-serif; color: #f5f5f5; background: #111; }
main { max-width: 1100px; margin: 24px auto; padding: 0 20px; }
.notice { padding: 18px; border: 1px solid #3c765f; border-radius: 12px; background: #17251f; }
iframe { width: 100%; height: 70vh; margin-top: 18px; border: 1px solid #444; border-radius: 10px; background: #000; }
button, a { display: inline-block; margin: 14px 8px 0 0; padding: 11px 16px; border: 0; border-radius: 8px; font-weight: 700; }
button { color: white; background: #ff4500; cursor: pointer; }
a { color: white; background: #444; text-decoration: none; }
#status { margin-left: 8px; color: #b9dfcf; }
</style>
</head>
<body><main>
<section class="notice">
<strong>Chrome is running without automation.</strong>
<p>Log in to Reddit in the browser below. Only after Reddit shows you as signed in, click <em>I'm logged in - connect the bot</em>. Chrome will restart with the same profile and the bot will verify both Reddit and Matrix before any bot logic can run.</p>
<button id="complete" type="button">I'm logged in - connect the bot</button>
<a href="${safeVncUrl}" target="_blank" rel="noreferrer">Open VNC in a new tab</a>
<span id="status"></span>
</section>
<iframe src="${safeVncUrl}" title="Reddit login browser"></iframe>
<script>
document.getElementById('complete').addEventListener('click', async () => {
const button = document.getElementById('complete');
const status = document.getElementById('status');
button.disabled = true;
status.textContent = 'Restarting Chrome and verifying your account...';
const response = await fetch('/complete', { method: 'POST' });
if (!response.ok) {
button.disabled = false;
status.textContent = 'Could not start verification. Please try again.';
return;
}
setTimeout(() => window.location.reload(), 3000);
});
</script>
</main></body></html>`;
}
export function createNativeLoginGate(options: NativeLoginGateOptions): Server {
return http.createServer((request, response) => {
if (request.method === 'POST' && request.url === '/complete') {
response.writeHead(202, { 'content-type': 'application/json' });
response.end(JSON.stringify({ ok: true, restarting: true }));
options.onComplete();
return;
}
if (request.method !== 'GET') {
response.writeHead(405).end();
return;
}
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
response.end(loginPage(options.publicVncUrl));
});
}
function run(): void {
const port = Number.parseInt(process.env.WEB_PORT ?? '3000', 10);
const publicBase = (process.env.NOVNC_PUBLIC_URL ?? `http://localhost:${process.env.NOVNC_PORT ?? '6080'}`).replace(/\/$/, '');
const publicVncUrl = `${publicBase}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
let server: Server;
server = createNativeLoginGate({
publicVncUrl,
onComplete: () => setTimeout(() => server.close(() => process.exit(0)), 250).unref(),
});
server.listen(port, () => {
process.stdout.write(`[native-login] waiting for human login on port ${port}\n`);
});
}
const entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
if (import.meta.url === entryUrl) run();
-28
View File
@@ -1,28 +0,0 @@
export interface NativeLoginHandoff {
request(): void;
}
export interface ProcessNativeLoginHandoffOptions {
exitCode?: number;
delayMs?: number;
exit?: (code: number) => void;
}
export class ProcessNativeLoginHandoff implements NativeLoginHandoff {
private requested = false;
private readonly exitCode: number;
private readonly delayMs: number;
private readonly exit: (code: number) => void;
constructor(options: ProcessNativeLoginHandoffOptions = {}) {
this.exitCode = options.exitCode ?? 75;
this.delayMs = options.delayMs ?? 250;
this.exit = options.exit ?? ((code) => process.exit(code));
}
request(): void {
if (this.requested) return;
this.requested = true;
setTimeout(() => this.exit(this.exitCode), this.delayMs).unref();
}
}
-36
View File
@@ -1,36 +0,0 @@
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createNativeLoginGate } from '../src/native-login-gate.js';
const servers: Array<ReturnType<typeof createNativeLoginGate>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => {
server.close(() => resolve());
})));
});
describe('native Reddit login gate', () => {
it('serves VNC guidance and completes only after the explicit handoff', async () => {
const onComplete = vi.fn();
const server = createNativeLoginGate({
publicVncUrl: 'https://red-vnc.example/vnc.html',
onComplete,
}).listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const page = await fetch(`http://127.0.0.1:${port}/`);
const html = await page.text();
expect(html).toContain('Chrome is running without automation');
expect(html).toContain('https://red-vnc.example/vnc.html');
expect(html).toContain("fetch('/complete', { method: 'POST' })");
expect(onComplete).not.toHaveBeenCalled();
const completed = await fetch(`http://127.0.0.1:${port}/complete`, { method: 'POST' });
expect(completed.status).toBe(202);
expect(await completed.json()).toEqual({ ok: true, restarting: true });
expect(onComplete).toHaveBeenCalledOnce();
});
});