Files
steipete--codexbar/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift
T
2026-07-13 12:22:33 +08:00

549 lines
21 KiB
Swift

import AppKit
import CodexBarCore
import WebKit
@MainActor
final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigationDelegate, WKScriptMessageHandler {
private static let defaultSize = NSSize(width: 980, height: 760)
private static let logHandlerName = "codexbarLog"
private static let debugLogURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("codexbar-buy-credits.log")
private static let autoStartScript = """
(() => {
if (window.__codexbarAutoBuyCreditsStarted) return 'already';
const log = (...args) => {
try {
window.webkit?.messageHandlers?.codexbarLog?.postMessage(args);
} catch {}
};
const buttonSelector = 'button, a, [role="button"], input[type="button"], input[type="submit"]';
const isVisible = (el) => {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) return false;
const style = window.getComputedStyle ? window.getComputedStyle(el) : null;
if (style) {
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (parseFloat(style.opacity || '1') === 0) return false;
}
return true;
};
const textOf = el => {
const raw = el && (el.innerText || el.textContent) ? String(el.innerText || el.textContent) : '';
return raw.trim();
};
const matches = text => {
const lower = String(text || '').toLowerCase();
if (!lower.includes('credit')) return false;
return (
lower.includes('buy') ||
lower.includes('add') ||
lower.includes('purchase') ||
lower.includes('top up') ||
lower.includes('top-up')
);
};
const matchesAddMore = text => {
const lower = String(text || '').toLowerCase();
return lower.includes('add more');
};
const labelFor = el => {
if (!el) return '';
return textOf(el) || el.getAttribute('aria-label') || el.getAttribute('title') || el.value || '';
};
const summarize = el => {
if (!el) return null;
return {
tag: el.tagName,
type: el.getAttribute('type'),
role: el.getAttribute('role'),
label: labelFor(el),
aria: el.getAttribute('aria-label'),
disabled: isDisabled(el),
href: el.getAttribute('href'),
testId: el.getAttribute('data-testid'),
className: (el.className && String(el.className).slice(0, 120)) || ''
};
};
const collectButtons = () => {
const results = new Set();
const addAll = (root) => {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll(buttonSelector).forEach(el => results.add(el));
};
addAll(document);
document.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) addAll(el.shadowRoot);
});
document.querySelectorAll('iframe').forEach(frame => {
try {
const doc = frame.contentDocument;
if (!doc) return;
addAll(doc);
doc.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) addAll(el.shadowRoot);
});
} catch {}
});
return Array.from(results);
};
const findDialogNextButton = () => {
const dialog = document.querySelector('[role=\"dialog\"], dialog, [aria-modal=\"true\"]');
if (!dialog) return null;
const buttons = Array.from(dialog.querySelectorAll(buttonSelector));
const labeled = buttons.filter(btn => labelFor(btn).toLowerCase().startsWith('next'));
const visible = labeled.find(isVisible);
return visible || labeled[0] || null;
};
const clickButton = (el) => {
if (!el) return false;
try {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
} catch {
try {
el.click();
} catch {
return false;
}
}
return true;
};
const triggerPointerClick = (el) => {
if (!el) return false;
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
if (!rect) return false;
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const events = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
for (const type of events) {
try {
el.dispatchEvent(new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window,
clientX: x,
clientY: y
}));
} catch {
return false;
}
}
return true;
};
const pickLikelyButton = (buttons) => {
if (!buttons || buttons.length === 0) return null;
const labeled = buttons.find(btn => {
const label = labelFor(btn);
if (matches(label) || matchesAddMore(label)) return true;
const aria = String(btn.getAttribute('aria-label') || '').toLowerCase();
return aria.includes('credit') || aria.includes('buy') || aria.includes('add');
});
return labeled || buttons[0];
};
const findAddMoreButton = () => {
const buttons = collectButtons();
return buttons.find(btn => matchesAddMore(labelFor(btn))) || null;
};
const findNextButton = () => {
const dialogNext = findDialogNextButton();
if (dialogNext) return dialogNext;
const buttons = collectButtons();
const labeled = buttons.filter(btn => {
const label = labelFor(btn).toLowerCase();
return label === 'next' || label.startsWith('next ');
});
const visible = labeled.find(isVisible);
if (visible) return visible;
const submit = buttons.find(btn => btn.type && String(btn.type).toLowerCase() === 'submit' && isVisible(btn));
return submit || labeled[0] || null;
};
const isDisabled = (el) => {
if (!el) return true;
if (el.disabled) return true;
const ariaDisabled = String(el.getAttribute('aria-disabled') || '').toLowerCase();
if (ariaDisabled === 'true') return true;
if (el.classList && (el.classList.contains('disabled') || el.classList.contains('is-disabled'))) {
return true;
}
return false;
};
const forceClickElement = (el) => {
if (!el) return false;
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
if (rect) {
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const target = document.elementFromPoint(x, y);
if (target) {
try {
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return true;
} catch {
return false;
}
}
}
return false;
};
const requestSubmit = (el) => {
if (!el || !el.closest) return false;
const form = el.closest('form');
if (!form) return false;
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(el);
return true;
}
if (typeof form.submit === 'function') {
form.submit();
return true;
}
return false;
};
const clickNextIfReady = (attempts) => {
const nextButton = findNextButton();
if (!nextButton) {
if (attempts && attempts % 5 === 0) log('next_missing', { attempts });
return false;
}
if (isDisabled(nextButton)) {
if (attempts && attempts % 5 === 0) log('next_disabled', summarize(nextButton));
return false;
}
if (!isVisible(nextButton)) {
if (attempts && attempts % 5 === 0) log('next_hidden', summarize(nextButton));
return false;
}
nextButton.focus?.();
if (requestSubmit(nextButton)) {
log('next_submit', summarize(nextButton));
return true;
}
if (triggerPointerClick(nextButton)) {
log('next_pointer', summarize(nextButton));
return true;
}
if (clickButton(nextButton)) {
log('next_click', summarize(nextButton));
return true;
}
return forceClickElement(nextButton);
};
const startNextPolling = (initialDelay = 500, interval = 500, maxAttempts = 90) => {
if (window.__codexbarNextPolling) return;
window.__codexbarNextPolling = true;
log('start_next_poll', { initialDelay, interval, maxAttempts });
setTimeout(() => {
let attempts = 0;
const nextTimer = setInterval(() => {
attempts += 1;
if (attempts % 5 === 0) {
const nextButton = findNextButton();
log('next_poll', {
attempts,
found: Boolean(nextButton),
summary: summarize(nextButton)
});
}
if (clickNextIfReady(attempts) || attempts >= maxAttempts) {
clearInterval(nextTimer);
}
}, interval);
}, initialDelay);
};
const observeNextButton = () => {
if (window.__codexbarNextObserver || !window.MutationObserver) return;
const observer = new MutationObserver(() => {
if (clickNextIfReady(1)) {
observer.disconnect();
window.__codexbarNextObserver = null;
}
});
observer.observe(document.body, { subtree: true, childList: true, attributes: true });
window.__codexbarNextObserver = observer;
};
const findCreditsCardButton = () => {
const nodes = Array.from(document.querySelectorAll('h1,h2,h3,div,span,p'));
const labelMatch = nodes.find(node => {
const lower = textOf(node).toLowerCase();
return lower === 'credits remaining' || (lower.includes('credits') && lower.includes('remaining'));
});
if (!labelMatch) return null;
let cur = labelMatch;
for (let i = 0; i < 6 && cur; i++) {
const buttons = Array.from(cur.querySelectorAll(buttonSelector));
const picked = pickLikelyButton(buttons);
if (picked) return picked;
cur = cur.parentElement;
}
return null;
};
const findAndClick = () => {
const addMoreButton = findAddMoreButton();
if (addMoreButton) {
log('add_more_click', summarize(addMoreButton));
clickButton(addMoreButton);
return true;
}
const cardButton = findCreditsCardButton();
if (!cardButton) return false;
log('credits_card_click', summarize(cardButton));
return clickButton(cardButton);
};
const logDialogButtons = () => {
const dialog = document.querySelector('[role=\"dialog\"], dialog, [aria-modal=\"true\"]');
if (dialog) {
const buttons = Array.from(dialog.querySelectorAll(buttonSelector)).map(summarize).filter(Boolean);
if (buttons.length) {
log('dialog_buttons', { count: buttons.length, buttons: buttons.slice(0, 6) });
}
const nextButton = findDialogNextButton();
if (nextButton) {
log('dialog_next', summarize(nextButton));
setTimeout(() => clickNextIfReady(1), 100);
}
return;
}
const candidates = collectButtons()
.map(summarize)
.filter(Boolean)
.filter(entry => {
const label = (entry.label || '').toLowerCase();
return label.includes('next')
|| label.includes('continue')
|| label.includes('confirm')
|| label.includes('buy');
});
if (candidates.length) {
log('button_candidates', { count: candidates.length, buttons: candidates.slice(0, 8) });
}
};
log('auto_start', { href: location.href, ready: document.readyState });
const iframeSources = Array.from(document.querySelectorAll('iframe'))
.map(frame => frame.getAttribute('src') || '')
.filter(Boolean)
.slice(0, 6);
if (iframeSources.length) {
log('iframes', iframeSources);
}
const shadowHostCount = Array.from(document.querySelectorAll('*')).filter(el => el.shadowRoot).length;
if (shadowHostCount > 0) {
log('shadow_roots', { count: shadowHostCount });
}
if (findAndClick()) {
window.__codexbarAutoBuyCreditsStarted = true;
startNextPolling();
observeNextButton();
logDialogButtons();
return 'clicked';
}
startNextPolling(500);
observeNextButton();
logDialogButtons();
let attempts = 0;
const maxAttempts = 14;
const timer = setInterval(() => {
attempts += 1;
if (findAndClick()) {
logDialogButtons();
startNextPolling();
clearInterval(timer);
return;
}
if (attempts >= maxAttempts) {
clearInterval(timer);
}
}, 500);
window.__codexbarAutoBuyCreditsStarted = true;
return 'scheduled';
})();
"""
private let logger = CodexBarLog.logger(LogCategories.creditsPurchase)
private var webView: WKWebView?
private var accountEmail: String?
private var cacheScope: CookieHeaderCache.Scope?
private var pendingAutoStart = false
private let logHandler = WeakScriptMessageHandler()
init() {
super.init(window: nil)
self.logHandler.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show(
purchaseURL: URL,
accountEmail: String?,
cacheScope: CookieHeaderCache.Scope?,
autoStartPurchase: Bool)
{
guard Self.canOpenPurchaseWindow(accountEmail: accountEmail, cacheScope: cacheScope) else {
self.close()
self.accountEmail = nil
self.cacheScope = nil
self.logger.error("Buy credits blocked: scoped account email unavailable")
return
}
let normalizedEmail = Self.normalizeEmail(accountEmail)
if self.window == nil || normalizedEmail != self.accountEmail || cacheScope != self.cacheScope {
self.accountEmail = normalizedEmail
self.cacheScope = cacheScope
self.buildWindow()
}
Self.resetDebugLog()
let accountValue = normalizedEmail == nil ? "none" : "set"
let sanitizedURL = Self.sanitizedURLString(purchaseURL)
Self.appendDebugLog(
"show autoStart=\(autoStartPurchase) url=\(sanitizedURL) account=\(accountValue)")
self.logger.info("Buy credits window opened")
self.logger.debug("Auto-start purchase", metadata: ["enabled": autoStartPurchase ? "1" : "0"])
self.logger.debug("Purchase URL", metadata: ["url": sanitizedURL])
self.logger.debug("Account email", metadata: ["state": accountValue])
self.pendingAutoStart = autoStartPurchase
self.load(url: purchaseURL)
self.window?.center()
self.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)
}
private func buildWindow() {
let config = WKWebViewConfiguration()
config.userContentController.add(self.logHandler, name: Self.logHandlerName)
config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store(
forAccountEmail: self.accountEmail,
scope: self.cacheScope)
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
webView.allowsBackForwardNavigationGestures = true
webView.translatesAutoresizingMaskIntoConstraints = false
let container = NSView(frame: .zero)
container.addSubview(webView)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
webView.topAnchor.constraint(equalTo: container.topAnchor),
webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
let window = NSWindow(
contentRect: Self.defaultFrame(),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false)
window.title = "Buy Credits"
window.isReleasedWhenClosed = false
window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
window.contentView = container
window.center()
window.delegate = self
self.window = window
self.webView = webView
}
private func load(url: URL) {
guard let webView else { return }
let request = URLRequest(url: url)
webView.load(request)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard self.pendingAutoStart else { return }
self.pendingAutoStart = false
let currentURL = webView.url?.absoluteString ?? "unknown"
Self.appendDebugLog("didFinish url=\(currentURL)")
self.logger.debug("Buy credits navigation finished", metadata: ["url": currentURL])
webView.evaluateJavaScript(Self.autoStartScript) { [logger] result, error in
if let error {
Self.appendDebugLog("autoStart error=\(error.localizedDescription)")
logger.error("Auto-start purchase failed", metadata: ["error": error.localizedDescription])
return
}
if let result {
Self.appendDebugLog("autoStart result=\(String(describing: result))")
logger.debug("Auto-start purchase result", metadata: ["result": String(describing: result)])
}
}
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == Self.logHandlerName else { return }
let payload = String(describing: message.body)
Self.appendDebugLog("js \(payload)")
self.logger.debug("Auto-buy log", metadata: ["payload": payload])
}
private static func normalizeEmail(_ email: String?) -> String? {
guard let raw = email?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
return raw.lowercased()
}
static func canOpenPurchaseWindow(accountEmail: String?, cacheScope: CookieHeaderCache.Scope?) -> Bool {
cacheScope == nil || self.normalizeEmail(accountEmail) != nil
}
private static func defaultFrame() -> NSRect {
let visible = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900)
let width = min(Self.defaultSize.width, visible.width * 0.92)
let height = min(Self.defaultSize.height, visible.height * 0.88)
let origin = NSPoint(x: visible.midX - width / 2, y: visible.midY - height / 2)
return NSRect(origin: origin, size: NSSize(width: width, height: height))
}
private static func appendDebugLog(_ message: String) {
let timestamp = ISO8601DateFormatter().string(from: Date())
let line = "[\(timestamp)] \(LogRedactor.redact(message))\n"
guard let data = line.data(using: .utf8) else { return }
if FileManager.default.fileExists(atPath: Self.debugLogURL.path) {
if let handle = try? FileHandle(forWritingTo: Self.debugLogURL) {
handle.seekToEndOfFile()
handle.write(data)
try? handle.close()
}
} else {
try? data.write(to: Self.debugLogURL, options: .atomic)
}
}
private static func resetDebugLog() {
try? FileManager.default.removeItem(at: self.debugLogURL)
}
private static func sanitizedURLString(_ url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url.absoluteString
}
components.query = nil
components.fragment = nil
return components.string ?? url.absoluteString
}
}
private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
weak var delegate: WKScriptMessageHandler?
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
self.delegate?.userContentController(userContentController, didReceive: message)
}
}
// MARK: - NSWindowDelegate
extension OpenAICreditsPurchaseWindowController: NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
guard let window = self.window else { return }
let webView = self.webView
self.pendingAutoStart = false
self.webView = nil
self.window = nil
self.logger.info("Buy credits window closing")
WebKitTeardown.scheduleCleanup(owner: window, window: window, webView: webView)
}
}