7 Commits
Author SHA1 Message Date
tuxgyverandClaude Opus 5 6fd7e71082 chore: version 0.1.5
Livre la sortie de domaine (bouton et commande `leave`), le nettoyage
optionnel des paquets sous Linux, le champ « Nom de la machine » en lecture
seule, l'assainissement de la sortie PowerShell et le controle du SRV
`_ldap._tcp.dc._msdcs`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:15:35 +02:00
tuxgyverandClaude Opus 5 eba4d0b843 feat: sortie de domaine, et corrections issues d'un echec reel de jointure
Sortie de domaine (demandee) :
- Bouton « Quitter le domaine » et commande CLI `leave`, via Remove-Computer
  sous Windows et realm leave sous Linux.
- Nettoyage optionnel des paquets sous Linux. packagekit est exclu de la
  liste : il sert aussi au gestionnaire de logiciels du bureau.
- Le champ « Nom de la machine » passe en lecture seule : il ne peut pas
  etre modifie depuis cette fenetre.

Corrections tirees de la sortie PowerShell rapportee :
- Les barres de progression etaient serialisees en CLIXML et polluaient le
  journal ; les accents arrivaient illisibles (console OEM lue en cp1252).
  Progression desactivee, console en UTF-8, CLIXML residuel filtre.
- Le marqueur interne JOIN_FAILURE: n'apparait plus dans le message affiche.

Diagnostic :
- Ajout du SRV `_ldap._tcp.dc._msdcs`, celui que DsGetDcName utilise pour
  localiser un DC. Son absence donne exactement l'erreur rencontree, et le
  diagnostic ne le verifiait pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:11:20 +02:00
tuxgyverandClaude Opus 5 04a7b5dc64 chore: version 0.1.4
Livre le correctif de jointure Windows : le script PowerShell n'etait jamais
execute, la jointure echouait sans message avec un code retour 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:12:17 +02:00
tuxgyverandClaude Opus 5 3d4d6de0f8 fix: la jointure Windows n'executait jamais le script PowerShell
Le script etait envoye sur stdin de `powershell -Command -`, qui analyse
l'entree ligne par ligne. Le `}` fermant le try/catch n'etait suivi d'aucun
saut de ligne : le parseur gardait l'instruction incomplete en attente et la
jetait a la fermeture de stdin. PowerShell rendait la main avec le code 0
sans avoir appele Add-Computer, et l'utilisateur ne voyait qu'un « echec de
la jointure (code retour 0) » sans explication.

- Transmission du script d'un seul bloc via `-EncodedCommand` (base64
  UTF-16LE), ce qui supprime toute analyse ligne par ligne et propage
  correctement les codes de sortie. Le mot de passe reste hors de la ligne
  de commande : il ne transite que par l'environnement du sous-processus.
- Report de la sortie brute de PowerShell quand aucun marqueur n'est trouve,
  pour qu'un echec inattendu reste diagnosticable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:09:54 +02:00
tuxgyverandClaude Opus 5 bc080fd76a chore: version 0.1.3
Premiere version publiee avec un binaire Windows fonctionnel : le .exe
produit sous Wine demarrait sans plugin de plateforme Qt (voir CHANGELOG).
Ajoute egalement le Makefile, le README et la cible de publication
multi-depots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:53:01 +02:00
tuxgyverandClaude Opus 5 59b6c75942 fix: le .exe Windows demarrait sans plugin de plateforme Qt
Le hook PySide6 de PyInstaller importe QtCore pour localiser les plugins Qt.
Depuis Qt 6.10, Qt6Core.dll est lie a l'ICU de Windows (icuuc.dll, System32),
que Wine n'implemente pas : l'import echouait, le hook ne collectait aucun
plugin, et le binaire produit refusait de demarrer sous Windows avec
« could not initialize Qt platform plugin "windows" ».

- La build Wine epingle PySide6<6.10 (PYSIDE6_CONSTRAINT pour surcharger).
- Verification avant compilation que PySide6 s'importe reellement.
- Verification apres compilation que qwindows.dll est embarque.
- Reinstallation des dependances declenchee par empreinte de requirements.txt
  et de la contrainte, au lieu de la seule presence de PyInstaller.

Binaire verifie sous Wine : GUI (fenetre a trois onglets) et CLI OK, 45 Mo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:46:00 +02:00
tuxgyverandClaude Opus 5 599192fd67 build: Makefile categorise, README et compilation Windows depuis Linux
- Makefile avec aide auto-documentee par categories (`make help`) :
  installation, utilisation, compilation, publication, nettoyage.
- packaging/build_windows_wine.sh : produit le .exe Windows natif depuis
  Linux via un PyInstaller Windows execute dans un prefixe Wine dedie
  (PyInstaller ne cross-compilant pas). Installation des dependances
  idempotente, builds suivants incrementaux.
