Presse-papiers macOS : les quatre pièges de NSPasteboard
Il n’existe aucune notification quand le presse-papiers de macOS change.
Pas de NSNotification, pas de KVO, pas de callback de délégué. NSPasteboard
expose un changeCount monotone croissant, incrémenté à chaque écriture, et
c’est toute la surface d’API dont vous disposez pour l’observer. Chaque
gestionnaire de presse-papiers sur Mac — Jackdaw compris — est une boucle de
polling autour de cet entier.
Ça paraît trivial. Ça l’est, pendant environ une journée. Ensuite les choses intéressantes commencent.
La boucle
La forme est celle qu’on imagine : lire changeCount, le comparer au dernier
vu, et s’il a bougé, lire le contenu du presse-papiers.
let pasteboard = NSPasteboard::generalPasteboard();
let mut last_count = pasteboard.changeCount();
while running.load(Ordering::SeqCst) {
thread::sleep(POLL_INTERVAL);
let current_count = pasteboard.changeCount();
if current_count == last_count {
continue;
}
last_count = current_count;
// ...capture
}
La première vraie décision, c’est l’intervalle — et le raisonnement n’est pas celui auquel on s’attend.
Piège 1 : l’intervalle ne concerne pas la réactivité
Le réflexe est de régler l’intervalle pour que l’interface paraisse rapide. C’est le mauvais axe. Personne n’ouvre un gestionnaire de presse-papiers dans les 400 ms qui suivent une copie : l’historique se consulte des secondes ou des minutes plus tard. Un intervalle de 500 ms est imperceptible pour la fonctionnalité elle-même.
Ce que l’intervalle borne réellement, c’est la latence d’arrêt. Le thread de travail ne remarque qu’il doit s’arrêter qu’en se réveillant : un tick de 500 ms signifie donc que la fermeture peut bloquer une demi-seconde. C’est assez long pour se voir à la fermeture de l’app, et assez long pour compter quand le moniteur redémarre après une sortie de veille.
Jackdaw interroge toutes les 100 ms pour cette raison, pas pour la réactivité. Sur Apple silicon, lire un entier dix fois par seconde est négligeable énergétiquement ; le coût est entièrement dans ce qu’on fait après que le compteur a bougé.
Piège 2 : les autorelease pools sur les threads longue durée
C’est celui qui produit un rapport de bug six semaines après la sortie, formulé ainsi : « l’app grignote lentement la mémoire ».
Chaque lecture du presse-papiers vous rend des objets Objective-C autoreleased —
NSString, NSData, NSURL, le tableau de types. Sur le thread principal,
AppKit vide le pool à chaque tour de run loop, donc la question ne se pose
jamais. Un thread de travail longue durée n’a pas de run loop, donc pas de
vidage de pool. Ces objets s’accumulent pendant toute la vie du thread —
c’est-à-dire, pour un moniteur de presse-papiers, tant que l’app tourne.
Le correctif consiste à envelopper le corps de chaque tick :
let should_notify = autoreleasepool(|_| {
let current_count = pasteboard.changeCount();
// ...lecture et stockage
});
Deux détails comptent. Le pool ne doit pas englober le sleep — ce serait
contre-productif, puisqu’il resterait ouvert ~99 % du temps. Et
generalPasteboard() est un singleton : il reste donc à l’extérieur du pool,
plutôt que d’être récupéré et relâché à chaque tick.
Piège 3 : certaines écritures ne vous regardent pas
Les gestionnaires de mots de passe déposent vos identifiants sur le presse-papiers général. Les champs de texte sécurisés aussi. Un gestionnaire naïf enregistre tout cela allègrement, en clair, pour toujours.
macOS ne résout pas ce problème pour vous. Ce qui existe à la place est une convention communautaire documentée sur nspasteboard.org : l’écrivain déclare un type supplémentaire sur le presse-papiers pour signaler son intention. Celui qui compte pour la vie privée est :
org.nspasteboard.ConcealedType
1Password et d’autres outils d’identifiants le déclarent. Le respecter tient en une vérification d’une ligne avant toute capture, et elle doit venir avant toute lecture, pour qu’une écriture dissimulée soit rejetée en bloc plutôt qu’enregistrée partiellement :
if pasteboard_is_concealed(&pasteboard) {
return false; // ne jamais toucher au contenu
}
La même convention définit TransientType et AutoGeneratedType. Jackdaw choisit
délibérément de ne pas filtrer ces deux-là : ils sont déclarés par des outils
dont les utilisateurs veulent souvent conserver la sortie, et les écarter
silencieusement ferait perdre des clips voulus. « Dissimulé » est une frontière
de confidentialité ; « transitoire » est une préférence. Les deux méritent des
réponses différentes.
Si vous écrivez sur le presse-papiers depuis votre propre app et que le contenu
est sensible, déclarez ConcealedType vous-même. Tout gestionnaire correct
l’ignorera.
Piège 4 : vos propres écritures vous reviennent
Dès que votre app colle quelque chose, elle écrit sur le presse-papiers. Ce qui
incrémente changeCount. Votre propre moniteur voit le changement et
l’enregistre — coller un élément de l’historique crée donc un doublon de cet
élément en tête de l’historique.
Le correctif est un drapeau de suspension que le chemin de collage lève et que le moniteur consomme exactement une fois :
pub fn suspend_capture_once() { raise_suspension(&MONITOR_SUSPENDED); }
Le consommer une seule fois, plutôt que d’utiliser un mode « ignorer »
booléen, a son importance. Un drapeau qu’on lève et qu’on abaisse autour de
l’écriture est une course : si l’incrément de changeCount arrive après
l’abaissement, le doublon apparaît quand même. La consommation unique lie la
suppression au prochain changement observé, quel que soit son moment.
La question de l’ordre
Une fois le changement jugé réel et autorisé, une même écriture peut porter plusieurs représentations à la fois. Copier un fichier dans le Finder donne une URL de fichier et une chaîne. Copier depuis un outil de design peut donner une image et du texte.
Il n’y a pas d’ordre universellement correct, seulement une décision produit. Jackdaw teste l’URL de fichier d’abord, puis l’image, puis le texte — du plus spécifique au moins spécifique — parce qu’un fichier que l’utilisateur peut re-glisser vaut mieux que la forme textuelle de son chemin.
let captured = if let Some(item) = capture_file_url(&pasteboard, app.clone()) {
Captured::Item(item)
} else if let Some(bytes) = read_image_bytes(&pasteboard) {
Captured::Image(bytes, app)
} else if let Some(item) = capture_text(&pasteboard, app) {
Captured::Item(item)
} else {
Captured::None
};
Quel que soit l’ordre retenu, choisissez-le délibérément et écrivez-le quelque part. C’est le genre de décision qui paraît arbitraire six mois plus tard, et qu’on « corrige » en régression.
Ce que ça coûte
Un entier lu toutes les 100 ms, un autorelease pool par tick, et une comparaison de chaîne avant toute capture. La partie coûteuse d’un gestionnaire de presse-papiers n’est jamais la surveillance : c’est de décider ce qui mérite d’être conservé, et de rester discipliné sur ce qui ne le mérite pas.