This commit is contained in:
2026-01-17 13:10:10 +01:00
parent 17fad55e9f
commit 3a6d443b3f
25 changed files with 2698 additions and 1 deletions

33
AGENTS.md Normal file
View File

@@ -0,0 +1,33 @@
# Repository Guidelines
discussion et commentaire de code en francais
## Project Structure & Module Organization
- Current repository is a minimal scaffold. Top-level files: `README.md`, `CLAUDE.md`, and this guide.
- Product requirements and expected extension layout are documented in `CLAUDE.md` (GNOME Shell extension with `src/`, `schemas/`, `stylesheet.css`, etc.). Add those directories when implementation begins.
- Keep user-facing docs in the root (e.g., `README.md`, `CHANGELOG.md`, `TODO.md`).
## Build, Test, and Development Commands
- No build, test, or run commands are defined yet. When adding them, document in `README.md` and mirror here.
- Expected future examples (from `CLAUDE.md`) include schema compilation and install scripts, e.g.:
- `glib-compile-schemas schemas/` (compile GSettings schemas)
- `./install.sh` (install the extension into the GNOME extensions directory)
## Coding Style & Naming Conventions
- No formatter or linter is configured. Match existing file style and keep changes consistent within each file.
- For GNOME Shell extension code (GJS), follow GNOME JavaScript conventions and keep names descriptive (e.g., `popupSecrets.js`, `clipboardService.js`).
- Use clear file names and keep modules focused by responsibility.
## Testing Guidelines
- No automated tests exist yet. If you add tests, document the framework and naming convention (e.g., `*.test.js`).
- Maintain a manual checklist in `README.md` or `TODO.md` for core behaviors until automated tests exist.
## Commit & Pull Request Guidelines
- Git history is minimal and does not define a commit message convention. Use short, imperative summaries (e.g., "Add secrets storage service").
- For PRs (if applicable), include: a brief description, any linked issues, and screenshots/GIFs for UI changes.
## Security & Configuration Tips
- Follow the security notes in `CLAUDE.md`, especially around secret handling and file permissions.
- Avoid logging sensitive data and keep any local data stores under user-only permissions.
## Agent-Specific Instructions
- `CLAUDE.md` contains the authoritative product and architecture requirements for this repository. Review it before implementing features.

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
## 0.2.0 - Backup feature
- add backup functionality with timestamped exports
- add `backup_path` setting in popup3 (GSettings)
- add backup icon (document-save-symbolic) in headers
- export settings.json, clipboard.json, secrets.json (obfuscated), manifest.json
- set proper permissions (0700 dirs, 0600 files)
## 0.1.0 - Initial scaffold
- add metadata, schema, and placeholder extension entrypoint
- outline architecture from `CLAUDE.md` with UI/service/storage stubs
- introduce obfuscated secrets storage helpers and clipboard persistence helpers

96
CLAUDE.md Normal file
View File

@@ -0,0 +1,96 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Projet
ClipCoffre est une extension GNOME Shell (GNOME 47+, Debian 13) combinant :
- Un gestionnaire de secrets (user/password) avec obfuscation XOR+base64
- Un gestionnaire de clipboard (favoris + historique)
UUID : `clipcoffre@gilles`
Dépôt : https://gitea.maison43.duckdns.org/gilles/ClipCoffre.git
## Commandes de développement
```bash
# Installation de l'extension dans GNOME
./install.sh
# Compilation manuelle du schéma GSettings
glib-compile-schemas schemas/
# Redémarrage de GNOME Shell (X11 seulement)
# Alt+F2 puis taper "r"
# Logs de l'extension
journalctl -f -o cat /usr/bin/gnome-shell
# Activer/désactiver l'extension
gnome-extensions enable clipcoffre@gilles
gnome-extensions disable clipcoffre@gilles
```
## Architecture
```
extension.js # Point d'entrée (init/enable/disable)
├── src/ui/
│ ├── panelButton.js # Icône topbar + dispatch click_mode (unifie/separe)
│ ├── popupMain.js # Mode unifié avec onglets
│ ├── popupSecrets.js # Popup secrets (popup1)
│ ├── popupClipboard.js # Popup clipboard (popup2)
│ └── popupSettings.js # Popup réglages (popup3)
├── src/services/
│ ├── secretService.js # CRUD secrets + encode/decode passwords
│ └── clipboardService.js # Gestion favoris/historique
├── src/storage/
│ ├── settings.js # Wrapper GSettings
│ ├── jsonStore.js # Helpers lecture/écriture JSON + permissions
│ ├── secretsStore.js # Obfuscation XOR + gestion key.json/secrets.json
│ └── clipboardStore.js # Persistance favoris/historique
└── schemas/
└── org.gnome.shell.extensions.secretclipboard.gschema.xml
```
## GSettings
Schema ID : `org.gnome.shell.extensions.secretclipboard`
| Clé | Type | Description |
|-----|------|-------------|
| `history_size` | int | Limite historique clipboard (défaut: 50) |
| `show_passwords` | bool | Afficher mots de passe en clair (défaut: false) |
| `click_mode` | string | `unifie` ou `separe` |
## Stockage des données
Chemin : `~/.local/share/gnome-shell/extensions/clipcoffre@gilles/data/`
- `secrets.json` - Secrets obfusqués (perms 0600)
- `key.json` - Clé d'obfuscation générée au 1er lancement (perms 0600)
- `clipboard.json` - Favoris et historique
Sécurité MVP : obfuscation XOR+base64 avec clé locale (pas un vrai chiffrement). TODO: migrer vers libsecret/GNOME Keyring.
## Conventions GJS/GNOME Shell
- Imports : `const { Gio, St, GLib } = imports.gi;`
- Modules internes : `Me.imports.src.module.SubModule`
- Export : `var ModuleName = { Class };`
- Ne jamais loguer de mots de passe ou secrets
## Tests
Pas de tests automatisés. Checklist manuelle dans `MANUAL_TESTS.md`.
## Spécifications fonctionnelles
### Modes de clic (click_mode)
- **unifie** : clic gauche → popover avec onglets Secrets/Clipboard
- **separe** : clic gauche → Secrets, clic droit → Clipboard
### Fonctionnalité backup (à implémenter)
- Icône "document-save-symbolic" dans l'en-tête
- Export dans `<backup_path>/YYYY-MM-DD_HH-mm-ss/`
- Fichiers : settings.json, clipboard.json, secrets.json (obfusqué), manifest.json
- Setting `backup_path` configurable dans popup3

34
MANUAL_TESTS.md Normal file
View File

@@ -0,0 +1,34 @@
# Manual Test Checklist
## Installation
1. Run `./install.sh` and restart GNOME Shell (Alt+F2 puis `r`)
2. Enable the extension via GNOME Tweaks ou `gnome-extensions enable clipcoffre@gilles`
## Mode de clic
3. Toggle `click_mode` dans popup3 et verifier le comportement `unifie` vs `separe`
4. En mode separe : clic gauche = Secrets, clic droit = Clipboard
## Clipboard
5. Ajouter des entrees clipboard et verifier que l'historique respecte `history_size`
6. Marquer un item comme favori et confirmer qu'il reste en haut
## Secrets
7. Ajouter un secret (label, user, password)
8. Toggle `show_passwords` et verifier le masquage/affichage
9. Copier user et password, verifier la notification
10. Supprimer un secret
## Backup
11. Sans backup_path configure : cliquer sur l'icone backup, verifier le message d'erreur
12. Configurer un backup_path valide dans popup3 (ex: `/tmp/clipcoffre-backup`)
13. Cliquer sur l'icone backup (document-save), verifier "Backup OK"
14. Inspecter le dossier cree : `<backup_path>/YYYY-MM-DD_HH-mm-ss/`
- settings.json
- clipboard.json
- secrets.json (obfusque, pas en clair)
- manifest.json
15. Verifier les permissions : dossiers 0700, fichiers 0600
## Persistance
16. Redemarrer GNOME Shell et verifier que les secrets et l'historique sont conserves
17. Inspecter `~/.local/share/gnome-shell/extensions/clipcoffre@gilles/data/` pour key.json, secrets.json, clipboard.json

View File

@@ -1,2 +1,31 @@
# ClipCoffre
# ClipCoffre Secret Clipboard
A GNOME Shell extension that unifies a lightweight secrets manager with clipboard favorites/history controls. It is designed as a MVP shell extension for Debian 13 (GNOME 47+) and follows the architecture outlined in `CLAUDE.md`.
## Installation
1. Run `./install.sh` to copy files into `~/.local/share/gnome-shell/extensions/secret-clipboard@gilles` and compile the schema.
2. Enable the extension using GNOME Tweaks or `gnome-extensions enable secret-clipboard@gilles`.
3. Restart GNOME Shell (`Alt+F2`, enter `r`).
## Development Commands
- `glib-compile-schemas schemas/` compiles the GSettings schema so GNOME Shell can read `history_size`, `show_passwords`, and `click_mode`.
- `./install.sh` synchronizes the repo with the extension directory and reruns schema compilation.
## Project Layout
- `extension.js` entry point that wires together UI, services, and storage.
- `src/ui/` popups, panel button, and placeholder UI components.
- `src/services/` clipboard and secret services that talk to storage layers.
- `src/storage/` helpers for settings, JSON persistence, clipboard, and obfuscated secrets (
`key.json`, `secrets.json`, and `clipboard.json`).
- `schemas/` GSettings schema for runtime configuration.
- `stylesheet.css` theme adjustments for the topbar icon.
- `install.sh` deployment helper with schema compilation.
## Testing Notes
- No automated tests yet. Use the manual checklist in `MANUAL_TESTS.md` to verify behavior.
- For new tests, document the framework and naming pattern (e.g. `*.test.js`).
## Next Steps
- Replace placeholder UI components (see `src/ui/`) with real paginated popovers and action buttons.
- Implement secure storage as outlined in `CLAUDE.md` (MVP obfuscation with `secretsStore.js`).
- Wire GNOME shell clipboard operations and integrate override clicks based on `click_mode`.

