5555 property bool tailscaleRunning: false
5656 property string tailscaleIp: " "
5757 property string tailscaleStatus: " "
58+ property bool needsLogin: false
59+ property string authUrl: " "
5860 property int peerCount: 0
5961 property bool isRefreshing: false
6062 property string lastToggleAction: " "
@@ -160,10 +162,35 @@ Item {
160162 try {
161163 var data = JSON .parse (stdout);
162164 root .tailscaleRunning = data .BackendState === " Running" ;
165+ root .needsLogin = data .BackendState === " NeedsLogin" ;
163166
164- if (root .tailscaleRunning && data .Self && data .Self .TailscaleIPs && data .Self .TailscaleIPs .length > 0 ) {
167+ if (root .needsLogin ) {
168+ root .tailscaleIp = " " ;
169+ root .tailscaleStatus = " NeedsLogin" ;
170+ root .peerCount = 0 ;
171+ root ._realPeerList = [];
172+ root .exitNodeStatus = null ;
173+ // Capture the pending authentication URL exposed by the daemon.
174+ var newAuthUrl = data .AuthURL || " " ;
175+ root .authUrl = newAuthUrl;
176+ // If a login attempt is in progress and the daemon has surfaced a
177+ // URL that differs from the one cached at click-time, it means the
178+ // URL was regenerated (fresh) — open it immediately.
179+ if (root ._loginInProgress
180+ && ! root ._loginUrlOpened
181+ && newAuthUrl .length > 0
182+ && newAuthUrl !== root ._preLoginAuthUrl ) {
183+ root ._openAuthUrl (newAuthUrl);
184+ }
185+ } else if (root .tailscaleRunning && data .Self && data .Self .TailscaleIPs && data .Self .TailscaleIPs .length > 0 ) {
165186 root .tailscaleIp = filterIPv4 (data .Self .TailscaleIPs )[0 ] || data .Self .TailscaleIPs [0 ];
166187 root .tailscaleStatus = " Connected" ;
188+ root .authUrl = " " ;
189+ // Login flow settled — reset flags so the next attempt starts clean
190+ root ._loginInProgress = false ;
191+ root ._loginUrlOpened = false ;
192+ root ._preLoginAuthUrl = " " ;
193+ loginTimeoutTimer .stop ();
167194
168195 var peers = [];
169196 if (data .Peer ) {
@@ -201,19 +228,24 @@ Item {
201228 root .peerCount = 0 ;
202229 root ._realPeerList = [];
203230 root .exitNodeStatus = null ;
231+ root .authUrl = " " ;
204232 }
205233 } catch (e) {
206234 Logger .e (" Tailscale" , " Failed to parse status: " + e);
207235 root .tailscaleRunning = false ;
236+ root .needsLogin = false ;
208237 root .tailscaleStatus = " Error" ;
209238 root ._realPeerList = [];
239+ root .authUrl = " " ;
210240 }
211241 } else {
212242 root .tailscaleRunning = false ;
243+ root .needsLogin = false ;
213244 root .tailscaleStatus = " Disconnected" ;
214245 root .tailscaleIp = " " ;
215246 root .peerCount = 0 ;
216247 root ._realPeerList = [];
248+ root .authUrl = " " ;
217249 }
218250 }
219251 }
@@ -243,6 +275,95 @@ Item {
243275
244276 property string lastExitNodeAction: " "
245277
278+ // ─── Login flow state ────────────────────────────────────────────────────
279+ // A login attempt is tracked across two asynchronous sources that may deliver
280+ // a fresh AuthURL: (1) stdout/stderr of `tailscale up` and (2) the periodic
281+ // `tailscale status --json` poll. Whichever arrives first wins; the other is
282+ // deduped via `_loginUrlOpened`.
283+ property bool _loginUrlOpened: false
284+ property bool _loginInProgress: false
285+ // Snapshot of authUrl taken at click-time. Used to detect when the daemon
286+ // has regenerated the URL so we never open a stale/expired one.
287+ property string _preLoginAuthUrl: " "
288+
289+ /**
290+ * Open an authentication URL in the default browser, with anti-double-open
291+ * protection. Resets login-flow state.
292+ *
293+ * @param {string} url Authentication URL to open
294+ */
295+ function _openAuthUrl (url ) {
296+ if (root ._loginUrlOpened ) return ;
297+ if (! url || url .length === 0 ) return ;
298+ root ._loginUrlOpened = true ;
299+ root ._loginInProgress = false ;
300+ loginTimeoutTimer .stop ();
301+ Logger .d (" Tailscale" , " Opening auth URL: " + url);
302+ Qt .openUrlExternally (url);
303+ ToastService .showNotice (
304+ pluginApi? .tr (" toast.title" ),
305+ pluginApi? .tr (" toast.login-browser-opened" ),
306+ " external-link"
307+ );
308+ }
309+
310+ // Safety net: if no fresh AuthURL surfaces within 10s after a login click,
311+ // either open whatever is cached (best effort) or show an error toast.
312+ Timer {
313+ id: loginTimeoutTimer
314+ interval: 10000
315+ repeat: false
316+ onTriggered: {
317+ if (! root ._loginInProgress || root ._loginUrlOpened ) return ;
318+ root ._loginInProgress = false ;
319+ Logger .w (" Tailscale" , " Login timeout: no fresh AuthURL received within 10s" );
320+ if (root .authUrl && root .authUrl .length > 0 ) {
321+ // Last resort: open the cached URL (may be the stale one)
322+ Logger .w (" Tailscale" , " Falling back to cached (possibly stale) AuthURL" );
323+ root ._openAuthUrl (root .authUrl );
324+ } else {
325+ ToastService .showError (
326+ pluginApi? .tr (" toast.title" ),
327+ pluginApi? .tr (" toast.login-failed" ),
328+ " alert-circle"
329+ );
330+ }
331+ }
332+ }
333+
334+ Process {
335+ id: loginProcess
336+
337+ function _handleLine (data ) {
338+ if (root ._loginUrlOpened ) return ;
339+ var line = data .trim ();
340+ Logger .d (" Tailscale" , " Login output: " + line);
341+ var urlMatch = line .match (/ https? :\/\/ \S + / );
342+ if (urlMatch) {
343+ root ._openAuthUrl (urlMatch[0 ]);
344+ }
345+ }
346+
347+ stdout: SplitParser {
348+ onRead : data => loginProcess ._handleLine (data)
349+ }
350+
351+ stderr: SplitParser {
352+ onRead : data => loginProcess ._handleLine (data)
353+ }
354+
355+ onExited : function (exitCode , exitStatus ) {
356+ Logger .d (" Tailscale" , " Login exited (code " + exitCode + " ), urlOpened=" + root ._loginUrlOpened );
357+ if (exitCode !== 0 && ! root ._loginUrlOpened ) {
358+ Logger .e (" Tailscale" , " tailscale up failed (exit " + exitCode + " ), waiting on status poll for fresh AuthURL" );
359+ }
360+ // Do NOT reset _loginUrlOpened here — the status poll may still deliver
361+ // the fresh URL after process exit. The flag is reset by _openAuthUrl
362+ // and by the login-state transitions in statusProcess.
363+ statusDelayTimer .start ();
364+ }
365+ }
366+
246367 // ─── Taildrop state ──────────────────────────────────────────────────────
247368
248369 // Possible values: "idle", "receiving", "sending", "error"
@@ -462,6 +583,7 @@ Item {
462583 function updateTailscaleStatus () {
463584 if (! root .tailscaleInstalled ) {
464585 root .tailscaleRunning = false ;
586+ root .needsLogin = false ;
465587 root .tailscaleIp = " " ;
466588 root .tailscaleStatus = " Not installed" ;
467589 root .peerCount = 0 ;
@@ -487,6 +609,48 @@ Item {
487609 toggleProcess .running = true ;
488610 }
489611
612+ /**
613+ * Trigger the Tailscale authentication flow.
614+ *
615+ * @description
616+ * Always runs `tailscale up --force-reauth` to force the daemon to generate
617+ * a fresh AuthURL (any cached one may be expired). The fresh URL is then
618+ * opened from whichever channel delivers it first:
619+ * - stdout/stderr of `tailscale up` (parsed by loginProcess)
620+ * - the next `tailscale status --json` poll (via statusProcess)
621+ * Deduped through `_openAuthUrl` / `_loginUrlOpened`.
622+ *
623+ * Guard: no-op when not in NeedsLogin state to avoid disconnecting an
624+ * already-authenticated session.
625+ */
626+ function loginTailscale () {
627+ if (! root .tailscaleInstalled )
628+ return ;
629+ if (! root .needsLogin ) {
630+ Logger .w (" Tailscale" , " Login requested but backend is not in NeedsLogin state — ignoring" );
631+ return ;
632+ }
633+
634+ // Start a fresh login attempt
635+ root ._loginInProgress = true ;
636+ root ._loginUrlOpened = false ;
637+ root ._preLoginAuthUrl = root .authUrl ;
638+
639+ // --force-reauth forces the daemon to regenerate the AuthURL even if a
640+ // (possibly expired) one is still cached in its session state.
641+ var loginServer = pluginApi? .pluginSettings ? .loginServer || " " ;
642+ var cmd = [" tailscale" , " up" , " --accept-routes" , " --force-reauth" ];
643+ if (loginServer .trim () !== " " ) {
644+ cmd .push (" --login-server=" + loginServer .trim ());
645+ }
646+ loginProcess .command = cmd;
647+ loginProcess .running = true ;
648+
649+ // Arm the safety-net timeout and kick an early status refresh
650+ loginTimeoutTimer .restart ();
651+ statusDelayTimer .start ();
652+ }
653+
490654 Timer {
491655 id: updateTimer
492656 interval: refreshInterval
@@ -526,14 +690,19 @@ Item {
526690 " running" : root .tailscaleRunning ,
527691 " ip" : root .tailscaleIp ,
528692 " status" : root .tailscaleStatus ,
529- " peers" : root .peerCount
693+ " peers" : root .peerCount ,
694+ " needsLogin" : root .needsLogin
530695 };
531696 }
532697
533698 function refresh () {
534699 updateTailscaleStatus ();
535700 }
536701
702+ function login () {
703+ loginTailscale ();
704+ }
705+
537706 // Taildrop IPC: qs ipc call plugin:tailscale receive
538707 function receive () {
539708 startTaildropReceive ();
0 commit comments