Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f2a0121a | |||
| 11e883279d | |||
| e305a25802 |
@@ -5,7 +5,7 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
|
||||
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
|
||||
APP_ID ?= org.julianfamily.keepassgo
|
||||
APK_OUT ?= build/keepassgo.apk
|
||||
APK_VERSION ?= 0.1.0.1
|
||||
APK_VERSION ?= 0.8.2.298
|
||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||
APK_ARCH ?= arm64,amd64
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const (
|
||||
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
|
||||
DefaultAppID = "org.julianfamily.keepassgo"
|
||||
DefaultAPKOut = "build/keepassgo.apk"
|
||||
DefaultVersion = "0.1.0.1"
|
||||
DefaultVersion = "0.8.2.298"
|
||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
||||
DefaultMinSDK = "28"
|
||||
DefaultTargetSDK = "35"
|
||||
|
||||
+127
-1
@@ -249,6 +249,7 @@ type ui struct {
|
||||
entryFields widget.Editor
|
||||
customFieldKeys []widget.Editor
|
||||
customFieldValues []widget.Editor
|
||||
copyCustomFields []widget.Clickable
|
||||
historyIndex widget.Editor
|
||||
groupName widget.Editor
|
||||
groupParentPath widget.Editor
|
||||
@@ -402,6 +403,8 @@ type ui struct {
|
||||
vaultRemoteCredentialClicks []widget.Clickable
|
||||
syncRemoteCredentialClicks []widget.Clickable
|
||||
removeCustomFields []widget.Clickable
|
||||
toggleCustomFields []widget.Clickable
|
||||
revealedCustomFields map[string]bool
|
||||
state appstate.State
|
||||
visible []entry
|
||||
currentPath []string
|
||||
@@ -662,6 +665,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
pendingSharedLookupPath: paths.PendingSharedLookupPath,
|
||||
recentVaultGroups: map[string][]string{},
|
||||
recentVaultUsedAt: map[string]time.Time{},
|
||||
revealedCustomFields: map[string]bool{},
|
||||
lifecycleAdvancedHidden: true,
|
||||
historyHidden: true,
|
||||
statusBannerTTL: statusBannerDuration,
|
||||
@@ -940,6 +944,7 @@ func (u *ui) handlePhoneBack() bool {
|
||||
|
||||
func (u *ui) resetPasswordPeek() {
|
||||
u.showPassword = false
|
||||
u.revealedCustomFields = map[string]bool{}
|
||||
}
|
||||
|
||||
func (u *ui) childGroups() []string {
|
||||
@@ -2153,6 +2158,12 @@ type detailViewMetrics struct {
|
||||
cardGap unit.Dp
|
||||
}
|
||||
|
||||
type extraStringView struct {
|
||||
Key string
|
||||
Value string
|
||||
Revealed bool
|
||||
}
|
||||
|
||||
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
|
||||
rows := u.detailViewRows(item)
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
@@ -2180,6 +2191,8 @@ func (u *ui) detailViewRows(item entry) []layout.Widget {
|
||||
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||
u.detailNotesCard(item),
|
||||
layout.Spacer{Height: metrics.cardGap}.Layout,
|
||||
u.detailExtraStringsCard,
|
||||
layout.Spacer{Height: metrics.cardGap}.Layout,
|
||||
u.attachmentSummaryPanel,
|
||||
layout.Spacer{Height: metrics.cardGap}.Layout,
|
||||
u.historyPanel,
|
||||
@@ -2339,6 +2352,115 @@ func (u *ui) detailNotesCard(item entry) layout.Widget {
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) detailExtraStringsCard(gtx layout.Context) layout.Dimensions {
|
||||
fields := u.detailExtraStrings()
|
||||
u.ensureExtraStringClickables(len(fields))
|
||||
if len(fields) == 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "EXTRA STRINGS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
}
|
||||
for i, field := range fields {
|
||||
index := i
|
||||
item := field
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.detailExtraStringRow(gtx, index, item)
|
||||
}))
|
||||
if i < len(fields)-1 {
|
||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) detailExtraStringRow(gtx layout.Context, index int, field extraStringView) layout.Dimensions {
|
||||
for u.toggleCustomFields[index].Clicked(gtx) {
|
||||
u.toggleExtraStringReveal(field.Key)
|
||||
}
|
||||
for u.copyCustomFields[index].Clicked(gtx) {
|
||||
key := field.Key
|
||||
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction(key) })
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(field.Key))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(15), field.Value)
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.inlinePasswordToggle(gtx, &u.toggleCustomFields[index], field.Revealed)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.IconButton(u.theme, &u.copyCustomFields[index], u.copyIcon, "Copy extra string")
|
||||
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
|
||||
btn.Color = accentColor
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) detailExtraStrings() []extraStringView {
|
||||
item, ok := u.selectedEntry()
|
||||
if !ok || len(item.Fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(item.Fields))
|
||||
for key := range item.Fields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
out := make([]extraStringView, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
value := item.Fields[key]
|
||||
revealed := u.revealedCustomFields[key]
|
||||
if !revealed {
|
||||
value = maskedSecretValue(value)
|
||||
}
|
||||
out = append(out, extraStringView{Key: key, Value: value, Revealed: revealed})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (u *ui) toggleExtraStringReveal(key string) {
|
||||
if u.revealedCustomFields == nil {
|
||||
u.revealedCustomFields = map[string]bool{}
|
||||
}
|
||||
u.revealedCustomFields[key] = !u.revealedCustomFields[key]
|
||||
}
|
||||
|
||||
func (u *ui) ensureExtraStringClickables(count int) {
|
||||
if len(u.copyCustomFields) < count {
|
||||
clicks := make([]widget.Clickable, count)
|
||||
copy(clicks, u.copyCustomFields)
|
||||
u.copyCustomFields = clicks
|
||||
}
|
||||
if len(u.toggleCustomFields) < count {
|
||||
clicks := make([]widget.Clickable, count)
|
||||
copy(clicks, u.toggleCustomFields)
|
||||
u.toggleCustomFields = clicks
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions {
|
||||
switch u.state.Section {
|
||||
case appstate.SectionTemplates:
|
||||
@@ -2996,7 +3118,11 @@ func (u *ui) detailPasswordValue() string {
|
||||
if u.showPassword {
|
||||
return item.Password
|
||||
}
|
||||
return strings.Repeat("•", max(8, len(item.Password)))
|
||||
return maskedSecretValue(item.Password)
|
||||
}
|
||||
|
||||
func maskedSecretValue(value string) string {
|
||||
return strings.Repeat("•", max(8, len(value)))
|
||||
}
|
||||
|
||||
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
|
||||
@@ -82,6 +82,8 @@ func (u *ui) setCustomFieldRows(fields map[string]string) {
|
||||
u.customFieldKeys = nil
|
||||
u.customFieldValues = nil
|
||||
u.removeCustomFields = nil
|
||||
u.copyCustomFields = nil
|
||||
u.toggleCustomFields = nil
|
||||
if len(fields) == 0 {
|
||||
u.appendCustomFieldRow("", "")
|
||||
return
|
||||
@@ -104,21 +106,53 @@ func (u *ui) appendCustomFieldRow(key, value string) {
|
||||
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
|
||||
u.customFieldValues = append(u.customFieldValues, valueEditor)
|
||||
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
|
||||
u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{})
|
||||
u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{})
|
||||
}
|
||||
|
||||
func (u *ui) removeCustomFieldRow(index int) {
|
||||
u.ensureCustomFieldRowControls()
|
||||
if index < 0 || index >= len(u.customFieldKeys) {
|
||||
return
|
||||
}
|
||||
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
|
||||
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
|
||||
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
|
||||
u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...)
|
||||
u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...)
|
||||
if len(u.customFieldKeys) == 0 {
|
||||
u.appendCustomFieldRow("", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) ensureCustomFieldRowControls() {
|
||||
if len(u.customFieldValues) < len(u.customFieldKeys) {
|
||||
values := make([]widget.Editor, len(u.customFieldKeys))
|
||||
copy(values, u.customFieldValues)
|
||||
for i := len(u.customFieldValues); i < len(values); i++ {
|
||||
values[i] = widget.Editor{SingleLine: true, Submit: false}
|
||||
}
|
||||
u.customFieldValues = values
|
||||
}
|
||||
if len(u.removeCustomFields) < len(u.customFieldKeys) {
|
||||
clicks := make([]widget.Clickable, len(u.customFieldKeys))
|
||||
copy(clicks, u.removeCustomFields)
|
||||
u.removeCustomFields = clicks
|
||||
}
|
||||
if len(u.copyCustomFields) < len(u.customFieldKeys) {
|
||||
clicks := make([]widget.Clickable, len(u.customFieldKeys))
|
||||
copy(clicks, u.copyCustomFields)
|
||||
u.copyCustomFields = clicks
|
||||
}
|
||||
if len(u.toggleCustomFields) < len(u.customFieldKeys) {
|
||||
clicks := make([]widget.Clickable, len(u.customFieldKeys))
|
||||
copy(clicks, u.toggleCustomFields)
|
||||
u.toggleCustomFields = clicks
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) currentCustomFields() (map[string]string, error) {
|
||||
u.ensureCustomFieldRowControls()
|
||||
fields := map[string]string{}
|
||||
for i := range u.customFieldKeys {
|
||||
key := strings.TrimSpace(u.customFieldKeys[i].Text())
|
||||
@@ -399,6 +433,16 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
|
||||
return service.Copy(model, u.state.SelectedEntryID, target)
|
||||
}
|
||||
|
||||
func (u *ui) copySelectedCustomFieldAction(key string) error {
|
||||
model, err := u.state.Session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service := clipboard.Service{Writer: u.clipboardWriter}
|
||||
return service.CopyCustomField(model, u.state.SelectedEntryID, key)
|
||||
}
|
||||
|
||||
func (u *ui) generatePasswordAction() error {
|
||||
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
|
||||
if err != nil {
|
||||
|
||||
@@ -667,6 +667,7 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||
if len(u.customFieldKeys) == 0 {
|
||||
u.setCustomFieldRows(nil)
|
||||
}
|
||||
u.ensureCustomFieldRowControls()
|
||||
return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
|
||||
@@ -3830,6 +3830,21 @@ func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIRemoveCustomFieldRowToleratesMissingClickables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u := newUIWithModel("desktop", vault.Model{})
|
||||
u.customFieldKeys = []widget.Editor{{SingleLine: true}}
|
||||
u.customFieldValues = []widget.Editor{{SingleLine: true}}
|
||||
u.removeCustomFields = nil
|
||||
|
||||
u.removeCustomFieldRow(0)
|
||||
|
||||
if len(u.customFieldKeys) != 1 || len(u.customFieldValues) != 1 || len(u.removeCustomFields) != 1 {
|
||||
t.Fatalf("custom field rows after remove with missing clickables = %d/%d/%d, want one blank row", len(u.customFieldKeys), len(u.customFieldValues), len(u.removeCustomFields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -9126,6 +9141,76 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIExtraStringValuesAreMaskedUntilRevealed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u := newUIWithModel("desktop", vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Fields: map[string]string{
|
||||
"OTPSeed": "green-light",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
u.showEntriesSection()
|
||||
u.state.NavigateToPath([]string{"Root", "Internet"})
|
||||
u.filter()
|
||||
u.state.SelectedEntryID = "vault-console"
|
||||
|
||||
fields := u.detailExtraStrings()
|
||||
if len(fields) != 1 {
|
||||
t.Fatalf("len(detailExtraStrings()) = %d, want 1", len(fields))
|
||||
}
|
||||
if fields[0].Value != strings.Repeat("•", len("green-light")) {
|
||||
t.Fatalf("detailExtraStrings()[0].Value hidden = %q, want masked value", fields[0].Value)
|
||||
}
|
||||
|
||||
u.toggleExtraStringReveal("OTPSeed")
|
||||
fields = u.detailExtraStrings()
|
||||
if fields[0].Value != "green-light" {
|
||||
t.Fatalf("detailExtraStrings()[0].Value revealed = %q, want green-light", fields[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUICopyExtraStringWritesClipboardWithoutLeakingStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u := newUIWithModel("desktop", vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Fields: map[string]string{
|
||||
"OTPSeed": "green-light",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
writer := &memoryClipboardWriter{}
|
||||
u.clipboardWriter = writer
|
||||
u.showEntriesSection()
|
||||
u.state.NavigateToPath([]string{"Root", "Internet"})
|
||||
u.filter()
|
||||
u.state.SelectedEntryID = "vault-console"
|
||||
|
||||
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction("OTPSeed") })
|
||||
|
||||
if writer.content != "green-light" {
|
||||
t.Fatalf("clipboard content = %q, want green-light", writer.content)
|
||||
}
|
||||
if u.state.StatusMessage != "copy extra string complete" {
|
||||
t.Fatalf("state.StatusMessage = %q, want copy extra string complete", u.state.StatusMessage)
|
||||
}
|
||||
if strings.Contains(u.state.StatusMessage, "green-light") {
|
||||
t.Fatalf("state.StatusMessage = %q, must not contain copied extra string value", u.state.StatusMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -45,6 +45,22 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Service) CopyCustomField(model vault.Model, entryID, key string) error {
|
||||
entry, err := findEntry(model, entryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, ok := entry.Fields[key]
|
||||
if !ok {
|
||||
return ErrUnsupportedTarget
|
||||
}
|
||||
if err := s.writer().WriteText(content); err != nil {
|
||||
return writeError{err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Service) writer() Writer {
|
||||
if s.Writer != nil {
|
||||
return s.Writer
|
||||
|
||||
@@ -48,6 +48,30 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCopiesCustomField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var writer memoryWriter
|
||||
service := Service{Writer: &writer}
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Fields: map[string]string{
|
||||
"OTPSeed": "green-light",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := service.CopyCustomField(model, "vault-console", "OTPSeed"); err != nil {
|
||||
t.Fatalf("CopyCustomField(vault-console, OTPSeed) error = %v", err)
|
||||
}
|
||||
if writer.content != "green-light" {
|
||||
t.Fatalf("clipboard content = %q, want green-light", writer.content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user