MyGo 云音乐
Go와 MyGo로 만든 서드파티 네이버 클라우드 뮤직 데스크톱 클라이언트
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
SwiperNews — apprendre & suivre l'actu en swipe Une application (PWA + APK Android) qui fait lire des articles au swipe vertical plein écran, comme un fil de réseau social. Un seul fil, où deux natures d'articles alternent — trois actus, un article Wikipédia, et ainsi de suite :README 전문 보기 ↗
Une application (PWA + APK Android) qui fait lire des articles au swipe vertical plein écran, comme un fil de réseau social. Un seul fil, où deux natures d'articles alternent — trois actus, un article Wikipédia, et ainsi de suite :
L'idée : reprendre le geste des réseaux sociaux sans ce qui va avec. Aucun compte, aucune télémétrie, aucun appel à un service que vous n'avez pas choisi.
Côté exécution, tout tient dans index.html : aucune dépendance, aucun build, ouvrable tel quel.
L'outillage de développement (lint, tests, CI) est optionnel et ne change rien au déploiement.
Sur Android, l'app est empaquetée avec Capacitor et embarque son propre
navigateur intégré pour lire les articles sans quitter l'app — voir
Navigateur intégré.
speechSynthesis sur
le web, le moteur TextToSpeech d'Android dans l'APK (la WebView n'implémente
pas speechSynthesis) — aucun service tiers* final couvre les
variantes (chat* → « chats »). Appliqué à l'entrelacement, pas à la
récupération : ajouter ou retirer un mot agit à l'instant, sans réseau. Voir
mutedMatcher (src/lib.js)og:image) une fois la carte proche
de l'écran/feed/rubrique/asie/rss.xml → « asie ») est ajouté au nom, et seulement
quand plusieurs sources le partagent — un flux unique garde son nom nu. Voir
feedLabels (src/lib.js)?xtor=RSS-1 contre ?xtor=RSS-3208), ou quand, sur le même site, leur
titre normalisé est identique et leurs dates ne se contredisent pas. Ces deux
garde-fous évitent les faux positifs qui coûteraient un vrai article : sans
« même site », deux rédactions reprenant la même dépêche AFP au même titre
n'en garderaient qu'une ; sans la proximité de date, une chronique au titre
fixe (« Programme TV du jour ») s'effacerait d'un jour sur l'autre. Des deux
copies, on garde la plus riche — celle qui a une image, puis le résumé le plus
complet — plutôt que la première arrivée, dont l'ordre dépend du réseau. Voir
dedupNews (src/lib.js)/bons-plans/ — répandu chez
les sites tech français — plutôt qu'à des mots-clés, souvent absents d'un
titre de bon plan purement descriptif). Voir isPromotionalItem (src/lib.js)$ discrète, à côté du
bouton « Lire l'article », signale un article réservé aux abonnés — vérifié
par article, pas par domaine (la quasi-totalité des sites de presse ont
aussi du contenu gratuit). Le signal vient de la page elle-même
(isAccessibleForFree en JSON-LD, ou à défaut le texte « Réservé aux
abonnés »), lue uniquement pour les articles d'une liste de domaines
candidats, une fois la carte proche de l'écran — jamais pour tout le fil.
Voir isPaywallCandidateDomain/isPaywalledHtml (src/lib.js) et
articleMetaFor (index.html)isSponsorCandidateDomain/isSponsoredHtml (src/lib.js) et
checkSponsor (index.html)src/i18n.js).Trois rangées — marque, sources, centres d'intérêt — mangeaient 19 % de la hauteur de l'écran (152 px sur 780). Pire : les deux barres de puces défilaient horizontalement, si bien qu'avec cinq sources et six thèmes, trois de chacun seulement tenaient à l'écran — le filtre actif pouvait être hors champ, hors de portée du regard comme de la main.
Les deux barres sont devenues deux pastilles (Toutes ▾ / Tous ▾) : la barre tombe à 113 px (14 %). Chaque pastille affiche le filtre en cours et s'allume quand il n'est plus « tout » ; un tap ouvre une feuille où la liste complète tient d'un coup, sans rien couper. Une pastille disparaît quand elle n'a pas de choix à offrir (une seule source cochée, aucun centre d'intérêt) ou quand la dose a retiré sa moitié du fil — les deux muettes, la rangée entière s'efface.
Restent deux rangées, et chacune a un seul métier : la marque et les quatre boutons d'action en haut, les deux filtres en dessous. Les pastilles ont d'abord partagé la rangée des boutons, ce qui tenait tant qu'ils étaient trois ; au quatrième, elles tombaient à 68 px — soit « T… », un filtre qui ne dit plus quel filtre il est — pendant que la rangée de la marque restait à moitié vide. Les boutons sont donc remontés là où la place était.
Les pastilles prennent alors une moitié de rangée chacune, chevron calé à droite comme un sélecteur : de 40 px de texte utile à 125 px sur un écran de 412, et plus rien de tronqué jusqu'à 320 px. C'est exactement là qu'il fallait mettre la place : une pastille affiche un titre de flux, texte quelconque et souvent long (« Courrier international - Actualités France et Monde »), quand un bouton n'a jamais qu'une icône de 20 px à montrer. Sur un écran très étroit, c'est désormais la marque qui se coupe la première : « SwiperNe… » se devine encore, « T… » ne dit plus rien.
Le compromis assumé : un tap de plus pour filtrer. C'est un geste rare (la plupart restent sur « Toutes »), et il devient en échange complet et lisible.
La barre mélangeait trois langages : des emoji en couleur (🎓, 📰), des glyphes de police (↻, ⚙, ▾) dont le dessin et la position dépendent du système, et le logo. À quatre ou cinq voisins, cela se voyait comme un empilement d'icônes disparates — et un emoji ne s'aligne pas comme un tracé : il suit la ligne de base du texte et porte sa propre marge, d'où des icônes qui « flottent » dans leur bouton.
Tout est passé en tracés (2 px, extrémités rondes, boîte de 24, couleur héritée), tirés des mêmes constantes que les icônes des cartes : boussole (la moitié Wikipédia), flèche circulaire (rafraîchir), roue crantée (réglages), onde RSS (la source), étiquette (le thème), chevron. Même boîte pour toutes, donc même centre optique — mesuré à 0 px d'écart, ce qu'aucun glyphe de police ne garantit. Le seul élément coloré restant en haut est le logo, qui est une marque et non une icône.
Les emoji restent là où ils sont une valeur et non une décoration : le badge de thème sur les cartes, et les puces des listes (chaque centre d'intérêt a le sien). Chaque liste garde d'ailleurs son langage — les sources sont des noms nus, les thèmes portent tous leur emoji.
Le fil n'a pas de marche arrière fine : un swipe de trop et l'article est passé, on le cherche alors à l'aveugle en remontant carte par carte — le retour arrière d'Android, lui, ferme les panneaux, il ne remonte pas les cartes.
Un quatrième bouton (une liste) ouvre donc ce que le fil garde en mémoire : toutes ses cartes, dans l'ordre, en rangées — vignette, source (ou thème, côté Wikipédia), date, titre sur trois lignes au plus. Celles déjà dépassées reculent d'un cran, celle qu'on lit porte un repère ICI, et la liste s'ouvre sur elle : ce qu'on vient y chercher est presque toujours juste au-dessus. Un tap y ramène.
Ce n'est pas un historique de lecture, et le nom le dit : rien n'est stocké, rien n'est téléchargé, c'est une vue de la liste en cours. Ce que le fil a déjà jeté — sa tête au-delà de 180 cartes, ou tout le fil quand un filtre change — n'y est pas. Et c'est une feuille, non une vraie page : une page déchargerait le fil derrière elle, donc le rechargerait, et ferait perdre la position qu'on venait justement retrouver.
C'est ce quatrième bouton qui a fait déborder la rangée d'outils, et donc déclenché le rééquilibrage des deux rangées décrit plus haut.
Dès que la page occupe tout l'écran — APK, PWA installée, plein écran — son haut se dessine dans la bande de la barre d'état. Un poinçon de caméra centré tombe alors pile sur un bouton de la barre d'outils, qu'il rogne. Tant que cette rangée ne portait que la marque, alignée à gauche, le centre était vide et personne ne le voyait ; les boutons y sont montés, le problème est apparu. (Dans un onglet ordinaire, rien à faire : c'est l'interface du navigateur qui occupe cette bande.)
env(safe-area-inset-top), qui devrait le dire, ne le dit pas toujours : sur
WebView Android il ne décrit que la découpe d'écran, et plusieurs moteurs
mobiles le rapportent à zéro face à un poinçon. Trois sources se relaient donc,
et le CSS retient la plus grande — aucune n'a besoin d'être fiable seule :
env(safe-area-inset-top), quand le moteur le renseigne (encoche d'iPhone) ;InAppBrowserPlugin.systemInsets). C'est
un chevauchement avec la WebView, jamais l'inset brut : si une couche
quelconque — Capacitor, un thème, une future version d'Android — a déjà
décalé la WebView, la réponse est 0 et rien n'est réservé deux fois ;La vraie question n'est pas « la page est-elle en plein écran » mais « y a-t-il quelque chose entre le haut de l'écran et le haut de la page ». Deux réponses plausibles se sont révélées fausses avant la bonne :
display-mode répond browser sur un navigateur mobile qui dessine pourtant
la page sous la barre d'état ;Reste window.screenY : la position du haut du contenu sur l'écran. Au-delà de
zéro, une interface de navigateur occupe déjà le haut. Les moteurs qui ne le
renseignent pas rendent 0 — et la bande est alors réservée, seul côté sûr. Le
tout est limité à pointer:coarse : un ordinateur, même en plein écran, n'a ni
barre d'état ni découpe.
Ce que la bande coûte quand elle ne sert à rien : presque rien. La barre du
haut est un calque (position:fixed, effacé pendant le swipe) — elle ne pousse
aucun contenu, la bande ne fait que descendre ce que la barre affiche.
La valeur est bornée à 120 px et rejouée à chaque redimensionnement (tourner
l'écran déplace la découpe). Le pied du panneau de réglages affiche la marge
réellement appliquée, lue sur le style calculé : ni l'APK ni une PWA
installée n'ont de barre d'URL où ajouter ?debug=1, et « le poinçon rogne
encore » n'est pas débogable à distance sans ce chiffre.
L'app ne connaissait qu'un seul paysage, celui du téléphone couché, où une carte plein écran ne tient pas debout : elle y répond par un verrou d'orientation (« Remets ton téléphone à la verticale »). Un pliant déplié n'est pas ce paysage-là. Un Galaxy Z Fold couché offre ~1224×924 points CSS (pour 1848×2448 pixels) : la carte y tient parfaitement, et il reste de la place à côté d'elle.
Les deux se séparent par la hauteur, jamais par un nom d'appareil — même règle qu'au paragraphe précédent : on mesure la géométrie, on ne devine pas le matériel.
| Largeur | Hauteur | Ce qui s'affiche | |
|---|---|---|---|
| Téléphone couché | ≤ 1024 | < 560 | Le verrou d'orientation |
| Pliant déplié, tablette, fenêtre desktop couchés | ≥ 900 | ≥ 560 | Le fil et le volet |
560 px de hauteur ne peut pas se tromper de catégorie : être haut de 560 une fois couché, c'est être large de 560 debout, et aucun téléphone ne l'est (les plus grands plafonnent vers 480 points CSS).
Le volet n'invente rien : c'est la liste « Articles en mémoire » (Retrouver un article dépassé), aux mêmes rangées et avec le même saut, simplement posée à demeure au lieu de recouvrir le fil. Elle se cherchait déjà en plein milieu d'une lecture ; sur un écran assez large, elle n'a plus besoin de le cacher pour se montrer. Le repère de position y suit le fil pendant qu'on swipe.
Le fil, lui, ne change que de largeur : les cartes font toujours une hauteur d'écran et le geste reste exactement le même. Un pliant déplié en portrait reste d'ailleurs en fil plein écran — il est large, mais c'est déjà l'orientation du geste.
La carte, elle, est recomposée. Elle est dessinée pour le portrait — image plein cadre, texte ancré en bas, dégradé qui monte du bas — et couchée, cette composition ne tient plus : le texte reste collé en bas à gauche, la moitié droite est vide, et le dégradé vertical noircit la moitié basse pendant qu'il ne reste de la photo qu'une bande délavée. Le texte devient donc une colonne à gauche, centrée verticalement, et le dégradé passe à l'horizontale : opaque sous la colonne, il se lève vers la droite et rend la photo visible au lieu de l'écraser.
L'image, elle, reste plein cadre — la confiner à la moitié droite a été essayé et rendu : un cadre de 700×1000 rogne 60 % de la largeur d'une photo de presse en 16:9. Plein cadre sur 1260×1000, la même photo n'en perd que 29 %, soit moins que sur l'écran portrait d'un téléphone (74 %). Ce n'était pas le cadrage qu'il fallait corriger, c'était le dégradé qui cachait le résultat.
Le détail des choix est dans
docs/architecture/2026-09-01-paysage-large-volet.md.
La composition du fil tient à un seul réglage — un curseur à six crans, en tête du panneau ⚙, qui donne la proportion entre les deux natures d'articles :
| Cran | Nom | Cadence |
|---|---|---|
| 0 | Actus seules | que des actus — Wikipédia n'est pas interrogé |
| 1 | Une pincée | 1 article Wikipédia toutes les 6 actus |
| 2 | Équilibré (défaut) | 1 toutes les 3 actus |
| 3 | Moitié-moitié | 1 pour 1 |
| 4 | Surtout apprendre | 3 articles Wikipédia pour 1 actu |
| 5 | Wikipédia seul | que des articles — aucun flux n'est chargé |
Un curseur plutôt que six boutons : le réglage est un continuum entre deux extrémités nommées, et le geste de glisser le dit mieux qu'une rangée d'étiquettes. Sous le curseur, un aperçu de la cadence (une barre par carte, les articles Wikipédia en corail) montre le rythme obtenu — il est calculé avec la même fonction que le fil réel, pas dessiné à la main. L'aperçu suit le doigt pendant le glissement ; le fil, lui, n'est recomposé qu'au relâchement, sinon il serait reconstruit à chaque pixel.
Les deux extrémités remplacent les anciens onglets : Actus seules est l'ancien mode Actus, Wikipédia seul l'ancien mode Apprendre. Chacune coupe vraiment l'autre moitié — pas seulement à l'affichage, mais aussi côté réseau.
Le bouton boussole de la barre du haut ouvre ce curseur en un tap, sans
passer par les réglages — c'était auparavant un interrupteur tout ou rien, qui
n'atteignait que les deux extrémités d'une échelle qui en compte six. Le même
curseur vit donc à deux endroits ([data-mixmount], comme les réglages du
lecteur) : un seul état, une seule fonction de rendu. Le bouton continue de
dire l'état : plein, tracé corail, quand Wikipédia est dans le fil ;
fantôme au cran Actus seules, où la pastille des centres d'intérêt disparaît
aussi (elle ne filtrerait plus rien) — c'est le signal le plus clair que le geste a porté. Changer de dose
ne recharge rien : les deux réserves sont déjà en mémoire, seul leur
entrelacement change, et l'utilisateur reste sur sa carte (ou sur la première
qui lui survit, quand la dose écarte celle qu'il lisait).
Entre les actus se glissent des articles Wikipédia tirés au hasard (titre, extrait, image, lien vers l'article). Ils se distinguent par un badge de thème, une typographie un peu plus dense et le verbe « Découvrir » sur leur bouton.
La pastille « thème » de la barre du haut permet de choisir ce qu'on veut apprendre : Aléatoire (défaut), Sciences, Histoire, Art & Culture,
Artistes, Géographie, Nature, Espace, Technologie, Sport, Cinéma, Films, Musique,
Jeux vidéo, Cuisine, Philosophie. Chaque
catégorie utilise le moteur de recherche de Wikipédia (generator=search,
gsrsort=random, deepcategory:"…") pour tirer des articles au hasard dans la catégorie
et ses sous-catégories. Le choix est mémorisé et chaque catégorie a son propre cache.
Le fil s'appuie sur Wikipédia (API action, generator=search/random, extrait
d'intro + image), en français par défaut, pour toutes les catégories. Quand plusieurs
catégories sont interrogées pour un même lot (mode « Tous »), les résultats sont
dédoublonnés et mélangés (les cartes avec image d'abord).
Ouvrir index.html dans un navigateur suffit pour tester le swipe.
Pour que l'installation PWA et le service worker fonctionnent, il faut servir la page
en HTTP local plutôt qu'en file:// :
python3 -m http.server 8000
# puis ouvrir http://localhost:8000
vercel en CLI). Vercel sert
index.html en statique et déploie automatiquement la fonction api/feed.js,
qui récupère les flux RSS côté serveur — c'est le chemin le plus fiable, sans aucun
proxy tiers.main.Un navigateur ne peut pas lire un flux RSS directement (CORS). Pour chaque source, l'app essaie donc, dans l'ordre :
api/feed.js (/api/feed?url=…) — présent si l'app est
déployée sur Vercel (ou tout hébergeur avec fonctions). Aucun CORS, aucun tiers :
c'est le plus fiable ;api.rss2json.com) en dernier repli.Ces trois étapes partagent un seul budget de temps (12 s par source, tous replis compris). Chacune avait auparavant son propre délai d'expiration de 7 s, et ils s'additionnaient : une source morte immobilisait son emplacement de parallélisme pendant vingt et une secondes. Le repli rss2json n'est donc plus tenté quand le budget est épuisé — il vise les flux qu'on n'arrive pas à parser (échec rapide), pas les sources qu'aucun des six transports précédents n'a su joindre.
Un export Feedly peut contenir plusieurs centaines de flux. Trois garde-fous :
MAX_NEWS / nombre de sources, avec un minimum
de 10. Avec peu de sources il n'y a donc aucune limite pratique ; avec des
centaines, aucune ne peut occuper tout le fil ;Cette dernière est ce qui rend une réouverture rapide. La repeinture qui montre les
articles neufs quand un cache est déjà à l'écran — le cas de toute réouverture
au-delà des 30 minutes — était celle de la fin du chargement, donc celle d'après la
dernière source. Mesuré au navigateur (banc lentnews : 40 sources, dont 3 lentes
et 2 mortes), le fil affiché restait celui d'il y a une demi-heure pendant 24
secondes, alors que 38 sources sur 40 avaient répondu en moins de deux. Avec
l'échéance : 2,6 s, pour le même contenu final (120 articles dans les deux cas).
Le fil ne remonte en tête qu'une fois par chargement, et cette fois-là vaut pour les deux moitiés du fil. Les repeintures suivantes — actus en retard comme lot Wikipédia arrivé après coup — gardent l'ancrage sur l'article en train d'être lu, plutôt que d'arracher à sa lecture quelqu'un qui a déjà commencé à glisser dans le fil neuf. Chaque moitié comptant auparavant pour elle seule, un rafraîchissement où Wikipédia répondait plus lentement que les sources RSS remontait en tête deux fois, la seconde au moment où la barre de chargement s'éteignait.
Pendant qu'un chargement se poursuit en arrière-plan, une fine barre en haut de l'écran l'indique. Elle ne bloque rien : le fil déjà affiché reste lisible — et depuis l'échéance, ce fil est déjà le fil neuf.
Parce qu'un tri par date enterre les sources lentes. Une source qui publie un article par heure a ses dix plus récents étalés sur dix heures ; une qui en publie cinquante à l'heure a les siens dans un intervalle de douze minutes. Classés ensemble par fraîcheur, les bavardes occupent tout le haut du fil — et comme on ne fait défiler que vingt ou trente cartes, on ne descend jamais jusqu'aux autres. Le plafond par source n'y change rien : le problème n'est pas combien une source livre, c'est où ses articles atterrissent.
Les sources sont donc servies à tour de rôle, une carte chacune, et la date ne classe plus qu'à l'intérieur d'une source. C'est exactement ce que fait déjà le mode Wikipédia pour ses catégories, avec le même code. Mesuré sur 15 sources dont une à un article par heure : elle obtenait une carte sur 120, en position 94 — elle en obtient huit, la première en position 18.
Deux garde-fous :
Contrepartie assumée : le fil n'est plus « le plus récent d'abord » de bout en bout. La deuxième carte peut avoir six heures pendant que la vingt-cinquième en a dix.
Une carte affichée est mémorisée, et repoussée en fin de file au rafraîchissement suivant — jamais retirée du fil.
C'est la contrepartie directe du tour de rôle. Une source horaire garde le même article en tête de sa file pendant une heure : sans cette mémoire, chaque rafraîchissement reposait la même carte à la même place. Mesuré sur quatre lectures d'une source à un article par heure : un seul article distinct sans la mémoire, quatre avec — elle puise dans son fond plutôt que de répéter sa dernière parution.
Trois précisions :
Conséquence assumée : après un rafraîchissement, la première carte est l'article le plus récent que vous n'avez pas déjà vu, et non le plus récent dans l'absolu.
Les articles datés dans le futur de plus de deux jours (agendas de concerts, annonces de festivals) sont classés en fin de fil comme les articles sans date : ce ne sont pas des actualités récentes, et ils monopolisaient sinon la tête du fil.
Une chaîne YouTube publie un flux Atom
(https://www.youtube.com/feeds/videos.xml?channel_id=…) : elle s'ajoute comme
n'importe quelle source, et ses vidéos s'entrelacent dans le fil comme des actus.
Seuls les Shorts sont servis — jamais les vidéos classiques ni les directs.
Le format court est le seul qui tienne dans une carte qu'on regarde entre deux
articles : une vidéo de trente minutes n'a pas sa place dans un fil qu'on
parcourt au pouce. Rien dans le flux d'une chaîne ne distingue pourtant un Short
d'une vidéo ordinaire (ni durée, ni catégorie, ni format de vignette), et trier
après coup demanderait d'interroger la page de chaque vidéo — une requête par
carte croisée, exactement ce que l'app refuse. C'est donc YouTube qui trie :
toute chaîne a des playlists auto-générées, une par nature de vidéo, dont
l'identifiant est celui de la chaîne privé de son préfixe (UUSH… pour les
Shorts, UULF… pour les vidéos classiques). L'app interroge la playlist des
Shorts au lieu de la chaîne, sur le même point d'entrée : aucune requête de plus,
aucune clé d'API. L'URL que vous avez enregistrée ne change pas — la
substitution a lieu au moment de la requête, donc l'OPML exporté reste celui que
vous avez importé. Une chaîne qui ne publie aucun Short ne sert simplement aucune
carte ; elle n'est pas signalée « injoignable » pour autant.
Elle apparaît sous « YT · nom de la chaîne ». L'URL d'un flux YouTube ne
contient qu'un identifiant opaque (channel_id=UC…), donc le seul nom qu'on
pouvait en tirer était le nom d'hôte : toutes les chaînes s'appelaient
« youtube.com », et suivre trois chaînes donnait trois lignes identiques dans le
panneau Sources comme dans le filtre. Le vrai nom n'existe que dans le flux :
il est donc lu au premier chargement, et demandé en arrière-plan dès l'ajout pour
que la liste soit juste tout de suite. Les autres sources gardent le nom que vous
leur avez donné — seule la chaîne YouTube fait exception, parce que son nom
d'hôte ne distingue rien.
Effet de bord voulu : le fil sert les sources à tour de rôle en les regroupant par nom. Cinq chaînes suivies ont donc maintenant cinq parts, au lieu d'une seule part partagée sous « youtube.com ».
Enfin, une vidéo n'est « vue » que lorsqu'elle est lancée, là où un article l'est dès que sa carte est passée à l'écran (voir Les actus déjà vues ci-dessus). La différence est celle du contenu : une carte d'article porte le titre, le résumé et l'image — l'avoir eue sous les yeux, c'est en avoir tiré ce qu'il y avait à en tirer. Une carte vidéo ne montre qu'une miniature. Passer devant sans appuyer sur ▶ ne compte donc pas, et la vidéo garde sa place : elle reviendra au rafraîchissement suivant, parce que vous ne l'avez pas encore regardée. « Ouvrir sur YouTube » compte aussi comme un lancement.
Quand le lien d'un article désigne une vidéo qu'on sait intégrer, la carte se regarde sur place : un ▶ au centre de la miniature, un appui, et le lecteur prend toute la carte. Le bloc titre/source s'efface pendant la lecture, le bouton de partage reste disponible, et glisser vers la carte suivante arrête la vidéo. La pastille du bas devient « Ouvrir sur YouTube » — pour les commentaires, la chaîne, ou une vidéo dont l'auteur refuse l'intégration.
Deux choix qui expliquent le reste :
youtube-nocookie.com : même lecteur, sans dépôt de cookies publicitaires
avant lecture ;video/iframe, et une page YouTube n'a de toute façon pas assez de
texte pour être simplifiée : « Ouvrir sur YouTube » livrait donc, dans l'app
Android, une page complète plus un message d'échec du lecteur.Sur le web, la CSP doit autoriser le lecteur : vercel.json déclare
frame-src https://www.youtube-nocookie.com https://www.youtube.com. Sans cette
ligne, default-src 'self' bloquerait le cadre.
content-visibility) et
leurs images ne sont chargées qu'à l'approche de l'écran : un fil de 120 cartes plein
écran ne garde plus 120 images en mémoire./api/learn répond sur l'une de quelques variantes tirées au sort, donc cacheables
par le CDN et mutualisées entre utilisateurs (un nonce par requête empêchait tout cache).
Sauf quand l'utilisateur réclame du neuf (bouton ↻, ou repli « tout ce lot est
déjà vu ») : le lot part alors avec un nonce et en no-store, et la réponse n'est
pas mise en cache. Sans cette exception, ↻ repiochait dans les quelques variantes
déjà servies — souvent le lot qu'on venait de lire — et la copie disque du navigateur
pouvait même le resservir sans aucun appel réseau pendant une heure : les actus se
renouvelaient, la moitié Wikipédia non.Une seule règle : les sources ne sont interrogées qu'une fois toutes les 30 minutes, sauf demande explicite.
loadFeeds via son compteur de génération) :
resume de @capacitor/app (index.html), plus fiable que
visibilitychange dans cette WebView, mais qui reste relayé par le pont
JS du plugin ;onResume() de l'Activity elle-même (MainActivity.java), qui évalue
directement loadFeeds() dans la WebView — un cran plus bas que le
plugin, donc encore plus fiable, puisqu'il ne dépend d'aucun relais JS
pour être livré.
La vérification périodique reste un filet de sécurité en dernier recours :
sur Android, une app mise en arrière-plan est presque toujours
suspendue, pas tuée — pas de rechargement à froid, et aucun des trois
signaux n'est formellement garanti au retour (le code le documente déjà pour
la sauvegarde de position). Sans ce filet, une actualisation qui rate ses
trois déclencheurs ne se rattraperait jamais, même après des heures. La
vérification elle-même ne coûte rien : loadFeeds() sans force ressort
immédiatement si le seuil n'est pas atteint.La date du dernier chargement est stockée avec le cache (localStorage), pas en mémoire : fermer complètement l'app ne la remet pas à zéro. C'est ce qui rend le seuil efficace sur mobile, où chaque réouverture était auparavant un chargement complet.
Quand un rafraîchissement se termine avec de vraies données (automatique ou ↻), le fil remonte en tête : un rafraîchissement toutes les 30 minutes n'a de sens que si on finit par voir les nouveaux articles, pas seulement les savoir présents plus bas dans le fil. Cela l'emporte même sur la reprise de lecture au lancement — rouvrir l'app après une pause ramène en haut dès que le rafraîchissement (souvent déclenché par le cache périmé) se termine, plutôt que de rester sur l'article laissé la fois précédente. Seules les repeintures progressives pendant un chargement (un flux qui répond après l'autre) gardent l'ancrage sur l'article affiché — sans ça, la carte à l'écran changerait sous les yeux plusieurs fois par seconde. La moitié Wikipédia est exclue de tout ceci — c'est un tirage aléatoire sans fin, la recharger ferait perdre le lot en cours (et donc la carte en train d'être lue, que le tirage suivant ne contient presque jamais). Elle n'est redemandée que sur une action explicite, ou quand il n'en reste rien.
Le cache conserve le fil mêlé presque entier (140 cartes) et non un extrait : puisqu'il est servi pendant 30 minutes, le tronquer reviendrait à perdre l'essentiel des articles à chaque relancement.
Un rafraîchissement ne vide jamais un fil déjà affiché : si le réseau échoue, le contenu reste à l'écran avec un simple message.
Si toutes les sources échouent au tout premier chargement, l'app n'affiche plus de contenu de démo : elle montre un message d'erreur avec les boutons Réessayer et Ouvrir les réglages — et encore, seulement si Wikipédia a échoué aussi : sans aucune source RSS joignable, la moitié Wikipédia suffit à faire un fil. Pour une fiabilité maximale (et pour ne dépendre d'aucun tiers), prévoir un petit backend qui récupère et parse le RSS côté serveur.
Le cœur de l'app reste sans build. Un petit outillage est fourni pour la qualité :
npm ci # eslint + prettier (dev uniquement)
npm run lint # analyse statique de api/, src/ et des tests
npm run format # formatage (index.html volontairement exclu)
npm test # tests unitaires (assainissement, parsing, fil mêlé, Wikipédia, anti-SSRF)
La CI GitHub Actions (.github/workflows/ci.yml) rejoue lint + format + tests sur chaque PR.
Quatre choses à savoir avant une première contribution :
index.html ou de
src/*.js impose d'incrémenter APP_VERSION, le ?v= des deux <script>
et CACHE dans sw.js, ensemble. C'est la règle la plus facile à
oublier et la plus visible quand on l'oublie ; le pourquoi est détaillé plus
bas, section « Versionner la coquille à chaque modification ». Une
modification purement native (android/) n'en demande pas.index.html est hors lint et hors formatage (voir .prettierignore) :
son JS/CSS en ligne est dense à dessein, s'aligner sur le style existant
plutôt que sur celui de src/.main, staging ou une pull
request. Pousser une branche seule ne lance ni les tests ni la
compilation de l'APK : ouvrez une PR pour faire compiler du code natif.AGENTS.md rassemble en un seul endroit la carte du code, les
décisions à ne pas défaire et les pièges déjà payés. Écrit pour un assistant de
code, il se lit très bien pour prendre le dépôt en main.
Ouvrir l'app avec ?debug=1 : un encadré affiche, pour la carte visible, la
provenance de l'image (content / thumbnail / enclosure / description), la
largeur déclarée par le flux, la largeur réelle du fichier chargé, et
l'URL utilisée. Si la taille réelle est très inférieure à l'écran, c'est le flux
qui ne publie qu'une petite image.
Une PWA installée n'est qu'une coquille qui ouvre cette URL : mettre le site à jour met l'app à jour. L'utilisateur n'a jamais à la réinstaller.
Trois mécanismes garantissent qu'il tourne sur la dernière version :
index.html et src/*.js sont servis réseau d'abord par le service
worker, et les modules portent un ?v= — un lancement avec du réseau donne
donc déjà la dernière version ;sw.js, index.html et le manifeste sont servis en max-age=0, must-revalidate (vercel.json), donc aucun intermédiaire ne peut épingler
une ancienne version ;index.html et src/*.js forment un ensemble indivisible : servir un index.html
neuf avec un src/lib.js périmé casse l'app entièrement (fil sans images ni
interactions). Le déploiement en a déjà fait les frais une fois.
Après toute modification de index.html ou de src/*.js, incrémenter le même
numéro aux deux endroits :
| Fichier | Ligne à changer |
|---|---|
index.html | <script src="src/lib.js?v=N"> et src/learn-core.js?v=N |
sw.js | const CACHE = "flux-vN" |
Trois garde-fous rendent l'oubli non catastrophique, mais ils ne dispensent pas du geste :
index.html et src/*.js sont servis réseau d'abord par le service worker
(le cache ne sert qu'hors-ligne) ;?v= fait partie de l'URL, donc un module d'une autre version n'est jamais
servi depuis le cache à un index.html neuf ;index.html vérifie que les modules exposent bien ce qu'il attend ;
sinon il purge caches et service worker et recharge une fois, puis affiche un
message explicite plutôt qu'une interface à moitié morte.L'app s'installe déjà en PWA depuis le navigateur. Un APK apporte deux choses :
l'installation par fichier (sans passer par le menu du navigateur) et, surtout, un
targetSdkVersion à jour — ce qui évite l'avertissement Play Protect « conçue pour
une version plus ancienne d'Android » que produisent certains navigateurs quand ils
fabriquent eux-mêmes le paquet.
Le format adapté est une TWA (Trusted Web Activity) : un APK minimal qui affiche ce site en plein écran, sans barre d'URL. Le contenu reste celui du site, donc les mises à jour continuent de se faire sans réinstaller.
https://news.lielu.eu ;.aab,
la clé signing.keystore et le fichier assetlinks.json déjà rempli) ;assetlinks.json dans well-known/assetlinks.json
de ce dépôt, puis redéployer. Sans cette étape, l'app fonctionne mais affiche
une barre d'URL en haut ;Conserver la clé de signature (signing.keystore et son mot de passe) : sans
elle, aucune mise à jour de l'APK ne pourra être installée par-dessus.
npm install -g @bubblewrap/cli
bubblewrap init --manifest https://news.lielu.eu/manifest.webmanifest
bubblewrap build # produit app-release-signed.apk
bubblewrap fingerprint # empreinte SHA-256 à coller dans well-known/assetlinks.json
Bubblewrap télécharge lui-même le JDK et le SDK Android au premier lancement
(~1 Go). Cela ne peut pas être fait depuis l'environnement de développement de
ce dépôt : dl.google.com, seul hôte fournissant le SDK Android, le plugin
Gradle Android et androidx.browser, y est bloqué par la politique réseau.
La TWA ci-dessus reste un rideau sur https://news.lielu.eu : sans réseau au
lancement, elle ne s'ouvre pas. Capacitor embarque
au contraire index.html, src/*.js, le manifeste et les icônes dans
l'APK : l'app s'ouvre hors-ligne. Capacitor refuse que webDir pointe sur la
racine du dépôt ; npm run cap:prepare régénère donc un dossier www/
(ignoré par git, simple copie des fichiers racine) avant chaque cap sync, ce
qui reste sans étape de build JS — juste une copie.
Les données, elles, restent réseau (flux RSS, Wikipédia) — c'est inhérent à une
app d'actualités — mais l'app packagée n'appelle jamais news.lielu.eu :
elle va chercher chaque source directement depuis l'appareil, sans dépendance
au backend Vercel.
Un navigateur ne peut normalement pas lire un flux RSS tiers (pas de CORS chez
la plupart des sources) — d'où api/feed.js et son repli proxys publics côté
web. Wikipédia, elle, autorise déjà le CORS et se lit en direct dans n'importe
quel navigateur (voir srcWikipedia dans index.html). Dans l'APK, seuls les
flux RSS ont donc besoin d'un contournement : nativeGet (index.html)
appelle explicitement le plugin Capacitor.Plugins.CapacitorHttp, qui route
la requête par le réseau natif Android au lieu de la WebView — le CORS,
qui est une politique de navigateur, ne s'applique alors plus du tout, avec un
timeout natif fiable (connectTimeout/readTimeout).
Une première tentative activait plugins.CapacitorHttp globalement
(capacitor.config.json), ce qui patche fetch() pour absolument tout —
y compris Wikipédia, qui n'en avait pourtant pas besoin — et s'est révélée
nettement plus lente à l'usage. Mesuré sur l'émulateur Android local après
être passé à un appel ciblé (nativeGet uniquement pour RSS) : ~3,2 s pour
charger 4 flux RSS en parallèle — un lot Wikipédia (seul, en CORS
direct) reste comparable à ce qu'on peut attendre sur le web.
npm install
npm run cap:prepare # copie index.html/src/manifeste/icônes dans www/
npx cap add android # génère le projet natif dans android/ (télécharge le SDK
# Android — même limitation réseau que Bubblewrap ci-dessus,
# à faire hors de cet environnement)
npm run android:open # ouvre android/ dans Android Studio pour builder/signer
Après la première génération, npm run cap:sync (prepare + cap sync android) suffit à répercuter une modification de index.html/src/*.js dans
le projet natif avant de rebuilder.
Builder en ligne de commande, sans Android Studio (il faut le SDK Android et un JDK 21) :
npm ci
npm run cap:sync # ⚠ indispensable : sans lui, le projet
# natif garde la version précédente
# d'index.html et on débogue un fichier
# qui n'est pas celui qu'on a modifié
cd android && ./gradlew assembleDebug
# → android/app/build/outputs/apk/debug/app-debug.apk
Sans les variables d'environnement de signature (ANDROID_KEYSTORE_FILE,
ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD),
Gradle retombe sur la clé debug : l'APK s'installe pour tester, mais ne pourra
pas être mis à jour par-dessus, la clé debug étant régénérée à chaque machine.
assembleRelease avec ces variables produit le paquet signé, comme en CI. Le
numéro de version se passe en propriétés Gradle
(-PversionCode=N -PversionName=X) ; sans elles, 1 / 1.0.
Contrairement à la TWA, la coquille embarquée ne se met pas à jour
automatiquement avec le site : republier l'APK (nouvelle build signée avec la
même clé) est nécessaire après toute modification de index.html ou de
src/*.js.
Dans l'APK, « Lire l'article » (actu) et « Découvrir » (Wikipédia) n'envoient
plus vers Chrome : l'article s'ouvre dans l'app, dans une activité maison
(android/app/src/main/java/eu/lielu/news/InAppBrowserActivity.java). On ne
quitte donc plus SwiperNews pour lire, et le retour ramène exactement à la carte
quittée sans recharger le fil.
L'habillage est délibérément minimal, aux couleurs du fil (:root d'index.html
recopié dans res/values/colors_reader.xml) : une barre fine avec le titre de la
page et le nom de domaine — jamais de barre d'URL — un bouton fermer, un
bouton partager, un bouton « ouvrir dans le navigateur » pour la sortie de
secours, et la jauge de chargement rose → cyan de l'app. L'article monte depuis
le bas comme les feuilles du fil, et le bouton retour remonte d'abord
l'historique de la page avant de refermer le lecteur.
Partage. La WebView d'Android n'implémente pas navigator.share : dans
l'APK, le bouton partager du fil tombait donc sur le menu de repli
d'index.html, une grille de cinq services web codés en dur — alors que c'est
justement la plateforme où l'appareil sait faire beaucoup mieux. Le pont expose
une méthode share qui déclenche un ACTION_SEND : la feuille de partage du
système s'ouvre, avec les applications installées, dans l'ordre des
habitudes de l'utilisateur. Le web garde sa cascade inchangée —
navigator.share quand le navigateur l'a, la grille sinon.
Lecture immersive. La barre s'efface dès qu'on descend dans l'article et revient au premier geste vers le haut — même règle que la barre du fil, qui se masque pendant le swipe. La barre d'état d'Android part avec elle : il ne reste alors que le texte, et un glissement depuis le haut la ramène sans quitter sa place dans l'article. Deux détails rendent la chose fluide :
translationY) au lieu d'être retirée de la mise en
page — un changement de hauteur de vue relancerait la mise en page du site à
chaque geste. D'où le FrameLayout : la barre flotte au-dessus de la WebView,
qui ne bouge jamais ;getInsetsIgnoringVisibility et non
de getInsets : escamoter la barre d'état changerait sinon ces marges, donc
la zone de rendu, donc… la mise en page du site, à nouveau. En haut, la
WebView reçoit une vraie marge de vue (topMargin, pas un padding) égale
à la hauteur de la barre. La nuance compte : un padding aurait laissé
l'en-tête du site partiellement caché sous la barre dès qu'il est en
position:fixed/sticky — la norme pour la presse — puisqu'un padding ne
déplace jamais un élément fixe (modèle de boîte CSS, pas une bizarrerie de
WebView), alors qu'il continue d'être dessiné dans la zone que la barre
recouvre. Une marge, elle, réduit les bornes RÉELLES de la WebView : rien ne
peut plus jamais y être dessiné, fixe ou non. Contrepartie assumée : quand la
barre s'efface au scroll, la bande qu'elle occupait reste vide un instant au
lieu de laisser le site en profiter — les deux ne sont pas conciliables sans
redimensionner la WebView pendant l'animation, ce que le paragraphe
précédent interdit justement.Détails d'implémentation qui comptent :
androidx.browser — l'inverse de « sobre et intégré ». Ici, rien
n'entre dans le paquet que du code de ce dépôt.setAlgorithmicDarkeningAllowed (androidx.webkit, version
déjà déclarée par Capacitor dans variables.gradle) laisse le site choisir son
thème sombre s'il en a un (prefers-color-scheme), et assombrit sinon. Le fond
de la WebView est déjà à la couleur de l'app, ce qui supprime l'éclair blanc
avant le premier rendu.; wv qui signale une WebView est retiré, sinon
certains sites servent une page dégradée ou refusent la lecture.mailto:, tel:,
intent:…) et les téléchargements sont confiés à l'appareil ; une page
injoignable affiche un écran d'erreur avec « Réessayer » et « Ouvrir dans le
navigateur » plutôt qu'un écran noir.setDecorFitsSystemWindows(false) + insets),
pour un rendu identique sur toutes les versions d'Android plutôt qu'un
comportement par défaut qui change avec targetSdk.Le pont est un plugin Capacitor local (InAppBrowserPlugin, enregistré dans
MainActivity) : côté web, index.html appelle openArticle(), qui délègue à
Capacitor.Plugins.InAppBrowser si et seulement si l'app est packagée. Hors
APK le plugin n'existe pas, la fonction rend false et le lien garde son
comportement de navigateur (target="_blank") — c'est aussi le repli si le pont
natif échouait.
Bandeaux de consentement. Le lecteur masque à l'ouverture les bandeaux
cookies (res/raw/reader_cmp.js, injecté au début, en cours et en fin de
chargement — les CMP arrivent souvent après la page, le script est donc
idempotent et rejoué).
Il masque, il n'accepte jamais. Aucun bouton n'est cliqué, aucun consentement n'est donné au nom de l'utilisateur : ne pas répondre à une demande de consentement vaut refus, c'est l'option la plus protectrice. Un script qui cliquerait « Tout accepter » pour faire disparaître le bandeau ferait exactement l'inverse — c'est pour cela qu'on s'y refuse.
Deux passes complémentaires, plus un déverrouillage du défilement (beaucoup de bandeaux figent la page derrière eux) :
<body>,
texte parlant de cookies/consentement, et présence d'un bouton d'acceptation.
Les trois ensemble évitent de faire disparaître un article qui parlerait de
cookies. Le parcours est borné (400 éléments) pour rester gratuit sur une
longue page.Vérifié sur une page piégée (test manuel en Chromium) : bandeau OneTrust,
bandeau Didomi et bandeau maison masqués ; barre de navigation fixe, article
traitant des cookies et encart newsletter préservés ; défilement rendu.
Troisième façon de lire, à côté de « Dans l'app » et « Navigateur » : le lecteur ne garde que le titre, le texte et les images, dans une colonne de liseuse. Un bouton dans la barre du lecteur bascule à tout moment entre l'article simplifié et la page complète.
Le principe (res/raw/reader_read.js) est celui de Readability — le moteur
derrière les vues lecteur de Firefox et Safari — mais réécrit court : chaque
bloc de la page est noté selon la quantité de texte qu'il porte, pondérée par
sa densité de liens (un menu ou un sommaire tend vers 1, un article vers 0)
et par sa signature de classe/id. Le meilleur bloc est élagué, puis la page est
remplacée par une version propre. Jeter la feuille de style du site fait
disparaître d'un coup habillage, colonnes, encarts et bandeaux, sans avoir à les
nommer un par un.
Les choix qui comptent :
colspan et rowspan : elles ne
décrivent pas une apparence mais la structure d'une grille — sans elles,
toute cellule fusionnée retombe dans la première colonne, et une infobox
Wikipédia se lit en colonne étroite avec la moitié droite vide ;Une vue lecteur ne vaut que par le confort qu'elle apporte, et le confort ne se
décide pas à la place du lecteur : le corps du texte (quatre tailles) et le
fond (sombre, sépia, clair) se choisissent dans le panneau ⚙ Sources, sous
« Ouvrir les articles », et ne s'affichent qu'en mode Lecture — en page complète,
c'est la feuille du site qui décide et ces boutons n'auraient aucun effet. Comme
les autres réglages du lecteur, ils vivent dans le localStorage du web et sont
transmis au natif à chaque ouverture ; l'activité ne conserve rien.
Le reste de la mise en page vise la même chose, et tient à peu de décisions :
hyphens: auto), sans quoi le français, avec ses mots longs sur
une colonne étroite, laisse un bord droit en dents de scie et des lignes à
moitié vides — c'était le défaut le plus visible du lecteur ;Les fonds clairs demandent deux précautions côté natif : la WebView porte la couleur de la page avant le premier rendu (sinon le fond sombre apparaît une fraction de seconde), et son assombrissement automatique — celui qui rend lisibles les sites sans thème sombre — est coupé, faute de quoi elle repeindrait le sépia en gris.
Enfin, quelques défauts d'extraction que la remise à plat rendait voyants :
og:title une version écrite
pour les réseaux — « Zelensky limoge l'ambassadrice… » là où l'article titre
« Guerre en Ukraine : le président Volodymyr Zelensky limoge… » : le début
manquait, alors que le <h1> de la page l'avait. Il est donc préféré dès
qu'il contient le titre retenu et le dépasse ; et dans l'autre sens, quand le
<title> n'ajoute qu'une enseigne (« — Le Monde »), c'est encore le <h1>
qui donne la bonne version. Bornée à 200 caractères, pour qu'un paragraphe
balisé en <h1> ne devienne pas un titre ;--sn-top, transmis à l'injection et corrigeable à chaud pour la rotation),
et non plus par la marge de la WebView — une seule marge, posée là où le
texte est mis en page ;<title> de la page, que le remplacement du <head> emportait : sans
lui, la WebView rapporte l'URL comme titre, et c'est l'URL brute qui
s'affichait dans la barre du lecteur ;<source> d'un <picture>, qui l'emporte sur le src rétabli et
ramenait l'image différée d'origine ;Sites sur abonnement. Le mode lecture ne touche à rien sur une page de
connexion. C'est un cas qui casserait tout sans précaution : le script supprime
form, input et button et remplace la page — il ne resterait pas un champ à
remplir, et le gestionnaire de mots de passe n'aurait plus rien à autoremplir,
l'Autofill d'Android ayant besoin des vrais champs dans le DOM. Deux
détections, l'une sûre et l'autre en filet : présence d'un
input[type="password"], ou chemin d'URL évocateur (/connexion, /login,
/sso, /abonnement…). La page est alors laissée intacte et marquée auth,
pour que le lecteur sache qu'il ne s'agit pas d'un échec d'extraction : le mode
reste actif pour l'article qui suit la connexion, et un message discret explique
pourquoi cet écran-là n'est pas simplifié.
Les cookies sont écrits sur disque à chaque mise en arrière-plan
(CookieManager.flush()). Sans ce vidage explicite, une session peut être
perdue quand le système récupère le processus — il faudrait alors se reconnecter
à chaque lecture.
Les images sont rétablies depuis leurs attributs de chargement différé
(data-src, srcset), résolues en URL absolue, et les vignettes de moins de
120 px comme les traceurs 1×1 sont écartés.
Vérifié en Chromium sur une page d'article complète (menu, bandeau cookies, colonne latérale avec pub, boutons de partage, « À lire aussi », pied de page) : titre, date, 3 paragraphes, sous-titre, citation, liste, image d'illustration et sa légende conservés ; tout le reste supprimé ; zéro attribut résiduel, zéro erreur JS.
Publicités et traceurs. Le blocage est désactivé par défaut : c'est une option qu'on active sciemment. Deux raisons, et aucune n'est technique — bloquer prive de revenu les éditeurs dont on lit justement les flux, et un site à mur anti-adblock donnerait une app qui « ne marche pas » à quelqu'un qui n'a rien demandé. Une fois activé, le blocage opère à deux étages complémentaires :
shouldInterceptRequest : toute ressource dont l'hôte
figure dans res/raw/reader_blocklist.txt reçoit une réponse vide. C'est le
vrai gain — la pub n'est jamais téléchargée, et le pistage ne part pas. Un
domaine couvre ses sous-domaines (doubleclick.net bloque
stats.g.doubleclick.net), par remontée des domaines parents ; la remontée
s'arrête avant le TLD, pour qu'une liste mal saisie ne puisse pas bloquer
.com. Le document principal n'est jamais bloqué : l'article doit
toujours s'afficher ;res/raw/reader_ads.js), qui referme les emplacements
réservés restés vides après le blocage — et uniquement ceux-là : un
conteneur nommé ad-… mais qui porte du texte ou une image est laissé
tranquille.La liste intégrée est écrite à la main (178 domaines) : elle sert de
plancher, disponible hors-ligne dès l'installation. Deux exclusions
volontaires, parce que les bloquer casse l'article lui-même :
googletagmanager.com (beaucoup de sites y font transiter le chargement du
contenu) et les CDN d'images.
Une liste intégrée ne bouge qu'au rythme des publications de l'app. Le réglage
Publicités propose donc de télécharger une liste de référence, mise à jour
sans republier l'APK (BlocklistStore.java, méthode syncBlocklist du plugin) :
| Liste | Format | Poids réel | Domaines retenus | Licence |
|---|---|---|---|---|
EasyList easylist_adservers.txt | ||domaine^ | 1,16 Mo | 50 047 | GPL-3 / CC BY-SA 3.0 |
StevenBlack hosts | hosts | 2,98 Mo | 99 275 | MIT |
Points de conception :
Le parseur (BlocklistStore.parseLine) reconnaît les trois formats répandus —
||domaine^, 0.0.0.0 domaine, domaine brut — et ignore délibérément tout
le reste : règles à chemin, joker ou options ($third-party), qui ne se
ramènent pas à un domaine, et exceptions (@@), qui bloqueraient l'inverse de
ce qu'elles disent. Deux pièges vérifiés sur les vrais fichiers :
lemonde.fr##.banniere ressemble à une ligne hosts
suivie d'un commentaire. La couper naïvement au # donnerait lemonde.fr —
et ferait bloquer le site lui-même. Le # n'est traité comme un commentaire
que détaché d'un mot ;0.0.0.0 0.0.0.0 : une adresse IP
n'est pas un domaine, un TLD fait au moins deux caractères et n'est jamais
entièrement numérique (xn--p1ai reste accepté).Le lecteur intégré se refuse : un réglage « Ouvrir les articles » propose
Dans l'app, Lecture (défaut) ou Navigateur, mémorisé dans
fluxswipe.readpref.v1. Sur Navigateur, openArticle() rend false et le
lien repart au navigateur du téléphone, exactement comme avant l'ajout du
lecteur. Deux autres réglages, « Bandeaux cookies : Masqués / Affichés »
(fluxswipe.cookiebanner.v1, masqués par défaut) et « Publicités et traceurs :
Bloqués / Affichés » (fluxswipe.ads.v1, affichés par défaut),
n'apparaissent que quand le lecteur est actif — sans lui, la question ne se
pose plus. Les trois préférences vivent côté web et sont
transmises à chaque ouverture (hideCmp, blockAds) : le natif ne garde aucun
état. Le réglage figure en bas du panneau unique, sous les sources :
renderReadPref() remplit le point de montage [data-readmount] depuis un état
unique (il y en avait un par panneau tant qu'il y en avait deux). Il n'apparaît
pas sur le web, où un
lien s'ouvre forcément dans un onglet, ni pendant l'accueil du premier
lancement, où l'on ne demande qu'une chose à la fois. Le lecteur garde par
ailleurs son bouton « ouvrir dans le navigateur » pour les cas ponctuels.
Icône et écran de démarrage : générés par @capacitor/assets
à partir de resources/. Le logo est un carré à coins arrondis rempli d'un
dégradé vertical orange → rouge, portant une carte blanche et une ampoule ;
il en existe trois formes, et savoir laquelle va où est tout l'intérêt de
cette section :
| Forme | Où | Pourquoi |
|---|---|---|
| Coins arrondis (coins transparents) | logo-192/512.png, écrans de démarrage | Personne ne masque ces images : elles doivent porter leur propre arrondi |
| Plein bord (dégradé prolongé jusqu'aux bords du carré) | logo-maskable-512.png, resources/icon.png, resources/icon-background.png | Tout ce qui subit un masque. Un masque circulaire mord au-delà des coins arrondis : sans plein bord, il découperait dans le vide et laisserait des encoches transparentes |
| Contenu seul (carte + ampoule sur fond transparent) | resources/icon-foreground.png | Calque avant de l'icône adaptative, posé sur le dégradé du calque arrière — c'est ce qui permet au lanceur de les animer séparément |
Le fond de l'icône adaptative n'est donc plus transparent : le logo apporte son propre fond, et un aplat imposé par le lanceur n'est plus à craindre — c'est le dégradé du logo qui remplit le masque, quelle que soit la forme choisie par l'OEM.
Deux marges de sécurité, à ne pas confondre :
logo-maskable-512.png le réduit donc à 0,884 avant de le poser
sur le dégradé plein bord ;mipmap-anydpi-v26/ic_launcher.xml insère
les deux calques à 16,7 %, ce qui ramène 0,452 sous la limite. Le contenu
passe tel quel, sans réduction supplémentaire.Écrans de démarrage splash*.png : logo centré à 16,3 % de la largeur sur la
couleur de thème #0a0a0f — @capacitor/assets les redimensionne en cover,
donc tout ce qui compte doit rester près du centre.
Après une mise à jour du logo à la racine, régénérer avec :
python3 tools/gen_logo.py tools/logo-source-1024.png # dépend de Pillow
npm run android:assets
tools/gen_logo.py fabrique les trois formes depuis un unique PNG source
1024×1024 — tools/logo-source-1024.png, versionné pour que tout soit
regénérable : il reconstitue le dégradé ligne par ligne (il est purement
vertical) pour le prolonger jusqu'aux bords, et en déduit le contenu par
écart au dégradé. Il produit aussi les vignettes Fastlane et les écrans de
démarrage ; il ne touche ni au ?v= ni à android/, d'où les deux rappels
qu'il affiche en terminant.
/logo-*.png est servi avec un Cache-Control immutable d'un an
(vercel.json) : à URL identique, un logo mis à jour resterait invisible en
cache CDN/navigateur pendant un an, même après un bump de CACHE (sw.js).
Après toute modification des logo-*.png à la racine, incrémenter le ?v=
qui les accompagne partout où ils sont référencés (index.html : favicon,
apple-touch-icon, og:image/twitter:image, logo de l'en-tête ;
manifest.webmanifest ; SHELL dans sw.js) — même mécanisme que le ?v=
des modules src/*.js.
Ce que Vercel fait pour le web, .github/workflows/android.yml le fait pour
l'APK Capacitor : chaque push sur main ou staging (et chaque pull
request) synchronise index.html/src/*.js dans le projet natif et lance
Gradle. Aucun Android Studio n'est nécessaire — le SDK est installé sur le
runner.
Où récupérer le paquet :
| Déclencheur | Résultat |
|---|---|
push sur main, clé en place | release android-v<version> marquée « latest », APK attaché |
push sur staging, clé en place | préversion roulante android-staging — toujours la même adresse de téléchargement |
| push sans clé de signature | préversion roulante android-debug-<branche>, APK debug |
| pull request | artefact du run seulement, aucune release |
Passer par une release plutôt que par l'artefact du run n'est pas cosmétique :
GitHub sert tout artefact d'Actions dans un .zip, même quand il ne contient
qu'un fichier, alors qu'une release donne un .apk en téléchargement direct —
donc installable depuis le téléphone, sans détour par un ordinateur.
L'APK reste malgré tout joint aux artefacts de chaque run (90 jours), y
compris sur main et staging.
versionName vaut la version de package.json suffixée du numéro de run, et
versionCode place ce numéro sous le palier de la version que
android/app/build.gradle prépare : un APK de main est une préversion de
celle-ci, numérotée entre le tag précédent et celui à venir.
tag v1.3.1 ....... 10301 (ancienne échelle, avant élargissement)
main build 77 .... 103010077 = 103020000 − 10000 + 77
tag v1.3.2 ....... 103020000
main build 80 .... 103020080 (préversion de 1.3.3)
tag v1.3.3 ....... 103030000
Le numéro de run seul ne suffisait pas : il vit sur une échelle sans rapport
avec celle des versions publiées. Un APK de main portant « 76 » face au
« 10301 » du tag v1.3.1 était une rétrogradation pour Android, donc
impossible à installer par-dessus sans désinstaller d'abord — le symptôme qui a
motivé l'élargissement. Ajouter au palier au lieu de soustraire aurait produit
l'erreur inverse : des builds CI au-dessus du tag de leur propre version, qui
serait devenu ininstallable par-dessus eux.
Signature. Sans clé, le workflow produit un APK debug : installable pour
tester, mais signé d'une clé jetable régénérée à chaque run — il faut donc
désinstaller la version précédente avant de poser la suivante. Pour des paquets
signés durablement, installables par-dessus les précédents, créer une clé
une fois et la garder.
keytool est fourni par n'importe quel JDK — dont celui embarqué dans Android
Studio (.../Android Studio/jbr/bin/keytool) si aucun n'est installé par
ailleurs :
keytool -genkey -v -keystore release.keystore -alias swipernews \
-keyalg RSA -keysize 2048 -validity 10000
# valeur du secret ANDROID_KEYSTORE_BASE64 (une seule ligne, macOS comme Linux)
base64 < release.keystore | tr -d '\n'
Puis, dans Settings → Secrets and variables → Actions du dépôt, ajouter
ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS
(swipernews ci-dessus) et ANDROID_KEY_PASSWORD. Le workflow bascule seul sur
assembleRelease et publie la release. Conserver release.keystore hors du
dépôt : perdue, plus aucune mise à jour de l'app installée n'est possible.
manifest.webmanifest remplit les exigences d'une TWA : id, name,
short_name, start_url et scope absolus, display: standalone, couleurs de
thème, icônes 192 et 512, plus une icône maskable pour que le lanceur Android
n'en rogne pas les bords ;well-known/assetlinks.json est en place, avec une empreinte à remplacer, et
vercel.json le réécrit vers /.well-known/assetlinks.json — le chemin exact
qu'Android va interroger.api/feed.js valide l'URL demandée et refuse le réseau interne
(localhost, IP privées, métadonnées cloud) — protection anti-SSRF — en plus de
plafonner la taille de réponse.api/feed.js ne relaie jamais le Content-Type de l'amont : la réponse est
toujours servie en application/xml avec nosniff et Content-Disposition: attachment. Sans cela, /api/feed?url=… pouvait servir du HTML ou du JavaScript
exécuté sur notre propre origine./api/* n'ouvrent le CORS qu'à l'origine configurée par la
variable d'environnement ALLOWED_ORIGIN (rien par défaut) : le front est
same-origin et n'en a pas besoin, et l'endpoint n'est pas offert comme proxy ouvert.DOMParser), et
non via innerHTML sur un élément détaché — qui, lui, déclenche bien les handlers
onerror des flux.javascript:/data: non exécutés), et les URL d'image sont posées en JavaScript
plutôt que dans un attribut style (un attribut style est décodé en HTML avant
d'être parsé en CSS, ce qui rend l'échappement HTML insuffisant)./api/*, et
purge ses anciennes versions (pas de cache non borné).api/feed.js (proxy RSS durci) ou, en repli, proxys CORS publics.api/og.js lit la page
d'un article pour TROIS signaux distincts, extraits d'une même requête —
jamais deux appels pour une seule page. Image : quand un flux ne publie
qu'une vignette (Franceinfo sert des URL Thumbor signées en 432 px, où
la taille fait partie de la signature — donc non modifiable) ou n'en publie
aucune, api/og.js va lire la balise og:image de l'article, qui
pointe vers la version pleine taille ou comble l'absence. Statut payant : le
signal isAccessibleForFree (JSON-LD schema.org, celui qu'utilise Google
Actualités) est cherché dans la même page. Sponsoring d'auteur : certains
partenariats commerciaux ne se déclarent QUE via le lien de byline vers la
fiche de l'auteur (isSponsoredHtml, src/lib.js — cas connu : « L'équipe
Promo » de Les Numériques), jamais dans le flux RSS ni le titre/résumé. Les
deux derniers signaux sont lus plus profondément que la simple balise
og:image (paramètre deep=1) quand le domaine appartient à une liste de
candidats (isPaywallCandidateDomain / isSponsorCandidateDomain,
src/lib.js), parce qu'ils vivent souvent après le <head> sur les sites en
rendu serveur (Next.js et consorts), là où og:image seul n'a jamais besoin
d'aller chercher aussi loin. Résultat mémorisé côté client (un seul cache
pour les trois signaux) et mis en cache 24 h par le CDN. Un article confirmé
sponsorisé par ce biais est retiré du fil (checkSponsor, index.html) —
contrairement au paywall (simple badge), puisque filterSponsored dit que
l'utilisateur ne veut voir aucun contenu promotionnel.api/learn.js agrège côté serveur les catégories
demandées (cache CDN mutualisé entre utilisateurs). Le front l'appelle en priorité et
se rabat sur son agrégation client si l'endpoint n'est pas déployé (hébergement statique).src/lib.js (fonctions pures : assainissement, parsing OPML/JSON,
dates, entrelacement du fil) et src/learn-core.js (catégories, URL et
normaliseurs Wikipédia) sont
chargés par index.html et par les fonctions serverless — une seule implémentation,
couverte par npm test. Ce sont des <script> classiques, pas des modules ESM :
index.html reste ouvrable en file://.index.html reste volontairement hors du périmètre lint/format. La logique
réutilisable en a été extraite vers src/, mais le rendu et les interactions y
vivent encore.logo-512.png pèse ~38 Ko). La
quantification par palette qui allégeait l'ancien logo fait border le dégradé
du nouveau : le gain de poids ne vaut pas la dégradation visible.relTime() n'est pas encore
internationalisée (voir src/i18n.js).GNU General Public License v3.0 (GPL-3.0) — voir LICENSE.
Les versions publiées avant ce changement (jusqu'au tag v1.5.3 inclus) sont restées, et
demeurent, sous licence MIT pour quiconque les a déjà obtenues : ce changement ne s'applique qu'au
code publié à partir de maintenant.
manifest.webmanifest et sw.js sont de vrais fichiers servis en statique.
Le service worker ne met jamais /api/* en cache.vercel.json porte la CSP, les en-têtes de sécurité et le cache long
des assets immuables.role="dialog" avec piège de focus et
fermeture clavier (Échap), prefers-reduced-motion respecté ; titres de carte en h2.