Compare commits
No commits in common. "master" and "v0.6.0" have entirely different histories.
38
README.md
38
README.md
@ -12,18 +12,7 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
|
||||
- Environment variables with secure credential storage
|
||||
- JavaScript preprocessing and postprocessing scripts
|
||||
- Export requests (cURL and more)
|
||||
- GNOME-native UI
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main Interface
|
||||

|
||||
|
||||
### Request History
|
||||

|
||||
|
||||
### Environment Variables
|
||||

|
||||
- Beautiful GNOME-native UI
|
||||
|
||||
## Quick Start
|
||||
|
||||
@ -52,16 +41,23 @@ Launch Roster from your application menu or run `roster` from the command line.
|
||||
|
||||
See the [Getting Started Guide](https://git.bugsy.cz/beval/roster/wiki/Getting-Started) for a walkthrough.
|
||||
|
||||
## Documentation
|
||||
|
||||
Complete documentation is available in the [Wiki](https://git.bugsy.cz/beval/roster/wiki):
|
||||
|
||||
- **[Home](https://git.bugsy.cz/beval/roster/wiki/Home)** - Overview and quick links
|
||||
- **[Installation](https://git.bugsy.cz/beval/roster/wiki/Installation)** - Build and install instructions
|
||||
- **[Getting Started](https://git.bugsy.cz/beval/roster/wiki/Getting-Started)** - Your first HTTP request
|
||||
- **[Projects and Environments](https://git.bugsy.cz/beval/roster/wiki/Projects-and-Environments)** - Organize your work
|
||||
- **[Variables](https://git.bugsy.cz/beval/roster/wiki/Variables)** - Use variables in requests
|
||||
- **[Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)** - Secure credential storage with GNOME Keyring
|
||||
- **[Scripts](https://git.bugsy.cz/beval/roster/wiki/Scripts)** - Automate workflows with JavaScript
|
||||
- **[API Reference](https://git.bugsy.cz/beval/roster/wiki/API-Reference)** - Complete JavaScript API
|
||||
- **[Keyboard Shortcuts](https://git.bugsy.cz/beval/roster/wiki/Keyboard-Shortcuts)** - Speed up your workflow
|
||||
- **[FAQ](https://git.bugsy.cz/beval/roster/wiki/FAQ)** - Frequently asked questions
|
||||
|
||||
## Key Features
|
||||
|
||||
### Multi-Environment Support
|
||||
|
||||
Manage multiple environments (development, staging, production) with different variable values. Switch environments with one click.
|
||||
|
||||
[Learn more about Variables](https://git.bugsy.cz/beval/roster/wiki/Variables)
|
||||
|
||||
### Secure Credential Storage
|
||||
|
||||
Store API keys, passwords, and tokens securely in GNOME Keyring with one-click encryption. Regular variables are stored in JSON, while sensitive variables are encrypted.
|
||||
@ -78,6 +74,12 @@ Use preprocessing and postprocessing scripts to:
|
||||
|
||||
[Learn more about Scripts](https://git.bugsy.cz/bavel/roster/wiki/Scripts)
|
||||
|
||||
### Multi-Environment Support
|
||||
|
||||
Manage multiple environments (development, staging, production) with different variable values. Switch environments with one click.
|
||||
|
||||
[Learn more about Variables](https://git.bugsy.cz/beval/roster/wiki/Variables)
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Runtime
|
||||
|
||||
230
SENSITIVE_VARIABLES_UI_GUIDE.md
Normal file
230
SENSITIVE_VARIABLES_UI_GUIDE.md
Normal file
@ -0,0 +1,230 @@
|
||||
# Sensitive Variables - UI User Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Roster now supports marking variables as **sensitive** to store their values securely in GNOME Keyring instead of plain text. This guide explains how to use this feature in the UI.
|
||||
|
||||
## Features
|
||||
|
||||
### 🔒 Lock Icon Toggle
|
||||
|
||||
Each variable in the Environments dialog has a lock icon button:
|
||||
- **Unlocked** (🔓 `channel-insecure-symbolic`) - Variable is stored in plain JSON
|
||||
- **Locked** (🔒 `channel-secure-symbolic`) - Variable is stored encrypted in GNOME Keyring
|
||||
|
||||
### Visual Indicators
|
||||
|
||||
1. **Lock Icon Color/State**
|
||||
- Inactive (gray): Non-sensitive variable
|
||||
- Active (accent color): Sensitive variable
|
||||
|
||||
2. **Variable Name Styling**
|
||||
- Regular variables: Normal text
|
||||
- Sensitive variables: Colored with accent color and bold weight
|
||||
|
||||
3. **Value Entry Fields**
|
||||
- Regular variables: Show plain text
|
||||
- Sensitive variables: Show bullets (••••••) instead of text
|
||||
|
||||
## How to Use
|
||||
|
||||
### Step 1: Open Environments Dialog
|
||||
|
||||
1. Select a project from the sidebar
|
||||
2. Click the "Environments" button in the header bar
|
||||
3. The dialog shows a table with:
|
||||
- Rows: Variable names
|
||||
- Columns: Environment values
|
||||
|
||||
### Step 2: Create Variables
|
||||
|
||||
If you haven't already:
|
||||
1. Click the **"Add Variable"** button (bottom-left)
|
||||
2. Enter a variable name (e.g., `api_key`)
|
||||
3. Click "Add"
|
||||
|
||||
### Step 3: Mark Variable as Sensitive
|
||||
|
||||
**Option A: Before Entering Values (Recommended)**
|
||||
1. Find the variable row in the table
|
||||
2. Click the **lock icon** next to the variable name
|
||||
3. Icon changes from 🔓 to 🔒
|
||||
4. Now enter your secret values - they go directly to keyring
|
||||
|
||||
**Option B: After Entering Values (Migration)**
|
||||
1. If you already entered sensitive values in plain text
|
||||
2. Click the lock icon to mark as sensitive
|
||||
3. Values are automatically moved from JSON to GNOME Keyring
|
||||
4. JSON file now shows empty placeholders
|
||||
|
||||
### Step 4: Enter Sensitive Values
|
||||
|
||||
1. Click in the value entry field for any environment
|
||||
2. Type your secret value
|
||||
3. You'll see bullets (••••••) instead of text
|
||||
4. Value is automatically saved to GNOME Keyring
|
||||
|
||||
### Step 5: Use in Requests
|
||||
|
||||
Sensitive variables work exactly like regular variables:
|
||||
```
|
||||
URL: {{base_url}}/users
|
||||
Headers:
|
||||
Authorization: Bearer {{api_key}}
|
||||
```
|
||||
|
||||
When you send the request, values are automatically retrieved from the keyring and substituted.
|
||||
|
||||
## UI Walkthrough
|
||||
|
||||
### Example: Storing a GitHub Token
|
||||
|
||||
**Before (Insecure - in plain JSON):**
|
||||
```
|
||||
Variables Table:
|
||||
┌─────────────┬──────────────────┬──────────────────┐
|
||||
│ Variable │ Production │ Development │
|
||||
├─────────────┼──────────────────┼──────────────────┤
|
||||
│ base_url 🔓 │ api.github.com │ api.dev.local │
|
||||
│ token 🔓 │ ghp_abc123... │ ghp_dev456... │ ← VISIBLE!
|
||||
└─────────────┴──────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
**After (Secure - in GNOME Keyring):**
|
||||
```
|
||||
Variables Table:
|
||||
┌─────────────┬──────────────────┬──────────────────┐
|
||||
│ Variable │ Production │ Development │
|
||||
├─────────────┼──────────────────┼──────────────────┤
|
||||
│ base_url 🔓 │ api.github.com │ api.dev.local │
|
||||
│ token 🔒 │ •••••••••••• │ •••••••••••• │ ← HIDDEN!
|
||||
└─────────────┴──────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
## Tooltips
|
||||
|
||||
Hover over the lock icon to see:
|
||||
- **Unlocked**: "Not sensitive (stored in JSON) - Click to mark as sensitive"
|
||||
- **Locked**: "Sensitive (stored in keyring) - Click to make non-sensitive"
|
||||
|
||||
## Making a Variable Non-Sensitive Again
|
||||
|
||||
If you accidentally marked a variable as sensitive:
|
||||
1. Click the lock icon again (🔒 → 🔓)
|
||||
2. Values are moved from keyring back to JSON
|
||||
3. You can now see the values in plain text
|
||||
|
||||
**Warning**: Only do this if the variable truly doesn't contain secrets!
|
||||
|
||||
## Viewing Secrets in GNOME Keyring
|
||||
|
||||
Want to verify your secrets are stored?
|
||||
1. Open **"Passwords and Keys"** application (Seahorse)
|
||||
2. Look under **"Login"** keyring
|
||||
3. Find entries labeled: `Roster: ProjectName/EnvironmentName/VariableName`
|
||||
|
||||
Example:
|
||||
```
|
||||
Login Keyring
|
||||
└─ Roster: My API Project/Production/api_key
|
||||
└─ Roster: My API Project/Development/api_key
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO Mark as Sensitive:
|
||||
- `api_key` - API keys
|
||||
- `secret_key` - Secret keys
|
||||
- `password` - Passwords
|
||||
- `token` - Bearer tokens, OAuth tokens
|
||||
- `client_secret` - OAuth client secrets
|
||||
- `private_key` - Private keys
|
||||
- Any variable containing credentials
|
||||
|
||||
### ❌ DON'T Mark as Sensitive:
|
||||
- `base_url` - API endpoints
|
||||
- `env` - Environment names
|
||||
- `region` - Cloud regions
|
||||
- `timeout` - Timeout values
|
||||
- `version` - API versions
|
||||
- Any non-secret configuration
|
||||
|
||||
### Workflow Tips
|
||||
|
||||
1. **Create variables first, then mark as sensitive**
|
||||
- Add variable
|
||||
- Click lock icon
|
||||
- Enter values (they go directly to keyring)
|
||||
|
||||
2. **Use descriptive names**
|
||||
- ✅ `stripe_secret_key`
|
||||
- ❌ `key1`
|
||||
|
||||
3. **Group related variables**
|
||||
```
|
||||
base_url 🔓
|
||||
api_key 🔒
|
||||
api_secret 🔒
|
||||
timeout 🔓
|
||||
```
|
||||
|
||||
4. **Audit regularly**
|
||||
- Check which variables are marked as sensitive
|
||||
- Ensure all secrets are locked (🔒)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Q: I clicked the lock but it didn't work
|
||||
**A:** Check the application logs. The keyring might be locked. Unlock it with your login password.
|
||||
|
||||
### Q: Can I see my secret values after marking as sensitive?
|
||||
**A:** The UI shows bullets (••••••) for security. To view:
|
||||
- Temporarily unlock (click 🔒 → 🔓)
|
||||
- Or use "Passwords and Keys" app to view in keyring
|
||||
|
||||
### Q: What happens if I delete a sensitive variable?
|
||||
**A:** Both the JSON entry AND the keyring secret are deleted automatically.
|
||||
|
||||
### Q: What happens if I rename a sensitive variable?
|
||||
**A:** The keyring secret is automatically renamed. No data is lost.
|
||||
|
||||
### Q: What happens if I delete an environment?
|
||||
**A:** All keyring secrets for that environment are deleted automatically.
|
||||
|
||||
### Q: What happens if I delete a project?
|
||||
**A:** ALL keyring secrets for that project are deleted automatically.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- ✅ Secrets are encrypted with your login password
|
||||
- ✅ Automatically unlocked when you log in
|
||||
- ✅ Protected by OS-level security
|
||||
- ✅ Same security as browser passwords, WiFi passwords, SSH keys
|
||||
- ⚠️ Non-sensitive variables are still in plain JSON
|
||||
- ⚠️ Mark variables as sensitive BEFORE entering secret values
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- **Tab**: Move between value fields
|
||||
- **Enter**: Confirm value (auto-saved)
|
||||
- **Escape**: Close dialog
|
||||
|
||||
## Visual Design
|
||||
|
||||
The UI uses the following visual cues:
|
||||
|
||||
1. **Lock Icon State**
|
||||
- Gray/inactive = Plain JSON storage
|
||||
- Colored/active = Encrypted keyring storage
|
||||
|
||||
2. **Variable Name Color**
|
||||
- Sensitive variables have accent color and bold font
|
||||
|
||||
3. **Password Entry**
|
||||
- Sensitive variable values show as bullets
|
||||
- Same UX as password fields throughout GNOME
|
||||
|
||||
4. **Consistent with GNOME HIG**
|
||||
- Follows GNOME Human Interface Guidelines
|
||||
- Familiar patterns (lock icon = security)
|
||||
- Accessible and keyboard-navigable
|
||||
271
SENSITIVE_VARIABLES_USAGE.md
Normal file
271
SENSITIVE_VARIABLES_USAGE.md
Normal file
@ -0,0 +1,271 @@
|
||||
# Sensitive Variables - Usage Guide
|
||||
|
||||
This document explains how to use the GNOME Keyring integration for storing sensitive variable values securely.
|
||||
|
||||
## Overview
|
||||
|
||||
Roster now supports marking variables as **sensitive**, which stores their values encrypted in GNOME Keyring instead of plain text in the JSON file.
|
||||
|
||||
**Use sensitive variables for:**
|
||||
- API keys (`api_key`, `secret_key`)
|
||||
- Authentication tokens (`bearer_token`, `oauth_token`)
|
||||
- Passwords (`password`, `db_password`)
|
||||
- Any secret credentials
|
||||
|
||||
**Use regular variables for:**
|
||||
- Base URLs (`base_url`, `api_endpoint`)
|
||||
- Environment names (`env`, `region`)
|
||||
- Non-sensitive configuration values
|
||||
|
||||
## Storage
|
||||
|
||||
### Regular Variables
|
||||
Stored in: `~/.local/share/cz.bugsy.roster/requests.json`
|
||||
```json
|
||||
{
|
||||
"projects": [{
|
||||
"variable_names": ["base_url", "api_key"],
|
||||
"sensitive_variables": [], // empty = not sensitive
|
||||
"environments": [{
|
||||
"variables": {
|
||||
"base_url": "https://api.example.com", // visible in JSON
|
||||
"api_key": "secret-key-123" // visible (NOT SAFE!)
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive Variables
|
||||
Stored in: GNOME Keyring (encrypted)
|
||||
```json
|
||||
{
|
||||
"projects": [{
|
||||
"variable_names": ["base_url", "api_key"],
|
||||
"sensitive_variables": ["api_key"], // marked as sensitive
|
||||
"environments": [{
|
||||
"variables": {
|
||||
"base_url": "https://api.example.com", // visible in JSON
|
||||
"api_key": "" // empty placeholder
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
The actual value of `api_key` is stored encrypted in GNOME Keyring and automatically retrieved when needed.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from roster.project_manager import ProjectManager
|
||||
|
||||
pm = ProjectManager()
|
||||
project_id = "your-project-id"
|
||||
env_id = "your-environment-id"
|
||||
|
||||
# Mark a variable as sensitive (moves existing values to keyring)
|
||||
pm.mark_variable_as_sensitive(project_id, "api_key")
|
||||
|
||||
# Set a sensitive variable value (goes to keyring)
|
||||
pm.set_variable_value(project_id, env_id, "api_key", "my-secret-key-123")
|
||||
|
||||
# Get a sensitive variable value (retrieved from keyring)
|
||||
value = pm.get_variable_value(project_id, env_id, "api_key")
|
||||
# Returns: "my-secret-key-123"
|
||||
|
||||
# Check if variable is sensitive
|
||||
is_sensitive = pm.is_variable_sensitive(project_id, "api_key")
|
||||
# Returns: True
|
||||
```
|
||||
|
||||
### Using in Request Substitution
|
||||
|
||||
```python
|
||||
# Get environment with ALL values (including secrets from keyring)
|
||||
environment = pm.get_environment_with_secrets(project_id, env_id)
|
||||
|
||||
# Now use this environment for variable substitution
|
||||
from roster.variable_substitution import VariableSubstitution
|
||||
|
||||
vs = VariableSubstitution()
|
||||
request = HttpRequest(
|
||||
method="GET",
|
||||
url="{{base_url}}/users",
|
||||
headers={"Authorization": "Bearer {{api_key}}"},
|
||||
body=""
|
||||
)
|
||||
|
||||
substituted_request, undefined = vs.substitute_request(request, environment)
|
||||
# Result:
|
||||
# url = "https://api.example.com/users"
|
||||
# headers = {"Authorization": "Bearer my-secret-key-123"}
|
||||
```
|
||||
|
||||
### Mark Variable as Sensitive
|
||||
|
||||
```python
|
||||
# Existing variable with values in all environments
|
||||
pm.mark_variable_as_sensitive(project_id, "token")
|
||||
|
||||
# This will:
|
||||
# 1. Add "token" to project.sensitive_variables list
|
||||
# 2. Move all environment values to GNOME Keyring
|
||||
# 3. Clear values in JSON (replaced with empty strings)
|
||||
```
|
||||
|
||||
### Mark Variable as Non-Sensitive
|
||||
|
||||
```python
|
||||
# Move back from keyring to JSON
|
||||
pm.mark_variable_as_nonsensitive(project_id, "base_url")
|
||||
|
||||
# This will:
|
||||
# 1. Remove "base_url" from project.sensitive_variables list
|
||||
# 2. Move all environment values from keyring to JSON
|
||||
# 3. Delete secrets from keyring
|
||||
```
|
||||
|
||||
### Variable Operations (Automatic Secret Handling)
|
||||
|
||||
```python
|
||||
# Rename a sensitive variable
|
||||
pm.rename_variable(project_id, "old_token", "new_token")
|
||||
# Automatically renames in both JSON AND keyring
|
||||
|
||||
# Delete a sensitive variable
|
||||
pm.delete_variable(project_id, "api_key")
|
||||
# Automatically deletes from both JSON AND keyring
|
||||
|
||||
# Delete an environment
|
||||
pm.delete_environment(project_id, env_id)
|
||||
# Automatically deletes all secrets for that environment
|
||||
|
||||
# Delete a project
|
||||
pm.delete_project(project_id)
|
||||
# Automatically deletes ALL secrets for that project
|
||||
```
|
||||
|
||||
## Integration with UI
|
||||
|
||||
When implementing UI for sensitive variables:
|
||||
|
||||
### Variable List Display
|
||||
|
||||
```python
|
||||
# Show lock icon for sensitive variables
|
||||
for var_name in project.variable_names:
|
||||
is_sensitive = var_name in project.sensitive_variables
|
||||
icon = "🔒" if is_sensitive else ""
|
||||
label = f"{icon} {var_name}"
|
||||
```
|
||||
|
||||
### Variable Value Entry
|
||||
|
||||
```python
|
||||
# Use password entry for sensitive variables
|
||||
entry = Gtk.Entry()
|
||||
if var_name in project.sensitive_variables:
|
||||
entry.set_visibility(False) # Show bullets instead of text
|
||||
entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
|
||||
```
|
||||
|
||||
### Context Menu
|
||||
|
||||
```python
|
||||
# Right-click menu on variable
|
||||
menu = Gio.Menu()
|
||||
|
||||
if pm.is_variable_sensitive(project_id, var_name):
|
||||
menu.append("Mark as Non-Sensitive", f"app.mark-nonsensitive::{var_name}")
|
||||
else:
|
||||
menu.append("Mark as Sensitive", f"app.mark-sensitive::{var_name}")
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### GNOME Keyring Schema
|
||||
|
||||
Each secret is stored with these attributes:
|
||||
```python
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"environment_id": "uuid-of-environment",
|
||||
"variable_name": "api_key"
|
||||
}
|
||||
```
|
||||
|
||||
Label shown in Seahorse (Passwords and Keys app):
|
||||
```
|
||||
"Roster: ProjectName/EnvironmentName/variable_name"
|
||||
```
|
||||
|
||||
### Viewing Secrets in Seahorse
|
||||
|
||||
1. Open "Passwords and Keys" application
|
||||
2. Look under "Login" keyring
|
||||
3. Find entries labeled "Roster: ..."
|
||||
4. These are your sensitive variable values
|
||||
|
||||
### Security
|
||||
|
||||
- Encrypted at rest with your login password
|
||||
- Automatically unlocked when you log in
|
||||
- Protected by OS-level security
|
||||
- Uses the same secure backend as browser passwords, WiFi passwords, SSH keys
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you have existing variables with sensitive data:
|
||||
|
||||
```python
|
||||
project_id = "your-project-id"
|
||||
sensitive_vars = ["api_key", "token", "password", "secret"]
|
||||
|
||||
for var_name in sensitive_vars:
|
||||
pm.mark_variable_as_sensitive(project_id, var_name)
|
||||
|
||||
# Done! All values are now encrypted in GNOME Keyring
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from roster.secret_manager import get_secret_manager
|
||||
|
||||
sm = get_secret_manager()
|
||||
|
||||
# store_secret returns bool
|
||||
success = sm.store_secret(project_id, env_id, "api_key", "value")
|
||||
if not success:
|
||||
# Handle error (check logs)
|
||||
print("Failed to store secret")
|
||||
|
||||
# retrieve_secret returns None on failure
|
||||
value = sm.retrieve_secret(project_id, env_id, "api_key")
|
||||
if value is None:
|
||||
# Secret not found or error occurred
|
||||
print("Secret not found")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Mark variables as sensitive BEFORE entering values**
|
||||
- This ensures values never touch the JSON file
|
||||
|
||||
2. **Use descriptive variable names**
|
||||
- `github_token` instead of `token`
|
||||
- `stripe_api_key` instead of `key`
|
||||
|
||||
3. **Don't commit the JSON file with sensitive data**
|
||||
- If you accidentally stored secrets in JSON, mark as sensitive to move them
|
||||
|
||||
4. **Regular variables are fine for most things**
|
||||
- Only use sensitive variables for actual secrets
|
||||
- Base URLs, regions, etc. don't need encryption
|
||||
|
||||
5. **Backup your GNOME Keyring**
|
||||
- Sensitive variables are stored in your system keyring
|
||||
- Backup `~/.local/share/keyrings/` if needed
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"id": "cz.bugsy.roster",
|
||||
"runtime": "org.gnome.Platform",
|
||||
"runtime-version": "49",
|
||||
"sdk": "org.gnome.Sdk",
|
||||
"command": "roster",
|
||||
"finish-args": [
|
||||
"--share=network",
|
||||
"--share=ipc",
|
||||
"--socket=fallback-x11",
|
||||
"--device=dri",
|
||||
"--socket=wayland"
|
||||
],
|
||||
"cleanup": [
|
||||
"/include",
|
||||
"/lib/pkgconfig",
|
||||
"/man",
|
||||
"/share/doc",
|
||||
"/share/gtk-doc",
|
||||
"/share/man",
|
||||
"/share/pkgconfig",
|
||||
"*.la",
|
||||
"*.a"
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"name": "roster",
|
||||
"builddir": true,
|
||||
"buildsystem": "meson",
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://git.bugsy.cz/beval/roster.git",
|
||||
"tag": "v0.8.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -9,7 +9,8 @@
|
||||
"--share=ipc",
|
||||
"--socket=fallback-x11",
|
||||
"--device=dri",
|
||||
"--socket=wayland"
|
||||
"--socket=wayland",
|
||||
"--talk-name=org.freedesktop.secrets"
|
||||
],
|
||||
"cleanup" : [
|
||||
"/include",
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
<summary>HTTP client for API testing</summary>
|
||||
<description>
|
||||
<p>Roster is a modern HTTP client for testing and debugging REST APIs. It provides a clean, GNOME-native interface for making HTTP requests and inspecting responses.</p>
|
||||
<p>Note: Application-specific icons included with this project are licensed under CC-BY-SA-3.0. GNOME system icons are provided by the system and are not distributed with this application. All other components are licensed under GPL-3.0-or-later.</p>
|
||||
</description>
|
||||
|
||||
<developer id="cz.bugsy">
|
||||
@ -25,71 +26,18 @@
|
||||
<!-- Use the OARS website (https://hughsie.github.io/oars/generate.html) to generate these and make sure to use oars-1.1 -->
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#8b9dbc</color>
|
||||
<color type="primary" scheme_preference="dark">#173c7a</color>
|
||||
</branding>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/main.png</image>
|
||||
<caption>Main application window with HTTP request and response</caption>
|
||||
<image>https://example.org/example1.png</image>
|
||||
<caption>A caption</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/variables.png</image>
|
||||
<caption>Environment variables configuration</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/history.png</image>
|
||||
<caption>Request history panel</caption>
|
||||
<image>https://example.org/example2.png</image>
|
||||
<caption>A caption</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="0.8.1" date="2026-01-15">
|
||||
<description translate="no">
|
||||
<p>Version 0.8.1 release</p>
|
||||
<ul>
|
||||
<li>Remove license note from app description</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.8.0" date="2026-01-15">
|
||||
<description translate="no">
|
||||
<p>Version 0.8.0 release</p>
|
||||
<ul>
|
||||
<li>Shorten User-Agent version to major.minor format</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.7.0" date="2026-01-14">
|
||||
<description translate="no">
|
||||
<p>Version 0.7.0 release</p>
|
||||
<ul>
|
||||
<li>Prepare for Flathub submission</li>
|
||||
<li>Add real screenshots and release notes</li>
|
||||
<li>Add Request creates new tab for project</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.6.0" date="2026-01-10">
|
||||
<description translate="no">
|
||||
<p>Version 0.6.0 release</p>
|
||||
<ul>
|
||||
<li>Added keyboard shortcuts: Ctrl+L (focus URL), Ctrl+Return (send request)</li>
|
||||
<li>Translation improvements</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.5.0" date="2026-01-08">
|
||||
<description translate="no">
|
||||
<p>Version 0.5.0 release</p>
|
||||
<ul>
|
||||
<li>Authentication improvements</li>
|
||||
<li>Bug fixes and stability improvements</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.4.0" date="2026-01-05">
|
||||
<description translate="no">
|
||||
<p>Version 0.4.0 release</p>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
project('roster',
|
||||
version: '0.8.2',
|
||||
version: '0.6.0',
|
||||
meson_version: '>= 1.0.0',
|
||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||
)
|
||||
|
||||
@ -3,19 +3,6 @@
|
||||
data/cz.bugsy.roster.desktop.in
|
||||
data/cz.bugsy.roster.metainfo.xml.in
|
||||
data/cz.bugsy.roster.gschema.xml
|
||||
src/environments_dialog.py
|
||||
src/environments-dialog.ui
|
||||
src/export_dialog.py
|
||||
src/export-dialog.ui
|
||||
src/icon_picker_dialog.py
|
||||
src/icon-picker-dialog.ui
|
||||
src/main.py
|
||||
src/main-window.ui
|
||||
src/preferences_dialog.py
|
||||
src/preferences-dialog.ui
|
||||
src/project_manager.py
|
||||
src/request_tab_widget.py
|
||||
src/secret_manager.py
|
||||
src/shortcuts-dialog.ui
|
||||
src/tab_manager.py
|
||||
src/window.py
|
||||
src/window.ui
|
||||
|
||||
182
po/roster.pot
182
po/roster.pot
@ -1,182 +0,0 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the roster package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: roster\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-13 17:55+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=CHARSET\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: data/cz.bugsy.roster.desktop.in:2 data/cz.bugsy.roster.metainfo.xml.in:7
|
||||
msgid "Roster"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.desktop.in:3 data/cz.bugsy.roster.metainfo.xml.in:8
|
||||
msgid "HTTP client for API testing"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.desktop.in:9
|
||||
msgid "HTTP;REST;API;Client;Request;JSON;"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:10
|
||||
msgid ""
|
||||
"Roster is a modern HTTP client for testing and debugging REST APIs. It "
|
||||
"provides a clean, GNOME-native interface for making HTTP requests and "
|
||||
"inspecting responses."
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:11
|
||||
msgid ""
|
||||
"Note: Application-specific icons included with this project are licensed "
|
||||
"under CC-BY-SA-3.0. GNOME system icons are provided by the system and are "
|
||||
"not distributed with this application. All other components are licensed "
|
||||
"under GPL-3.0-or-later."
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:15
|
||||
msgid "Pavel Baksy"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:32
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:36
|
||||
msgid "A caption"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:6
|
||||
msgid "Force TLS verification"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:7
|
||||
msgid ""
|
||||
"When enabled, TLS certificates will be verified. Disable to test endpoints "
|
||||
"with self-signed or invalid certificates."
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:11
|
||||
msgid "Request timeout"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:12
|
||||
msgid "Timeout for HTTP requests in seconds"
|
||||
msgstr ""
|
||||
|
||||
#. Translators: Replace "translator-credits" with your name/username, and optionally an email or URL.
|
||||
#: src/main.py:79
|
||||
msgid "translator-credits"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:132
|
||||
msgid "Main Menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:234
|
||||
msgid "_Preferences"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:238
|
||||
msgid "_Keyboard Shortcuts"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:242
|
||||
msgid "_About Roster"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:7
|
||||
msgid "Preferences"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:14
|
||||
msgid "General"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:19
|
||||
msgid "Network"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:20
|
||||
msgid "HTTP connection settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:24
|
||||
msgid "Force TLS Verification"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:25
|
||||
msgid ""
|
||||
"Verify TLS certificates for HTTPS connections. Disable to test endpoints "
|
||||
"with self-signed certificates."
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:31
|
||||
msgid "Request Timeout"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:32
|
||||
msgid "Maximum time to wait for a response (in seconds)"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:49
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:50
|
||||
msgid "Manage request history"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:54
|
||||
msgid "Clear All History"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:55
|
||||
msgid "Remove all saved request and response history"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:58
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:13
|
||||
msgctxt "shortcut window"
|
||||
msgid "New Tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:19
|
||||
msgctxt "shortcut window"
|
||||
msgid "Close Tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:25
|
||||
msgctxt "shortcut window"
|
||||
msgid "Save Request"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:31
|
||||
msgctxt "shortcut window"
|
||||
msgid "Focus URL Field"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:37
|
||||
msgctxt "shortcut window"
|
||||
msgid "Send Request"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:43
|
||||
msgctxt "shortcut window"
|
||||
msgid "Show Shortcuts"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:49
|
||||
msgctxt "shortcut window"
|
||||
msgid "Quit"
|
||||
msgstr ""
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
@ -5,7 +5,7 @@
|
||||
|
||||
<template class="EnvironmentsDialog" parent="AdwDialog">
|
||||
<property name="title">Manage Environments</property>
|
||||
<property name="content-width">1100</property>
|
||||
<property name="content-width">900</property>
|
||||
<property name="content-height">600</property>
|
||||
<property name="follows-content-size">false</property>
|
||||
|
||||
|
||||
@ -169,12 +169,10 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
def on_response(dlg, response):
|
||||
if response == "delete":
|
||||
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)
|
||||
self.project_manager.delete_variable(self.project.id, var_name)
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
@ -183,12 +181,10 @@ 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:
|
||||
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)
|
||||
self.project_manager.rename_variable(self.project.id, old_name, new_name)
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_add_environment_clicked(self, button):
|
||||
@ -271,12 +267,10 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
def on_response(dlg, response):
|
||||
if response == "delete":
|
||||
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)
|
||||
self.project_manager.delete_environment(self.project.id, environment.id)
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
@ -289,19 +283,22 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
def _on_sensitivity_changed(self, widget, is_sensitive, var_name):
|
||||
"""Handle variable sensitivity toggle."""
|
||||
def on_complete(success):
|
||||
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')
|
||||
|
||||
if is_sensitive:
|
||||
# Mark as sensitive (move to keyring)
|
||||
self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
|
||||
else:
|
||||
# Mark as non-sensitive (move to JSON)
|
||||
self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
|
||||
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')
|
||||
|
||||
def _reload_project(self):
|
||||
"""Reload project data from manager."""
|
||||
|
||||
@ -35,11 +35,8 @@ class HttpRequest:
|
||||
@classmethod
|
||||
def default_headers(cls) -> Dict[str, str]:
|
||||
"""Return default headers for new requests."""
|
||||
# Use only major.minor version (without patch number)
|
||||
version_parts = constants.VERSION.split(".")
|
||||
short_version = ".".join(version_parts[:2])
|
||||
return {
|
||||
"User-Agent": f"Roster/{short_version}"
|
||||
"User-Agent": f"Roster/{constants.VERSION}"
|
||||
}
|
||||
|
||||
def to_dict(self):
|
||||
|
||||
@ -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, Callable
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import gi
|
||||
gi.require_version('GLib', '2.0')
|
||||
@ -132,23 +132,18 @@ class ProjectManager:
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def delete_project(self, project_id: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete a project and all its requests (async for secret cleanup)."""
|
||||
def delete_project(self, project_id: str):
|
||||
"""Delete a project and all its requests."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
# Remove project from list and save immediately
|
||||
# Delete all secrets for this project
|
||||
secret_manager.delete_all_project_secrets(project_id)
|
||||
|
||||
# Remove project from list
|
||||
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()
|
||||
@ -244,27 +239,20 @@ class ProjectManager:
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def delete_environment(self, project_id: str, env_id: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete an environment (async for secret cleanup)."""
|
||||
def delete_environment(self, project_id: str, env_id: str):
|
||||
"""Delete an environment."""
|
||||
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()
|
||||
@ -278,12 +266,10 @@ class ProjectManager:
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
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)."""
|
||||
def rename_variable(self, project_id: str, old_name: str, new_name: str):
|
||||
"""Rename a variable in the project and all environments."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
is_sensitive = False
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -292,33 +278,24 @@ class ProjectManager:
|
||||
idx = p.variable_names.index(old_name)
|
||||
p.variable_names[idx] = new_name
|
||||
|
||||
# Check if sensitive and update list
|
||||
# Update sensitive_variables list if applicable
|
||||
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)
|
||||
|
||||
# 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)."""
|
||||
def delete_variable(self, project_id: str, variable_name: str):
|
||||
"""Delete a variable from the project and all environments."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
is_sensitive = False
|
||||
environments_to_cleanup = []
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -326,33 +303,19 @@ class ProjectManager:
|
||||
if variable_name in p.variable_names:
|
||||
p.variable_names.remove(variable_name)
|
||||
|
||||
# Check if sensitive and get environments for cleanup
|
||||
# Remove from sensitive_variables if applicable
|
||||
if variable_name in p.sensitive_variables:
|
||||
is_sensitive = True
|
||||
p.sensitive_variables.remove(variable_name)
|
||||
environments_to_cleanup = [env.id for env in p.environments]
|
||||
# Delete all secrets for this variable
|
||||
for env in p.environments:
|
||||
secret_manager.delete_secret(project_id, env.id, variable_name)
|
||||
|
||||
# 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()
|
||||
@ -388,10 +351,9 @@ class ProjectManager:
|
||||
|
||||
# ========== Sensitive Variable Management ==========
|
||||
|
||||
def mark_variable_as_sensitive(self, project_id: str, variable_name: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
def mark_variable_as_sensitive(self, project_id: str, variable_name: str) -> bool:
|
||||
"""
|
||||
Mark a variable as sensitive (move values from JSON to keyring) - async.
|
||||
Mark a variable as sensitive (move values from JSON to keyring).
|
||||
|
||||
This will:
|
||||
1. Add variable to sensitive_variables list
|
||||
@ -401,11 +363,12 @@ class ProjectManager:
|
||||
Args:
|
||||
project_id: Project ID
|
||||
variable_name: Variable name to mark as sensitive
|
||||
callback: Optional callback called with success boolean
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
secrets_to_store = []
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -413,56 +376,30 @@ class ProjectManager:
|
||||
if variable_name not in p.sensitive_variables:
|
||||
p.sensitive_variables.append(variable_name)
|
||||
|
||||
# Collect all environment values to store in keyring
|
||||
# Move all environment values to keyring
|
||||
for env in p.environments:
|
||||
if variable_name in env.variables:
|
||||
value = env.variables[variable_name]
|
||||
secrets_to_store.append({
|
||||
'environment_id': env.id,
|
||||
'environment_name': env.name,
|
||||
'value': value,
|
||||
'project_name': p.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
|
||||
)
|
||||
# Clear from JSON (keep empty placeholder)
|
||||
env.variables[variable_name] = ""
|
||||
|
||||
self.save_projects(projects)
|
||||
return True
|
||||
|
||||
# Store all secrets asynchronously
|
||||
if not secrets_to_store:
|
||||
if callback:
|
||||
callback(True)
|
||||
return
|
||||
return False
|
||||
|
||||
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):
|
||||
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str) -> bool:
|
||||
"""
|
||||
Mark a variable as non-sensitive (move values from keyring to JSON) - async.
|
||||
Mark a variable as non-sensitive (move values from keyring to JSON).
|
||||
|
||||
This will:
|
||||
1. Remove variable from sensitive_variables list
|
||||
@ -472,62 +409,40 @@ class ProjectManager:
|
||||
Args:
|
||||
project_id: Project ID
|
||||
variable_name: Variable name to mark as non-sensitive
|
||||
callback: Optional callback called with success boolean
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
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)
|
||||
|
||||
environments_to_migrate = list(p.environments)
|
||||
break
|
||||
# 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
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 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)
|
||||
)
|
||||
return False
|
||||
|
||||
def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool:
|
||||
"""Check if a variable is marked as sensitive."""
|
||||
@ -537,10 +452,9 @@ 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,
|
||||
callback: Callable[[str], None]):
|
||||
def get_variable_value(self, project_id: str, env_id: str, variable_name: str) -> str:
|
||||
"""
|
||||
Get variable value from appropriate storage (keyring or JSON) - async.
|
||||
Get variable value from appropriate storage (keyring or JSON).
|
||||
|
||||
This is a convenience method that handles both sensitive and non-sensitive variables.
|
||||
|
||||
@ -548,7 +462,9 @@ class ProjectManager:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
variable_name: Variable name
|
||||
callback: Callback called with variable value (empty string if not found)
|
||||
|
||||
Returns:
|
||||
Variable value (empty string if not found)
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
|
||||
@ -556,32 +472,25 @@ class ProjectManager:
|
||||
if p.id == project_id:
|
||||
# Check if sensitive
|
||||
if variable_name in p.sensitive_variables:
|
||||
# Retrieve from keyring asynchronously
|
||||
# Retrieve from keyring
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
def on_retrieve(value):
|
||||
callback(value or "")
|
||||
|
||||
secret_manager.retrieve_secret(
|
||||
value = secret_manager.retrieve_secret(
|
||||
project_id=project_id,
|
||||
environment_id=env_id,
|
||||
variable_name=variable_name,
|
||||
callback=on_retrieve
|
||||
variable_name=variable_name
|
||||
)
|
||||
return
|
||||
return value or ""
|
||||
else:
|
||||
# Retrieve from JSON synchronously
|
||||
# Retrieve from JSON
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
callback(env.variables.get(variable_name, ""))
|
||||
return
|
||||
return env.variables.get(variable_name, "")
|
||||
|
||||
callback("")
|
||||
return ""
|
||||
|
||||
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str):
|
||||
"""
|
||||
Set variable value in appropriate storage (keyring or JSON) - async.
|
||||
Set variable value in appropriate storage (keyring or JSON).
|
||||
|
||||
This is a convenience method that handles both sensitive and non-sensitive variables.
|
||||
|
||||
@ -590,7 +499,6 @@ 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()
|
||||
@ -599,7 +507,7 @@ class ProjectManager:
|
||||
if p.id == project_id:
|
||||
# Check if sensitive
|
||||
if variable_name in p.sensitive_variables:
|
||||
# Store in keyring asynchronously
|
||||
# Store in keyring
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
secret_manager.store_secret(
|
||||
@ -608,32 +516,24 @@ class ProjectManager:
|
||||
variable_name=variable_name,
|
||||
value=value,
|
||||
project_name=p.name,
|
||||
environment_name=env.name,
|
||||
callback=callback
|
||||
environment_name=env.name
|
||||
)
|
||||
# Keep empty placeholder in JSON
|
||||
env.variables[variable_name] = ""
|
||||
self.save_projects(projects)
|
||||
return
|
||||
break
|
||||
else:
|
||||
# Store in JSON synchronously
|
||||
# Store in JSON
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
env.variables[variable_name] = value
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
if callback:
|
||||
callback(True)
|
||||
return
|
||||
self.save_projects(projects)
|
||||
return
|
||||
|
||||
if callback:
|
||||
callback(False)
|
||||
|
||||
def get_environment_with_secrets(self, project_id: str, env_id: str,
|
||||
callback: Callable[[Optional[Environment]], None]):
|
||||
def get_environment_with_secrets(self, project_id: str, env_id: str) -> Optional[Environment]:
|
||||
"""
|
||||
Get an environment with all variable values (including secrets from keyring) - async.
|
||||
Get an environment with all variable values (including secrets from keyring).
|
||||
|
||||
This is useful for variable substitution - it returns a complete Environment
|
||||
object with all values populated from both JSON and keyring.
|
||||
@ -641,7 +541,9 @@ class ProjectManager:
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
callback: Callback called with Environment object (or None if not found)
|
||||
|
||||
Returns:
|
||||
Environment object with all values, or None if not found
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
@ -658,24 +560,16 @@ 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
|
||||
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)
|
||||
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
|
||||
|
||||
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 complete_env
|
||||
|
||||
callback(None)
|
||||
return None
|
||||
|
||||
@ -1318,55 +1318,45 @@ class RequestTabWidget(Gtk.Box):
|
||||
# Always update visual indicators when environment changes
|
||||
self._update_variable_indicators()
|
||||
|
||||
def get_selected_environment(self, callback):
|
||||
def get_selected_environment(self):
|
||||
"""
|
||||
Get the currently selected environment object with all values (async).
|
||||
Get the currently selected environment object with all values.
|
||||
|
||||
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:
|
||||
callback(None)
|
||||
return
|
||||
return None
|
||||
|
||||
# Get environment with secrets (includes values from keyring) - async
|
||||
self.project_manager.get_environment_with_secrets(
|
||||
# Get environment with secrets (includes values from keyring)
|
||||
return self.project_manager.get_environment_with_secrets(
|
||||
self.project_id,
|
||||
self.selected_environment_id,
|
||||
callback
|
||||
self.selected_environment_id
|
||||
)
|
||||
|
||||
def _detect_undefined_variables(self, callback):
|
||||
"""Detect undefined variables in the current request (async).
|
||||
|
||||
Args:
|
||||
callback: Function called with set of undefined variable names
|
||||
"""
|
||||
def _detect_undefined_variables(self):
|
||||
"""Detect undefined variables in the current request. Returns set of undefined variable names."""
|
||||
from .variable_substitution import VariableSubstitution
|
||||
|
||||
# Get current request from UI
|
||||
request = self.get_request()
|
||||
|
||||
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)
|
||||
# Get selected environment
|
||||
env = self.get_selected_environment()
|
||||
|
||||
# Get selected environment asynchronously
|
||||
self.get_selected_environment(on_environment_loaded)
|
||||
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
|
||||
|
||||
def _schedule_indicator_update(self):
|
||||
"""Schedule an indicator update with debouncing."""
|
||||
@ -1384,26 +1374,22 @@ class RequestTabWidget(Gtk.Box):
|
||||
return False # Don't repeat
|
||||
|
||||
def _update_variable_indicators(self):
|
||||
"""Update visual indicators for undefined variables (async)."""
|
||||
"""Update visual indicators for undefined variables."""
|
||||
# Only update if we have a project (variables only make sense in project context)
|
||||
if not self.project_id:
|
||||
return
|
||||
|
||||
def on_undefined_detected(undefined_vars):
|
||||
# Store detected undefined variables
|
||||
self.undefined_variables = undefined_vars
|
||||
# Detect undefined variables
|
||||
self.undefined_variables = self._detect_undefined_variables()
|
||||
|
||||
# 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()
|
||||
|
||||
# Detect undefined variables asynchronously
|
||||
self._detect_undefined_variables(on_undefined_detected)
|
||||
# Update body
|
||||
self._update_body_indicators()
|
||||
|
||||
def _update_url_indicator(self):
|
||||
"""Update warning indicator on URL entry."""
|
||||
|
||||
@ -23,9 +23,6 @@ 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
|
||||
@ -34,9 +31,8 @@ Each secret is identified by:
|
||||
|
||||
import gi
|
||||
gi.require_version('Secret', '1')
|
||||
from gi.repository import Secret, GLib, Gio
|
||||
from gi.repository import Secret, GLib
|
||||
import logging
|
||||
from typing import Callable, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -54,10 +50,7 @@ ROSTER_SCHEMA = Secret.Schema.new(
|
||||
|
||||
|
||||
class SecretManager:
|
||||
"""Manages sensitive variable storage using GNOME Keyring via libsecret.
|
||||
|
||||
All methods use async APIs for compatibility with the XDG Secrets Portal.
|
||||
"""
|
||||
"""Manages sensitive variable storage using GNOME Keyring via libsecret."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the secret manager."""
|
||||
@ -65,10 +58,9 @@ class SecretManager:
|
||||
|
||||
def store_secret(self, project_id: str, environment_id: str,
|
||||
variable_name: str, value: str, project_name: str = "",
|
||||
environment_name: str = "",
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
environment_name: str = "") -> bool:
|
||||
"""
|
||||
Store a sensitive variable value in GNOME Keyring (async).
|
||||
Store a sensitive variable value in GNOME Keyring.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
@ -77,7 +69,9 @@ 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)
|
||||
callback: Optional callback function called with success boolean
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Create a descriptive label for the keyring UI
|
||||
label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \
|
||||
@ -90,40 +84,36 @@ class SecretManager:
|
||||
"variable_name": variable_name,
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
# Store the secret asynchronously
|
||||
Secret.password_store(
|
||||
self.schema,
|
||||
attributes,
|
||||
Secret.COLLECTION_DEFAULT,
|
||||
label,
|
||||
value,
|
||||
None, # cancellable
|
||||
on_store_complete,
|
||||
None # user_data
|
||||
)
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to store secret: {e.message}")
|
||||
return False
|
||||
|
||||
def retrieve_secret(self, project_id: str, environment_id: str,
|
||||
variable_name: str,
|
||||
callback: Callable[[Optional[str]], None]):
|
||||
variable_name: str) -> str | None:
|
||||
"""
|
||||
Retrieve a sensitive variable value from GNOME Keyring (async).
|
||||
Retrieve a sensitive variable value from GNOME Keyring.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
variable_name: Name of the variable
|
||||
callback: Callback function called with the secret value (or None if not found)
|
||||
|
||||
Returns:
|
||||
The secret value if found, None otherwise
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
@ -131,38 +121,37 @@ class SecretManager:
|
||||
"variable_name": variable_name,
|
||||
}
|
||||
|
||||
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)
|
||||
try:
|
||||
# Retrieve the secret synchronously
|
||||
value = Secret.password_lookup_sync(
|
||||
self.schema,
|
||||
attributes,
|
||||
None # cancellable
|
||||
)
|
||||
|
||||
# Retrieve the secret asynchronously
|
||||
Secret.password_lookup(
|
||||
self.schema,
|
||||
attributes,
|
||||
None, # cancellable
|
||||
on_lookup_complete,
|
||||
None # user_data
|
||||
)
|
||||
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
|
||||
|
||||
def delete_secret(self, project_id: str, environment_id: str,
|
||||
variable_name: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
variable_name: str) -> bool:
|
||||
"""
|
||||
Delete a sensitive variable value from GNOME Keyring (async).
|
||||
Delete a sensitive variable value from GNOME Keyring.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
variable_name: Name of the variable
|
||||
callback: Optional callback function called with success boolean
|
||||
|
||||
Returns:
|
||||
True if deleted (or didn't exist), False on error
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
@ -170,98 +159,85 @@ class SecretManager:
|
||||
"variable_name": variable_name,
|
||||
}
|
||||
|
||||
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)
|
||||
try:
|
||||
# Delete the secret synchronously
|
||||
deleted = Secret.password_clear_sync(
|
||||
self.schema,
|
||||
attributes,
|
||||
None # cancellable
|
||||
)
|
||||
|
||||
# Delete the secret asynchronously
|
||||
Secret.password_clear(
|
||||
self.schema,
|
||||
attributes,
|
||||
None, # cancellable
|
||||
on_clear_complete,
|
||||
None # user_data
|
||||
)
|
||||
if deleted:
|
||||
logger.info(f"Deleted secret for {variable_name}")
|
||||
else:
|
||||
logger.debug(f"No secret to delete for {variable_name}")
|
||||
|
||||
def delete_all_project_secrets(self, project_id: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
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:
|
||||
"""
|
||||
Delete all secrets for a project (when project is deleted).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
callback: Optional callback function called with success boolean
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
}
|
||||
|
||||
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)
|
||||
try:
|
||||
# Delete all matching secrets
|
||||
deleted = Secret.password_clear_sync(
|
||||
self.schema,
|
||||
attributes,
|
||||
None
|
||||
)
|
||||
|
||||
# Delete all matching secrets asynchronously
|
||||
Secret.password_clear(
|
||||
self.schema,
|
||||
attributes,
|
||||
None,
|
||||
on_clear_complete,
|
||||
None
|
||||
)
|
||||
logger.info(f"Deleted all secrets for project {project_id}")
|
||||
return True
|
||||
|
||||
def delete_all_environment_secrets(self, project_id: str, environment_id: str,
|
||||
callback: Optional[Callable[[bool], None]] = 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:
|
||||
"""
|
||||
Delete all secrets for an environment (when environment is deleted).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
callback: Optional callback function called with success boolean
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
"environment_id": environment_id,
|
||||
}
|
||||
|
||||
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)
|
||||
try:
|
||||
deleted = Secret.password_clear_sync(
|
||||
self.schema,
|
||||
attributes,
|
||||
None
|
||||
)
|
||||
|
||||
Secret.password_clear(
|
||||
self.schema,
|
||||
attributes,
|
||||
None,
|
||||
on_clear_complete,
|
||||
None
|
||||
)
|
||||
logger.info(f"Deleted all secrets for environment {environment_id}")
|
||||
return True
|
||||
|
||||
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
|
||||
callback: Optional[Callable[[bool], None]] = 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:
|
||||
"""
|
||||
Rename a variable across all environments (updates secret keys).
|
||||
|
||||
@ -274,174 +250,65 @@ class SecretManager:
|
||||
project_id: UUID of the project
|
||||
old_name: Current variable name
|
||||
new_name: New variable name
|
||||
callback: Optional callback function called with success boolean
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Search for all secrets with the old variable name
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
"variable_name": old_name,
|
||||
}
|
||||
|
||||
def on_search_complete(source, result, user_data):
|
||||
try:
|
||||
secrets = Secret.password_search_finish(result)
|
||||
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
|
||||
)
|
||||
|
||||
if not secrets:
|
||||
logger.debug(f"No secrets found for variable {old_name}")
|
||||
if callback:
|
||||
callback(True)
|
||||
return
|
||||
if not secrets:
|
||||
logger.debug(f"No secrets found for variable {old_name}")
|
||||
return True
|
||||
|
||||
# Track how many secrets we need to process
|
||||
pending_count = [len(secrets)]
|
||||
all_success = [True]
|
||||
# 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()
|
||||
|
||||
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()
|
||||
# Get environment_id from attributes
|
||||
attrs = item.get_attributes()
|
||||
environment_id = attrs.get("environment_id")
|
||||
|
||||
if not environment_id:
|
||||
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)
|
||||
logger.warning(f"Secret missing environment_id, skipping")
|
||||
continue
|
||||
|
||||
# Store with new name
|
||||
self.store_secret(
|
||||
project_id=project_id,
|
||||
environment_id=environment_id,
|
||||
variable_name=new_name,
|
||||
value=value,
|
||||
callback=on_store_done
|
||||
value=value
|
||||
)
|
||||
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to load secret for rename: {e.message}")
|
||||
callback(False)
|
||||
# Delete old secret
|
||||
self.delete_secret(
|
||||
project_id=project_id,
|
||||
environment_id=environment_id,
|
||||
variable_name=old_name
|
||||
)
|
||||
|
||||
item.load_secret(None, on_load_complete, None)
|
||||
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
|
||||
return True
|
||||
|
||||
# ========== 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
|
||||
)
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to rename variable secrets: {e.message}")
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
@ -26,18 +26,6 @@
|
||||
<property name="accelerator"><Control>s</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Focus URL Field</property>
|
||||
<property name="accelerator"><Control>l</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Send Request</property>
|
||||
<property name="accelerator"><Control>Return</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>
|
||||
|
||||
@ -19,64 +19,31 @@
|
||||
<object class="GtkEntry" id="name_entry">
|
||||
<property name="placeholder-text">Variable name</property>
|
||||
<property name="hexpand">True</property>
|
||||
<signal name="changed" handler="on_name_changed"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Inline edit buttons (shown only when editing) -->
|
||||
<child>
|
||||
<object class="GtkRevealer" id="edit_buttons_revealer">
|
||||
<property name="transition-type">slide-left</property>
|
||||
<property name="reveal-child">False</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">3</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="cancel_button">
|
||||
<property name="icon-name">process-stop-symbolic</property>
|
||||
<property name="tooltip-text">Cancel editing</property>
|
||||
<signal name="clicked" handler="on_cancel_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<property name="tooltip-text">Save changes</property>
|
||||
<signal name="clicked" handler="on_save_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<object class="GtkToggleButton" id="sensitive_toggle">
|
||||
<property name="icon-name">channel-insecure-symbolic</property>
|
||||
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
|
||||
<signal name="toggled" handler="on_sensitive_toggled"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="sensitive_toggle">
|
||||
<property name="icon-name">changes-allow-symbolic</property>
|
||||
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
|
||||
<signal name="toggled" handler="on_sensitive_toggled"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="delete_button">
|
||||
<property name="icon-name">edit-delete-symbolic</property>
|
||||
<property name="tooltip-text">Delete variable</property>
|
||||
<signal name="clicked" handler="on_delete_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
<child>
|
||||
<object class="GtkButton" id="delete_button">
|
||||
<property name="icon-name">edit-delete-symbolic</property>
|
||||
<property name="tooltip-text">Delete variable</property>
|
||||
<signal name="clicked" handler="on_delete_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
|
||||
@ -30,9 +30,6 @@ class VariableDataRow(Gtk.Box):
|
||||
|
||||
variable_cell = Gtk.Template.Child()
|
||||
name_entry = Gtk.Template.Child()
|
||||
edit_buttons_revealer = Gtk.Template.Child()
|
||||
cancel_button = Gtk.Template.Child()
|
||||
save_button = Gtk.Template.Child()
|
||||
sensitive_toggle = Gtk.Template.Child()
|
||||
delete_button = Gtk.Template.Child()
|
||||
values_box = Gtk.Template.Child()
|
||||
@ -47,7 +44,6 @@ class VariableDataRow(Gtk.Box):
|
||||
def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None):
|
||||
super().__init__()
|
||||
self.variable_name = variable_name
|
||||
self.original_name = variable_name # Store original name for cancel
|
||||
self.environments = environments
|
||||
self.size_group = size_group
|
||||
self.project = project
|
||||
@ -55,20 +51,10 @@ class VariableDataRow(Gtk.Box):
|
||||
self.value_entries = {}
|
||||
self.update_timeout_id = None
|
||||
self._updating_toggle = False # Flag to prevent recursion
|
||||
self._is_editing = False # Track editing state
|
||||
|
||||
# Set variable name
|
||||
self.name_entry.set_text(variable_name)
|
||||
|
||||
# Connect focus events for inline editing
|
||||
focus_controller = Gtk.EventControllerFocus()
|
||||
focus_controller.connect('enter', self._on_name_entry_focus_in)
|
||||
focus_controller.connect('leave', self._on_name_entry_focus_out)
|
||||
self.name_entry.add_controller(focus_controller)
|
||||
|
||||
# Connect activate signal (Enter key)
|
||||
self.name_entry.connect('activate', self._on_name_entry_activate)
|
||||
|
||||
# Set initial sensitivity state
|
||||
is_sensitive = variable_name in self.project.sensitive_variables
|
||||
self._updating_toggle = True
|
||||
@ -107,12 +93,22 @@ 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
|
||||
@ -122,74 +118,22 @@ 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()
|
||||
|
||||
# Emit signal first (value is being changed)
|
||||
self.emit('value-changed', env_id, value)
|
||||
|
||||
# Use project_manager to store value in appropriate location (async)
|
||||
# Use project_manager to store value in appropriate location
|
||||
self.project_manager.set_variable_value(
|
||||
self.project.id,
|
||||
env_id,
|
||||
self.variable_name,
|
||||
value,
|
||||
callback=None # Fire and forget - errors are logged
|
||||
value
|
||||
)
|
||||
|
||||
def _on_name_entry_focus_in(self, controller):
|
||||
"""Show edit buttons when entry gains focus."""
|
||||
self._is_editing = True
|
||||
self.original_name = self.name_entry.get_text().strip()
|
||||
self.edit_buttons_revealer.set_reveal_child(True)
|
||||
|
||||
def _on_name_entry_focus_out(self, controller):
|
||||
"""Handle focus leaving the entry without explicit save/cancel."""
|
||||
# Only auto-hide if user didn't click cancel/save
|
||||
# The buttons will handle hiding themselves
|
||||
pass
|
||||
|
||||
def _on_name_entry_activate(self, entry):
|
||||
"""Handle Enter key press - same as clicking Save."""
|
||||
self._save_changes()
|
||||
self.emit('value-changed', env_id, value)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_cancel_clicked(self, button):
|
||||
"""Cancel editing and restore original name."""
|
||||
self.name_entry.set_text(self.original_name)
|
||||
self._is_editing = False
|
||||
self.edit_buttons_revealer.set_reveal_child(False)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_save_clicked(self, button):
|
||||
"""Save variable name changes."""
|
||||
self._save_changes()
|
||||
|
||||
def _save_changes(self):
|
||||
"""Save the variable name change."""
|
||||
new_name = self.name_entry.get_text().strip()
|
||||
|
||||
# Only emit if name actually changed
|
||||
if new_name != self.original_name:
|
||||
self.emit('variable-changed')
|
||||
|
||||
self._is_editing = False
|
||||
self.edit_buttons_revealer.set_reveal_child(False)
|
||||
self.original_name = new_name
|
||||
def on_name_changed(self, entry):
|
||||
"""Handle variable name changes."""
|
||||
self.emit('variable-changed')
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_delete_clicked(self, button):
|
||||
@ -215,7 +159,7 @@ class VariableDataRow(Gtk.Box):
|
||||
self.sensitive_toggle.set_tooltip_text("Sensitive (stored in keyring)\nClick to make non-sensitive")
|
||||
self.name_entry.add_css_class("sensitive-variable-name")
|
||||
else:
|
||||
self.sensitive_toggle.set_icon_name("changes-allow-symbolic")
|
||||
self.sensitive_toggle.set_icon_name("channel-insecure-symbolic")
|
||||
self.sensitive_toggle.set_tooltip_text("Not sensitive (stored in JSON)\nClick to mark as sensitive")
|
||||
self.name_entry.remove_css_class("sensitive-variable-name")
|
||||
|
||||
@ -228,9 +172,6 @@ class VariableDataRow(Gtk.Box):
|
||||
self.environments = environments
|
||||
self.project = project
|
||||
|
||||
# Update original_name in case variable was renamed
|
||||
self.original_name = self.variable_name
|
||||
|
||||
# Update sensitivity toggle state
|
||||
is_sensitive = self.variable_name in self.project.sensitive_variables
|
||||
self._updating_toggle = True
|
||||
|
||||
219
src/window.py
219
src/window.py
@ -434,23 +434,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
if page:
|
||||
self.tab_view.close_page(page)
|
||||
|
||||
def _focus_url_field(self) -> None:
|
||||
"""Focus the URL field in the current tab."""
|
||||
page = self.tab_view.get_selected_page()
|
||||
if page:
|
||||
widget = self.page_to_widget.get(page)
|
||||
if widget and hasattr(widget, 'url_entry'):
|
||||
widget.url_entry.grab_focus()
|
||||
|
||||
def _send_current_request(self) -> None:
|
||||
"""Send the request from the current tab."""
|
||||
page = self.tab_view.get_selected_page()
|
||||
if page:
|
||||
widget = self.page_to_widget.get(page)
|
||||
if widget and hasattr(widget, 'send_button'):
|
||||
# Trigger the send button click
|
||||
self._on_send_clicked(widget)
|
||||
|
||||
def _on_new_request_clicked(self, button):
|
||||
"""Handle New Request button click."""
|
||||
self._create_new_tab()
|
||||
@ -475,18 +458,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
self.add_action(action)
|
||||
self.get_application().set_accels_for_action("win.save-request", ["<Control>s"])
|
||||
|
||||
# Focus URL field shortcut (Ctrl+L)
|
||||
action = Gio.SimpleAction.new("focus-url", None)
|
||||
action.connect("activate", lambda a, p: self._focus_url_field())
|
||||
self.add_action(action)
|
||||
self.get_application().set_accels_for_action("win.focus-url", ["<Control>l"])
|
||||
|
||||
# Send request shortcut (Ctrl+Return)
|
||||
action = Gio.SimpleAction.new("send-request", None)
|
||||
action.connect("activate", lambda a, p: self._send_current_request())
|
||||
self.add_action(action)
|
||||
self.get_application().set_accels_for_action("win.send-request", ["<Control>Return"])
|
||||
|
||||
def _on_send_clicked(self, widget):
|
||||
"""Handle Send button click from a tab widget."""
|
||||
# Clear previous preprocessing results
|
||||
@ -535,13 +506,10 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
if preprocessing_result.modified_request:
|
||||
modified_request = preprocessing_result.modified_request
|
||||
|
||||
# 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
|
||||
# Apply variable substitution to (possibly modified) request
|
||||
substituted_request = modified_request
|
||||
if widget.selected_environment_id:
|
||||
env = widget.get_selected_environment()
|
||||
if env:
|
||||
from .variable_substitution import VariableSubstitution
|
||||
substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env)
|
||||
@ -549,60 +517,65 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
if undefined:
|
||||
logger.warning(f"Undefined variables in request: {', '.join(undefined)}")
|
||||
|
||||
# Execute async
|
||||
def callback(response, error, user_data):
|
||||
"""Callback runs on main thread."""
|
||||
# Re-enable send button
|
||||
widget.send_button.set_sensitive(True)
|
||||
# Disable send button during request
|
||||
widget.send_button.set_sensitive(False)
|
||||
|
||||
# Update tab with response
|
||||
if response:
|
||||
widget.display_response(response)
|
||||
# Execute async
|
||||
def callback(response, error, user_data):
|
||||
"""Callback runs on main thread."""
|
||||
# Re-enable send button
|
||||
widget.send_button.set_sensitive(True)
|
||||
|
||||
# Execute postprocessing script if exists
|
||||
scripts = widget.get_scripts()
|
||||
if scripts and scripts.postprocessing.strip():
|
||||
from .script_executor import ScriptExecutor, ScriptContext
|
||||
# Update tab with response
|
||||
if response:
|
||||
widget.display_response(response)
|
||||
|
||||
# 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 postprocessing script if exists
|
||||
scripts = widget.get_scripts()
|
||||
if scripts and scripts.postprocessing.strip():
|
||||
from .script_executor import ScriptExecutor, ScriptContext
|
||||
|
||||
# Execute script with context
|
||||
script_result = ScriptExecutor.execute_postprocessing_script(
|
||||
scripts.postprocessing,
|
||||
response,
|
||||
context
|
||||
# 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
|
||||
)
|
||||
|
||||
# Display script results
|
||||
widget.display_script_results(script_result)
|
||||
# Execute script with context
|
||||
script_result = ScriptExecutor.execute_postprocessing_script(
|
||||
scripts.postprocessing,
|
||||
response,
|
||||
context
|
||||
)
|
||||
|
||||
# Process variable updates
|
||||
self._process_variable_updates(script_result, widget, context)
|
||||
else:
|
||||
widget._clear_script_results()
|
||||
# Display script results
|
||||
widget.display_script_results(script_result)
|
||||
|
||||
# Process variable updates
|
||||
self._process_variable_updates(script_result, widget, context)
|
||||
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()
|
||||
@ -622,28 +595,22 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# No sensitive variables defined, use fully substituted request
|
||||
request_for_history = substituted_request
|
||||
elif env:
|
||||
# Environment loaded but no project - use fully substituted request
|
||||
# Environment selected 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)
|
||||
|
||||
# Fetch environment asynchronously then proceed
|
||||
if widget.selected_environment_id:
|
||||
widget.get_selected_environment(proceed_with_request)
|
||||
else:
|
||||
proceed_with_request(None)
|
||||
self.http_client.execute_request_async(substituted_request, callback, None)
|
||||
|
||||
# History and Project Management
|
||||
def _load_history(self) -> None:
|
||||
@ -1086,7 +1053,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
|
||||
# Check if current tab has a saved request or project (for pre-filling)
|
||||
# Check if current tab has a saved request (for pre-filling)
|
||||
current_tab = None
|
||||
preselect_project_index = 0
|
||||
prefill_name = ""
|
||||
@ -1101,13 +1068,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
preselect_project_index = i
|
||||
prefill_name = current_tab.name
|
||||
break
|
||||
elif current_tab.project_id:
|
||||
# Tab associated with a project (e.g., from "Add Request")
|
||||
for i, p in enumerate(projects):
|
||||
if p.id == current_tab.project_id:
|
||||
preselect_project_index = i
|
||||
break
|
||||
if current_tab.name and current_tab.name != "New Request":
|
||||
elif current_tab.name and current_tab.name != "New Request":
|
||||
# Copy tab or named unsaved tab - prefill with tab name
|
||||
prefill_name = current_tab.name
|
||||
|
||||
@ -1271,18 +1232,48 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
dialog.present(self)
|
||||
|
||||
def _on_add_to_project(self, widget, project):
|
||||
"""Create a new empty request tab associated with the project."""
|
||||
# Get default environment for the project
|
||||
default_env_id = project.environments[0].id if project.environments else None
|
||||
"""Save current request to specific project."""
|
||||
request = self._build_request_from_ui()
|
||||
|
||||
# Create a new tab with project_id set so Save will pre-select this project
|
||||
self._create_new_tab(
|
||||
name="New Request",
|
||||
project_id=project.id,
|
||||
selected_environment_id=default_env_id
|
||||
)
|
||||
if not request.url.strip():
|
||||
self._show_toast("Cannot save: URL is empty")
|
||||
return
|
||||
|
||||
self._show_toast(f"New request for '{project.name}'")
|
||||
dialog = Adw.AlertDialog()
|
||||
dialog.set_heading(f"Save to {project.name}")
|
||||
dialog.set_body("Enter a name for this request:")
|
||||
|
||||
entry = Gtk.Entry()
|
||||
entry.set_placeholder_text("Request name")
|
||||
dialog.set_extra_child(entry)
|
||||
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("save", "Save")
|
||||
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
|
||||
|
||||
def on_response(dlg, response):
|
||||
if response == "save":
|
||||
name = entry.get_text().strip()
|
||||
is_valid, error_msg = self._validate_request_name(name)
|
||||
if is_valid:
|
||||
# Check for duplicate name
|
||||
existing = self.project_manager.find_request_by_name(project.id, name)
|
||||
if existing:
|
||||
# Show overwrite confirmation
|
||||
self._show_overwrite_dialog(project, name, existing.id, request)
|
||||
else:
|
||||
# No duplicate, save normally
|
||||
saved_request = self.project_manager.add_request(project.id, name, request)
|
||||
self._load_projects()
|
||||
self._show_toast(f"Saved as '{name}'")
|
||||
|
||||
# Clear modified flag on current tab
|
||||
self._mark_tab_as_saved(saved_request.id, name, request)
|
||||
else:
|
||||
self._show_toast(error_msg)
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
|
||||
def _on_load_request(self, widget, saved_request):
|
||||
"""Load saved request - smart loading based on current tab state."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user