From 0720649036e2a4a5fafb9940337a23f9fb6c0de6 Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Mon, 19 Jan 2026 22:06:10 +0100 Subject: [PATCH] Use XDG Secrets Portal instead of direct D-Bus access --- cz.bugsy.roster.flathub.json | 8 +- cz.bugsy.roster.json | 3 +- meson.build | 2 +- src/environments_dialog.py | 51 ++-- src/project_manager.py | 302 +++++++++++++++-------- src/request_tab_widget.py | 80 +++--- src/secret_manager.py | 409 ++++++++++++++++++++----------- src/widgets/variable_data_row.py | 33 ++- src/window.py | 130 +++++----- 9 files changed, 640 insertions(+), 378 deletions(-) diff --git a/cz.bugsy.roster.flathub.json b/cz.bugsy.roster.flathub.json index 89c6bf6..13118cb 100644 --- a/cz.bugsy.roster.flathub.json +++ b/cz.bugsy.roster.flathub.json @@ -9,8 +9,7 @@ "--share=ipc", "--socket=fallback-x11", "--device=dri", - "--socket=wayland", - "--talk-name=org.freedesktop.secrets" + "--socket=wayland" ], "cleanup": [ "/include", @@ -32,11 +31,8 @@ { "type": "git", "url": "https://git.bugsy.cz/beval/roster.git", - "tag": "v0.8.1" + "tag": "v0.8.2" } - ], - "config-opts": [ - "--libdir=lib" ] } ] diff --git a/cz.bugsy.roster.json b/cz.bugsy.roster.json index f7ed017..8ac31e4 100644 --- a/cz.bugsy.roster.json +++ b/cz.bugsy.roster.json @@ -9,8 +9,7 @@ "--share=ipc", "--socket=fallback-x11", "--device=dri", - "--socket=wayland", - "--talk-name=org.freedesktop.secrets" + "--socket=wayland" ], "cleanup" : [ "/include", diff --git a/meson.build b/meson.build index 3f5dc48..15043d3 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('roster', - version: '0.8.1', + version: '0.8.2', meson_version: '>= 1.0.0', default_options: [ 'warning_level=2', 'werror=false', ], ) diff --git a/src/environments_dialog.py b/src/environments_dialog.py index 9765aa3..e8c6baf 100644 --- a/src/environments_dialog.py +++ b/src/environments_dialog.py @@ -169,10 +169,12 @@ class EnvironmentsDialog(Adw.Dialog): def on_response(dlg, response): if response == "delete": - self.project_manager.delete_variable(self.project.id, var_name) - self._reload_project() - self._populate_table() - self.emit('environments-updated') + def on_delete_complete(): + self._reload_project() + self._populate_table() + self.emit('environments-updated') + + self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete) dialog.connect("response", on_response) dialog.present(self) @@ -181,10 +183,12 @@ class EnvironmentsDialog(Adw.Dialog): """Handle variable name change.""" new_name = row.get_variable_name() if new_name and new_name != old_name and new_name not in self.project.variable_names: - self.project_manager.rename_variable(self.project.id, old_name, new_name) - self._reload_project() - self._populate_table() - self.emit('environments-updated') + def on_rename_complete(): + self._reload_project() + self._populate_table() + self.emit('environments-updated') + + self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete) @Gtk.Template.Callback() def on_add_environment_clicked(self, button): @@ -267,10 +271,12 @@ class EnvironmentsDialog(Adw.Dialog): def on_response(dlg, response): if response == "delete": - self.project_manager.delete_environment(self.project.id, environment.id) - self._reload_project() - self._populate_table() - self.emit('environments-updated') + def on_delete_complete(): + self._reload_project() + self._populate_table() + self.emit('environments-updated') + + self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete) dialog.connect("response", on_response) dialog.present(self) @@ -283,22 +289,19 @@ class EnvironmentsDialog(Adw.Dialog): def _on_sensitivity_changed(self, widget, is_sensitive, var_name): """Handle variable sensitivity toggle.""" + def on_complete(success): + if success: + # Reload and refresh UI + self._reload_project() + self._populate_table() + self.emit('environments-updated') + if is_sensitive: # Mark as sensitive (move to keyring) - success = self.project_manager.mark_variable_as_sensitive(self.project.id, var_name) - if success: - # Reload and refresh UI - self._reload_project() - self._populate_table() - self.emit('environments-updated') + self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete) else: # Mark as non-sensitive (move to JSON) - success = self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name) - if success: - # Reload and refresh UI - self._reload_project() - self._populate_table() - self.emit('environments-updated') + self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete) def _reload_project(self): """Reload project data from manager.""" diff --git a/src/project_manager.py b/src/project_manager.py index 94cb35f..27edca7 100644 --- a/src/project_manager.py +++ b/src/project_manager.py @@ -6,7 +6,7 @@ # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. -# +# # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of @@ -22,7 +22,7 @@ import json import uuid import logging from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Callable from datetime import datetime, timezone import gi gi.require_version('GLib', '2.0') @@ -132,18 +132,23 @@ class ProjectManager: break self.save_projects(projects) - def delete_project(self, project_id: str): - """Delete a project and all its requests.""" + def delete_project(self, project_id: str, + callback: Optional[Callable[[], None]] = None): + """Delete a project and all its requests (async for secret cleanup).""" projects = self.load_projects() secret_manager = get_secret_manager() - # Delete all secrets for this project - secret_manager.delete_all_project_secrets(project_id) - - # Remove project from list + # Remove project from list and save immediately projects = [p for p in projects if p.id != project_id] self.save_projects(projects) + # Delete all secrets for this project asynchronously + def on_secrets_deleted(success): + if callback: + callback() + + secret_manager.delete_all_project_secrets(project_id, on_secrets_deleted) + def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest: """Add request to a project.""" projects = self.load_projects() @@ -239,20 +244,27 @@ class ProjectManager: break self.save_projects(projects) - def delete_environment(self, project_id: str, env_id: str): - """Delete an environment.""" + def delete_environment(self, project_id: str, env_id: str, + callback: Optional[Callable[[], None]] = None): + """Delete an environment (async for secret cleanup).""" projects = self.load_projects() secret_manager = get_secret_manager() for p in projects: if p.id == project_id: - # Delete all secrets for this environment - secret_manager.delete_all_environment_secrets(project_id, env_id) # Remove environment from list p.environments = [e for e in p.environments if e.id != env_id] break + self.save_projects(projects) + # Delete all secrets for this environment asynchronously + def on_secrets_deleted(success): + if callback: + callback() + + secret_manager.delete_all_environment_secrets(project_id, env_id, on_secrets_deleted) + def add_variable(self, project_id: str, variable_name: str): """Add a variable to the project and all environments.""" projects = self.load_projects() @@ -266,10 +278,12 @@ class ProjectManager: break self.save_projects(projects) - def rename_variable(self, project_id: str, old_name: str, new_name: str): - """Rename a variable in the project and all environments.""" + def rename_variable(self, project_id: str, old_name: str, new_name: str, + callback: Optional[Callable[[], None]] = None): + """Rename a variable in the project and all environments (async for secrets).""" projects = self.load_projects() secret_manager = get_secret_manager() + is_sensitive = False for p in projects: if p.id == project_id: @@ -278,24 +292,33 @@ class ProjectManager: idx = p.variable_names.index(old_name) p.variable_names[idx] = new_name - # Update sensitive_variables list if applicable + # Check if sensitive and update list if old_name in p.sensitive_variables: + is_sensitive = True idx_sensitive = p.sensitive_variables.index(old_name) p.sensitive_variables[idx_sensitive] = new_name - # Rename secrets in keyring - secret_manager.rename_variable_secrets(project_id, old_name, new_name) # Update all environments for env in p.environments: if old_name in env.variables: env.variables[new_name] = env.variables.pop(old_name) break + self.save_projects(projects) - def delete_variable(self, project_id: str, variable_name: str): - """Delete a variable from the project and all environments.""" + # Rename secrets in keyring if sensitive + if is_sensitive: + secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None) + elif callback: + callback() + + def delete_variable(self, project_id: str, variable_name: str, + callback: Optional[Callable[[], None]] = None): + """Delete a variable from the project and all environments (async for secrets).""" projects = self.load_projects() secret_manager = get_secret_manager() + is_sensitive = False + environments_to_cleanup = [] for p in projects: if p.id == project_id: @@ -303,19 +326,33 @@ class ProjectManager: if variable_name in p.variable_names: p.variable_names.remove(variable_name) - # Remove from sensitive_variables if applicable + # Check if sensitive and get environments for cleanup if variable_name in p.sensitive_variables: + is_sensitive = True p.sensitive_variables.remove(variable_name) - # Delete all secrets for this variable - for env in p.environments: - secret_manager.delete_secret(project_id, env.id, variable_name) + environments_to_cleanup = [env.id for env in p.environments] # Remove from all environments for env in p.environments: env.variables.pop(variable_name, None) break + self.save_projects(projects) + # Delete secrets for this variable asynchronously + if is_sensitive and environments_to_cleanup: + pending_count = [len(environments_to_cleanup)] + + def on_single_delete(success): + pending_count[0] -= 1 + if pending_count[0] == 0 and callback: + callback() + + for env_id in environments_to_cleanup: + secret_manager.delete_secret(project_id, env_id, variable_name, on_single_delete) + elif callback: + callback() + def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str): """Update a single variable value in an environment.""" projects = self.load_projects() @@ -351,9 +388,10 @@ class ProjectManager: # ========== Sensitive Variable Management ========== - def mark_variable_as_sensitive(self, project_id: str, variable_name: str) -> bool: + def mark_variable_as_sensitive(self, project_id: str, variable_name: str, + callback: Optional[Callable[[bool], None]] = None): """ - Mark a variable as sensitive (move values from JSON to keyring). + Mark a variable as sensitive (move values from JSON to keyring) - async. This will: 1. Add variable to sensitive_variables list @@ -363,12 +401,11 @@ class ProjectManager: Args: project_id: Project ID variable_name: Variable name to mark as sensitive - - Returns: - True if successful + callback: Optional callback called with success boolean """ projects = self.load_projects() secret_manager = get_secret_manager() + secrets_to_store = [] for p in projects: if p.id == project_id: @@ -376,30 +413,56 @@ class ProjectManager: if variable_name not in p.sensitive_variables: p.sensitive_variables.append(variable_name) - # Move all environment values to keyring + # Collect all environment values to store in keyring for env in p.environments: if variable_name in env.variables: value = env.variables[variable_name] - # Store in keyring - secret_manager.store_secret( - project_id=project_id, - environment_id=env.id, - variable_name=variable_name, - value=value, - project_name=p.name, - environment_name=env.name - ) + secrets_to_store.append({ + 'environment_id': env.id, + 'environment_name': env.name, + 'value': value, + 'project_name': p.name + }) # Clear from JSON (keep empty placeholder) env.variables[variable_name] = "" self.save_projects(projects) - return True - return False + # Store all secrets asynchronously + if not secrets_to_store: + if callback: + callback(True) + return - def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str) -> bool: + pending_count = [len(secrets_to_store)] + all_success = [True] + + def on_single_store(success): + if not success: + all_success[0] = False + pending_count[0] -= 1 + if pending_count[0] == 0 and callback: + callback(all_success[0]) + + for secret_info in secrets_to_store: + secret_manager.store_secret( + project_id=project_id, + environment_id=secret_info['environment_id'], + variable_name=variable_name, + value=secret_info['value'], + project_name=secret_info['project_name'], + environment_name=secret_info['environment_name'], + callback=on_single_store + ) + return + + if callback: + callback(False) + + def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str, + callback: Optional[Callable[[bool], None]] = None): """ - Mark a variable as non-sensitive (move values from keyring to JSON). + Mark a variable as non-sensitive (move values from keyring to JSON) - async. This will: 1. Remove variable from sensitive_variables list @@ -409,40 +472,62 @@ class ProjectManager: Args: project_id: Project ID variable_name: Variable name to mark as non-sensitive - - Returns: - True if successful + callback: Optional callback called with success boolean """ projects = self.load_projects() secret_manager = get_secret_manager() + target_project = None + environments_to_migrate = [] + for p in projects: if p.id == project_id: + target_project = p # Remove from sensitive list if variable_name in p.sensitive_variables: p.sensitive_variables.remove(variable_name) - # Move all environment values from keyring to JSON - for env in p.environments: - # Retrieve from keyring - value = secret_manager.retrieve_secret( - project_id=project_id, - environment_id=env.id, - variable_name=variable_name - ) - # Store in JSON - env.variables[variable_name] = value or "" - # Delete from keyring - secret_manager.delete_secret( - project_id=project_id, - environment_id=env.id, - variable_name=variable_name - ) + environments_to_migrate = list(p.environments) + break + if not target_project or not environments_to_migrate: + if callback: + callback(False) + return + + # Retrieve all secrets, then update JSON, then delete from keyring + pending_count = [len(environments_to_migrate)] + all_success = [True] + + def on_single_migrate(env): + def on_retrieve(value): + # Store in JSON + env.variables[variable_name] = value or "" self.save_projects(projects) - return True - return False + # Delete from keyring + def on_delete(success): + if not success: + all_success[0] = False + pending_count[0] -= 1 + if pending_count[0] == 0 and callback: + callback(all_success[0]) + + secret_manager.delete_secret( + project_id=project_id, + environment_id=env.id, + variable_name=variable_name, + callback=on_delete + ) + return on_retrieve + + for env in environments_to_migrate: + secret_manager.retrieve_secret( + project_id=project_id, + environment_id=env.id, + variable_name=variable_name, + callback=on_single_migrate(env) + ) def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool: """Check if a variable is marked as sensitive.""" @@ -452,9 +537,10 @@ class ProjectManager: return variable_name in p.sensitive_variables return False - def get_variable_value(self, project_id: str, env_id: str, variable_name: str) -> str: + def get_variable_value(self, project_id: str, env_id: str, variable_name: str, + callback: Callable[[str], None]): """ - Get variable value from appropriate storage (keyring or JSON). + Get variable value from appropriate storage (keyring or JSON) - async. This is a convenience method that handles both sensitive and non-sensitive variables. @@ -462,9 +548,7 @@ class ProjectManager: project_id: Project ID env_id: Environment ID variable_name: Variable name - - Returns: - Variable value (empty string if not found) + callback: Callback called with variable value (empty string if not found) """ projects = self.load_projects() @@ -472,25 +556,32 @@ class ProjectManager: if p.id == project_id: # Check if sensitive if variable_name in p.sensitive_variables: - # Retrieve from keyring + # Retrieve from keyring asynchronously secret_manager = get_secret_manager() - value = secret_manager.retrieve_secret( + + def on_retrieve(value): + callback(value or "") + + secret_manager.retrieve_secret( project_id=project_id, environment_id=env_id, - variable_name=variable_name + variable_name=variable_name, + callback=on_retrieve ) - return value or "" + return else: - # Retrieve from JSON + # Retrieve from JSON synchronously for env in p.environments: if env.id == env_id: - return env.variables.get(variable_name, "") + callback(env.variables.get(variable_name, "")) + return - return "" + callback("") - def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str): + def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str, + callback: Optional[Callable[[bool], None]] = None): """ - Set variable value in appropriate storage (keyring or JSON). + Set variable value in appropriate storage (keyring or JSON) - async. This is a convenience method that handles both sensitive and non-sensitive variables. @@ -499,6 +590,7 @@ class ProjectManager: env_id: Environment ID variable_name: Variable name value: Value to set + callback: Optional callback called with success boolean """ projects = self.load_projects() secret_manager = get_secret_manager() @@ -507,7 +599,7 @@ class ProjectManager: if p.id == project_id: # Check if sensitive if variable_name in p.sensitive_variables: - # Store in keyring + # Store in keyring asynchronously for env in p.environments: if env.id == env_id: secret_manager.store_secret( @@ -516,24 +608,32 @@ class ProjectManager: variable_name=variable_name, value=value, project_name=p.name, - environment_name=env.name + environment_name=env.name, + callback=callback ) # Keep empty placeholder in JSON env.variables[variable_name] = "" - break + self.save_projects(projects) + return else: - # Store in JSON + # Store in JSON synchronously for env in p.environments: if env.id == env_id: env.variables[variable_name] = value break - self.save_projects(projects) - return + self.save_projects(projects) + if callback: + callback(True) + return - def get_environment_with_secrets(self, project_id: str, env_id: str) -> Optional[Environment]: + if callback: + callback(False) + + def get_environment_with_secrets(self, project_id: str, env_id: str, + callback: Callable[[Optional[Environment]], None]): """ - Get an environment with all variable values (including secrets from keyring). + Get an environment with all variable values (including secrets from keyring) - async. This is useful for variable substitution - it returns a complete Environment object with all values populated from both JSON and keyring. @@ -541,9 +641,7 @@ class ProjectManager: Args: project_id: Project ID env_id: Environment ID - - Returns: - Environment object with all values, or None if not found + callback: Callback called with Environment object (or None if not found) """ projects = self.load_projects() secret_manager = get_secret_manager() @@ -560,16 +658,24 @@ class ProjectManager: created_at=env.created_at ) + # If no sensitive variables, return immediately + if not p.sensitive_variables: + callback(complete_env) + return + # Fill in sensitive variable values from keyring - for var_name in p.sensitive_variables: - value = secret_manager.retrieve_secret( - project_id=project_id, - environment_id=env_id, - variable_name=var_name - ) - if value is not None: - complete_env.variables[var_name] = value + def on_secrets_retrieved(secrets_dict): + for var_name, value in secrets_dict.items(): + if value is not None: + complete_env.variables[var_name] = value + callback(complete_env) - return complete_env + secret_manager.retrieve_multiple_secrets( + project_id=project_id, + environment_id=env_id, + variable_names=list(p.sensitive_variables), + callback=on_secrets_retrieved + ) + return - return None + callback(None) diff --git a/src/request_tab_widget.py b/src/request_tab_widget.py index 3d695a7..bf8e6c3 100644 --- a/src/request_tab_widget.py +++ b/src/request_tab_widget.py @@ -1318,45 +1318,55 @@ class RequestTabWidget(Gtk.Box): # Always update visual indicators when environment changes self._update_variable_indicators() - def get_selected_environment(self): + def get_selected_environment(self, callback): """ - Get the currently selected environment object with all values. + Get the currently selected environment object with all values (async). This includes sensitive variable values from the keyring. + + Args: + callback: Function called with the Environment object (or None) """ if not self.selected_environment_id or not self.project_manager or not self.project_id: - return None + callback(None) + return - # Get environment with secrets (includes values from keyring) - return self.project_manager.get_environment_with_secrets( + # Get environment with secrets (includes values from keyring) - async + self.project_manager.get_environment_with_secrets( self.project_id, - self.selected_environment_id + self.selected_environment_id, + callback ) - def _detect_undefined_variables(self): - """Detect undefined variables in the current request. Returns set of undefined variable names.""" + def _detect_undefined_variables(self, callback): + """Detect undefined variables in the current request (async). + + Args: + callback: Function called with set of undefined variable names + """ from .variable_substitution import VariableSubstitution # Get current request from UI request = self.get_request() - # Get selected environment - env = self.get_selected_environment() + def on_environment_loaded(env): + if not env: + # No environment selected - ALL variables are undefined + # Find all variables in the request + all_vars = set() + all_vars.update(VariableSubstitution.find_variables(request.url)) + all_vars.update(VariableSubstitution.find_variables(request.body)) + for key, value in request.headers.items(): + all_vars.update(VariableSubstitution.find_variables(key)) + all_vars.update(VariableSubstitution.find_variables(value)) + callback(all_vars) + else: + # Environment selected - find which variables are undefined + _, undefined = VariableSubstitution.substitute_request(request, env) + callback(undefined) - if not env: - # No environment selected - ALL variables are undefined - # Find all variables in the request - all_vars = set() - all_vars.update(VariableSubstitution.find_variables(request.url)) - all_vars.update(VariableSubstitution.find_variables(request.body)) - for key, value in request.headers.items(): - all_vars.update(VariableSubstitution.find_variables(key)) - all_vars.update(VariableSubstitution.find_variables(value)) - return all_vars - else: - # Environment selected - find which variables are undefined - _, undefined = VariableSubstitution.substitute_request(request, env) - return undefined + # Get selected environment asynchronously + self.get_selected_environment(on_environment_loaded) def _schedule_indicator_update(self): """Schedule an indicator update with debouncing.""" @@ -1374,22 +1384,26 @@ class RequestTabWidget(Gtk.Box): return False # Don't repeat def _update_variable_indicators(self): - """Update visual indicators for undefined variables.""" + """Update visual indicators for undefined variables (async).""" # Only update if we have a project (variables only make sense in project context) if not self.project_id: return - # Detect undefined variables - self.undefined_variables = self._detect_undefined_variables() + def on_undefined_detected(undefined_vars): + # Store detected undefined variables + self.undefined_variables = undefined_vars - # Update URL entry - self._update_url_indicator() + # Update URL entry + self._update_url_indicator() - # Update headers - self._update_header_indicators() + # Update headers + self._update_header_indicators() - # Update body - self._update_body_indicators() + # Update body + self._update_body_indicators() + + # Detect undefined variables asynchronously + self._detect_undefined_variables(on_undefined_detected) def _update_url_indicator(self): """Update warning indicator on URL entry.""" diff --git a/src/secret_manager.py b/src/secret_manager.py index 315033e..abfe12d 100644 --- a/src/secret_manager.py +++ b/src/secret_manager.py @@ -23,6 +23,9 @@ Manager for storing and retrieving sensitive variable values using GNOME Keyring This module provides a clean interface to libsecret for storing environment variable values that contain sensitive data (API keys, passwords, tokens). +Uses async APIs for compatibility with the XDG Secrets Portal in sandboxed +environments (Flatpak). + Each secret is identified by: - project_id: UUID of the project - environment_id: UUID of the environment @@ -31,8 +34,9 @@ Each secret is identified by: import gi gi.require_version('Secret', '1') -from gi.repository import Secret, GLib +from gi.repository import Secret, GLib, Gio import logging +from typing import Callable, Optional, Any logger = logging.getLogger(__name__) @@ -50,7 +54,10 @@ ROSTER_SCHEMA = Secret.Schema.new( class SecretManager: - """Manages sensitive variable storage using GNOME Keyring via libsecret.""" + """Manages sensitive variable storage using GNOME Keyring via libsecret. + + All methods use async APIs for compatibility with the XDG Secrets Portal. + """ def __init__(self): """Initialize the secret manager.""" @@ -58,9 +65,10 @@ class SecretManager: def store_secret(self, project_id: str, environment_id: str, variable_name: str, value: str, project_name: str = "", - environment_name: str = "") -> bool: + environment_name: str = "", + callback: Optional[Callable[[bool], None]] = None): """ - Store a sensitive variable value in GNOME Keyring. + Store a sensitive variable value in GNOME Keyring (async). Args: project_id: UUID of the project @@ -69,9 +77,7 @@ class SecretManager: value: The secret value to store project_name: Optional human-readable project name (for display in Seahorse) environment_name: Optional human-readable environment name (for display) - - Returns: - True if successful, False otherwise + callback: Optional callback function called with success boolean """ # Create a descriptive label for the keyring UI label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \ @@ -84,36 +90,40 @@ class SecretManager: "variable_name": variable_name, } - try: - # Store the secret synchronously - # Using PASSWORD collection (default keyring) - Secret.password_store_sync( - self.schema, - attributes, - Secret.COLLECTION_DEFAULT, - label, - value, - None # cancellable - ) - logger.info(f"Stored secret for {variable_name} in {environment_id}") - return True + def on_store_complete(source, result, user_data): + try: + Secret.password_store_finish(result) + logger.info(f"Stored secret for {variable_name} in {environment_id}") + if callback: + callback(True) + except GLib.Error as e: + logger.error(f"Failed to store secret: {e.message}") + if callback: + callback(False) - except GLib.Error as e: - logger.error(f"Failed to store secret: {e.message}") - return False + # Store the secret asynchronously + Secret.password_store( + self.schema, + attributes, + Secret.COLLECTION_DEFAULT, + label, + value, + None, # cancellable + on_store_complete, + None # user_data + ) def retrieve_secret(self, project_id: str, environment_id: str, - variable_name: str) -> str | None: + variable_name: str, + callback: Callable[[Optional[str]], None]): """ - Retrieve a sensitive variable value from GNOME Keyring. + Retrieve a sensitive variable value from GNOME Keyring (async). Args: project_id: UUID of the project environment_id: UUID of the environment variable_name: Name of the variable - - Returns: - The secret value if found, None otherwise + callback: Callback function called with the secret value (or None if not found) """ attributes = { "project_id": project_id, @@ -121,37 +131,38 @@ class SecretManager: "variable_name": variable_name, } - try: - # Retrieve the secret synchronously - value = Secret.password_lookup_sync( - self.schema, - attributes, - None # cancellable - ) + def on_lookup_complete(source, result, user_data): + try: + value = Secret.password_lookup_finish(result) + if value is None: + logger.debug(f"No secret found for {variable_name}") + else: + logger.debug(f"Retrieved secret for {variable_name}") + callback(value) + except GLib.Error as e: + logger.error(f"Failed to retrieve secret: {e.message}") + callback(None) - if value is None: - logger.debug(f"No secret found for {variable_name}") - else: - logger.debug(f"Retrieved secret for {variable_name}") - - return value - - except GLib.Error as e: - logger.error(f"Failed to retrieve secret: {e.message}") - return None + # Retrieve the secret asynchronously + Secret.password_lookup( + self.schema, + attributes, + None, # cancellable + on_lookup_complete, + None # user_data + ) def delete_secret(self, project_id: str, environment_id: str, - variable_name: str) -> bool: + variable_name: str, + callback: Optional[Callable[[bool], None]] = None): """ - Delete a sensitive variable value from GNOME Keyring. + Delete a sensitive variable value from GNOME Keyring (async). Args: project_id: UUID of the project environment_id: UUID of the environment variable_name: Name of the variable - - Returns: - True if deleted (or didn't exist), False on error + callback: Optional callback function called with success boolean """ attributes = { "project_id": project_id, @@ -159,85 +170,98 @@ class SecretManager: "variable_name": variable_name, } - try: - # Delete the secret synchronously - deleted = Secret.password_clear_sync( - self.schema, - attributes, - None # cancellable - ) + def on_clear_complete(source, result, user_data): + try: + deleted = Secret.password_clear_finish(result) + if deleted: + logger.info(f"Deleted secret for {variable_name}") + else: + logger.debug(f"No secret to delete for {variable_name}") + if callback: + callback(True) + except GLib.Error as e: + logger.error(f"Failed to delete secret: {e.message}") + if callback: + callback(False) - if deleted: - logger.info(f"Deleted secret for {variable_name}") - else: - logger.debug(f"No secret to delete for {variable_name}") + # Delete the secret asynchronously + Secret.password_clear( + self.schema, + attributes, + None, # cancellable + on_clear_complete, + None # user_data + ) - return True - - except GLib.Error as e: - logger.error(f"Failed to delete secret: {e.message}") - return False - - def delete_all_project_secrets(self, project_id: str) -> bool: + def delete_all_project_secrets(self, project_id: str, + callback: Optional[Callable[[bool], None]] = None): """ Delete all secrets for a project (when project is deleted). Args: project_id: UUID of the project - - Returns: - True if successful, False otherwise + callback: Optional callback function called with success boolean """ attributes = { "project_id": project_id, } - try: - # Delete all matching secrets - deleted = Secret.password_clear_sync( - self.schema, - attributes, - None - ) + def on_clear_complete(source, result, user_data): + try: + Secret.password_clear_finish(result) + logger.info(f"Deleted all secrets for project {project_id}") + if callback: + callback(True) + except GLib.Error as e: + logger.error(f"Failed to delete project secrets: {e.message}") + if callback: + callback(False) - logger.info(f"Deleted all secrets for project {project_id}") - return True + # Delete all matching secrets asynchronously + Secret.password_clear( + self.schema, + attributes, + None, + on_clear_complete, + None + ) - except GLib.Error as e: - logger.error(f"Failed to delete project secrets: {e.message}") - return False - - def delete_all_environment_secrets(self, project_id: str, environment_id: str) -> bool: + def delete_all_environment_secrets(self, project_id: str, environment_id: str, + callback: Optional[Callable[[bool], None]] = None): """ Delete all secrets for an environment (when environment is deleted). Args: project_id: UUID of the project environment_id: UUID of the environment - - Returns: - True if successful, False otherwise + callback: Optional callback function called with success boolean """ attributes = { "project_id": project_id, "environment_id": environment_id, } - try: - deleted = Secret.password_clear_sync( - self.schema, - attributes, - None - ) + def on_clear_complete(source, result, user_data): + try: + Secret.password_clear_finish(result) + logger.info(f"Deleted all secrets for environment {environment_id}") + if callback: + callback(True) + except GLib.Error as e: + logger.error(f"Failed to delete environment secrets: {e.message}") + if callback: + callback(False) - logger.info(f"Deleted all secrets for environment {environment_id}") - return True + Secret.password_clear( + self.schema, + attributes, + None, + on_clear_complete, + None + ) - except GLib.Error as e: - logger.error(f"Failed to delete environment secrets: {e.message}") - return False - - def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str) -> bool: + def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str, + callback: Optional[Callable[[bool], None]] = None): """ Rename a variable across all environments (updates secret keys). @@ -250,65 +274,174 @@ class SecretManager: project_id: UUID of the project old_name: Current variable name new_name: New variable name - - Returns: - True if successful, False otherwise + callback: Optional callback function called with success boolean """ - # Search for all secrets with the old variable name attributes = { "project_id": project_id, "variable_name": old_name, } - try: - # Search for matching secrets - # This returns a list of Secret.Item objects - secrets = Secret.password_search_sync( - self.schema, - attributes, - Secret.SearchFlags.ALL, - None - ) + def on_search_complete(source, result, user_data): + try: + secrets = Secret.password_search_finish(result) - if not secrets: - logger.debug(f"No secrets found for variable {old_name}") - return True + if not secrets: + logger.debug(f"No secrets found for variable {old_name}") + if callback: + callback(True) + return - # For each secret, retrieve value, store with new name, delete old - for item in secrets: - # Get the secret value - item.load_secret_sync(None) - value = item.get_secret().get_text() + # Track how many secrets we need to process + pending_count = [len(secrets)] + all_success = [True] - # Get environment_id from attributes + def on_rename_done(success): + pending_count[0] -= 1 + if not success: + all_success[0] = False + if pending_count[0] == 0: + logger.info(f"Renamed variable secrets from {old_name} to {new_name}") + if callback: + callback(all_success[0]) + + # For each secret, retrieve value, store with new name, delete old + for item in secrets: + self._rename_single_secret(item, project_id, old_name, new_name, on_rename_done) + + except GLib.Error as e: + logger.error(f"Failed to search for variable secrets: {e.message}") + if callback: + callback(False) + + # Search for matching secrets asynchronously + Secret.password_search( + self.schema, + attributes, + Secret.SearchFlags.ALL, + None, + on_search_complete, + None + ) + + def _rename_single_secret(self, item, project_id: str, old_name: str, + new_name: str, callback: Callable[[bool], None]): + """Helper to rename a single secret item.""" + + def on_load_complete(item, result, user_data): + try: + item.load_secret_finish(result) + secret = item.get_secret() + if secret is None: + callback(False) + return + + value = secret.get_text() attrs = item.get_attributes() environment_id = attrs.get("environment_id") if not environment_id: - logger.warning(f"Secret missing environment_id, skipping") - continue + logger.warning("Secret missing environment_id, skipping") + callback(False) + return + + # Store with new name, then delete old + def on_store_done(success): + if success: + self.delete_secret(project_id, environment_id, old_name, callback) + else: + callback(False) - # Store with new name self.store_secret( project_id=project_id, environment_id=environment_id, variable_name=new_name, - value=value + value=value, + callback=on_store_done ) - # Delete old secret - self.delete_secret( - project_id=project_id, - environment_id=environment_id, - variable_name=old_name - ) + except GLib.Error as e: + logger.error(f"Failed to load secret for rename: {e.message}") + callback(False) - logger.info(f"Renamed variable secrets from {old_name} to {new_name}") - return True + item.load_secret(None, on_load_complete, None) - except GLib.Error as e: - logger.error(f"Failed to rename variable secrets: {e.message}") - return False + # ========== Batch Operations ========== + + def retrieve_multiple_secrets(self, project_id: str, environment_id: str, + variable_names: list, + callback: Callable[[dict], None]): + """ + Retrieve multiple secrets at once (async). + + Args: + project_id: UUID of the project + environment_id: UUID of the environment + variable_names: List of variable names to retrieve + callback: Callback function called with dict of {variable_name: value} + """ + if not variable_names: + callback({}) + return + + results = {} + pending_count = [len(variable_names)] + + def on_single_retrieve(var_name): + def handler(value): + results[var_name] = value + pending_count[0] -= 1 + if pending_count[0] == 0: + callback(results) + return handler + + for var_name in variable_names: + self.retrieve_secret( + project_id=project_id, + environment_id=environment_id, + variable_name=var_name, + callback=on_single_retrieve(var_name) + ) + + def store_multiple_secrets(self, project_id: str, environment_id: str, + variables: dict, project_name: str = "", + environment_name: str = "", + callback: Optional[Callable[[bool], None]] = None): + """ + Store multiple secrets at once (async). + + Args: + project_id: UUID of the project + environment_id: UUID of the environment + variables: Dict of {variable_name: value} to store + project_name: Optional project name for display + environment_name: Optional environment name for display + callback: Optional callback called with overall success boolean + """ + if not variables: + if callback: + callback(True) + return + + pending_count = [len(variables)] + all_success = [True] + + def on_single_store(success): + if not success: + all_success[0] = False + pending_count[0] -= 1 + if pending_count[0] == 0 and callback: + callback(all_success[0]) + + for var_name, value in variables.items(): + self.store_secret( + project_id=project_id, + environment_id=environment_id, + variable_name=var_name, + value=value, + project_name=project_name, + environment_name=environment_name, + callback=on_single_store + ) # Singleton instance diff --git a/src/widgets/variable_data_row.py b/src/widgets/variable_data_row.py index 698a846..a21f54c 100644 --- a/src/widgets/variable_data_row.py +++ b/src/widgets/variable_data_row.py @@ -107,22 +107,12 @@ class VariableDataRow(Gtk.Box): entry.set_placeholder_text(env.name) entry.set_hexpand(True) - # Get value from appropriate storage (keyring or JSON) - value = self.project_manager.get_variable_value( - self.project.id, - env.id, - self.variable_name - ) - entry.set_text(value) - # Configure entry for sensitive variables if is_sensitive: entry.set_visibility(False) # Show bullets instead of text entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) entry.add_css_class("sensitive-variable") - entry.connect('changed', self._on_value_changed, env.id) - entry_box.append(entry) # Add entry box to size group for alignment @@ -132,17 +122,34 @@ class VariableDataRow(Gtk.Box): self.values_box.append(entry_box) self.value_entries[env.id] = entry + # Get value from appropriate storage (keyring or JSON) asynchronously + def on_value_retrieved(value, entry=entry, env_id=env.id): + entry.set_text(value) + # Connect changed handler after setting initial value to avoid spurious signals + entry.connect('changed', self._on_value_changed, env_id) + + self.project_manager.get_variable_value( + self.project.id, + env.id, + self.variable_name, + on_value_retrieved + ) + def _on_value_changed(self, entry, env_id): """Handle value entry changes.""" value = entry.get_text() - # Use project_manager to store value in appropriate location + + # Emit signal first (value is being changed) + self.emit('value-changed', env_id, value) + + # Use project_manager to store value in appropriate location (async) self.project_manager.set_variable_value( self.project.id, env_id, self.variable_name, - value + value, + callback=None # Fire and forget - errors are logged ) - self.emit('value-changed', env_id, value) def _on_name_entry_focus_in(self, controller): """Show edit buttons when entry gains focus.""" diff --git a/src/window.py b/src/window.py index d0ed200..4272990 100644 --- a/src/window.py +++ b/src/window.py @@ -535,10 +535,13 @@ class RosterWindow(Adw.ApplicationWindow): if preprocessing_result.modified_request: modified_request = preprocessing_result.modified_request - # Apply variable substitution to (possibly modified) request - substituted_request = modified_request - if widget.selected_environment_id: - env = widget.get_selected_environment() + # Disable send button during request + widget.send_button.set_sensitive(False) + + def proceed_with_request(env): + """Continue with request after environment is loaded.""" + # Apply variable substitution to (possibly modified) request + substituted_request = modified_request if env: from .variable_substitution import VariableSubstitution substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env) @@ -546,65 +549,60 @@ class RosterWindow(Adw.ApplicationWindow): if undefined: logger.warning(f"Undefined variables in request: {', '.join(undefined)}") - # Disable send button during request - widget.send_button.set_sensitive(False) + # Execute async + def callback(response, error, user_data): + """Callback runs on main thread.""" + # Re-enable send button + widget.send_button.set_sensitive(True) - # Execute async - def callback(response, error, user_data): - """Callback runs on main thread.""" - # Re-enable send button - widget.send_button.set_sensitive(True) + # Update tab with response + if response: + widget.display_response(response) - # Update tab with response - if response: - widget.display_response(response) + # Execute postprocessing script if exists + scripts = widget.get_scripts() + if scripts and scripts.postprocessing.strip(): + from .script_executor import ScriptExecutor, ScriptContext - # Execute postprocessing script if exists - scripts = widget.get_scripts() - if scripts and scripts.postprocessing.strip(): - from .script_executor import ScriptExecutor, ScriptContext + # Create context for variable updates + context = None + if widget.project_id and widget.selected_environment_id: + context = ScriptContext( + project_id=widget.project_id, + environment_id=widget.selected_environment_id, + project_manager=self.project_manager + ) - # Create context for variable updates - context = None - if widget.project_id and widget.selected_environment_id: - context = ScriptContext( - project_id=widget.project_id, - environment_id=widget.selected_environment_id, - project_manager=self.project_manager + # Execute script with context + script_result = ScriptExecutor.execute_postprocessing_script( + scripts.postprocessing, + response, + context ) - # Execute script with context - script_result = ScriptExecutor.execute_postprocessing_script( - scripts.postprocessing, - response, - context - ) + # Display script results + widget.display_script_results(script_result) - # Display script results - widget.display_script_results(script_result) - - # Process variable updates - self._process_variable_updates(script_result, widget, context) + # Process variable updates + self._process_variable_updates(script_result, widget, context) + else: + widget._clear_script_results() else: + widget.display_error(error or "Unknown error") widget._clear_script_results() - else: - widget.display_error(error or "Unknown error") - widget._clear_script_results() - # Save response to tab - page = self.tab_view.get_selected_page() - if page: - tab = self.page_to_tab.get(page) - if tab: - tab.response = response + # Save response to tab + page = self.tab_view.get_selected_page() + if page: + tab = self.page_to_tab.get(page) + if tab: + tab.response = response - # Create history entry with redacted sensitive variables - # Determine which request to save to history - request_for_history = modified_request - has_redacted = False + # Create history entry with redacted sensitive variables + # Determine which request to save to history + request_for_history = modified_request + has_redacted = False - if widget.selected_environment_id: - env = widget.get_selected_environment() if env and widget.project_id: # Get the project's sensitive variables list projects = self.project_manager.load_projects() @@ -624,22 +622,28 @@ class RosterWindow(Adw.ApplicationWindow): # No sensitive variables defined, use fully substituted request request_for_history = substituted_request elif env: - # Environment selected but no project - use fully substituted request + # Environment loaded but no project - use fully substituted request request_for_history = substituted_request - entry = HistoryEntry( - timestamp=datetime.now().isoformat(), - request=request_for_history, - response=response, - error=error, - has_redacted_variables=has_redacted - ) - self.history_manager.add_entry(entry) + entry = HistoryEntry( + timestamp=datetime.now().isoformat(), + request=request_for_history, + response=response, + error=error, + has_redacted_variables=has_redacted + ) + self.history_manager.add_entry(entry) - # Refresh history panel to show new entry - self._load_history() + # Refresh history panel to show new entry + self._load_history() - self.http_client.execute_request_async(substituted_request, callback, None) + self.http_client.execute_request_async(substituted_request, callback, None) + + # Fetch environment asynchronously then proceed + if widget.selected_environment_id: + widget.get_selected_environment(proceed_with_request) + else: + proceed_with_request(None) # History and Project Management def _load_history(self) -> None: