Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

# Installs the Tuist version pinned in mise.toml, keeping CI and local dev
# in sync. Not Homebrew: the tuist tap's cask declares `conflicts_with
# formula:`, a key Homebrew 6 dropped, which aborts any `tuist` lookup in
# the tap. See tuist/homebrew-tuist#41.
- name: Install Tuist
run: |
brew tap tuist/tuist
brew install --formula tuist
uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2

- name: Lint
run: swift run --package-path BuildTools swiftformat . --lint
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,9 @@ jobs:
exit 1
fi

# Same pinned-Tuist-via-mise setup as ci.yml; see the note there.
- name: Install Tuist
run: |
brew tap tuist/tuist
brew install --formula tuist
uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2

- name: Set up Ruby and fastlane
uses: ruby/setup-ruby@v1
Expand Down
23 changes: 20 additions & 3 deletions Allkdic/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,26 +51,35 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.popover.contentViewController = NSHostingController(rootView: contentView)
self.popover.contentSize = NSSize(width: 420, height: 580)
self.popover.behavior = .transient
self.popover.delegate = self
}

private func setupEventMonitor() {
self.eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseUp, .leftMouseDown]) { [weak self] _ in
self?.closePopover()
}
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, self.isPopoverEvent(event) else { return event }
if event.keyCode == 53 { // Escape key
self?.closePopover()
self.closePopover()
return nil
}
if event.modifierFlags.contains(.command), event.charactersIgnoringModifiers == "," {
self?.closePopover()
self?.openPreferencesWindow()
self.closePopover()
self.openPreferencesWindow()
return nil
}
return event
}
}

/// This monitor is app-wide and never removed, so without a window check it
/// would also swallow keys aimed at the login, preferences and about windows.
func isPopoverEvent(_ event: NSEvent) -> Bool {
guard self.popover.isShown, let popoverWindow = popover.contentViewController?.view.window else { return false }
return event.window === popoverWindow
}

@objc func togglePopover() {
if self.popover.isShown {
self.closePopover()
Expand Down Expand Up @@ -132,3 +141,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
AnalyticsHelper.shared.trackAboutOpened()
}
}

extension AppDelegate: NSPopoverDelegate {
/// A `.transient` popover also closes on its own, e.g. when the login window
/// takes focus, which bypasses `closePopover()` and leaves the button lit.
func popoverDidClose(_: Notification) {
self.statusItem.button?.state = .off
}
}
67 changes: 67 additions & 0 deletions Allkdic/Types/PopupRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import WebKit

enum PopupDisposition: Equatable {
/// Opened inside the app so the session lands in the same cookie store as the
/// dictionary web view.
case inAppLogin
/// Handed to the default browser: full-site pages are unusable at popover size.
case externalBrowser
case deny
}

/// The parts of a `WKNavigationAction` that popup routing depends on.
///
/// Modelled as plain values because `WKNavigationAction` and `WKWindowFeatures`
/// are framework-produced and cannot be constructed in tests.
struct PopupRequest: Equatable {
let url: URL?
let sourceURL: URL?
let navigationType: WKNavigationType

/// Exact hosts, never suffix matches: `*.naver.com` or `*.daum.net` would
/// gradually promote unrelated content and ad popups into in-app windows that
/// share the logged-in cookie store.
private static let authenticationHosts: Set<String> = [
"nid.naver.com",
"logins.daum.net",
"accounts.kakao.com",
]

private static let dictionaryHosts: Set<String> = [
"dict.naver.com",
"dic.daum.net",
]

var disposition: PopupDisposition {
if self.isBlankBootstrap {
let opensLoginWindow = self.navigationType == .other && self.isFromDictionary
return opensLoginWindow ? .inAppLogin : .deny
}

// The scheme is checked before the host so that a host we trust cannot pull
// an unsupported scheme, such as file://nid.naver.com/, into a window that
// shares logged-in cookies.
guard let url = self.url, let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return .deny
}

if let host = url.host?.lowercased(), Self.authenticationHosts.contains(host) {
return .inAppLogin
}

return .externalBrowser
}

/// `window.open("", name, features)` opens an empty window and navigates it
/// afterwards, so the login URL isn't known yet when the window is requested.
/// Only a genuinely blank URL qualifies; other `about:` URLs are not.
private var isBlankBootstrap: Bool {
guard let url = self.url else { return true }
return url.absoluteString.lowercased() == "about:blank"
}

