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