Skip to content

Commit fc81614

Browse files
committed
Store credentials in a keyring based storage, fix #3
1 parent 5e0f63c commit fc81614

File tree

3 files changed

+254
-6
lines changed

3 files changed

+254
-6
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ readme = "README.md"
66
requires-python = ">=3.13"
77
dependencies = [
88
"ifaddr>=0.2.0",
9-
"pymvr>=1.0.5",
9+
"keyring>=25.7.0",
10+
"pymvr>=1.0.6",
1011
"pyserial>=3.5",
1112
"requests>=2.32.5",
1213
"textual>=6.5.0",

tui/app.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
from textual_fspicker import FileSave, Filters
2929
from tui.gdtf_share.gdtf import GDTFScreen
3030
from 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

3339
class MVRDisplay(VerticalScroll):
@@ -118,6 +124,8 @@ def on_select_changed(self, event: Select.Changed):
118124
class 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

Comments
 (0)