18
TODO.md Normal file
View File

@@ -0,0 +1,18 @@
# TODO
## Prioritaire
- Remplacer l'obfuscation XOR par libsecret (GNOME Keyring) pour une vraie securite
- Ajouter la surveillance du clipboard systeme pour capturer automatiquement les copies
## Ameliorations UI
- Ajouter confirmation avant suppression d'un secret
- Ajouter fonction de recherche/filtre dans les listes
- Implementer l'edition inline des secrets existants
## Tests
- Creer une suite de tests automatises
- Documenter les cas de test dans MANUAL_TESTS.md
## Documentation
- Ajouter des captures d'ecran dans le README
- Documenter la fonction de restauration depuis un backup

View File

@@ -0,0 +1,144 @@
# Implementation de la fonctionnalite Backup
**Date** : 2026-01-17 12:18:36
**Version** : 0.2.0
**Auteur** : Claude Code
---
## Resume
Ajout d'une fonctionnalite de sauvegarde (backup) permettant d'exporter les donnees de l'extension ClipCoffre vers un dossier configurable. L'export inclut les parametres, l'historique clipboard, et les secrets (toujours obfusques).
---
## Fichiers crees
### `src/services/backupService.js`
Service responsable de l'export des donnees. Principales methodes :
- `isConfigured()` : Verifie si le chemin de backup est configure et valide
- `backup()` : Execute l'export complet des donnees
- `_formatTimestamp()` : Genere un timestamp au format `YYYY-MM-DD_HH-mm-ss`
- `_ensureBackupDir()` : Cree le dossier de backup avec les bonnes permissions
---
## Fichiers modifies
### Schema GSettings
**Fichier** : `schemas/org.gnome.shell.extensions.secretclipboard.gschema.xml`
Ajout de la cle `backup_path` :
```xml
<key name="backup_path" type="s">
<default>''</default>
<summary>Backup directory path</summary>
</key>
```
### Settings Wrapper
**Fichier** : `src/storage/settings.js`
Ajout du getter/setter pour `backupPath` :
```javascript
get backupPath() {
return this._settings.get_string('backup_path');
}
set backupPath(value) {
this._settings.set_string('backup_path', value);
}
```
### Popup Settings (popup3)
**Fichier** : `src/ui/popupSettings.js`
- Ajout du champ de saisie pour configurer le chemin de backup
- Gestion de la valeur temporaire `_tempBackupPath` pour annulation
- Sauvegarde dans GSettings via le bouton OK
### Headers UI (Secrets et Clipboard)
**Fichiers** :
- `src/ui/popupSecrets.js`
- `src/ui/popupClipboard.js`
Ajout de l'icone backup (`document-save-symbolic`) dans les headers, a cote de l'icone settings. Le callback `onBackupClick` declenche l'export.
### Panel Button et Popup Main
**Fichiers** :
- `src/ui/panelButton.js`
- `src/ui/popupMain.js`
- Import du `BackupService`
- Ajout de la methode `_doBackup()` qui :
1. Verifie si le backup est configure
2. Execute l'export
3. Affiche une notification avec le resultat
### Styles CSS
**Fichier** : `stylesheet.css`
```css
.clipcoffre-backup-btn {
color: rgba(255, 255, 255, 0.9);
}
.clipcoffre-backup-btn:hover {
background-color: rgba(60, 180, 100, 0.3);
}
.clipcoffre-backup-path-input {
min-width: 280px;
}
```
---
## Format d'export
Lors d'un backup, un dossier est cree avec le format :
```
<backup_path>/YYYY-MM-DD_HH-mm-ss/
```
Contenu du dossier :
| Fichier | Description |
|---------|-------------|
| `settings.json` | Parametres GSettings (history_size, show_passwords, click_mode, backup_path) |
| `clipboard.json` | Favoris et historique du clipboard |
| `secrets.json` | Secrets obfusques (jamais en clair) |
| `manifest.json` | Metadata (uuid, version, timestamp) |
### Permissions
- Dossiers : `0700`
- Fichiers : `0600`
---
## Utilisation
1. Ouvrir les parametres (icone engrenage)
2. Configurer le "Chemin de backup" (ex: `/home/user/backup/clipcoffre`)
3. Cliquer sur OK pour sauvegarder
4. Cliquer sur l'icone de sauvegarde (disquette) dans le header
5. Une notification confirme "Backup OK" ou affiche l'erreur
### Messages d'erreur possibles
- "Chemin de backup non configure" : Le champ est vide
- "Chemin de backup invalide" : Le dossier n'existe pas
- "Configurer le chemin de backup dans les parametres" : Bouton backup clique sans configuration
---
## Tests manuels
Voir `MANUAL_TESTS.md` section "Backup" (points 11-15) pour la procedure de test complete.
---
## Securite
Les mots de passe exportes dans `secrets.json` restent obfusques (XOR + base64). Ils ne sont **jamais** exportes en clair. La cle d'obfuscation (`key.json`) n'est pas incluse dans le backup.

95
extension.js Normal file
View File

@@ -0,0 +1,95 @@
import GLib from 'gi://GLib';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import { SettingsWrapper } from './src/storage/settings.js';
import { SecretsStore } from './src/storage/secretsStore.js';
import { ClipboardStore } from './src/storage/clipboardStore.js';
import { SecretService } from './src/services/secretService.js';
import { ClipboardService } from './src/services/clipboardService.js';
import { PanelButton } from './src/ui/panelButton.js';
function _log(msg) {
console.log(`[ClipCoffre] ${msg}`);
}
export default class ClipCoffreExtension extends Extension {
constructor(metadata) {
super(metadata);
_log('constructor called');
this._panelButton = null;
this._settings = null;
this._clipboardService = null;
this._secretService = null;
}
enable() {
_log('enable() called');
try {
if (this._panelButton) {
_log('panelButton already exists, returning');
return;
}
_log('Creating SettingsWrapper...');
this._settings = new SettingsWrapper(this.getSettings());
_log('SettingsWrapper created');
_log('Creating SecretsStore...');
const secretsStore = new SecretsStore(this);
_log('SecretsStore created');
_log('Creating ClipboardStore...');
const clipboardStore = new ClipboardStore(this, this._settings);
_log('ClipboardStore created');
_log('Creating SecretService...');
this._secretService = new SecretService(secretsStore, this._settings);
_log('SecretService created');
_log('Creating ClipboardService...');
this._clipboardService = new ClipboardService(clipboardStore);
_log('ClipboardService created');
_log('Creating PanelButton...');
this._panelButton = new PanelButton(this._settings, {
secretService: this._secretService,
clipboardService: this._clipboardService,
});
_log('PanelButton created');
_log('Adding to status area...');
Main.panel.addToStatusArea('clipcoffre-panel', this._panelButton);
_log('Extension enabled successfully');
} catch (e) {
_log(`ERROR in enable(): ${e.message}`);
_log(`Stack: ${e.stack}`);
}
}
disable() {
_log('disable() called');
try {
if (this._panelButton) {
this._panelButton.destroy();
this._panelButton = null;
}
if (this._clipboardService) {
this._clipboardService.destroy();
this._clipboardService = null;
}
if (this._settings) {
this._settings.destroy();
this._settings = null;
}
_log('Extension disabled successfully');
} catch (e) {
_log(`ERROR in disable(): ${e.message}`);
_log(`Stack: ${e.stack}`);
}
}
}

23
install.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -euo pipefail
EXT_DIR="$HOME/.local/share/gnome-shell/extensions/clipcoffre@gilles"
# Nettoyer et recreer le dossier
rm -rf "$EXT_DIR"
mkdir -p "$EXT_DIR"
# Copier les fichiers (exclure .git, node_modules, backup, docs)
for item in extension.js metadata.json stylesheet.css src schemas; do
if [ -e "$item" ]; then
cp -r "$item" "$EXT_DIR/"
fi
done
# Compiler le schema GSettings
if [ -d "$EXT_DIR/schemas" ]; then
glib-compile-schemas "$EXT_DIR/schemas"
fi
echo "Extension installée dans $EXT_DIR"
echo "Redémarrez GNOME Shell (Alt+F2, r) puis activez l'extension."

9
metadata.json Normal file
View File

