549 lines
21 KiB
Swift
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)
|
|
}
|
|
}
|