fix(web): pre-open the sign-in popup in the click gesture and use popup wording

Opening the verification page from the async poll callback was popup-
blocked (the 'click sign-in twice' bug); a placeholder window opened
synchronously inside the press is navigated once the URL arrives, and
closed on cancel/failure. The wasm login modal now says 'popup window'
instead of 'your browser' — the web editor already runs in one.
This commit is contained in:
Kayshen-X 2026-07-25 23:51:27 +08:00
parent 7d3b66e07b
commit 6a780c7425
8 changed files with 136 additions and 30 deletions

View file

@ -38,10 +38,10 @@ jobs:
submodules: recursive
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- name: Enable Nix cache
uses: DeterminateSystems/magic-nix-cache-action@main
uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14
- name: Evaluate flake checks
run: nix flake check --no-build --show-trace

View file

@ -85,6 +85,7 @@ on:
permissions:
contents: write
packages: write
pull-requests: write
jobs:
version:
@ -842,6 +843,8 @@ jobs:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: dist
@ -967,9 +970,8 @@ jobs:
name: ${{ steps.body.outputs.title }}
body_path: release-body.md
files: release-files/*
- name: Update latest published Nix release manifest
env:
GH_TOKEN: ${{ github.token }}
- name: Prepare latest published Nix release manifest
id: nix-manifest
shell: bash
run: |
set -euo pipefail
@ -983,18 +985,43 @@ jobs:
--arg cliHash "$(sri_hash op-cli-linux-x86_64.tar.gz)" \
'{version: $version, desktopHash: $desktopHash, cliHash: $cliHash}')"
git fetch origin main
git switch --create update-release-manifest origin/main
printf '%s\n' "$manifest" > nix/release-manifest.json
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git add nix/release-manifest.json
if git diff --cached --quiet; then
echo "release manifest already current"
exit 0
# Tags do not retain their source branch. Prefer the matching release
# branch when it contains the tagged commit, then fall back to the
# repository's default branch.
release_branch="v${version}"
default_branch="${{ github.event.repository.default_branch }}"
git fetch --prune origin \
"+refs/heads/*:refs/remotes/origin/*"
if git show-ref --verify --quiet "refs/remotes/origin/${release_branch}" \
&& git merge-base --is-ancestor "$GITHUB_SHA" "origin/${release_branch}"; then
target_branch="$release_branch"
elif git merge-base --is-ancestor "$GITHUB_SHA" "origin/${default_branch}"; then
target_branch="$default_branch"
else
echo "::error::tagged commit is not contained in ${release_branch} or ${default_branch}"
exit 1
fi
git commit -m "chore(release): update Nix release manifest for v${version}"
git push origin HEAD:main
git switch --force-create "$target_branch" "origin/${target_branch}"
printf '%s\n' "$manifest" > nix/release-manifest.json
echo "target-branch=$target_branch" >> "$GITHUB_OUTPUT"
# The release branches are protected, so publish the generated hash
# update through a reviewable PR instead of trying to push as the
# GitHub Actions bot. Re-running a release updates the same PR branch.
- name: Open Nix release manifest update
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
base: ${{ steps.nix-manifest.outputs.target-branch }}
branch: automation/nix-release-manifest-v${{ env.OP_VERSION }}
delete-branch: true
add-paths: nix/release-manifest.json
commit-message: "chore(release): update Nix release manifest for v${{ env.OP_VERSION }}"
title: "chore(release): update Nix release manifest for v${{ env.OP_VERSION }}"
body: |
Updates the pinned Nix prebuilt release metadata after publishing v${{ env.OP_VERSION }}.
This PR was generated by the Rust release workflow.
package-managers:
name: Update Homebrew tap and Scoop bucket

View file

@ -165,9 +165,11 @@ nix build .#appimage # portable desktop AppImage
The flake uses the pinned Rust toolchain from `rust-toolchain.toml` and is
currently published for `x86_64-linux`. A Debian package is not produced by
the flake yet; use the upstream release artifacts when a `.deb` is required.
The `prebuilt` outputs require the upstream GitHub release for the workspace
version to publish matching Linux archives; otherwise use the source-built
outputs above.
The `prebuilt` outputs use the release version and hashes pinned in
`nix/release-manifest.json`, independently of the workspace source version.
After a release is published, the release workflow opens a PR to refresh that
manifest. Until it is merged, the prebuilt outputs continue to use the prior
published release; the source-built outputs always build the checked-out source.
**CLI (`op`):**

View file

@ -310,18 +310,25 @@ fn paint_status_note(
) {
use op_editor_core::{LoginFlowError, LoginFlowStatus};
let status = status_rect(panel);
// The web build runs IN a browser: the approval happens in a popup
// window, so "waiting for your browser" would read as nonsense there.
let (waiting_key, approval_key) = if cfg!(target_arch = "wasm32") {
("account.openingPopup", "account.approveInPopup")
} else {
("account.waitingForBrowser", "account.waitingForApproval")
};
let (icon, text, background, border, color) = if let Some(flow) = flow_status {
match flow {
LoginFlowStatus::WaitingBrowser => (
Icon::Globe,
t(locale, "account.waitingForBrowser"),
t(locale, waiting_key),
theme.row_selected_primary,
theme.primary.with_alpha(0.32),
theme.foreground,
),
LoginFlowStatus::WaitingApproval => (
Icon::Globe,
t(locale, "account.waitingForApproval"),
t(locale, approval_key),
theme.row_selected_primary,
theme.primary.with_alpha(0.32),
theme.foreground,

View file

@ -132,7 +132,11 @@ fn drain_actions<C: RepaintContext + 'static>(
}
})),
);
if action == PendingAuthAction::CancelLogin {
close_login_popup_placeholder();
}
if !ok && action == PendingAuthAction::BeginLogin {
close_login_popup_placeholder();
let mut b = inner.borrow_mut();
let ui = &mut b.host_mut().editor_state_mut().editor_ui;
ui.login_modal_status = Some(LoginFlowStatus::Failed(LoginFlowError::Unavailable));
@ -194,7 +198,7 @@ fn apply_login_status<C: RepaintContext + 'static>(
if let Some(url) = parsed["verification_uri"].as_str() {
if !url.is_empty() {
browser_opened.set(true);
open_popup(url);
navigate_login_popup(url);
}
}
}
@ -217,6 +221,7 @@ fn apply_login_status<C: RepaintContext + 'static>(
ui.login_modal_hover = None;
}
"error" => {
close_login_popup_placeholder();
ui.login_modal_status = Some(LoginFlowStatus::Failed(
match parsed["code"].as_str().unwrap_or_default() {
"denied" => LoginFlowError::Denied,
@ -227,7 +232,10 @@ fn apply_login_status<C: RepaintContext + 'static>(
}
// "idle" / "canceled": another tab or the daemon finished the
// flow out from under us — reset the note, keep the modal.
_ => ui.login_modal_status = None,
_ => {
close_login_popup_placeholder();
ui.login_modal_status = None;
}
}
if ui.login_modal_status != previous || !ui.login_modal_open {
b.host_mut().mark_editor_state_dirty();
@ -235,12 +243,53 @@ fn apply_login_status<C: RepaintContext + 'static>(
}
}
/// Open the verification page. A blocked popup falls back to a full
/// navigation in a new tab via an anchor click, and if even that is
/// unavailable the user can still see the URL-less waiting note (the
/// approval also works from any logged-in browser tab).
fn open_popup(url: &str) {
if let Some(window) = web_sys::window() {
let _ = window.open_with_url_and_target(url, "_blank");
thread_local! {
/// Placeholder popup opened synchronously inside the SignIn click
/// (user-activation context — the only moment `window.open` is
/// reliably allowed). The poll callback later navigates it to the
/// verification page; opening from the async callback instead gets
/// popup-blocked, which is exactly the "have to click sign-in
/// twice" failure this design removes.
static PENDING_POPUP: RefCell<Option<web_sys::Window>> = const { RefCell::new(None) };
}
/// Open the placeholder popup. Must be called synchronously from a
/// pointer-event handler (the web sign-in press dispatcher).
pub(crate) fn open_login_popup_placeholder() {
let opened = web_sys::window()
.and_then(|window| {
window
.open_with_url_and_target("about:blank", "_blank")
.ok()
})
.flatten();
PENDING_POPUP.with(|slot| *slot.borrow_mut() = opened);
}
/// Close a still-pending placeholder (flow canceled or failed before
/// the verification page was known).
pub(crate) fn close_login_popup_placeholder() {
PENDING_POPUP.with(|slot| {
if let Some(popup) = slot.borrow_mut().take() {
let _ = popup.close();
}
});
}
/// Point the placeholder at the verification page; when the placeholder
/// is missing (blocked, or an older flow) fall back to a direct open —
/// it may be blocked outside a gesture, but the approval also works
/// from any logged-in browser tab, so the flow still completes.
fn navigate_login_popup(url: &str) {
let pending = PENDING_POPUP.with(|slot| slot.borrow_mut().take());
match pending {
Some(popup) => {
let _ = popup.location().set_href(url);
}
None => {
if let Some(window) = web_sys::window() {
let _ = window.open_with_url_and_target(url, "_blank");
}
}
}
}

View file

@ -43,6 +43,23 @@ impl WidgetHost {
self.editor_state.editor_ui.login_modal_stub_hint_shown = false;
}
LoginModalHit::SignIn => {
// A flow is already running — don't spawn another
// placeholder popup or re-begin; the poll owns the modal.
if matches!(
self.editor_state.editor_ui.login_modal_status,
Some(
LoginFlowStatus::WaitingBrowser
| LoginFlowStatus::WaitingApproval
| LoginFlowStatus::Exchanging
)
) {
self.mark_dirty();
return;
}
// Open the placeholder popup NOW, inside the click's
// user-activation window — an async `window.open` later
// would be popup-blocked (the "click twice" bug).
crate::web_auth_sync::open_login_popup_placeholder();
// Optimistic status so the modal reacts on this frame;
// the daemon poll refines it (or reports the failure).
self.editor_state.editor_ui.login_modal_status =

View file

@ -587,6 +587,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
"account.signInComingSoon" => "Browser sign-in is coming soon.",
"account.waitingForBrowser" => "Waiting for your browser…",
"account.waitingForApproval" => "Approve the sign-in in your browser…",
"account.openingPopup" => "Opening the sign-in window…",
"account.approveInPopup" => "Finish signing in in the popup window…",
"account.signingIn" => "Signing you in…",
"account.signInDenied" => "Sign-in was declined in the browser.",
"account.signInExpired" => "The sign-in request expired. Try again.",

View file

@ -591,6 +591,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
"account.signInComingSoon" => "浏览器登录即将上线。",
"account.waitingForBrowser" => "正在等待浏览器…",
"account.waitingForApproval" => "请在浏览器中确认登录…",
"account.openingPopup" => "正在打开登录窗口…",
"account.approveInPopup" => "请在弹出的窗口中完成登录确认…",
"account.signingIn" => "正在登录…",
"account.signInDenied" => "登录请求在浏览器中被拒绝。",
"account.signInExpired" => "登录请求已过期,请重试。",