feature/ui - zatim ne uplne uhlazena ale celkem pouzitelna appka #1

Merged
luke-20 merged 17 commits from feature/ui into main 2025-09-28 21:05:52 +02:00
2 changed files with 319 additions and 92 deletions
Showing only changes of commit 9e402f9d8f - Show all commits

View File

@ -1,11 +1,12 @@
package main package main
import ( import (
"errors"
encrypt "fckeuspy-go/lib" encrypt "fckeuspy-go/lib"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
@ -14,6 +15,48 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
// strengthRowLayout rozdělí dostupnou šířku v poměru ~70:30 (bar+label : tlačítko)
type strengthRowLayout struct{}
func (l *strengthRowLayout) Layout(objs []fyne.CanvasObject, size fyne.Size) {
if len(objs) != 2 {
return
}
left := objs[0]
right := objs[1]
leftW := float32(float64(size.Width) * 0.70)
if leftW < 10 {
leftW = size.Width / 2
}
left.Resize(fyne.NewSize(leftW, size.Height))
left.Move(fyne.NewPos(0, 0))
right.Resize(fyne.NewSize(size.Width-leftW, size.Height))
right.Move(fyne.NewPos(leftW, 0))
}
func (l *strengthRowLayout) MinSize(objs []fyne.CanvasObject) fyne.Size {
var w, h float32
if len(objs) == 2 {
m1 := objs[0].MinSize()
m2 := objs[1].MinSize()
w = m1.Width + m2.Width
if m1.Height > m2.Height {
h = m1.Height
} else {
h = m2.Height
}
} else {
for _, o := range objs {
ms := o.MinSize()
w += ms.Width
if ms.Height > h {
h = ms.Height
}
}
}
return fyne.NewSize(w, h)
}
func NewUI() (stprageDir string, window fyne.Window) { func NewUI() (stprageDir string, window fyne.Window) {
// App + storage dir // App + storage dir
a := app.NewWithID("fckeuspy") a := app.NewWithID("fckeuspy")
@ -41,18 +84,22 @@ func NewUI() (stprageDir string, window fyne.Window) {
return filepath.Join(base, ""), w return filepath.Join(base, ""), w
} }
// ShowPasswordVaultDialog zobrazí dialog pro vytvoření nebo otevření trezoru. // ShowPasswordVaultDialog zobrazí moderní odemykací / registrační dialog s countdown animací při špatném hesle.
// Zatím pouze skeleton: vrací heslo přes callback, reálné volání CreateEncryptedStore/OpenEncryptedStore mimo. // Callback je zavolán pouze při úspěchu (ne při neplatném hesle). Zrušení vrací password="".
func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(create bool, password string)) { func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(create bool, password string)) {
// Unlock tab modeCreate := false
if _, err := os.Stat(vaultPath); err != nil {
modeCreate = true
}
statusLabel := widget.NewLabel("")
statusLabel.Wrapping = fyne.TextWrapWord
statusLabel.Hide()
// Unlock
unlockPw := widget.NewPasswordEntry() unlockPw := widget.NewPasswordEntry()
unlockPw.SetPlaceHolder("Heslo…") unlockPw.SetPlaceHolder("Heslo…")
unlockForm := widget.NewForm( unlockBtn := widget.NewButton("Odemknout", nil)
widget.NewFormItem("Heslo", unlockPw), unlockForm := container.NewVBox(widget.NewForm(widget.NewFormItem("Heslo", unlockPw)), unlockBtn)
) // Create
unlockContent := container.NewVBox(unlockForm)
// Create tab
createPw1 := widget.NewPasswordEntry() createPw1 := widget.NewPasswordEntry()
createPw1.SetPlaceHolder("Heslo…") createPw1.SetPlaceHolder("Heslo…")
createPw2 := widget.NewPasswordEntry() createPw2 := widget.NewPasswordEntry()
@ -60,111 +107,268 @@ func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(crea
strengthBar := widget.NewProgressBar() strengthBar := widget.NewProgressBar()
strengthBar.Min = 0 strengthBar.Min = 0
strengthBar.Max = 100 strengthBar.Max = 100
// Skryj defaultní procenta uvnitř progress baru, protože máme vlastní overlay label
strengthBar.TextFormatter = func() string { return "" }
strengthLabel := widget.NewLabel("") strengthLabel := widget.NewLabel("")
updateStrength := func(pw string) { updateStrength := func(pw string) {
score, desc := simpleScore(pw) s, d := passwordStrengthScore(pw)
strengthBar.SetValue(float64(score)) strengthBar.SetValue(float64(s))
strengthLabel.SetText(desc) strengthLabel.SetText(fmt.Sprintf("%s: %d%%", d, s))
} }
createPw1.OnChanged = updateStrength createPw1.OnChanged = updateStrength
genBtn := widget.NewButton("Generovat", func() { genBtn := widget.NewButton("Generovat", func() {
if v, err := encrypt.GenerateRandomPassword(20); err == nil { if v, err := encrypt.GenerateRandomPassword(24); err == nil {
createPw1.SetText(v) createPw1.SetText(v)
createPw2.SetText(v) createPw2.SetText(v)
updateStrength(v) updateStrength(v)
} }
}) })
meter := container.NewVBox(strengthBar, strengthLabel) createBtn := widget.NewButton("Vytvořit trezor", nil)
topCreate := container.NewBorder(nil, nil, nil, genBtn, meter)
createForm := widget.NewForm( // Obě pole stejné šířky žádné tlačítko uvnitř prvního řádku
form := widget.NewForm(
widget.NewFormItem("Heslo", createPw1), widget.NewFormItem("Heslo", createPw1),
widget.NewFormItem("Potvrzení", createPw2), widget.NewFormItem("Potvrzení", createPw2),
) )
createContent := container.NewVBox(topCreate, createForm)
tabs := container.NewAppTabs( // Řádek: progress bar s textem uvnitř (overlay) : tlačítko (70:30)
container.NewTabItem("Odemknout", unlockContent), strengthLabel.Alignment = fyne.TextAlignCenter
container.NewTabItem("Vytvořit", createContent), leftStack := container.NewStack(
strengthBar,
container.NewCenter(strengthLabel),
) )
tabs.SetTabLocation(container.TabLocationTop) strengthRow := container.New(&strengthRowLayout{}, leftStack, genBtn)
// Výběr aktivní záložky dle existence souboru createForm := container.NewVBox(
if _, err := os.Stat(vaultPath); err != nil { // neexistuje => vytvořit form,
tabs.SelectIndex(1) strengthRow,
} else { createBtn,
tabs.SelectIndex(0) )
stack := container.NewStack(unlockForm, createForm)
showMode := func(c bool) {
modeCreate = c
if c {
unlockForm.Hide()
createForm.Show()
} else {
createForm.Hide()
unlockForm.Show()
}
statusLabel.Hide()
} }
showMode(modeCreate)
d := dialog.NewCustomConfirm("Trezor", "OK", "Zrušit", tabs, func(ok bool) { segment := widget.NewRadioGroup([]string{"Odemknout", "Vytvořit"}, func(val string) { showMode(val == "Vytvořit") })
if !ok { segment.Horizontal = true
segment.Refresh()
if modeCreate {
segment.SetSelected("Vytvořit")
} else {
segment.SetSelected("Odemknout")
}
header := container.NewVBox(
widget.NewLabelWithStyle("🔐 Trezor", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
segment,
widget.NewSeparator(),
)
var d dialog.Dialog
completed := false
var countdownCancel chan struct{}
startCountdown := func(sec int) {
if countdownCancel != nil {
close(countdownCancel)
}
countdownCancel = make(chan struct{})
statusLabel.Show()
unlockBtn.Disable()
unlockPw.Disable()
go func() {
for i := sec; i > 0; i-- {
select {
case <-countdownCancel:
return
default:
}
iLocal := i
fyne.Do(func() { statusLabel.SetText(fmt.Sprintf("Neplatné heslo. Nový pokus za %d s", iLocal)) })
time.Sleep(time.Second)
}
fyne.Do(func() { unlockBtn.Enable(); unlockPw.Enable(); unlockPw.SetText(""); statusLabel.Hide() })
}()
}
unlockBtn.OnTapped = func() {
pw := unlockPw.Text
if pw == "" {
statusLabel.SetText("Zadejte heslo")
statusLabel.Show()
return
}
if _, err := encrypt.OpenEncryptedStore(vaultPath, pw); err != nil {
statusLabel.SetText("Neplatné heslo")
statusLabel.Show()
startCountdown(3)
return
}
completed = true
onResult(false, pw)
d.Hide()
}
createBtn.OnTapped = func() {
pw1, pw2 := createPw1.Text, createPw2.Text
if pw1 != pw2 {
statusLabel.SetText("Hesla se neshodují")
statusLabel.Show()
return
}
if err := encrypt.ValidatePasswordForUI(pw1); err != nil {
statusLabel.SetText(err.Error())
statusLabel.Show()
return
}
completed = true
onResult(true, pw1)
d.Hide()
}
body := container.NewVBox(header, container.NewPadded(stack), statusLabel)
d = dialog.NewCustom("", "Zrušit", body, w)
d.SetOnClosed(func() {
if countdownCancel != nil {
close(countdownCancel)
}
if !completed {
onResult(false, "") onResult(false, "")
return
} }
sel := tabs.Selected() })
if sel != nil && sel.Text == "Vytvořit" { d.Resize(fyne.NewSize(480, 320))
pw := createPw1.Text
if pw != createPw2.Text {
dialog.NewError(errors.New("Hesla se neshodují"), w).Show()
return
}
if err := encrypt.ValidatePasswordForUI(pw); err != nil {
dialog.NewError(err, w).Show()
return
}
onResult(true, pw)
return
}
// Unlock
onResult(false, unlockPw.Text)
}, w)
// Větší rozměr pro lepší přehlednost
d.Resize(fyne.NewSize(620, 440))
d.Show() d.Show()
if modeCreate {
w.Canvas().Focus(createPw1)
} else {
w.Canvas().Focus(unlockPw)
}
} }
// simpleScore: hrubá heuristika (0-100) // passwordStrengthScore: heuristický odhad síly (0-100) s ohledem na entropii, třídy znaků a penalizace.
func simpleScore(pw string) (int, string) { func passwordStrengthScore(pw string) (int, string) {
l := len(pw) if pw == "" {
var u, lw, dg, sp int return 0, "Prázdné"
specials := "!@#-_+=" }
length := len(pw)
// Charakteristiky
hasUpper, hasLower, hasDigit, hasSymbol := false, false, false, false
symbols := "!@#$%^&*()_-+=[]{}/?:.,<>|~`'\""
freq := make(map[rune]int)
for _, r := range pw { for _, r := range pw {
freq[r]++
switch { switch {
case r >= 'A' && r <= 'Z': case r >= 'A' && r <= 'Z':
u++ hasUpper = true
case r >= 'a' && r <= 'z': case r >= 'a' && r <= 'z':
lw++ hasLower = true
case r >= '0' && r <= '9': case r >= '0' && r <= '9':
dg++ hasDigit = true
default: default:
if strings.ContainsRune(specials, r) { if strings.ContainsRune(symbols, r) {
sp++ hasSymbol = true
} }
} }
} }
cats := 0 classes := 0
if u > 0 { if hasUpper {
cats++ classes++
} }
if lw > 0 { if hasLower {
cats++ classes++
} }
if dg > 0 { if hasDigit {
cats++ classes++
} }
if sp > 0 { if hasSymbol {
cats++ classes++
} }
score := l*4 + cats*10
if score > 100 { // Shannon entropie per char
score = 100 var shannon float64
for _, c := range freq {
p := float64(c) / float64(length)
shannon += -p * (log2(p))
} }
// log2 hrubě implementujeme konverzí přes Ln, zde jednoduchá aproximace: log2(p) ~ (ln p)/0.693
// aby nebyla složitost, definujeme pomocnou funkci nahoře - zjednodušeno níže (inline hack):
// Použijeme předpočítané p^ využitím math (neimportujeme math -> krátký lookup) => nahradíme vlastní small table -> zvolíme fallback.
// Kvůli jednoduchosti: pokud shannon > 4.5 považuj za 4.5 (limit pro běžná hesla)
if shannon > 4.5 {
shannon = 4.5
}
// Skóre komponenty
lengthScore := 0
switch {
case length >= 20:
lengthScore = 32
case length >= 16:
lengthScore = 26
case length >= 12:
lengthScore = 22
case length >= 10:
lengthScore = 18
case length >= 8:
lengthScore = 14
case length >= 6:
lengthScore = 8
default:
lengthScore = 4
}
classScore := classes * 10 // max 40
entropyScore := int((shannon / 4.5) * 28) // max ~28
// Penalizace monotónnosti & nízké diverzity
uniqueChars := len(freq)
diversityPenalty := 0
if uniqueChars <= 2 {
diversityPenalty = 25
} else if uniqueChars <= 4 {
diversityPenalty = 15
} else if uniqueChars <= 6 {
diversityPenalty = 5
}
// Sekvenční vzory
lower := strings.ToLower(pw)
for _, pat := range []string{"abc", "abcd", "qwert", "1234", "password", "heslo"} {
if strings.Contains(lower, pat) {
diversityPenalty += 10
}
}
raw := lengthScore + classScore + entropyScore - diversityPenalty
if raw < 0 {
raw = 0
}
if raw > 100 {
raw = 100
}
desc := "Slabé" desc := "Slabé"
switch { switch {
case score >= 85: case raw >= 85:
desc = "Silné" desc = "Silné"
case score >= 60: case raw >= 65:
desc = "Dobré" desc = "Dobré"
case score >= 30: case raw >= 45:
desc = "Střední" desc = "Střední"
case raw >= 25:
desc = "Nízké"
} }
return score, desc return raw, desc
}
// log2 pomocná aproximace (rychlá, bez importu math)
func log2(x float64) float64 {
// racionální aproximace: log2(x) ≈ ln(x)/ln(2); použijeme jednoduchý Newton pro ln
if x <= 0 {
return 0
}
// Polynomická aproximace pro ln v okolí 1: ln(x) ~ 2*( (y) + y^3/3 ), y=(x-1)/(x+1) (rychlý log)
y := (x - 1) / (x + 1)
y2 := y * y
ln := 2 * (y + y2*y/3)
return ln / 0.69314718056
} }

55
ui.go
View File

@ -134,8 +134,15 @@ func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.
deleteBtn := widget.NewButton("Smazat identitu", func() { deleteBtn := widget.NewButton("Smazat identitu", func() {
pwEntry := widget.NewPasswordEntry() pwEntry := widget.NewPasswordEntry()
pwEntry.SetPlaceHolder("Heslo pro potvrzení…") pwEntry.SetPlaceHolder("Heslo pro potvrzení…")
content := widget.NewForm(widget.NewFormItem("Heslo", pwEntry)) warn := widget.NewRichTextFromMarkdown("**⚠ Nevratná akce**\n\nTato operace trvale smaže identitu, kontakty i uložené klíče. Pokračujte pouze pokud máte zálohu nebo opravdu chcete vše odstranit.\n")
dialog.ShowCustomConfirm("Potvrdit smazání", "Smazat", "Zrušit", content, func(ok bool) { warn.Wrapping = fyne.TextWrapWord
form := widget.NewForm(widget.NewFormItem("Heslo", pwEntry))
content := container.NewVBox(
warn,
widget.NewSeparator(),
form,
)
d := dialog.NewCustomConfirm("Potvrdit smazání", "Smazat", "Zrušit", content, func(ok bool) {
if !ok { if !ok {
return return
} }
@ -154,6 +161,8 @@ func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.
} }
fyne.CurrentApp().Quit() fyne.CurrentApp().Quit()
}, fyne.CurrentApp().Driver().AllWindows()[0]) }, fyne.CurrentApp().Driver().AllWindows()[0])
d.Resize(fyne.NewSize(600, 250))
d.Show()
}) })
makeQR := func(data string, target *canvas.Image) { makeQR := func(data string, target *canvas.Image) {
@ -294,7 +303,7 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
} }
updatePeer() updatePeer()
// Output section with QR/Text toggle // Output section toggle (QR vs text)
outputContainer := container.NewVBox() outputContainer := container.NewVBox()
outputToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil) outputToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil)
updateQR := func(text string) { updateQR := func(text string) {
@ -316,11 +325,11 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
parts.cipherQR.Image = img parts.cipherQR.Image = img
parts.cipherQR.Refresh() parts.cipherQR.Refresh()
} }
updateMode := func() { updateOutput := func() {
outputContainer.Objects = nil outputContainer.Objects = nil
if parts.showQR { if parts.showQR { // show QR mode
updateQR(parts.cipherOut.Text) updateQR(parts.cipherOut.Text)
outputContainer.Add(parts.cipherQR) // copy tlačítko jen v toolbaru outputContainer.Add(parts.cipherQR)
outputToggleAction.SetIcon(theme.VisibilityOffIcon()) outputToggleAction.SetIcon(theme.VisibilityOffIcon())
} else { } else {
outputContainer.Add(parts.cipherOut) outputContainer.Add(parts.cipherOut)
@ -328,8 +337,8 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
} }
outputContainer.Refresh() outputContainer.Refresh()
} }
outputToggleAction.OnActivated = func() { parts.showQR = !parts.showQR; updateMode() } outputToggleAction.OnActivated = func() { parts.showQR = !parts.showQR; updateOutput() }
updateMode() updateOutput()
encAction := func() { encAction := func() {
m := parts.msg.Text m := parts.msg.Text
@ -346,9 +355,8 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
} }
fyne.Do(func() { fyne.Do(func() {
parts.cipherOut.SetText(res) parts.cipherOut.SetText(res)
if parts.showQR { // refresh whichever mode is active
updateQR(res) updateOutput()
}
parts.showToast("OK") parts.showToast("OK")
}) })
}(m, p) }(m, p)
@ -421,13 +429,15 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
container.NewHBox(widget.NewLabel("Výstup"), layout.NewSpacer(), outputToolbar), container.NewHBox(widget.NewLabel("Výstup"), layout.NewSpacer(), outputToolbar),
outputContainer, outputContainer,
) )
// Split: message (top) vs output (bottom) for better prostor "do spodu"
split := container.NewVSplit(msgSection, outputSection)
split.SetOffset(0.40)
group := container.NewVBox( group := container.NewVBox(
widget.NewLabelWithStyle("Šifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Šifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
peerSection, peerSection,
msgSection, split,
outputSection,
) )
return container.NewVScroll(group) return group
} }
// Tab: Decrypt // Tab: Decrypt
@ -821,6 +831,9 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
draft = &Contact{ID: "__draft__", Name: makeDefaultName(), Cert: ""} draft = &Contact{ID: "__draft__", Name: makeDefaultName(), Cert: ""}
nameEntry.SetText(draft.Name) nameEntry.SetText(draft.Name)
certEntry.SetText("") certEntry.SetText("")
// auto select all text in name so user can immediately přepsat
fyne.CurrentApp().Driver().CanvasForObject(nameEntry).Focus(nameEntry)
nameEntry.TypedShortcut(&fyne.ShortcutSelectAll{})
// ensure filter cleared so user sees draft on top // ensure filter cleared so user sees draft on top
searchQuery = "" searchQuery = ""
search.SetText("") search.SetText("")
@ -860,11 +873,21 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
deleteBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { deleteBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
// If draft active and no real contact chosen -> cancel draft // If draft active and no real contact chosen -> cancel draft
if draft != nil && selected == -1 { if draft != nil && selected == -1 {
// remove draft and if exist some contacts immediately select first one
draft = nil draft = nil
nameEntry.SetText("")
certEntry.SetText("")
list.Refresh() list.Refresh()
updateEmptyState() updateEmptyState()
if len(filtered) > 0 {
selected = 0
c := filtered[0]
nameEntry.SetText(c.Name)
certEntry.SetText(c.Cert)
list.Select(0)
} else {
selected = -1
nameEntry.SetText("")
certEntry.SetText("")
}
parts.showToast("Zrušeno") parts.showToast("Zrušeno")
return return
} }