@@ -0,0 +1,9 @@
{
"uuid": "clipcoffre@gilles",
"name": "ClipCoffre",
"description": "Extension GNOME Shell qui combine un gestionnaire de secrets et un historique clipboard avec modes configurables.",
"shell-version": ["45", "46", "47", "48"],
"version": 2,
"url": "https://gitea.maison43.duckdns.org/gilles/ClipCoffre",
"settings-schema": "org.gnome.shell.extensions.secretclipboard"
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="org.gnome.shell.extensions.secretclipboard" path="/org/gnome/shell/extensions/secretclipboard/">
<key name="history-size" type="i">
<default>50</default>
<summary>Maximum number of clipboard items to keep</summary>
<description>Limits how many clipboard entries are kept in history before older entries are dropped.</description>
</key>
<key name="show-passwords" type="b">
<default>false</default>
<summary>Show passwords in clear text</summary>
<description>When enabled, secrets are shown without masking within the Secrets popup.</description>
</key>
<key name="click-mode" type="s">
<default>'unifie'</default>
<summary>Icon click behavior</summary>
<description>Controls whether the top bar icon shows a unified popover or separates clicks for secrets and clipboard.</description>
</key>
<key name="backup-path" type="s">
<default>''</default>
<summary>Backup directory path</summary>
<description>Absolute path where backups will be saved. Leave empty to disable backup.</description>
</key>
<key name="sync-path" type="s">
<default>''</default>
<summary>Sync directory path</summary>
<description>Path to git repository folder for syncing secrets across machines.</description>
</key>
<key name="git-url" type="s">
<default>'https://gitea.maison43.duckdns.org/gilles/ClipCoffre.git'</default>
<summary>Git repository URL</summary>
<description>URL of the git repository for syncing secrets.</description>
</key>
<key name="git-user" type="s">
<default>'gilles'</default>
<summary>Git username</summary>
<description>Username for git authentication.</description>
</key>
</schema>
</schemalist>

View File

@@ -0,0 +1,98 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import * as JsonStore from '../storage/jsonStore.js';
export class BackupService {
constructor(extension, settings, secretsStore, clipboardStore) {
this._extension = extension;
this._settings = settings;
this._secretsStore = secretsStore;
this._clipboardStore = clipboardStore;
}
isConfigured() {
const path = this._settings.backupPath;
if (!path || path.trim() === '') {
return false;
}
const file = Gio.File.new_for_path(path);
return file.query_exists(null);
}
getBackupPath() {
return this._settings.backupPath;
}
_formatTimestamp() {
const now = GLib.DateTime.new_now_local();
return now.format('%Y-%m-%d_%H-%M-%S');
}
_ensureBackupDir(backupDir) {
const file = Gio.File.new_for_path(backupDir);
if (!file.query_exists(null)) {
file.make_directory_with_parents(null);
}
GLib.chmod(backupDir, 0o700);
return true;
}
backup() {
const basePath = this._settings.backupPath;
if (!basePath || basePath.trim() === '') {
return { success: false, message: 'Chemin de backup non configuré' };
}
const baseFile = Gio.File.new_for_path(basePath);
if (!baseFile.query_exists(null)) {
return { success: false, message: 'Chemin de backup invalide' };
}
try {
const timestamp = this._formatTimestamp();
const backupDir = GLib.build_filenamev([basePath, timestamp]);
this._ensureBackupDir(backupDir);
// Export settings
const settingsData = {
history_size: this._settings.historySize,
show_passwords: this._settings.showPasswords,
click_mode: this._settings.clickMode,
backup_path: this._settings.backupPath,
};
const settingsPath = GLib.build_filenamev([backupDir, 'settings.json']);
JsonStore.writeJson(settingsPath, settingsData);
JsonStore.setPermissions(settingsPath, 0o600);
// Export clipboard (favorites + history)
const clipboardData = this._clipboardStore.load();
const clipboardPath = GLib.build_filenamev([backupDir, 'clipboard.json']);
JsonStore.writeJson(clipboardPath, clipboardData);
JsonStore.setPermissions(clipboardPath, 0o600);
// Export secrets (already obfuscated, never in clear)
const secretsData = this._secretsStore.loadSecrets();
const secretsPath = GLib.build_filenamev([backupDir, 'secrets.json']);
JsonStore.writeJson(secretsPath, secretsData);
JsonStore.setPermissions(secretsPath, 0o600);
// Create manifest
const manifestData = {
uuid: this._extension.metadata.uuid,
version: this._extension.metadata.version,
timestamp: timestamp,
created_at: GLib.DateTime.new_now_utc().format('%Y-%m-%dT%H:%M:%SZ'),
};
const manifestPath = GLib.build_filenamev([backupDir, 'manifest.json']);
JsonStore.writeJson(manifestPath, manifestData);
JsonStore.setPermissions(manifestPath, 0o600);
return { success: true, message: 'Backup OK', path: backupDir };
} catch (error) {
console.log(`[ClipCoffre] Backup failed: ${error}`);
return { success: false, message: `Backup échoué: ${error.message}` };
}
}
}

View File

@@ -0,0 +1,70 @@
import GLib from 'gi://GLib';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
const POLL_INTERVAL_MS = 1000; // Vérifie toutes les secondes
export class ClipboardService {
constructor(store) {
this._store = store;
this._clipboard = St.Clipboard.get_default();
this._lastContent = null;
this._pollId = null;
this._startPolling();
}
_startPolling() {
// Récupérer le contenu initial
this._clipboard.get_text(St.ClipboardType.CLIPBOARD, (clipboard, text) => {
this._lastContent = text;
});
// Polling régulier
this._pollId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POLL_INTERVAL_MS, () => {
this._checkClipboard();
return GLib.SOURCE_CONTINUE;
});
}
_checkClipboard() {
this._clipboard.get_text(St.ClipboardType.CLIPBOARD, (clipboard, text) => {
if (text && text !== this._lastContent) {
this._lastContent = text;
this._store.addItem(text);
}
});
}
state() {
return this._store.load();
}
add(entry) {
if (!entry) return this.state();
const sanitized = entry.trim();
if (!sanitized) return this.state();
this._lastContent = sanitized; // Éviter de l'ajouter 2 fois
const payload = this._store.addItem(sanitized);
return payload;
}
toggleFavorite(id) {
const payload = this._store.toggleFavorite(id);
return payload;
}
copy(content) {
if (!content) return;
this._lastContent = content; // Éviter de l'ajouter 2 fois via polling
this._clipboard.set_text(St.ClipboardType.CLIPBOARD, content);
Main.notify('ClipCoffre', 'Copié dans le presse-papiers');
}
destroy() {
if (this._pollId) {
GLib.source_remove(this._pollId);
this._pollId = null;
}
}
}

View File

@@ -0,0 +1,72 @@
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
export class SecretService {
constructor(store, settings) {
this._store = store;
this._settings = settings;
this._clipboard = St.Clipboard.get_default();
}
get entries() {
const payload = this._store.loadSecrets();
return payload.map(entry => {
const raw = this._store.decodePassword(entry.password);
const display = raw
? this._settings.showPasswords
? raw
: '••••••••'
: '';
return {
id: entry.id,
user: entry.user,
label: entry.label,
rawPassword: raw,
displayPassword: display,
};
});
}
addEntry(user, password, label = '') {
if (!user || !password) return false;
const list = this._store.loadSecrets();
const encoded = this._store.encodePassword(password);
list.unshift({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
user,
password: encoded,
label,
});
this._store.saveSecrets(list);
return true;
}
deleteEntry(id) {
if (!id) return false;
const list = this._store.loadSecrets();
const filtered = list.filter(entry => entry.id !== id);
this._store.saveSecrets(filtered);
return true;
}
updateEntry(id, user, password, label) {
if (!id) return false;
const list = this._store.loadSecrets();
const index = list.findIndex(entry => entry.id === id);
if (index === -1) return false;
if (user !== undefined) list[index].user = user;
if (label !== undefined) list[index].label = label;
if (password !== undefined) {
list[index].password = this._store.encodePassword(password);
}
this._store.saveSecrets(list);
return true;
}
copyToClipboard(value) {
if (!value) return;
this._clipboard.set_text(St.ClipboardType.CLIPBOARD, value);
Main.notify('ClipCoffre', 'Copié dans le presse-papiers');
}
}

145
src/services/syncService.js Normal file
View File

