Skip to content

Commit b2a5177

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

File tree

3 files changed

+257
-6
lines changed

3 files changed

+257
-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: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
from tui.gdtf_share.gdtf import GDTFScreen
3030
from pathlib import Path
3131

32+
try:
33+
import keyring
34+
from keyring.errors import KeyringError
35+
except ImportError: # pragma: no cover - optional dependency at runtime
36+
keyring = None
37+
KeyringError = Exception
38+
3239

3340
class MVRDisplay(VerticalScroll):
3441
def update_items(self, items):
@@ -118,6 +125,8 @@ def on_select_changed(self, event: Select.Changed):
118125
class PollToMVR(App):
119126
"""A Textual app to manage Uptime Kuma MVR."""
120127

128+
APP_NAME = "PollToMVR"
129+
TITLE = APP_NAME
121130
CSS_PATH = [
122131
"app.css",
123132
"quit_screen.css",
@@ -138,6 +147,9 @@ class PollToMVR(App):
138147
]
139148

140149
CONFIG_FILE = "config.json"
150+
KEYRING_SERVICE = APP_NAME
151+
KEYRING_USERNAME_KEY = "gdtf_username"
152+
KEYRING_PASSWORD_KEY = "gdtf_password"
141153
configuration = SimpleNamespace(
142154
artnet_timeout="1", show_debug=False, gdtf_username="", gdtf_password=""
143155
)
@@ -172,15 +184,22 @@ def on_mount(self) -> None:
172184
"""Load the configuration from the JSON file when the app starts."""
173185
path = Path("gdtf_files")
174186
path.mkdir(parents=True, exist_ok=True)
187+
config_data = {}
175188
if os.path.exists(self.CONFIG_FILE):
176189
with open(self.CONFIG_FILE, "r") as f:
177190
try:
178-
vars(self.configuration).update(json.load(f))
191+
config_data = json.load(f)
192+
vars(self.configuration).update(config_data)
179193
self.notify("Configuration loaded...", timeout=1)
180194

181195
except json.JSONDecodeError:
182196
# Handle empty or invalid JSON file
183197
pass
198+
migrated = self._load_credentials(config_data)
199+
if migrated:
200+
with open(self.CONFIG_FILE, "w") as f:
201+
json.dump(config_data, f, indent=4)
202+
self.notify("Credentials moved to system keyring.", timeout=2)
184203

185204
def on_button_pressed(self, event: Button.Pressed) -> None:
186205
"""Called when a button is pressed."""
@@ -236,8 +255,10 @@ def check_quit(quit_confirmed: bool) -> None:
236255

237256
def action_save_config(self) -> None:
238257
"""Save the configuration to the JSON file."""
258+
config_data = vars(self.app.configuration).copy()
259+
self._persist_credentials(config_data)
239260
with open(self.CONFIG_FILE, "w") as f:
240-
json.dump(vars(self.app.configuration), f, indent=4)
261+
json.dump(config_data, f, indent=4)
241262

242263
def action_quit(self) -> None:
243264
"""Save the configuration to the JSON file when the app closes."""
@@ -248,6 +269,69 @@ def get_layer_name(self, uuid):
248269
for layer_name, layer_id in self.mvr_layers:
249270
if layer_id == uuid:
250271
return layer_name
272+
return None
273+
274+
def _keyring_get(self, key):
275+
if not keyring:
276+
return None
277+
try:
278+
return keyring.get_password(self.KEYRING_SERVICE, key)
279+
except KeyringError:
280+
return None
281+
282+
def _keyring_set(self, key, value):
283+
if not keyring:
284+
return False
285+
try:
286+
if value:
287+
keyring.set_password(self.KEYRING_SERVICE, key, value)
288+
else:
289+
try:
290+
keyring.delete_password(self.KEYRING_SERVICE, key)
291+
except KeyringError:
292+
pass
293+
return True
294+
except KeyringError:
295+
return False
296+
297+
def _load_credentials(self, config_data):
298+
migrated = False
299+
username = self._keyring_get(self.KEYRING_USERNAME_KEY)
300+
password = self._keyring_get(self.KEYRING_PASSWORD_KEY)
301+
config_username = config_data.get("gdtf_username", "")
302+
config_password = config_data.get("gdtf_password", "")
303+
if (config_username or config_password) and (
304+
username is None or password is None
305+
):
306+
stored_user = self._keyring_set(self.KEYRING_USERNAME_KEY, config_username)
307+
stored_pass = self._keyring_set(self.KEYRING_PASSWORD_KEY, config_password)
308+
if stored_user and stored_pass:
309+
config_data.pop("gdtf_username", None)
310+
config_data.pop("gdtf_password", None)
311+
migrated = True
312+
username = self._keyring_get(self.KEYRING_USERNAME_KEY)
313+
password = self._keyring_get(self.KEYRING_PASSWORD_KEY)
314+
if username is None:
315+
username = config_username
316+
if password is None:
317+
password = config_password
318+
self.configuration.gdtf_username = username or ""
319+
self.configuration.gdtf_password = password or ""
320+
return migrated
321+
322+
def _persist_credentials(self, config_data):
323+
stored_user = self._keyring_set(
324+
self.KEYRING_USERNAME_KEY, self.configuration.gdtf_username
325+
)
326+
stored_pass = self._keyring_set(
327+
self.KEYRING_PASSWORD_KEY, self.configuration.gdtf_password
328+
)
329+
if stored_user and stored_pass:
330+
config_data.pop("gdtf_username", None)
331+
config_data.pop("gdtf_password", None)
332+
else:
333+
config_data["gdtf_username"] = self.configuration.gdtf_username
334+
config_data["gdtf_password"] = self.configuration.gdtf_password
251335

252336
@on(Button.Pressed)
253337
@work

0 commit comments

Comments
 (0)