diff --git a/main.go b/main.go index e7de6c1..4c29a42 100644 --- a/main.go +++ b/main.go @@ -304,6 +304,7 @@ type ui struct { editEntry widget.Clickable cancelEdit widget.Clickable pickVaultPath widget.Clickable + importSharedVault widget.Clickable pickKeyFile widget.Clickable pickSyncLocalPath widget.Clickable clearVaultSelection widget.Clickable @@ -758,6 +759,10 @@ func supportsDesktopFilePicker(goos string) bool { return !strings.EqualFold(strings.TrimSpace(goos), "android") } +func supportsSharedVaultImport(goos string) bool { + return strings.EqualFold(strings.TrimSpace(goos), "android") +} + func (u *ui) selectedAttachmentItems() []attachmentItem { item, ok := u.selectedEntry() 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 { switch u.syncSourceMode { case syncSourceRemote: @@ -1452,6 +1480,38 @@ func (u *ui) saveAsTargetPath() string { 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) { path = strings.TrimSpace(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) }) } + for u.importSharedVault.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.startImportSharedVaultAction() + } for u.pickKeyFile.Clicked(gtx) { if u.lifecycleBusy() { continue diff --git a/main_test.go b/main_test.go index 761ab07..2ad555b 100644 --- a/main_test.go +++ b/main_test.go @@ -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) { base := filepath.Join(t.TempDir(), "keepassgo-state-env") 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) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 28a814e..750d8f6 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -192,6 +192,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } 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 { if !showLocalChooser { return layout.Dimensions{}