@@ -0,0 +1,145 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import * as JsonStore from '../storage/jsonStore.js';
const SYNC_SUBFOLDER = 'clipcoffre-secrets';
const SECRETS_FILE = 'secrets.json';
const KEY_FILE = 'key.json';
export class SyncService {
constructor(extension, settings, secretsStore) {
this._extension = extension;
this._settings = settings;
this._secretsStore = secretsStore;
this._dataDir = JsonStore.getDataDir(extension);
}
isConfigured() {
const path = this._settings.syncPath;
if (!path || path.trim() === '') {
return false;
}
const file = Gio.File.new_for_path(path);
return file.query_exists(null);
}
getSyncPath() {
return this._settings.syncPath;
}
_getSyncFolder() {
const basePath = this._settings.syncPath;
return GLib.build_filenamev([basePath, SYNC_SUBFOLDER]);
}
_ensureSyncFolder() {
const syncFolder = this._getSyncFolder();
const file = Gio.File.new_for_path(syncFolder);
if (!file.query_exists(null)) {
file.make_directory_with_parents(null);
}
GLib.chmod(syncFolder, 0o700);
return syncFolder;
}
/**
* Exporte les secrets vers le dossier de sync (dépôt Git)
*/
exportSecrets() {
if (!this.isConfigured()) {
return { success: false, message: 'Chemin de sync non configuré' };
}
try {
const syncFolder = this._ensureSyncFolder();
// Copier secrets.json
const secretsSrc = GLib.build_filenamev([this._dataDir, SECRETS_FILE]);
const secretsDst = GLib.build_filenamev([syncFolder, SECRETS_FILE]);
const srcFile = Gio.File.new_for_path(secretsSrc);
if (srcFile.query_exists(null)) {
const dstFile = Gio.File.new_for_path(secretsDst);
srcFile.copy(dstFile, Gio.FileCopyFlags.OVERWRITE, null, null);
GLib.chmod(secretsDst, 0o600);
}
// Copier key.json
const keySrc = GLib.build_filenamev([this._dataDir, KEY_FILE]);
const keyDst = GLib.build_filenamev([syncFolder, KEY_FILE]);
const keySrcFile = Gio.File.new_for_path(keySrc);
if (keySrcFile.query_exists(null)) {
const keyDstFile = Gio.File.new_for_path(keyDst);
keySrcFile.copy(keyDstFile, Gio.FileCopyFlags.OVERWRITE, null, null);
GLib.chmod(keyDst, 0o600);
}
return {
success: true,
message: 'Secrets exportés',
path: syncFolder
};
} catch (error) {
console.log(`[ClipCoffre] Sync export failed: ${error}`);
return { success: false, message: `Export échoué: ${error.message}` };
}
}
/**
* Importe les secrets depuis le dossier de sync (dépôt Git)
*/
importSecrets() {
if (!this.isConfigured()) {
return { success: false, message: 'Chemin de sync non configuré' };
}
const syncFolder = this._getSyncFolder();
const syncFolderFile = Gio.File.new_for_path(syncFolder);
if (!syncFolderFile.query_exists(null)) {
return { success: false, message: 'Dossier clipcoffre-secrets introuvable' };
}
try {
// Vérifier que les fichiers existent dans le dossier sync
const secretsSrc = GLib.build_filenamev([syncFolder, SECRETS_FILE]);
const keySrc = GLib.build_filenamev([syncFolder, KEY_FILE]);
const secretsSrcFile = Gio.File.new_for_path(secretsSrc);
const keySrcFile = Gio.File.new_for_path(keySrc);
if (!secretsSrcFile.query_exists(null)) {
return { success: false, message: 'secrets.json introuvable dans le dépôt' };
}
if (!keySrcFile.query_exists(null)) {
return { success: false, message: 'key.json introuvable dans le dépôt' };
}
// S'assurer que le dossier data existe
JsonStore.ensureDataDir(this._extension);
// Copier key.json d'abord (nécessaire pour déchiffrer)
const keyDst = GLib.build_filenamev([this._dataDir, KEY_FILE]);
const keyDstFile = Gio.File.new_for_path(keyDst);
keySrcFile.copy(keyDstFile, Gio.FileCopyFlags.OVERWRITE, null, null);
GLib.chmod(keyDst, 0o600);
// Copier secrets.json
const secretsDst = GLib.build_filenamev([this._dataDir, SECRETS_FILE]);
const secretsDstFile = Gio.File.new_for_path(secretsDst);
secretsSrcFile.copy(secretsDstFile, Gio.FileCopyFlags.OVERWRITE, null, null);
GLib.chmod(secretsDst, 0o600);
return {
success: true,
message: 'Secrets importés - Redémarrez l\'extension',
needsReload: true
};
} catch (error) {
console.log(`[ClipCoffre] Sync import failed: ${error}`);
return { success: false, message: `Import échoué: ${error.message}` };
}
}
}

View File

@@ -0,0 +1,56 @@
import GLib from 'gi://GLib';
import * as JsonStore from './jsonStore.js';
function _buildPath(directory, filename) {
return GLib.build_filenamev([directory, filename]);
}
export class ClipboardStore {
constructor(extension, settings) {
this._extension = extension;
this._settings = settings;
this._dataDir = JsonStore.ensureDataDir(extension);
this._filePath = _buildPath(this._dataDir, 'clipboard.json');
}
load() {
const payload = JsonStore.readJson(this._filePath, {
favorites: [],
history: [],
});
return payload;
}
save(payload) {
JsonStore.writeJson(this._filePath, payload);
}
addItem(content) {
const payload = this.load();
const cleaned = payload.history.filter(entry => entry.content !== content);
const item = {
id: `${Date.now()}-${Math.random()}`,
content,
favorite: Boolean(payload.favorites.find(favorite => favorite.content === content)),
};
cleaned.unshift(item);
const limit = Math.max(10, this._settings.historySize);
payload.history = cleaned.slice(0, limit);
payload.favorites = payload.history.filter(entry => entry.favorite);
this.save(payload);
return payload;
}
toggleFavorite(itemId) {
const payload = this.load();
payload.history = payload.history.map(entry => {
if (entry.id === itemId) {
entry.favorite = !entry.favorite;
}
return entry;
});
payload.favorites = payload.history.filter(entry => entry.favorite);
this.save(payload);
return payload;
}
}

70
src/storage/jsonStore.js Normal file
View File

@@ -0,0 +1,70 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
export function getDataDir(extension) {
return GLib.build_filenamev([
GLib.get_user_data_dir(),
'gnome-shell',
'extensions',
extension.metadata.uuid,
'data',
]);
}
export function ensureDataDir(extension) {
const dir = getDataDir(extension);
const file = Gio.File.new_for_path(dir);
if (!file.query_exists(null)) {
file.make_directory_with_parents(null);
}
// Set permissions 0700
GLib.chmod(dir, 0o700);
return dir;
}
export function readJson(filePath, fallback = {}) {
try {
const file = Gio.File.new_for_path(filePath);
if (!file.query_exists(null)) {
return fallback;
}
const [success, contents] = file.load_contents(null);
if (!success) return fallback;
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(contents);
if (!text) return fallback;
return JSON.parse(text);
} catch (error) {
console.log(`[ClipCoffre] Failed to read ${filePath}: ${error}`);
return fallback;
}
}
export function writeJson(filePath, value) {
try {
const text = JSON.stringify(value, null, 2);
const file = Gio.File.new_for_path(filePath);
const parentDir = file.get_parent();
if (parentDir && !parentDir.query_exists(null)) {
parentDir.make_directory_with_parents(null);
}
file.replace_contents(
new TextEncoder().encode(text),
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
null
);
} catch (error) {
console.log(`[ClipCoffre] Failed to write ${filePath}: ${error}`);
}
}
export function setPermissions(filePath, mode) {
const file = Gio.File.new_for_path(filePath);
if (file.query_exists(null)) {
GLib.chmod(filePath, mode);
}
}

110
src/storage/secretsStore.js Normal file
View File

@@ -0,0 +1,110 @@
import GLib from 'gi://GLib';
import * as JsonStore from './jsonStore.js';
const SECRETS_FILENAME = 'secrets.json';
const KEY_FILENAME = 'key.json';
function _buildPath(directory, filename) {
return GLib.build_filenamev([directory, filename]);
}
function _generateId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export class SecretsStore {
constructor(extension) {
this._extension = extension;
this._dataDir = JsonStore.ensureDataDir(extension);
this._secretsPath = _buildPath(this._dataDir, SECRETS_FILENAME);
this._keyPath = _buildPath(this._dataDir, KEY_FILENAME);
this._key = this._loadKey();
JsonStore.setPermissions(this._secretsPath, 0o600);
}
_normalize(entry) {
return {
id: entry.id || _generateId(),
user: entry.user || '',
password: entry.password || '',
label: entry.label || '',
};
}
_normalizeCollection(secrets) {
return secrets.map(entry => this._normalize(entry));
}
_loadKey() {
const payload = JsonStore.readJson(this._keyPath, null);
if (payload && payload.key) {
return this._base64ToBytes(payload.key);
}
// Generate new key
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
const serialized = this._bytesToBase64(bytes);
JsonStore.writeJson(this._keyPath, { key: serialized });
JsonStore.setPermissions(this._keyPath, 0o600);
return bytes;
}
_bytesToBase64(bytes) {
return GLib.base64_encode(bytes);
}
_base64ToBytes(base64) {
const decoded = GLib.base64_decode(base64);
return new Uint8Array(decoded);
}
_xored(bytes) {
const result = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
result[i] = bytes[i] ^ this._key[i % this._key.length];
}
return result;
}
_encode(value) {
const encoder = new TextEncoder();
const buffer = encoder.encode(value || '');
const xored = this._xored(buffer);
return this._bytesToBase64(xored);
}
_decode(value) {
if (!value) return '';
const bytes = this._base64ToBytes(value);
const xored = this._xored(bytes);
const decoder = new TextDecoder();
return decoder.decode(xored);
}
loadSecrets() {
const payload = JsonStore.readJson(this._secretsPath, []);
return this._normalizeCollection(payload);
}
saveSecrets(secrets) {
const normalized = this._normalizeCollection(secrets);
JsonStore.writeJson(this._secretsPath, normalized);
JsonStore.setPermissions(this._secretsPath, 0o600);
}
encodePassword(value) {
return this._encode(value);
}
decodePassword(value) {
try {
return this._decode(value);
} catch (error) {
console.log(`[ClipCoffre] Unable to decode secret: ${error}`);
return '';
}
}
}

