Compare commits

...

No commits in common. "v0.5.0" and "master" have entirely different histories.

25 changed files with 1341 additions and 1092 deletions

232
README.md
View File

@ -6,187 +6,115 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
- Send HTTP requests (GET, POST, PUT, DELETE) - Send HTTP requests (GET, POST, PUT, DELETE)
- Configure custom headers and request bodies - Configure custom headers and request bodies
- View response headers and bodies - View response headers and bodies with syntax highlighting
- Track request history with persistence - Track request history with persistence
- **JavaScript preprocessing and postprocessing scripts** - Organize requests into projects
- **Project environments with variables** - Environment variables with secure credential storage
- Beautiful GNOME-native UI - JavaScript preprocessing and postprocessing scripts
- Export requests (cURL and more)
- GNOME-native UI
## Dependencies ## Screenshots
- GTK 4 ### Main Interface
- libadwaita 1 ![Main Interface](screenshots/main.png)
- Python 3
- libsoup3 (provided by GNOME Platform)
- gjs (GNOME JavaScript) - for script execution
## Building ### Request History
![Request History](screenshots/history.png)
### Environment Variables
![Environment Variables](screenshots/variables.png)
## Quick Start
### Installation
**Build from source:**
```bash ```bash
git clone https://git.bugsy.cz/beval/roster.git
cd roster
meson setup builddir meson setup builddir
meson compile -C builddir meson compile -C builddir
sudo meson install -C builddir sudo meson install -C builddir
``` ```
## Usage **Flatpak:**
```bash
Roster uses libsoup3 (from GNOME Platform) for making HTTP requests - no external dependencies required. flatpak-builder --user --install --force-clean build-dir cz.bugsy.roster.json
flatpak run cz.bugsy.roster
Run Roster from your application menu or with the `roster` command.
## Scripts
Roster supports JavaScript preprocessing and postprocessing scripts to automate request modifications and response data extraction.
### Preprocessing Scripts
**Run BEFORE the HTTP request is sent.** Use preprocessing to:
- Modify request headers, URL, body, or method
- Add dynamic values (timestamps, request IDs, signatures)
- Read environment variables
- Set/update environment variables
**Available API:**
```javascript
// Request object (modifiable)
request.method // "GET", "POST", "PUT", "DELETE"
request.url // Full URL string
request.headers // Object with header key-value pairs
request.body // Request body string
// Roster API
roster.getVariable(name) // Get variable from selected environment
roster.setVariable(name, value) // Set/update variable
roster.setVariables({key: value}) // Batch set variables
roster.project.name // Current project name
roster.project.environments // Array of environment names
// Console output
console.log(message) // Output shown in preprocessing results
``` ```
**Example 1: Add Dynamic Authentication Header** See the [Installation Guide](https://git.bugsy.cz/beval/roster/wiki/Installation) for detailed instructions.
```javascript
const token = roster.getVariable('auth_token');
request.headers['Authorization'] = 'Bearer ' + token;
request.headers['X-Request-Time'] = new Date().toISOString();
console.log('Added auth header for token:', token);
```
**Example 2: Modify Request Based on Environment** ### Usage
```javascript
const env = roster.getVariable('environment_name');
if (env === 'production') {
request.url = request.url.replace('localhost', 'api.example.com');
console.log('Switched to production URL');
}
```
**Example 3: Generate Request Signature** Launch Roster from your application menu or run `roster` from the command line.
```javascript
const apiKey = roster.getVariable('api_key');
const timestamp = Date.now().toString();
const requestId = Math.random().toString(36).substring(7);
request.headers['X-API-Key'] = apiKey; See the [Getting Started Guide](https://git.bugsy.cz/beval/roster/wiki/Getting-Started) for a walkthrough.
request.headers['X-Timestamp'] = timestamp;
request.headers['X-Request-ID'] = requestId;
// Save for later reference Complete documentation is available in the [Wiki](https://git.bugsy.cz/beval/roster/wiki):
roster.setVariable('last_request_id', requestId);
console.log('Request ID:', requestId);
```
### Postprocessing Scripts ## Key Features
**Run AFTER receiving the HTTP response.** Use postprocessing to: ### Multi-Environment Support
- Extract data from response body
- Parse JSON/XML responses
- Store values in environment variables for use in subsequent requests
- Validate response data
**Available API:** Manage multiple environments (development, staging, production) with different variable values. Switch environments with one click.
```javascript
// Response object (read-only)
response.body // Response body as string
response.headers // Object with header key-value pairs
response.statusCode // HTTP status code (e.g., 200, 404)
response.statusText // Status text (e.g., "OK", "Not Found")
response.responseTime // Response time in milliseconds
// Roster API [Learn more about Variables](https://git.bugsy.cz/beval/roster/wiki/Variables)
roster.setVariable(name, value) // Set/update variable
roster.setVariables({key: value}) // Batch set variables
// Console output ### Secure Credential Storage
console.log(message) // Output shown in script results
```
**Example 1: Extract Authentication Token** 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.
```javascript
const data = JSON.parse(response.body);
if (data.access_token) {
roster.setVariable('auth_token', data.access_token);
console.log('Saved auth token');
}
```
**Example 2: Extract Multiple Values** [Learn more about Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)
```javascript
const data = JSON.parse(response.body);
roster.setVariables({
user_id: data.user.id,
user_name: data.user.name,
session_id: data.session.id
});
console.log('Extracted user:', data.user.name);
```
**Example 3: Validate and Store Response** ### JavaScript Automation
```javascript
const data = JSON.parse(response.body);
if (response.statusCode === 200 && data.items) {
roster.setVariable('item_count', data.items.length.toString());
if (data.items.length > 0) {
roster.setVariable('first_item_id', data.items[0].id);
}
console.log('Found', data.items.length, 'items');
} else {
console.log('Error: Invalid response');
}
```
### Workflow Example: OAuth Token Flow Use preprocessing and postprocessing scripts to:
- Extract authentication tokens from responses
- Add dynamic headers (timestamps, signatures)
- Chain requests together
- Validate responses
**Request 1 (Login) - Postprocessing:** [Learn more about Scripts](https://git.bugsy.cz/bavel/roster/wiki/Scripts)
```javascript
// Extract and store tokens from login response
const data = JSON.parse(response.body);
roster.setVariables({
access_token: data.access_token,
refresh_token: data.refresh_token,
user_id: data.user_id
});
console.log('Logged in as user:', data.user_id);
```
**Request 2 (API Call) - Preprocessing:** ## Dependencies
```javascript
// Use stored token in subsequent request
const token = roster.getVariable('access_token');
const userId = roster.getVariable('user_id');
request.headers['Authorization'] = 'Bearer ' + token; ### Runtime
request.url = request.url.replace('{userId}', userId); - GTK 4
console.log('Making authenticated request for user:', userId); - libadwaita 1
``` - Python 3
- libsoup3 (provided by GNOME Platform)
- libsecret (provided by GNOME Platform)
- GJS (GNOME JavaScript)
### Environment Variables ### Build
- Meson (>= 1.0.0)
- Ninja
- pkg-config
- gettext
Variables can be: See the [Installation Guide](https://git.bugsy.cz/beval/roster/wiki/Installation) for platform-specific dependencies.
- Manually defined in "Manage Environments"
- Automatically created by scripts using `roster.setVariable()`
- Used in requests with `{{variable_name}}` syntax
- Read in preprocessing with `roster.getVariable()`
Variables are scoped to environments within projects, allowing different values for development, staging, and production. ## Platform
Roster is built specifically for the GNOME desktop using native technologies:
- **GTK 4** - Modern UI toolkit
- **libadwaita** - GNOME design patterns
- **libsoup3** - HTTP networking
- **libsecret** - Secure credential storage
- **GJS** - JavaScript runtime for scripts
## License
GPL-3.0-or-later
## Support
- **Issues**: [git.bugsy.cz/beval/roster/issues](https://git.bugsy.cz/beval/roster/issues)
- **Wiki**: [git.bugsy.cz/beval/roster/wiki](https://git.bugsy.cz/bavel/roster/wiki)
- **Source**: [git.bugsy.cz/beval/roster](https://git.bugsy.cz/beval/roster)
## Contributing
Contributions are welcome! Please open an issue or pull request.

View File

@ -1,230 +0,0 @@
# 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

View File

@ -1,271 +0,0 @@
# 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

View File

@ -0,0 +1,39 @@
{
"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"
}
]
}
]
}

View File

@ -9,8 +9,7 @@
"--share=ipc", "--share=ipc",
"--socket=fallback-x11", "--socket=fallback-x11",
"--device=dri", "--device=dri",
"--socket=wayland", "--socket=wayland"
"--talk-name=org.freedesktop.secrets"
], ],
"cleanup" : [ "cleanup" : [
"/include", "/include",

View File

@ -8,7 +8,6 @@
<summary>HTTP client for API testing</summary> <summary>HTTP client for API testing</summary>
<description> <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>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> </description>
<developer id="cz.bugsy"> <developer id="cz.bugsy">
@ -26,18 +25,71 @@
<!-- Use the OARS website (https://hughsie.github.io/oars/generate.html) to generate these and make sure to use oars-1.1 --> <!-- 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" /> <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> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image>https://example.org/example1.png</image> <image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/main.png</image>
<caption>A caption</caption> <caption>Main application window with HTTP request and response</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://example.org/example2.png</image> <image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/variables.png</image>
<caption>A caption</caption> <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>
</screenshot> </screenshot>
</screenshots> </screenshots>
<releases> <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"> <release version="0.4.0" date="2026-01-05">
<description translate="no"> <description translate="no">
<p>Version 0.4.0 release</p> <p>Version 0.4.0 release</p>

View File

@ -1,5 +1,5 @@
project('roster', project('roster',
version: '0.5.0', version: '0.8.2',
meson_version: '>= 1.0.0', meson_version: '>= 1.0.0',
default_options: [ 'warning_level=2', 'werror=false', ], default_options: [ 'warning_level=2', 'werror=false', ],
) )

View File

@ -3,6 +3,19 @@
data/cz.bugsy.roster.desktop.in data/cz.bugsy.roster.desktop.in
data/cz.bugsy.roster.metainfo.xml.in data/cz.bugsy.roster.metainfo.xml.in
data/cz.bugsy.roster.gschema.xml 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.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.py
src/window.ui

182
po/roster.pot Normal file
View File

@ -0,0 +1,182 @@
# 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 ""

BIN
screenshots/history.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
screenshots/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
screenshots/variables.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@ -5,7 +5,7 @@
<template class="EnvironmentsDialog" parent="AdwDialog"> <template class="EnvironmentsDialog" parent="AdwDialog">
<property name="title">Manage Environments</property> <property name="title">Manage Environments</property>
<property name="content-width">900</property> <property name="content-width">1100</property>
<property name="content-height">600</property> <property name="content-height">600</property>
<property name="follows-content-size">false</property> <property name="follows-content-size">false</property>

View File

@ -169,11 +169,13 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response): def on_response(dlg, response):
if response == "delete": if response == "delete":
self.project_manager.delete_variable(self.project.id, var_name) def on_delete_complete():
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -181,11 +183,13 @@ class EnvironmentsDialog(Adw.Dialog):
"""Handle variable name change.""" """Handle variable name change."""
new_name = row.get_variable_name() new_name = row.get_variable_name()
if new_name and new_name != old_name and new_name not in self.project.variable_names: if new_name and new_name != old_name and new_name not in self.project.variable_names:
self.project_manager.rename_variable(self.project.id, old_name, new_name) def on_rename_complete():
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete)
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_add_environment_clicked(self, button): def on_add_environment_clicked(self, button):
"""Add new environment.""" """Add new environment."""
@ -267,11 +271,13 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response): def on_response(dlg, response):
if response == "delete": if response == "delete":
self.project_manager.delete_environment(self.project.id, environment.id) def on_delete_complete():
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -283,22 +289,19 @@ class EnvironmentsDialog(Adw.Dialog):
def _on_sensitivity_changed(self, widget, is_sensitive, var_name): def _on_sensitivity_changed(self, widget, is_sensitive, var_name):
"""Handle variable sensitivity toggle.""" """Handle variable sensitivity toggle."""
def on_complete(success):
if success:
# Reload and refresh UI
self._reload_project()
self._populate_table()
self.emit('environments-updated')
if is_sensitive: if is_sensitive:
# Mark as sensitive (move to keyring) # Mark as sensitive (move to keyring)
success = self.project_manager.mark_variable_as_sensitive(self.project.id, var_name) self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
if success:
# Reload and refresh UI
self._reload_project()
self._populate_table()
self.emit('environments-updated')
else: else:
# Mark as non-sensitive (move to JSON) # Mark as non-sensitive (move to JSON)
success = self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name) self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
if success:
# Reload and refresh UI
self._reload_project()
self._populate_table()
self.emit('environments-updated')
def _reload_project(self): def _reload_project(self):
"""Reload project data from manager.""" """Reload project data from manager."""

