🔧 chore(dock/components): import cleanup in __init__ files, refactor SystemTrayItem monkey patching

This commit is contained in:
sankalp committed 2025-06-30 01:11:08 +05:45
1 parent 994b44f515
commit e5471eb771
9 files changed
+293 -499

No files matched your search

+10 -6
View File
@@ -1,6 +1,9 @@
from gi.repository import Gtk
from fabric.system_tray.widgets import SystemTrayItem
import json
import os
import gi
from fabric.hyprland.service import HyprlandEvent
from fabric.hyprland.widgets import Language, get_hyprland_connection
from fabric.system_tray.widgets import SystemTray
@@ -21,12 +24,9 @@ from .metrics import Metrics
from .music_player import MusicPlayer
from .workspaces import workspace
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
# Monkey patch SystemTray to use GTK theme icons
from fabric.system_tray.widgets import SystemTrayItem
def _patched_do_update_properties(self, *_):
"""Enhanced update method that prioritizes GTK theme icons"""
@@ -65,10 +65,13 @@ def _patched_do_update_properties(self, *_):
# Set tooltip (same as original)
tooltip = self._item.tooltip
self.set_tooltip_markup(
tooltip.description or tooltip.title or self._item.title.title() if self._item.title else "Unknown"
tooltip.description or tooltip.title or self._item.title.title()
if self._item.title
else "Unknown"
)
return
# Apply the patch
SystemTrayItem.do_update_properties = _patched_do_update_properties
@@ -142,7 +145,8 @@ class DockComponents(Box):
# Apply invert style for specific themes and positions
should_invert = data.DOCK_THEME in ["Dense", "Edge"] or (
data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right", "Top"]
data.DOCK_THEME == "Pills"
and data.DOCK_POSITION in ["Left", "Right", "Top"]
)
if should_invert:
+122 -42
View File
@@ -8,9 +8,9 @@ from gi.repository import Gdk
import config.data as data
import utils.icons as icons
from modules.dock.components.player import PlayerContainer
from services.mpris import PlayerManager, PlayerService
from utils.wayland import WaylandWindow as Window
from modules.dock.components.player import PlayerContainer
class MusicPlayerPopup(Window):
@@ -40,7 +40,9 @@ class MusicPlayerPopup(Window):
# Connect player container events to sync with dock
if self.dock_component:
self.player_container.connect("active-player-changed", self.on_active_player_changed)
self.player_container.connect(
"active-player-changed", self.on_active_player_changed
)
# Connect to escape key to close
self.connect("key-press-event", self.on_key_press)
@@ -152,15 +154,19 @@ class MusicPlayer(Box):
# Set up periodic check for active player
from gi.repository import GLib
self.active_player_check_timeout = GLib.timeout_add_seconds(2, self.check_active_player)
self.active_player_check_timeout = GLib.timeout_add_seconds(
2, self.check_active_player
)
# Connect to destroy signal for cleanup
self.connect("destroy", self.on_destroy)
def on_destroy(self, widget):
"""Clean up popup when component is destroyed"""
if hasattr(self, 'active_player_check_timeout'):
if hasattr(self, "active_player_check_timeout"):
from gi.repository import GLib
GLib.source_remove(self.active_player_check_timeout)
if self.popup:
self.popup.destroy()
@@ -175,8 +181,12 @@ class MusicPlayer(Box):
self.set_current_player(player)
else:
# If the new player is playing, switch to it
if (player.props.playback_status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" and
self.current_player.props.playback_status.value_name != "PLAYERCTL_PLAYBACK_STATUS_PLAYING"):
if (
player.props.playback_status.value_name
== "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
and self.current_player.props.playback_status.value_name
!= "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
):
self.set_current_player(player)
self.update_visibility()
@@ -194,26 +204,40 @@ class MusicPlayer(Box):
self.current_player_service = None
# Try to find another player with media content
if hasattr(self.manager, '_manager') and hasattr(self.manager._manager, 'props'):
if hasattr(self.manager, "_manager") and hasattr(
self.manager._manager, "props"
):
for player_name in self.manager._manager.props.player_names:
try:
from gi.repository import Playerctl
candidate_player = Playerctl.Player.new_from_name(player_name)
# Check if this player has media content
if (hasattr(candidate_player, 'props') and
hasattr(candidate_player.props, 'metadata')):
if hasattr(candidate_player, "props") and hasattr(
candidate_player.props, "metadata"
):
metadata = candidate_player.props.metadata
if metadata and len(metadata.keys()) > 0:
keys = metadata.keys()
if ("xesam:title" in keys or
"xesam:artist" in keys or
"mpris:length" in keys):
print(f"[DEBUG] Switching to player with media: {candidate_player.props.player_name}")
if (
"xesam:title" in keys
or "xesam:artist" in keys
or "mpris:length" in keys
):
print(
f"[DEBUG] Switching to player with media: {
candidate_player.props.player_name
}"
)
self.set_current_player(candidate_player)
break
except Exception as e:
print(f"[DEBUG] Error checking candidate player {player_name}: {e}")
print(
f"[DEBUG] Error checking candidate player {player_name}: {
e
}"
)
continue
self.update_visibility()
@@ -221,15 +245,25 @@ class MusicPlayer(Box):
def get_playing_player(self):
"""Find a currently playing player from the manager"""
try:
if hasattr(self.manager, '_manager') and hasattr(self.manager._manager, 'props'):
if hasattr(self.manager, "_manager") and hasattr(
self.manager._manager, "props"
):
for player_name in self.manager._manager.props.player_names:
try:
from gi.repository import Playerctl
player = Playerctl.Player.new_from_name(player_name)
if (hasattr(player, 'props') and
hasattr(player.props, 'playback_status') and
player.props.playback_status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING"):
print(f"[DEBUG] Found playing player: {player.props.player_name}")
if (
hasattr(player, "props")
and hasattr(player.props, "playback_status")
and player.props.playback_status.value_name
== "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
):
print(
f"[DEBUG] Found playing player: {
player.props.player_name
}"
)
return player
except Exception as e:
print(f"[DEBUG] Error checking player {player_name}: {e}")
@@ -246,22 +280,42 @@ class MusicPlayer(Box):
if playing_player:
# If we found a playing player and it's different from current, switch to it
if (not self.current_player or
self.current_player.props.player_name != playing_player.props.player_name):
print(f"[DEBUG] Switching to playing player: {playing_player.props.player_name}")
if (
not self.current_player
or self.current_player.props.player_name
!= playing_player.props.player_name
):
print(
f"[DEBUG] Switching to playing player: {
playing_player.props.player_name
}"
)
self.set_current_player(playing_player)
elif self.current_player:
# If no player is playing, check if current player is paused
if (self.current_player.props.playback_status.value_name != "PLAYERCTL_PLAYBACK_STATUS_PLAYING"):
if (
self.current_player.props.playback_status.value_name
!= "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
):
# Current player is paused, see if there's another player available
if hasattr(self.manager, '_manager') and hasattr(self.manager._manager, 'props'):
if hasattr(self.manager, "_manager") and hasattr(
self.manager._manager, "props"
):
for player_name in self.manager._manager.props.player_names:
try:
from gi.repository import Playerctl
player = Playerctl.Player.new_from_name(player_name)
if player.props.player_name != self.current_player.props.player_name:
if (
player.props.player_name
!= self.current_player.props.player_name
):
# Switch to a different available player
print(f"[DEBUG] Switching to available player: {player.props.player_name}")
print(
f"[DEBUG] Switching to available player: {
player.props.player_name
}"
)
self.set_current_player(player)
break
except:
@@ -320,10 +374,12 @@ class MusicPlayer(Box):
pos_sec = int(pos % 60)
dur_min = int(dur // 60)
dur_sec = int(dur % 60)
timestamp_text = f"{pos_min:02d}:{pos_sec:02d} / {dur_min:02d}:{dur_sec:02d}"
timestamp_text = (
f"{pos_min:02d}:{pos_sec:02d} / {dur_min:02d}:{dur_sec:02d}"
)
# Get current track info if available
if hasattr(self, '_current_track_info'):
if hasattr(self, "_current_track_info"):
tooltip = f"{self._current_track_info}\n{timestamp_text}"
else:
tooltip = f"Music Player\n{timestamp_text}"
@@ -337,7 +393,7 @@ class MusicPlayer(Box):
self.music_label.set_markup(icons.music)
self.progress_bar.remove_style_class("paused")
# Update tooltip to show playing state
if hasattr(self, '_current_track_info'):
if hasattr(self, "_current_track_info"):
self.event_box.set_tooltip_text(f"{self._current_track_info} (Playing)")
# Trigger immediate check for active player when play state changes
@@ -351,7 +407,7 @@ class MusicPlayer(Box):
self.music_label.set_markup(icons.music)
self.progress_bar.add_style_class("paused")
# Update tooltip to show paused state
if hasattr(self, '_current_track_info'):
if hasattr(self, "_current_track_info"):
self.event_box.set_tooltip_text(f"{self._current_track_info} (Paused)")
# Trigger immediate check for active player when pause state changes
@@ -373,12 +429,18 @@ class MusicPlayer(Box):
self._current_track_info = f"{artist} - {title}"
# If we have position info, include timestamp
if hasattr(self, 'position') and hasattr(self, 'duration') and self.duration > 0:
if (
hasattr(self, "position")
and hasattr(self, "duration")
and self.duration > 0
):
pos_min = int(self.position // 60)
pos_sec = int(self.position % 60)
dur_min = int(self.duration // 60)
dur_sec = int(self.duration % 60)
timestamp_text = f"{pos_min:02d}:{pos_sec:02d} / {dur_min:02d}:{dur_sec:02d}"
timestamp_text = (
f"{pos_min:02d}:{pos_sec:02d} / {dur_min:02d}:{dur_sec:02d}"
)
tooltip = f"{self._current_track_info}\n{timestamp_text}"
else:
tooltip = self._current_track_info
@@ -438,29 +500,47 @@ class MusicPlayer(Box):
def has_running_players(self):
"""Check if there are any players that actually have media content running"""
try:
if hasattr(self.manager, '_manager') and hasattr(self.manager._manager, 'props'):
if hasattr(self.manager, "_manager") and hasattr(
self.manager._manager, "props"
):
for player_name in self.manager._manager.props.player_names:
try:
from gi.repository import Playerctl
player = Playerctl.Player.new_from_name(player_name)
# Check if player has metadata indicating actual media content
if hasattr(player, 'props') and hasattr(player.props, 'metadata'):
if hasattr(player, "props") and hasattr(
player.props, "metadata"
):
metadata = player.props.metadata
if metadata and len(metadata.keys()) > 0:
# Check for essential metadata that indicates real media
keys = metadata.keys()
if ("xesam:title" in keys or
"xesam:artist" in keys or
"mpris:length" in keys):
print(f"[DEBUG] Found player with media content: {player.props.player_name}")
if (
"xesam:title" in keys
or "xesam:artist" in keys
or "mpris:length" in keys
):
print(
f"[DEBUG] Found player with media content: {
player.props.player_name
}"
)
return True
# Also check if player is currently playing (even without full metadata)
if (hasattr(player, 'props') and
hasattr(player.props, 'playback_status') and
player.props.playback_status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING"):
print(f"[DEBUG] Found playing player: {player.props.player_name}")
if (
hasattr(player, "props")
and hasattr(player.props, "playback_status")
and player.props.playback_status.value_name
== "PLAYERCTL_PLAYBACK_STATUS_PLAYING"
):
print(
f"[DEBUG] Found playing player: {
player.props.player_name
}"
)
return True
except Exception as e:
+18 -52
View File
@@ -7,33 +7,32 @@ from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import GLib, Gtk, Gdk
from gi.repository import Gdk, GLib, Gtk
import config.data as data
import utils.icons as icons
from modules.notification_popup import NotificationBox
from utils.custom_image import CustomImage
from utils.wayland import WaylandWindow as Window
from utils.notification_utils import (
CONFIG_FILE,
PERSISTENT_DIR,
PERSISTENT_HISTORY_FILE,
CONFIG_FILE,
load_scaled_pixbuf,
get_shared_notification_history,
cleanup_orphan_cached_images,
get_date_header,
schedule_midnight_update,
compute_time_label,
create_historical_notification_from_data,
save_persistent_history
get_date_header,
get_shared_notification_history,
load_scaled_pixbuf,
save_persistent_history,
schedule_midnight_update,
)
from utils.wayland import WaylandWindow as Window
class NotificationIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar-notifications", **kwargs)
self.notification_icon = Label(
name="notification-icon", markup=icons.notifications
)
@@ -46,7 +45,6 @@ class NotificationIndicator(Button):
self.popup_window = None
self._connect_to_dnd_state()
def on_clicked(self, _button):
@@ -64,14 +62,15 @@ class NotificationIndicator(Button):
def _connect_to_dnd_state(self):
try:
self.notification_history = get_shared_notification_history()
self.notification_history.connect(
"dnd-state-changed", self._on_dnd_state_changed
)
self.notification_history.connect('dnd-state-changed', self._on_dnd_state_changed)
self._update_icon_for_dnd_state(self.notification_history.do_not_disturb_enabled)
self._update_icon_for_dnd_state(
self.notification_history.do_not_disturb_enabled
)
except Exception as e:
print(f"Could not connect to DND state: {e}")
@@ -87,9 +86,6 @@ class NotificationIndicator(Button):
self.set_tooltip_text("Notifications")
class NotificationHistory(Box):
def __init__(self, **kwargs):
super().__init__(name="notification-history", orientation="v", **kwargs)
@@ -169,10 +165,8 @@ class NotificationHistory(Box):
cleanup_orphan_cached_images(self.persistent_notifications)
self.schedule_midnight_update()
self._connect_to_shared_history()
self._load_and_sync_dnd_state()
self.LIMITED_APPS_HISTORY = ["Spotify"]
@@ -180,7 +174,9 @@ class NotificationHistory(Box):
def _connect_to_shared_history(self):
try:
shared_history = get_shared_notification_history()
shared_history.connect('notification-added', self._on_shared_notification_added)
shared_history.connect(
"notification-added", self._on_shared_notification_added
)
except Exception as e:
print(f"Could not connect to shared notification history: {e}")
@@ -189,20 +185,17 @@ class NotificationHistory(Box):
def _refresh_history(self):
try:
for child in self.notifications_list.get_children()[:]:
self.notifications_list.remove(child)
if hasattr(child, 'destroy'):
if hasattr(child, "destroy"):
child.destroy()
self.containers.clear()
if os.path.exists(PERSISTENT_HISTORY_FILE):
with open(PERSISTENT_HISTORY_FILE, "r") as f:
self.persistent_notifications = json.load(f)
for note in reversed(self.persistent_notifications[-50:]):
self._add_historical_notification(note)
@@ -212,18 +205,15 @@ class NotificationHistory(Box):
def _load_and_sync_dnd_state(self):
try:
dnd_enabled = False
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
config_data = json.load(f)
dnd_enabled = config_data.get("dnd_enabled", False)
shared_history = get_shared_notification_history()
shared_history.do_not_disturb_enabled = dnd_enabled
self.header_switch.set_active(dnd_enabled)
self.do_not_disturb_enabled = dnd_enabled
@@ -284,8 +274,6 @@ class NotificationHistory(Box):
def on_do_not_disturb_changed(self, switch, pspec):
self.do_not_disturb_enabled = switch.get_active()
try:
shared_history = get_shared_notification_history()
shared_history.set_do_not_disturb_enabled(self.do_not_disturb_enabled)
@@ -305,8 +293,6 @@ class NotificationHistory(Box):
self.notifications_list.remove(child)
child.destroy()
if os.path.exists(PERSISTENT_HISTORY_FILE):
try:
os.remove(PERSISTENT_HISTORY_FILE)
@@ -322,14 +308,11 @@ class NotificationHistory(Box):
if not os.path.exists(PERSISTENT_DIR):
os.makedirs(PERSISTENT_DIR, exist_ok=True)
if os.path.exists(PERSISTENT_HISTORY_FILE):
try:
with open(PERSISTENT_HISTORY_FILE, "r") as f:
self.persistent_notifications = json.load(f)
for note in reversed(self.persistent_notifications[-50:]):
self._add_historical_notification(note)
@@ -349,8 +332,6 @@ class NotificationHistory(Box):
self.no_notifications_box.set_visible(not has_notifications)
self.notifications_list.set_visible(has_notifications)
def _add_historical_notification(self, note):
hist_notif = create_historical_notification_from_data(note)
@@ -497,8 +478,6 @@ class NotificationHistory(Box):
if removed_from_list:
self.persistent_notifications = new_persistent_notifications
self._save_persistent_history()
container.destroy()
self.containers = [c for c in self.containers if c != container]
@@ -507,10 +486,8 @@ class NotificationHistory(Box):
class NotificationHistoryWindow(Window):
def __init__(self):
self.notification_history = NotificationHistory()
scrolled = ScrolledWindow(
name="notifications-scrolled",
child=self.notification_history,
@@ -533,11 +510,9 @@ class NotificationHistoryWindow(Window):
children=[scrolled],
)
dock_position = data.DOCK_POSITION
popup_anchor = self._get_popup_anchor(dock_position)
margin = self._get_popup_margin(dock_position)
super().__init__(
@@ -551,15 +526,12 @@ class NotificationHistoryWindow(Window):
all_visible=True,
)
self.connect("button-press-event", self.on_button_press)
self.connect("key-press-event", self.on_key_press)
self.set_can_focus(True)
self.grab_focus()
self.set_tooltip_text(
"Keyboard shortcuts:\n• Escape: Close popup\n• Ctrl+D: Toggle DND\n• Ctrl+A: Clear all notifications"
)
@@ -586,22 +558,16 @@ class NotificationHistoryWindow(Window):
pass
def on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.set_visible(False)
return True
elif event.state & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_d:
current_dnd = self.notification_history.do_not_disturb_enabled
self.notification_history.header_switch.set_active(not current_dnd)
return True
elif event.state & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_a:
self.notification_history.clear_history()
return True
+40 -22
View File
@@ -1,25 +1,27 @@
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.button import Button
from fabric.widgets.stack import Stack
from fabric.core.service import Signal
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.stack import Stack
from loguru import logger
import config.data as data
import utils.icons as icons
from services.mpris import PlayerManager, PlayerService
from utils.wiggle_bar import WigglyWidget
import utils.icons as icons
import config.data as data
from loguru import logger
class Player(Box):
def __init__(self, player, **kwargs):
super().__init__(style_classes="player", orientation="v", **kwargs)
# Check if we're in vertical mode based on dock position
vertical_mode = data.DOCK_POSITION in ["Left", "Right"] if hasattr(data, 'DOCK_POSITION') else False
vertical_mode = (
data.DOCK_POSITION in ["Left", "Right"]
if hasattr(data, "DOCK_POSITION")
else False
)
if not vertical_mode:
# vertical class binding from unknown source
@@ -42,9 +44,7 @@ class Player(Box):
)
# Use a fallback background image
self.set_style(
f"background-image:url('{data.HOME_DIR}/.current.wall')"
)
self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
self.song = Label(
name="song",
@@ -182,7 +182,11 @@ class Player(Box):
keys = metadata.keys()
if "xesam:artist" in keys and "xesam:title" in keys:
# Check if we're in vertical mode
vertical_mode = data.DOCK_POSITION in ["Left", "Right"] if hasattr(data, 'DOCK_POSITION') else False
vertical_mode = (
data.DOCK_POSITION in ["Left", "Right"]
if hasattr(data, "DOCK_POSITION")
else False
)
_max_chars = 43 if not vertical_mode else 30
song_title = metadata["xesam:title"]
@@ -201,16 +205,21 @@ class Player(Box):
elif "xesam:url" in keys and "youtube.com" in metadata["xesam:url"]:
# Simple YouTube thumbnail extraction for Firefox
import re
url = metadata["xesam:url"]
match = re.search(r'(?:v=|/)([a-zA-Z0-9_-]{11})', url)
match = re.search(r"(?:v=|/)([a-zA-Z0-9_-]{11})", url)
if match:
video_id = match.group(1)
thumbnail_url = f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg"
thumbnail_url = (
f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg"
)
self.set_style(f"background-image:url('{thumbnail_url}')")
print(f"Using YouTube thumbnail: {thumbnail_url}")
else:
# Fallback to default wallpaper
self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
self.set_style(
f"background-image:url('{data.HOME_DIR}/.current.wall')"
)
else:
# Fallback to default wallpaper when no artwork is available
self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')")
@@ -222,7 +231,9 @@ class Player(Box):
self.on_play(self._player)
if player.props.shuffle == True:
self.shuffle_button.get_child().set_markup(icons.shuffle) # Use same icon for now
self.shuffle_button.get_child().set_markup(
icons.shuffle
) # Use same icon for now
self.shuffle_button.get_child().set_name("disable-shuffle")
else:
self.shuffle_button.get_child().set_markup(icons.shuffle)
@@ -250,7 +261,9 @@ class Player(Box):
self.shuffle_button.get_child().set_markup(icons.shuffle)
self.shuffle_button.get_child().set_name("shuffle")
else:
self.shuffle_button.get_child().set_markup(icons.shuffle) # Use same icon for now
self.shuffle_button.get_child().set_markup(
icons.shuffle
) # Use same icon for now
self.shuffle_button.get_child().set_name("disable-shuffle")
self.shuffle_button.get_child().set_style("color: white")
@@ -324,7 +337,11 @@ class PlayerContainer(Box):
)
# Check if we're in vertical mode
vertical_mode = data.DOCK_POSITION in ["Left", "Right"] if hasattr(data, 'DOCK_POSITION') else False
vertical_mode = (
data.DOCK_POSITION in ["Left", "Right"]
if hasattr(data, "DOCK_POSITION")
else False
)
self.player_switch_container = CenterBox(
name="player-switch-container",
@@ -347,7 +364,8 @@ class PlayerContainer(Box):
new_player.gtk_wrapper.queue_draw()
new_player.set_name(player.props.player_name)
self.player.append(new_player)
self.player_objects[player.props.player_name] = player # Store the player object
# Store the player object
self.player_objects[player.props.player_name] = player
print("stacking player")
self.stack.add_named(new_player, player.props.player_name)
+35 -12
View File
@@ -13,9 +13,9 @@ from modules.launcher.result import Result
from services.auth import (
generate_totp,
get_time_remaining_with_blink,
validate_base32_secret,
parse_otpauth_uri,
scan_qr_and_add_account,
validate_base32_secret,
)
@@ -421,7 +421,9 @@ class OTPPlugin(PluginBase):
results.append(
Result(
title=f"{totp_code}",
subtitle_markup=f"{display_name} • {time_display} remaining • Shift+Enter: remove",
subtitle_markup=f"{display_name} • {
time_display
} remaining • Shift+Enter: remove",
icon_markup=icons.key,
action=lambda code=totp_code: self._copy_to_clipboard(code),
relevance=1.0,
@@ -430,7 +432,9 @@ class OTPPlugin(PluginBase):
"type": "totp",
"account": account_name,
"code": totp_code,
"alt_action": lambda acc=account_name: self._remove_account_and_refresh(acc),
"alt_action": lambda acc=account_name: self._remove_account_and_refresh(
acc
),
},
)
)
@@ -488,7 +492,9 @@ class OTPPlugin(PluginBase):
"type": "totp",
"account": account_name,
"code": totp_code,
"alt_action": lambda acc=account_name: self._remove_account_and_refresh(acc),
"alt_action": lambda acc=account_name: self._remove_account_and_refresh(
acc
),
},
)
)
@@ -606,7 +612,9 @@ class OTPPlugin(PluginBase):
title=f"Scan QR Code for '{account_name}'",
subtitle="Click to scan QR code from screen",
icon_markup=icons.connect,
action=lambda name=account_name: self._scan_qr_and_add_account(name),
action=lambda name=account_name: self._scan_qr_and_add_account(
name
),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "qr_scan"},
@@ -620,6 +628,7 @@ class OTPPlugin(PluginBase):
# Run QR scanning asynchronously so launcher closes immediately
import threading
thread = threading.Thread(target=self._scan_qr_async, args=(account_name,))
thread.daemon = True
thread.start()
@@ -688,14 +697,16 @@ class OTPPlugin(PluginBase):
time_display
} remaining",
icon_markup=icons.trash,
action=lambda acc=acc_name: self._remove_account_and_refresh(acc),
action=lambda acc=acc_name: self._remove_account_and_refresh(
acc
),
relevance=0.9,
plugin_name=self.display_name,
data={
"type": "remove_instruction",
"account": acc_name,
"code": totp_code,
"keep_launcher_open": True
"keep_launcher_open": True,
},
)
)
@@ -703,9 +714,11 @@ class OTPPlugin(PluginBase):
results.append(
Result(
title=f"Error: {acc_name}",
subtitle=f"Press Enter to remove (Invalid secret)",
subtitle="Press Enter to remove (Invalid secret)",
icon_markup=icons.trash,
action=lambda acc=acc_name: self._remove_account_and_refresh(acc),
action=lambda acc=acc_name: self._remove_account_and_refresh(
acc
),
relevance=0.8,
plugin_name=self.display_name,
data={
@@ -746,7 +759,11 @@ class OTPPlugin(PluginBase):
action=lambda acc=account_name: self._remove_account_and_refresh(acc),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "remove_confirm", "account": account_name,"keep_launcher_open": True,},
data={
"type": "remove_confirm",
"account": account_name,
"keep_launcher_open": True,
},
)
]
@@ -779,7 +796,9 @@ class OTPPlugin(PluginBase):
return [
Result(
title=f"✓ Added '{account_name}'",
subtitle=f"OTP account added successfully (secret: {result['secret'][:4]}...)",
subtitle=f"OTP account added successfully (secret: {
result['secret'][:4]
}...)",
icon_markup=icons.check,
action=lambda: self._trigger_refresh(),
relevance=1.0,
@@ -814,7 +833,11 @@ class OTPPlugin(PluginBase):
}
self._save_secrets()
display_name = f"{result['issuer']} - {result['account_name']}" if result['issuer'] else result['account_name']
display_name = (
f"{result['issuer']} - {result['account_name']}"
if result["issuer"]
else result["account_name"]
)
return [
Result(
title=f"✓ Added '{display_name}'",
+26 -13
View File
@@ -1,13 +1,15 @@
import subprocess
import time
import json
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pyotp
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
@@ -197,9 +199,7 @@ def validate_base32_secret(secret: str) -> dict:
try:
# Clean up the secret - remove spaces, dashes, and convert to uppercase
clean_secret = (
secret.replace(" ", "").replace("-", "").replace("_", "").upper()
)
clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper()
# Remove any non-base32 characters
clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
@@ -233,7 +233,10 @@ def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
try:
parsed = urlparse(uri)
if parsed.scheme != "otpauth" or parsed.netloc != "totp":
return {"success": False, "error": "Only otpauth://totp/ URIs are supported"}
return {
"success": False,
"error": "Only otpauth://totp/ URIs are supported",
}
if not account_name:
account_path = parsed.path.lstrip("/")
@@ -280,7 +283,10 @@ def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
decoded_objects = decode(img)
if not decoded_objects:
return {"success": False, "error": "No QR code detected in selected area"}
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")
@@ -316,14 +322,21 @@ def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
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)}"}
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']
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}"
"message": f"Successfully added OTP account: {display_name}",
}
else:
return {"success": False, "error": "QR code is not an otpauth URI"}
-335
View File
@@ -1,335 +0,0 @@
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)}"}
+3
View File
@@ -12,6 +12,9 @@
color: var(--surface-bright);
border: 3px solid var(--primary);
}
#music-label{
font-size: 16x;
}
/*
* ── language indicator ────────────────────────────────────────────────
+39 -17
View File
@@ -1,10 +1,6 @@
"""
Common notification utilities and shared functionality
"""
import json
import locale
import os
import uuid
from datetime import datetime, timedelta
from fabric.utils.helpers import get_relative_path
@@ -31,7 +27,9 @@ _shared_notification_history = None
class HistoricalNotification:
"""Data structure for historical notification data"""
def __init__(self, id, app_icon, summary, body, app_name, timestamp, cached_image_path=None):
def __init__(
self, id, app_icon, summary, body, app_name, timestamp, cached_image_path=None
):
self.id = id
self.app_icon = app_icon
self.summary = summary
@@ -51,12 +49,17 @@ def cache_notification_pixbuf(notification_box):
if notification.image_pixbuf:
os.makedirs(PERSISTENT_DIR, exist_ok=True)
cache_file = os.path.join(
PERSISTENT_DIR, f"{NOTIFICATION_IMAGE_PREFIX}{notification_box.uuid}{NOTIFICATION_IMAGE_SUFFIX}"
PERSISTENT_DIR,
f"{NOTIFICATION_IMAGE_PREFIX}{notification_box.uuid}{
NOTIFICATION_IMAGE_SUFFIX
}",
)
try:
scaled = notification.image_pixbuf.scale_simple(
DEFAULT_NOTIFICATION_IMAGE_SIZE, DEFAULT_NOTIFICATION_IMAGE_SIZE, GdkPixbuf.InterpType.BILINEAR
DEFAULT_NOTIFICATION_IMAGE_SIZE,
DEFAULT_NOTIFICATION_IMAGE_SIZE,
GdkPixbuf.InterpType.BILINEAR,
)
scaled.savev(cache_file, "png", [], [])
return cache_file
@@ -84,23 +87,33 @@ def load_scaled_pixbuf(notification_box, width, height):
):
try:
logger.debug(
f"Attempting to load cached image from: {notification_box.cached_image_path} "
f"Attempting to load cached image from: {
notification_box.cached_image_path
} "
f"for notification {notification.id}"
)
pixbuf = GdkPixbuf.Pixbuf.new_from_file(notification_box.cached_image_path)
if pixbuf:
pixbuf = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
pixbuf = pixbuf.scale_simple(
width, height, GdkPixbuf.InterpType.BILINEAR
)
return pixbuf
except Exception as e:
logger.error(
f"Error loading cached image from {notification_box.cached_image_path} "
f"for notification {notification.id}: {e}"
)
logger.warning(f"Falling back to notification.image_pixbuf for notification {notification.id}")
logger.warning(
f"Falling back to notification.image_pixbuf for notification {
notification.id
}"
)
# Fall back to notification image_pixbuf
if notification.image_pixbuf:
pixbuf = notification.image_pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
pixbuf = notification.image_pixbuf.scale_simple(
width, height, GdkPixbuf.InterpType.BILINEAR
)
return pixbuf
# Fall back to app icon
@@ -130,8 +143,10 @@ def cleanup_orphan_cached_images(persistent_notifications):
return
cached_files = [
f for f in os.listdir(PERSISTENT_DIR)
if f.startswith(NOTIFICATION_IMAGE_PREFIX) and f.endswith(NOTIFICATION_IMAGE_SUFFIX)
f
for f in os.listdir(PERSISTENT_DIR)
if f.startswith(NOTIFICATION_IMAGE_PREFIX)
and f.endswith(NOTIFICATION_IMAGE_SUFFIX)
]
if not cached_files:
return
@@ -141,7 +156,9 @@ def cleanup_orphan_cached_images(persistent_notifications):
}
for cached_file in cached_files:
try:
uuid_from_filename = cached_file[len(NOTIFICATION_IMAGE_PREFIX):-len(NOTIFICATION_IMAGE_SUFFIX)]
uuid_from_filename = cached_file[
len(NOTIFICATION_IMAGE_PREFIX) : -len(NOTIFICATION_IMAGE_SUFFIX)
]
if uuid_from_filename not in history_uuids:
cache_file_path = os.path.join(PERSISTENT_DIR, cached_file)
os.remove(cache_file_path)
@@ -256,7 +273,9 @@ def load_persistent_history():
try:
with open(PERSISTENT_HISTORY_FILE, "r") as f:
notifications = json.load(f)
logger.info(f"Loaded {len(notifications)} notifications from persistent history")
logger.info(
f"Loaded {len(notifications)} notifications from persistent history"
)
return notifications
except Exception as e:
logger.error(f"Error loading persistent history: {e}")
@@ -363,13 +382,16 @@ class NotificationHistory(Box):
try:
original_count = len(self.persistent_notifications)
self.persistent_notifications = [
notif for notif in self.persistent_notifications
notif
for notif in self.persistent_notifications
if notif.get("app_name") != app_name
]
removed_count = original_count - len(self.persistent_notifications)
if removed_count > 0:
self._save_persistent_history()
logger.info(f"Cleared {removed_count} notifications for app: {app_name}")
logger.info(
f"Cleared {removed_count} notifications for app: {app_name}"
)
except Exception as e:
print(f"Error clearing history for app {app_name}: {e}")