78
src/storage/settings.js Normal file
View File

@@ -0,0 +1,78 @@
export class SettingsWrapper {
constructor(settings) {
this._settings = settings;
this._connections = [];
}
get historySize() {
return this._settings.get_int('history-size');
}
set historySize(value) {
this._settings.set_int('history-size', value);
}
get showPasswords() {
return this._settings.get_boolean('show-passwords');
}
set showPasswords(value) {
this._settings.set_boolean('show-passwords', value);
}
get clickMode() {
return this._settings.get_string('click-mode');
}
set clickMode(value) {
this._settings.set_string('click-mode', value);
}
get backupPath() {
return this._settings.get_string('backup-path');
}
set backupPath(value) {
this._settings.set_string('backup-path', value);
}
get syncPath() {
return this._settings.get_string('sync-path');
}
set syncPath(value) {
this._settings.set_string('sync-path', value);
}
get gitUrl() {
return this._settings.get_string('git-url');
}
set gitUrl(value) {
this._settings.set_string('git-url', value);
}
get gitUser() {
return this._settings.get_string('git-user');
}
set gitUser(value) {
this._settings.set_string('git-user', value);
}
connect(signal, callback) {
const id = this._settings.connect(signal, callback);
this._connections.push(id);
return id;
}
disconnect(signalId) {
this._settings.disconnect(signalId);
this._connections = this._connections.filter(id => id !== signalId);
}
destroy() {
this._connections.forEach(id => this._settings.disconnect(id));
this._connections = [];
}
}

195
src/ui/panelButton.js Normal file
View File

@@ -0,0 +1,195 @@
import Clutter from 'gi://Clutter';
import St from 'gi://St';
import GObject from 'gi://GObject';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { PopupMain } from './popupMain.js';
import { PopupSecrets } from './popupSecrets.js';
import { PopupClipboard } from './popupClipboard.js';
import { PopupSettings } from './popupSettings.js';
import { BackupService } from '../services/backupService.js';
const ICON_NAME = 'dialog-password-symbolic';
const MODE_UNIFIE = 'unifie';
const MODE_SEPARE = 'separe';
export const PanelButton = GObject.registerClass(
class PanelButton extends PanelMenu.Button {
_init(settings, services) {
super._init(0.0, 'ClipCoffre');
this._settings = settings;
this._services = services;
this._currentMode = null;
this._settingsChangedId = null;
this._icon = new St.Icon({
icon_name: ICON_NAME,
style_class: 'system-status-icon clipcoffre-icon',
});
this.add_child(this._icon);
this._buildMenu();
this._settingsChangedId = this._settings.connect(
'changed::click-mode',
this._onModeChanged.bind(this)
);
}
_buildMenu() {
this.menu.removeAll();
this._currentMode = this._settings.clickMode;
if (this._currentMode === MODE_UNIFIE) {
this._buildUnifiedMode();
} else {
this._buildSeparateMode();
}
}
_buildUnifiedMode() {
this._mainPopup = new PopupMain(this._settings, this._services, {
onSettingsChanged: () => this._onSettingsChanged(),
onBackupClick: () => this._doBackup(),
});
this.menu.addMenuItem(this._mainPopup.widget);
}
_buildSeparateMode() {
this._showSecretsView = true;
this._secretsPopup = new PopupSecrets(this._settings, this._services, {
onSettingsClick: () => this._showSettings(),
onBackupClick: () => this._doBackup(),
});
this._clipboardPopup = new PopupClipboard(this._settings, this._services, {
onSettingsClick: () => this._showSettings(),
onBackupClick: () => this._doBackup(),
});
this._settingsPopup = new PopupSettings(this._settings, {
onSave: () => {
this._onSettingsChanged();
this._showSecrets();
},
onCancel: () => this._showSecrets(),
});
this._rebuildSeparateContent();
}
_rebuildSeparateContent() {
this.menu.removeAll();
if (this._showSettingsView) {
const headerItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const headerBox = new St.BoxLayout({
style_class: 'clipcoffre-tabs-header',
x_expand: true,
});
const backBtn = new St.Button({
style_class: 'clipcoffre-back-btn',
child: new St.Icon({
icon_name: 'go-previous-symbolic',
icon_size: 16,
}),
});
backBtn.connect('clicked', () => this._showSecrets());
headerBox.add_child(backBtn);
const title = new St.Label({
text: 'Paramètres',
style_class: 'clipcoffre-title',
x_expand: true,
});
headerBox.add_child(title);
headerItem.add_child(headerBox);
this.menu.addMenuItem(headerItem);
this.menu.addMenuItem(this._settingsPopup.widget);
} else if (this._showSecretsView) {
this._secretsPopup.refresh();
this.menu.addMenuItem(this._secretsPopup.widget);
} else {
this._clipboardPopup.refresh();
this.menu.addMenuItem(this._clipboardPopup.widget);
}
}
_showSecrets() {
this._showSecretsView = true;
this._showSettingsView = false;
this._rebuildSeparateContent();
}
_showClipboard() {
this._showSecretsView = false;
this._showSettingsView = false;
this._rebuildSeparateContent();
}
_showSettings() {
this._showSettingsView = true;
this._settingsPopup.refresh();
this._rebuildSeparateContent();
}
_onModeChanged() {
this._buildMenu();
}
_onSettingsChanged() {
if (this._currentMode !== this._settings.clickMode) {
this._buildMenu();
}
}
_doBackup() {
const backupService = new BackupService(
this._services.secretService._store._extension,
this._settings,
this._services.secretService._store,
this._services.clipboardService._store
);
if (!backupService.isConfigured()) {
Main.notify('ClipCoffre', 'Configurer le chemin de backup dans les paramètres');
return;
}
const result = backupService.backup();
Main.notify('ClipCoffre', result.message);
}
vfunc_event(event) {
if (event.type() === Clutter.EventType.BUTTON_PRESS) {
const button = event.get_button();
if (this._currentMode === MODE_SEPARE) {
if (button === Clutter.BUTTON_PRIMARY) {
this._showSecretsView = true;
this._showSettingsView = false;
this._rebuildSeparateContent();
} else if (button === Clutter.BUTTON_SECONDARY) {
this._showSecretsView = false;
this._showSettingsView = false;
this._rebuildSeparateContent();
}
}
}
return super.vfunc_event(event);
}
destroy() {
if (this._settingsChangedId) {
this._settings.disconnect(this._settingsChangedId);
this._settingsChangedId = null;
}
super.destroy();
}
});

170
src/ui/popupClipboard.js Normal file
View File

@@ -0,0 +1,170 @@
import St from 'gi://St';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
const MAX_DISPLAY_LENGTH = 50;
export class PopupClipboard {
constructor(settings, services, callbacks = {}) {
this._settings = settings;
this._clipboardService = services.clipboardService;
this._callbacks = callbacks;
this._section = new PopupMenu.PopupMenuSection();
this._build();
}
_truncate(text) {
if (!text) return '';
const singleLine = text.replace(/\n/g, ' ').trim();
if (singleLine.length <= MAX_DISPLAY_LENGTH) return singleLine;
return singleLine.slice(0, MAX_DISPLAY_LENGTH - 3) + '...';
}
_build() {
this._section.removeAll();
this._buildHeader();
const state = this._clipboardService.state();
this._buildFavorites(state.favorites || []);
this._buildHistory(state.history || []);
}
_buildHeader() {
const headerBox = new St.BoxLayout({
style_class: 'clipcoffre-header',
x_expand: true,
});
const title = new St.Label({
text: 'Clipboard',
style_class: 'clipcoffre-title',
x_expand: true,
});
headerBox.add_child(title);
if (this._callbacks.onBackupClick) {
const backupButton = new St.Button({
style_class: 'clipcoffre-header-button clipcoffre-backup-btn',
child: new St.Icon({
icon_name: 'document-save-symbolic',
icon_size: 16,
}),
});
backupButton.connect('clicked', () => {
this._callbacks.onBackupClick();
});
headerBox.add_child(backupButton);
}
if (this._callbacks.onSettingsClick) {
const settingsButton = new St.Button({
style_class: 'clipcoffre-header-button',
child: new St.Icon({
icon_name: 'emblem-system-symbolic',
icon_size: 16,
}),
});
settingsButton.connect('clicked', () => {
this._callbacks.onSettingsClick();
});
headerBox.add_child(settingsButton);
}
const headerItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
headerItem.add_child(headerBox);
this._section.addMenuItem(headerItem);
}
_buildFavorites(favorites) {
if (favorites.length === 0) return;
const labelItem = new PopupMenu.PopupMenuItem('Favoris', {
reactive: false,
});
labelItem.add_style_class_name('clipcoffre-section-label');
this._section.addMenuItem(labelItem);
for (const item of favorites) {
this._buildClipboardRow(item, true);
}
this._section.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
}
_buildHistory(history) {
const nonFavorites = history.filter(item => !item.favorite);
if (nonFavorites.length === 0 && history.length === 0) {
const emptyItem = new PopupMenu.PopupMenuItem('Historique vide', {
reactive: false,
});
emptyItem.add_style_class_name('clipcoffre-empty');
this._section.addMenuItem(emptyItem);
return;
}
if (nonFavorites.length === 0) return;
const labelItem = new PopupMenu.PopupMenuItem('Historique', {
reactive: false,
});
labelItem.add_style_class_name('clipcoffre-section-label');
this._section.addMenuItem(labelItem);
for (const item of nonFavorites) {
this._buildClipboardRow(item, false);
}
}
_buildClipboardRow(item, isFavorite) {
const menuItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const rowBox = new St.BoxLayout({
style_class: 'clipcoffre-clipboard-row',
x_expand: true,
});
const starIcon = isFavorite ? 'starred-symbolic' : 'non-starred-symbolic';
const starBtn = new St.Button({
style_class: 'clipcoffre-star-btn',
child: new St.Icon({
icon_name: starIcon,
icon_size: 16,
}),
});
starBtn.connect('clicked', () => {
this._clipboardService.toggleFavorite(item.id);
this._build();
});
rowBox.add_child(starBtn);
const contentLabel = new St.Label({
text: this._truncate(item.content),
style_class: 'clipcoffre-clipboard-text',
x_expand: true,
});
rowBox.add_child(contentLabel);
const copyBtn = new St.Button({
style_class: 'clipcoffre-copy-btn',
child: new St.Icon({
icon_name: 'edit-copy-symbolic',
icon_size: 14,
}),
});
copyBtn.connect('clicked', () => {
this._clipboardService.copy(item.content);
});
rowBox.add_child(copyBtn);
menuItem.add_child(rowBox);
this._section.addMenuItem(menuItem);
}
refresh() {
this._build();
}
get widget() {
return this._section;
}
}

