feat(android): the list and the detail move to HTML
The FMX controls lost this one on merit. Every screen re-taught the same lessons: a template missing from the loaded style, an alignment order left to chance, a HitTest swallowing a tap, a native view painting over everything. The engine draws the same interface in forty lines of CSS and shapes Arabic on its own. The bridge needs no local server, so no port is open on the device: evaluateJavascript carries the data down, an intercepted cmd:// navigation carries the actions up. Through a throwaway iframe - FMX always tells the engine it did not handle the navigation, which on an unknown scheme replaces the page with an error screen. Secrets do not travel over that bridge. The list gets names, sites and usernames, which are already on screen; copying from a row sends an index and Delphi reads the vault itself. Only the open entry has its values in the JS heap, and PM.wipe clears them on lock. Folders become a row of chips with counts instead of a full-screen panel: one tap, and the count says what to expect. Also gone: about eight hundred lines of the screen this replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,3 +88,4 @@ android-client/PMA.LocalConfig.inc
|
|||||||
# Genere depuis theme.rc + pm-dark.style, tous deux suivis (voir README).
|
# Genere depuis theme.rc + pm-dark.style, tous deux suivis (voir README).
|
||||||
android-client/theme/theme.RES
|
android-client/theme/theme.RES
|
||||||
android-client/theme/theme.res
|
android-client/theme/theme.res
|
||||||
|
android-client/web/*.res
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
unit PMA.WebUI;
|
||||||
|
{
|
||||||
|
La page HTML et les donnees qu'on lui envoie.
|
||||||
|
|
||||||
|
POURQUOI HTML. Les controles FMX n'ont pas de gabarit fiable sur mobile :
|
||||||
|
la journee a vu disparaitre tour a tour la ligne de liste, l'invite de
|
||||||
|
recherche, la ligne de detail, selon le style charge. Le reste - arrondis,
|
||||||
|
ordre d'alignement, HitTest qui avale un appui, vues natives qui se peignent
|
||||||
|
par-dessus - se re-decouvre a chaque ecran. Le moteur du systeme rend la
|
||||||
|
meme interface en quelques lignes de CSS, gere l'arabe et le bidirectionnel
|
||||||
|
seul, et c'est deja le langage de l'interface du poste Windows.
|
||||||
|
|
||||||
|
CE QUI RESTE EN DELPHI : Keystore, biometrie, presse-papiers sensible,
|
||||||
|
FLAG_SECURE, reseau et dechiffrement. Le moteur n'est qu'une surface
|
||||||
|
d'affichage - il ne detient jamais de quoi ouvrir le coffre.
|
||||||
|
|
||||||
|
CE QUI PART VERS LA PAGE. La liste porte les noms, sites et identifiants,
|
||||||
|
soit ce qui est deja a l'ecran, et AUCUN mot de passe : copier depuis une
|
||||||
|
ligne envoie un indice, Delphi copie depuis le coffre. Seule l'entree
|
||||||
|
ouverte a ses valeurs dans le tas JS, une a la fois.
|
||||||
|
}
|
||||||
|
|
||||||
|
interface
|
||||||
|
|
||||||
|
uses
|
||||||
|
PMA.Vault;
|
||||||
|
|
||||||
|
/// La page, telle qu'embarquee dans le .res. ASCII pur - voir l'en-tete de
|
||||||
|
/// app.html : le chemin de chargement de FMX ne transporte pas l'encodage.
|
||||||
|
function PageHtml: string;
|
||||||
|
|
||||||
|
/// Ce que la liste affiche et cherche. Les indices sont ceux du coffre, pas
|
||||||
|
/// ceux de la liste : la page filtre et trie sans rien redemander, donc les
|
||||||
|
/// deux ne coincident plus des la premiere frappe.
|
||||||
|
function VaultToJson(AVault: TVault): string;
|
||||||
|
|
||||||
|
/// Les lignes d'une entree, dans l'ordre d'affichage.
|
||||||
|
function DetailToJson(const AEntry: TVaultEntry): string;
|
||||||
|
|
||||||
|
/// Une ligne du detail par son rang, dans le MEME ordre que DetailToJson : la
|
||||||
|
/// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui.
|
||||||
|
/// Faux si le rang ne designe rien.
|
||||||
|
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
||||||
|
out ACaption, AValue: string): Boolean;
|
||||||
|
|
||||||
|
implementation
|
||||||
|
|
||||||
|
uses
|
||||||
|
// System.Types pour RT_RCDATA, que Winapi.Windows fournirait sur un poste.
|
||||||
|
System.SysUtils, System.Classes, System.Types, System.JSON, System.Character;
|
||||||
|
|
||||||
|
{$R 'web\web.res' 'web\web.rc'}
|
||||||
|
|
||||||
|
function PageHtml: string;
|
||||||
|
var
|
||||||
|
LStream: TResourceStream;
|
||||||
|
LBytes: TBytes;
|
||||||
|
begin
|
||||||
|
LStream := TResourceStream.Create(HInstance, 'PMAPP', RT_RCDATA);
|
||||||
|
try
|
||||||
|
SetLength(LBytes, LStream.Size);
|
||||||
|
LStream.ReadBuffer(LBytes, LStream.Size);
|
||||||
|
Result := TEncoding.UTF8.GetString(LBytes);
|
||||||
|
finally
|
||||||
|
LStream.Free;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function HumanSize(ABytes: Int64): string;
|
||||||
|
begin
|
||||||
|
if ABytes >= 1024 * 1024 then
|
||||||
|
Result := Format('%.1f MB', [ABytes / (1024 * 1024)])
|
||||||
|
else if ABytes >= 1024 then
|
||||||
|
Result := Format('%.0f KB', [ABytes / 1024])
|
||||||
|
else
|
||||||
|
Result := Format('%d B', [ABytes]);
|
||||||
|
end;
|
||||||
|
|
||||||
|
/// Premiere lettre du nom affiche, pour la pastille sans icone.
|
||||||
|
function Initial(const AName: string): string;
|
||||||
|
var
|
||||||
|
LCh: Char;
|
||||||
|
begin
|
||||||
|
Result := '?';
|
||||||
|
for LCh in AName do
|
||||||
|
if LCh.IsLetterOrDigit then Exit(LCh.ToUpper);
|
||||||
|
end;
|
||||||
|
|
||||||
|
function VaultToJson(AVault: TVault): string;
|
||||||
|
var
|
||||||
|
LRoot: TJSONObject;
|
||||||
|
LEntries, LFolders: TJSONArray;
|
||||||
|
LObj: TJSONObject;
|
||||||
|
LIdx: TArray<Integer>;
|
||||||
|
I: Integer;
|
||||||
|
E: TVaultEntry;
|
||||||
|
LFolder: string;
|
||||||
|
begin
|
||||||
|
LRoot := TJSONObject.Create;
|
||||||
|
try
|
||||||
|
LEntries := TJSONArray.Create;
|
||||||
|
LRoot.AddPair('entries', LEntries);
|
||||||
|
|
||||||
|
// Search('') rend les vivantes, deja triees par nom affiche. La page
|
||||||
|
// conserve cet ordre : trier de nouveau en JS demanderait de repliquer
|
||||||
|
// la meme convention de comparaison pour rien.
|
||||||
|
LIdx := AVault.Search('');
|
||||||
|
for I := 0 to High(LIdx) do
|
||||||
|
begin
|
||||||
|
E := AVault.Entry(LIdx[I]);
|
||||||
|
LObj := TJSONObject.Create;
|
||||||
|
LObj.AddPair('i', TJSONNumber.Create(LIdx[I]));
|
||||||
|
LObj.AddPair('name', E.Display);
|
||||||
|
// Le site s'il existe, sinon l'identifiant : deux fois la meme chaine
|
||||||
|
// sur deux lignes n'apprendrait rien.
|
||||||
|
if E.Site <> '' then LObj.AddPair('sub', E.Site)
|
||||||
|
else if E.Username <> E.Display then LObj.AddPair('sub', E.Username)
|
||||||
|
else LObj.AddPair('sub', '');
|
||||||
|
LObj.AddPair('folder', E.Folder);
|
||||||
|
// Corpus de recherche pre-assemble et en minuscules : la page cherche a
|
||||||
|
// chaque frappe, autant ne pas recomposer la meme chaine a chaque fois.
|
||||||
|
// Il couvre ce que couvrait TVault.Search - nom, site, identifiant,
|
||||||
|
// etiquettes - donc l'identifiant reste trouvable meme quand la ligne
|
||||||
|
// affiche le site.
|
||||||
|
LObj.AddPair('s', (E.Title + ' ' + E.Site + ' ' + E.Username + ' ' +
|
||||||
|
E.Tags).ToLower);
|
||||||
|
LObj.AddPair('icon', E.IconB64);
|
||||||
|
LObj.AddPair('initial', Initial(E.Display));
|
||||||
|
LObj.AddPair('hasUser', TJSONBool.Create(E.Username <> ''));
|
||||||
|
LObj.AddPair('isNote', TJSONBool.Create(E.IsNote));
|
||||||
|
LEntries.AddElement(LObj);
|
||||||
|
end;
|
||||||
|
|
||||||
|
LFolders := TJSONArray.Create;
|
||||||
|
LRoot.AddPair('folders', LFolders);
|
||||||
|
for LFolder in AVault.Folders do
|
||||||
|
LFolders.Add(LFolder);
|
||||||
|
|
||||||
|
Result := LRoot.ToJSON;
|
||||||
|
finally
|
||||||
|
LRoot.Free;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
/// Les lignes d'une entree. UN SEUL ENDROIT decide de leur composition et de
|
||||||
|
/// leur ordre : DetailToJson et DetailValues en dependent tous les deux, et
|
||||||
|
/// un ecart entre eux ferait copier la valeur de la ligne voisine.
|
||||||
|
type
|
||||||
|
TDetailRow = record
|
||||||
|
Caption, Value: string;
|
||||||
|
Secret: Boolean;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function BuildRows(const E: TVaultEntry): TArray<TDetailRow>;
|
||||||
|
var
|
||||||
|
LRows: TArray<TDetailRow>;
|
||||||
|
I: Integer;
|
||||||
|
LMeta: string;
|
||||||
|
|
||||||
|
procedure Add(const ACaption, AValue: string; ASecret: Boolean);
|
||||||
|
begin
|
||||||
|
if AValue = '' then Exit; // une ligne vide n'apprend rien
|
||||||
|
SetLength(LRows, Length(LRows) + 1);
|
||||||
|
LRows[High(LRows)].Caption := ACaption;
|
||||||
|
LRows[High(LRows)].Value := AValue;
|
||||||
|
LRows[High(LRows)].Secret := ASecret;
|
||||||
|
end;
|
||||||
|
|
||||||
|
begin
|
||||||
|
Add('Site', E.Site, False);
|
||||||
|
Add('Username', E.Username, False);
|
||||||
|
// Le corps d'une note est range dans le champ mot de passe : meme donnee,
|
||||||
|
// autre intitule, et pas de raison de la masquer comme un secret.
|
||||||
|
if E.IsNote then Add('Content', E.Password, False)
|
||||||
|
else Add('Password', E.Password, True);
|
||||||
|
|
||||||
|
for I := 0 to High(E.Fields) do
|
||||||
|
Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret);
|
||||||
|
|
||||||
|
// Les pieces jointes ne sont pas encore enregistrables sur le telephone,
|
||||||
|
// mais taire leur existence serait pire : on saurait sur le poste qu'une
|
||||||
|
// entree porte un document, et pas ici.
|
||||||
|
for I := 0 to High(E.Attachments) do
|
||||||
|
Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes),
|
||||||
|
E.Attachments[I].FileName, False);
|
||||||
|
|
||||||
|
LMeta := E.Folder;
|
||||||
|
if E.Tags <> '' then
|
||||||
|
begin
|
||||||
|
if LMeta <> '' then LMeta := LMeta + ' - ';
|
||||||
|
LMeta := LMeta + E.Tags;
|
||||||
|
end;
|
||||||
|
Add('Folder / tags', LMeta, False);
|
||||||
|
Add('Type', E.Template, False);
|
||||||
|
// Horodatage tel quel : de l'ISO 8601 UTC, lisible, et le reformater
|
||||||
|
// demanderait de decider d'un fuseau alors qu'il n'y a rien a arbitrer.
|
||||||
|
Add('Updated', E.UpdatedAt, False);
|
||||||
|
|
||||||
|
Result := LRows;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function DetailToJson(const AEntry: TVaultEntry): string;
|
||||||
|
var
|
||||||
|
LRoot: TJSONObject;
|
||||||
|
LArr: TJSONArray;
|
||||||
|
LObj: TJSONObject;
|
||||||
|
LRows: TArray<TDetailRow>;
|
||||||
|
I: Integer;
|
||||||
|
begin
|
||||||
|
LRows := BuildRows(AEntry);
|
||||||
|
LRoot := TJSONObject.Create;
|
||||||
|
try
|
||||||
|
LRoot.AddPair('title', AEntry.Display);
|
||||||
|
LRoot.AddPair('totp', TJSONBool.Create(AEntry.Totp <> ''));
|
||||||
|
LArr := TJSONArray.Create;
|
||||||
|
LRoot.AddPair('rows', LArr);
|
||||||
|
for I := 0 to High(LRows) do
|
||||||
|
begin
|
||||||
|
LObj := TJSONObject.Create;
|
||||||
|
LObj.AddPair('c', LRows[I].Caption);
|
||||||
|
LObj.AddPair('v', LRows[I].Value);
|
||||||
|
LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret));
|
||||||
|
LArr.AddElement(LObj);
|
||||||
|
end;
|
||||||
|
Result := LRoot.ToJSON;
|
||||||
|
finally
|
||||||
|
LRoot.Free;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
||||||
|
out ACaption, AValue: string): Boolean;
|
||||||
|
var
|
||||||
|
LRows: TArray<TDetailRow>;
|
||||||
|
begin
|
||||||
|
LRows := BuildRows(AEntry);
|
||||||
|
Result := (AIndex >= 0) and (AIndex <= High(LRows));
|
||||||
|
if not Result then Exit;
|
||||||
|
ACaption := LRows[AIndex].Caption;
|
||||||
|
AValue := LRows[AIndex].Value;
|
||||||
|
end;
|
||||||
|
|
||||||
|
end.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Compile, empaquette, installe et lance le client Android.
|
||||||
|
#
|
||||||
|
# POURQUOI CE SCRIPT. La chaine a quatre etapes qui ne s'appellent pas entre
|
||||||
|
# elles, et en sauter une donne un APK qui s'installe sans porter la
|
||||||
|
# modification - le symptome se lit alors comme un bug applicatif. La premiere,
|
||||||
|
# web.res, s'est deja fait oublier une fois : la page embarquee etait celle
|
||||||
|
# d'avant l'edition, et le pont echouait pour une raison qui n'existait plus.
|
||||||
|
#
|
||||||
|
# -NoInstall compile et empaquette sans toucher au telephone.
|
||||||
|
|
||||||
|
param([switch]$NoInstall)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path $PSScriptRoot -Parent
|
||||||
|
$bin = 'C:\Program Files (x86)\Embarcadero\Studio\23.0\bin'
|
||||||
|
$sdk = 'C:\Users\Public\Documents\Embarcadero\Studio\23.0\CatalogRepository'
|
||||||
|
$adb = "$sdk\AndroidSDK-2525-23.0.51511.6924\platform-tools\Adb.exe"
|
||||||
|
$strip = "$sdk\AndroidNDK-21-23.0.51511.6924\android-ndk-r21\toolchains\" +
|
||||||
|
'aarch64-linux-android-4.9\prebuilt\windows-x86_64\aarch64-linux-android\bin\strip.exe'
|
||||||
|
$out = "$root\Android64\Debug"
|
||||||
|
$stage = "$out\pmserverApk"
|
||||||
|
|
||||||
|
# 1. La page HTML -> ressource liee dans l'executable.
|
||||||
|
Push-Location "$PSScriptRoot\web"
|
||||||
|
& "$bin\brcc32.exe" web.rc -foweb.res | Out-Null
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
# 2. Compilation Delphi.
|
||||||
|
$msbuild = "`"$bin\rsvars.bat`" && msbuild `"$root\pmserverApk.dproj`""
|
||||||
|
$log = cmd /c "$msbuild /p:Platform=Android64 /p:Config=Debug /t:Build /v:minimal /nologo" 2>&1
|
||||||
|
$err = $log | Select-String -Pattern 'error'
|
||||||
|
if ($err) { $err | ForEach-Object { $_.Line }; throw 'compilation echouee' }
|
||||||
|
$log | Select-String -Pattern 'lines,' | ForEach-Object { $_.Line.Trim() }
|
||||||
|
|
||||||
|
# 3. Mise en place a la main de ce que le deploiement ne place pas : manifeste,
|
||||||
|
# .dex, et la bibliotheque native ALLEGEE - la version de debogage pese
|
||||||
|
# 91 Mo, plus que la place libre sur ce volume.
|
||||||
|
Copy-Item "$out\AndroidManifest.xml" "$stage\AndroidManifest.xml" -Force
|
||||||
|
Copy-Item "$out\pmserverApk.classes\*.dex" "$stage\classes\" -Force
|
||||||
|
& $strip --strip-debug -o "$stage\library\lib\arm64-v8a\libpmserverApk.so" "$out\libpmserverApk.so"
|
||||||
|
|
||||||
|
cmd /c "$msbuild /p:Platform=Android64 /p:Config=Debug /t:Deploy /v:minimal /nologo" 2>&1 |
|
||||||
|
Select-String -Pattern 'error' | ForEach-Object { $_.Line }
|
||||||
|
|
||||||
|
# 4. aapt + zipalign + apksigner, et remise en place de ce que Deploy efface.
|
||||||
|
& "$PSScriptRoot\repack-apk.ps1" | Select-Object -Last 1
|
||||||
|
|
||||||
|
if ($NoInstall) { return }
|
||||||
|
|
||||||
|
# adb ecrit sa progression sur la sortie d'erreur, ce que PowerShell prend pour
|
||||||
|
# un echec. Sans ce relachement, une installation reussie interrompt le script.
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
& $adb install -r "$stage\bin\pmserverApk.apk" 2>&1 |
|
||||||
|
Select-String -Pattern 'Success|Failure' | ForEach-Object { $_.Line }
|
||||||
|
# Le telephone voit le poste comme 127.0.0.1 par ce renvoi.
|
||||||
|
& $adb reverse tcp:8080 tcp:80 | Out-Null
|
||||||
|
& $adb shell am force-stop com.embarcadero.pmserverApk
|
||||||
|
& $adb shell am start -n com.embarcadero.pmserverApk/com.embarcadero.firemonkey.FMXNativeActivity | Out-Null
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Interface du client Android. Liste, recherche, dossiers, detail.
|
||||||
|
|
||||||
|
ASCII UNIQUEMENT dans ce fichier. FMX charge la page par
|
||||||
|
loadDataWithBaseURL en laissant l'encodage a nil : le moteur lit alors de
|
||||||
|
l'UTF-8 comme du latin-1. Tout caractere hors ASCII doit s'ecrire en
|
||||||
|
reference numerique (م). Les DONNEES, elles, n'ont pas ce probleme :
|
||||||
|
elles arrivent par evaluateJavascript, qui passe par une chaine JNI.
|
||||||
|
|
||||||
|
CE QUI VIT ICI ET CE QUI N'Y VIT PAS. La liste recoit les noms, sites et
|
||||||
|
identifiants - ce qui est deja a l'ecran. Elle ne recoit AUCUN mot de passe :
|
||||||
|
copier depuis une ligne envoie un indice, et Delphi copie depuis le coffre.
|
||||||
|
Seule l'entree ouverte a ses valeurs ici, une a la fois, effacees a la
|
||||||
|
fermeture et au passage en arriere-plan.
|
||||||
|
-->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
|
<title>Vault</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-top: #3E6394;
|
||||||
|
--bg-bot: #1B3563;
|
||||||
|
--surface: rgba(255,255,255,.12);
|
||||||
|
--raised: rgba(255,255,255,.18);
|
||||||
|
--accent: rgba(255,255,255,.30);
|
||||||
|
--text: #fff;
|
||||||
|
--muted: rgba(232,240,250,.70);
|
||||||
|
--radius: 20px;
|
||||||
|
--radius-s: 14px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; height: 100%; overflow: hidden;
|
||||||
|
background: linear-gradient(var(--bg-top), var(--bg-bot)) fixed;
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.4 system-ui, "Roboto", sans-serif;
|
||||||
|
/* Le texte de l'interface ne se selectionne pas - un appui maintenu sur
|
||||||
|
une ligne doit ouvrir l'entree, pas armer une poignee de selection.
|
||||||
|
Les valeurs du detail font exception, plus bas. */
|
||||||
|
-webkit-user-select: none; user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- ecran : les deux vues glissent l'une sur l'autre ------------------ */
|
||||||
|
.screen {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
transition: transform .22s ease;
|
||||||
|
/* CHAQUE ECRAN PORTE SON FOND. Sans cela ils sont transparents, celui du
|
||||||
|
corps se voit a travers les deux, et la liste reste lisible sous le
|
||||||
|
detail - dont les blocs sont eux-memes du blanc translucide. */
|
||||||
|
background: linear-gradient(var(--bg-top), var(--bg-bot));
|
||||||
|
}
|
||||||
|
#detail { transform: translateX(100%); }
|
||||||
|
body.on-detail #list { transform: translateX(-22%); }
|
||||||
|
body.on-detail #detail { transform: translateX(0); }
|
||||||
|
|
||||||
|
.scroll { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0 12px 16px; }
|
||||||
|
|
||||||
|
/* --- barre de recherche ------------------------------------------------ */
|
||||||
|
.top { padding: 10px 12px 6px; display: flex; gap: 8px; }
|
||||||
|
.search { flex: 1; position: relative; }
|
||||||
|
.search input {
|
||||||
|
width: 100%; height: 46px; padding: 0 38px 0 40px;
|
||||||
|
border: 0; border-radius: var(--radius-s);
|
||||||
|
background: rgba(255,255,255,.10); color: var(--text);
|
||||||
|
font-size: 15px; outline: none;
|
||||||
|
}
|
||||||
|
.search input::placeholder { color: var(--muted); }
|
||||||
|
.glass, .clear {
|
||||||
|
position: absolute; top: 0; height: 46px; width: 38px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--muted); font-size: 17px;
|
||||||
|
}
|
||||||
|
.glass { left: 2px; }
|
||||||
|
.clear { right: 2px; display: none; font-size: 20px; }
|
||||||
|
body.searching .glass { display: none; }
|
||||||
|
body.searching .clear { display: flex; }
|
||||||
|
|
||||||
|
/* --- dossiers : une rangee de pastilles, pas un panneau ---------------- */
|
||||||
|
.folders {
|
||||||
|
display: flex; gap: 8px; overflow-x: auto; padding: 4px 12px 8px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.folders::-webkit-scrollbar { display: none; }
|
||||||
|
.chip {
|
||||||
|
flex: 0 0 auto; padding: 8px 14px; border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,.10); color: var(--muted);
|
||||||
|
font-size: 13px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip.on { background: var(--accent); color: var(--text); font-weight: 500; }
|
||||||
|
.chip .n { opacity: .6; margin-left: 6px; }
|
||||||
|
|
||||||
|
/* --- lignes ------------------------------------------------------------ */
|
||||||
|
.row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
background: var(--surface); border-radius: var(--radius);
|
||||||
|
padding: 10px 10px 10px 12px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.row:active { background: var(--raised); }
|
||||||
|
.av {
|
||||||
|
flex: 0 0 40px; height: 40px; border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,.18);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 600; font-size: 16px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.av img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.txt { flex: 1; min-width: 0; }
|
||||||
|
.txt b { display: block; font-weight: 500; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.txt span { display: block; color: var(--muted); font-size: 13px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.btn {
|
||||||
|
flex: 0 0 auto; border: 0; border-radius: var(--radius-s);
|
||||||
|
background: var(--raised); color: var(--text);
|
||||||
|
padding: 11px 13px; font-size: 13px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn:active { opacity: .62; }
|
||||||
|
.btn.wide { padding: 13px 18px; font-size: 15px; }
|
||||||
|
.btn.main { background: var(--accent); }
|
||||||
|
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 48px 16px; }
|
||||||
|
|
||||||
|
/* --- detail ------------------------------------------------------------ */
|
||||||
|
.head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
|
||||||
|
.head h1 { flex: 1; margin: 0; font-size: 19px; font-weight: 600;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.field {
|
||||||
|
background: var(--surface); border-radius: var(--radius);
|
||||||
|
padding: 10px 10px 12px 14px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.field .cap { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
|
||||||
|
.field .line { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.field .val {
|
||||||
|
flex: 1; min-width: 0; word-break: break-all;
|
||||||
|
-webkit-user-select: text; user-select: text;
|
||||||
|
}
|
||||||
|
.field .val.mask { letter-spacing: 2px; }
|
||||||
|
.field.totp .val { font-size: 30px; font-weight: 300; letter-spacing: 2px; }
|
||||||
|
.bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,.18);
|
||||||
|
margin-top: 10px; overflow: hidden; }
|
||||||
|
.bar i { display: block; height: 100%; background: var(--text);
|
||||||
|
transition: width 1s linear; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed; left: 12px; right: 12px; bottom: 14px;
|
||||||
|
background: rgba(10,20,40,.92); color: var(--text);
|
||||||
|
border-radius: var(--radius-s); padding: 12px 16px; font-size: 14px;
|
||||||
|
text-align: center; opacity: 0; transition: opacity .2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast.on { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="screen" id="list">
|
||||||
|
<div class="top">
|
||||||
|
<div class="search">
|
||||||
|
<div class="glass">⚲</div>
|
||||||
|
<input id="q" placeholder="Search" autocomplete="off" autocorrect="off"
|
||||||
|
autocapitalize="none" spellcheck="false">
|
||||||
|
<div class="clear" id="qx">×</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="folders" id="folders"></div>
|
||||||
|
<div class="scroll"><div id="rows"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screen" id="detail">
|
||||||
|
<div class="head">
|
||||||
|
<button class="btn" id="back">←</button>
|
||||||
|
<h1 id="dtitle"></h1>
|
||||||
|
</div>
|
||||||
|
<div class="scroll"><div id="fields"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// --- pont vers Delphi ------------------------------------------------------
|
||||||
|
// PAS location.href, UNE IFRAME JETABLE. FMX ecoute bien la navigation, mais
|
||||||
|
// il rend toujours "je ne la prends pas en charge" au moteur, qui essaie alors
|
||||||
|
// de charger cmd:// lui-meme, echoue sur un schema inconnu et remplace la page
|
||||||
|
// par son ecran d'erreur. Dans une iframe, l'echec reste confine et la page
|
||||||
|
// vit. C'est le prix a payer pour un pont sans interface Java.
|
||||||
|
//
|
||||||
|
// Et les commandes partent UNE PAR UNE : deux navigations dans le meme tour de
|
||||||
|
// boucle se fondent en une seule, et la premiere disparait - sur une copie,
|
||||||
|
// cela donnerait un presse-papiers vide sans rien signaler.
|
||||||
|
var queue = [], sending = false;
|
||||||
|
function send(cmd) {
|
||||||
|
queue.push(cmd);
|
||||||
|
if (!sending) drain();
|
||||||
|
}
|
||||||
|
function drain() {
|
||||||
|
if (!queue.length) { sending = false; return; }
|
||||||
|
sending = true;
|
||||||
|
var f = document.createElement('iframe');
|
||||||
|
f.style.display = 'none';
|
||||||
|
f.src = 'cmd://' + queue.shift();
|
||||||
|
document.body.appendChild(f);
|
||||||
|
setTimeout(function () {
|
||||||
|
f.parentNode.removeChild(f);
|
||||||
|
drain();
|
||||||
|
}, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = [], folders = [], folder = '', query = '', view = 'list';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- liste -----------------------------------------------------------------
|
||||||
|
function matches(e) {
|
||||||
|
if (folder && e.folder !== folder) return false;
|
||||||
|
if (!query) return true;
|
||||||
|
return (e.name + ' ' + e.sub + ' ' + (e.tags || '')).toLowerCase()
|
||||||
|
.indexOf(query) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolders() {
|
||||||
|
var counts = {}, total = 0, i;
|
||||||
|
for (i = 0; i < entries.length; i++) {
|
||||||
|
counts[entries[i].folder] = (counts[entries[i].folder] || 0) + 1;
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
var html = '<div class="chip' + (folder ? '' : ' on') + '" data-f="">All'
|
||||||
|
+ '<span class="n">' + total + '</span></div>';
|
||||||
|
for (i = 0; i < folders.length; i++) {
|
||||||
|
var f = folders[i], n = counts[f] || 0;
|
||||||
|
if (!n) continue; // un dossier vide n'a rien a offrir
|
||||||
|
html += '<div class="chip' + (folder === f ? ' on' : '') + '" data-f="'
|
||||||
|
+ esc(f) + '">' + esc(f) + '<span class="n">' + n + '</span></div>';
|
||||||
|
}
|
||||||
|
document.getElementById('folders').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
var html = '', shown = 0;
|
||||||
|
for (var i = 0; i < entries.length; i++) {
|
||||||
|
var e = entries[i];
|
||||||
|
if (!matches(e)) continue;
|
||||||
|
shown++;
|
||||||
|
html += '<div class="row" data-i="' + e.i + '">'
|
||||||
|
+ '<div class="av">'
|
||||||
|
+ (e.icon ? '<img src="' + esc(e.icon) + '">' : esc(e.initial))
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="txt"><b>' + esc(e.name) + '</b>'
|
||||||
|
+ (e.sub ? '<span>' + esc(e.sub) + '</span>' : '') + '</div>'
|
||||||
|
+ (e.hasUser ? '<button class="btn" data-copy="user">User</button>' : '')
|
||||||
|
+ '<button class="btn" data-copy="pwd">' + (e.isNote ? 'Text' : 'Pass')
|
||||||
|
+ '</button></div>';
|
||||||
|
}
|
||||||
|
document.getElementById('rows').innerHTML = shown ? html
|
||||||
|
: '<div class="empty">Nothing here</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un seul ecouteur pour toute la liste : les lignes sont refaites a chaque
|
||||||
|
// frappe, en attacher un par bouton les recreerait autant de fois.
|
||||||
|
document.getElementById('rows').addEventListener('click', function (ev) {
|
||||||
|
var row = ev.target.closest('.row');
|
||||||
|
if (!row) return;
|
||||||
|
var copy = ev.target.getAttribute('data-copy');
|
||||||
|
if (copy) send('copy?i=' + row.getAttribute('data-i') + '&f=' + copy);
|
||||||
|
else send('open?i=' + row.getAttribute('data-i'));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('folders').addEventListener('click', function (ev) {
|
||||||
|
var chip = ev.target.closest('.chip');
|
||||||
|
if (!chip) return;
|
||||||
|
folder = chip.getAttribute('data-f');
|
||||||
|
renderFolders();
|
||||||
|
renderRows();
|
||||||
|
send('touch');
|
||||||
|
});
|
||||||
|
|
||||||
|
var q = document.getElementById('q');
|
||||||
|
q.addEventListener('input', function () {
|
||||||
|
query = q.value.trim().toLowerCase();
|
||||||
|
document.body.classList.toggle('searching', q.value !== '');
|
||||||
|
renderRows();
|
||||||
|
send('touch');
|
||||||
|
});
|
||||||
|
document.getElementById('qx').addEventListener('click', function () {
|
||||||
|
q.value = ''; query = '';
|
||||||
|
document.body.classList.remove('searching');
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- detail ----------------------------------------------------------------
|
||||||
|
var detailRows = [];
|
||||||
|
|
||||||
|
function renderDetail(d) {
|
||||||
|
detailRows = d.rows || [];
|
||||||
|
document.getElementById('dtitle').textContent = d.title || '';
|
||||||
|
var html = '', i;
|
||||||
|
if (d.totp)
|
||||||
|
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
|
||||||
|
+ '<div class="line"><div class="val" id="totpv">------</div>'
|
||||||
|
+ '<button class="btn" data-copy="totp">Copy</button></div>'
|
||||||
|
+ '<div class="bar"><i id="totpb"></i></div></div>';
|
||||||
|
for (i = 0; i < detailRows.length; i++) {
|
||||||
|
var r = detailRows[i];
|
||||||
|
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div>'
|
||||||
|
+ '<div class="line"><div class="val' + (r.s ? ' mask' : '')
|
||||||
|
+ '" data-v="' + i + '">'
|
||||||
|
+ (r.s ? '•'.repeat(Math.min(r.v.length, 14)) : esc(r.v))
|
||||||
|
+ '</div>'
|
||||||
|
+ (r.s ? '<button class="btn" data-show="' + i + '">Show</button>' : '')
|
||||||
|
+ '<button class="btn" data-copy="row" data-k="' + i + '">Copy</button>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}
|
||||||
|
document.getElementById('fields').innerHTML = html;
|
||||||
|
document.body.classList.add('on-detail');
|
||||||
|
view = 'detail';
|
||||||
|
send('state?view=detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('fields').addEventListener('click', function (ev) {
|
||||||
|
var t = ev.target, k = t.getAttribute('data-show');
|
||||||
|
if (k !== null) {
|
||||||
|
var cell = document.querySelector('[data-v="' + k + '"]');
|
||||||
|
var hidden = cell.classList.contains('mask');
|
||||||
|
cell.classList.toggle('mask', !hidden);
|
||||||
|
cell.textContent = hidden ? detailRows[k].v
|
||||||
|
: '\u2022'.repeat(Math.min(detailRows[k].v.length, 14));
|
||||||
|
t.textContent = hidden ? 'Hide' : 'Show';
|
||||||
|
send('touch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var copy = t.getAttribute('data-copy');
|
||||||
|
if (copy === 'totp') send('copy?f=totp');
|
||||||
|
else if (copy === 'row') send('copy?f=row&k=' + t.getAttribute('data-k'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function goList() {
|
||||||
|
document.body.classList.remove('on-detail');
|
||||||
|
view = 'list';
|
||||||
|
// Les valeurs en clair partent avec la vue, pas seulement de l'ecran.
|
||||||
|
detailRows = [];
|
||||||
|
document.getElementById('fields').innerHTML = '';
|
||||||
|
send('state?view=list');
|
||||||
|
}
|
||||||
|
document.getElementById('back').addEventListener('click', goList);
|
||||||
|
|
||||||
|
// --- appele par Delphi -----------------------------------------------------
|
||||||
|
var PM = {
|
||||||
|
setVault: function (json) {
|
||||||
|
var d = JSON.parse(json);
|
||||||
|
entries = d.entries || [];
|
||||||
|
folders = d.folders || [];
|
||||||
|
folder = '';
|
||||||
|
q.value = ''; query = '';
|
||||||
|
document.body.classList.remove('searching', 'on-detail');
|
||||||
|
view = 'list';
|
||||||
|
renderFolders();
|
||||||
|
renderRows();
|
||||||
|
},
|
||||||
|
setDetail: function (json) { renderDetail(JSON.parse(json)); },
|
||||||
|
setTotp: function (code, left, period) {
|
||||||
|
var v = document.getElementById('totpv');
|
||||||
|
if (!v) return;
|
||||||
|
v.textContent = code.length === 6 ? code.slice(0, 3) + ' ' + code.slice(3) : code;
|
||||||
|
var b = document.getElementById('totpb');
|
||||||
|
// Le retour a plein ne s'anime pas : la transition jouerait le saut a
|
||||||
|
// l'envers et donnerait l'impression que la barre se fige.
|
||||||
|
b.style.transition = left >= period - 1 ? 'none' : 'width 1s linear';
|
||||||
|
b.style.width = (left / period * 100) + '%';
|
||||||
|
},
|
||||||
|
toast: function (text) {
|
||||||
|
var t = document.getElementById('toast');
|
||||||
|
t.textContent = text;
|
||||||
|
t.classList.add('on');
|
||||||
|
clearTimeout(t._h);
|
||||||
|
t._h = setTimeout(function () { t.classList.remove('on'); }, 2600);
|
||||||
|
},
|
||||||
|
back: function () { if (view === 'detail') goList(); },
|
||||||
|
// Efface tout ce qui est en clair. Appele au passage en arriere-plan et au
|
||||||
|
// verrouillage : le tas JS n'est pas efface par le masquage de la vue.
|
||||||
|
wipe: function () {
|
||||||
|
entries = []; folders = []; detailRows = [];
|
||||||
|
document.getElementById('rows').innerHTML = '';
|
||||||
|
document.getElementById('fields').innerHTML = '';
|
||||||
|
document.getElementById('folders').innerHTML = '';
|
||||||
|
q.value = '';
|
||||||
|
document.body.classList.remove('on-detail', 'searching');
|
||||||
|
view = 'list';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.PM = PM;
|
||||||
|
</script>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PMAPP RCDATA "app.html"
|
||||||
+212
-944
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -23,7 +23,8 @@ uses
|
|||||||
PMA.Totp in 'android-client\PMA.Totp.pas',
|
PMA.Totp in 'android-client\PMA.Totp.pas',
|
||||||
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
|
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
|
||||||
PMA.Clipboard in 'android-client\PMA.Clipboard.pas',
|
PMA.Clipboard in 'android-client\PMA.Clipboard.pas',
|
||||||
PMA.Theme in 'android-client\PMA.Theme.pas';
|
PMA.Theme in 'android-client\PMA.Theme.pas',
|
||||||
|
PMA.WebUI in 'android-client\PMA.WebUI.pas';
|
||||||
|
|
||||||
{$R *.res}
|
{$R *.res}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user