- README.md : fonctionnalites, usage GUI/CLI, compilation, publication.
- `make publish` pousse branche et tags vers tous les depots configures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:11:39 +02:00
13 changed files with 925 additions and 79 deletions
+2
View File
@@ -5,3 +5,5 @@ dist/
*.spec *.spec
.build-venv-linux/ .build-venv-linux/
.build-venv-windows/ .build-venv-windows/
.build-wine/
.build-wine-cache/
+62
View File
@@ -1,5 +1,67 @@
# Changelog # Changelog
## [0.1.5] - 2026-09-09
### Added
- Bouton « Quitter le domaine » dans l'interface et commande `leave` en ligne de
commande. Sous Windows via `Remove-Computer` (le poste rejoint le groupe de
travail WORKGROUP), sous Linux via `realm leave`.
- Option de nettoyage à la sortie du domaine sous Linux (case à cocher, ou
`--remove-packages`) : désinstalle `realmd`, `sssd`, `adcli` et
`samba-common-bin`. `packagekit` est délibérément conservé — il est installé
comme prérequis mais sert aussi au gestionnaire de logiciels du bureau.
- Vérification de l'enregistrement SRV `_ldap._tcp.dc._msdcs`, celui que Windows
interroge (DsGetDcName) pour localiser un contrôleur lors d'une jointure. Son
absence provoque « Le domaine spécifié n'existe pas ou n'a pas pu être
contacté » alors même que le domaine résout et que les DC répondent au ping :
le diagnostic ne pouvait pas le signaler.
### Changed
- Le champ « Nom de la machine » est désormais affiché en lecture seule : il ne
peut pas être modifié depuis cette fenêtre (sous Linux `realm join` reprend le
nom d'hôte système, sous Windows le renommage relève des paramètres système).
Une infobulle indique la commande à utiliser selon la plateforme.
### Fixed
- La sortie de PowerShell était polluée par la sérialisation `#< CLIXML` de ses
barres de progression, et les accents des messages d'erreur arrivaient
illisibles (console en codepage OEM lue comme du cp1252). Les indicateurs de
progression sont désactivés, la console bascule en UTF-8, et les fragments
CLIXML résiduels sont filtrés.
- Le marqueur interne `JOIN_FAILURE:` n'est plus affiché en préfixe du message
d'erreur montré à l'utilisateur.
## [0.1.4] - 2026-09-08
### Fixed
- La jointure de domaine sous Windows échouait sans aucun message, avec un code
retour 0. Le script PowerShell était transmis sur l'entrée standard de
`powershell -Command -`, qui analyse stdin ligne par ligne : le bloc
`try`/`catch` final, dépourvu de saut de ligne terminal, restait en attente
dans le parseur et n'était jamais exécuté. PowerShell rendait donc la main
sans avoir tenté la jointure. Le script est désormais transmis d'un bloc via
`-EncodedCommand`.
- En l'absence de marqueur de succès comme d'échec, le message d'erreur se
réduisait à « code retour 0 », impossible à diagnostiquer. La sortie brute de
PowerShell est maintenant reportée à l'utilisateur (le mot de passe ne
transitant que par l'environnement, il n'y apparaît jamais).
## [0.1.3] - 2026-09-08
### Fixed
- Le binaire Windows produit sous Wine ne démarrait pas (« could not initialize
Qt platform plugin "windows" ») : le hook PySide6 de PyInstaller importe
QtCore pour localiser les plugins Qt, or Qt 6.10+ lie `Qt6Core.dll` à l'ICU
de Windows (`icuuc.dll`), absent de Wine. L'import échouait et aucun plugin
n'était embarqué. La compilation Windows épingle désormais `PySide6<6.10` et
vérifie explicitement l'import de PySide6 puis la présence de `qwindows.dll`.
### Added
- `Makefile` avec aide auto-documentée par catégories (`make help`).
- `packaging/build_windows_wine.sh` : production du `.exe` Windows depuis Linux.
- `README.md`.
- `make publish` : pousse branche et tags vers tous les dépôts Gitea configurés.
## [0.1.2] - 2026-09-04 ## [0.1.2] - 2026-09-04
### Changed ### Changed
+87
View File
@@ -0,0 +1,87 @@
# JoinDomainGUI — diagnostic reseau AD + jointure de domaine
# `make` seul (ou `make help`) affiche les cibles disponibles.
.DEFAULT_GOAL := help
VENV := .build-venv-linux
PY := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
VERSION := $(shell sed -n 's/^__version__ = "\(.*\)"/\1/p' domainjoin/__init__.py)
DOMAIN ?= h3adm.lan
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
REMOTES := $(shell git remote)
##@ 📦 Installation
$(VENV)/bin/python:
python3 -m venv --system-site-packages $(VENV)
$(PIP) install --upgrade pip -q
$(PIP) install -q -r requirements.txt
.PHONY: install
install: $(VENV)/bin/python ## Cree l'environnement virtuel et installe les dependances
@echo "✅ Environnement pret : $(VENV)"
##@ 🚀 Utilisation
.PHONY: run
run: install ## Lance l'interface graphique
$(PY) main.py
.PHONY: diagnose
diagnose: install ## Lance le diagnostic reseau en CLI (make diagnose DOMAIN=exemple.lan)
$(PY) main.py diagnose --domain $(DOMAIN)
##@ 🏗️ Compilation
.PHONY: build
build: build-linux build-windows ## Compile les binaires Linux et Windows
.PHONY: build-linux
build-linux: ## Compile le binaire Linux autonome (dist/JoinDomainGUI)
./packaging/build_linux.sh
.PHONY: build-windows
build-windows: ## Compile le .exe Windows depuis Linux via Wine (dist/windows/JoinDomainGUI.exe)
./packaging/build_windows_wine.sh
##@ 🚢 Publication
.PHONY: remotes
remotes: ## Liste les depots distants configures
@git remote -v | awk '$$3 == "(push)" { printf " %-10s %s\n", $$1, $$2 }'
.PHONY: publish
publish: ## Pousse la branche courante et les tags vers tous les depots (Gitea)
@git diff --quiet HEAD || { echo "❌ Modifications non commitees — commit avant de publier."; exit 1; }
@for remote in $(REMOTES); do \
echo "🚀 $$remote ← $(BRANCH)"; \
git push $$remote $(BRANCH) || exit 1; \
git push $$remote --tags || exit 1; \
done
@echo "✅ Publie sur : $(REMOTES)"
##@ 🧹 Nettoyage
.PHONY: clean
clean: ## Supprime les artefacts de compilation et les caches Python
rm -rf build dist
find . -path ./$(VENV) -prune -o -name '__pycache__' -type d -print0 | xargs -0 rm -rf
.PHONY: distclean
distclean: clean ## Nettoyage complet : + environnements virtuels, prefixe Wine et telechargements
rm -rf $(VENV) .build-venv-windows .build-wine .build-wine-cache
##@ ️ Aide
.PHONY: version
version: ## Affiche la version courante
@echo "JoinDomainGUI $(VERSION)"
.PHONY: help
help: ## Affiche cette aide
@echo "JoinDomainGUI $(VERSION)"
@awk 'BEGIN {FS = ":.*?## "} \
/^[a-zA-Z0-9_.-]+:.*?##/ { printf " \033[36m%-16s\033[0m %s\n", $$1, $$2 } \
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)
@echo ""
+128
View File
@@ -0,0 +1,128 @@
# JoinDomainGUI
Outil de **diagnostic réseau Active Directory** et de **jointure de domaine**,
avec interface graphique (PySide6) et interface en ligne de commande.
Fonctionne sous **Linux** (`realmd`) et sous **Windows** (PowerShell `Add-Computer`),
le backend étant choisi automatiquement selon la plateforme.
## Fonctionnalités
- **Diagnostic réseau AD** : résolution DNS du domaine, enregistrements SRV
(LDAP / Kerberos), ping des contrôleurs de domaine, test des ports AD
(53, 88, 389, 445, 464, 636, 3268), synchronisation NTP et présence des
outils système requis.
- **Jointure de domaine** avec unité d'organisation optionnelle et détection du
besoin de redémarrage. Le nom de la machine est affiché mais non modifiable :
il vient du nom d'hôte système (Linux) ou des paramètres système (Windows).
- **Sortie de domaine** (`Remove-Computer` sous Windows, `realm leave` sous
Linux), avec sous Linux une option de désinstallation des paquets devenus
inutiles.
- **Installation des prérequis Linux** manquants en un clic ou une commande
(`realmd`, `sssd`, `adcli`, `samba-common-bin`, `packagekit` via `apt-get`).
- **Interface graphique** à trois onglets : Diagnostic réseau, Rejoindre le
domaine, À propos.
- **Interface en ligne de commande** utilisable sans environnement graphique
(SSH, déploiement scripté) : la CLI n'importe jamais PySide6.
- **Le mot de passe ne transite jamais par la ligne de commande** : il est
transmis au sous-processus via stdin ou son environnement, et n'apparaît
donc pas dans `ps` ni dans l'historique du shell.
## Installation
```bash
make install # crée l'environnement virtuel et installe les dépendances
```
Dépendances : Python 3.11+, `PySide6`, `dnspython`, `ntplib`.
## Utilisation
### Interface graphique
```bash
make run # depuis les sources
sudo ./dist/JoinDomainGUI # binaire autonome Linux
```
La jointure de domaine nécessite les droits root / administrateur ; le
diagnostic, non.
### Ligne de commande
```bash
# Diagnostic réseau
python main.py diagnose --domain exemple.lan
make diagnose DOMAIN=exemple.lan
# Jointure de domaine (root / administrateur requis)
echo 'motdepasse' | sudo python main.py join \
--domain exemple.lan --username administrateur --password-stdin
# Sortie de domaine (--username optionnel sous Linux, requis sous Windows)
sudo python main.py leave --domain exemple.lan --remove-packages
# Installation des prérequis système (Linux / apt uniquement)
sudo python main.py install-prereqs
```
Sans mot de passe fourni, il est demandé de façon interactive et masquée.
`--password` existe mais est déconseillé (visible dans `ps` et l'historique).
Code de sortie : `0` si tout va bien, `1` si un test échoue ou si la jointure
échoue, `2` en cas de droits insuffisants ou de backend indisponible.
## Compilation des binaires
```bash
make build-linux # -> dist/JoinDomainGUI (ELF autonome)
make build-windows # -> dist/windows/JoinDomainGUI.exe (PE64 autonome)
make build # les deux
```
PyInstaller ne fait pas de cross-compilation. Le binaire Windows est donc
produit depuis Linux en exécutant un vrai PyInstaller Windows dans un
préfixe **Wine** dédié (`packaging/build_windows_wine.sh`) : le `.exe` obtenu
est un binaire Windows natif. Le premier appel télécharge un Python Windows
embarqué et les dépendances (plusieurs minutes) ; les suivants sont
incrémentaux.
Sur une machine Windows, `packaging/build_windows.bat` fait le même travail
nativement.
La build Wine épingle **Qt 6.9** (`PySide6<6.10`) : à partir de Qt 6.10,
`Qt6Core.dll` dépend de l'ICU fourni par Windows (`icuuc.dll` dans `System32`),
que Wine n'implémente pas. PySide6 y devient inimportable — et le hook PySide6
de PyInstaller, qui importe QtCore pour localiser les plugins Qt, échoue alors
*silencieusement* et produit un `.exe` sans plugin de plateforme, qui refuse de
démarrer sous Windows (« could not initialize Qt platform plugin windows »). Le
script vérifie donc, avant compilation, que PySide6 s'importe, et après
compilation, que `qwindows.dll` est bien embarqué.
## Publication
```bash
make publish # pousse la branche courante et les tags vers tous les dépôts
```
Le projet est publié sur deux instances Gitea :
[git.tuxtech.fr](https://git.tuxtech.fr/tuxgyver/JoinDomainGUI) (`origin`) et
[git.h3campus.fr](https://git.h3campus.fr/Johnny/JoinDomainGUI) (`h3campus`).
## Structure
```
main.py Point d'entrée : GUI sans argument, CLI sinon
domainjoin/
├── cli.py Interface en ligne de commande
├── gui.py Interface PySide6
├── diagnostics.py Moteur de diagnostic réseau
├── worker.py Exécution en arrière-plan pour la GUI
├── privileges.py Détection des droits root/administrateur
└── backend/
├── base.py Interface commune de jointure
├── linux_realmd.py Backend Linux (realmd / adcli)
└── windows_addcomputer.py Backend Windows (Add-Computer)
packaging/ Scripts de compilation PyInstaller
```
Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique des versions.
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.2" __version__ = "0.1.5"
+20
View File
@@ -38,6 +38,26 @@ class DomainJoinBackend(ABC):
de ligne de commande : il est transmis via l'entrée standard du de ligne de commande : il est transmis via l'entrée standard du
sous-processus pour ne pas apparaître dans la liste des processus.""" sous-processus pour ne pas apparaître dans la liste des processus."""
def can_leave(self) -> bool:
"""True si ce backend sait faire quitter le domaine au poste."""
return False
def leave(
self,
domain: str,
username: str,
password: str,
remove_packages: bool = False,
on_output: Optional[OutputCallback] = None,
) -> JoinResult:
"""Fait quitter le domaine au poste. Comme pour join(), le mot de passe
ne passe jamais par la ligne de commande.
`remove_packages` demande en plus de désinstaller les paquets système
devenus inutiles — sans effet sur les backends qui n'en installent pas.
"""
return JoinResult(success=False, message="Sortie de domaine non prise en charge pour ce backend.")
def can_install_prerequisites(self) -> bool: def can_install_prerequisites(self) -> bool:
"""True si ce backend sait installer automatiquement les outils manquants.""" """True si ce backend sait installer automatiquement les outils manquants."""
return False return False
+109 -9
View File
@@ -12,6 +12,12 @@ from .base import DomainJoinBackend, JoinResult, OutputCallback
REQUIRED_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin", "packagekit"] REQUIRED_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin", "packagekit"]
INSTALL_HINT = "sudo apt install " + " ".join(REQUIRED_PACKAGES) INSTALL_HINT = "sudo apt install " + " ".join(REQUIRED_PACKAGES)
# Paquets retires lors d'une sortie de domaine avec nettoyage. `packagekit` est
# volontairement exclu de cette liste : il est installe comme prerequis mais sert
# aussi au gestionnaire de logiciels du bureau, et le retirer casserait des
# fonctions sans rapport avec le domaine.
REMOVABLE_PACKAGES = ["realmd", "sssd", "adcli", "samba-common-bin"]
class LinuxRealmdBackend(DomainJoinBackend): class LinuxRealmdBackend(DomainJoinBackend):
def is_available(self) -> tuple[bool, str]: def is_available(self) -> tuple[bool, str]:
@@ -22,18 +28,13 @@ class LinuxRealmdBackend(DomainJoinBackend):
def can_install_prerequisites(self) -> bool: def can_install_prerequisites(self) -> bool:
return shutil.which("apt-get") is not None return shutil.which("apt-get") is not None
def install_prerequisites(self, on_output: Optional[OutputCallback] = None) -> JoinResult: def _run_apt(self, commands: list[list[str]], emit: OutputCallback) -> Optional[JoinResult]:
def emit(line: str) -> None: """Enchaîne des commandes apt-get. Retourne None si tout a réussi,
if on_output: sinon le JoinResult d'échec décrivant la commande fautive."""
on_output(line)
if shutil.which("apt-get") is None:
return JoinResult(success=False, message="apt-get est introuvable sur ce système.")
env = os.environ.copy() env = os.environ.copy()
env["DEBIAN_FRONTEND"] = "noninteractive" env["DEBIAN_FRONTEND"] = "noninteractive"
for cmd in (["apt-get", "update"], ["apt-get", "install", "-y", *REQUIRED_PACKAGES]): for cmd in commands:
emit(f"$ {' '.join(cmd)}") emit(f"$ {' '.join(cmd)}")
try: try:
process = subprocess.Popen( process = subprocess.Popen(
@@ -58,6 +59,21 @@ class LinuxRealmdBackend(DomainJoinBackend):
message=f"Échec de « {' '.join(cmd)} » (code retour {returncode}). " message=f"Échec de « {' '.join(cmd)} » (code retour {returncode}). "
"Vérifiez que l'application tourne avec les droits root.", "Vérifiez que l'application tourne avec les droits root.",
) )
return None
def install_prerequisites(self, on_output: Optional[OutputCallback] = None) -> JoinResult:
def emit(line: str) -> None:
if on_output:
on_output(line)
if shutil.which("apt-get") is None:
return JoinResult(success=False, message="apt-get est introuvable sur ce système.")
failure = self._run_apt(
[["apt-get", "update"], ["apt-get", "install", "-y", *REQUIRED_PACKAGES]], emit
)
if failure:
return failure
available, _ = self.is_available() available, _ = self.is_available()
if available: if available:
@@ -132,3 +148,87 @@ class LinuxRealmdBackend(DomainJoinBackend):
success=False, success=False,
message=f"Échec de la jointure au domaine (code retour {returncode}). Voir le journal ci-dessus.", message=f"Échec de la jointure au domaine (code retour {returncode}). Voir le journal ci-dessus.",
) )
def can_leave(self) -> bool:
return True
def leave(
self,
domain: str,
username: str,
password: str,
remove_packages: bool = False,
on_output: Optional[OutputCallback] = None,
) -> JoinResult:
available, reason = self.is_available()
if not available:
return JoinResult(success=False, message=reason)
def emit(line: str) -> None:
if on_output:
on_output(line)
# `realm leave` sait travailler sans identifiants (il se sert du compte
# machine local). Fournir un compte permet en plus de supprimer l'objet
# ordinateur dans l'annuaire ; on ne l'impose donc pas.
cmd = ["realm", "-v", "leave"]
if username:
cmd += ["--user", username]
cmd.append(domain)
emit(f"$ {' '.join(cmd)}")
try:
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
except OSError as exc:
return JoinResult(success=False, message=f"Impossible de lancer realm : {exc}")
try:
if username:
process.stdin.write(password + "\n")
process.stdin.close()
except BrokenPipeError:
pass
if process.stdout:
for line in process.stdout:
emit(line.rstrip("\n"))
if process.wait() != 0:
return JoinResult(
success=False,
message=f"Échec de la sortie du domaine {domain}. Voir le journal ci-dessus.",
)
message = f"Le poste a quitté le domaine {domain}."
if remove_packages:
if shutil.which("apt-get") is None:
message += " Les paquets n'ont pas été retirés : apt-get est introuvable."
else:
emit("Retrait des paquets devenus inutiles...")
failure = self._run_apt(
[
["apt-get", "remove", "--purge", "-y", *REMOVABLE_PACKAGES],
["apt-get", "autoremove", "--purge", "-y"],
],
emit,
)
if failure:
# La sortie du domaine, elle, a bien eu lieu : on le dit
# plutot que de faire passer l'ensemble pour un echec.
return JoinResult(
success=True,
message=f"{message} En revanche le nettoyage des paquets a échoué : "
f"{failure.message}",
needs_reboot=True,
)
message += " Paquets retirés : " + ", ".join(REMOVABLE_PACKAGES) + "."
return JoinResult(success=True, message=message, needs_reboot=True)
+205 -66
View File
@@ -1,7 +1,9 @@
"""Backend de jointure de domaine pour Windows, via la cmdlet PowerShell Add-Computer.""" """Backend de jointure/sortie de domaine pour Windows, via les cmdlets PowerShell
`Add-Computer` et `Remove-Computer`."""
from __future__ import annotations from __future__ import annotations
import base64
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -10,8 +12,12 @@ from typing import Optional
from .base import DomainJoinBackend, JoinResult, OutputCallback from .base import DomainJoinBackend, JoinResult, OutputCallback
_PASSWORD_ENV_VAR = "DOMAINJOIN_PWD" _PASSWORD_ENV_VAR = "DOMAINJOIN_PWD"
_SUCCESS_MARKER = "JOIN_SUCCESS" _SUCCESS_MARKER = "DOMAINJOIN_OK"
_FAILURE_PREFIX = "JOIN_FAILURE:" _FAILURE_PREFIX = "DOMAINJOIN_ERROR:"
# En-tête du flux d'objets sérialisés que PowerShell émet sur sa sortie d'erreur
# lorsqu'elle est redirigée. Ce n'est pas un message pour l'utilisateur.
_CLIXML_HEADER = "#< CLIXML"
def _ps_quote(value: str) -> str: def _ps_quote(value: str) -> str:
@@ -19,38 +25,177 @@ def _ps_quote(value: str) -> str:
return value.replace("'", "''") return value.replace("'", "''")
def _encode_command(script: str) -> str:
"""Encode un script pour `powershell -EncodedCommand` (base64 d'UTF-16LE).
Le script est ainsi transmis comme un bloc unique et atomique. L'alternative
`-Command -` fait analyser l'entrée standard ligne par ligne : la dernière
instruction d'un bloc `try`/`catch` restait en attente dans le parseur et
n'était jamais exécutée à la fermeture de stdin — PowerShell rendait alors
la main avec le code 0 sans avoir rien fait. Le mot de passe reste hors du
script (variable d'environnement) : la ligne de commande ne l'expose pas.
"""
return base64.b64encode(script.encode("utf-16-le")).decode("ascii")
def _is_noise(line: str) -> bool:
"""Écarte la sérialisation CLIXML que PowerShell mêle à sa sortie."""
stripped = line.strip()
return stripped.startswith(_CLIXML_HEADER) or stripped.startswith("<Objs ")
_PREAMBLE = [
"$ErrorActionPreference = 'Stop'",
# Sans cela, les barres de progression (« Préparation des modules… ») sont
# serialisees en CLIXML sur la sortie d'erreur et polluent le journal.
"$ProgressPreference = 'SilentlyContinue'",
# La console Windows sort en codepage OEM (850/437) : sans cette bascule en
# UTF-8, les accents des messages d'erreur arrivent illisibles cote Python.
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
]
def _credential_lines(username: str) -> list[str]:
return [
f"$username = '{_ps_quote(username)}'",
f"$password = $env:{_PASSWORD_ENV_VAR}",
"if ([string]::IsNullOrEmpty($password)) { "
f"Write-Output '{_FAILURE_PREFIX} mot de passe manquant.'; exit 2 }}",
"$securePwd = ConvertTo-SecureString -String $password -AsPlainText -Force",
"$cred = New-Object System.Management.Automation.PSCredential($username, $securePwd)",
]
def _guarded(body: list[str]) -> list[str]:
return [
"try {",
*[f" {line}" for line in body],
f" Write-Output '{_SUCCESS_MARKER}'",
"} catch {",
f" Write-Output ('{_FAILURE_PREFIX} ' + $_.Exception.Message)",
" exit 1",
"}",
]
class WindowsAddComputerBackend(DomainJoinBackend): class WindowsAddComputerBackend(DomainJoinBackend):
def is_available(self) -> tuple[bool, str]: def is_available(self) -> tuple[bool, str]:
if shutil.which("powershell") is None and shutil.which("powershell.exe") is None: if shutil.which("powershell") is None and shutil.which("powershell.exe") is None:
return False, "PowerShell est introuvable sur ce poste." return False, "PowerShell est introuvable sur ce poste."
return True, "PowerShell disponible." return True, "PowerShell disponible."
def _build_script(self, domain: str, username: str, ou: Optional[str], computer_name: Optional[str]) -> str: # ------------------------------------------------------------------ scripts
def _join_script(self, domain: str, username: str, ou: Optional[str], computer_name: Optional[str]) -> str:
lines = [ lines = [
"$ErrorActionPreference = 'Stop'", *_PREAMBLE,
f"$domain = '{_ps_quote(domain)}'", f"$domain = '{_ps_quote(domain)}'",
f"$username = '{_ps_quote(username)}'", *_credential_lines(username),
f"$password = $env:{_PASSWORD_ENV_VAR}",
"if ([string]::IsNullOrEmpty($password)) { Write-Output 'JOIN_FAILURE: mot de passe manquant.'; exit 2 }",
"$securePwd = ConvertTo-SecureString -String $password -AsPlainText -Force",
"$cred = New-Object System.Management.Automation.PSCredential($username, $securePwd)",
"$params = @{ DomainName = $domain; Credential = $cred; Force = $true; Restart = $false }", "$params = @{ DomainName = $domain; Credential = $cred; Force = $true; Restart = $false }",
] ]
if ou: if ou:
lines.append(f"$params['OUPath'] = '{_ps_quote(ou)}'") lines.append(f"$params['OUPath'] = '{_ps_quote(ou)}'")
if computer_name: if computer_name:
lines.append(f"$params['NewName'] = '{_ps_quote(computer_name)}'") lines.append(f"$params['NewName'] = '{_ps_quote(computer_name)}'")
lines += [ lines += _guarded(["Add-Computer @params -ErrorAction Stop"])
"try {",
" Add-Computer @params -ErrorAction Stop",
f" Write-Output '{_SUCCESS_MARKER}'",
"} catch {",
f" Write-Output ('{_FAILURE_PREFIX} ' + $_.Exception.Message)",
" exit 1",
"}",
]
return "\n".join(lines) return "\n".join(lines)
def _leave_script(self, username: str, workgroup: str) -> str:
lines = [
*_PREAMBLE,
*_credential_lines(username),
f"$workgroup = '{_ps_quote(workgroup)}'",
]
lines += _guarded(
[
"Remove-Computer -UnjoinDomainCredential $cred -WorkgroupName $workgroup "
"-Force -Restart:$false -ErrorAction Stop | Out-Null"
]
)
return "\n".join(lines)
# ------------------------------------------------------------------ execution
def _run_powershell(
self, script: str, password: str, emit: OutputCallback
) -> tuple[Optional[int], list[str]]:
"""Exécute un script PowerShell et retourne (code retour, lignes utiles).
Un code retour None signale que PowerShell n'a pas pu être lancé ; le
motif est alors la première ligne retournée.
"""
env = os.environ.copy()
env[_PASSWORD_ENV_VAR] = password
try:
process = subprocess.Popen(
[
"powershell",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
_encode_command(script),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except OSError as exc:
return None, [f"Impossible de lancer PowerShell : {exc}"]
finally:
# Le processus fils a déjà reçu sa copie de l'environnement : on
# efface le mot de passe de la nôtre sans l'en priver.
env[_PASSWORD_ENV_VAR] = ""
lines: list[str] = []
if process.stdout:
for raw in process.stdout:
line = raw.rstrip("\n")
if _is_noise(line):
continue
lines.append(line)
emit(line)
return process.wait(), lines
def _interpret(
self,
returncode: Optional[int],
lines: list[str],
success_message: str,
needs_reboot: bool,
) -> JoinResult:
if returncode is None:
return JoinResult(success=False, message=lines[0])
if returncode == 0 and any(line.strip() == _SUCCESS_MARKER for line in lines):
return JoinResult(success=True, message=success_message, needs_reboot=needs_reboot)
detail = next((l for l in lines if l.strip().startswith(_FAILURE_PREFIX)), None)
if detail:
# Le marqueur est un détail d'implémentation : l'utilisateur ne voit
# que le message d'erreur de PowerShell.
return JoinResult(success=False, message=detail.strip()[len(_FAILURE_PREFIX) :].strip())
# Sans ce report de la sortie brute, un echec inattendu se resumerait a
# « code retour 0 », indiagnosticable. Le mot de passe n'y apparait
# jamais : il ne transite que par l'environnement du sous-processus.
output = "\n".join(l for l in lines if l.strip() and l.strip() != _SUCCESS_MARKER)
return JoinResult(
success=False,
message=f"Échec (code retour {returncode}).\nSortie de PowerShell : "
f"{output.strip() or 'aucune sortie produite par PowerShell'}",
)
# ------------------------------------------------------------------ API
def join( def join(
self, self,
domain: str, domain: str,
@@ -64,55 +209,49 @@ class WindowsAddComputerBackend(DomainJoinBackend):
if not available: if not available:
return JoinResult(success=False, message=reason) return JoinResult(success=False, message=reason)
def emit(line: str) -> None: emit: OutputCallback = on_output or (lambda _line: None)
if on_output:
on_output(line)
script = self._build_script(domain, username, ou, computer_name)
emit(f"Exécution de Add-Computer pour le domaine {domain}...") emit(f"Exécution de Add-Computer pour le domaine {domain}...")
env = os.environ.copy() returncode, lines = self._run_powershell(
env[_PASSWORD_ENV_VAR] = password self._join_script(domain, username, ou, computer_name), password, emit
)
return self._interpret(
returncode,
lines,
f"Le poste a rejoint le domaine {domain} avec succès. Un redémarrage est requis.",
needs_reboot=True,
)
try: def can_leave(self) -> bool:
process = subprocess.Popen( return True
["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
stdin=subprocess.PIPE, def leave(
stdout=subprocess.PIPE, self,
stderr=subprocess.STDOUT, domain: str,
text=True, username: str,
bufsize=1, password: str,
env=env, remove_packages: bool = False,
creationflags=subprocess.CREATE_NO_WINDOW, on_output: Optional[OutputCallback] = None,
) -> JoinResult:
available, reason = self.is_available()
if not available:
return JoinResult(success=False, message=reason)
emit: OutputCallback = on_output or (lambda _line: None)
if remove_packages:
emit(
"Note : la désinstallation de paquets ne concerne que Linux. "
"Aucun composant Windows n'est retiré."
) )
except OSError as exc: emit(f"Exécution de Remove-Computer pour quitter le domaine {domain}...")
return JoinResult(success=False, message=f"Impossible de lancer PowerShell : {exc}")
finally:
env[_PASSWORD_ENV_VAR] = ""
try: returncode, lines = self._run_powershell(
process.stdin.write(script) self._leave_script(username, "WORKGROUP"), password, emit
process.stdin.close() )
except BrokenPipeError: return self._interpret(
pass returncode,
lines,
output_lines: list[str] = [] f"Le poste a quitté le domaine {domain} et rejoint le groupe de travail "
if process.stdout: "WORKGROUP. Un redémarrage est requis.",
for line in process.stdout: needs_reboot=True,
line = line.rstrip("\n") )
output_lines.append(line)
emit(line)
returncode = process.wait()
full_output = "\n".join(output_lines)
if returncode == 0 and _SUCCESS_MARKER in full_output:
return JoinResult(
success=True,
message=f"Le poste a rejoint le domaine {domain} avec succès. Un redémarrage est requis.",
needs_reboot=True,
)
failure_detail = next((l for l in output_lines if l.strip().startswith(_FAILURE_PREFIX)), None)
message = failure_detail or f"Échec de la jointure au domaine (code retour {returncode})."
return JoinResult(success=False, message=message)
+53
View File
@@ -166,6 +166,41 @@ def _cmd_join(args: argparse.Namespace) -> int:
return 0 if result.success else 1 return 0 if result.success else 1
def _cmd_leave(args: argparse.Namespace) -> int:
if (code := _require_elevation()) is not None:
return code
backend = get_backend()
available, reason = backend.is_available()
if not available:
_print_error(reason)
return 2
if not backend.can_leave():
_print_error("Ce backend ne prend pas en charge la sortie de domaine.")
return 2
password = _resolve_password(args) if args.username else ""
warning = f"Faire quitter le domaine « {args.domain} » à ce poste ?"
if args.remove_packages:
warning += " Les paquets système liés au domaine seront également désinstallés."
if not _confirm(warning, args.yes):
print(_colorize("Annulé.", _Color.YELLOW, sys.stdout))
return 1
result = backend.leave(
args.domain,
args.username or "",
password,
remove_packages=args.remove_packages,
on_output=print,
)
_print_result(result)
if result.success and result.needs_reboot:
print(_colorize("Un redémarrage est nécessaire pour finaliser la sortie.", _Color.YELLOW, sys.stdout))
return 0 if result.success else 1
def _cmd_install_prereqs(args: argparse.Namespace) -> int: def _cmd_install_prereqs(args: argparse.Namespace) -> int:
if (code := _require_elevation()) is not None: if (code := _require_elevation()) is not None:
return code return code
@@ -207,6 +242,24 @@ def build_parser() -> argparse.ArgumentParser:
p_join.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.") p_join.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.")
p_join.set_defaults(func=_cmd_join) p_join.set_defaults(func=_cmd_join)
p_leave = sub.add_parser("leave", help="Quitte le domaine (nécessite les droits root/administrateur).")
p_leave.add_argument("--domain", default=DEFAULT_DOMAIN, help=f"Domaine à quitter (défaut : {DEFAULT_DOMAIN}).")
p_leave.add_argument(
"--username",
default=None,
help="Compte autorisé à retirer la machine de l'annuaire. Optionnel sous Linux "
"(realm leave sait utiliser le compte machine local), requis sous Windows.",
)
p_leave.add_argument("--password", help="Mot de passe (déconseillé). Préférez --password-stdin.")
p_leave.add_argument("--password-stdin", action="store_true", help="Lit le mot de passe depuis l'entrée standard.")
p_leave.add_argument(
"--remove-packages",
action="store_true",
help="Désinstalle aussi les paquets système devenus inutiles (Linux/apt uniquement).",
)
p_leave.add_argument("-y", "--yes", action="store_true", help="Ne pas demander de confirmation.")
p_leave.set_defaults(func=_cmd_leave)
p_install = sub.add_parser( p_install = sub.add_parser(
"install-prereqs", help="Installe les outils système requis pour la jointure (Linux/apt uniquement)." "install-prereqs", help="Installe les outils système requis pour la jointure (Linux/apt uniquement)."
) )
+5
View File
@@ -59,6 +59,11 @@ AD_PORTS = {
SRV_RECORDS = [ SRV_RECORDS = [
"_ldap._tcp.{domain}", "_ldap._tcp.{domain}",
"_kerberos._tcp.{domain}", "_kerberos._tcp.{domain}",
# C'est ce dernier enregistrement que Windows interroge (DsGetDcName) pour
# localiser un controleur de domaine lors d'une jointure. Son absence donne
# « Le domaine specifie n'existe pas ou n'a pas pu etre contacte », alors
# meme que le domaine resout et que les DC repondent au ping.
"_ldap._tcp.dc._msdcs.{domain}",
"_kerberos._tcp.dc._msdcs.{domain}", "_kerberos._tcp.dc._msdcs.{domain}",
] ]
+113 -3
View File
@@ -9,6 +9,7 @@ from PySide6.QtCore import Qt
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QCheckBox,
QFormLayout, QFormLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -26,7 +27,8 @@ from PySide6.QtWidgets import (
from . import __version__, diagnostics, privileges from . import __version__, diagnostics, privileges
from .backend import get_backend from .backend import get_backend
from .worker import DiagnosticsWorker, InstallPrerequisitesWorker, JoinWorker from .backend.linux_realmd import REMOVABLE_PACKAGES
from .worker import DiagnosticsWorker, InstallPrerequisitesWorker, JoinWorker, LeaveWorker
DEFAULT_DOMAIN = "h3adm.lan" DEFAULT_DOMAIN = "h3adm.lan"
@@ -114,6 +116,7 @@ class JoinTab(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._worker: JoinWorker | None = None self._worker: JoinWorker | None = None
self._leave_worker: LeaveWorker | None = None
self.backend = get_backend() self.backend = get_backend()
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -126,14 +129,37 @@ class JoinTab(QWidget):
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password) self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.ou_edit = QLineEdit() self.ou_edit = QLineEdit()
self.ou_edit.setPlaceholderText("optionnel, ex : OU=Postes,DC=h3adm,DC=lan") self.ou_edit.setPlaceholderText("optionnel, ex : OU=Postes,DC=h3adm,DC=lan")
# Le nom de machine ne se change pas depuis cette fenetre : sous Linux
# `realm join` reprend le hostname systeme, et sous Windows le renommage
# releve des parametres systeme. Le champ reste affiche, en lecture
# seule, pour montrer le nom sous lequel le poste apparaitra.
self.computer_name_edit = QLineEdit(socket.gethostname()) self.computer_name_edit = QLineEdit(socket.gethostname())
self.computer_name_edit.setEnabled(False)
self.computer_name_edit.setToolTip(
"Le nom de la machine ne peut pas être modifié depuis cette fenêtre.\n"
+ (
"Renommez le poste depuis les paramètres système de Windows, puis redémarrez."
if platform.system() == "Windows"
else "Changez le nom d'hôte avec : sudo hostnamectl set-hostname <nom>"
)
)
form.addRow("Domaine :", self.domain_edit) form.addRow("Domaine :", self.domain_edit)
form.addRow("Utilisateur :", self.username_edit) form.addRow("Utilisateur :", self.username_edit)
form.addRow("Mot de passe :", self.password_edit) form.addRow("Mot de passe :", self.password_edit)
form.addRow("Unité d'organisation :", self.ou_edit) form.addRow("Unité d'organisation :", self.ou_edit)
form.addRow("Nom de la machine :", self.computer_name_edit) form.addRow("Nom de la machine :", self.computer_name_edit)
self.remove_packages_check = QCheckBox(
"Désinstaller aussi les paquets système du domaine en quittant"
)
self.remove_packages_check.setVisible(platform.system() != "Windows")
self.remove_packages_check.setToolTip(
"Retire " + ", ".join(REMOVABLE_PACKAGES) + ".\n"
"packagekit est conservé : il sert aussi au gestionnaire de logiciels."
)
layout.addLayout(form) layout.addLayout(form)
layout.addWidget(self.remove_packages_check)
self.prereq_label = QLabel("") self.prereq_label = QLabel("")
self.prereq_label.setWordWrap(True) self.prereq_label.setWordWrap(True)
@@ -148,6 +174,9 @@ class JoinTab(QWidget):
self.install_button.clicked.connect(self._start_install) self.install_button.clicked.connect(self._start_install)
self.install_button.setVisible(False) self.install_button.setVisible(False)
buttons.addWidget(self.install_button) buttons.addWidget(self.install_button)
self.leave_button = QPushButton("Quitter le domaine")
self.leave_button.clicked.connect(self._start_leave)
buttons.addWidget(self.leave_button)
buttons.addStretch() buttons.addStretch()
layout.addLayout(buttons) layout.addLayout(buttons)
@@ -187,6 +216,21 @@ class JoinTab(QWidget):
self.join_button.setEnabled(True) self.join_button.setEnabled(True)
self.join_button.setToolTip("") self.join_button.setToolTip("")
can_leave = self.backend.can_leave()
self.leave_button.setVisible(can_leave)
self.remove_packages_check.setVisible(can_leave and platform.system() != "Windows")
if not elevated:
self.leave_button.setEnabled(False)
self.leave_button.setToolTip(
"Droits insuffisants pour quitter le domaine.\n" + privileges.elevation_hint()
)
elif not available:
self.leave_button.setEnabled(False)
self.leave_button.setToolTip("Prérequis manquants — installez-les d'abord.")
else:
self.leave_button.setEnabled(True)
self.leave_button.setToolTip("")
def _start_install(self) -> None: def _start_install(self) -> None:
confirm = QMessageBox.question( confirm = QMessageBox.question(
self, self,
@@ -220,7 +264,6 @@ class JoinTab(QWidget):
username = self.username_edit.text().strip() username = self.username_edit.text().strip()
password = self.password_edit.text() password = self.password_edit.text()
ou = self.ou_edit.text().strip() or None ou = self.ou_edit.text().strip() or None
computer_name = self.computer_name_edit.text().strip() or None
if not domain or not username or not password: if not domain or not username or not password:
QMessageBox.warning(self, "Champs manquants", "Domaine, utilisateur et mot de passe sont requis.") QMessageBox.warning(self, "Champs manquants", "Domaine, utilisateur et mot de passe sont requis.")
@@ -246,11 +289,78 @@ class JoinTab(QWidget):
self.log.clear() self.log.clear()
self.join_button.setEnabled(False) self.join_button.setEnabled(False)
self._worker = JoinWorker(self.backend, domain, username, password, ou, computer_name) self._worker = JoinWorker(self.backend, domain, username, password, ou, None)
self._worker.output_line.connect(self.log.appendPlainText) self._worker.output_line.connect(self.log.appendPlainText)
self._worker.finished_join.connect(self._on_join_finished) self._worker.finished_join.connect(self._on_join_finished)
self._worker.start() self._worker.start()
def _start_leave(self) -> None:
domain = self.domain_edit.text().strip()
username = self.username_edit.text().strip()
password = self.password_edit.text()
remove_packages = self.remove_packages_check.isChecked() and self.remove_packages_check.isVisible()
if not domain:
QMessageBox.warning(self, "Champ manquant", "Le domaine est requis.")
return
# Sous Windows, Remove-Computer exige un compte autorise a retirer
# l'objet ordinateur ; sous Linux, realm leave s'en passe.
if platform.system() == "Windows" and (not username or not password):
QMessageBox.warning(
self,
"Identifiants requis",
"Sous Windows, quitter le domaine demande un compte du domaine "
"autorisé à retirer cette machine de l'annuaire.",
)
return
available, reason = self.backend.is_available()
if not available:
QMessageBox.critical(self, "Backend indisponible", reason)
return
details = (
f"Faire quitter le domaine « {domain} » à ce poste ?\n\n"
"Les comptes du domaine ne pourront plus s'y connecter et un "
"redémarrage sera nécessaire."
)
if remove_packages:
details += (
"\n\nLes paquets suivants seront également désinstallés :\n"
+ ", ".join(REMOVABLE_PACKAGES)
+ "\n\nAssurez-vous qu'aucune session de compte du domaine n'est ouverte."
)
confirm = QMessageBox.question(
self,
"Confirmer la sortie du domaine",
details,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
self.log.clear()
self.join_button.setEnabled(False)
self.leave_button.setEnabled(False)
self._leave_worker = LeaveWorker(self.backend, domain, username, password, remove_packages)
self._leave_worker.output_line.connect(self.log.appendPlainText)
self._leave_worker.finished_leave.connect(self._on_leave_finished)
self._leave_worker.start()
def _on_leave_finished(self, result) -> None:
self._refresh_backend_state()
if result.success:
msg = result.message
if result.needs_reboot:
msg += "\n\nUn redémarrage est nécessaire pour finaliser la sortie."
QMessageBox.information(self, "Sortie du domaine réussie", msg)
else:
QMessageBox.critical(self, "Échec de la sortie du domaine", result.message)
def _on_join_finished(self, result) -> None: def _on_join_finished(self, result) -> None:
self._refresh_backend_state() self._refresh_backend_state()
if result.success: if result.success:
+31
View File
@@ -70,3 +70,34 @@ class InstallPrerequisitesWorker(QThread):
def run(self) -> None: def run(self) -> None:
result: JoinResult = self.backend.install_prerequisites(on_output=self.output_line.emit) result: JoinResult = self.backend.install_prerequisites(on_output=self.output_line.emit)
self.finished_install.emit(result) self.finished_install.emit(result)
class LeaveWorker(QThread):
output_line = Signal(str)
finished_leave = Signal(object) # backend.base.JoinResult
def __init__(
self,
backend: DomainJoinBackend,
domain: str,
username: str,
password: str,
remove_packages: bool,
parent=None,
):
super().__init__(parent)
self.backend = backend
self.domain = domain
self.username = username
self.password = password
self.remove_packages = remove_packages
def run(self) -> None:
result: JoinResult = self.backend.leave(
self.domain,
self.username,
self.password,
remove_packages=self.remove_packages,
on_output=self.output_line.emit,
)
self.finished_leave.emit(result)
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Fabrique le binaire Windows autonome (.exe) de JoinDomainGUI DEPUIS LINUX,
# en pilotant un Python Windows embarque dans un prefixe Wine dedie.
#
# PyInstaller ne cross-compile pas : on execute donc un vrai PyInstaller Windows
# sous Wine. Le .exe produit est un binaire Windows natif.
#
# Prerequis : wine (>= 8), curl, unzip.
set -euo pipefail
cd "$(dirname "$0")/.."
PYTHON_VERSION="${PYTHON_VERSION:-3.12.10}"
# Depuis Qt 6.10, Qt6Core.dll est lie a l'ICU fourni par Windows (icuuc.dll dans
# System32, present depuis Win10 1703). Wine ne fournit pas cette DLL : PySide6
# y devient inimportable, et le hook PySide6 de PyInstaller -- qui importe QtCore
# pour localiser les plugins Qt -- echoue alors SILENCIEUSEMENT et produit un
# .exe depourvu de plugin de plateforme ("could not initialize Qt platform
# plugin windows" au lancement). On contraint donc cette build a Qt 6.9.
PYSIDE6_CONSTRAINT="${PYSIDE6_CONSTRAINT:-PySide6<6.10}"
WINEPREFIX="${WINEPREFIX:-$PWD/.build-wine}"
export WINEPREFIX
export WINEARCH=win64
export WINEDEBUG="${WINEDEBUG:--all}"
PY_DIR="$WINEPREFIX/drive_c/python"
WPY="c:\\python\\python.exe"
CACHE=".build-wine-cache"
command -v wine >/dev/null || { echo "wine est requis (apt install wine)" >&2; exit 1; }
mkdir -p "$CACHE"
if [ ! -d "$WINEPREFIX" ]; then
echo ">> Initialisation du prefixe Wine ($WINEPREFIX)"
wineboot --init >/dev/null 2>&1
wineserver -w
fi
if [ ! -f "$PY_DIR/python.exe" ]; then
ZIP="$CACHE/python-$PYTHON_VERSION-embed-amd64.zip"
[ -f "$ZIP" ] || curl -fL --retry 3 -o "$ZIP" \
"https://www.python.org/ftp/python/$PYTHON_VERSION/python-$PYTHON_VERSION-embed-amd64.zip"
echo ">> Installation de Python $PYTHON_VERSION (Windows) dans le prefixe"
rm -rf "$PY_DIR"
mkdir -p "$PY_DIR"
unzip -q "$ZIP" -d "$PY_DIR"
# La distribution "embeddable" desactive site-packages : on le reactive
# pour que pip et les paquets installes soient importables.
sed -i 's/^#import site/import site/' "$PY_DIR"/python*._pth
fi
if [ ! -f "$PY_DIR/Scripts/pip.exe" ]; then
GETPIP="$CACHE/get-pip.py"
[ -f "$GETPIP" ] || curl -fL --retry 3 -o "$GETPIP" https://bootstrap.pypa.io/get-pip.py
echo ">> Installation de pip"
cp "$GETPIP" "$PY_DIR/get-pip.py"
wine "$WPY" c:\\python\\get-pip.py --no-warn-script-location
fi
# pip sous Wine est lent (plusieurs minutes pour le wheel PySide6) : on ne
# reinstalle que si requirements.txt ou la contrainte ont change (FORCE_DEPS=1
# pour forcer).
DEPS_STAMP="$PY_DIR/.deps-stamp"
WANT_HASH="$(cat requirements.txt <(echo "$PYSIDE6_CONSTRAINT") | sha256sum | cut -d' ' -f1)"
if [ "${FORCE_DEPS:-0}" = "1" ] || [ "$(cat "$DEPS_STAMP" 2>/dev/null)" != "$WANT_HASH" ]; then
echo ">> Installation des dependances Windows (peut prendre plusieurs minutes)"
printf '%s\n' "$PYSIDE6_CONSTRAINT" > "$PY_DIR/constraints.txt"
# Desinstallation prealable : pip ne retrograde pas toujours un paquet deja
# installe qui viole une contrainte nouvellement ajoutee.
wine "$WPY" -m pip uninstall -q -y PySide6 PySide6-Essentials PySide6-Addons shiboken6 >/dev/null 2>&1 || true
wine "$WPY" -m pip install -q --no-warn-script-location \
-c c:\\python\\constraints.txt -r requirements.txt pyinstaller
echo "$WANT_HASH" > "$DEPS_STAMP"
else
echo ">> Dependances Windows a jour (FORCE_DEPS=1 pour les reinstaller)"
fi
# Garde-fou : si PySide6 ne s'importe pas ici, le hook PyInstaller ne collectera
# aucun plugin Qt et produira un .exe casse sans le signaler. Echouer tot.
echo ">> Verification de PySide6 sous Wine"
if ! wine "$WPY" -c "import PySide6.QtWidgets" 2>&1; then
echo "ERREUR : PySide6 n'est pas importable sous Wine — le .exe produit serait" >&2
echo " depourvu de plugins Qt. Verifiez PYSIDE6_CONSTRAINT." >&2
exit 1
fi
echo ">> Compilation du .exe"
wine "$WPY" -m PyInstaller \
--noconfirm \
--onefile \
--windowed \
--name JoinDomainGUI \
--distpath dist/windows \
--workpath build/windows \
--specpath build/windows \
main.py
wineserver -w
# Garde-fou : sans qwindows.dll, l'application refuse de demarrer sous Windows.
if ! grep -qi 'qwindows.dll' build/windows/JoinDomainGUI/PKG-00.toc; then
echo "ERREUR : le plugin de plateforme qwindows.dll est absent du binaire." >&2
exit 1
fi
echo
echo "Binaire produit : dist/windows/JoinDomainGUI.exe"
echo "Lancement : clic droit -> Executer en tant qu'administrateur"