private var isFromDictionary: Bool {
guard let host = self.sourceURL?.host?.lowercased() else { return false }
return Self.dictionaryHosts.contains(host) || Self.dictionaryHosts.contains { host.hasSuffix(".\($0)") }
}
}
57 changes: 52 additions & 5 deletions Allkdic/Views/DictionaryWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ private struct WebView: NSViewRepresentable {
var lastLoadedURL: String?
weak var webView: WKWebView?
private nonisolated(unsafe) var popoverObserver: NSObjectProtocol?
private var loginPopupController: LoginPopupController?
private var needsSessionReload = false

init(isLoading: Binding<Bool>, dictionary: DictionaryType) {
_isLoading = isLoading
Expand Down Expand Up @@ -91,7 +93,18 @@ private struct WebView: NSViewRepresentable {
}

private func reloadIfNeeded() {
guard self.lastLoadedURL == nil, let url = URL(string: self.dictionary.URLString) else { return }
guard self.lastLoadedURL != nil else {
self.loadDictionary()
return
}
guard self.needsSessionReload else { return }
self.isLoading = true
self.webView?.reload()
}

private func loadDictionary() {
guard let url = URL(string: self.dictionary.URLString) else { return }
self.lastLoadedURL = self.dictionary.URLString
self.isLoading = true
self.webView?.load(URLRequest(url: url))
}
Expand All @@ -108,6 +121,9 @@ private struct WebView: NSViewRepresentable {

func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
self.isLoading = false
// Cleared only once a load actually lands, so a reload that fails while
// offline is retried on the next popover open instead of being lost.
self.needsSessionReload = false
if let css = dictionary.customCSS {
let script = """
var style = document.createElement('style');
Expand All @@ -133,14 +149,45 @@ private struct WebView: NSViewRepresentable {

func webView(
_: WKWebView,
createWebViewWith _: WKWebViewConfiguration,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures _: WKWindowFeatures,
) -> WKWebView? {
if let url = navigationAction.request.url {
NSWorkspace.shared.open(url)
let request = PopupRequest(
url: navigationAction.request.url,
sourceURL: navigationAction.sourceFrame.request.url,
navigationType: navigationAction.navigationType,
)

switch request.disposition {
case .inAppLogin:
return self.presentLoginPopup(configuration: configuration)

case .externalBrowser:
if let url = request.url {
NSWorkspace.shared.open(url)
}
return nil

case .deny:
return nil
}
return nil
}

/// Reuses the configuration WebKit passes in, which is what ties the popup
/// to the dictionary web view's cookie store and makes the login stick.
private func presentLoginPopup(configuration: WKWebViewConfiguration) -> WKWebView? {
self.loginPopupController?.close()

let controller = LoginPopupController(configuration: configuration, title: self.dictionary.title)
controller.onClose = { [weak self] in
self?.loginPopupController = nil
self?.needsSessionReload = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: needsSessionReload is set unconditionally in the popup's onClose, regardless of whether the user actually logged in. Because the .transient popover auto-closes the moment the login window takes focus, this then triggers a full webView.reload() on the next popover open for every login-popup interaction — including a user who opens the login window and immediately closes it without authenticating. That reload discards the dictionary page's scroll/search state needlessly. Consider only scheduling the reload when a login actually completed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Allkdic/Views/DictionaryWebView.swift, line 184:

<comment>`needsSessionReload` is set unconditionally in the popup's `onClose`, regardless of whether the user actually logged in. Because the `.transient` popover auto-closes the moment the login window takes focus, this then triggers a full `webView.reload()` on the next popover open for every login-popup interaction — including a user who opens the login window and immediately closes it without authenticating. That reload discards the dictionary page's scroll/search state needlessly. Consider only scheduling the reload when a login actually completed.</comment>

<file context>
@@ -133,14 +148,45 @@ private struct WebView: NSViewRepresentable {
+      let controller = LoginPopupController(configuration: configuration, title: self.dictionary.title)
+      controller.onClose = { [weak self] in
+        self?.loginPopupController = nil
+        self?.needsSessionReload = true
+      }
+      controller.show()
</file context>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one, deliberately — the alternatives are less reliable than the cost they save.

To make the reload conditional we would need to detect that a login actually succeeded, and every available signal is guesswork:

  • Cookie diffing — opening the login page mutates CSRF/tracking/session-temp cookies even when the user cancels, so this produces false positives.
  • Known session cookie names (NID_AUT/NID_SES, _kawlt) — undocumented, provider-private, and free to change without notice.
  • Closed via window.close() vs. closed by the user — the closest thing to a real signal, but flows that redirect the opener instead of self-closing, or that self-close on failure, break it in both directions.

The failure modes are not symmetric. A needless reload costs the user their scroll position and current search. A missed reload silently leaves the app logged out — which is precisely the bug this PR exists to fix, and it would fail in exactly the hard-to-reproduce way that made the original report tricky.

Worth noting the reload is already deferred to the next popover open rather than firing immediately, so it does not interrupt anything mid-interaction, and the popup only ever appears because the user deliberately clicked a login button.

Happy to revisit if the cancel-then-reopen path turns out to be common in practice, but I would rather not trade a correctness guarantee for a state-preservation nicety on a heuristic.

}
controller.show()
self.loginPopupController = controller

return controller.webView
}
}
}
Expand Down
136 changes: 136 additions & 0 deletions Allkdic/Views/LoginPopupController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Cocoa
import WebKit

/// The app has no menu bar (`LSUIElement`), so there is no Close item to route
/// Cmd+W here and no responder handling for Escape. Both are wired up manually.
private final class LoginPopupWindow: NSWindow {
override func cancelOperation(_: Any?) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
self.performClose(nil)
}

override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
event.charactersIgnoringModifiers == "w"
else {
return super.performKeyEquivalent(with: event)
}
self.performClose(nil)
return true
}
}

/// A login window opened by a dictionary page via `window.open`.
///
/// It deliberately does not reuse the dictionary's `Coordinator` as its delegate:
/// those callbacks drive the main view's loading indicator, custom CSS and input
/// focusing, none of which may be triggered by popup navigations.
@MainActor
final class LoginPopupController: NSObject {
let webView: WKWebView
var onClose: (() -> Void)?

private let window: NSWindow
private var hasFinished = false

init(configuration: WKWebViewConfiguration, title: String) {
self.webView = WKWebView(
frame: NSRect(x: 0, y: 0, width: 480, height: 640),
configuration: configuration,
)
self.window = LoginPopupWindow(
contentRect: self.webView.frame,
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false,
)
super.init()

self.webView.uiDelegate = self
self.webView.navigationDelegate = self

// Under ARC the controller owns the window; the AppKit-era auto-release
// would over-release it.
self.window.isReleasedWhenClosed = false
self.window.title = title
self.window.contentView = self.webView
self.window.delegate = self
self.window.center()
}

func show() {
// Plain activate(): `ignoringOtherApps` makes Stage Manager treat this as a
// scene switch and slide other apps' windows aside (see 0746a14).
NSApp.activate()
self.window.makeKeyAndOrderFront(nil)
}

func close() {
self.window.close()
}

/// Both `webViewDidClose` and `windowWillClose` can fire for a single close.
private func finish() {
guard !self.hasFinished else { return }
self.hasFinished = true

self.webView.uiDelegate = nil
self.webView.navigationDelegate = nil
self.window.delegate = nil

let onClose = self.onClose
self.onClose = nil
onClose?()
}
}

extension LoginPopupController: WKUIDelegate {
func webViewDidClose(_: WKWebView) {
self.close()
}

func webView(
_ webView: WKWebView,
createWebViewWith _: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures _: WKWindowFeatures,
) -> WKWebView? {
// Keep secondary flows ("find ID/password") in this window rather than
// stacking more windows on top.
guard navigationAction.targetFrame == nil else { return nil }
webView.load(navigationAction.request)
return nil
}
}

extension LoginPopupController: WKNavigationDelegate {
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void,
) {
guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() else {
decisionHandler(.cancel)
return
}

switch scheme {
case "http", "https", "about", "data", "blob":
decisionHandler(.allow)

// "Log in with the app" hands off to a native app WebKit cannot load. Only
// known schemes are forwarded, so a page cannot launch arbitrary apps.
case "naversearchapp", "kakaotalk":
decisionHandler(.cancel)
NSWorkspace.shared.open(url)

default:
decisionHandler(.cancel)
}
}
}

extension LoginPopupController: NSWindowDelegate {
func windowWillClose(_: Notification) {
self.finish()
}
}
Loading
Loading