153
src/ui/popupMain.js Normal file
View File

@@ -0,0 +1,153 @@
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { PopupSecrets } from './popupSecrets.js';
import { PopupClipboard } from './popupClipboard.js';
import { PopupSettings } from './popupSettings.js';
const TAB_SECRETS = 'secrets';
const TAB_CLIPBOARD = 'clipboard';
const TAB_SETTINGS = 'settings';
export class PopupMain {
constructor(settings, services, callbacks = {}) {
this._settings = settings;
this._services = services;
this._callbacks = callbacks;
this._section = new PopupMenu.PopupMenuSection();
this._activeTab = TAB_SECRETS;
this._build();
}
_createSecretsPopup() {
return new PopupSecrets(this._settings, this._services, {
onSettingsClick: () => this._switchTab(TAB_SETTINGS),
onBackupClick: () => this._doBackup(),
});
}
_createClipboardPopup() {
return new PopupClipboard(this._settings, this._services, {
onSettingsClick: () => this._switchTab(TAB_SETTINGS),
onBackupClick: () => this._doBackup(),
});
}
_createSettingsPopup() {
return new PopupSettings(this._settings, {
onSave: () => {
this._switchTab(TAB_SECRETS);
if (this._callbacks.onSettingsChanged) {
this._callbacks.onSettingsChanged();
}
},
onCancel: () => this._switchTab(TAB_SECRETS),
});
}
_build() {
this._section.removeAll();
this._buildTabs();
this._buildContent();
}
_buildTabs() {
if (this._activeTab === TAB_SETTINGS) {
const headerItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const headerBox = new St.BoxLayout({
style_class: 'clipcoffre-tabs-header',
x_expand: true,
});
const backBtn = new St.Button({
style_class: 'clipcoffre-back-btn',
child: new St.Icon({
icon_name: 'go-previous-symbolic',
icon_size: 16,
}),
});
backBtn.connect('clicked', () => this._switchTab(TAB_SECRETS));
headerBox.add_child(backBtn);
const title = new St.Label({
text: 'Paramètres',
style_class: 'clipcoffre-title',
x_expand: true,
});
headerBox.add_child(title);
headerItem.add_child(headerBox);
this._section.addMenuItem(headerItem);
return;
}
const tabsItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const tabsBox = new St.BoxLayout({
style_class: 'clipcoffre-tabs',
x_expand: true,
});
const secretsTab = new St.Button({
style_class:
this._activeTab === TAB_SECRETS
? 'clipcoffre-tab clipcoffre-tab-active'
: 'clipcoffre-tab',
label: 'Secrets',
x_expand: true,
});
secretsTab.connect('clicked', () => this._switchTab(TAB_SECRETS));
const clipboardTab = new St.Button({
style_class:
this._activeTab === TAB_CLIPBOARD
? 'clipcoffre-tab clipcoffre-tab-active'
: 'clipcoffre-tab',
label: 'Clipboard',
x_expand: true,
});
clipboardTab.connect('clicked', () => this._switchTab(TAB_CLIPBOARD));
tabsBox.add_child(secretsTab);
tabsBox.add_child(clipboardTab);
tabsItem.add_child(tabsBox);
this._section.addMenuItem(tabsItem);
}
_buildContent() {
// Créer un nouveau popup à chaque fois pour éviter les problèmes de parent
switch (this._activeTab) {
case TAB_SECRETS:
this._section.addMenuItem(this._createSecretsPopup().widget);
break;
case TAB_CLIPBOARD:
this._section.addMenuItem(this._createClipboardPopup().widget);
break;
case TAB_SETTINGS:
this._section.addMenuItem(this._createSettingsPopup().widget);
break;
}
}
_switchTab(tab) {
this._activeTab = tab;
this._build();
}
_doBackup() {
if (this._callbacks.onBackupClick) {
this._callbacks.onBackupClick();
}
}
refresh() {
this._build();
}
get widget() {
return this._section;
}
}

330
src/ui/popupSecrets.js Normal file
View File

