diff --git a/password-applet@gilles/dbRepository.js b/password-applet@gilles/dbRepository.js index e69de29..6154d25 100644 --- a/password-applet@gilles/dbRepository.js +++ b/password-applet@gilles/dbRepository.js @@ -0,0 +1,71 @@ +// dbRepository.js +// Couche d’accès aux données pour l’extension. +// Utilise le helper Python password_db.py pour manipuler SQLite. + +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import * as ExtensionUtils from 'resource:///org/gnome/shell/misc/extensionUtils.js'; + +const Me = ExtensionUtils.getCurrentExtension(); + +const APP_DATA_DIR = GLib.build_filenamev([ + GLib.get_home_dir(), + '.local', 'share', 'gnome-shell', 'password-applet' +]); + +export class PasswordRepository { + constructor() { + this._helperPath = Me.dir.get_child('password_db.py').get_path(); + } + + init() { + if (!GLib.file_test(APP_DATA_DIR, GLib.FileTest.IS_DIR)) { + GLib.mkdir_with_parents(APP_DATA_DIR, 0o700); + } + this._runHelperSync(['init']); + } + + listEntries() { + const stdout = this._runHelperSync(['list']); + if (!stdout) + return []; + + try { + return JSON.parse(stdout); + } catch (e) { + logError(e, 'Erreur JSON listEntries'); + return []; + } + } + + addEntry(entry) { + const payload = JSON.stringify(entry); + this._runHelperSync(['add'], payload); + } + + _runHelperSync(args, stdinText = '') { + const argv = ['python3', this._helperPath, ...args]; + + const proc = new Gio.Subprocess({ + argv, + flags: Gio.SubprocessFlags.STDOUT_PIPE | + Gio.SubprocessFlags.STDERR_PIPE, + }); + + try { + proc.init(null); + } catch (e) { + logError(e, 'Impossible de lancer password_db.py'); + return ''; + } + + const [ok, stdout, stderr] = proc.communicate_utf8(stdinText, null); + + if (!ok || !proc.get_successful()) { + log(`password_db.py error: ${stderr}`); + return ''; + } + + return stdout.trim(); + } +} diff --git a/password-applet@gilles/extension.js b/password-applet@gilles/extension.js index e69de29..e149c10 100644 --- a/password-applet@gilles/extension.js +++ b/password-applet@gilles/extension.js @@ -0,0 +1,32 @@ +// extension.js +// Point d’entrée de l’extension GNOME Shell Password Applet. + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as ExtensionUtils from 'resource:///org/gnome/shell/misc/extensionUtils.js'; + +import { PasswordRepository } from './dbRepository.js'; +import { PasswordApplet } from './uiComponents.js'; + +let applet; +let repository; + +export function init() { + // Initialisation minimale +} + +export function enable() { + repository = new PasswordRepository(); + repository.init(); + + applet = new PasswordApplet(repository); + + Main.panel.addToStatusArea('password-applet', applet, 1, 'right'); +} + +export function disable() { + if (applet) { + applet.destroy(); + applet = null; + } + repository = null; +} diff --git a/password-applet@gilles/metadata.json b/password-applet@gilles/metadata.json index e69de29..1e9128f 100644 --- a/password-applet@gilles/metadata.json +++ b/password-applet@gilles/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": "password-applet@gilles", + "name": "Password Applet", + "description": "Mini gestionnaire de mots de passe dans la barre supérieure.", + "shell-version": ["45", "46"], + "version": 1, + "settings-schema": "org.gnome.shell.extensions.password-applet", + "stylesheet": "stylesheet.css" +} diff --git a/password-applet@gilles/password_db.py b/password-applet@gilles/password_db.py old mode 100644 new mode 100755 index e69de29..686cd87 --- a/password-applet@gilles/password_db.py +++ b/password-applet@gilles/password_db.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Helper SQLite pour l’extension Password Applet. + +Commandes : + - init : initialise la base + - list : renvoie les entrées + - add : ajoute une entrée + +Note : + Le champ password_enc stocke le mot de passe en clair (MVP). +""" + +import json +import os +import sqlite3 +import sys +from datetime import datetime + +APP_DATA_DIR = os.path.join( + os.path.expanduser('~'), + '.local', 'share', 'gnome-shell', 'password-applet' +) +DB_PATH = os.path.join(APP_DATA_DIR, 'passwords.db') + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + username TEXT NOT NULL, + password_enc TEXT NOT NULL, + url TEXT, + notes TEXT, + is_favorite INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +""" + +def ensure_db(): + os.makedirs(APP_DATA_DIR, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() + +def encrypt_password(plain: str) -> str: + return plain # Pour le MVP, rien + +def cmd_init(): + ensure_db() + +def cmd_list(): + ensure_db() + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + cur = conn.execute( + "SELECT id, label, username, password_enc, url, notes, " + "is_favorite, created_at, updated_at " + "FROM entries ORDER BY created_at DESC" + ) + rows = [dict(row) for row in cur.fetchall()] + finally: + conn.close() + + print(json.dumps(rows)) + +def cmd_add(): + ensure_db() + raw = sys.stdin.read() + data = json.loads(raw or '{}') + + label = (data.get('label') or '').strip() + username = (data.get('username') or '').strip() + password = (data.get('password') or '').strip() + url = (data.get('url') or '').strip() or None + notes = (data.get('notes') or '').strip() or None + + if not label or not username or not password: + print('Missing required fields', file=sys.stderr) + sys.exit(1) + + password_enc = encrypt_password(password) + now = datetime.utcnow().isoformat(timespec='seconds') + 'Z' + + conn = sqlite3.connect(DB_PATH) + try: + conn.execute( + "INSERT INTO entries (label, username, password_enc, url, notes, " + "is_favorite, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)", + (label, username, password_enc, url, notes, now, now) + ) + conn.commit() + finally: + conn.close() + +def main(argv): + if len(argv) < 2: + print("Usage: password_db.py [init|list|add]") + return 1 + + cmd = argv[1] + + if cmd == "init": + cmd_init() + elif cmd == "list": + cmd_list() + elif cmd == "add": + cmd_add() + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/password-applet@gilles/stylesheet.css b/password-applet@gilles/stylesheet.css new file mode 100644 index 0000000..34a2bcb --- /dev/null +++ b/password-applet@gilles/stylesheet.css @@ -0,0 +1,70 @@ +/* stylesheet.css */ +/* Style du mini gestionnaire de mots de passe GNOME Shell */ + +/* Lien cliquable (URL présente) */ +.password-applet-label-link { + color: #5da5ff; +} + +/* Nom d’utilisateur */ +.password-applet-username { + margin-left: 8px; +} + +/* Mot de passe masqué */ +.password-applet-password { + margin-left: 8px; + font-family: monospace; + opacity: 0.7; +} + +/* Boutons copier (User / Password) */ +.password-applet-copy-btn { + margin-left: 6px; + padding: 0 6px; + border-radius: 6px; + background-color: #3a3a3a; + color: white; +} + +.password-applet-copy-btn:hover { + background-color: #4d4d4d; +} + +/* Champs de formulaire */ +.password-applet-entry { + min-width: 220px; + padding: 4px; + border-radius: 6px; + background-color: rgba(255,255,255,0.08); +} + +/* Bouton Enregistrer */ +.password-applet-save-btn { + padding: 4px 12px; + margin-right: 6px; + background-color: #4caf50; + color: white; + border-radius: 6px; +} + +.password-applet-save-btn:hover { + background-color: #45a047; +} + +/* Bouton Annuler */ +.password-applet-cancel-btn { + padding: 4px 12px; + background-color: #d9534f; + color: white; + border-radius: 6px; +} + +.password-applet-cancel-btn:hover { + background-color: #c9302c; +} + +/* Ligne + Formulaire */ +.password-applet-form-row { + margin-bottom: 6px; +} diff --git a/password-applet@gilles/uiComponents.js b/password-applet@gilles/uiComponents.js index e69de29..a223226 100644 --- a/password-applet@gilles/uiComponents.js +++ b/password-applet@gilles/uiComponents.js @@ -0,0 +1,178 @@ +// uiComponents.js +// UI du mini gestionnaire de mots de passe. + +import St from 'gi://St'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +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 * as ExtensionUtils from 'resource:///org/gnome/shell/misc/extensionUtils.js'; + +const Clutter = imports.gi.Clutter; +const Clipboard = St.Clipboard.get_default(); +const ClipboardType = St.ClipboardType.CLIPBOARD; + +function copyToClipboard(text) { + if (text) + Clipboard.set_text(ClipboardType, text); +} + +export class PasswordEntryRow extends PopupMenu.PopupBaseMenuItem { + constructor(entry) { + super({ reactive: true }); + + const box = new St.BoxLayout({ vertical: false, x_expand: true }); + + let labelWidget; + if (entry.url) { + labelWidget = new St.Label({ + text: entry.label, + style_class: 'password-applet-label-link', + x_expand: true + }); + labelWidget.clutter_text.underline = true; + labelWidget.connect('button-press-event', () => { + Gio.AppInfo.launch_default_for_uri(entry.url, null); + }); + } else { + labelWidget = new St.Label({ text: entry.label, x_expand: true }); + } + + box.add_child(labelWidget); + + box.add_child(new St.Label({ text: entry.username, x_expand: true })); + box.add_child(new St.Label({ text: '••••••••' })); + + const uBtn = new St.Button({ label: 'U' }); + uBtn.connect('clicked', () => copyToClipboard(entry.username)); + box.add_child(uBtn); + + const pBtn = new St.Button({ label: 'P' }); + pBtn.connect('clicked', () => copyToClipboard(entry.password_enc)); + box.add_child(pBtn); + + this.actor.add_child(box); + } +} + +export class PasswordListSection extends PopupMenu.PopupMenuSection { + setEntries(entries) { + this.removeAll(); + + if (!entries.length) { + this.addMenuItem(new PopupMenu.PopupMenuItem( + 'Aucune entrée', { reactive: false } + )); + return; + } + + for (const e of entries) + this.addMenuItem(new PasswordEntryRow(e)); + } +} + +export class AddEntrySection extends PopupMenu.PopupMenuSection { + constructor(repo, refreshCb) { + super(); + this._repo = repo; + this._refreshCb = refreshCb; + this._showAddButton(); + } + + _showAddButton() { + this.removeAll(); + const item = new PopupMenu.PopupMenuItem('➕ Ajouter une entrée'); + item.connect('activate', () => this._showForm()); + this.addMenuItem(item); + } + + _showForm() { + this.removeAll(); + const wrapper = new PopupMenu.PopupBaseMenuItem({ reactive: false }); + const box = new St.BoxLayout({ vertical: true }); + + const mkEntry = hint => + new St.Entry({ hint_text: hint, style_class: 'password-applet-entry' }); + + const label = mkEntry("Label"); + const user = mkEntry("User"); + const pwd = mkEntry("Password"); + const url = mkEntry("URL"); + const notes = mkEntry("Notes"); + + const mkRow = (name, widget) => { + const row = new St.BoxLayout(); + row.add_child(new St.Label({ text: name + " : " })); + row.add_child(widget); + return row; + }; + + box.add_child(mkRow("Label", label)); + box.add_child(mkRow("User", user)); + box.add_child(mkRow("Password", pwd)); + box.add_child(mkRow("URL", url)); + box.add_child(mkRow("Notes", notes)); + + const btnRow = new St.BoxLayout(); + + const save = new St.Button({ label: "Enregistrer" }); + save.connect('clicked', () => { + const entry = { + label: label.get_text().trim(), + username: user.get_text().trim(), + password: pwd.get_text().trim(), + url: url.get_text().trim(), + notes: notes.get_text().trim() + }; + + if (!entry.label || !entry.username || !entry.password) { + Main.notify("Password Applet", "Champs obligatoires manquants"); + return; + } + + this._repo.addEntry(entry); + this._refreshCb(); + this._showAddButton(); + }); + + const cancel = new St.Button({ label: "Annuler" }); + cancel.connect('clicked', () => this._showAddButton()); + + btnRow.add_child(save); + btnRow.add_child(cancel); + + box.add_child(btnRow); + wrapper.actor.add_child(box); + + this.addMenuItem(wrapper); + } +} + +export class PasswordApplet extends PanelMenu.Button { + constructor(repo) { + super(0.0, 'PasswordApplet', false); + + this._repo = repo; + + this.add_child(new St.Icon({ + icon_name: 'dialog-password-symbolic', + style_class: 'system-status-icon' + })); + + this._list = new PasswordListSection(); + this.menu.addMenuItem(this._list); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._add = new AddEntrySection(repo, () => this._reload()); + this.menu.addMenuItem(this._add); + + this._reload(); + } + + _reload() { + const entries = this._repo.listEntries(); + this._list.setEntries(entries); + } +}