From 3a6d443b3f9a06819c06db7349c8b79345e2d047 Mon Sep 17 00:00:00 2001 From: gilles Date: Sat, 17 Jan 2026 13:10:10 +0100 Subject: [PATCH] claude 2 --- AGENTS.md | 33 ++ CHANGELOG.md | 13 + CLAUDE.md | 96 +++++ MANUAL_TESTS.md | 34 ++ README.md | 31 +- TODO.md | 18 + docs/2026-01-17_12-18-36_backup-feature.md | 144 +++++++ extension.js | 95 +++++ install.sh | 23 ++ metadata.json | 9 + ...ell.extensions.secretclipboard.gschema.xml | 40 ++ src/services/backupService.js | 98 +++++ src/services/clipboardService.js | 70 ++++ src/services/secretService.js | 72 ++++ src/services/syncService.js | 145 +++++++ src/storage/clipboardStore.js | 56 +++ src/storage/jsonStore.js | 70 ++++ src/storage/secretsStore.js | 110 ++++++ src/storage/settings.js | 78 ++++ src/ui/panelButton.js | 195 ++++++++++ src/ui/popupClipboard.js | 170 +++++++++ src/ui/popupMain.js | 153 ++++++++ src/ui/popupSecrets.js | 330 ++++++++++++++++ src/ui/popupSettings.js | 354 ++++++++++++++++++ stylesheet.css | 262 +++++++++++++ 25 files changed, 2698 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 MANUAL_TESTS.md create mode 100644 TODO.md create mode 100644 docs/2026-01-17_12-18-36_backup-feature.md create mode 100644 extension.js create mode 100755 install.sh create mode 100644 metadata.json create mode 100644 schemas/org.gnome.shell.extensions.secretclipboard.gschema.xml create mode 100644 src/services/backupService.js create mode 100644 src/services/clipboardService.js create mode 100644 src/services/secretService.js create mode 100644 src/services/syncService.js create mode 100644 src/storage/clipboardStore.js create mode 100644 src/storage/jsonStore.js create mode 100644 src/storage/secretsStore.js create mode 100644 src/storage/settings.js create mode 100644 src/ui/panelButton.js create mode 100644 src/ui/popupClipboard.js create mode 100644 src/ui/popupMain.js create mode 100644 src/ui/popupSecrets.js create mode 100644 src/ui/popupSettings.js create mode 100644 stylesheet.css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ce71712 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..53c6e0f --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d4ee9db --- /dev/null +++ b/CLAUDE.md @@ -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 `/YYYY-MM-DD_HH-mm-ss/` +- Fichiers : settings.json, clipboard.json, secrets.json (obfusqué), manifest.json +- Setting `backup_path` configurable dans popup3 diff --git a/MANUAL_TESTS.md b/MANUAL_TESTS.md new file mode 100644 index 0000000..66383f9 --- /dev/null +++ b/MANUAL_TESTS.md @@ -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 : `/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 diff --git a/README.md b/README.md index 744c578..7383017 100644 --- a/README.md +++ b/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`. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fc9ad40 --- /dev/null +++ b/TODO.md @@ -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 diff --git a/docs/2026-01-17_12-18-36_backup-feature.md b/docs/2026-01-17_12-18-36_backup-feature.md new file mode 100644 index 0000000..ad360cd --- /dev/null +++ b/docs/2026-01-17_12-18-36_backup-feature.md @@ -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 + + '' + Backup directory path + +``` + +### 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 : +``` +/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. diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..cd9daa6 --- /dev/null +++ b/extension.js @@ -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}`); + } + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f2aa9ec --- /dev/null +++ b/install.sh @@ -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." diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..f9a7881 --- /dev/null +++ b/metadata.json @@ -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" +} diff --git a/schemas/org.gnome.shell.extensions.secretclipboard.gschema.xml b/schemas/org.gnome.shell.extensions.secretclipboard.gschema.xml new file mode 100644 index 0000000..a238794 --- /dev/null +++ b/schemas/org.gnome.shell.extensions.secretclipboard.gschema.xml @@ -0,0 +1,40 @@ + + + + + 50 + Maximum number of clipboard items to keep + Limits how many clipboard entries are kept in history before older entries are dropped. + + + false + Show passwords in clear text + When enabled, secrets are shown without masking within the Secrets popup. + + + 'unifie' + Icon click behavior + Controls whether the top bar icon shows a unified popover or separates clicks for secrets and clipboard. + + + '' + Backup directory path + Absolute path where backups will be saved. Leave empty to disable backup. + + + '' + Sync directory path + Path to git repository folder for syncing secrets across machines. + + + 'https://gitea.maison43.duckdns.org/gilles/ClipCoffre.git' + Git repository URL + URL of the git repository for syncing secrets. + + + 'gilles' + Git username + Username for git authentication. + + + diff --git a/src/services/backupService.js b/src/services/backupService.js new file mode 100644 index 0000000..b9719ff --- /dev/null +++ b/src/services/backupService.js @@ -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}` }; + } + } +} diff --git a/src/services/clipboardService.js b/src/services/clipboardService.js new file mode 100644 index 0000000..32644c5 --- /dev/null +++ b/src/services/clipboardService.js @@ -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; + } + } +} diff --git a/src/services/secretService.js b/src/services/secretService.js new file mode 100644 index 0000000..5e538f2 --- /dev/null +++ b/src/services/secretService.js @@ -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'); + } +} diff --git a/src/services/syncService.js b/src/services/syncService.js new file mode 100644 index 0000000..7b7fdec --- /dev/null +++ b/src/services/syncService.js @@ -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}` }; + } + } +} diff --git a/src/storage/clipboardStore.js b/src/storage/clipboardStore.js new file mode 100644 index 0000000..1b58c20 --- /dev/null +++ b/src/storage/clipboardStore.js @@ -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; + } +} diff --git a/src/storage/jsonStore.js b/src/storage/jsonStore.js new file mode 100644 index 0000000..d790508 --- /dev/null +++ b/src/storage/jsonStore.js @@ -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); + } +} diff --git a/src/storage/secretsStore.js b/src/storage/secretsStore.js new file mode 100644 index 0000000..a28be0e --- /dev/null +++ b/src/storage/secretsStore.js @@ -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 ''; + } + } +} diff --git a/src/storage/settings.js b/src/storage/settings.js new file mode 100644 index 0000000..f7fb919 --- /dev/null +++ b/src/storage/settings.js @@ -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 = []; + } +} diff --git a/src/ui/panelButton.js b/src/ui/panelButton.js new file mode 100644 index 0000000..e9cf7f1 --- /dev/null +++ b/src/ui/panelButton.js @@ -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(); + } +}); diff --git a/src/ui/popupClipboard.js b/src/ui/popupClipboard.js new file mode 100644 index 0000000..1939e66 --- /dev/null +++ b/src/ui/popupClipboard.js @@ -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; + } +} diff --git a/src/ui/popupMain.js b/src/ui/popupMain.js new file mode 100644 index 0000000..c22d5fc --- /dev/null +++ b/src/ui/popupMain.js @@ -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; + } +} diff --git a/src/ui/popupSecrets.js b/src/ui/popupSecrets.js new file mode 100644 index 0000000..07dbce7 --- /dev/null +++ b/src/ui/popupSecrets.js @@ -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; + } +} diff --git a/src/ui/popupSettings.js b/src/ui/popupSettings.js new file mode 100644 index 0000000..8ba2158 --- /dev/null +++ b/src/ui/popupSettings.js @@ -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; + } +} diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..7c4d322 --- /dev/null +++ b/stylesheet.css @@ -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); +}