33Two copies share one state directory, one TUN and one resolver, so a second
44copy that quits runs the first one's teardown and cuts its tunnel. Each extra
55launch also asked for the admin password again, then sat hidden in the tray.
6+
7+ The handover used to be blind: any build answering on the socket got the
8+ window, so opening a freshly installed version while an older one was still
9+ running raised the *older* window and the new launch vanished. Settings then
10+ showed the old version number and nothing said why -- and with a login item
11+ pointing at the old copy, the new one could never get a turn to fix it. The
12+ listener therefore advertises which version it is, and a launch that finds a
13+ different one says so instead of disappearing.
614"""
715from __future__ import annotations
816
17+ import json
18+ import os
19+ import sys
20+ from dataclasses import dataclass
21+ from pathlib import Path
22+
923from PySide6 .QtCore import QObject , Signal
1024from PySide6 .QtNetwork import QLocalServer , QLocalSocket
1125
26+ from .. import __version__ , paths
27+
1228# One fixed name for every user: the elevated copy (root) listens and the
1329# user's unelevated launch knocks, and there is only one tunnel to own anyway.
1430NAME = "sushtun-single-instance"
1531_SHOW = b"show\n "
1632
1733
34+ # Written by the listener next to the rest of the state, read by every later
35+ # launch. A build older than this one writes none, which reads as "unknown".
36+ IDENTITY = "instance.json"
37+
38+
39+ @dataclass (frozen = True )
40+ class Instance :
41+ """The copy that is already running, as it advertised itself."""
42+
43+ version : str
44+ exe : str
45+ pid : int
46+
47+ @property
48+ def is_this_build (self ) -> bool :
49+ return self .version == __version__
50+
51+
52+ def identity_path () -> Path :
53+ return paths .state_dir () / IDENTITY
54+
55+
56+ def advertise () -> None :
57+ """Record who is listening. Best effort: failing to write it only costs
58+ a later launch the version in its message."""
59+ try :
60+ identity_path ().parent .mkdir (parents = True , exist_ok = True )
61+ identity_path ().write_text (json .dumps ({
62+ "version" : __version__ , "exe" : sys .executable , "pid" : os .getpid (),
63+ }), encoding = "utf-8" )
64+ except OSError :
65+ pass
66+
67+
68+ def withdraw () -> None :
69+ try :
70+ identity_path ().unlink (missing_ok = True )
71+ except OSError :
72+ pass
73+
74+
75+ def _alive (pid : int ) -> bool :
76+ if pid <= 0 :
77+ return False
78+ try :
79+ os .kill (pid , 0 )
80+ except ProcessLookupError :
81+ return False
82+ except PermissionError :
83+ return True # running as root, which is exactly what we expect
84+ except (OSError , AttributeError ):
85+ return True # Windows has no signal 0; assume the listener is real
86+ return True
87+
88+
89+ def running_instance () -> Instance | None :
90+ """The advertised identity of the copy that is running, or None when there
91+ is none to read -- a crashed leftover, or a build too old to write one."""
92+ try :
93+ data = json .loads (identity_path ().read_text (encoding = "utf-8" ))
94+ pid = int (data ["pid" ])
95+ version , exe = str (data ["version" ]), str (data .get ("exe" , "" ))
96+ except (OSError , ValueError , KeyError , TypeError ):
97+ return None
98+ return Instance (version , exe , pid ) if _alive (pid ) else None
99+
100+
18101def notify_running (show : bool = True , name : str = NAME ) -> bool :
19102 """True if a copy is already running; asks it to show its window unless
20103 show is False. Works before any QApplication exists."""
@@ -29,6 +112,47 @@ def notify_running(show: bool = True, name: str = NAME) -> bool:
29112 return True
30113
31114
115+ def hand_over (show : bool = True , name : str = NAME ) -> bool :
116+ """True when a copy is already running and this launch must stop.
117+
118+ Stopping silently is right only when the window that comes up is this same
119+ build. When it is a different one, nothing on screen would tell the user
120+ which version they are looking at, so say it.
121+ """
122+ if not notify_running (show = show , name = name ):
123+ return False
124+ other = running_instance ()
125+ # A login launch raises no window and must never pop a dialog behind one.
126+ if show and (other is None or not other .is_this_build ):
127+ _warn_other_build (other )
128+ return True
129+
130+
131+ def _warn_other_build (other : Instance | None ) -> None :
132+ from PySide6 .QtWidgets import QApplication , QMessageBox
133+
134+ from .. import i18n
135+ from ..core import settings as app_settings
136+ from ..i18n import ltr , tr
137+
138+ # Nothing has set the language yet: this runs before ui.app.run.
139+ try :
140+ i18n .set_language (app_settings .load ().get ("language" , "en" ))
141+ except Exception : # a broken settings file must not cost the message
142+ pass
143+ app = QApplication .instance () or QApplication ([])
144+ if other is None :
145+ text = tr ("You opened sushTun {this}, but another copy is already running — the "
146+ "window on screen is that one. Quit it first, then open this copy again." ,
147+ this = ltr (__version__ ))
148+ else :
149+ text = tr ("You opened sushTun {this}, but sushTun {running} is already running — the "
150+ "window on screen is that one. Quit it first, then open this copy again." ,
151+ this = ltr (__version__ ), running = ltr (other .version ))
152+ QMessageBox .information (None , "sushTun" , text )
153+ app .processEvents ()
154+
155+
32156class InstanceServer (QObject ):
33157 """Listens for later launches and emits show_requested for each one."""
34158
@@ -45,6 +169,9 @@ def __init__(self, name: str = NAME, parent: QObject | None = None) -> None:
45169 QLocalServer .removeServer (name )
46170 self ._server .listen (name )
47171 self ._server .newConnection .connect (self ._accept )
172+ if self ._server .isListening ():
173+ advertise ()
174+ self .destroyed .connect (lambda * _ : withdraw ())
48175
49176 def is_listening (self ) -> bool :
50177 return self ._server .isListening ()
0 commit comments