diff --git a/Pipfile b/Pipfile index 1897a50..33b8918 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ python-dateutil = "*" pyside6 = ">=6.8.0.2,<6.10" typing-extensions = "*" appdirs = "*" +keyring = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index 5187239..259f252 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "033b8d5d2d6197253c00bfe8d3f9f97319375aa5751096be5e7d1f563de6fc0f" + "sha256": "3587d2e7976a3a10e283dd93e6538e5ee94200b0c6857f2e52cc3b48bdf278ac" }, "pipfile-spec": 6, "requires": { @@ -218,6 +218,47 @@ "markers": "python_version >= '3.9'", "version": "==3.20" }, + "jaraco.classes": { + "hashes": [ + "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + ], + "markers": "python_version >= '3.8'", + "version": "==3.4.0" + }, + "jaraco.context": { + "hashes": [ + "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", + "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3" + ], + "markers": "python_version >= '3.10'", + "version": "==6.1.2" + }, + "jaraco.functools": { + "hashes": [ + "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", + "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30" + ], + "markers": "python_version >= '3.10'", + "version": "==4.6.0" + }, + "keyring": { + "hashes": [ + "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", + "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==25.7.0" + }, + "more-itertools": { + "hashes": [ + "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0", + "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63" + ], + "markers": "python_version >= '3.11'", + "version": "==11.2.1" + }, "pyside6": { "hashes": [ "sha256:33fb70addd02e1adaa45573485c431bca43cf12bb7b2596535b824ff169138ce", diff --git a/src/shortcircuit/app.py b/src/shortcircuit/app.py index 7fa37ca..950416e 100644 --- a/src/shortcircuit/app.py +++ b/src/shortcircuit/app.py @@ -395,6 +395,9 @@ def __init__(self, parent=None): self.esip.logout_response.connect(self.logout_handler) self.esip.location_response.connect(self.location_handler) self.esip.destination_response.connect(self.destination_handler) + # Resume the previous EVE session from the persisted refresh token, if + # any; fires login_response exactly like an interactive login. + self.esip.try_silent_login() # Start version check self.version_thread.start() diff --git a/src/shortcircuit/model/esi/esi.py b/src/shortcircuit/model/esi/esi.py index 89762a3..1532403 100644 --- a/src/shortcircuit/model/esi/esi.py +++ b/src/shortcircuit/model/esi/esi.py @@ -11,11 +11,78 @@ import webbrowser import requests +from appdirs import AppDirs + from shortcircuit.model.logger import Logger -from shortcircuit import USER_AGENT +from shortcircuit import USER_AGENT, __appslug__ from .server import AuthHandler, StoppableHTTPServer +try: + import keyring + import keyring.errors as keyring_errors +except Exception: # pragma: no cover - keyring is an optional backend + keyring = None + keyring_errors = None + +# CCP recommends discovering SSO endpoints from the OAuth metadata document +# rather than hardcoding them. When they retire or move a route (as with the +# 24 March 2026 spring cleaning that removed /verify and the implicit flow) a +# deployed client that follows the metadata keeps working without a rebuild. +# The hardcoded ESI.ENDPOINT_EVE_* values below are only used if the metadata +# fetch fails. +# https://developers.eveonline.com/docs/services/sso/ +ENDPOINT_EVE_METADATA = 'https://login.eveonline.com/.well-known/oauth-authorization-server' +_METADATA_CACHE_TTL_SECONDS = 3600 + +_metadata_cache_lock = threading.Lock() +_metadata_cache = {'fetched_at': 0.0, 'endpoints': None} + + +def discover_sso_endpoints(force_refresh=False): + """ + Fetch and cache the EVE SSO OAuth metadata document. + + Returns a dict with at least ``authorization_endpoint`` and + ``token_endpoint``. On network failure, returns the hardcoded fallback + values so login still works (at least until CCP retires those routes). + The cache is per-process, in-memory, and refreshes hourly. + """ + now = time.monotonic() + with _metadata_cache_lock: + cached = _metadata_cache['endpoints'] + if ( + not force_refresh and cached is not None + and now - _metadata_cache['fetched_at'] < _METADATA_CACHE_TTL_SECONDS + ): + return cached + + try: + r = requests.get( + ENDPOINT_EVE_METADATA, + headers={'User-Agent': USER_AGENT}, + timeout=10.0, + ) + r.raise_for_status() + data = r.json() + endpoints = { + 'authorization_endpoint': data['authorization_endpoint'], + 'token_endpoint': data['token_endpoint'], + } + except (requests.RequestException, KeyError, ValueError) as e: + Logger.warning( + 'SSO metadata discovery failed ({}); falling back to hardcoded endpoints'.format(e) + ) + endpoints = { + 'authorization_endpoint': ESI.ENDPOINT_EVE_AUTH, + 'token_endpoint': ESI.ENDPOINT_EVE_TOKEN, + } + + with _metadata_cache_lock: + _metadata_cache['fetched_at'] = now + _metadata_cache['endpoints'] = endpoints + return endpoints + class ESI: @@ -32,6 +99,17 @@ class ESI: 'esi-ui.write_waypoint.v1', ] + # Keyring service name for the persisted refresh token. Username is the + # EVE character_id as a string, so a future multi-character feature can + # store one entry per character without changing the schema. + KEYRING_SERVICE = 'shortcircuit-esi' + # Filename under appdirs.user_data_dir holding the most-recently-used + # character_id. The id itself is public (it appears in killboards, zKill, + # ESI URLs); the secret is the refresh token, which lives in the keyring. + # Deliberately NOT version-scoped like the log dir: the persisted session + # should survive app upgrades. + CHAR_ID_META_FILENAME = 'esi_char_id' + def __init__(self, login_callback, logout_callback): self.login_callback = login_callback self.logout_callback = logout_callback @@ -110,7 +188,8 @@ def start_server(self): 'code_challenge': code_challenge, 'code_challenge_method': 'S256', } - endpoint_auth = ESI.ENDPOINT_EVE_AUTH + '?' + urllib.parse.urlencode(params) + endpoints = discover_sso_endpoints() + endpoint_auth = endpoints['authorization_endpoint'] + '?' + urllib.parse.urlencode(params) return webbrowser.open(endpoint_auth) def timeout_server(self): @@ -135,8 +214,11 @@ def handle_login(self, message): return code = message['code'][0] + # Reuse the metadata cache warmed by start_server() so we post to the + # same token endpoint CCP currently advertises. + endpoints = discover_sso_endpoints() r = requests.post( - ESI.ENDPOINT_EVE_TOKEN, + endpoints['token_endpoint'], data={ 'grant_type': 'authorization_code', 'code': code, @@ -164,6 +246,9 @@ def handle_login(self, message): sub = claims.get('sub', '') self.char_id = int(sub.split(':')[-1]) if sub.startswith('CHARACTER:EVE:') else None self.char_name = claims.get('name') + if self.char_id is not None and self.refresh_token: + ESI._store_refresh_token(self.char_id, self.refresh_token) + ESI._save_persisted_char_id(self.char_id) self.login_callback(True, self.char_name) else: self._clear_tokens() @@ -180,31 +265,176 @@ def _schedule_refresh(self, expires_in): self.sso_timer.start() def _refresh_access_token(self): + """ + Exchange the stored refresh token for a fresh access token. Called at + startup via try_silent_login() and from the background sso_timer before + the current access token expires. + + CCP rotates the refresh token on every successful refresh, so the + response carries a NEW refresh_token superseding the old one. Persisting + the rotated value is mandatory — re-using the old token a second time + fails with invalid_grant. + + Returns True iff the refresh succeeded and self.token is now valid; on + failure _logout() has already run, which also purges the persisted + session so a dead token is not retried on every startup. + """ if not self.refresh_token: self._logout() - return + return False - r = requests.post( - ESI.ENDPOINT_EVE_TOKEN, - data={ - 'grant_type': 'refresh_token', - 'refresh_token': self.refresh_token, - 'client_id': ESI.CLIENT_ID, - 'code_verifier': self._code_verifier, - }, - headers={'User-Agent': USER_AGENT}, - ) + endpoints = discover_sso_endpoints() + try: + r = requests.post( + endpoints['token_endpoint'], + data={ + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + 'client_id': ESI.CLIENT_ID, + 'code_verifier': self._code_verifier, + }, + headers={'User-Agent': USER_AGENT}, + ) + except requests.RequestException as e: + # A network blip, not necessarily a dead token. Log out so the user + # sees an honest disconnected state; they can click Login to retry. + # Without this, an offline refresh kills the timer thread and leaves + # the UI claiming to be connected with an expired token. + Logger.warning('Token refresh request failed: {}'.format(e)) + self._logout() + return False - if r.status_code == requests.codes.ok: - token_data = r.json() - self.token = token_data['access_token'] - self.refresh_token = token_data.get('refresh_token', self.refresh_token) - expires_in = token_data.get('expires_in', 1200) - self._schedule_refresh(expires_in) - Logger.debug('ESI token refreshed for {}'.format(self.char_name)) - else: + if r.status_code != requests.codes.ok: Logger.warning('Token refresh failed: {} {}'.format(r.status_code, r.text)) self._logout() + return False + + token_data = r.json() + self.token = token_data['access_token'] + self.refresh_token = token_data.get('refresh_token', self.refresh_token) + expires_in = token_data.get('expires_in', 1200) + self._schedule_refresh(expires_in) + + if self.char_id is None or self.char_name is None: + # Silent-login path: character identity comes from the fresh JWT. + claims = self._decode_jwt(self.token) + if not claims: + self._logout() + return False + sub = claims.get('sub', '') + self.char_id = int(sub.split(':')[-1]) if sub.startswith('CHARACTER:EVE:') else None + self.char_name = claims.get('name') + + if self.char_id is not None and self.refresh_token: + ESI._store_refresh_token(self.char_id, self.refresh_token) + ESI._save_persisted_char_id(self.char_id) + + Logger.debug('ESI token refreshed for {}'.format(self.char_name)) + return True + + def try_silent_login(self): + """ + Resume a previous session using a persisted refresh token, with no + browser interaction. Intended to be called once at app startup. Fires + login_callback on success so the UI can flip to its logged-in state. + Returns True iff a session was resumed. + """ + char_id = ESI._load_persisted_char_id() + if char_id is None: + return False + refresh_token = ESI._load_refresh_token(char_id) + if not refresh_token: + # Stale meta file pointing at a character with no keyring entry — + # clean it up so we don't keep checking on every startup. + ESI._clear_persisted_char_id() + return False + + self.refresh_token = refresh_token + self.char_id = char_id + + if not self._refresh_access_token(): + return False + + self.login_callback(True, self.char_name) + return True + + # The refresh token is functionally equivalent to a password — anyone + # holding it can act as the user against ESI within the granted scopes + # until it is revoked. It is therefore stored in the OS keychain (Keychain + # on macOS, Credential Manager on Windows, Secret Service on Linux) via + # the keyring library, never in a plaintext file. If no keyring backend is + # available (eg. a headless Linux session without Secret Service) we + # silently degrade to the old behaviour: the user re-auths each session, + # but the secret is never written to disk in the clear. + + @staticmethod + def _char_id_meta_path(): + app_dirs = AppDirs(__appslug__, 'secondfry') + os.makedirs(app_dirs.user_data_dir, exist_ok=True) + return os.path.join(app_dirs.user_data_dir, ESI.CHAR_ID_META_FILENAME) + + @staticmethod + def _load_persisted_char_id(): + try: + with open(ESI._char_id_meta_path(), 'r') as f: + return int(f.read().strip()) + except (FileNotFoundError, ValueError, OSError): + return None + + @staticmethod + def _save_persisted_char_id(char_id): + try: + with open(ESI._char_id_meta_path(), 'w') as f: + f.write(str(char_id)) + except OSError as e: + Logger.warning('Failed to persist char_id: {}'.format(e)) + + @staticmethod + def _clear_persisted_char_id(): + try: + os.remove(ESI._char_id_meta_path()) + except FileNotFoundError: + pass + except OSError as e: + Logger.warning('Failed to clear persisted char_id: {}'.format(e)) + + @staticmethod + def _store_refresh_token(char_id, refresh_token): + if keyring is None: + Logger.warning( + 'keyring unavailable; skipping refresh-token persistence ' + '(user will need to re-auth next session)' + ) + return False + try: + keyring.set_password(ESI.KEYRING_SERVICE, str(char_id), refresh_token) + return True + except Exception as e: + Logger.warning('Could not store refresh token in keyring: {}'.format(e)) + return False + + @staticmethod + def _load_refresh_token(char_id): + if keyring is None: + return None + try: + return keyring.get_password(ESI.KEYRING_SERVICE, str(char_id)) + except Exception as e: + Logger.warning('Could not load refresh token from keyring: {}'.format(e)) + return None + + @staticmethod + def _delete_refresh_token(char_id): + if keyring is None: + return + try: + keyring.delete_password(ESI.KEYRING_SERVICE, str(char_id)) + except Exception as e: + # PasswordDeleteError when the entry doesn't exist is benign; other + # errors are just logged — there is nothing the user can do. + if keyring_errors and isinstance(e, keyring_errors.PasswordDeleteError): + return + Logger.warning('Could not delete refresh token from keyring: {}'.format(e)) def _clear_tokens(self): self.token = None @@ -260,6 +490,12 @@ def logout(self): self._logout() def _logout(self): + # Purge persisted credentials so a restart doesn't silently resume a + # session the user (or the server) just ended. Both operations are + # no-ops when the entries don't exist. + if self.char_id is not None: + ESI._delete_refresh_token(self.char_id) + ESI._clear_persisted_char_id() self._clear_tokens() self._code_verifier = None self.logout_callback() diff --git a/src/shortcircuit/model/esi/test_esi.py b/src/shortcircuit/model/esi/test_esi.py new file mode 100644 index 0000000..39ebffc --- /dev/null +++ b/src/shortcircuit/model/esi/test_esi.py @@ -0,0 +1,274 @@ +# test_esi.py + +import base64 +import json +import os +import tempfile +import time +import unittest +from unittest import mock + +import requests + +from shortcircuit.model.esi import esi as esi_module +from shortcircuit.model.esi.esi import ESI, discover_sso_endpoints + + +def _reset_metadata_cache(): + with esi_module._metadata_cache_lock: + esi_module._metadata_cache['fetched_at'] = 0.0 + esi_module._metadata_cache['endpoints'] = None + + +class _FakeResponse: + + def __init__(self, payload=None, error=None): + self._payload = payload + self._error = error + + def raise_for_status(self): + if self._error: + raise self._error + + def json(self): + return self._payload + + +class TestDiscoverSsoEndpoints(unittest.TestCase): + + def setUp(self): + _reset_metadata_cache() + + def tearDown(self): + _reset_metadata_cache() + + def test_success_returns_advertised_endpoints(self): + payload = { + 'authorization_endpoint': 'https://login.eveonline.com/v3/oauth/authorize', + 'token_endpoint': 'https://login.eveonline.com/v3/oauth/token', + 'issuer': 'https://login.eveonline.com', + } + with mock.patch.object( + esi_module.requests, 'get', return_value=_FakeResponse(payload) + ) as fake_get: + endpoints = discover_sso_endpoints() + + fake_get.assert_called_once() + self.assertEqual( + endpoints['authorization_endpoint'], + 'https://login.eveonline.com/v3/oauth/authorize', + ) + self.assertEqual( + endpoints['token_endpoint'], + 'https://login.eveonline.com/v3/oauth/token', + ) + + def test_result_is_cached(self): + payload = { + 'authorization_endpoint': 'https://a.example', + 'token_endpoint': 'https://t.example', + } + with mock.patch.object( + esi_module.requests, 'get', return_value=_FakeResponse(payload) + ) as fake_get: + discover_sso_endpoints() + discover_sso_endpoints() + + self.assertEqual(fake_get.call_count, 1) + + def test_force_refresh_bypasses_cache(self): + payload = { + 'authorization_endpoint': 'https://a.example', + 'token_endpoint': 'https://t.example', + } + with mock.patch.object( + esi_module.requests, 'get', return_value=_FakeResponse(payload) + ) as fake_get: + discover_sso_endpoints() + discover_sso_endpoints(force_refresh=True) + + self.assertEqual(fake_get.call_count, 2) + + def test_network_failure_falls_back_to_hardcoded(self): + with mock.patch.object( + esi_module.requests, + 'get', + return_value=_FakeResponse(error=requests.ConnectionError('boom')), + ): + endpoints = discover_sso_endpoints() + + self.assertEqual(endpoints['authorization_endpoint'], ESI.ENDPOINT_EVE_AUTH) + self.assertEqual(endpoints['token_endpoint'], ESI.ENDPOINT_EVE_TOKEN) + + def test_missing_keys_fall_back_to_hardcoded(self): + with mock.patch.object( + esi_module.requests, + 'get', + return_value=_FakeResponse({'issuer': 'https://login.eveonline.com'}), + ): + endpoints = discover_sso_endpoints() + + self.assertEqual(endpoints['authorization_endpoint'], ESI.ENDPOINT_EVE_AUTH) + self.assertEqual(endpoints['token_endpoint'], ESI.ENDPOINT_EVE_TOKEN) + + +class _FakeKeyring: + """In-memory stand-in for the keyring module; tests must never touch the + real OS keychain (it can prompt interactively on macOS).""" + + def __init__(self): + self.store = {} + + def set_password(self, service, username, password): + self.store[(service, username)] = password + + def get_password(self, service, username): + return self.store.get((service, username)) + + def delete_password(self, service, username): + try: + del self.store[(service, username)] + except KeyError: + raise Exception('no such entry') + + +def _make_jwt(char_id=92000001, name='Test Pilot'): + payload = { + 'iss': 'https://login.eveonline.com', + 'aud': [ESI.CLIENT_ID, 'EVE Online'], + 'exp': int(time.time()) + 1200, + 'sub': 'CHARACTER:EVE:{}'.format(char_id), + 'name': name, + } + body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode() + return 'header.{}.signature'.format(body) + + +class _PersistenceCase(unittest.TestCase): + """Common fixture: fake keyring, temp char_id meta file, no real timers.""" + + def setUp(self): + _reset_metadata_cache() + self.fake_keyring = _FakeKeyring() + self.meta_path = os.path.join(tempfile.mkdtemp(), 'esi_char_id') + + patchers = [ + mock.patch.object(esi_module, 'keyring', self.fake_keyring), + mock.patch.object(esi_module, 'keyring_errors', None), + mock.patch.object(ESI, '_char_id_meta_path', staticmethod(lambda: self.meta_path)), + mock.patch.object(ESI, '_schedule_refresh', lambda self_, expires_in: None), + ] + for p in patchers: + p.start() + self.addCleanup(p.stop) + + self.login_calls = [] + self.logout_calls = [] + self.esi = ESI( + lambda is_ok, char_name: self.login_calls.append((is_ok, char_name)), + lambda: self.logout_calls.append(True), + ) + + def _persist_session(self, char_id=92000001, refresh_token='rt-old'): + self.fake_keyring.set_password(ESI.KEYRING_SERVICE, str(char_id), refresh_token) + with open(self.meta_path, 'w') as f: + f.write(str(char_id)) + + +class TestRefreshTokenStorage(_PersistenceCase): + + def test_store_load_delete_roundtrip(self): + self.assertTrue(ESI._store_refresh_token(92000001, 'rt-secret')) + self.assertEqual(ESI._load_refresh_token(92000001), 'rt-secret') + ESI._delete_refresh_token(92000001) + self.assertIsNone(ESI._load_refresh_token(92000001)) + + def test_char_id_meta_roundtrip(self): + ESI._save_persisted_char_id(92000001) + self.assertEqual(ESI._load_persisted_char_id(), 92000001) + ESI._clear_persisted_char_id() + self.assertIsNone(ESI._load_persisted_char_id()) + + def test_keyring_unavailable_degrades_gracefully(self): + with mock.patch.object(esi_module, 'keyring', None): + self.assertFalse(ESI._store_refresh_token(92000001, 'rt-secret')) + self.assertIsNone(ESI._load_refresh_token(92000001)) + ESI._delete_refresh_token(92000001) # must not raise + + +class TestSilentLogin(_PersistenceCase): + + def test_no_persisted_session_returns_false(self): + with mock.patch.object(esi_module.requests, 'post') as fake_post: + self.assertFalse(self.esi.try_silent_login()) + fake_post.assert_not_called() + self.assertEqual(self.login_calls, []) + + def test_stale_meta_without_keyring_entry_is_cleaned_up(self): + with open(self.meta_path, 'w') as f: + f.write('92000001') + with mock.patch.object(esi_module.requests, 'post') as fake_post: + self.assertFalse(self.esi.try_silent_login()) + fake_post.assert_not_called() + self.assertFalse(os.path.exists(self.meta_path)) + + def test_resumes_session_and_persists_rotated_token(self): + self._persist_session(refresh_token='rt-old') + response = mock.Mock( + status_code=200, + json=lambda: { + 'access_token': _make_jwt(), + 'refresh_token': 'rt-rotated', + 'expires_in': 1200, + }, + ) + with mock.patch.object(esi_module.requests, 'post', return_value=response) as fake_post: + self.assertTrue(self.esi.try_silent_login()) + + sent = fake_post.call_args.kwargs['data'] + self.assertEqual(sent['grant_type'], 'refresh_token') + self.assertEqual(sent['refresh_token'], 'rt-old') + self.assertEqual(self.login_calls, [(True, 'Test Pilot')]) + self.assertEqual(self.esi.char_id, 92000001) + # CCP rotates the refresh token; the rotated value must be persisted or + # the NEXT silent login would present a single-use token twice. + self.assertEqual(ESI._load_refresh_token(92000001), 'rt-rotated') + self.assertEqual(ESI._load_persisted_char_id(), 92000001) + + def test_dead_token_is_purged_and_logs_out(self): + self._persist_session() + response = mock.Mock(status_code=400, text='invalid_grant') + with mock.patch.object(esi_module.requests, 'post', return_value=response): + self.assertFalse(self.esi.try_silent_login()) + + self.assertEqual(self.login_calls, []) + self.assertEqual(self.logout_calls, [True]) + self.assertIsNone(ESI._load_refresh_token(92000001)) + self.assertIsNone(ESI._load_persisted_char_id()) + + def test_network_failure_logs_out_without_crashing(self): + self._persist_session() + with mock.patch.object( + esi_module.requests, 'post', side_effect=requests.ConnectionError('offline') + ): + self.assertFalse(self.esi.try_silent_login()) + self.assertEqual(self.logout_calls, [True]) + + +class TestLogoutPurgesSession(_PersistenceCase): + + def test_logout_removes_keyring_entry_and_meta(self): + self._persist_session() + self.esi.char_id = 92000001 + self.esi.refresh_token = 'rt-old' + self.esi.token = 'at' + + self.esi.logout() + + self.assertEqual(self.logout_calls, [True]) + self.assertIsNone(ESI._load_refresh_token(92000001)) + self.assertIsNone(ESI._load_persisted_char_id()) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/shortcircuit/model/esi_processor.py b/src/shortcircuit/model/esi_processor.py index 8d5d8f1..cc33a77 100644 --- a/src/shortcircuit/model/esi_processor.py +++ b/src/shortcircuit/model/esi_processor.py @@ -23,6 +23,16 @@ def __init__(self, parent=None): def login(self): return self.esi.start_server() + def try_silent_login(self): + """ + Resume the previous session from a persisted refresh token, if any. + Runs in a background thread because it does a token-endpoint round + trip; emits login_response on success just like interactive login. + """ + server_thread = threading.Thread(target=self.esi.try_silent_login) + server_thread.setDaemon(True) + server_thread.start() + def logout(self): self.esi.logout()