claude 2
This commit is contained in:
33
AGENTS.md
Normal file
33
AGENTS.md
Normal 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
13
CHANGELOG.md
Normal 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
96
CLAUDE.md
Normal 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
34
MANUAL_TESTS.md
Normal 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
|
||||||
31
README.md
31
README.md
@@ -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
18
TODO.md
Normal 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
|
||||||
144
docs/2026-01-17_12-18-36_backup-feature.md
Normal file
144
docs/2026-01-17_12-18-36_backup-feature.md
Normal 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
95
extension.js
Normal 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
23
install.sh
Executable 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
9
metadata.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
98
src/services/backupService.js
Normal file
98
src/services/backupService.js
Normal 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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/services/clipboardService.js
Normal file
70
src/services/clipboardService.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/services/secretService.js
Normal file
72
src/services/secretService.js
Normal 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
145
src/services/syncService.js
Normal 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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/storage/clipboardStore.js
Normal file
56
src/storage/clipboardStore.js
Normal 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
70
src/storage/jsonStore.js
Normal 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
110
src/storage/secretsStore.js
Normal 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
78
src/storage/settings.js
Normal 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
195
src/ui/panelButton.js
Normal 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
170
src/ui/popupClipboard.js
Normal 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
153
src/ui/popupMain.js
Normal 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
330
src/ui/popupSecrets.js
Normal 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
354
src/ui/popupSettings.js
Normal 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
262
stylesheet.css
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user