5656 property string tailscaleIp: " "
5757 property string tailscaleStatus: " "
5858 property bool needsLogin: false
59+ property string authUrl: " "
5960 property int peerCount: 0
6061 property bool isRefreshing: false
6162 property string lastToggleAction: " "
@@ -162,9 +163,27 @@ Item {
162163 root .peerCount = 0 ;
163164 root ._realPeerList = [];
164165 root .exitNodeStatus = null ;
166+ // Capture the pending authentication URL exposed by the daemon.
167+ var newAuthUrl = data .AuthURL || " " ;
168+ root .authUrl = newAuthUrl;
169+ // If a login attempt is in progress and the daemon has surfaced a
170+ // URL that differs from the one cached at click-time, it means the
171+ // URL was regenerated (fresh) — open it immediately.
172+ if (root ._loginInProgress
173+ && ! root ._loginUrlOpened
174+ && newAuthUrl .length > 0
175+ && newAuthUrl !== root ._preLoginAuthUrl ) {
176+ root ._openAuthUrl (newAuthUrl);
177+ }
165178 } else if (root .tailscaleRunning && data .Self && data .Self .TailscaleIPs && data .Self .TailscaleIPs .length > 0 ) {
166179 root .tailscaleIp = filterIPv4 (data .Self .TailscaleIPs )[0 ] || data .Self .TailscaleIPs [0 ];
167180 root .tailscaleStatus = " Connected" ;
181+ root .authUrl = " " ;
182+ // Login flow settled — reset flags so the next attempt starts clean
183+ root ._loginInProgress = false ;
184+ root ._loginUrlOpened = false ;
185+ root ._preLoginAuthUrl = " " ;
186+ loginTimeoutTimer .stop ();
168187
169188 var peers = [];
170189 if (data .Peer ) {
@@ -202,13 +221,15 @@ Item {
202221 root .peerCount = 0 ;
203222 root ._realPeerList = [];
204223 root .exitNodeStatus = null ;
224+ root .authUrl = " " ;
205225 }
206226 } catch (e) {
207227 Logger .e (" Tailscale" , " Failed to parse status: " + e);
208228 root .tailscaleRunning = false ;
209229 root .needsLogin = false ;
210230 root .tailscaleStatus = " Error" ;
211231 root ._realPeerList = [];
232+ root .authUrl = " " ;
212233 }
213234 } else {
214235 root .tailscaleRunning = false ;
@@ -217,6 +238,7 @@ Item {
217238 root .tailscaleIp = " " ;
218239 root .peerCount = 0 ;
219240 root ._realPeerList = [];
241+ root .authUrl = " " ;
220242 }
221243 }
222244 }
@@ -246,7 +268,61 @@ Item {
246268
247269 property string lastExitNodeAction: " "
248270
271+ // ─── Login flow state ────────────────────────────────────────────────────
272+ // A login attempt is tracked across two asynchronous sources that may deliver
273+ // a fresh AuthURL: (1) stdout/stderr of `tailscale up` and (2) the periodic
274+ // `tailscale status --json` poll. Whichever arrives first wins; the other is
275+ // deduped via `_loginUrlOpened`.
249276 property bool _loginUrlOpened: false
277+ property bool _loginInProgress: false
278+ // Snapshot of authUrl taken at click-time. Used to detect when the daemon
279+ // has regenerated the URL so we never open a stale/expired one.
280+ property string _preLoginAuthUrl: " "
281+
282+ /**
283+ * Open an authentication URL in the default browser, with anti-double-open
284+ * protection. Resets login-flow state.
285+ *
286+ * @param {string} url Authentication URL to open
287+ */
288+ function _openAuthUrl (url ) {
289+ if (root ._loginUrlOpened ) return ;
290+ if (! url || url .length === 0 ) return ;
291+ root ._loginUrlOpened = true ;
292+ root ._loginInProgress = false ;
293+ loginTimeoutTimer .stop ();
294+ Logger .d (" Tailscale" , " Opening auth URL: " + url);
295+ Qt .openUrlExternally (url);
296+ ToastService .showNotice (
297+ pluginApi? .tr (" toast.title" ),
298+ pluginApi? .tr (" toast.login-browser-opened" ),
299+ " external-link"
300+ );
301+ }
302+
303+ // Safety net: if no fresh AuthURL surfaces within 10s after a login click,
304+ // either open whatever is cached (best effort) or show an error toast.
305+ Timer {
306+ id: loginTimeoutTimer
307+ interval: 10000
308+ repeat: false
309+ onTriggered: {
310+ if (! root ._loginInProgress || root ._loginUrlOpened ) return ;
311+ root ._loginInProgress = false ;
312+ Logger .w (" Tailscale" , " Login timeout: no fresh AuthURL received within 10s" );
313+ if (root .authUrl && root .authUrl .length > 0 ) {
314+ // Last resort: open the cached URL (may be the stale one)
315+ Logger .w (" Tailscale" , " Falling back to cached (possibly stale) AuthURL" );
316+ root ._openAuthUrl (root .authUrl );
317+ } else {
318+ ToastService .showError (
319+ pluginApi? .tr (" toast.title" ),
320+ pluginApi? .tr (" toast.login-failed" ),
321+ " alert-circle"
322+ );
323+ }
324+ }
325+ }
250326
251327 Process {
252328 id: loginProcess
@@ -257,13 +333,7 @@ Item {
257333 Logger .d (" Tailscale" , " Login output: " + line);
258334 var urlMatch = line .match (/ https? :\/\/ \S + / );
259335 if (urlMatch) {
260- root ._loginUrlOpened = true ;
261- Qt .openUrlExternally (urlMatch[0 ]);
262- ToastService .showNotice (
263- pluginApi? .tr (" toast.title" ),
264- pluginApi? .tr (" toast.login-browser-opened" ),
265- " external-link"
266- );
336+ root ._openAuthUrl (urlMatch[0 ]);
267337 }
268338 }
269339
@@ -278,14 +348,11 @@ Item {
278348 onExited : function (exitCode , exitStatus ) {
279349 Logger .d (" Tailscale" , " Login exited (code " + exitCode + " ), urlOpened=" + root ._loginUrlOpened );
280350 if (exitCode !== 0 && ! root ._loginUrlOpened ) {
281- ToastService .showError (
282- pluginApi? .tr (" toast.title" ),
283- pluginApi? .tr (" toast.login-failed" ),
284- " alert-circle"
285- );
286- Logger .e (" Tailscale" , " Login failed (exit " + exitCode + " )" );
351+ Logger .e (" Tailscale" , " tailscale up failed (exit " + exitCode + " ), waiting on status poll for fresh AuthURL" );
287352 }
288- root ._loginUrlOpened = false ;
353+ // Do NOT reset _loginUrlOpened here — the status poll may still deliver
354+ // the fresh URL after process exit. The flag is reset by _openAuthUrl
355+ // and by the login-state transitions in statusProcess.
289356 statusDelayTimer .start ();
290357 }
291358 }
@@ -535,16 +602,46 @@ Item {
535602 toggleProcess .running = true ;
536603 }
537604
605+ /**
606+ * Trigger the Tailscale authentication flow.
607+ *
608+ * @description
609+ * Always runs `tailscale up --force-reauth` to force the daemon to generate
610+ * a fresh AuthURL (any cached one may be expired). The fresh URL is then
611+ * opened from whichever channel delivers it first:
612+ * - stdout/stderr of `tailscale up` (parsed by loginProcess)
613+ * - the next `tailscale status --json` poll (via statusProcess)
614+ * Deduped through `_openAuthUrl` / `_loginUrlOpened`.
615+ *
616+ * Guard: no-op when not in NeedsLogin state to avoid disconnecting an
617+ * already-authenticated session.
618+ */
538619 function loginTailscale () {
539620 if (! root .tailscaleInstalled )
540621 return ;
622+ if (! root .needsLogin ) {
623+ Logger .w (" Tailscale" , " Login requested but backend is not in NeedsLogin state — ignoring" );
624+ return ;
625+ }
626+
627+ // Start a fresh login attempt
628+ root ._loginInProgress = true ;
629+ root ._loginUrlOpened = false ;
630+ root ._preLoginAuthUrl = root .authUrl ;
631+
632+ // --force-reauth forces the daemon to regenerate the AuthURL even if a
633+ // (possibly expired) one is still cached in its session state.
541634 var loginServer = pluginApi? .pluginSettings ? .loginServer || " " ;
542- var cmd = [" tailscale" , " up" , " --accept-routes" ];
635+ var cmd = [" tailscale" , " up" , " --accept-routes" , " --force-reauth " ];
543636 if (loginServer .trim () !== " " ) {
544637 cmd .push (" --login-server=" + loginServer .trim ());
545638 }
546639 loginProcess .command = cmd;
547640 loginProcess .running = true ;
641+
642+ // Arm the safety-net timeout and kick an early status refresh
643+ loginTimeoutTimer .restart ();
644+ statusDelayTimer .start ();
548645 }
549646
550647 Timer {
0 commit comments