Compare commits
No commits in common. "v0.5.0" and "master" have entirely different histories.
232
README.md
232
README.md
@ -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
|

|
||||||
- Python 3
|
|
||||||
- libsoup3 (provided by GNOME Platform)
|
|
||||||
- gjs (GNOME JavaScript) - for script execution
|
|
||||||
|
|
||||||
## Building
|
### Request History
|
||||||
|

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

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
39
cz.bugsy.roster.flathub.json
Normal file
39
cz.bugsy.roster.flathub.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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', ],
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
182
po/roster.pot
Normal 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
BIN
screenshots/history.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
screenshots/main.png
Normal file
BIN
screenshots/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
screenshots/variables.png
Normal file
BIN
screenshots/variables.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -169,10 +169,12 @@ 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,10 +183,12 @@ 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):
|
||||||
@ -267,10 +271,12 @@ 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."""
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
@ -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(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=env.id,
|
|
||||||
variable_name=variable_name
|
|
||||||
)
|
|
||||||
# Store in JSON
|
|
||||||
env.variables[variable_name] = value or ""
|
|
||||||
# Delete from keyring
|
|
||||||
secret_manager.delete_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=env.id,
|
|
||||||
variable_name=variable_name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if not target_project or not environments_to_migrate:
|
||||||
|
if callback:
|
||||||
|
callback(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Retrieve all secrets, then update JSON, then delete from keyring
|
||||||
|
pending_count = [len(environments_to_migrate)]
|
||||||
|
all_success = [True]
|
||||||
|
|
||||||
|
def on_single_migrate(env):
|
||||||
|
def on_retrieve(value):
|
||||||
|
# Store in JSON
|
||||||
|
env.variables[variable_name] = value or ""
|
||||||
self.save_projects(projects)
|
self.save_projects(projects)
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
# Delete from keyring
|
||||||
|
def on_delete(success):
|
||||||
|
if not success:
|
||||||
|
all_success[0] = False
|
||||||
|
pending_count[0] -= 1
|
||||||
|
if pending_count[0] == 0 and callback:
|
||||||
|
callback(all_success[0])
|
||||||
|
|
||||||
|
secret_manager.delete_secret(
|
||||||
|
project_id=project_id,
|
||||||
|
environment_id=env.id,
|
||||||
|
variable_name=variable_name,
|
||||||
|
callback=on_delete
|
||||||
|
)
|
||||||
|
return on_retrieve
|
||||||
|
|
||||||
|
for env in environments_to_migrate:
|
||||||
|
secret_manager.retrieve_secret(
|
||||||
|
project_id=project_id,
|
||||||
|
environment_id=env.id,
|
||||||
|
variable_name=variable_name,
|
||||||
|
callback=on_single_migrate(env)
|
||||||
|
)
|
||||||
|
|
||||||
def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool:
|
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)
|
||||||
return
|
if callback:
|
||||||
|
callback(True)
|
||||||
|
return
|
||||||
|
|
||||||
def get_environment_with_secrets(self, project_id: str, env_id: str) -> Optional[Environment]:
|
if callback:
|
||||||
|
callback(False)
|
||||||
|
|
||||||
|
def get_environment_with_secrets(self, project_id: str, env_id: str,
|
||||||
|
callback: Callable[[Optional[Environment]], None]):
|
||||||
"""
|
"""
|
||||||
Get an environment with all variable values (including secrets from keyring).
|
Get an environment with all variable values (including secrets from keyring) - async.
|
||||||
|
|
||||||
This is useful for variable substitution - it returns a complete Environment
|
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,
|
if value is not None:
|
||||||
environment_id=env_id,
|
complete_env.variables[var_name] = value
|
||||||
variable_name=var_name
|
callback(complete_env)
|
||||||
)
|
|
||||||
if value is not None:
|
|
||||||
complete_env.variables[var_name] = value
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@ -1318,45 +1318,55 @@ 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:
|
||||||
|
# No environment selected - ALL variables are undefined
|
||||||
|
# Find all variables in the request
|
||||||
|
all_vars = set()
|
||||||
|
all_vars.update(VariableSubstitution.find_variables(request.url))
|
||||||
|
all_vars.update(VariableSubstitution.find_variables(request.body))
|
||||||
|
for key, value in request.headers.items():
|
||||||
|
all_vars.update(VariableSubstitution.find_variables(key))
|
||||||
|
all_vars.update(VariableSubstitution.find_variables(value))
|
||||||
|
callback(all_vars)
|
||||||
|
else:
|
||||||
|
# Environment selected - find which variables are undefined
|
||||||
|
_, undefined = VariableSubstitution.substitute_request(request, env)
|
||||||
|
callback(undefined)
|
||||||
|
|
||||||
if not env:
|
# Get selected environment asynchronously
|
||||||
# No environment selected - ALL variables are undefined
|
self.get_selected_environment(on_environment_loaded)
|
||||||
# Find all variables in the request
|
|
||||||
all_vars = set()
|
|
||||||
all_vars.update(VariableSubstitution.find_variables(request.url))
|
|
||||||
all_vars.update(VariableSubstitution.find_variables(request.body))
|
|
||||||
for key, value in request.headers.items():
|
|
||||||
all_vars.update(VariableSubstitution.find_variables(key))
|
|
||||||
all_vars.update(VariableSubstitution.find_variables(value))
|
|
||||||
return all_vars
|
|
||||||
else:
|
|
||||||
# Environment selected - find which variables are undefined
|
|
||||||
_, undefined = VariableSubstitution.substitute_request(request, env)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
def _schedule_indicator_update(self):
|
def _schedule_indicator_update(self):
|
||||||
"""Schedule an indicator update with debouncing."""
|
"""Schedule an indicator update with debouncing."""
|
||||||
@ -1374,22 +1384,26 @@ 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()
|
||||||
|
|
||||||
# Update headers
|
# Update headers
|
||||||
self._update_header_indicators()
|
self._update_header_indicators()
|
||||||
|
|
||||||
# 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."""
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
def on_store_complete(source, result, user_data):
|
||||||
# Store the secret synchronously
|
try:
|
||||||
# Using PASSWORD collection (default keyring)
|
Secret.password_store_finish(result)
|
||||||
Secret.password_store_sync(
|
logger.info(f"Stored secret for {variable_name} in {environment_id}")
|
||||||
self.schema,
|
if callback:
|
||||||
attributes,
|
callback(True)
|
||||||
Secret.COLLECTION_DEFAULT,
|
except GLib.Error as e:
|
||||||
label,
|
logger.error(f"Failed to store secret: {e.message}")
|
||||||
value,
|
if callback:
|
||||||
None # cancellable
|
callback(False)
|
||||||
)
|
|
||||||
logger.info(f"Stored secret for {variable_name} in {environment_id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except GLib.Error as e:
|
# Store the secret asynchronously
|
||||||
logger.error(f"Failed to store secret: {e.message}")
|
Secret.password_store(
|
||||||
return False
|
self.schema,
|
||||||
|
attributes,
|
||||||
|
Secret.COLLECTION_DEFAULT,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
None, # cancellable
|
||||||
|
on_store_complete,
|
||||||
|
None # user_data
|
||||||
|
)
|
||||||
|
|
||||||
def retrieve_secret(self, project_id: str, environment_id: str,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
def on_lookup_complete(source, result, user_data):
|
||||||
# Retrieve the secret synchronously
|
try:
|
||||||
value = Secret.password_lookup_sync(
|
value = Secret.password_lookup_finish(result)
|
||||||
self.schema,
|
if value is None:
|
||||||
attributes,
|
logger.debug(f"No secret found for {variable_name}")
|
||||||
None # cancellable
|
else:
|
||||||
)
|
logger.debug(f"Retrieved secret for {variable_name}")
|
||||||
|
callback(value)
|
||||||
|
except GLib.Error as e:
|
||||||
|
logger.error(f"Failed to retrieve secret: {e.message}")
|
||||||
|
callback(None)
|
||||||
|
|
||||||
if value is None:
|
# Retrieve the secret asynchronously
|
||||||
logger.debug(f"No secret found for {variable_name}")
|
Secret.password_lookup(
|
||||||
else:
|
self.schema,
|
||||||
logger.debug(f"Retrieved secret for {variable_name}")
|
attributes,
|
||||||
|
None, # cancellable
|
||||||
return value
|
on_lookup_complete,
|
||||||
|
None # user_data
|
||||||
except GLib.Error as e:
|
)
|
||||||
logger.error(f"Failed to retrieve secret: {e.message}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def delete_secret(self, project_id: str, environment_id: str,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
def on_clear_complete(source, result, user_data):
|
||||||
# Delete the secret synchronously
|
try:
|
||||||
deleted = Secret.password_clear_sync(
|
deleted = Secret.password_clear_finish(result)
|
||||||
self.schema,
|
if deleted:
|
||||||
attributes,
|
logger.info(f"Deleted secret for {variable_name}")
|
||||||
None # cancellable
|
else:
|
||||||
)
|
logger.debug(f"No secret to delete for {variable_name}")
|
||||||
|
if callback:
|
||||||
|
callback(True)
|
||||||
|
except GLib.Error as e:
|
||||||
|
logger.error(f"Failed to delete secret: {e.message}")
|
||||||
|
if callback:
|
||||||
|
callback(False)
|
||||||
|
|
||||||
if deleted:
|
# Delete the secret asynchronously
|
||||||
logger.info(f"Deleted secret for {variable_name}")
|
Secret.password_clear(
|
||||||
else:
|
self.schema,
|
||||||
logger.debug(f"No secret to delete for {variable_name}")
|
attributes,
|
||||||
|
None, # cancellable
|
||||||
|
on_clear_complete,
|
||||||
|
None # user_data
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
def delete_all_project_secrets(self, project_id: str,
|
||||||
|
callback: Optional[Callable[[bool], None]] = None):
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to delete secret: {e.message}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def delete_all_project_secrets(self, project_id: str) -> bool:
|
|
||||||
"""
|
"""
|
||||||
Delete all secrets for a project (when project is deleted).
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
def on_clear_complete(source, result, user_data):
|
||||||
# Delete all matching secrets
|
try:
|
||||||
deleted = Secret.password_clear_sync(
|
Secret.password_clear_finish(result)
|
||||||
self.schema,
|
logger.info(f"Deleted all secrets for project {project_id}")
|
||||||
attributes,
|
if callback:
|
||||||
None
|
callback(True)
|
||||||
)
|
except GLib.Error as e:
|
||||||
|
logger.error(f"Failed to delete project secrets: {e.message}")
|
||||||
|
if callback:
|
||||||
|
callback(False)
|
||||||
|
|
||||||
logger.info(f"Deleted all secrets for project {project_id}")
|
# Delete all matching secrets asynchronously
|
||||||
return True
|
Secret.password_clear(
|
||||||
|
self.schema,
|
||||||
|
attributes,
|
||||||
|
None,
|
||||||
|
on_clear_complete,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
except GLib.Error as e:
|
def delete_all_environment_secrets(self, project_id: str, environment_id: str,
|
||||||
logger.error(f"Failed to delete project secrets: {e.message}")
|
callback: Optional[Callable[[bool], None]] = None):
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
def on_clear_complete(source, result, user_data):
|
||||||
deleted = Secret.password_clear_sync(
|
try:
|
||||||
self.schema,
|
Secret.password_clear_finish(result)
|
||||||
attributes,
|
logger.info(f"Deleted all secrets for environment {environment_id}")
|
||||||
None
|
if callback:
|
||||||
)
|
callback(True)
|
||||||
|
except GLib.Error as e:
|
||||||
|
logger.error(f"Failed to delete environment secrets: {e.message}")
|
||||||
|
if callback:
|
||||||
|
callback(False)
|
||||||
|
|
||||||
logger.info(f"Deleted all secrets for environment {environment_id}")
|
Secret.password_clear(
|
||||||
return True
|
self.schema,
|
||||||
|
attributes,
|
||||||
|
None,
|
||||||
|
on_clear_complete,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
except GLib.Error as e:
|
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
|
||||||
logger.error(f"Failed to delete environment secrets: {e.message}")
|
callback: Optional[Callable[[bool], None]] = None):
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
def on_search_complete(source, result, user_data):
|
||||||
# Search for matching secrets
|
try:
|
||||||
# This returns a list of Secret.Item objects
|
secrets = Secret.password_search_finish(result)
|
||||||
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
|
||||||
|
|
||||||
# For each secret, retrieve value, store with new name, delete old
|
# Track how many secrets we need to process
|
||||||
for item in secrets:
|
pending_count = [len(secrets)]
|
||||||
# Get the secret value
|
all_success = [True]
|
||||||
item.load_secret_sync(None)
|
|
||||||
value = item.get_secret().get_text()
|
|
||||||
|
|
||||||
# Get environment_id from attributes
|
def on_rename_done(success):
|
||||||
|
pending_count[0] -= 1
|
||||||
|
if not success:
|
||||||
|
all_success[0] = False
|
||||||
|
if pending_count[0] == 0:
|
||||||
|
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
|
||||||
|
if callback:
|
||||||
|
callback(all_success[0])
|
||||||
|
|
||||||
|
# For each secret, retrieve value, store with new name, delete old
|
||||||
|
for item in secrets:
|
||||||
|
self._rename_single_secret(item, project_id, old_name, new_name, on_rename_done)
|
||||||
|
|
||||||
|
except GLib.Error as e:
|
||||||
|
logger.error(f"Failed to search for variable secrets: {e.message}")
|
||||||
|
if callback:
|
||||||
|
callback(False)
|
||||||
|
|
||||||
|
# Search for matching secrets asynchronously
|
||||||
|
Secret.password_search(
|
||||||
|
self.schema,
|
||||||
|
attributes,
|
||||||
|
Secret.SearchFlags.ALL,
|
||||||
|
None,
|
||||||
|
on_search_complete,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rename_single_secret(self, item, project_id: str, old_name: str,
|
||||||
|
new_name: str, callback: Callable[[bool], None]):
|
||||||
|
"""Helper to rename a single secret item."""
|
||||||
|
|
||||||
|
def on_load_complete(item, result, user_data):
|
||||||
|
try:
|
||||||
|
item.load_secret_finish(result)
|
||||||
|
secret = item.get_secret()
|
||||||
|
if secret is None:
|
||||||
|
callback(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
value = secret.get_text()
|
||||||
attrs = item.get_attributes()
|
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
|
except GLib.Error as e:
|
||||||
self.delete_secret(
|
logger.error(f"Failed to load secret for rename: {e.message}")
|
||||||
project_id=project_id,
|
callback(False)
|
||||||
environment_id=environment_id,
|
|
||||||
variable_name=old_name
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
|
item.load_secret(None, on_load_complete, None)
|
||||||
return True
|
|
||||||
|
|
||||||
except GLib.Error as e:
|
# ========== Batch Operations ==========
|
||||||
logger.error(f"Failed to rename variable secrets: {e.message}")
|
|
||||||
return False
|
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
|
||||||
|
|||||||
@ -26,6 +26,18 @@
|
|||||||
<property name="accelerator"><Control>s</property>
|
<property name="accelerator"><Control>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"><Control>l</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes" context="shortcut window">Send Request</property>
|
||||||
|
<property name="accelerator"><Control>Return</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
<child>
|
<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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -19,31 +19,64 @@
|
|||||||
<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>
|
</object>
|
||||||
</child>
|
</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>
|
<child>
|
||||||
<object class="GtkToggleButton" id="sensitive_toggle">
|
<object class="GtkBox">
|
||||||
<property name="icon-name">channel-insecure-symbolic</property>
|
<property name="spacing">3</property>
|
||||||
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
|
<child>
|
||||||
<signal name="toggled" handler="on_sensitive_toggled"/>
|
<object class="GtkButton" id="cancel_button">
|
||||||
<style>
|
<property name="icon-name">process-stop-symbolic</property>
|
||||||
<class name="flat"/>
|
<property name="tooltip-text">Cancel editing</property>
|
||||||
</style>
|
<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>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkButton" id="delete_button">
|
<object class="GtkToggleButton" id="sensitive_toggle">
|
||||||
<property name="icon-name">edit-delete-symbolic</property>
|
<property name="icon-name">changes-allow-symbolic</property>
|
||||||
<property name="tooltip-text">Delete variable</property>
|
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
|
||||||
<signal name="clicked" handler="on_delete_clicked"/>
|
<signal name="toggled" handler="on_sensitive_toggled"/>
|
||||||
<style>
|
<style>
|
||||||
<class name="flat"/>
|
<class name="flat"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="delete_button">
|
||||||
|
<property name="icon-name">edit-delete-symbolic</property>
|
||||||
|
<property name="tooltip-text">Delete variable</property>
|
||||||
|
<signal name="clicked" handler="on_delete_clicked"/>
|
||||||
|
<style>
|
||||||
|
<class name="flat"/>
|
||||||
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
|
|||||||
@ -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,22 +122,74 @@ 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.emit('variable-changed')
|
self.name_entry.set_text(self.original_name)
|
||||||
|
self._is_editing = False
|
||||||
|
self.edit_buttons_revealer.set_reveal_child(False)
|
||||||
|
|
||||||
|
@Gtk.Template.Callback()
|
||||||
|
def on_save_clicked(self, button):
|
||||||
|
"""Save variable name changes."""
|
||||||
|
self._save_changes()
|
||||||
|
|
||||||
|
def _save_changes(self):
|
||||||
|
"""Save the variable name change."""
|
||||||
|
new_name = self.name_entry.get_text().strip()
|
||||||
|
|
||||||
|
# Only emit if name actually changed
|
||||||
|
if new_name != self.original_name:
|
||||||
|
self.emit('variable-changed')
|
||||||
|
|
||||||
|
self._is_editing = False
|
||||||
|
self.edit_buttons_revealer.set_reveal_child(False)
|
||||||
|
self.original_name = new_name
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
@Gtk.Template.Callback()
|
||||||
def on_delete_clicked(self, button):
|
def on_delete_clicked(self, button):
|
||||||
@ -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
|
||||||
|
|||||||
234
src/window.py
234
src/window.py
@ -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
|
||||||
|
|
||||||
# Apply variable substitution to (possibly modified) request
|
# Disable send button during request
|
||||||
substituted_request = modified_request
|
widget.send_button.set_sensitive(False)
|
||||||
if widget.selected_environment_id:
|
|
||||||
env = widget.get_selected_environment()
|
def proceed_with_request(env):
|
||||||
|
"""Continue with request after environment is loaded."""
|
||||||
|
# Apply variable substitution to (possibly modified) request
|
||||||
|
substituted_request = modified_request
|
||||||
if env:
|
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,71 +549,101 @@ 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
|
# Execute async
|
||||||
widget.send_button.set_sensitive(False)
|
def callback(response, error, user_data):
|
||||||
|
"""Callback runs on main thread."""
|
||||||
|
# Re-enable send button
|
||||||
|
widget.send_button.set_sensitive(True)
|
||||||
|
|
||||||
# Execute async
|
# Update tab with response
|
||||||
def callback(response, error, user_data):
|
if response:
|
||||||
"""Callback runs on main thread."""
|
widget.display_response(response)
|
||||||
# Re-enable send button
|
|
||||||
widget.send_button.set_sensitive(True)
|
|
||||||
|
|
||||||
# Update tab with response
|
# Execute postprocessing script if exists
|
||||||
if response:
|
scripts = widget.get_scripts()
|
||||||
widget.display_response(response)
|
if scripts and scripts.postprocessing.strip():
|
||||||
|
from .script_executor import ScriptExecutor, ScriptContext
|
||||||
|
|
||||||
# Execute postprocessing script if exists
|
# Create context for variable updates
|
||||||
scripts = widget.get_scripts()
|
context = None
|
||||||
if scripts and scripts.postprocessing.strip():
|
if widget.project_id and widget.selected_environment_id:
|
||||||
from .script_executor import ScriptExecutor, ScriptContext
|
context = ScriptContext(
|
||||||
|
project_id=widget.project_id,
|
||||||
|
environment_id=widget.selected_environment_id,
|
||||||
|
project_manager=self.project_manager
|
||||||
|
)
|
||||||
|
|
||||||
# Create context for variable updates
|
# Execute script with context
|
||||||
context = None
|
script_result = ScriptExecutor.execute_postprocessing_script(
|
||||||
if widget.project_id and widget.selected_environment_id:
|
scripts.postprocessing,
|
||||||
context = ScriptContext(
|
response,
|
||||||
project_id=widget.project_id,
|
context
|
||||||
environment_id=widget.selected_environment_id,
|
|
||||||
project_manager=self.project_manager
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute script with context
|
# Display script results
|
||||||
script_result = ScriptExecutor.execute_postprocessing_script(
|
widget.display_script_results(script_result)
|
||||||
scripts.postprocessing,
|
|
||||||
response,
|
|
||||||
context
|
|
||||||
)
|
|
||||||
|
|
||||||
# Display script results
|
# Process variable updates
|
||||||
widget.display_script_results(script_result)
|
self._process_variable_updates(script_result, widget, context)
|
||||||
|
else:
|
||||||
# Process variable updates
|
widget._clear_script_results()
|
||||||
self._process_variable_updates(script_result, widget, context)
|
|
||||||
else:
|
else:
|
||||||
|
widget.display_error(error or "Unknown error")
|
||||||
widget._clear_script_results()
|
widget._clear_script_results()
|
||||||
else:
|
|
||||||
widget.display_error(error or "Unknown error")
|
|
||||||
widget._clear_script_results()
|
|
||||||
|
|
||||||
# Save response to tab
|
# Save response to tab
|
||||||
page = self.tab_view.get_selected_page()
|
page = self.tab_view.get_selected_page()
|
||||||
if page:
|
if page:
|
||||||
tab = self.page_to_tab.get(page)
|
tab = self.page_to_tab.get(page)
|
||||||
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
|
||||||
entry = HistoryEntry(
|
# Determine which request to save to history
|
||||||
timestamp=datetime.now().isoformat(),
|
request_for_history = modified_request
|
||||||
request=substituted_request,
|
has_redacted = False
|
||||||
response=response,
|
|
||||||
error=error
|
|
||||||
)
|
|
||||||
self.history_manager.add_entry(entry)
|
|
||||||
|
|
||||||
# Refresh history panel to show new entry
|
if env and widget.project_id:
|
||||||
self._load_history()
|
# 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
|
||||||
|
|
||||||
self.http_client.execute_request_async(substituted_request, callback, None)
|
# 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(
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
request=request_for_history,
|
||||||
|
response=response,
|
||||||
|
error=error,
|
||||||
|
has_redacted_variables=has_redacted
|
||||||
|
)
|
||||||
|
self.history_manager.add_entry(entry)
|
||||||
|
|
||||||
|
# Refresh history panel to show new entry
|
||||||
|
self._load_history()
|
||||||
|
|
||||||
|
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:
|
||||||
@ -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."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user