2828from textual_fspicker import FileSave , Filters
2929from tui .gdtf_share .gdtf import GDTFScreen
3030from pathlib import Path
31+ try :
32+ import keyring
33+ from keyring .errors import KeyringError
34+ except ImportError : # pragma: no cover - optional dependency at runtime
35+ keyring = None
36+ KeyringError = Exception
3137
3238
3339class MVRDisplay (VerticalScroll ):
@@ -118,6 +124,8 @@ def on_select_changed(self, event: Select.Changed):
118124class PollToMVR (App ):
119125 """A Textual app to manage Uptime Kuma MVR."""
120126
127+ APP_NAME = "PollToMVR"
128+ TITLE = APP_NAME
121129 CSS_PATH = [
122130 "app.css" ,
123131 "quit_screen.css" ,
@@ -138,6 +146,9 @@ class PollToMVR(App):
138146 ]
139147
140148 CONFIG_FILE = "config.json"
149+ KEYRING_SERVICE = APP_NAME
150+ KEYRING_USERNAME_KEY = "gdtf_username"
151+ KEYRING_PASSWORD_KEY = "gdtf_password"
141152 configuration = SimpleNamespace (
142153 artnet_timeout = "1" , show_debug = False , gdtf_username = "" , gdtf_password = ""
143154 )
@@ -172,15 +183,22 @@ def on_mount(self) -> None:
172183 """Load the configuration from the JSON file when the app starts."""
173184 path = Path ("gdtf_files" )
174185 path .mkdir (parents = True , exist_ok = True )
186+ config_data = {}
175187 if os .path .exists (self .CONFIG_FILE ):
176188 with open (self .CONFIG_FILE , "r" ) as f :
177189 try :
178- vars (self .configuration ).update (json .load (f ))
190+ config_data = json .load (f )
191+ vars (self .configuration ).update (config_data )
179192 self .notify ("Configuration loaded..." , timeout = 1 )
180193
181194 except json .JSONDecodeError :
182195 # Handle empty or invalid JSON file
183196 pass
197+ migrated = self ._load_credentials (config_data )
198+ if migrated :
199+ with open (self .CONFIG_FILE , "w" ) as f :
200+ json .dump (config_data , f , indent = 4 )
201+ self .notify ("Credentials moved to system keyring." , timeout = 2 )
184202
185203 def on_button_pressed (self , event : Button .Pressed ) -> None :
186204 """Called when a button is pressed."""
@@ -236,8 +254,10 @@ def check_quit(quit_confirmed: bool) -> None:
236254
237255 def action_save_config (self ) -> None :
238256 """Save the configuration to the JSON file."""
257+ config_data = vars (self .app .configuration ).copy ()
258+ self ._persist_credentials (config_data )
239259 with open (self .CONFIG_FILE , "w" ) as f :
240- json .dump (vars ( self . app . configuration ) , f , indent = 4 )
260+ json .dump (config_data , f , indent = 4 )
241261
242262 def action_quit (self ) -> None :
243263 """Save the configuration to the JSON file when the app closes."""
@@ -248,6 +268,67 @@ def get_layer_name(self, uuid):
248268 for layer_name , layer_id in self .mvr_layers :
249269 if layer_id == uuid :
250270 return layer_name
271+ return None
272+
273+ def _keyring_get (self , key ):
274+ if not keyring :
275+ return None
276+ try :
277+ return keyring .get_password (self .KEYRING_SERVICE , key )
278+ except KeyringError :
279+ return None
280+
281+ def _keyring_set (self , key , value ):
282+ if not keyring :
283+ return False
284+ try :
285+ if value :
286+ keyring .set_password (self .KEYRING_SERVICE , key , value )
287+ else :
288+ try :
289+ keyring .delete_password (self .KEYRING_SERVICE , key )
290+ except KeyringError :
291+ pass
292+ return True
293+ except KeyringError :
294+ return False
295+
296+ def _load_credentials (self , config_data ):
297+ migrated = False
298+ username = self ._keyring_get (self .KEYRING_USERNAME_KEY )
299+ password = self ._keyring_get (self .KEYRING_PASSWORD_KEY )
300+ config_username = config_data .get ("gdtf_username" , "" )
301+ config_password = config_data .get ("gdtf_password" , "" )
302+ if (config_username or config_password ) and (username is None or password is None ):
303+ stored_user = self ._keyring_set (self .KEYRING_USERNAME_KEY , config_username )
304+ stored_pass = self ._keyring_set (self .KEYRING_PASSWORD_KEY , config_password )
305+ if stored_user and stored_pass :
306+ config_data .pop ("gdtf_username" , None )
307+ config_data .pop ("gdtf_password" , None )
308+ migrated = True
309+ username = self ._keyring_get (self .KEYRING_USERNAME_KEY )
310+ password = self ._keyring_get (self .KEYRING_PASSWORD_KEY )
311+ if username is None :
312+ username = config_username
313+ if password is None :
314+ password = config_password
315+ self .configuration .gdtf_username = username or ""
316+ self .configuration .gdtf_password = password or ""
317+ return migrated
318+
319+ def _persist_credentials (self , config_data ):
320+ stored_user = self ._keyring_set (
321+ self .KEYRING_USERNAME_KEY , self .configuration .gdtf_username
322+ )
323+ stored_pass = self ._keyring_set (
324+ self .KEYRING_PASSWORD_KEY , self .configuration .gdtf_password
325+ )
326+ if stored_user and stored_pass :
327+ config_data .pop ("gdtf_username" , None )
328+ config_data .pop ("gdtf_password" , None )
329+ else :
330+ config_data ["gdtf_username" ] = self .configuration .gdtf_username
331+ config_data ["gdtf_password" ] = self .configuration .gdtf_password
251332
252333 @on (Button .Pressed )
253334 @work
0 commit comments