diff --git a/fyne_ui.go b/fyne_ui.go index b94013c..0d6c456 100644 --- a/fyne_ui.go +++ b/fyne_ui.go @@ -1,11 +1,12 @@ package main import ( - "errors" encrypt "fckeuspy-go/lib" + "fmt" "os" "path/filepath" "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -14,6 +15,48 @@ import ( "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) { // App + storage dir a := app.NewWithID("fckeuspy") @@ -41,18 +84,22 @@ func NewUI() (stprageDir string, window fyne.Window) { return filepath.Join(base, ""), w } -// ShowPasswordVaultDialog zobrazí dialog pro vytvoření nebo otevření trezoru. -// Zatím pouze skeleton: vrací heslo přes callback, reálné volání CreateEncryptedStore/OpenEncryptedStore mimo. +// ShowPasswordVaultDialog zobrazí moderní odemykací / registrační dialog s countdown animací při špatném hesle. +// 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)) { - // 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.SetPlaceHolder("Heslo…") - unlockForm := widget.NewForm( - widget.NewFormItem("Heslo", unlockPw), - ) - unlockContent := container.NewVBox(unlockForm) - - // Create tab + unlockBtn := widget.NewButton("Odemknout", nil) + unlockForm := container.NewVBox(widget.NewForm(widget.NewFormItem("Heslo", unlockPw)), unlockBtn) + // Create createPw1 := widget.NewPasswordEntry() createPw1.SetPlaceHolder("Heslo…") createPw2 := widget.NewPasswordEntry() @@ -60,111 +107,268 @@ func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(crea strengthBar := widget.NewProgressBar() strengthBar.Min = 0 strengthBar.Max = 100 + // Skryj defaultní procenta uvnitř progress baru, protože máme vlastní overlay label + strengthBar.TextFormatter = func() string { return "" } strengthLabel := widget.NewLabel("") updateStrength := func(pw string) { - score, desc := simpleScore(pw) - strengthBar.SetValue(float64(score)) - strengthLabel.SetText(desc) + s, d := passwordStrengthScore(pw) + strengthBar.SetValue(float64(s)) + strengthLabel.SetText(fmt.Sprintf("%s: %d%%", d, s)) } createPw1.OnChanged = updateStrength genBtn := widget.NewButton("Generovat", func() { - if v, err := encrypt.GenerateRandomPassword(20); err == nil { + if v, err := encrypt.GenerateRandomPassword(24); err == nil { createPw1.SetText(v) createPw2.SetText(v) updateStrength(v) } }) - meter := container.NewVBox(strengthBar, strengthLabel) - topCreate := container.NewBorder(nil, nil, nil, genBtn, meter) - createForm := widget.NewForm( + createBtn := widget.NewButton("Vytvořit trezor", nil) + + // Obě pole stejné šířky – žádné tlačítko uvnitř prvního řádku + form := widget.NewForm( widget.NewFormItem("Heslo", createPw1), widget.NewFormItem("Potvrzení", createPw2), ) - createContent := container.NewVBox(topCreate, createForm) - tabs := container.NewAppTabs( - container.NewTabItem("Odemknout", unlockContent), - container.NewTabItem("Vytvořit", createContent), + // Řádek: progress bar s textem uvnitř (overlay) : tlačítko (70:30) + strengthLabel.Alignment = fyne.TextAlignCenter + leftStack := container.NewStack( + strengthBar, + container.NewCenter(strengthLabel), ) - tabs.SetTabLocation(container.TabLocationTop) - // Výběr aktivní záložky dle existence souboru - if _, err := os.Stat(vaultPath); err != nil { // neexistuje => vytvořit - tabs.SelectIndex(1) - } else { - tabs.SelectIndex(0) + strengthRow := container.New(&strengthRowLayout{}, leftStack, genBtn) + createForm := container.NewVBox( + form, + strengthRow, + createBtn, + ) + stack := container.NewStack(unlockForm, createForm) + showMode := func(c bool) { + modeCreate = c + if c { + unlockForm.Hide() + createForm.Show() + } else { + createForm.Hide() + unlockForm.Show() + } + statusLabel.Hide() } - - d := dialog.NewCustomConfirm("Trezor", "OK", "Zrušit", tabs, func(ok bool) { - if !ok { + showMode(modeCreate) + segment := widget.NewRadioGroup([]string{"Odemknout", "Vytvořit"}, func(val string) { showMode(val == "Vytvořit") }) + 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, "") - return } - sel := tabs.Selected() - if sel != nil && sel.Text == "Vytvořit" { - 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.Resize(fyne.NewSize(480, 320)) d.Show() + if modeCreate { + w.Canvas().Focus(createPw1) + } else { + w.Canvas().Focus(unlockPw) + } } -// simpleScore: hrubá heuristika (0-100) -func simpleScore(pw string) (int, string) { - l := len(pw) - var u, lw, dg, sp int - specials := "!@#-_+=" +// passwordStrengthScore: heuristický odhad síly (0-100) s ohledem na entropii, třídy znaků a penalizace. +func passwordStrengthScore(pw string) (int, string) { + if pw == "" { + return 0, "Prázdné" + } + length := len(pw) + // Charakteristiky + hasUpper, hasLower, hasDigit, hasSymbol := false, false, false, false + symbols := "!@#$%^&*()_-+=[]{}/?:.,<>|~`'\"" + freq := make(map[rune]int) for _, r := range pw { + freq[r]++ switch { case r >= 'A' && r <= 'Z': - u++ + hasUpper = true case r >= 'a' && r <= 'z': - lw++ + hasLower = true case r >= '0' && r <= '9': - dg++ + hasDigit = true default: - if strings.ContainsRune(specials, r) { - sp++ + if strings.ContainsRune(symbols, r) { + hasSymbol = true } } } - cats := 0 - if u > 0 { - cats++ + classes := 0 + if hasUpper { + classes++ } - if lw > 0 { - cats++ + if hasLower { + classes++ } - if dg > 0 { - cats++ + if hasDigit { + classes++ } - if sp > 0 { - cats++ + if hasSymbol { + classes++ } - score := l*4 + cats*10 - if score > 100 { - score = 100 + + // Shannon entropie per char + 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é" switch { - case score >= 85: + case raw >= 85: desc = "Silné" - case score >= 60: + case raw >= 65: desc = "Dobré" - case score >= 30: + case raw >= 45: 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 } diff --git a/ui.go b/ui.go index 1d77ba2..5e073b8 100644 --- a/ui.go +++ b/ui.go @@ -134,8 +134,15 @@ func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne. deleteBtn := widget.NewButton("Smazat identitu", func() { pwEntry := widget.NewPasswordEntry() pwEntry.SetPlaceHolder("Heslo pro potvrzení…") - content := widget.NewForm(widget.NewFormItem("Heslo", pwEntry)) - dialog.ShowCustomConfirm("Potvrdit smazání", "Smazat", "Zrušit", content, func(ok bool) { + 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") + 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 { return } @@ -154,6 +161,8 @@ func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne. } fyne.CurrentApp().Quit() }, fyne.CurrentApp().Driver().AllWindows()[0]) + d.Resize(fyne.NewSize(600, 250)) + d.Show() }) makeQR := func(data string, target *canvas.Image) { @@ -294,7 +303,7 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { } updatePeer() - // Output section with QR/Text toggle + // Output section toggle (QR vs text) outputContainer := container.NewVBox() outputToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil) updateQR := func(text string) { @@ -316,11 +325,11 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { parts.cipherQR.Image = img parts.cipherQR.Refresh() } - updateMode := func() { + updateOutput := func() { outputContainer.Objects = nil - if parts.showQR { + if parts.showQR { // show QR mode updateQR(parts.cipherOut.Text) - outputContainer.Add(parts.cipherQR) // copy tlačítko jen v toolbaru + outputContainer.Add(parts.cipherQR) outputToggleAction.SetIcon(theme.VisibilityOffIcon()) } else { outputContainer.Add(parts.cipherOut) @@ -328,8 +337,8 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { } outputContainer.Refresh() } - outputToggleAction.OnActivated = func() { parts.showQR = !parts.showQR; updateMode() } - updateMode() + outputToggleAction.OnActivated = func() { parts.showQR = !parts.showQR; updateOutput() } + updateOutput() encAction := func() { m := parts.msg.Text @@ -346,9 +355,8 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { } fyne.Do(func() { parts.cipherOut.SetText(res) - if parts.showQR { - updateQR(res) - } + // refresh whichever mode is active + updateOutput() parts.showToast("OK") }) }(m, p) @@ -421,13 +429,15 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { container.NewHBox(widget.NewLabel("Výstup"), layout.NewSpacer(), outputToolbar), outputContainer, ) + // Split: message (top) vs output (bottom) for better prostor "do spodu" + split := container.NewVSplit(msgSection, outputSection) + split.SetOffset(0.40) group := container.NewVBox( widget.NewLabelWithStyle("Šifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), peerSection, - msgSection, - outputSection, + split, ) - return container.NewVScroll(group) + return group } // Tab: Decrypt @@ -821,6 +831,9 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { draft = &Contact{ID: "__draft__", Name: makeDefaultName(), Cert: ""} nameEntry.SetText(draft.Name) 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 searchQuery = "" search.SetText("") @@ -860,11 +873,21 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { deleteBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { // If draft active and no real contact chosen -> cancel draft if draft != nil && selected == -1 { + // remove draft and if exist some contacts immediately select first one draft = nil - nameEntry.SetText("") - certEntry.SetText("") list.Refresh() 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") return }