View File

@ -35,8 +35,11 @@ class HttpRequest:
@classmethod @classmethod
def default_headers(cls) -> Dict[str, str]: def default_headers(cls) -> Dict[str, str]:
"""Return default headers for new requests.""" """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 { return {
"User-Agent": f"Roster/{constants.VERSION}" "User-Agent": f"Roster/{short_version}"
} }
def to_dict(self): def to_dict(self):
@ -80,6 +83,7 @@ class HistoryEntry:
response: Optional[HttpResponse] response: Optional[HttpResponse]
error: Optional[str] # Error message if request failed error: Optional[str] # Error message if request failed
id: str = None # Unique identifier for the entry id: str = None # Unique identifier for the entry
has_redacted_variables: bool = False # True if sensitive variables were redacted
def __post_init__(self): def __post_init__(self):
"""Generate UUID if id not provided.""" """Generate UUID if id not provided."""
@ -94,7 +98,8 @@ class HistoryEntry:
'timestamp': self.timestamp, 'timestamp': self.timestamp,
'request': self.request.to_dict(), 'request': self.request.to_dict(),
'response': self.response.to_dict() if self.response else None, 'response': self.response.to_dict() if self.response else None,
'error': self.error 'error': self.error,
'has_redacted_variables': self.has_redacted_variables
} }
@classmethod @classmethod
@ -105,7 +110,8 @@ class HistoryEntry:
timestamp=data['timestamp'], timestamp=data['timestamp'],
request=HttpRequest.from_dict(data['request']), request=HttpRequest.from_dict(data['request']),
response=HttpResponse.from_dict(data['response']) if data.get('response') else None, response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
error=data.get('error') error=data.get('error'),
has_redacted_variables=data.get('has_redacted_variables', False)
) )

View File

@ -22,7 +22,7 @@ import json
import uuid import uuid
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional, Callable
from datetime import datetime, timezone from datetime import datetime, timezone
import gi import gi
gi.require_version('GLib', '2.0') gi.require_version('GLib', '2.0')
@ -132,18 +132,23 @@ class ProjectManager:
break break
self.save_projects(projects) self.save_projects(projects)
def delete_project(self, project_id: str): def delete_project(self, project_id: str,
"""Delete a project and all its requests.""" callback: Optional[Callable[[], None]] = None):
"""Delete a project and all its requests (async for secret cleanup)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
# Delete all secrets for this project # Remove project from list and save immediately
secret_manager.delete_all_project_secrets(project_id)
# Remove project from list
projects = [p for p in projects if p.id != project_id] projects = [p for p in projects if p.id != project_id]
self.save_projects(projects) 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: def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
"""Add request to a project.""" """Add request to a project."""
projects = self.load_projects() projects = self.load_projects()
@ -239,20 +244,27 @@ class ProjectManager:
break break
self.save_projects(projects) self.save_projects(projects)
def delete_environment(self, project_id: str, env_id: str): def delete_environment(self, project_id: str, env_id: str,
"""Delete an environment.""" callback: Optional[Callable[[], None]] = None):
"""Delete an environment (async for secret cleanup)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
for p in projects: for p in projects:
if p.id == project_id: 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 # Remove environment from list
p.environments = [e for e in p.environments if e.id != env_id] p.environments = [e for e in p.environments if e.id != env_id]
break break
self.save_projects(projects) 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): def add_variable(self, project_id: str, variable_name: str):
"""Add a variable to the project and all environments.""" """Add a variable to the project and all environments."""
projects = self.load_projects() projects = self.load_projects()
@ -266,10 +278,12 @@ class ProjectManager:
break break
self.save_projects(projects) self.save_projects(projects)
def rename_variable(self, project_id: str, old_name: str, new_name: str): def rename_variable(self, project_id: str, old_name: str, new_name: str,
"""Rename a variable in the project and all environments.""" callback: Optional[Callable[[], None]] = None):
"""Rename a variable in the project and all environments (async for secrets)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
is_sensitive = False
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -278,24 +292,33 @@ class ProjectManager:
idx = p.variable_names.index(old_name) idx = p.variable_names.index(old_name)
p.variable_names[idx] = new_name p.variable_names[idx] = new_name
# Update sensitive_variables list if applicable # Check if sensitive and update list
if old_name in p.sensitive_variables: if old_name in p.sensitive_variables:
is_sensitive = True
idx_sensitive = p.sensitive_variables.index(old_name) idx_sensitive = p.sensitive_variables.index(old_name)
p.sensitive_variables[idx_sensitive] = new_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 # Update all environments
for env in p.environments: for env in p.environments:
if old_name in env.variables: if old_name in env.variables:
env.variables[new_name] = env.variables.pop(old_name) env.variables[new_name] = env.variables.pop(old_name)
break break
self.save_projects(projects) self.save_projects(projects)
def delete_variable(self, project_id: str, variable_name: str): # Rename secrets in keyring if sensitive
"""Delete a variable from the project and all environments.""" if is_sensitive:
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None)
elif callback:
callback()
def delete_variable(self, project_id: str, variable_name: str,
callback: Optional[Callable[[], None]] = None):
"""Delete a variable from the project and all environments (async for secrets)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
is_sensitive = False
environments_to_cleanup = []
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -303,19 +326,33 @@ class ProjectManager:
if variable_name in p.variable_names: if variable_name in p.variable_names:
p.variable_names.remove(variable_name) p.variable_names.remove(variable_name)
# Remove from sensitive_variables if applicable # Check if sensitive and get environments for cleanup
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
is_sensitive = True
p.sensitive_variables.remove(variable_name) p.sensitive_variables.remove(variable_name)
# Delete all secrets for this variable environments_to_cleanup = [env.id for env in p.environments]
for env in p.environments:
secret_manager.delete_secret(project_id, env.id, variable_name)
# Remove from all environments # Remove from all environments
for env in p.environments: for env in p.environments:
env.variables.pop(variable_name, None) env.variables.pop(variable_name, None)
break break
self.save_projects(projects) 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): def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str):
"""Update a single variable value in an environment.""" """Update a single variable value in an environment."""
projects = self.load_projects() projects = self.load_projects()
@ -351,9 +388,10 @@ class ProjectManager:
# ========== Sensitive Variable Management ========== # ========== Sensitive Variable Management ==========
def mark_variable_as_sensitive(self, project_id: str, variable_name: str) -> bool: def mark_variable_as_sensitive(self, project_id: str, variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
""" """
Mark a variable as sensitive (move values from JSON to keyring). Mark a variable as sensitive (move values from JSON to keyring) - async.
This will: This will:
1. Add variable to sensitive_variables list 1. Add variable to sensitive_variables list
@ -363,12 +401,11 @@ class ProjectManager:
Args: Args:
project_id: Project ID project_id: Project ID
variable_name: Variable name to mark as sensitive variable_name: Variable name to mark as sensitive
callback: Optional callback called with success boolean
Returns:
True if successful
""" """
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
secrets_to_store = []
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -376,30 +413,56 @@ class ProjectManager:
if variable_name not in p.sensitive_variables: if variable_name not in p.sensitive_variables:
p.sensitive_variables.append(variable_name) p.sensitive_variables.append(variable_name)
# Move all environment values to keyring # Collect all environment values to store in keyring
for env in p.environments: for env in p.environments:
if variable_name in env.variables: if variable_name in env.variables:
value = env.variables[variable_name] value = env.variables[variable_name]
# Store in keyring secrets_to_store.append({
secret_manager.store_secret( 'environment_id': env.id,
project_id=project_id, 'environment_name': env.name,
environment_id=env.id, 'value': value,
variable_name=variable_name, 'project_name': p.name
value=value, })
project_name=p.name,
environment_name=env.name
)
# Clear from JSON (keep empty placeholder) # Clear from JSON (keep empty placeholder)
env.variables[variable_name] = "" env.variables[variable_name] = ""
self.save_projects(projects) self.save_projects(projects)
return True
return False # Store all secrets asynchronously
if not secrets_to_store:
if callback:
callback(True)
return
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str) -> bool: pending_count = [len(secrets_to_store)]
all_success = [True]
def on_single_store(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
for secret_info in secrets_to_store:
secret_manager.store_secret(
project_id=project_id,
environment_id=secret_info['environment_id'],
variable_name=variable_name,
value=secret_info['value'],
project_name=secret_info['project_name'],
environment_name=secret_info['environment_name'],
callback=on_single_store
)
return
if callback:
callback(False)
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
""" """
Mark a variable as non-sensitive (move values from keyring to JSON). Mark a variable as non-sensitive (move values from keyring to JSON) - async.
This will: This will:
1. Remove variable from sensitive_variables list 1. Remove variable from sensitive_variables list
@ -409,40 +472,62 @@ class ProjectManager:
Args: Args:
project_id: Project ID project_id: Project ID
variable_name: Variable name to mark as non-sensitive variable_name: Variable name to mark as non-sensitive
callback: Optional callback called with success boolean
Returns:
True if successful
""" """
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
target_project = None
environments_to_migrate = []
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
target_project = p
# Remove from sensitive list # Remove from sensitive list
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
p.sensitive_variables.remove(variable_name) p.sensitive_variables.remove(variable_name)
# Move all environment values from keyring to JSON environments_to_migrate = list(p.environments)
for env in p.environments: break
# Retrieve from keyring
value = secret_manager.retrieve_secret( if not target_project or not environments_to_migrate:
project_id=project_id, if callback:
environment_id=env.id, callback(False)
variable_name=variable_name 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 # Store in JSON
env.variables[variable_name] = value or "" env.variables[variable_name] = value or ""
self.save_projects(projects)
# Delete from keyring # 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( secret_manager.delete_secret(
project_id=project_id, project_id=project_id,
environment_id=env.id, environment_id=env.id,
variable_name=variable_name variable_name=variable_name,
callback=on_delete
) )
return on_retrieve
self.save_projects(projects) for env in environments_to_migrate:
return True secret_manager.retrieve_secret(
project_id=project_id,
return False environment_id=env.id,
variable_name=variable_name,
callback=on_single_migrate(env)
)
def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool: def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool:
"""Check if a variable is marked as sensitive.""" """Check if a variable is marked as sensitive."""
@ -452,9 +537,10 @@ class ProjectManager:
return variable_name in p.sensitive_variables return variable_name in p.sensitive_variables
return False return False
def get_variable_value(self, project_id: str, env_id: str, variable_name: str) -> str: def get_variable_value(self, project_id: str, env_id: str, variable_name: str,
callback: Callable[[str], None]):
""" """
Get variable value from appropriate storage (keyring or JSON). Get variable value from appropriate storage (keyring or JSON) - async.
This is a convenience method that handles both sensitive and non-sensitive variables. This is a convenience method that handles both sensitive and non-sensitive variables.
@ -462,9 +548,7 @@ class ProjectManager:
project_id: Project ID project_id: Project ID
env_id: Environment ID env_id: Environment ID
variable_name: Variable name 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() projects = self.load_projects()
@ -472,25 +556,32 @@ class ProjectManager:
if p.id == project_id: if p.id == project_id:
# Check if sensitive # Check if sensitive
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
# Retrieve from keyring # Retrieve from keyring asynchronously
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
value = secret_manager.retrieve_secret(
def on_retrieve(value):
callback(value or "")
secret_manager.retrieve_secret(
project_id=project_id, project_id=project_id,
environment_id=env_id, environment_id=env_id,
variable_name=variable_name variable_name=variable_name,
callback=on_retrieve
) )
return value or "" return
else: else:
# Retrieve from JSON # Retrieve from JSON synchronously
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
return env.variables.get(variable_name, "") callback(env.variables.get(variable_name, ""))
return
return "" callback("")
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str): def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
callback: Optional[Callable[[bool], None]] = None):
""" """
Set variable value in appropriate storage (keyring or JSON). Set variable value in appropriate storage (keyring or JSON) - async.
This is a convenience method that handles both sensitive and non-sensitive variables. This is a convenience method that handles both sensitive and non-sensitive variables.
@ -499,6 +590,7 @@ class ProjectManager:
env_id: Environment ID env_id: Environment ID
variable_name: Variable name variable_name: Variable name
value: Value to set value: Value to set
callback: Optional callback called with success boolean
""" """
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
@ -507,7 +599,7 @@ class ProjectManager:
if p.id == project_id: if p.id == project_id:
# Check if sensitive # Check if sensitive
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
# Store in keyring # Store in keyring asynchronously
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
secret_manager.store_secret( secret_manager.store_secret(
@ -516,24 +608,32 @@ class ProjectManager:
variable_name=variable_name, variable_name=variable_name,
value=value, value=value,
project_name=p.name, project_name=p.name,
environment_name=env.name environment_name=env.name,
callback=callback
) )
# Keep empty placeholder in JSON # Keep empty placeholder in JSON
env.variables[variable_name] = "" env.variables[variable_name] = ""
break self.save_projects(projects)
return
else: else:
# Store in JSON # Store in JSON synchronously
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
env.variables[variable_name] = value env.variables[variable_name] = value
break break
self.save_projects(projects) self.save_projects(projects)
if callback:
callback(True)
return return
def get_environment_with_secrets(self, project_id: str, env_id: str) -> Optional[Environment]: if callback:
callback(False)
def get_environment_with_secrets(self, project_id: str, env_id: str,
callback: Callable[[Optional[Environment]], None]):
""" """
Get an environment with all variable values (including secrets from keyring). Get an environment with all variable values (including secrets from keyring) - async.
This is useful for variable substitution - it returns a complete Environment This is useful for variable substitution - it returns a complete Environment
object with all values populated from both JSON and keyring. object with all values populated from both JSON and keyring.
@ -541,9 +641,7 @@ class ProjectManager:
Args: Args:
project_id: Project ID project_id: Project ID
env_id: Environment 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() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
@ -560,16 +658,24 @@ class ProjectManager:
created_at=env.created_at 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 # Fill in sensitive variable values from keyring
for var_name in p.sensitive_variables: def on_secrets_retrieved(secrets_dict):
value = secret_manager.retrieve_secret( for var_name, value in secrets_dict.items():
project_id=project_id,
environment_id=env_id,
variable_name=var_name
)
if value is not None: if value is not None:
complete_env.variables[var_name] = value complete_env.variables[var_name] = value
callback(complete_env)
return complete_env secret_manager.retrieve_multiple_secrets(
project_id=project_id,
environment_id=env_id,
variable_names=list(p.sensitive_variables),
callback=on_secrets_retrieved
)
return
return None callback(None)

View File

@ -1318,31 +1318,38 @@ class RequestTabWidget(Gtk.Box):
# Always update visual indicators when environment changes # Always update visual indicators when environment changes
self._update_variable_indicators() self._update_variable_indicators()
def get_selected_environment(self): def get_selected_environment(self, callback):
""" """
Get the currently selected environment object with all values. Get the currently selected environment object with all values (async).
This includes sensitive variable values from the keyring. 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: if not self.selected_environment_id or not self.project_manager or not self.project_id:
return None callback(None)
return
# Get environment with secrets (includes values from keyring) # Get environment with secrets (includes values from keyring) - async
return self.project_manager.get_environment_with_secrets( self.project_manager.get_environment_with_secrets(
self.project_id, self.project_id,
self.selected_environment_id self.selected_environment_id,
callback
) )
def _detect_undefined_variables(self): def _detect_undefined_variables(self, callback):
"""Detect undefined variables in the current request. Returns set of undefined variable names.""" """Detect undefined variables in the current request (async).
Args:
callback: Function called with set of undefined variable names
"""
from .variable_substitution import VariableSubstitution from .variable_substitution import VariableSubstitution
# Get current request from UI # Get current request from UI
request = self.get_request() request = self.get_request()
# Get selected environment def on_environment_loaded(env):
env = self.get_selected_environment()
if not env: if not env:
# No environment selected - ALL variables are undefined # No environment selected - ALL variables are undefined
# Find all variables in the request # Find all variables in the request
@ -1352,11 +1359,14 @@ class RequestTabWidget(Gtk.Box):
for key, value in request.headers.items(): for key, value in request.headers.items():
all_vars.update(VariableSubstitution.find_variables(key)) all_vars.update(VariableSubstitution.find_variables(key))
all_vars.update(VariableSubstitution.find_variables(value)) all_vars.update(VariableSubstitution.find_variables(value))
return all_vars callback(all_vars)
else: else:
# Environment selected - find which variables are undefined # Environment selected - find which variables are undefined
_, undefined = VariableSubstitution.substitute_request(request, env) _, undefined = VariableSubstitution.substitute_request(request, env)
return undefined callback(undefined)
# Get selected environment asynchronously
self.get_selected_environment(on_environment_loaded)
def _schedule_indicator_update(self): def _schedule_indicator_update(self):
"""Schedule an indicator update with debouncing.""" """Schedule an indicator update with debouncing."""
@ -1374,13 +1384,14 @@ class RequestTabWidget(Gtk.Box):
return False # Don't repeat return False # Don't repeat
def _update_variable_indicators(self): def _update_variable_indicators(self):
"""Update visual indicators for undefined variables.""" """Update visual indicators for undefined variables (async)."""
# Only update if we have a project (variables only make sense in project context) # Only update if we have a project (variables only make sense in project context)
if not self.project_id: if not self.project_id:
return return
# Detect undefined variables def on_undefined_detected(undefined_vars):
self.undefined_variables = self._detect_undefined_variables() # Store detected undefined variables
self.undefined_variables = undefined_vars
# Update URL entry # Update URL entry
self._update_url_indicator() self._update_url_indicator()
@ -1391,6 +1402,9 @@ class RequestTabWidget(Gtk.Box):
# Update body # Update body
self._update_body_indicators() self._update_body_indicators()
# Detect undefined variables asynchronously
self._detect_undefined_variables(on_undefined_detected)
def _update_url_indicator(self): def _update_url_indicator(self):
"""Update warning indicator on URL entry.""" """Update warning indicator on URL entry."""
from .variable_substitution import VariableSubstitution from .variable_substitution import VariableSubstitution

View File

@ -23,6 +23,9 @@ Manager for storing and retrieving sensitive variable values using GNOME Keyring
This module provides a clean interface to libsecret for storing environment This module provides a clean interface to libsecret for storing environment
variable values that contain sensitive data (API keys, passwords, tokens). 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: Each secret is identified by:
- project_id: UUID of the project - project_id: UUID of the project
- environment_id: UUID of the environment - environment_id: UUID of the environment
@ -31,8 +34,9 @@ Each secret is identified by:
import gi import gi
gi.require_version('Secret', '1') gi.require_version('Secret', '1')
from gi.repository import Secret, GLib from gi.repository import Secret, GLib, Gio
import logging import logging
from typing import Callable, Optional, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -50,7 +54,10 @@ ROSTER_SCHEMA = Secret.Schema.new(
class SecretManager: class SecretManager:
"""Manages sensitive variable storage using GNOME Keyring via libsecret.""" """Manages sensitive variable storage using GNOME Keyring via libsecret.
All methods use async APIs for compatibility with the XDG Secrets Portal.
"""
def __init__(self): def __init__(self):
"""Initialize the secret manager.""" """Initialize the secret manager."""
@ -58,9 +65,10 @@ class SecretManager:
def store_secret(self, project_id: str, environment_id: str, def store_secret(self, project_id: str, environment_id: str,
variable_name: str, value: str, project_name: str = "", variable_name: str, value: str, project_name: str = "",
environment_name: str = "") -> bool: environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
""" """
Store a sensitive variable value in GNOME Keyring. Store a sensitive variable value in GNOME Keyring (async).
Args: Args:
project_id: UUID of the project project_id: UUID of the project
@ -69,9 +77,7 @@ class SecretManager:
value: The secret value to store value: The secret value to store
project_name: Optional human-readable project name (for display in Seahorse) project_name: Optional human-readable project name (for display in Seahorse)
environment_name: Optional human-readable environment name (for display) 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 # Create a descriptive label for the keyring UI
label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \ label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \
@ -84,36 +90,40 @@ class SecretManager:
"variable_name": variable_name, "variable_name": variable_name,
} }
def on_store_complete(source, result, user_data):
try: try:
# Store the secret synchronously Secret.password_store_finish(result)
# Using PASSWORD collection (default keyring) logger.info(f"Stored secret for {variable_name} in {environment_id}")
Secret.password_store_sync( if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to store secret: {e.message}")
if callback:
callback(False)
# Store the secret asynchronously
Secret.password_store(
self.schema, self.schema,
attributes, attributes,
Secret.COLLECTION_DEFAULT, Secret.COLLECTION_DEFAULT,
label, label,
value, value,
None # cancellable None, # cancellable
on_store_complete,
None # user_data
) )
logger.info(f"Stored secret for {variable_name} in {environment_id}")
return True
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, def retrieve_secret(self, project_id: str, environment_id: str,
variable_name: str) -> str | None: variable_name: str,
callback: Callable[[Optional[str]], None]):
""" """
Retrieve a sensitive variable value from GNOME Keyring. Retrieve a sensitive variable value from GNOME Keyring (async).
Args: Args:
project_id: UUID of the project project_id: UUID of the project
environment_id: UUID of the environment environment_id: UUID of the environment
variable_name: Name of the variable 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 = { attributes = {
"project_id": project_id, "project_id": project_id,
@ -121,37 +131,38 @@ class SecretManager:
"variable_name": variable_name, "variable_name": variable_name,
} }
def on_lookup_complete(source, result, user_data):
try: try:
# Retrieve the secret synchronously value = Secret.password_lookup_finish(result)
value = Secret.password_lookup_sync(
self.schema,
attributes,
None # cancellable
)
if value is None: if value is None:
logger.debug(f"No secret found for {variable_name}") logger.debug(f"No secret found for {variable_name}")
else: else:
logger.debug(f"Retrieved secret for {variable_name}") logger.debug(f"Retrieved secret for {variable_name}")
callback(value)
return value
except GLib.Error as e: except GLib.Error as e:
logger.error(f"Failed to retrieve secret: {e.message}") logger.error(f"Failed to retrieve secret: {e.message}")
return None callback(None)
# Retrieve the secret asynchronously
Secret.password_lookup(
self.schema,
attributes,
None, # cancellable
on_lookup_complete,
None # user_data
)
def delete_secret(self, project_id: str, environment_id: str, def delete_secret(self, project_id: str, environment_id: str,
variable_name: str) -> bool: variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
""" """
Delete a sensitive variable value from GNOME Keyring. Delete a sensitive variable value from GNOME Keyring (async).
Args: Args:
project_id: UUID of the project project_id: UUID of the project
environment_id: UUID of the environment environment_id: UUID of the environment
variable_name: Name of the variable 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 = { attributes = {
"project_id": project_id, "project_id": project_id,
@ -159,85 +170,98 @@ class SecretManager:
"variable_name": variable_name, "variable_name": variable_name,
} }
def on_clear_complete(source, result, user_data):
try: try:
# Delete the secret synchronously deleted = Secret.password_clear_finish(result)
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None # cancellable
)
if deleted: if deleted:
logger.info(f"Deleted secret for {variable_name}") logger.info(f"Deleted secret for {variable_name}")
else: else:
logger.debug(f"No secret to delete for {variable_name}") logger.debug(f"No secret to delete for {variable_name}")
if callback:
return True callback(True)
except GLib.Error as e: except GLib.Error as e:
logger.error(f"Failed to delete secret: {e.message}") logger.error(f"Failed to delete secret: {e.message}")
return False if callback:
callback(False)
def delete_all_project_secrets(self, project_id: str) -> bool: # Delete the secret asynchronously
Secret.password_clear(
self.schema,
attributes,
None, # cancellable
on_clear_complete,
None # user_data
)
def delete_all_project_secrets(self, project_id: str,
callback: Optional[Callable[[bool], None]] = None):
""" """
Delete all secrets for a project (when project is deleted). Delete all secrets for a project (when project is deleted).
Args: Args:
project_id: UUID of the project project_id: UUID of the project
callback: Optional callback function called with success boolean
Returns:
True if successful, False otherwise
""" """
attributes = { attributes = {
"project_id": project_id, "project_id": project_id,
} }
def on_clear_complete(source, result, user_data):
try: try:
# Delete all matching secrets Secret.password_clear_finish(result)
deleted = Secret.password_clear_sync( 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)
# Delete all matching secrets asynchronously
Secret.password_clear(
self.schema, self.schema,
attributes, attributes,
None,
on_clear_complete,
None None
) )
logger.info(f"Deleted all secrets for project {project_id}") def delete_all_environment_secrets(self, project_id: str, environment_id: str,
return True 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). Delete all secrets for an environment (when environment is deleted).
Args: Args:
project_id: UUID of the project project_id: UUID of the project
environment_id: UUID of the environment environment_id: UUID of the environment
callback: Optional callback function called with success boolean
Returns:
True if successful, False otherwise
""" """
attributes = { attributes = {
"project_id": project_id, "project_id": project_id,
"environment_id": environment_id, "environment_id": environment_id,
} }
def on_clear_complete(source, result, user_data):
try: try:
deleted = Secret.password_clear_sync( 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)
Secret.password_clear(
self.schema, self.schema,
attributes, attributes,
None,
on_clear_complete,
None None
) )
logger.info(f"Deleted all secrets for environment {environment_id}") def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
return True 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). Rename a variable across all environments (updates secret keys).
@ -250,65 +274,174 @@ class SecretManager:
project_id: UUID of the project project_id: UUID of the project
old_name: Current variable name old_name: Current variable name
new_name: New 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 = { attributes = {
"project_id": project_id, "project_id": project_id,
"variable_name": old_name, "variable_name": old_name,
} }
def on_search_complete(source, result, user_data):
try: try:
# Search for matching secrets secrets = Secret.password_search_finish(result)
# This returns a list of Secret.Item objects
secrets = Secret.password_search_sync(
self.schema,
attributes,
Secret.SearchFlags.ALL,
None
)
if not secrets: if not secrets:
logger.debug(f"No secrets found for variable {old_name}") logger.debug(f"No secrets found for variable {old_name}")
return True if callback:
callback(True)
return
# Track how many secrets we need to process
pending_count = [len(secrets)]
all_success = [True]
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 each secret, retrieve value, store with new name, delete old
for item in secrets: for item in secrets:
# Get the secret value self._rename_single_secret(item, project_id, old_name, new_name, on_rename_done)
item.load_secret_sync(None)
value = item.get_secret().get_text()
# Get environment_id from attributes except GLib.Error as e:
logger.error(f"Failed to search for variable secrets: {e.message}")
if callback:
callback(False)
# Search for matching secrets asynchronously
Secret.password_search(
self.schema,
attributes,
Secret.SearchFlags.ALL,
None,
on_search_complete,
None
)
def _rename_single_secret(self, item, project_id: str, old_name: str,
new_name: str, callback: Callable[[bool], None]):
"""Helper to rename a single secret item."""
def on_load_complete(item, result, user_data):
try:
item.load_secret_finish(result)
secret = item.get_secret()
if secret is None:
callback(False)
return
value = secret.get_text()
attrs = item.get_attributes() attrs = item.get_attributes()
environment_id = attrs.get("environment_id") environment_id = attrs.get("environment_id")
if not environment_id: if not environment_id:
logger.warning(f"Secret missing environment_id, skipping") logger.warning("Secret missing environment_id, skipping")
continue callback(False)
return
# Store with new name, then delete old
def on_store_done(success):
if success:
self.delete_secret(project_id, environment_id, old_name, callback)
else:
callback(False)
# Store with new name
self.store_secret( self.store_secret(
project_id=project_id, project_id=project_id,
environment_id=environment_id, environment_id=environment_id,
variable_name=new_name, variable_name=new_name,
value=value value=value,
callback=on_store_done
) )
# Delete old secret
self.delete_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=old_name
)
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
return True
except GLib.Error as e: except GLib.Error as e:
logger.error(f"Failed to rename variable secrets: {e.message}") logger.error(f"Failed to load secret for rename: {e.message}")
return False callback(False)
item.load_secret(None, on_load_complete, None)
# ========== Batch Operations ==========
def retrieve_multiple_secrets(self, project_id: str, environment_id: str,
variable_names: list,
callback: Callable[[dict], None]):
"""
Retrieve multiple secrets at once (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variable_names: List of variable names to retrieve
callback: Callback function called with dict of {variable_name: value}
"""
if not variable_names:
callback({})
return
results = {}
pending_count = [len(variable_names)]
def on_single_retrieve(var_name):
def handler(value):
results[var_name] = value
pending_count[0] -= 1
if pending_count[0] == 0:
callback(results)
return handler
for var_name in variable_names:
self.retrieve_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=var_name,
callback=on_single_retrieve(var_name)
)
def store_multiple_secrets(self, project_id: str, environment_id: str,
variables: dict, project_name: str = "",
environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
"""
Store multiple secrets at once (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variables: Dict of {variable_name: value} to store
project_name: Optional project name for display
environment_name: Optional environment name for display
callback: Optional callback called with overall success boolean
"""
if not variables:
if callback:
callback(True)
return
pending_count = [len(variables)]
all_success = [True]
def on_single_store(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
for var_name, value in variables.items():
self.store_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=var_name,
value=value,
project_name=project_name,
environment_name=environment_name,
callback=on_single_store
)
# Singleton instance # Singleton instance

View File

@ -26,6 +26,18 @@
<property name="accelerator">&lt;Control&gt;s</property> <property name="accelerator">&lt;Control&gt;s</property>
</object> </object>
</child> </child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Focus URL Field</property>
<property name="accelerator">&lt;Control&gt;l</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Send Request</property>
<property name="accelerator">&lt;Control&gt;Return</property>
</object>
</child>
<child> <child>
<object class="GtkShortcutsShortcut"> <object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property> <property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>

View File

@ -127,3 +127,118 @@ class VariableSubstitution:
) )
return new_request, all_undefined return new_request, all_undefined
@staticmethod
def substitute_excluding_variables(text: str, variables: Dict[str, str], excluded_vars: List[str]) -> Tuple[str, List[str]]:
"""
Replace {{variable_name}} with values, but skip excluded variables (leave as {{var}}).
Args:
text: The text containing variable placeholders
variables: Dictionary mapping variable names to their values
excluded_vars: List of variable names to skip (leave as placeholders)
Returns:
Tuple of (substituted_text, list_of_undefined_variables)
"""
if not text:
return text, []
undefined_vars = []
def replace_var(match):
var_name = match.group(1)
# Skip excluded variables - leave as {{var_name}}
if var_name in excluded_vars:
return match.group(0) # Return original {{var_name}}
# Substitute non-excluded variables
if var_name in variables:
value = variables[var_name]
# Check if value is empty/None - treat as undefined for warning purposes
if not value:
undefined_vars.append(var_name)
return ""
return value
else:
# Variable not defined - track it and replace with empty string
undefined_vars.append(var_name)
return ""
substituted = VariableSubstitution.VARIABLE_PATTERN.sub(replace_var, text)
return substituted, undefined_vars
@staticmethod
def substitute_request_excluding_sensitive(
request: HttpRequest,
environment: Environment,
sensitive_variables: List[str]
) -> Tuple[HttpRequest, Set[str], bool]:
"""
Substitute variables in request, but exclude sensitive variables (leave as {{var}}).
This is useful for saving requests to history without exposing sensitive values.
Sensitive variables remain in their {{variable_name}} placeholder form.
Args:
request: The HTTP request with variable placeholders
environment: The environment containing variable values
sensitive_variables: List of variable names to exclude from substitution
Returns:
Tuple of (redacted_request, set_of_undefined_variables, has_sensitive_vars)
- redacted_request: Request with only non-sensitive variables substituted
- set_of_undefined_variables: Variables that were referenced but not defined
- has_sensitive_vars: True if any sensitive variables were found and redacted
"""
all_undefined = set()
# Substitute URL (excluding sensitive variables)
new_url, url_undefined = VariableSubstitution.substitute_excluding_variables(
request.url, environment.variables, sensitive_variables
)
all_undefined.update(url_undefined)
# Substitute headers (both keys and values, excluding sensitive variables)
new_headers = {}
for key, value in request.headers.items():
new_key, key_undefined = VariableSubstitution.substitute_excluding_variables(
key, environment.variables, sensitive_variables
)
new_value, value_undefined = VariableSubstitution.substitute_excluding_variables(
value, environment.variables, sensitive_variables
)
all_undefined.update(key_undefined)
all_undefined.update(value_undefined)
new_headers[new_key] = new_value
# Substitute body (excluding sensitive variables)
new_body, body_undefined = VariableSubstitution.substitute_excluding_variables(
request.body, environment.variables, sensitive_variables
)
all_undefined.update(body_undefined)
# Create new HttpRequest with redacted values
redacted_request = HttpRequest(
method=request.method,
url=new_url,
headers=new_headers,
body=new_body,
syntax=request.syntax
)
# Check if any sensitive variables were actually used in the request
all_vars_in_request = set()
all_vars_in_request.update(VariableSubstitution.find_variables(request.url))
all_vars_in_request.update(VariableSubstitution.find_variables(request.body))
for key, value in request.headers.items():
all_vars_in_request.update(VariableSubstitution.find_variables(key))
all_vars_in_request.update(VariableSubstitution.find_variables(value))
# Determine if any sensitive variables were actually present
has_sensitive_vars = bool(
set(sensitive_variables) & all_vars_in_request
)
return redacted_request, all_undefined, has_sensitive_vars

View File

@ -62,6 +62,19 @@
</object> </object>
</child> </child>
<!-- Redaction Indicator (shown when sensitive variables were redacted) -->
<child>
<object class="GtkImage" id="redaction_indicator">
<property name="icon-name">dialog-information-symbolic</property>
<property name="tooltip-text">Sensitive variables were redacted from this history entry</property>
<property name="valign">center</property>
<property name="visible">False</property>
<style>
<class name="warning"/>
</style>
</object>
</child>
<!-- Delete Button --> <!-- Delete Button -->
<child> <child>
<object class="GtkButton" id="delete_button"> <object class="GtkButton" id="delete_button">

View File

@ -34,6 +34,7 @@ class HistoryItem(Gtk.Box):
url_label = Gtk.Template.Child() url_label = Gtk.Template.Child()
timestamp_label = Gtk.Template.Child() timestamp_label = Gtk.Template.Child()
status_label = Gtk.Template.Child() status_label = Gtk.Template.Child()
redaction_indicator = Gtk.Template.Child()
delete_button = Gtk.Template.Child() delete_button = Gtk.Template.Child()
request_headers_label = Gtk.Template.Child() request_headers_label = Gtk.Template.Child()
request_body_scroll = Gtk.Template.Child() request_body_scroll = Gtk.Template.Child()
@ -77,6 +78,10 @@ class HistoryItem(Gtk.Box):
self.timestamp_label.set_text(timestamp_str) self.timestamp_label.set_text(timestamp_str)
# Show redaction indicator if sensitive variables were redacted
if self.entry.has_redacted_variables:
self.redaction_indicator.set_visible(True)
# Status # Status
if self.entry.response: if self.entry.response:
status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}" status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}"

View File

@ -19,13 +19,48 @@
<object class="GtkEntry" id="name_entry"> <object class="GtkEntry" id="name_entry">
<property name="placeholder-text">Variable name</property> <property name="placeholder-text">Variable name</property>
<property name="hexpand">True</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>
</child>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkToggleButton" id="sensitive_toggle"> <object class="GtkToggleButton" id="sensitive_toggle">
<property name="icon-name">channel-insecure-symbolic</property> <property name="icon-name">changes-allow-symbolic</property>
<property name="tooltip-text">Mark as sensitive (store in keyring)</property> <property name="tooltip-text">Mark as sensitive (store in keyring)</property>
<signal name="toggled" handler="on_sensitive_toggled"/> <signal name="toggled" handler="on_sensitive_toggled"/>
<style> <style>
@ -44,8 +79,6 @@
</style> </style>
</object> </object>
</child> </child>
</object>
</child>
<child> <child>
<object class="GtkBox" id="values_box"> <object class="GtkBox" id="values_box">

View File

@ -30,6 +30,9 @@ class VariableDataRow(Gtk.Box):
variable_cell = Gtk.Template.Child() variable_cell = Gtk.Template.Child()
name_entry = 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() sensitive_toggle = Gtk.Template.Child()
delete_button = Gtk.Template.Child() delete_button = Gtk.Template.Child()
values_box = Gtk.Template.Child() values_box = Gtk.Template.Child()
@ -44,6 +47,7 @@ class VariableDataRow(Gtk.Box):
def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None): def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None):
super().__init__() super().__init__()
self.variable_name = variable_name self.variable_name = variable_name
self.original_name = variable_name # Store original name for cancel
self.environments = environments self.environments = environments
self.size_group = size_group self.size_group = size_group
self.project = project self.project = project
@ -51,10 +55,20 @@ class VariableDataRow(Gtk.Box):
self.value_entries = {} self.value_entries = {}
self.update_timeout_id = None self.update_timeout_id = None
self._updating_toggle = False # Flag to prevent recursion self._updating_toggle = False # Flag to prevent recursion
self._is_editing = False # Track editing state
# Set variable name # Set variable name
self.name_entry.set_text(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 # Set initial sensitivity state
is_sensitive = variable_name in self.project.sensitive_variables is_sensitive = variable_name in self.project.sensitive_variables
self._updating_toggle = True self._updating_toggle = True
@ -93,22 +107,12 @@ class VariableDataRow(Gtk.Box):
entry.set_placeholder_text(env.name) entry.set_placeholder_text(env.name)
entry.set_hexpand(True) 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 # Configure entry for sensitive variables
if is_sensitive: if is_sensitive:
entry.set_visibility(False) # Show bullets instead of text entry.set_visibility(False) # Show bullets instead of text
entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
entry.add_css_class("sensitive-variable") entry.add_css_class("sensitive-variable")
entry.connect('changed', self._on_value_changed, env.id)
entry_box.append(entry) entry_box.append(entry)
# Add entry box to size group for alignment # Add entry box to size group for alignment
@ -118,23 +122,75 @@ class VariableDataRow(Gtk.Box):
self.values_box.append(entry_box) self.values_box.append(entry_box)
self.value_entries[env.id] = entry 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): def _on_value_changed(self, entry, env_id):
"""Handle value entry changes.""" """Handle value entry changes."""
value = entry.get_text() value = entry.get_text()
# Use project_manager to store value in appropriate location
# Emit signal first (value is being changed)
self.emit('value-changed', env_id, value)
# Use project_manager to store value in appropriate location (async)
self.project_manager.set_variable_value( self.project_manager.set_variable_value(
self.project.id, self.project.id,
env_id, env_id,
self.variable_name, self.variable_name,
value value,
callback=None # Fire and forget - errors are logged
) )
self.emit('value-changed', env_id, value)
def _on_name_entry_focus_in(self, controller):
"""Show edit buttons when entry gains focus."""
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()
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_name_changed(self, entry): def on_cancel_clicked(self, button):
"""Handle variable name changes.""" """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.emit('variable-changed')
self._is_editing = False
self.edit_buttons_revealer.set_reveal_child(False)
self.original_name = new_name
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_delete_clicked(self, button): def on_delete_clicked(self, button):
"""Handle delete button click.""" """Handle delete button click."""
@ -159,7 +215,7 @@ class VariableDataRow(Gtk.Box):
self.sensitive_toggle.set_tooltip_text("Sensitive (stored in keyring)\nClick to make non-sensitive") self.sensitive_toggle.set_tooltip_text("Sensitive (stored in keyring)\nClick to make non-sensitive")
self.name_entry.add_css_class("sensitive-variable-name") self.name_entry.add_css_class("sensitive-variable-name")
else: else:
self.sensitive_toggle.set_icon_name("channel-insecure-symbolic") self.sensitive_toggle.set_icon_name("changes-allow-symbolic")
self.sensitive_toggle.set_tooltip_text("Not sensitive (stored in JSON)\nClick to mark as sensitive") 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") self.name_entry.remove_css_class("sensitive-variable-name")
@ -172,6 +228,9 @@ class VariableDataRow(Gtk.Box):
self.environments = environments self.environments = environments
self.project = project self.project = project
# Update original_name in case variable was renamed
self.original_name = self.variable_name
# Update sensitivity toggle state # Update sensitivity toggle state
is_sensitive = self.variable_name in self.project.sensitive_variables is_sensitive = self.variable_name in self.project.sensitive_variables
self._updating_toggle = True self._updating_toggle = True

View File

@ -434,6 +434,23 @@ class RosterWindow(Adw.ApplicationWindow):
if page: if page:
self.tab_view.close_page(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): def _on_new_request_clicked(self, button):
"""Handle New Request button click.""" """Handle New Request button click."""
self._create_new_tab() self._create_new_tab()
@ -458,6 +475,18 @@ class RosterWindow(Adw.ApplicationWindow):
self.add_action(action) self.add_action(action)
self.get_application().set_accels_for_action("win.save-request", ["<Control>s"]) 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): def _on_send_clicked(self, widget):
"""Handle Send button click from a tab widget.""" """Handle Send button click from a tab widget."""
# Clear previous preprocessing results # Clear previous preprocessing results
@ -506,10 +535,13 @@ class RosterWindow(Adw.ApplicationWindow):
if preprocessing_result.modified_request: if preprocessing_result.modified_request:
modified_request = 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 # Apply variable substitution to (possibly modified) request
substituted_request = modified_request substituted_request = modified_request
if widget.selected_environment_id:
env = widget.get_selected_environment()
if env: if env:
from .variable_substitution import VariableSubstitution from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env) substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env)
@ -517,9 +549,6 @@ class RosterWindow(Adw.ApplicationWindow):
if undefined: if undefined:
logger.warning(f"Undefined variables in request: {', '.join(undefined)}") logger.warning(f"Undefined variables in request: {', '.join(undefined)}")
# Disable send button during request
widget.send_button.set_sensitive(False)
# Execute async # Execute async
def callback(response, error, user_data): def callback(response, error, user_data):
"""Callback runs on main thread.""" """Callback runs on main thread."""
@ -569,12 +598,39 @@ class RosterWindow(Adw.ApplicationWindow):
if tab: if tab:
tab.response = response tab.response = response
# Create history entry (save the substituted request with actual values) # Create history entry with redacted sensitive variables
# Determine which request to save to history
request_for_history = modified_request
has_redacted = False
if env and widget.project_id:
# Get the project's sensitive variables list
projects = self.project_manager.load_projects()
sensitive_vars = []
for p in projects:
if p.id == widget.project_id:
sensitive_vars = p.sensitive_variables
break
# Create redacted request (only non-sensitive variables substituted)
if sensitive_vars:
from .variable_substitution import VariableSubstitution
request_for_history, _, has_redacted = VariableSubstitution.substitute_request_excluding_sensitive(
modified_request, env, sensitive_vars
)
else:
# No sensitive variables defined, use fully substituted request
request_for_history = substituted_request
elif env:
# Environment loaded but no project - use fully substituted request
request_for_history = substituted_request
entry = HistoryEntry( entry = HistoryEntry(
timestamp=datetime.now().isoformat(), timestamp=datetime.now().isoformat(),
request=substituted_request, request=request_for_history,
response=response, response=response,
error=error error=error,
has_redacted_variables=has_redacted
) )
self.history_manager.add_entry(entry) self.history_manager.add_entry(entry)
@ -583,6 +639,12 @@ class RosterWindow(Adw.ApplicationWindow):
self.http_client.execute_request_async(substituted_request, callback, None) self.http_client.execute_request_async(substituted_request, callback, None)
# Fetch environment asynchronously then proceed
if widget.selected_environment_id:
widget.get_selected_environment(proceed_with_request)
else:
proceed_with_request(None)
# History and Project Management # History and Project Management
def _load_history(self) -> None: def _load_history(self) -> None:
"""Load history from file and populate list.""" """Load history from file and populate list."""
@ -1024,7 +1086,7 @@ class RosterWindow(Adw.ApplicationWindow):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
# Check if current tab has a saved request (for pre-filling) # Check if current tab has a saved request or project (for pre-filling)
current_tab = None current_tab = None
preselect_project_index = 0 preselect_project_index = 0
prefill_name = "" prefill_name = ""
@ -1039,7 +1101,13 @@ class RosterWindow(Adw.ApplicationWindow):
preselect_project_index = i preselect_project_index = i
prefill_name = current_tab.name prefill_name = current_tab.name
break break
elif current_tab.name and current_tab.name != "New Request": 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":
# Copy tab or named unsaved tab - prefill with tab name # Copy tab or named unsaved tab - prefill with tab name
prefill_name = current_tab.name prefill_name = current_tab.name
@ -1203,48 +1271,18 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.present(self) dialog.present(self)
def _on_add_to_project(self, widget, project): def _on_add_to_project(self, widget, project):
"""Save current request to specific project.""" """Create a new empty request tab associated with the project."""
request = self._build_request_from_ui() # Get default environment for the project
default_env_id = project.environments[0].id if project.environments else None
if not request.url.strip(): # Create a new tab with project_id set so Save will pre-select this project
self._show_toast("Cannot save: URL is empty") self._create_new_tab(
return name="New Request",
project_id=project.id,
selected_environment_id=default_env_id
)
dialog = Adw.AlertDialog() self._show_toast(f"New request for '{project.name}'")
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): def _on_load_request(self, widget, saved_request):
"""Load saved request - smart loading based on current tab state.""" """Load saved request - smart loading based on current tab state."""