Repository navigation
Fix Naver/Daum login not syncing back to the in-app WebView #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
337dced
Fix Naver/Daum login not syncing back to the in-app WebView
DusanBaek 6882b37
Install Tuist with mise instead of the Homebrew tap
devxoul 9a1204c
Add a routing policy for popups opened by dictionary pages
devxoul c307b4a
Give the login popup its own window and delegates
devxoul a3f8d99
Scope the app-wide key monitor to the popover
devxoul File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)") } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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?) { | ||
|
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() | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3:
needsSessionReloadis set unconditionally in the popup'sonClose, regardless of whether the user actually logged in. Because the.transientpopover auto-closes the moment the login window takes focus, this then triggers a fullwebView.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
There was a problem hiding this comment.
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:
NID_AUT/NID_SES,_kawlt) — undocumented, provider-private, and free to change without notice.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.