🔧 chore: update code

This commit is contained in:
sankalp committed 2025-06-29 23:41:21 +05:45
1 parent 6d010ee65c
commit 0b652ff87a
19 files changed
+2336 -393

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
import subprocess
import time
import json
import os
from datetime import datetime
from PIL import Image
from pyzbar.pyzbar import decode
import pyotp
from urllib.parse import urlparse, parse_qs
from pathlib import Path
import config.data as data
def get_otp_file_path():
"""Returns the path to the OTP file inside the cache directory."""
cache_dir = Path(data.CACHE_DIR) / "otp"
# Create the directory if it doesn't exist
cache_dir.mkdir(parents=True, exist_ok=True)
file_path = cache_dir / "otp_codes.json"
# Create the file if it doesn't exist
if not file_path.exists():
with open(file_path, "w") as f:
json.dump([], f)
print(f"File {file_path} created successfully!")
return file_path
def capture_selected_area(filename="/tmp/screenshot.png"):
"""
Uses slurp to let the user select an area of the screen,
then captures that area using grim.
"""
try:
# slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400)
result = subprocess.run(["slurp"], check=True, capture_output=True, text=True)
geometry = result.stdout.strip()
if not geometry:
print("No area selected with slurp.")
return None
except subprocess.CalledProcessError as e:
print("Error selecting area with slurp:", e)
return None
try:
# grim uses -g to capture a specific region
subprocess.run(["grim", "-g", geometry, filename], check=True)
except subprocess.CalledProcessError as e:
print("Error capturing screenshot with grim:", e)
return None
return filename
def read_and_save_to_json():
# Get the full path to the JSON file
json_file = get_otp_file_path()
screenshot = capture_selected_area()
if screenshot is None:
print("Failed to capture the selected area.")
return False
# Short delay to ensure the file is written
time.sleep(1)
# Open the captured image
try:
img = Image.open(screenshot)
except Exception as e:
print("Error opening the image:", e)
return False
# Decode QR Code(s) from the image
decoded_objects = decode(img)
if not decoded_objects:
print("No QR Code detected in the selected area.")
return False
results = []
for obj in decoded_objects:
data = obj.data.decode("utf-8")
print("QR Code detected:", data)
result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data}
if data.startswith("otpauth://"):
parsed = urlparse(data)
query = parse_qs(parsed.query)
# Extract the secret properly
secret = query.get("secret", [None])[0]
issuer_from_query = query.get("issuer", [None])[0]
# Extract the label (path), which may contain issuer and account
label = parsed.path.lstrip("/") if parsed.path else ""
account_name = label
issuer_from_path = None
if ":" in label:
parts = label.split(":", 1)
issuer_from_path = parts[0]
account_name = parts[1]
# Prefer issuer from path if available, otherwise use from query
issuer = issuer_from_path or issuer_from_query
# Extract the period (default is 30 seconds)
period = int(query.get("period", ["30"])[0])
# Create TOTP object with the correct interval
totp = pyotp.TOTP(secret, interval=period)
current_otp = totp.now()
print(f"Generated OTP: {current_otp} (valid for {period} seconds)")
result_entry.update(
{
"type": "otp",
"secret": secret,
"issuer": issuer,
"account_name": account_name,
}
)
else:
result_entry["type"] = "unknown"
print("Unrecognized format. Expected a URI like otpauth://")
return False
results.append(result_entry)
# Load existing data if the file exists
existing_data = []
if os.path.exists(json_file):
try:
with open(json_file, "r") as f:
existing_data = json.load(f)
except json.JSONDecodeError:
print(f"Error reading the file {json_file}. Creating a new one.")
# Append new results
existing_data.extend(results)
# Save to JSON file
with open(json_file, "w") as f:
json.dump(existing_data, f, indent=4)
print(f"OTP data saved to {json_file}")
return True
def CodeOTP(uri):
parsed = urlparse(uri)
query = parse_qs(parsed.query)
secret = query.get("secret", [None])[0]
if secret is None:
return None
else:
totp = pyotp.TOTP(secret)
return totp.now()
# TOTP/OTP utility functions
def generate_totp(secret: str) -> str:
"""Generate TOTP code from secret."""
try:
return pyotp.TOTP(secret).now()
except Exception as e:
print(f"Error generating TOTP: {e}")
return None
def get_time_remaining() -> int:
"""Get seconds remaining until next token refresh."""
return 30 - (int(time.time()) % 30)
def get_time_remaining_with_blink() -> str:
"""Get time remaining with blinking effect."""
time_remaining = get_time_remaining()
current_second = int(time.time())
should_blink = current_second % 2 == 0
if should_blink:
return f"<span alpha='30%'>{time_remaining}s</span>"
else:
return f"{time_remaining}s"
def validate_base32_secret(secret: str) -> dict:
"""Validate and clean Base32 secret."""
import base64
import re
try:
# Clean up the secret - remove spaces, dashes, and convert to uppercase
clean_secret = (
secret.replace(" ", "").replace("-", "").replace("_", "").upper()
)
# Remove any non-base32 characters
clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
# Add padding if needed (Base32 requires padding to multiple of 8)
while len(clean_secret) % 8 != 0:
clean_secret += "="
# Validate Base32 format
try:
base64.b32decode(clean_secret)
except Exception as e:
return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"}
# Test if the secret can generate a valid TOTP
try:
test_totp = pyotp.TOTP(clean_secret)
test_code = test_totp.now()
if not test_code or len(test_code) != 6:
raise ValueError("Generated invalid TOTP code")
except Exception as e:
return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"}
return {"success": True, "secret": clean_secret}
except Exception as e:
return {"success": False, "error": f"Unexpected error: {str(e)}"}
def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
"""Parse otpauth URI and extract account information."""
try:
parsed = urlparse(uri)
if parsed.scheme != "otpauth" or parsed.netloc != "totp":
return {"success": False, "error": "Only otpauth://totp/ URIs are supported"}
if not account_name:
account_path = parsed.path.lstrip("/")
if ":" in account_path:
issuer, extracted_name = account_path.split(":", 1)
account_name = extracted_name
else:
account_name = account_path
params = parse_qs(parsed.query)
secret = params.get("secret", [""])[0]
issuer = params.get("issuer", [""])[0]
algorithm = params.get("algorithm", ["SHA1"])[0]
digits = int(params.get("digits", ["6"])[0])
period = int(params.get("period", ["30"])[0])
if not secret:
return {"success": False, "error": "No secret found in URI"}
return {
"success": True,
"account_name": account_name,
"secret": secret,
"issuer": issuer,
"algorithm": algorithm,
"digits": digits,
"period": period,
}
except Exception as e:
return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"}
def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
"""Scan QR code and add OTP account to secrets file."""
try:
# Capture QR code from screen
screenshot_path = capture_selected_area()
if not screenshot_path:
return {"success": False, "error": "QR scan cancelled or failed"}
# Decode QR code
try:
img = Image.open(screenshot_path)
decoded_objects = decode(img)
if not decoded_objects:
return {"success": False, "error": "No QR code detected in selected area"}
# Process the first QR code found
qr_data = decoded_objects[0].data.decode("utf-8")
print(f"QR Code detected: {qr_data}")
if qr_data.startswith("otpauth://"):
# Parse otpauth URI
result = parse_otpauth_uri(qr_data, account_name)
if not result["success"]:
return result
# Load existing secrets
secrets = {}
if os.path.exists(secrets_file_path):
try:
with open(secrets_file_path, "r", encoding="utf-8") as f:
secrets = json.load(f)
except Exception as e:
print(f"Error loading secrets: {e}")
# Add new account
secrets[result["account_name"]] = {
"secret": result["secret"],
"issuer": result["issuer"],
"algorithm": result["algorithm"],
"digits": result["digits"],
"period": result["period"],
}
# Save secrets
try:
os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True)
with open(secrets_file_path, "w", encoding="utf-8") as f:
json.dump(secrets, f, indent=2)
except Exception as e:
return {"success": False, "error": f"Error saving secrets: {str(e)}"}
display_name = f"{result['issuer']} - {result['account_name']}" if result['issuer'] else result['account_name']
return {
"success": True,
"account_name": result["account_name"],
"display_name": display_name,
"message": f"Successfully added OTP account: {display_name}"
}
else:
return {"success": False, "error": "QR code is not an otpauth URI"}
except Exception as e:
return {"success": False, "error": f"Error processing QR code: {str(e)}"}
except Exception as e:
return {"success": False, "error": f"Error during QR scan: {str(e)}"}
+104 -172
View File
@@ -1,209 +1,141 @@
import contextlib
from typing import Optional
from gi.repository import Playerctl, GLib
from fabric.utils import bulk_connect
from fabric.core.service import Service, Signal
from fabric import Fabricator
from config.data import ALLOWED_PLAYERS
import gi
from fabric.core.service import Property, Service, Signal
from fabric.utils.helpers import get_enum_member_name, snake_case_to_kebab_case
from gi.repository import Playerctl
from loguru import logger
gi.require_version("Playerctl", "2.0")
class MediaPlayer(Service):
"""A service to manage a media player."""
class PlayerService(Service):
@Signal
def shuffle_toggle(self, player: Playerctl.Player, status: bool) -> None: ...
@Signal
def exit(self) -> None: ...
def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ...
@Signal
def metadata_changed(self) -> None: ...
def pause(self) -> None: ...
@Signal
def playback_status_changed(self) -> None: ...
def play(self) -> None: ...
@Property(str, flags="read-write")
def player_name(self) -> str:
return self._player.get_property("player_name")
@Signal
def track_position(self, pos: float, dur: float) -> None: ...
@Property(int, "read-write", default_value=0)
def track_position(self) -> int:
return self._player.get_property("position")
def __init__(self, player: Playerctl.Player, **kwargs):
self._player: Playerctl.Player = player
super().__init__(**kwargs)
if player.props.player_name in ALLOWED_PLAYERS:
self._player.connect("playback-status::playing", self.on_play)
self._player.connect("playback-status::paused", self.on_pause)
self._player.connect("shuffle", self.on_shuffle)
self._player.connect("metadata", self.on_metadata)
self._player.connect("seeked", self.on_seeked)
print(type(player))
@track_position.setter
def track_position(self, new_pos: int):
self._player.set_position(new_pos)
@Property(str, flags="read-write")
def album_image_url(self) -> str:
return self._player.get_property("metadata").unpack().get("mpris:artUrl", None)
@Property(int, flags="read-write")
def track_duration(self) -> int:
default_duration = minutes_to_microseconds(5)
metadata = self._player.get_property("metadata").unpack()
duration = metadata.get("mpris:length", default_duration) # Default: 5 minutes
return default_duration if duration == 0 else duration
@Property(str, flags="readable")
def status(self) -> str:
return snake_case_to_kebab_case(
get_enum_member_name(
self._player.get_property("playback-status"), # type: ignore
default="unknown",
)
self.status = self._player.props.playback_status
self.pos_fabricator = Fabricator(
interval=1000, # 1s
poll_from=lambda f, *_: self._player.get_position(),
on_changed=lambda f, *_: self.fabricating(),
)
self.poll_progress()
@Property(str, flags="readable")
def track(self) -> str:
return (
f"{self.track_artist} {self.track_title}"
if self.player_name == "spotify"
else self.track_title or "Music"
)
def on_seeked(self, player, position):
if self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
self.pos_fabricator.start()
@Property(str, flags="readable")
def track_title(self) -> str:
return self._player.get_title()
def set_position(self, pos: float):
print("seeking in the service")
self.pos_fabricator.stop()
micro_pos = int(pos * 1_000_000)
try:
self._player.set_position(micro_pos)
print(f"Set position to {micro_pos}")
except GLib.Error as e:
print(f"Failed to seek: {e}")
@Property(str, flags="readable")
def track_artist(self) -> str:
return self._player.get_artist()
def poll_progress(self):
if self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
self.pos_fabricator.start()
else:
self.pos_fabricator.stop()
@Property(bool, "readable", default_value=False)
def can_go_next(self) -> bool:
return self._player.get_property("can_go_next")
def fabricating(self):
pos = self._player.get_position() / 1_000_000 # seconds
# seconds
dur = self._player.props.metadata["mpris:length"] / 1_000_000
# print(self._player.get_position())
# print(f"[progress] {pos:.2f}s / {dur:.2f}s")
self.track_position(pos, dur)
@Property(bool, "readable", default_value=False)
def can_go_previous(self) -> bool:
return self._player.get_property("can_go_previous")
def on_play(self, player, status):
print("player is playing: {}".format(player.props.player_name))
self.status = player.props.playback_status
self.poll_progress()
self.play()
@Property(bool, "readable", default_value=False)
def can_seek(self) -> bool:
return self._player.get_property("can_seek")
def on_pause(self, player, status):
print("player is paused: {}".format(player.props.player_name))
self.status = player.props.playback_status
self.poll_progress()
self.pause()
@Property(bool, "readable", default_value=False)
def can_pause(self) -> bool:
return self._player.get_property("can_pause")
def on_shuffle(self, player, status):
print("suffle status changed for: {}".format(player.props.player_name))
print(type(status), "here is the status type")
self.shuffle_toggle(player, status)
def __init__(self, player):
super().__init__()
self._player = player
self._signal_connectors = {}
signals = {
"seeked": lambda *args: self.notify("track_position"),
"playback-status": lambda *args: self._on_playback_status_changed(),
"metadata": lambda *args: self._on_metadata_changed(),
"exit": lambda *args: self._on_exit(),
}
for signal_name, handler in signals.items():
self._signal_connectors[signal_name] = self._player.connect(
signal_name, handler
def on_metadata(self, player, metadata):
keys = metadata.keys()
if "xesam:artist" in keys and "xesam:title" in keys:
print(
"{} - {}".format(metadata["xesam:artist"][0], metadata["xesam:title"])
)
def _on_playback_status_changed(self):
self.notify("status")
self.playback_status_changed.emit()
def _on_metadata_changed(self):
for prop in [
"track_duration",
"track_position",
"track_title",
"track_artist",
"track",
"album_image_url",
]:
self.notify(prop)
self.metadata_changed.emit()
def _on_exit(self):
for signal_id in self._signal_connectors.values():
with contextlib.suppress(Exception):
self._player.disconnect(signal_id)
del self._player
self.exit.emit()
def play_pause(self):
if self.can_pause:
self._player.play_pause()
def next(self):
if self.can_go_next:
self._player.next()
def previous(self):
if self.can_go_previous:
self._player.previous()
def get_position(self):
return self._player.get_position() if self._player else 0
self.meta_change(metadata, player)
@staticmethod
def minutes_to_microseconds(minutes):
return minutes * 60 * 1000000
class MediaManager(Service):
"""MediaManager Service using Playerctl to control media players"""
class PlayerManager(Service):
@Signal
def new_player(self, player: Playerctl.Player) -> Playerctl.Player: ...
@Signal
def player_appeared(self) -> None: ...
def player_vanish(self, player: Playerctl.Player) -> None: ...
@Signal
def player_vanished(self) -> None: ...
@Property(list, "readable")
def player_names(self):
return self._manager.get_property("player_names")
@Property(list[MediaPlayer], "readable")
def players(self) -> list[MediaPlayer]:
return self._players.values()
@Property(MediaPlayer, "readable")
def current_player(self) -> Optional[MediaPlayer]:
current_player = None
for player in self.players:
if player.status == "playing":
return player
current_player = player
return current_player
def __init__(self):
super().__init__()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._manager = Playerctl.PlayerManager()
self._players: dict[str, MediaPlayer] = {}
self._manager.connect("name-appeared", self._on_name_appeared, self._manager)
self._manager.connect(
"name-appeared", lambda _, player: self._on_player_appeared(player=player)
)
self._manager.connect(
"player-vanished", lambda _, player: self._on_player_vanished(player=player)
"player-vanished", self._on_player_vanished, self._manager
)
print("playerctl init done")
for player in self._manager.get_property("player-names"):
self._on_player_appeared(player=player)
self._players = {}
def _on_player_appeared(self, player):
"""Callback when a new player appears"""
player = Playerctl.Player.new_from_name(player)
player_name = player.get_property("player_name")
def init_all_players(self):
# invoked in the UI
for player_obj in self._manager.props.player_names:
name_str = player_obj.name
print(f"{name_str} appeared")
if name_str in ALLOWED_PLAYERS:
player = Playerctl.Player.new_from_name(player_obj)
self._manager.manage_player(player)
self.new_player(player)
logger.info(f"[Media] New player appeared: {player_name}")
def _on_name_appeared(self, sender, name, manager):
name_str = name.name
print(f"{name_str} appeared")
if name_str in ALLOWED_PLAYERS:
player = Playerctl.Player.new_from_name(name)
self._manager.manage_player(player)
self.new_player(player)
self._manager.manage_player(player)
self._players[player_name] = MediaPlayer(player=player)
self.notify("current-player")
self.player_appeared.emit()
def _on_player_vanished(self, player):
"""Callback when a player disappears"""
player_name = player.get_property("player_name") or "Unknown"
logger.info(f"[Media] Player vanished: {player_name}")
self._players.pop(player_name)
self.notify("current-player")
self.player_vanished.emit()
def _on_player_vanished(self, sender, player, manager):
print("player has exited: {}".format(player.props.player_name))
print(type(player))
self.player_vanish(player)
+335
View File
@@ -0,0 +1,335 @@
import subprocess
import time
import json
import os
from datetime import datetime
from PIL import Image
from pyzbar.pyzbar import decode
import pyotp
from urllib.parse import urlparse, parse_qs
from pathlib import Path
import config.data as data
def get_otp_file_path():
"""Returns the path to the OTP file inside the cache directory."""
cache_dir = Path(data.CACHE_DIR) / "otp"
# Create the directory if it doesn't exist
cache_dir.mkdir(parents=True, exist_ok=True)
file_path = cache_dir / "otp_codes.json"
# Create the file if it doesn't exist
if not file_path.exists():
with open(file_path, "w") as f:
json.dump([], f)
print(f"File {file_path} created successfully!")
return file_path
def capture_selected_area(filename="/tmp/screenshot.png"):
"""
Uses slurp to let the user select an area of the screen,
then captures that area using grim.
"""
try:
# slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400)
result = subprocess.run(["slurp"], check=True, capture_output=True, text=True)
geometry = result.stdout.strip()
if not geometry:
print("No area selected with slurp.")
return None
except subprocess.CalledProcessError as e:
print("Error selecting area with slurp:", e)
return None
try:
# grim uses -g to capture a specific region
subprocess.run(["grim", "-g", geometry, filename], check=True)
except subprocess.CalledProcessError as e:
print("Error capturing screenshot with grim:", e)
return None
return filename
def read_and_save_to_json():
# Get the full path to the JSON file
json_file = get_otp_file_path()
screenshot = capture_selected_area()
if screenshot is None:
print("Failed to capture the selected area.")
return False
# Short delay to ensure the file is written
time.sleep(1)
# Open the captured image
try:
img = Image.open(screenshot)
except Exception as e:
print("Error opening the image:", e)
return False
# Decode QR Code(s) from the image
decoded_objects = decode(img)
if not decoded_objects:
print("No QR Code detected in the selected area.")
return False
results = []
for obj in decoded_objects:
data = obj.data.decode("utf-8")
print("QR Code detected:", data)
result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data}
if data.startswith("otpauth://"):
parsed = urlparse(data)
query = parse_qs(parsed.query)
# Extract the secret properly
secret = query.get("secret", [None])[0]
issuer_from_query = query.get("issuer", [None])[0]
# Extract the label (path), which may contain issuer and account
label = parsed.path.lstrip("/") if parsed.path else ""
account_name = label
issuer_from_path = None
if ":" in label:
parts = label.split(":", 1)
issuer_from_path = parts[0]
account_name = parts[1]
# Prefer issuer from path if available, otherwise use from query
issuer = issuer_from_path or issuer_from_query
# Extract the period (default is 30 seconds)
period = int(query.get("period", ["30"])[0])
# Create TOTP object with the correct interval
totp = pyotp.TOTP(secret, interval=period)
current_otp = totp.now()
print(f"Generated OTP: {current_otp} (valid for {period} seconds)")
result_entry.update(
{
"type": "otp",
"secret": secret,
"issuer": issuer,
"account_name": account_name,
}
)
else:
result_entry["type"] = "unknown"
print("Unrecognized format. Expected a URI like otpauth://")
return False
results.append(result_entry)
# Load existing data if the file exists
existing_data = []
if os.path.exists(json_file):
try:
with open(json_file, "r") as f:
existing_data = json.load(f)
except json.JSONDecodeError:
print(f"Error reading the file {json_file}. Creating a new one.")
# Append new results
existing_data.extend(results)
# Save to JSON file
with open(json_file, "w") as f:
json.dump(existing_data, f, indent=4)
print(f"OTP data saved to {json_file}")
return True
def CodeOTP(uri):
parsed = urlparse(uri)
query = parse_qs(parsed.query)
secret = query.get("secret", [None])[0]
if secret is None:
return None
else:
totp = pyotp.TOTP(secret)
return totp.now()
# TOTP/OTP utility functions
def generate_totp(secret: str) -> str:
"""Generate TOTP code from secret."""
try:
return pyotp.TOTP(secret).now()
except Exception as e:
print(f"Error generating TOTP: {e}")
return None
def get_time_remaining() -> int:
"""Get seconds remaining until next token refresh."""
return 30 - (int(time.time()) % 30)
def get_time_remaining_with_blink() -> str:
"""Get time remaining with blinking effect."""
time_remaining = get_time_remaining()
current_second = int(time.time())
should_blink = current_second % 2 == 0
if should_blink:
return f"<span alpha='30%'>{time_remaining}s</span>"
else:
return f"{time_remaining}s"
def validate_base32_secret(secret: str) -> dict:
"""Validate and clean Base32 secret."""
import base64
import re
try:
# Clean up the secret - remove spaces, dashes, and convert to uppercase
clean_secret = (
secret.replace(" ", "").replace("-", "").replace("_", "").upper()
)
# Remove any non-base32 characters
clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
# Add padding if needed (Base32 requires padding to multiple of 8)
while len(clean_secret) % 8 != 0:
clean_secret += "="
# Validate Base32 format
try:
base64.b32decode(clean_secret)
except Exception as e:
return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"}
# Test if the secret can generate a valid TOTP
try:
test_totp = pyotp.TOTP(clean_secret)
test_code = test_totp.now()
if not test_code or len(test_code) != 6:
raise ValueError("Generated invalid TOTP code")
except Exception as e:
return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"}
return {"success": True, "secret": clean_secret}
except Exception as e:
return {"success": False, "error": f"Unexpected error: {str(e)}"}
def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
"""Parse otpauth URI and extract account information."""
try:
parsed = urlparse(uri)
if parsed.scheme != "otpauth" or parsed.netloc != "totp":
return {"success": False, "error": "Only otpauth://totp/ URIs are supported"}
if not account_name:
account_path = parsed.path.lstrip("/")
if ":" in account_path:
issuer, extracted_name = account_path.split(":", 1)
account_name = extracted_name
else:
account_name = account_path
params = parse_qs(parsed.query)
secret = params.get("secret", [""])[0]
issuer = params.get("issuer", [""])[0]
algorithm = params.get("algorithm", ["SHA1"])[0]
digits = int(params.get("digits", ["6"])[0])
period = int(params.get("period", ["30"])[0])
if not secret:
return {"success": False, "error": "No secret found in URI"}
return {
"success": True,
"account_name": account_name,
"secret": secret,
"issuer": issuer,
"algorithm": algorithm,
"digits": digits,
"period": period,
}
except Exception as e:
return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"}
def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
"""Scan QR code and add OTP account to secrets file."""
try:
# Capture QR code from screen
screenshot_path = capture_selected_area()
if not screenshot_path:
return {"success": False, "error": "QR scan cancelled or failed"}
# Decode QR code
try:
img = Image.open(screenshot_path)
decoded_objects = decode(img)
if not decoded_objects:
return {"success": False, "error": "No QR code detected in selected area"}
# Process the first QR code found
qr_data = decoded_objects[0].data.decode("utf-8")
print(f"QR Code detected: {qr_data}")
if qr_data.startswith("otpauth://"):
# Parse otpauth URI
result = parse_otpauth_uri(qr_data, account_name)
if not result["success"]:
return result
# Load existing secrets
secrets = {}
if os.path.exists(secrets_file_path):
try:
with open(secrets_file_path, "r", encoding="utf-8") as f:
secrets = json.load(f)
except Exception as e:
print(f"Error loading secrets: {e}")
# Add new account
secrets[result["account_name"]] = {
"secret": result["secret"],
"issuer": result["issuer"],
"algorithm": result["algorithm"],
"digits": result["digits"],
"period": result["period"],
}
# Save secrets
try:
os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True)
with open(secrets_file_path, "w", encoding="utf-8") as f:
json.dump(secrets, f, indent=2)
except Exception as e:
return {"success": False, "error": f"Error saving secrets: {str(e)}"}
display_name = f"{result['issuer']} - {result['account_name']}" if result['issuer'] else result['account_name']
return {
"success": True,
"account_name": result["account_name"],
"display_name": display_name,
"message": f"Successfully added OTP account: {display_name}"
}
else:
return {"success": False, "error": "QR code is not an otpauth URI"}
except Exception as e:
return {"success": False, "error": f"Error processing QR code: {str(e)}"}
except Exception as e:
return {"success": False, "error": f"Error during QR scan: {str(e)}"}