@@ -0,0 +1,330 @@
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { SyncService } from '../services/syncService.js';
export class PopupSecrets {
constructor(settings, services, callbacks = {}) {
this._settings = settings;
this._secretService = services.secretService;
this._callbacks = callbacks;
this._section = new PopupMenu.PopupMenuSection();
this._addMode = false;
this._build();
}
_build() {
this._section.removeAll();
this._buildHeader();
this._buildList();
if (this._addMode) {
this._buildAddForm();
}
}
_buildHeader() {
const headerBox = new St.BoxLayout({
style_class: 'clipcoffre-header',
x_expand: true,
});
const title = new St.Label({
text: 'Secrets',
style_class: 'clipcoffre-title',
x_expand: true,
});
headerBox.add_child(title);
const addButton = new St.Button({
style_class: 'clipcoffre-header-button',
child: new St.Icon({
icon_name: 'list-add-symbolic',
icon_size: 16,
}),
});
addButton.connect('clicked', () => {
this._addMode = !this._addMode;
this._build();
});
headerBox.add_child(addButton);
// Bouton sync export (envoyer vers le dépôt)
const syncExportBtn = new St.Button({
style_class: 'clipcoffre-header-button clipcoffre-sync-btn',
child: new St.Icon({
icon_name: 'send-to-symbolic',
icon_size: 16,
}),
});
syncExportBtn.connect('clicked', () => {
this._doSyncExport();
});
headerBox.add_child(syncExportBtn);
// Bouton sync import (récupérer depuis le dépôt)
const syncImportBtn = new St.Button({
style_class: 'clipcoffre-header-button clipcoffre-sync-btn',
child: new St.Icon({
icon_name: 'document-open-symbolic',
icon_size: 16,
}),
});
syncImportBtn.connect('clicked', () => {
this._doSyncImport();
});
headerBox.add_child(syncImportBtn);
if (this._callbacks.onBackupClick) {
const backupButton = new St.Button({
style_class: 'clipcoffre-header-button clipcoffre-backup-btn',
child: new St.Icon({
icon_name: 'document-save-symbolic',
icon_size: 16,
}),
});
backupButton.connect('clicked', () => {
this._callbacks.onBackupClick();
});
headerBox.add_child(backupButton);
}
if (this._callbacks.onSettingsClick) {
const settingsButton = new St.Button({
style_class: 'clipcoffre-header-button',
child: new St.Icon({
icon_name: 'emblem-system-symbolic',
icon_size: 16,
}),
});
settingsButton.connect('clicked', () => {
this._callbacks.onSettingsClick();
});
headerBox.add_child(settingsButton);
}
const headerItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
headerItem.add_child(headerBox);
this._section.addMenuItem(headerItem);
}
_buildList() {
const entries = this._secretService.entries;
if (entries.length === 0) {
const emptyItem = new PopupMenu.PopupMenuItem('Aucun secret enregistré', {
reactive: false,
});
emptyItem.add_style_class_name('clipcoffre-empty');
this._section.addMenuItem(emptyItem);
return;
}
for (const entry of entries) {
this._buildEntryRow(entry);
}
}
_buildEntryRow(entry) {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const rowBox = new St.BoxLayout({
style_class: 'clipcoffre-secret-row',
x_expand: true,
});
// Colonne 1 : Label (app/service)
const labelBox = new St.BoxLayout({
style_class: 'clipcoffre-col clipcoffre-col-label',
x_expand: true,
});
const labelText = new St.Label({
text: entry.label || '—',
style_class: 'clipcoffre-text',
x_expand: true,
});
labelBox.add_child(labelText);
rowBox.add_child(labelBox);
// Colonne 2 : User
const userBox = new St.BoxLayout({
style_class: 'clipcoffre-col clipcoffre-col-user',
x_expand: true,
});
const userText = new St.Label({
text: entry.user,
style_class: 'clipcoffre-text',
x_expand: true,
});
userBox.add_child(userText);
const copyUserBtn = new St.Button({
style_class: 'clipcoffre-copy-btn',
child: new St.Icon({
icon_name: 'edit-copy-symbolic',
icon_size: 14,
}),
});
copyUserBtn.connect('clicked', () => {
this._secretService.copyToClipboard(entry.user);
});
userBox.add_child(copyUserBtn);
rowBox.add_child(userBox);
// Colonne 3 : Password
const pwdBox = new St.BoxLayout({
style_class: 'clipcoffre-col clipcoffre-col-pwd',
x_expand: true,
});
const pwdText = new St.Label({
text: entry.displayPassword,
style_class: 'clipcoffre-text clipcoffre-password',
x_expand: true,
});
pwdBox.add_child(pwdText);
const copyPwdBtn = new St.Button({
style_class: 'clipcoffre-copy-btn',
child: new St.Icon({
icon_name: 'edit-copy-symbolic',
icon_size: 14,
}),
});
copyPwdBtn.connect('clicked', () => {
this._secretService.copyToClipboard(entry.rawPassword);
});
pwdBox.add_child(copyPwdBtn);
const deleteBtn = new St.Button({
style_class: 'clipcoffre-delete-btn',
child: new St.Icon({
icon_name: 'edit-delete-symbolic',
icon_size: 14,
}),
});
deleteBtn.connect('clicked', () => {
this._secretService.deleteEntry(entry.id);
this._build();
});
pwdBox.add_child(deleteBtn);
rowBox.add_child(pwdBox);
item.add_child(rowBox);
this._section.addMenuItem(item);
}
_buildAddForm() {
const formItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const formBox = new St.BoxLayout({
style_class: 'clipcoffre-add-form',
vertical: true,
x_expand: true,
});
const labelEntry = new St.Entry({
style_class: 'clipcoffre-input',
hint_text: 'App / Service',
can_focus: true,
});
const userEntry = new St.Entry({
style_class: 'clipcoffre-input',
hint_text: 'Utilisateur',
can_focus: true,
});
const pwdEntry = new St.Entry({
style_class: 'clipcoffre-input',
hint_text: 'Mot de passe',
can_focus: true,
});
pwdEntry.clutter_text.set_password_char('•');
const buttonsBox = new St.BoxLayout({
style_class: 'clipcoffre-form-buttons',
x_expand: true,
});
const saveBtn = new St.Button({
style_class: 'clipcoffre-btn clipcoffre-btn-save',
label: 'Enregistrer',
});
saveBtn.connect('clicked', () => {
const label = labelEntry.get_text().trim();
const user = userEntry.get_text().trim();
const pwd = pwdEntry.get_text();
if (user && pwd) {
this._secretService.addEntry(user, pwd, label);
this._addMode = false;
this._build();
}
});
const cancelBtn = new St.Button({
style_class: 'clipcoffre-btn clipcoffre-btn-cancel',
label: 'Annuler',
});
cancelBtn.connect('clicked', () => {
this._addMode = false;
this._build();
});
buttonsBox.add_child(saveBtn);
buttonsBox.add_child(cancelBtn);
formBox.add_child(labelEntry);
formBox.add_child(userEntry);
formBox.add_child(pwdEntry);
formBox.add_child(buttonsBox);
formItem.add_child(formBox);
this._section.addMenuItem(formItem);
}
refresh() {
this._build();
}
_doSyncExport() {
const syncService = new SyncService(
this._secretService._store._extension,
this._settings,
this._secretService._store
);
if (!syncService.isConfigured()) {
Main.notify('ClipCoffre', 'Configurer le chemin sync Git dans les paramètres');
return;
}
const result = syncService.exportSecrets();
Main.notify('ClipCoffre', result.message);
}
_doSyncImport() {
const syncService = new SyncService(
this._secretService._store._extension,
this._settings,
this._secretService._store
);
if (!syncService.isConfigured()) {
Main.notify('ClipCoffre', 'Configurer le chemin sync Git dans les paramètres');
return;
}
const result = syncService.importSecrets();
Main.notify('ClipCoffre', result.message);
if (result.success) {
// Rafraîchir l'affichage après import
this._build();
}
}
get widget() {
return this._section;
}
}

354
src/ui/popupSettings.js Normal file
View File

