Add Android shared vault import flow
This commit is contained in:
@@ -304,6 +304,7 @@ type ui struct {
|
|||||||
editEntry widget.Clickable
|
editEntry widget.Clickable
|
||||||
cancelEdit widget.Clickable
|
cancelEdit widget.Clickable
|
||||||
pickVaultPath widget.Clickable
|
pickVaultPath widget.Clickable
|
||||||
|
importSharedVault widget.Clickable
|
||||||
pickKeyFile widget.Clickable
|
pickKeyFile widget.Clickable
|
||||||
pickSyncLocalPath widget.Clickable
|
pickSyncLocalPath widget.Clickable
|
||||||
clearVaultSelection widget.Clickable
|
clearVaultSelection widget.Clickable
|
||||||
@@ -758,6 +759,10 @@ func supportsDesktopFilePicker(goos string) bool {
|
|||||||
return !strings.EqualFold(strings.TrimSpace(goos), "android")
|
return !strings.EqualFold(strings.TrimSpace(goos), "android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func supportsSharedVaultImport(goos string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(goos), "android")
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) selectedAttachmentItems() []attachmentItem {
|
func (u *ui) selectedAttachmentItems() []attachmentItem {
|
||||||
item, ok := u.selectedEntry()
|
item, ok := u.selectedEntry()
|
||||||
if !ok || len(item.Attachments) == 0 {
|
if !ok || len(item.Attachments) == 0 {
|
||||||
@@ -1418,6 +1423,29 @@ func (u *ui) startChooseSyncLocalSourceAction() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) startImportSharedVaultAction() {
|
||||||
|
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) advancedSyncToAction() error {
|
func (u *ui) advancedSyncToAction() error {
|
||||||
switch u.syncSourceMode {
|
switch u.syncSourceMode {
|
||||||
case syncSourceRemote:
|
case syncSourceRemote:
|
||||||
@@ -1452,6 +1480,38 @@ func (u *ui) saveAsTargetPath() string {
|
|||||||
return u.defaultSaveAsPath
|
return u.defaultSaveAsPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) importedVaultDestination(name string) string {
|
||||||
|
baseTarget := u.saveAsTargetPath()
|
||||||
|
baseDir := filepath.Dir(baseTarget)
|
||||||
|
baseName := filepath.Base(strings.TrimSpace(name))
|
||||||
|
switch {
|
||||||
|
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
||||||
|
return baseTarget
|
||||||
|
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
||||||
|
return filepath.Join(baseDir, baseName)
|
||||||
|
default:
|
||||||
|
return baseTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||||
|
target := u.importedVaultDestination(name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.lifecycleMode = "local"
|
||||||
|
u.vaultPath.SetText(target)
|
||||||
|
u.noteRecentVault(target)
|
||||||
|
u.state.ErrorMessage = ""
|
||||||
|
u.state.StatusMessage = ""
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) noteRecentVault(path string) {
|
func (u *ui) noteRecentVault(path string) {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -3490,6 +3550,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
||||||
}
|
}
|
||||||
|
for u.importSharedVault.Clicked(gtx) {
|
||||||
|
if u.lifecycleBusy() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u.startImportSharedVaultAction()
|
||||||
|
}
|
||||||
for u.pickKeyFile.Clicked(gtx) {
|
for u.pickKeyFile.Clicked(gtx) {
|
||||||
if u.lifecycleBusy() {
|
if u.lifecycleBusy() {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -5653,6 +5653,74 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
|
||||||
|
})
|
||||||
|
|
||||||
|
got := u.importedVaultDestination("shared-home.kdbx")
|
||||||
|
want := filepath.Join(filepath.Dir(u.defaultSaveAsPath), "shared-home.kdbx")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("importedVaultDestination() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIImportSharedVaultBytesActionCopiesVaultAndSelectsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
||||||
|
}
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
}, key); err != nil {
|
||||||
|
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := newUIWithState("phone", &session.Manager{}, paths)
|
||||||
|
u.lifecycleMode = "remote"
|
||||||
|
|
||||||
|
if err := u.importSharedVaultBytesAction("shared-home.kdbx", encoded.Bytes()); err != nil {
|
||||||
|
t.Fatalf("importSharedVaultBytesAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPath := filepath.Join(dir, "shared-home.kdbx")
|
||||||
|
if got := u.vaultPath.Text(); got != wantPath {
|
||||||
|
t.Fatalf("vaultPath = %q, want %q", got, wantPath)
|
||||||
|
}
|
||||||
|
if got := u.lifecycleMode; got != "local" {
|
||||||
|
t.Fatalf("lifecycleMode = %q, want local", got)
|
||||||
|
}
|
||||||
|
if !u.hasSelectedLifecycleTarget() {
|
||||||
|
t.Fatal("hasSelectedLifecycleTarget() = false, want true after import")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wantPath); err != nil {
|
||||||
|
t.Fatalf("Stat(imported vault) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened := newUIWithState("phone", &session.Manager{}, paths)
|
||||||
|
reopened.vaultPath.SetText(wantPath)
|
||||||
|
reopened.masterPassword.SetText(key.Password)
|
||||||
|
if err := reopened.openVaultAction(); err != nil {
|
||||||
|
t.Fatalf("openVaultAction(imported) error = %v", err)
|
||||||
|
}
|
||||||
|
reopened.state.NavigateToPath([]string{"Root", "Internet"})
|
||||||
|
reopened.filter()
|
||||||
|
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
||||||
|
t.Fatalf("filteredTitles() = %v, want [Vault Console]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
|
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
|
||||||
base := filepath.Join(t.TempDir(), "keepassgo-state-env")
|
base := filepath.Join(t.TempDir(), "keepassgo-state-env")
|
||||||
t.Setenv("KEEPASSGO_STATE_DIR", base)
|
t.Setenv("KEEPASSGO_STATE_DIR", base)
|
||||||
@@ -5765,6 +5833,17 @@ func TestSupportsDesktopFilePicker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSupportsSharedVaultImport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := supportsSharedVaultImport("android"); !got {
|
||||||
|
t.Fatal("supportsSharedVaultImport(android) = false, want true")
|
||||||
|
}
|
||||||
|
if got := supportsSharedVaultImport("linux"); got {
|
||||||
|
t.Fatal("supportsSharedVaultImport(linux) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(t *testing.T) {
|
func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+12
@@ -192,6 +192,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return u.recentVaultList(gtx)
|
return u.recentVaultList(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
|
||||||
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if !showLocalChooser {
|
if !showLocalChooser {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
|
|||||||
Reference in New Issue
Block a user