@@ -0,0 +1,354 @@
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
export class PopupSettings {
constructor(settings, callbacks = {}) {
this._settings = settings;
this._callbacks = callbacks;
this._section = new PopupMenu.PopupMenuSection();
this._tempHistorySize = settings.historySize;
this._tempShowPasswords = settings.showPasswords;
this._tempClickMode = settings.clickMode;
this._tempBackupPath = settings.backupPath;
this._tempSyncPath = settings.syncPath;
this._build();
}
_build() {
this._section.removeAll();
this._buildHeader();
this._buildHistorySizeSetting();
this._buildShowPasswordsSetting();
this._buildClickModeSetting();
this._buildBackupPathSetting();
this._buildSyncPathSetting();
this._buildButtons();
}
_buildHeader() {
const headerBox = new St.BoxLayout({
style_class: 'clipcoffre-header',
x_expand: true,
});
const title = new St.Label({
text: 'Paramètres',
style_class: 'clipcoffre-title',
x_expand: true,
});
headerBox.add_child(title);
const headerItem = new PopupMenu.PopupBaseMenuItem({ reactive: false });
headerItem.add_child(headerBox);
this._section.addMenuItem(headerItem);
}
_buildHistorySizeSetting() {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const rowBox = new St.BoxLayout({
style_class: 'clipcoffre-settings-row',
x_expand: true,
});
const label = new St.Label({
text: 'Taille historique',
style_class: 'clipcoffre-settings-label',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
rowBox.add_child(label);
const spinBox = new St.BoxLayout({
style_class: 'clipcoffre-spin-box',
});
const minusBtn = new St.Button({
style_class: 'clipcoffre-spin-btn',
label: '-',
});
minusBtn.connect('clicked', () => {
if (this._tempHistorySize > 10) {
this._tempHistorySize -= 10;
this._updateSpinLabel();
}
});
this._historySizeLabel = new St.Label({
text: String(this._tempHistorySize),
style_class: 'clipcoffre-spin-value',
x_align: Clutter.ActorAlign.CENTER,
});
const plusBtn = new St.Button({
style_class: 'clipcoffre-spin-btn',
label: '+',
});
plusBtn.connect('clicked', () => {
if (this._tempHistorySize < 200) {
this._tempHistorySize += 10;
this._updateSpinLabel();
}
});
spinBox.add_child(minusBtn);
spinBox.add_child(this._historySizeLabel);
spinBox.add_child(plusBtn);
rowBox.add_child(spinBox);
item.add_child(rowBox);
this._section.addMenuItem(item);
}
_updateSpinLabel() {
this._historySizeLabel.set_text(String(this._tempHistorySize));
}
_buildShowPasswordsSetting() {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const rowBox = new St.BoxLayout({
style_class: 'clipcoffre-settings-row',
x_expand: true,
});
const label = new St.Label({
text: 'Afficher mots de passe',
style_class: 'clipcoffre-settings-label',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
rowBox.add_child(label);
this._switchBtn = new St.Button({
style_class: this._tempShowPasswords
? 'clipcoffre-switch clipcoffre-switch-on'
: 'clipcoffre-switch clipcoffre-switch-off',
label: this._tempShowPasswords ? 'ON' : 'OFF',
});
this._switchBtn.connect('clicked', () => {
this._tempShowPasswords = !this._tempShowPasswords;
this._updateSwitchStyle();
});
rowBox.add_child(this._switchBtn);
item.add_child(rowBox);
this._section.addMenuItem(item);
}
_updateSwitchStyle() {
if (this._tempShowPasswords) {
this._switchBtn.remove_style_class_name('clipcoffre-switch-off');
this._switchBtn.add_style_class_name('clipcoffre-switch-on');
this._switchBtn.set_label('ON');
} else {
this._switchBtn.remove_style_class_name('clipcoffre-switch-on');
this._switchBtn.add_style_class_name('clipcoffre-switch-off');
this._switchBtn.set_label('OFF');
}
}
_buildClickModeSetting() {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const rowBox = new St.BoxLayout({
style_class: 'clipcoffre-settings-row',
x_expand: true,
});
const label = new St.Label({
text: 'Mode clic',
style_class: 'clipcoffre-settings-label',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
rowBox.add_child(label);
const comboBox = new St.BoxLayout({
style_class: 'clipcoffre-combo-box',
});
this._unifieBtn = new St.Button({
style_class:
this._tempClickMode === 'unifie'
? 'clipcoffre-combo-btn clipcoffre-combo-btn-active'
: 'clipcoffre-combo-btn',
label: 'Unifié',
});
this._unifieBtn.connect('clicked', () => {
this._tempClickMode = 'unifie';
this._updateComboStyle();
});
this._separeBtn = new St.Button({
style_class:
this._tempClickMode === 'separe'
? 'clipcoffre-combo-btn clipcoffre-combo-btn-active'
: 'clipcoffre-combo-btn',
label: 'Séparé',
});
this._separeBtn.connect('clicked', () => {
this._tempClickMode = 'separe';
this._updateComboStyle();
});
comboBox.add_child(this._unifieBtn);
comboBox.add_child(this._separeBtn);
rowBox.add_child(comboBox);
item.add_child(rowBox);
this._section.addMenuItem(item);
}
_updateComboStyle() {
if (this._tempClickMode === 'unifie') {
this._unifieBtn.add_style_class_name('clipcoffre-combo-btn-active');
this._separeBtn.remove_style_class_name('clipcoffre-combo-btn-active');
} else {
this._unifieBtn.remove_style_class_name('clipcoffre-combo-btn-active');
this._separeBtn.add_style_class_name('clipcoffre-combo-btn-active');
}
}
_buildBackupPathSetting() {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const rowBox = new St.BoxLayout({
style_class: 'clipcoffre-settings-row',
vertical: true,
x_expand: true,
});
const labelRow = new St.BoxLayout({
x_expand: true,
});
const label = new St.Label({
text: 'Chemin de backup',
style_class: 'clipcoffre-settings-label',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
labelRow.add_child(label);
rowBox.add_child(labelRow);
this._backupPathEntry = new St.Entry({
style_class: 'clipcoffre-input clipcoffre-backup-path-input',
hint_text: '/chemin/vers/backup',
text: this._tempBackupPath || '',
can_focus: true,
x_expand: true,
});
this._backupPathEntry.clutter_text.connect('text-changed', () => {
this._tempBackupPath = this._backupPathEntry.get_text();
});
rowBox.add_child(this._backupPathEntry);
item.add_child(rowBox);
this._section.addMenuItem(item);
}
_buildSyncPathSetting() {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const rowBox = new St.BoxLayout({
style_class: 'clipcoffre-settings-row',
vertical: true,
x_expand: true,
});
const labelRow = new St.BoxLayout({
x_expand: true,
});
const label = new St.Label({
text: 'Chemin sync Git (secrets)',
style_class: 'clipcoffre-settings-label',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
labelRow.add_child(label);
rowBox.add_child(labelRow);
this._syncPathEntry = new St.Entry({
style_class: 'clipcoffre-input clipcoffre-backup-path-input',
hint_text: '/chemin/vers/depot/git',
text: this._tempSyncPath || '',
can_focus: true,
x_expand: true,
});
this._syncPathEntry.clutter_text.connect('text-changed', () => {
this._tempSyncPath = this._syncPathEntry.get_text();
});
rowBox.add_child(this._syncPathEntry);
item.add_child(rowBox);
this._section.addMenuItem(item);
}
_buildButtons() {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
const buttonsBox = new St.BoxLayout({
style_class: 'clipcoffre-settings-buttons',
x_expand: true,
});
const okBtn = new St.Button({
style_class: 'clipcoffre-btn clipcoffre-btn-save',
label: 'OK',
x_expand: true,
});
okBtn.connect('clicked', () => {
this._settings.historySize = this._tempHistorySize;
this._settings.showPasswords = this._tempShowPasswords;
this._settings.clickMode = this._tempClickMode;
this._settings.backupPath = this._tempBackupPath;
this._settings.syncPath = this._tempSyncPath;
if (this._callbacks.onSave) {
this._callbacks.onSave();
}
});
const cancelBtn = new St.Button({
style_class: 'clipcoffre-btn clipcoffre-btn-cancel',
label: 'Annuler',
x_expand: true,
});
cancelBtn.connect('clicked', () => {
this._tempHistorySize = this._settings.historySize;
this._tempShowPasswords = this._settings.showPasswords;
this._tempClickMode = this._settings.clickMode;
this._tempBackupPath = this._settings.backupPath;
this._tempSyncPath = this._settings.syncPath;
this._build();
if (this._callbacks.onCancel) {
this._callbacks.onCancel();
}
});
buttonsBox.add_child(okBtn);
buttonsBox.add_child(cancelBtn);
item.add_child(buttonsBox);
this._section.addMenuItem(item);
}
refresh() {
this._tempHistorySize = this._settings.historySize;
this._tempShowPasswords = this._settings.showPasswords;
this._tempClickMode = this._settings.clickMode;
this._tempBackupPath = this._settings.backupPath;
this._tempSyncPath = this._settings.syncPath;
this._build();
}
get widget() {
return this._section;
}
}

262
stylesheet.css Normal file
View File

@@ -0,0 +1,262 @@
/* ClipCoffre GNOME Shell extension stylesheet */
/* Icône dans la barre */
.clipcoffre-icon {
color: #f5f5f5;
}
/* En-têtes */
.clipcoffre-header {
padding: 8px 12px;
spacing: 8px;
}
.clipcoffre-title {
font-weight: bold;
font-size: 1.1em;
}
.clipcoffre-header-button {
padding: 4px 8px;
border-radius: 4px;
}
.clipcoffre-header-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Onglets mode unifié */
.clipcoffre-tabs {
padding: 4px 8px;
spacing: 4px;
}
.clipcoffre-tab {
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
}
.clipcoffre-tab:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.clipcoffre-tab-active {
background-color: rgba(255, 255, 255, 0.2);
}
.clipcoffre-tabs-header {
padding: 8px 12px;
spacing: 8px;
}
.clipcoffre-back-btn {
padding: 4px 8px;
border-radius: 4px;
}
.clipcoffre-back-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Lignes de secrets */
.clipcoffre-secret-row {
padding: 4px 8px;
spacing: 8px;
}
.clipcoffre-col {
spacing: 4px;
}
.clipcoffre-col-label {
min-width: 80px;
}
.clipcoffre-col-user {
min-width: 100px;
}
.clipcoffre-col-pwd {
min-width: 100px;
}
.clipcoffre-text {
font-size: 0.95em;
}
.clipcoffre-password {
font-family: monospace;
}
.clipcoffre-copy-btn,
.clipcoffre-delete-btn,
.clipcoffre-star-btn {
padding: 4px;
border-radius: 4px;
}
.clipcoffre-copy-btn:hover,
.clipcoffre-star-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.clipcoffre-delete-btn:hover {
background-color: rgba(255, 80, 80, 0.3);
}
/* Formulaire d'ajout */
.clipcoffre-add-form {
padding: 8px 12px;
spacing: 8px;
}
.clipcoffre-input {
padding: 6px 10px;
border-radius: 4px;
min-width: 200px;
}
.clipcoffre-form-buttons {
spacing: 8px;
padding-top: 4px;
}
/* Boutons génériques */
.clipcoffre-btn {
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
}
.clipcoffre-btn-save {
background-color: rgba(60, 180, 100, 0.8);
}
.clipcoffre-btn-save:hover {
background-color: rgba(60, 180, 100, 1);
}
.clipcoffre-btn-cancel {
background-color: rgba(120, 120, 120, 0.6);
}
.clipcoffre-btn-cancel:hover {
background-color: rgba(120, 120, 120, 0.8);
}
/* Clipboard */
.clipcoffre-clipboard-row {
padding: 4px 8px;
spacing: 8px;
}
.clipcoffre-clipboard-text {
font-size: 0.95em;
}
.clipcoffre-section-label {
font-weight: bold;
font-size: 0.9em;
color: rgba(255, 255, 255, 0.7);
padding: 4px 12px;
}
/* Messages vides */
.clipcoffre-empty {
font-style: italic;
color: rgba(255, 255, 255, 0.5);
}
/* Settings */
.clipcoffre-settings-row {
padding: 8px 12px;
spacing: 12px;
}
.clipcoffre-settings-label {
font-size: 0.95em;
}
.clipcoffre-settings-buttons {
padding: 12px;
spacing: 8px;
}
/* SpinButton */
.clipcoffre-spin-box {
spacing: 4px;
}
.clipcoffre-spin-btn {
padding: 4px 12px;
border-radius: 4px;
font-weight: bold;
min-width: 24px;
}
.clipcoffre-spin-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.clipcoffre-spin-value {
padding: 4px 12px;
min-width: 40px;
text-align: center;
}
/* Switch */
.clipcoffre-switch {
padding: 4px 12px;
border-radius: 12px;
min-width: 50px;
text-align: center;
}
.clipcoffre-switch-on {
background-color: rgba(60, 180, 100, 0.8);
}
.clipcoffre-switch-off {
background-color: rgba(120, 120, 120, 0.6);
}
/* Combo buttons */
.clipcoffre-combo-box {
spacing: 4px;
}
.clipcoffre-combo-btn {
padding: 6px 12px;
border-radius: 4px;
}
.clipcoffre-combo-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.clipcoffre-combo-btn-active {
background-color: rgba(80, 140, 220, 0.8);
}
/* Backup */
.clipcoffre-backup-btn {
color: rgba(255, 255, 255, 0.9);
}
.clipcoffre-backup-btn:hover {
background-color: rgba(60, 180, 100, 0.3);
}
.clipcoffre-backup-path-input {
min-width: 280px;
}
/* Sync */
.clipcoffre-sync-btn {
color: rgba(100, 200, 255, 0.9);
}
.clipcoffre-sync-btn:hover {
background-color: rgba(100, 200, 255, 0.3);
}