From 332ab58f5880ae9ec0b6cdfce114efe6421aad32 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 21:47:44 -0700 Subject: [PATCH] Implement local-first remote sync flow --- .gitignore | 6 + AGENTS.md | 3 + Makefile | 14 +- android/application_snippets.xml | 23 + android/keepassgo-android.jar | Bin 16101 -> 23284 bytes android_share_android.go | 184 + android_share_stub.go | 7 + .../julianfamily/keepassgo/AndroidShare.java | 81 + .../keepassgo/SharedVaultImportActivity.java | 131 + .../keepassgo/SharedVaultProvider.java | 100 + appstate/remote_binding.go | 141 + appstate/remote_binding_test.go | 250 ++ appstate/state.go | 106 + appstate/state_test.go | 307 +- docs/local-first-remote-sync-plan.md | 148 + main.go | 2183 +++++++++--- main_test.go | 3110 +++++++++++++++-- .../keepassgo-git/{PKGBUILD => PKGBUILD.tmpl} | 10 +- ui_forms.go | 298 +- vault/kdbx.go | 43 +- vault/kdbx_test.go | 126 +- vault/model.go | 75 +- 22 files changed, 6428 insertions(+), 918 deletions(-) create mode 100644 android_share_android.go create mode 100644 android_share_stub.go create mode 100644 androidsrc/org/julianfamily/keepassgo/AndroidShare.java create mode 100644 androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java create mode 100644 androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java create mode 100644 appstate/remote_binding.go create mode 100644 appstate/remote_binding_test.go create mode 100644 docs/local-first-remote-sync-plan.md rename packaging/archlinux/keepassgo-git/{PKGBUILD => PKGBUILD.tmpl} (89%) diff --git a/.gitignore b/.gitignore index 125a61f..5564ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ build/ *.apk +keepassgo +packaging/archlinux/keepassgo-git/*.pkg.tar.zst +packaging/archlinux/keepassgo-git/PKGBUILD +packaging/archlinux/keepassgo-git/pkg/ +packaging/archlinux/keepassgo-git/src/ +packaging/archlinux/keepassgo-git/keepassgo/ diff --git a/AGENTS.md b/AGENTS.md index 97599c8..8f8cfc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,9 @@ These features are product requirements, not “nice to have” ideas. - Phone should optimize for low tap count, not purity of mobile patterns. - The stacked phone layout is the current preferred phone direction. - Do not reintroduce the abandoned phone flow mode unless explicitly requested. +- Make all test strings `Heist Movie` themed. Use characters, crews, casinos, + vaults, and locations from heist movies so test fixtures stay obviously fake + and consistent with the product theme. ## Architecture diff --git a/Makefile b/Makefile index a5056a8..53ecf78 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,11 @@ ANDROID_MIN_SDK ?= 28 ANDROID_TARGET_SDK ?= 35 SIGNKEY ?= SIGNPASS ?= +ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git +ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl +ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD +ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)") +ARCH_REPO_DIR ?= $(CURDIR) GOGIO_SIGN_FLAGS := ifneq ($(strip $(SIGNKEY)),) @@ -20,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),) GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) endif -.PHONY: apk +.PHONY: apk archlinux-pkgbuild apk: android/keepassgo-android.jar @test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } @@ -56,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) -d "$$tmpdir" \ $$(find androidsrc -name '\''*.java'\'' | sort); \ "$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .' + +archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile + @mkdir -p "$(ARCH_PKG_DIR)" + @sed \ + -e 's|@PKGVER@|$(ARCH_PKGVER)|g' \ + -e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \ + "$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)" diff --git a/android/application_snippets.xml b/android/application_snippets.xml index 0990299..8889103 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -22,3 +22,26 @@ android:name="android.accessibilityservice" android:resource="@xml/keepassgo_accessibility_service" /> + + + + + + + + + + + + + + + + diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar index 89509e5c6aea7fbffb3d10992116b5d3d13bdf95..556c6a86b6d7a7f273ad829d6265750b0aba9629 100644 GIT binary patch delta 20761 zcmZs?V{oNi)GeB%W81cE+je$r@7UNu$F|Y2ZQDtAY&#ux%)b4ed+VG!U)@!;YW{iF zk2ThOs>U31j^lubR&XR$g>R7XU|_IdU~*C6Nl3Kc{tLYFoQoX4IMjbRGs@4Y-cHUi z)c!g;J~)N|Q&oV0?FVLi3xa`x@&DWZ_whE8|8;zg@xPD)1I~^9FB<&&?0+vNuoN-@ zVi3^6)ydXE!^YIrlG)tB)XgnU=T(Pb73&L{GA@w@+JcZmz$Gnv#6i66_eh;=g&4Gc zBN$JzOJhn|C}%i0Tk%XRE2XVQM46I$x#I#An{8LSve%ZBYRZ_%Q|=emXDdkD&;KIO zi85IC(9L8gN8ow;xO=+$?es4YglGch1%jv$t3^zJVDU%Cr(VY6%GQXxJG)&gNq3=q z$zMX+K2K8J+~6i3W)XAAa_8VLJZ%B9fE+nDM1N|dUY#o^RNxuw-js$M{|4__oIm8Q zPv{zEm^@Ewsv@Op)&b%!J|^1c3~6yp2%)}AYj6VOEgY03spZ9VaSY~NRfL->fV>?T z9CPHrIZ07PImFqY7NlP`@UFO;(krbsFsh^k zLh(36GPt+f9sWA(w7^bAt&{)`qOBB%=Mib}aemh<=GBaqnJFJ@k-Df3mZPSVCfLs< z@+)B})#?L2Z3&a-i}ut6sejnRfI=18;wr*&-FDcmA8M@RU@;j323q7zM7pTdv2YM) zql_9JfOxg L>&LxvwV84dj(wF+LghIZ1B)eOrb?)*W+urFw65Px2C=WOmY+_}}c z?=wvz>3Ktn-M3eA9}M!tJ+5umd@;H{Ut+(1BXA%+N#9757g^jDpmi6l1MR`(Chq;j z$ql?y68A@-s^(V|@HJ{>2PhM{9k$k9{{%kumk8v^(!5IrdN6pStH^l|XzQ`Q#Q*?a zkZ&~Y3m{OIA8rB0S8nnzw*v9DM&rl}4b*X4@nWmyZ+srPRPKdbjiJ-u&^=hzz_5 zKbzqkG=LBeGOf8rRtK@yGjIuQDGd1Yr0Vbs9irp9fknKRi~O@rHruL|fd;9n?Ev#4 zXBnxKZpwYt-@YrgfP9jlO{?ob)99s_qL9-&D!4}~bdRRiZ0svX&}Ltn!@S@aro|R_ z)$B!^IYM&TUbI4X{?tXfLbhsQ47P>}iQl*bhmLd0eH8~9ivUEfp+|D z!MRI@M0ssS@2SlIy>Fl&Y-M2iL)9m`E-w(Rq|C5Hedo0}%ns2R?LD47%8X?1)9zH= z@-Craysx6Y1UtF&fZ84SJ%d{Ys!IH+pY3R`xny9dS54EUAZ>Vmn3F6!-hRu3x2t_i z1nw|AmbhqFw-|Co1htEGm*~+A;=h@By$cx9iuN0+Ev~BsU9Q!T+$`96F(_}VHUvHE zADmC!4jwHGNs0$5o*^*4tnfrxBLKz z&mIe-Ygh$?wmaM%xwMT(4aI4H+;Aw3TkX!G*v);lO(GPj6gK5bam1V9cKAruoi_Kiw)>1^?d?E-? zxBXm2p=rga=aFvG*xo{^HNxcClve9ZRbT;w!9 zLs7(y;3~t!=QJ-yiiGQJu0o=rub(V4xzDtR%-V*80AK3D95tXD2c|J0MUSJ%U1;n_ z6AB7hRKy))zXhsvys|*G!91sudD(Aw?XBF z>1#8_o0_-jI}0SsePai_K~x+@My8cQq1@_pVZd5n>?!nlPgl@4gC+zU^bQw+K!Oyi zv+uWa$T+cd^2C5(vY@$fL52nyidF^Mk)AkH|09~z{KWdm^?913=XumW;r^|DaI@1q zpqo#9u=nL0*rf6n4cKB8uxG%8m(0j9(%rKD4qb@KgfG)kFNR)8FWiQCBbPwt@U(&7 zEkD7f;Lc#TL&Ux1nfb>#m^*6$KVA^*VC(@e%_Q-OF~m488R58_cW#ri>IT{ruNa*S z?5iA(CqnmiQCP!nF{<8JA!B~2hy?UB=ul#>A2u(E4U_=BlWa+hPV)7)ux#jLbEIud z18Xm+!)_Wp{|G1P&(+o6Ad#07rHRq_ClOOf#p6?V$dv!MoiaSne=jEk3tdPlXG z-HznIm|YjggZBd4g`4eDlI>HbkJ&3&bl@R{f9$Wn;Q!Hs;pWfg4j3@7V&wmq9$`m=kQVMsk6`r7o=!psmL()aW5j*2s)RntVcvY+_y(b^Zl}W*d`R(4tbfXx}qjxb}yG2Hj_6)zw{UkT59 z3mUY6`Wfx4eCY_|@z7q#81q|JW4ZLR)o|&hz*8Ea4qIy~uwlb!N|pDh&<>_VeHVk8%F* zlu9vxasx5=>%7E!Sv|)+e|>@R4GGu= zuM$Ow4Mcbp?g8RD6zCxRAl^|Z7RT&>V}xKKDKLc$P#Lc-OP(gWF@mfZURLdBk48J0 zhi5GR=pY2&c#v=1z2dmn%~7ctqjF-l*A2*hGz9e?$@fK`C)(?)x|v`5LU!&XTo0@+ zG&+fw3Snfaj_JC9h|%AvUCGdpTFg4?95Oz`D&s3Gr}N;$6;=3)NEpY(QC@Y?QiwZ( z^CR03QA)4Kn1?+PC;M(ckcEVU*(E-Wg;_HV$I-e#Isn;8#*rV7~`U3`^FCp4No^ z)X30Fb;T8aA4VeH&tz57l8aVR{eu1v=@ZyIaHD@Hv;NEG{~KvWm^P5+)T~hn>2lDt zB>q-NP2LKyn$sdoU!I5avW4If;yKxb7uT3tc6Pe{ekj!an-K;cE7WL`jl%g{yy`D_ z9(~6_RqJ!+O<=oOW4i2lbi8*7A1-`t6Tbcx)1B-RwgorLuu`@v9%5rb0Zzk^(!6X# zWeXd6{N}#*IsNi=oDS#+hLd4XwlW*AKL|NkqTt|1;bq)S*4YW87owNGIPIvZfBI*6 z4oT*qb2cg7IR!^pk(+f?lG~Nm^v^)NH>)OoWD6`MZDs)uE_#51AUvCHsg{8XqQFdk=pG8KrzN`Eta`94|G=*Oc^r(&&^vQw2w|m7InS7HWSz#z? z*0!=*p`A*lK0`SJlOj|p2oA!iHoYHv%TFt_;9^(221hW;2pDem$pYNVfO%{IqojEB zRD4~N1HoJt-6zm~8%76!LY{V4SG!lY;=y?zc8z8Xkx_m_p213r)6Rqb%3Cd5vvj}} z1520u7V^+@>0mu1w`ZTb-t`O%PdEG+nwh&%ymFJr6JBe_ZqyFekIO49YFSreY?ugArG{&@Yb`)S+L_SsIp#{s_9q+ zO%O>@T`8BC$D^GH*~gu=R>M1mH#aZMFjaS;pj64#h~?qlHMSy4Y4zI;s({xpaD2eA zB_p3X5Ci`9Kz)f|4S2wAz?|uuTp|7BQwERHxGa_}#Necv$>9vu>%co1n!w77*q9iL zFv`!IdI^e^8Zx>R4Tax}3vrW{`r*Q;aSBc`(Mfew%eSWf7Od|#@WT77X+@6&sdTA% z;7504#{g85MiIA=mpWS86`*6JRa6v|TG@btih?R1+e4OA8c9Xz3S}!LlDc4RB=Cd8 zn7oe;*QH!SvyP8ykM|Um@e?kaRmRXcEN*elX$4J}SrXK~w3+mzIW=}*#dF}y5b&_Z z69m<9xe(P^L#{c7@n0g%7Gu+FTL>yc=0O8-!@1)Q9#cIyI3kI-oKprwGM$I6h+OJg zYuk=)$2&cF0pUt_2Uc4}tgQvMUhY=a3ga|)KcR&!548OOtokSsd^>A)Qs$nyg2^-t z8=w~Y`EvP%wiRt7pCl7slNk$Z^ko+cpoaBV%-+WjjtCbmmp`Lb-0KmW%YS~dyQ46v z!2XW?n-e9Vm6tMa(hfNccTE50`q;5KA_NaljOA@#!~M3?%Nd(DaXY2^ob}QZZ4G>{ zLT13!h`Hvu3)+~k_FD20537bza^EKt^940Yn>1|jYF@=QE#b-J1$d8)6kqr!5CL74 zcOzJGUZTm%#yz1ne`7Xawr-9bW=~a06p5G~$Vn;LBeK=upUF}8C3%G67)#Yh4WF6g zjXU?17WWF-jhk_QdvEEvV?dJ5u7@k+d`^ujze8NNC@L=JT<_|r__HVC?J9_?6AZd0 zAiVxZ<{Gp=(VkKvo+OKuh<1hi3O#6g2iXyVB zhrC5F_1Q1+e=cSQ@oAEL>F*4os{UfPz(wV@1pdcU>cAglrJscqnCc_ObWAsQ zO}y`|>f=t3F+Wv_O=2v-@$ya>BaLw`4*P~;pz0-5m%gt zq1|CWo1^{?3%Fl+Q=+b$%joE_`O(HA7YN0l3%!7}0!I(8j!R4J+WWJ;Q9v?qG|=}p zf@S3I6$_RPBWjBio)MSM3O!m$%C}aOMLqS!WG8!U0`+!1!&F+GkSGaM${Cy* zTV@sbf?F0hTIU0!Xvhn5iNRUxoIVpGxF9c z1{zij)(TFD>)N_%NPH3Hh^EJK}Au`Rc|W zM$c6mk&yyH<+GT@&w6Sq>%#f32y|ol^b;-@t)*|fB!5xdoL5JsxlURIN0x>?=A1*A z@CKi~B_+DGeDg;2mA8Ef-3eMc6(12Bx&0DOwZWZij)^ocyRRrHA0d4CJM52?nZQe< zQ9%|5ANA-9FHi$hQWS9gFs*mNc4C?Smcwy|D8z%cCnqc>^m@dyCiw?-5&X44#3_um zS;(D-JFXGoDKvSKA<-3yL0hb@?&iAiPU%|{N?#M7eod4352nEmrN*;TQer?)S3$CB_!cei@6dAgK^kgva1g^D=8mJD#gu36&$natC-uZA zIRCOirK#hRnG?kH2U!MQNFM{HL?TtHK7e2ro;&=vCoES>v#v`S`USo}aUn*y%Thln z7BkLs)?(x~OS&s?H0--rE4QRJ82hVntWyB~xQvjSPT87K<_&4(1v>+Bkk8)_ zi9#Mk98Wj~ec?x8G(=r{k0&b-z6@v<070w%D1dJ53EC(%`sDPhTCrzFw8LJ@Sh5#+ zT7Ev1{sPvOkNY2xex0KVyTSei7%qhhya+gh;WTzuk3I8+eC(p^5kB=L;$~{f;=G!n zXE>$2IO8C48(;9>3FeHy2&{!&W=rO%T^Bw1I#;}$4*}V^VP?Fm3>0&58*{mpXCbyull9$tBs8ZBOHTLlzAvYJo&oF)+sq>W zI!}--G`6w}iIm-x?>A#1F|Lk^(h*uXRN>g?NZ}X-L-!KAg~Hio*-sC&OFDTF-Th>G zhHuZ%D#`7m&ik9a|0g2K=kP}!$fmnkv{L4zxl_T%wv(omar@?caFygZ-|O}?&}6uo zp7iRk`4>}tb8Uzqjw!)Nh!=^v@$FXo&(74V1CRIjYb*Vm=a>BDi+Tkng_|Mir@tD{ z42D%8w)dasfqK~&EGB<_whKS)Q+-C)~#3g3$kB3eO$a z>oTYfVrud>WRd;R4`$Y^ZtFs@*PMRo&}==nHPo#z^1U-Yu46t@dt$NS8aVZ;4yIJx zp_d&f5gj~wd0rP>Ntk*YDl)@b{kWF!cAQZ2`0x%*0ww;vhtI2e9Rjtl5H-5&7UR&r$qrjz>{&^yO4 zjt62t6r{>;(QCc5XdZIli(+N^hrr5Gi0$_eE^-^4y{#k^mi!t48UW?3Rew0VT~JnKFgL5rJ2n9s_y>h#`v z#C?xmw`w#u$NWA&B@9nFoZL~$gxkw@hu6apkE33E6E)MGj99#hQ{L3nZ+`25B5p;Z zQ+QAgo`qgC3V%HVQND&oN{n}I23@j2fPD+O`6IX}9wn9;`YW{6g)FaO{Y?I58Nrf& zKfgnI*~~(!>#D^hh*pImlQBz(y-~4qD(6_b?VUI?usA__!L~_l+yUc~ z6}Fk9S!Yh$cQsA0*#wA<5O-R220!19J>m0P-j3_f&GrBI$GiCo7ESL7S~;Mr-wWgkbX ze@F>#w{%)sdVq7X zM5<~YraZJ2Hr+#OcYHT8LEk<#y$@a&&Hd=NVBe za{XwJCzvWgEZrmjuq>L=OE7Be*X>E#`>G75}^GkyPqvHM?r;zHH zP1>PiCyLeGFX94v{G%qDNQDS*k#giojojQUEkWL9ggR3?5X~XhEq?EHca58o40oSQ z!6z9)ElFeBCtK1BhQp9b)>)wRL+{|Lh;_|7mH9@!uGR-W&mNEDPLAY`lM7Fv%;i@; zZa~Zn$Ug`7<6G@L%?B9h1bAcT$0%t94bDXb)Ha%IvS;3JLF5g;*LB8gnh-b@deNm& zypx+7rR3wQk9yMo=}HmYN_tg3q(+z4E5BLXHPJX$Lq3j6#Wfns7l!X`%Ut%CN4=ku z);C_6?q>cxUVZCXPlwptmG62Wyznl$x*4MsG<7m){>(qlOyK7*KVi=G)QqrfUr+zL9`n2oK_&B%3ITVsG4^t|I*ObU&$n>=P02Sy-W(2YKEpI7EB>3CXZfpzIt-L* z%Sw*rrX)3~B`!;9S4(X9tSp>n{#x@`kbSC~?$E3fya!_08G-~_us`POlsL2PihiqX zwY<+v3{_L)Em9K?3UFOX%9^11 zN9GnO&){F4|AZHpFX!`eG%&De%Kss}{2$d6EJXxQ2V`4a``G~K|E&bs3Zo+k7flM) zstfpWAheygvgAmK2jDH4M8L4Glh;rA**q;kY5G#{fHBo1qAE$Ic?R zVr6JxO{6$~QGd?!^b!TqvicRZz^gzlbo;gQ9YL~22*T@O#?YD?6#^Z0@g)0_yJ#Y& zNCciGK`897Z)2V0T_`lsB9tnH>{Cx;F@^a5l(*bht4@0_{Sv~68Hp#3d+d{9_XxzGGRlwO*`5`byY&1 zgQ6n51rs+&akx)&K#RM1{aXT9)4o(Zgq9TZ7%XE$k)u}50h$DdeycLYg8uT4--ZCj z6)UaUrAO%a^m_cfmySs6<_x@~NMa!8hT?3z4BzkS?Gg`rEwovOYP*)=@b$I{h4feY zu^}4OwX)4>rfKBtWwOn)l~sJJwF|UN9qwlCQj>PjkLMEmtU9A!td&r!YN;V8T1{4? zQAkD1jy**<*w`PuNPr3n`^Dd_%F!ZGuCcVJHmgN(ZzmnV@CPYk#Ufv_YB%lVpEas} zOBYMMv||=f8k)H==)oU{k2n)ZdS$gap0g(%*imq~8lLRL96YiOWmTEs>iHZqx*5bx z-;hZ_!8=VH<2F`T>wpfn6BF6j@4;91i!?ue2X3BPG=oU1(@f}J@c$0 zHlVoU!SXpxwM2#6*J702?={E+$lDk27vrBm3)Re&yJ)|xeqKMaeX*!Fg5e-2o-Oga z|3S2U0t^FBX|3$blxVWmxU*r4A$Q3cwVk7)74C?At>p_k93`vln0(7wA?HZK)@YAH z*DJ=*p}?^esE*uzikpM0zieLeE0*v2CaO za?V#nrWb_$DW2#R!?(+eeq4~x*UWv@RjW%)G#DKL`C^N>$PaSOd{QiO>5KbjIO>+c z++fklV{}OJi^^47HAu_KY*mm$N_UxnYM6vIayZ#M9&89GW|GBF08n!Zr)i8~RM6iz z@#DPKeRakBwfur%3LvPGYpeVq(8u%Rj4DfZL;C>q9R`Kn0B7n5FRsO5sc3m|2GK~J3WvAwlM$0JmC0B4KJaz(`LaDKP2Mxt?-s>sjGM3}xQ zTxSN8Z)!zWE82|)2UF8wD=>$a`ny2$j_$I=q|(DSXiv{muR!f?y+jd2m*d-D1h7nq z{>f*nc;Zy?)!@+vC@nEK?rw`)vOgy5e=rYLNQGJ{%7?|$dcfAn_Vrj5dJT- zc&`460|oR_LixUs`CQSdnEDEDWMic^4SYX}Q_SiQxy{OybyL+*(@C*61yZ%OjEhFq zC1>=ktve(vnIq(7TT~@b?_>joa`|&ew9^##&lGg=!FI^8GSyZyErkOs-Rl4}W)vgz zOd}#2__`{mN~2zXB}1hE4Fz*04F!$auCnxj=!@kzE}fdnN|dFEPnK$Ls9lVS-_e`r z!n!^e7FjzI+})}WvP^*$2QuDTo&evLG(4%NEo_YK?@cCGaOeUctwXM!x;+~DodFdn z>awdJ2x;7_HNX<)hrO{<2K|snrI}+fC=r}Y*QQ>kU@ZlE{8G+-%{zwioe}pqTh%QS zUGX;ptp3p(*=GHikw>lC@kj1TUw!}+^8ktFhgx`WL{{{b+8WzJ0o7>#ge+pbYb@J> zW^ZmqNmr%SWJWv)&v`-9sMhr2@op~8w?z(`WHG|BEKFLdoteUM?);SF6p~4zEROqz zzypvWp+A_zd5xz=7MPBwxXc@TY0hl?^XqS#{auzu$HR7&=L}1n^2$kV@`j?&@w}co zFhq@I)58!D6Q}!yzQbhhsrkEV@ye5d@IDfe8@@~{xl9&Rx|rcU<)AgxL5oJbZxxTe zdfzW|+ay%F+_{?0tET`wSG;66Hw|Y&^U)hR#9AM!jwF225XYh=a^V=hY~z0@X{Yji zwgf~zrk46`Hs1MKyP?&>t!#cg47e7mKm-F5Q$zb8{d04NtGw1dTd zKr5xlWwwLfGL>n2$BmZR^HvPe%o94rN+9xr1#+=n7ekED!W2Q}rS;_0sE-;>mcw~# zvFnK>G6Vf`FAQT1i+^+Oxf4L0{xGh*8BvU1UwKZaZblP&e*vQ?nyNJkdj?`?Qf6cfMwBuEhwtuOdeJ42v@dkw_%CI)Oy})0|WL zA0wcRjqMTXKt%qKYBG*8f8{Z#IXCV)=ltq*z|<)=w!-U-^B%hkG6*5++B?5sU{PwK zm?)Z01W$t%=`>jxu2ON-tWT>r^`R^UfXC08!ueUE8}*wNq5V(S7pm9wo<{)9i5Uu$ zcjj{nh2({g!S@Uowl5_hLLvubeMksdtQKgk4Qa5v!G}aVD&&GpQAbtqyV)JGoF%R6 zA&9iTQpb8RRVJd>#CHJ!HbKSf(m5B!K-|xG>W(uy_cqw^Ay~1l#|75$_w#Ut(04q4 zVAXvF##lUj$QDX0Az7)Dg4wq6iS+K;5n)j8BeM1wTX0<8T(td`dGW*GE9 zG_i3vS1q{gjN3jj9NKZ+G2Fa}JT3IuQLK{hC$Z4QAyqy2<54_h<>N4NhL5nEqfpshtboY-jTGDu2x|eM=EI&*H?hvnNA# zLmWfGk1WSa{->JX>k+^K(z+3aj%X0l9G5p1lXo7EnC-^vgb#uS)*9y-swHQP~fn~b(RB2j?fXJCwQ9X&*H0h(pou5y=Paz4)-`Q6?>h7r+me6IB}X6iaC>-S#hef%kZDc+K$n8A`^Qtpf`>y)%7yFz zF%w9>B|+GZ+HBNvzq35~uEzo-9pX7aHX6L%fB%L^6t%-knt1*<%`o}mf!Wpb*$jfRe)=~Ev?ZxsN+-xAm<6HYE(vYaQCdHo8gU!8Du%o4Mp6NYs|I(ruW|^ zJHB}f*tN!c8-(4zQw|6DM$5g}+BJlpzu-w7==z~7!-(|al`o{v=>yRnp*QWP4T8dEr_Xs3~UIkjz4;m20p&~S| zE*Jac0>c=qm{zg;QPR3Sx$(=yhqZsaL>n#Y;ix3$C(3`7MXKnk0w*FEm?Yl+DDP4f z=oC`Ugb-8y(!hb*^jDWx>SKPzc&nmF-&RDbS70mXS9q)B*#0Y6leZo3tbNc0y;n)7 z=9uA)dT?cTW(0Q>cl`bP`oL{WX-3fH!P6;H)zB(bmJQ^p527xS$~ons=ufG+&%;E| z_ilwnwe}WXqjIfQD!gl~#)Kzfie%C#F=(8k-Utn!>zY7IadEBR{9Q{c4TT;Rcm~jv*F`uNxaaf0|8nd@fiMqBb<8F=>qwcAX?+2DsyJTk8YNJ zxl$2!%p_D;{wcXzwT^eofLLaxI*(IW0Ly_W|LaVJ+IpDjzsRm|$?)cgK?CdEOM4A3 zeq`M$I>;qy#A`mH7plgZo?t|pkKh}(E3f7+1NU1h1^)sHXcEI_!M*2A&~5x5pcpH! zUCjT#BOwZOrubvyC00&93=;vI!PDgMgVB;uqn{pv$~xnCDWi0xAW@_+O!7#($n!{Z zDN>$P4%@Yb##k#SSu4^8*D^w^Tm;FOQXo1UC0B`i$qxjBj(khR^0wqT(fG6gr`Sm9 zg=ePK4k|mgZaX98Z(fBC-AX%(SuMNC`6{3B4~z#7=Z%On-A+u#e{Bniyr&wKhm-M$ z)*Fm`$!C*VD#*quLC&fMPeyI2T@Z~`o2NkzhI~tz@qR}h7C)^Ns;-oUQtaq8pT6u~ zc@^msZ4yZgx&sSM^$Y(btV2BzjFZ*d-Lt&bd>KJ2doRK6QiT|F7+ZBZ1EmL9=CTLy zo8`SZ#~fbKiu`#aw1u#)YLlZm&MNu#Ac?K}GT8DFK$W(7_+e%T#k+@ucGnG(3=j_Xz;%Ob zy7G-1P^Qyhj0k?`1kJ2Af@y^i{86_cWI(%Wfq^uG(_Zx?W{=06|DHq``NT`d!1s&Y zrL>MBC@ciZhunq1P_i7-lqe=G)9vag-9=xaQE}LnhyfLCNwIRG*8h^5WGd5E1{Iez zuNKan^s1_nLbAAKsS%zlBurw=G$lF~Z_a2M?}+gVS1y948e}JMLH(x|w?l8-ZV8?q z3$Q?zXf9l1BHqe}szRl`VOuFGgwM%&VKc;(YW>kB+PrWjF+9hX(fSEqgT|ynWzs{c&*I!`)gIjkuZcy9KRq}*=$oS5SvF)>p56fF!X8P6{ooo>(n|UnH#+wvOpTL zR#*H@0M~Tb&Z|Bxc2fpQWo0frZLWPvN?}`Hbl$$C0yL2B>RzNUtW4DX>ocA6kgSA0 z9Fc}Q6NI3Ih#=+K9#1Tl;2T544Ly@LmZ+y(x>IeA@&q4(*Cm!5L*z0z#pX!dI8%&) zVIq_JqYdSqi}05V$+dM?XF9Fj3A^~ zfjlhRASj?&-nd!b1tSBZP>02W^VC-6Ep3_N)028~i#tdRDLr1oWQ1GEysamFBX+HD z{cw$5_vB$wgNMW1olak^Fl4x^lj2;`4ULDcepRZrOz6BMUM-hQCAa65oXvysz*m9K z0ny%DRz}`u)cx3&9s;7@oDWXphpW2V$YPT@YQ=F0DMpN5A!(?2= zc2v%xY~LC(=DteMZWQm^{Ps!WRi@urmu(vRPVJ%4i=LI&wFgd5!DFqbdTau=*3WG& zxWmjm7x_|y~3#BlXE?2<8TCqBWhD3kfpN_t6Px&(I2{Gm> z?9mHr_uN72bAL=S8B#+5H|QL!udd3NI}n$=y&5w}d;D~pccL5>$=IYRQ%j7+<}ci_ z9nBpBe;}3$`^?)CG=k^V%vKBS**$;XcNUH){5x-;;V%v)<0P1T=)V@ZT_gG02Er5- zIq}^taSH!5|8F_$dMPOnzh$?I>-O~qKSFp~L`N?t=B~Qi*{(qrliKaSwNvvlr;T^EO+3xwWgn zbj7Ofjcakr@uc5n7|ATm32)#+*8qOET3y7Q{VE@cUJU7b2wo>ceNvf_B#v z;RqqH*JnU+o@XwSJM}p&C#dic1#~B4WN)wrEOo2Vay*#Hw1-!1`E0+V!T-`6FW6ru zCn>;c86O62dSYa@qvvUf=%v8es8xQlxckHGmRfEBe+}QCIrt7jWdBVlb57RmMM083 z-d(-Zd9hE&kKV+0tD#7DtC=M>BW*ZB05T?b{(?Db(@G*Q>mTjr`p7d`2erV2Ufz9) z9bbZSAE}&FV1+-LtK|Q>DkX3mB6Wp;7H_{1iD(8dI1^<*F{Ap;_Axyrno?nv{rz=U z@WSFLBQIc{<>OzLQ}zw%px@&IC2(c|SlaD|w@T4^@|If&>>ql{j{iGt+Rd{%UST+T z{+oYCb-$d~k-*|<8fqC!9z;b>Wi;pPr72jOWhscocaL>o7vSdr$tvzkz@Gy&zhr? zSW|{E1SyJQH&|u0xR8@B=TcvSGvFmkT|#R4O{^u3b}_1bY3#4%fu7Vjmd79pE0`87 zJ8Mk-YDqeFr*+@05@mA!I+8Zjo}$M##KL{3d1zT*PrINt1ZqEUEvUVu;oEyurub&_ zCX5}&W;x0mXq?xmWt92jokw=hHUZ)s9xD9v8z^YR1eZ=}l z701=n^|2W&^0t0Vg7UhyN+Y#Y;v?-`EheCF?R@t=S{7mEi*xzNgp%=B?1MxoKsYO+@xASzP%d zVtZ0sN+G4w!Aed^fX=*Q+8z4J zo1fIl`VMbbpwh{afRa?G=eg5^gV<#&0fvAw`JI12Z{IJdJGVcMVZd+ww7XqaJbS8} z;#!IoW<<;o^s*#obo7Z{nbs9_X$~N|(Mhv(C8d3y{LCh_Kud`I$@=f^l+ za`g~zk$lc57Eno9gTn)FUjM4X70AP7Q{}-#(#;MJn(ARFFB~b#(UqFx)HcH8tr{!WL3sL4h9fH{O${Oj z+#!y$n3Oe!140*fPtN}sVEi7Do#4bBI${DSv-`G4fzpUn{YE^#u%8bV*}FSxh#3ur znY5DF_L0{WZd}kI!^Ph?06XEkvcbrE_Vn=a4-~9;qoIgk#=w1cqazx!5EdB$8_YX(x1oa%!hE%FQd+^F>ZPv0BjX5oy2BiFFpjF#8x-mrOQK* zI`<{PAS_ORo9LC!BghH$Ya}LwZJ6nfQJ%{0xwfOR1!R0O}M$$M& zR7)toLRWt=P<7~3HR#x_hiyfFy22oXex=-`Fm)Jv4v_M|SmF>T26(H!{i1O^s=%|< znJ@YAg*3v|Q)C(wK39F=h4Jpu>S{1N$upUuBB460uGG+0(Y2|)u6_xtu2>yzcU*hd!c~HlR``9E^ zxHOAgI^hl$EFw%dz3kXJU-Iv#xHYRP+;u)rB*Dm94LygZ=U3h0t7opYxQ8Z5*77=`=+`Nb=_y^)sN(AUQ8$Pm zrKdtjRg?a3NdvZ96YKt7ZHdkFUfdDQVYzuBCjv0Ww!v|}DR#Wn#)e%{Ta`I#@!dC- zyViWjJQh@n64UCIXPY^i;emm9{qBZ(T6$@J8MVU#v@Gp&S{t~deN5vb2%6FPO5NRy zV#7a>#q9BAEFNu_oHzEAhI4lgluli{w3Q4q$<f&N%ED?EZHAmrd_x5g!a=*gw4vwbOZtIDklnYnFUfn;9o;$|_ z`Xq>tDz{bpSD{8t0t>UCi9w*3;JA=l}EI>cV*wETerDIND-a=SlFTC6c-Z9VjG1%U?m_8%KpCaC#Ev zf|)uxUdELglGXZ)(&+^MQa=;ZcRmX!@gkBxCKNq`Q3BW~+{Engl=759WutzzGi)|7 zmA^XNZ07NRIGlE7MD9;sD|HeCp$o-cqSb=Lp`_>qZ{cTFXKWVOi%gl4V4W_T;)I8Z zF>mX5{jk)0)2gv5j5^Gq z=~`2D5Bn{$NpL*dxKME8UN1j>PnY-5-(MkSs+8FTwYty5k6>so5c8Nww*zGq^?4kF zseW*oGkEABvt7<WMnM< zYt|jLc%cX-T_g$f%8asi{!*NFSa~bH_MM7;JioST zr$Gt^HJvLFe4v*)Y=*LMLO}pwh+K5ui_LlO;xB zFHSdwnOa>TN>z)yoYNMk+cAHEjoNY)qIzgltAa4j*(|VSM}ySd?MrW|Bndnd$HFmX zB1`#|LPufgv^+~|Z5Shqtkh`8?@Pq|Uk4JjdZ-Ry(`yY{C?8$$ohA;xcXPBo->UYK z11YkNsSxT^R*d-uOS#nQ)VIzs&+eG63Hd9mXWN!j(-CZsX(mIM`R7Y;*EpAy1{Nx) ztc6?Bds77S%=I%lZb%!D!wO;`h zq>a1LIts~E8~;x29A$NomK?;!(kV>B%OsHkKFPA`m&GdN2fY89xw0H(FdWgeizA6vxlHE>sG+f9m=$gopPw%{-yg{fMgZ z;of3?J)~FET{!(V(~znG@|ueJtx1^|?S$rQRhiJ#J@M|lY%7J;ma zZUq14dN(NNLZCkvK>!H>#}mb1aHP#UQF|HZi98XoTXDeDF&PJ_MQC&cvMEe4Db-G; zWa{QS4c5W~HSV;xn4Pcx?w=Yl;$yO5*yea%01QY|%WGm^*&|YR`ZY{Oy`|-qFfiDU zS?N0zOk9ARfQ6^bY_rSZ%~H_r^+_8Ilw*19yikYEpnz5n!p&SQVJ9_?OC-Fvc(U6H@-Hk~2Q!{~c3a=6KJMAgR%^<*Fl;&0r$Pe-}SjMZAR3vbq zjv2VH4NZ)x#CyRMuHECH7moP}mwU>Z`Jjx0bG$AqY&V}g4|vYGeR6QJUz-gzl{>^d zVbo}4lrmPmlR|8MCq=b~cFaqjmRO9bS=XX4d{1IPYkoOLBzo^c5JV(Id-%Qed-8s7y?g&S=bp2_XPkZ z>)nB?C}Y}*S>5rx*#etk0Z{gmst|Rc?JCC{=cfn6^z{;T+b(kx2HFf`P+dKH3A%#0G=%rp zzB>^j{UQhNo~70V@4sEhq%78ijPJwc+Xd_HblHflLETH@*9iEdSSVem%HHcew)Ge_ zNf>~ZaHc$I>WQR)O$$}iff)6~ETaH3$eJaEMbvr2=H5Lo$zRb~N!wjT51J!~34cXB zX2mv&nRZEfZd9Ie@N$~)&hC1>ZQV@f%_{y}$6>oK#V?_w4yNxnI+N)u2;j~^5&my} zm4VYg8Bo<;q`UM}z6dm6FDT+>G}+))22^%u#1};_m{y*kVWnFX=5{|f`E~>r<|)8o za@8U}b4SpDoz@k9xsM;xU(zVbo~!qZBG42*>Z&Xx-<20!{hu)|nFIQotP&1HBR_Cb zCnUtk^9l@D+2MLh@-NIUxle`|kj^!LO`<8V3m+0tTk$^KgT#*-CXUvce1$@x3GE(7 zy}2>QK-s~3SRIRiWUTQ3RWo*3-kITuu&d_sbDkB#wl2LvU#(T{tl!4RLZD!AX_ABl zd$OP9f^xy*jP3LoYTy@fcA`$Dpx`M+JVryB3V)|wL?V(cQD`-Xr7MbTeymNqA8b)W zof|h2bx5T`E4V^XNe$z+Cm_^*kO4<{;hZNaa5mGz>;yFXk3l{CRg}Bo)+voeee+7g zP$qe`!iqrwi4(l@;HI*{Ca zf-asP8J%o6o9aA$Q4E>ptyv*)BP@}zikn%;rjuEA;XKSrR{qtOw>nO8Cx+U`N63F* zvaid*xRO7djw7p+MO_PYC5)+XA|Y8yRlj%Ke1ASQ03a5e4Ko@Qz)L0HaU-$jh~_@I zM^rI;Vr9IjcMFVb_ajLQ5M1b6IK?Ali9Ieb;>C1*QB8{5+=Hdu;~tR36GY0~_MI>G zBrWsQAU}BIw^bXm_A7f$aq+z}CLWJOQXktxnA}e?65HJ)B9aW>+MplHh7IKP)vNC) zy_YQz;uC{wcH#GXQ8VXc84s75+xC>3;ra}-CN-3w|wOJa-e#WBvD+C^)2`U>X#@X9b=v8F2tJti=@^TB<0JF zti9|Y#Z(^Wt}C6HlAG&-v~)i|S}UMWi9i08k4cB4Eq;ba#4q*%s-DI2-UZ#Tb)+lm ztfz;yg@G{BScKmgh%B?NveXE73=ZIiu`)fK0p7&n7D-+A5`pxg*QXS7^G4hf9X#5g zKX}HT0)A&Yt?I_L?-hJsE)j}x&9_Go1T($=3X@2g5{*QXA6ED=e$uy||ZvJ?v*<`L?C*Q>WZZpj!De zAp!cYd}F{bN}$LAM)t#pHl{`A*2c6ac40)P%-xO+^O&7lrLqz>B!06v4e@b8Qd4I^ z=kFA{)I3^Oz?S4V5Jt)z9UlkB1@X%$o4E3M3dT(gj8VYwDR=}A9Ib$(ytr~2lBMmN`{*aQdY9>j^NUOXn9f`S{DyNZ!*nguJk_Q@*=iygjW z;X495yP>jKI9N40ge!NMDO-AJ!NH3+ldVHt(LWsV&LX`Cw;P+P1T=CQexfbr{RWTH zVV;A_-sN)VKXY$mbnga6O9gWTQ;p;XW}5*$>+-+EnSOZExWT0ww3p{Bup#LJ6k{0C zntdO_3eZOPhqLf07!BcvShrQ}_f=>paBUvAdvNO8@>Z;jiv*@{#5STiZ)~+gmG80S3e?X*V@9|DwxF#N0P}j@e5So1!oh#SL`9yRJ+SB`} z?9LlWuw?eDkX7A5FMcx>Jg;zvBBJqb?Kgb0w`jG|>JyD!?M02%;LU!A4ndspY*;F* zXu0Hmt`Xwcoq@(|{PskQ48SVj<1kTJHhV^K@GZ3&fm|9t&Y40M1a^=f6D=<9;H;Vy zB}#lxsn%7|Hxquk&Ml2AvHz`kr_G&K?L?@+iG0b~172t3I%tdahJ^;eZjAOGjZ_!BJe24;dTKnZQzd9M*|ee)A9iEP@~c`y_el$oT$E!p1Q=R zwGVVA?KhLy$CHC7?fTW-t%aH6QfNgMg;acN1dLK>Z`xiY!U z2>Vz7KMUrUms%8#HI4gbbJMR!Zaw3A^flCqfR-W5mcQ*~MPVC2U`%h3{cbda#v{aS zl7zJ*Wx22906^M}N@z(gxiUq1xKc`h#J9J!=X9qV9mtBj1qFWn+prT-Ss4bzh4L|`T0jT!=WOUkT9k4le7y`lkeEKw6KWA8$qWU@F6^E56=&96qDg@ z6257YElpPkC1FOVFCS!9vd+ZK*uQR1a*K>=U@>`P`VAyT+ny(HG$H&=pH0uGrngZA zX*+K8WZY*h*$sh@?l3u0U|f-`i5p)YW0RZ6HHCo1q3K*Vq2 zH@u*xB^h}!4e6v<#-SXOE18Lq*FPd6&IQ!bl=uGJ@JBzVctv_Ry&~k~T8`)3Gz}Ab%cBlNL z#W*WWbl-<=aOjYK;H&UmQW35Uj~~wwMqZzK9ZGl1aE^zJeKJ~z&mI7uTecm=IT)+e z2%aPoHhTST2~UJ!NhT;H(a|6J&9|`SEry<=;`pA27V_Qqy|I13oXblYS(^`BH1T_8 z0==uM65mnM;002oV9s>}(b2_8+om)}(HIupWnyqCrPmwjW((N0?_i{m^*=bUEdN|M zMq_b~7ei1hGG?SSi1Q>g^mUX&zHgJ9D?TYNV}B?UoOu)`hgo496PF*#=5o(6_?_%V zQ#59ZYFAqR;D@axn3H&pO4MN`UE>NW9K{^ifby1S10{D12c~zLHSzo$U&MIl4v_JQ zopXYrnEdZa8&}X|elFSK+kUR#S}OD@){;VzA!a`7u3~wxWvzb{L`^dQ)uN!MNa?8i z?sWnz=gboJT0GQvcUn)%>oX66GV=6?p0X_N#<6rWlwu}^?DVsZ4KXTtqTO)jyIRP} zz>nv|<%xY^5-X(RFXisd-}K4VYzep0=AqH^HSjtqMUn zCWJ-3Jjza!B~(p)7{IxW9XNE(!P#pn%KqRr3-&f&%2*q)@`Ue)c5hF0qP13Owgmef zA=G4RWc&bM_)(aeK9y!}%nO5=4SR9yF_p0Fo2|CX7ElV+=~-_o;z~<%wi;DE?DIn$ z7$4D=zFC*PK~(BO;Q5=rP8r(;*>F5P>2IpsYyRxIBWmX=z)=F;=FO7Rh{q76SD#+d z?8=UuU*#WQCIqVxUvOj4#Fsx=Oe{i-zqxeC0Z9=4B|+vAJ%=QglDgu{sY?l6mHtxT ztFlQ-h5XX!I)Fw1Ng*vD^AFtI<@f-MzcC`_*8wh)f5Yl7D=GvRTW6{Sm*ZYhNRaN* zR9J6sUNj0L8>E@8W_1b8K+Z{1V_6elwWs1xBf(&ztG>%~9Y`H8HI@kJMV}Bd5lq2y zIoS2125NtJlw`dyWJFrg3iAK=G_Pk7$^IcCVgI*?5&2B!FAvu;>v delta 13689 zcmZv@Wl$Ym(>6*71c%`6?(XikaoD)KyUWJiHv}ia-Q9z`ySqEV1L54czwf-~dEPoR zwW@1suI{PTYt4_odR_NTpAP+zl;uA|!9zg6LO{5w2PGlVeEw^AlPADCCVoY+7jIM;{EIryZFHOuRpuEnnY+zE z1=mun7EgkSeW*LW0{HCe8 z#e23}{}ADZc`b)#_Y7;-H1lpTFd>gEC1Auo--irIHk1CiwXP)UxC{!qW=v75&~lX^Z%JT z1_UfD?BC3R+Yxj@VpXcQLBaCaBKe%omKdB8iGpd}3rOK^ zO-tkC5uI@3LwI)K4J7v)vXvB1(tdP{eg3E>Lj@xU8|PXe81gc8Fv@U#MTj!Nma8Cv z@Q|Ktm(fThuM%EufU0}H)FsrA06`*a~)ipodbCZdp8#9V0cJVyW7sFKg9 zLbpNr*cJE#yUH756g2Ple%Y-iBkmG~*TFJbE2ccJ%NHD4<4=++>V;oX!+IunLJ113 z3TSyoEh2gKq6o#Ls6!O`$m-_JHbcW+`L(+0L$!sqOy2D&H}X4RLr$O(mqc>)O+xfl z`X0;gkv%lE`h@Z%ae4R5Q$YXjEo#r_66sG65YzvRzkBQdORxiyJt*=^%Fd_0E)nf$ z^E5Y4g?mG95-Os%Pk(6k8L-jYPV)TcmovO`mZxi;P$0N@6v>0FAj6|@_7GQjL3`i! z;_U@vk4!Gu2k_^T0>Gp?WyoqWWT_%+16Y9Y;tG3PBAU@0T0@x!J3;umgIvpwCHI{U zwKDaX1k3#E4xQ|KJy6$ zqND5%l=}{X2ycx_Ilfw>$|$o4GqX^dxTP`^F3 zQ=#Yx8R$>Qk=(2RM0h-sl z-`?PGhMuh!aU4m{&Lm8=#qk28ymK*n@#~|_6&-rTX{J+{>{o#772gDXR8dnff}#>9 zd|A#|rUup0KAN08y<8LKamorGsH*6}yTn?k>(nc^UZ1vXO#+GQt29szOv3M2Ndo6* z&sL}HoWh=65C=@rA56T8;qV1+m9kjw!27H^CBqOKJK>m+;$la;>(Q)&aAQKdR)W4@ z{=|nqj*S-E*D9X+ES2agKceeeTXg@qKs?iF3xgaD6*pcvOn7?~MKOY8D=1)deLv2IMWkYGNp4n7!hzo=-ck`=}p7duEV>p z;AcuRDz=Y$WyA6zZLlEP;|-qEo?BRa<+s_P3Ieh0hOYDP>DlVqkM1V8UOF`*X6+45 zwn`e?2+v=gOaRm3mG-_P`Hc-!JvSOP6Ct~H)-6U4UGR8QC;=Nm)*AUrsfG4cRh@Ta zz4t%UN2e&t_oX(9r{1u8U&Jku_sjPVVl?dQQCcbvznh;B>EwQ0!u{Yz_blh5F6ecD z3r8Q*xIa8|Zi({6z!0FjS<3Z)Z|>chHGBN9?$ptCNfHT@r8(g;~={m#gOY3QxcGH3E#X- zYeZ|)5;e@3sum#u>Kgz@XD;EG=yA?~0ef?Y=x( zd+%%F#c~*;i&$ULqATs=){ja^D_PgOJF6V_f^H7HSgIjGo7_Abs0{_iRj=e1w9pq( z5?KOXLBII(E1#x*?NH)TRu^~z%3jUUw}^UoV;`{xqF&z}H(#KK0jGY;z=C;YK8riJBk7xn#>nE|21_|H*M z-}fv5h5JgNeML^)&y^d+Id;VEXVVIAV&ZbYMWty9BYRYJw)V}PA8m``w_&lq=kcXj zA>8Vs_oz)Bk<~F0e#EpOcG~ZuJe$@NxWeB{$!}t)Hap-}?uH|se0{Y<|A_EBS-6!U zsUME7t~Ys9#vv5)C7AcU`6z#j9$p`pmtS-Mao!Sv;?Wclk7m69l8-7y<3>>h#VF}K z#etW|tGnO4D#&tnCS@eN4W-2=dQCkH7QG_m%U$4zh~%@GtczO*l{vjyN4F~Gf@0_^ z7L?o9mMX?3%5R@k_lt;jC21E|8RajzPAM)lU%Z~q@5eAkxhRHH%GemSeM51zS{$nL z)#GA8hR!ywQ={ye>}_I~9GfY)W946R)H+bC8vwlYvT7NrQ&erm6Tqo&TR8PqwfT51 z$V#|;1#}qox|K80R`SyL)7X>eLca7=tn^6RZpnsm;%n>?hg?R z4ocirRu0^(P0;%}n5XqJOl)R#w1=vsG{V2CaP}^{zP+ZCtS?r}D`-=V!*N+89_?%e zJ`y08XO&g5m=}Q)?C8rE+SgJzRYy->JrcjhVGgdb&!49Iwk7OVvJeB#X+E@cbUH7j^b zi|x$-`-aEz!Fw|wZJSGYgJU)@bfMzP22Bzb^_O7dj^cu<-uR{R#xp-jZP`fP9UGn( z%@h=nuo6vNM17&{hK79;Kfnt{Fu52cvyEm4r+bV15NEl*{eoP75R0QFg_Zj%ZTL8q zlFGig`v-(*qLg;YYNI@M?-1oCfuD2jq&8elr((%gvP6@;3h&?Ox-=$3F_mJ`q`7{( zB%?M#&LHo?XCkpw$_#AVOXBK{buzmT{3C#kSOJGBMxF7#(iQD(uyR+3v3LzK(j`OR ztRJTLEt*fP(AcqDOXr6=H}c{}z=qk38rPx}SCGKm@I;;!J@_c_7H@_$%K}UZEgk+_R%VtdCGMpv> zonY@;bx8Ps$oVySv9X_Ee7T@M7#na~O;9g%J-~b{+bAn_rmjK-ltB| z-W$rHXI3FNE7SpA2zQ<(1VpQI#qk7e{9?k+oEpyp&z(vLNaNOIbo>Icgfau#i zso&ApKTYkWFrGJ_I}l7Ju9|Vzu23&M#6Nj2{7G3^$`YCG1}T`fscv_faO}Dl-1oe> z5^gqA;o|CPprTkGn0y8o{c!kvn@0`~>J}f`K z;u5_^yzql&J*0zF6DBNO)9&V6l;pJAx93A@gvSK0cc$NehFfZgtbGi>=^9w-LVGjy zaYaI33l(2_~X_7V16)dsgn`A_(2P# zeksu29eb(t+pTk%&G>wKt#a4>UeB1m{z$=v;fzh-^fmCaYO+|1AYJv#X2i`}V!?$)B%}wM%(aOCENf)btZ|=PIxOqvl(Gk=cN`mF@CI4*o=6kX zi1`}SoU^z0&Pcc%8FfAoaZZW<+iSWb=&3#HKyQOV^0`%g2TMEto#8~7Hnmlw{KmR$ z$dV(Fj^zakg@r>5EB;LwnB1cs@`24)o`>hQsZsDi7%E>HJzo{4SJHOs3P}~}Kzsrqi zpR(LKAp-b2DD*@$AmEOn-#(C((Oq^K+z3!T6clbP*Ze`=1z}J-5)EAY-js;sU2hV- zg+~3rmZd?5dwfj87+% z7{Bm%-X7pC^UZz?wA>ac9mUFoKB+i9DSe9Ufptk1W zmtlNJk4T+>>YRXX4TCz2-ukWVN-7)_t&0=vXbRhn?!Qs@y`g8SA=mht_RC?(Eko|& zP`PNkBSahB4WdzU={6kX@cs{}>~F$f8`u#uT!LR5JB?Y*;_(e0B>Ul$7VKXjL zLyP=yf{k{?xeYun?ICBtG57(oZPo`$f;-b)SQ$rbt+BH9-po$o`;mXvx4~dyPcO}J za)|0u{^(WWq;=?~1@9fynSdiF$UsS3jw83ExTvTLRy}f=V@9t62auml(&lc^)sDQt zvn$E1egZJub%0flG$eKJcw0L_iUhMYt0}$~x5jN+>3uoYK0jh->4r00h$r^)1cfv> zM$BiwrXk-3C=w=@@bI!%csW^M>-DStQ5>S*;q=+`)IRQvxARL7`Yn#7BWz;-+m!5< zdL{Hb+w6PniEqd+(3IAb{7|D|UHLPo!%xS^PVT7A?LCLL_?34-)>o`s&$jX83iVp7+Dt=1N6(5 z+(RiyThTS8umV*&-_&Ml_axIeZOM36Ih$x)S9s2wIX%FKV_|@`@;yP0>Q=!#VNvUK zL$$!rfy+h^lIT9ArX{r;D0MPq_{u$ApX+fYKWW(J!VIMaT#nt0-F%IXa7OeIhd$RGaC1hn5k`mk~@) z34_F@F`kID3}G=pG|WP(VoGmJ5VMA1lc#r5`e;oD%qSEzKmFkXgIle@rk7vCrK&&j zoy*RmhoRiFh$uPJ;R_;XbJTr@6PKl(OFNzJgxl8kb?RD?J~a_=T-CDNoV*~q)OHMT zFvgI=#?EZvt}~1STBbR!4b95JIKu(VVuzDKzR=vIhUv{!w%8;AQ}5ET<;_rc@y#5G zHtMs2uNI~g1HvB~^o0vvano!$@R`om$d~7o=vECPtT%?qmwzR{u(pXh1b;aGC0@YU zd=Dk45D?K6|BHCh`-e~czZY1AR1;LU7_hF>^FS567DAs3C7HM<|Btsiza7!q_aNs++K%zjN=V+v8CHEjxChBzk)FqQf-2mXBetm>Il zp_s6tY;+`JjY))1*zD=hFal2!Apnny5kq4P{NZ4?d@9+U{HpkvD(pfONdz+g>K5lJ z>oz(2vKV!)`n5FvPJ_n=Oh}W)b^f$;e>$-uG%me_UCKp`yq2fY?n|Q_4Oy?lrj~RK zcfnosSgDfBuFTYGE>_DebRk}^;OU^H}eXved z&$Jv3juRHH#m~YVX{Lc?27ta=)RH;fYA~ykP8&Sd~g*tf^_Fqxp)@Q(VG{C8x`>X3R8l?Vc zvT0iR)+|B_Wp!zcuhZDvv^H*Y>AzjVVbjCik@Xe!EfBuvqSJf$BY~CGRcLRsfeD-KFUN3Ip zzQWah-xQ8p*A9J@I$0@8zhVwL*(7fwC^N{xoE5i-k?ez}QD-q2gaTl+?f~FmWAk_( zbXxUo7tb4!qlKef5b3{K&6^;-9)&uDKS&Y*@_mdeOo7qUl}@@7XER+iLuSxw>e(@9 zL2vsH*ds`~#Z_4zPOf4xL(o&S{J04j_!L@d8US(HXrPT~mj)o&U zM!ih4j)`b@hOr^U?3y2dLBKOKRhURl-5EF zo!$?l=4jq@dS!2T-U{^QMX_rnt6i?H-sZwzN6j;YX(!Gdz=nQ%Nc@OTN*T3uB;R!} zB&pk9Xp@AJbggF^O$9;s zqbyS5sQPGnTVP`__44;rJ0rF-mZ**KXUp+owrCN9eo2#Z!M`YTmDfp#iVUj4y=*kh zX#E5mEwORp@)|Ix)e+D`VSu^x%Ur!zwY^{9`d&$#+9aGQzn6fDwygKGV|6gywFd9Y zOCZ~Cv|cN?984zIQi@y+@=Tgy(>EHY;%npw@ii(%!6J%!Ek#6e_jEyrmU(Y9E{=)e zIwdAujmgx6mO{W3dP)jS*4yv7gG$+|PDrjFH*QYyTGx$8Xh&@ZVwxzs+ikWY9qlzjGr`9ByLEB3D! z>(ZChOb6_lb$0LOgqW+)eC!0^hev-vkF_>%U5D?$Oj@} z`1vl0N-`Zsg5pAhnJ_n3`oy@Bh2tE)LdO|Xvk2+WXCH!;Z30w~s|VfTt2J+hWVbx` zuU_uSn8ZBJ5|+`AU}W_-CiEcvR%*#`OE718TX{E}>Y546Kz7C1dGuAnYatQQ^G zr+2z5xmkTMJXM=QHArL-##yPp!;0)w*{q4E1+C7hygVU=^H7KP`D-&>tJ2{6-No~) zJX=y@)d2_&g~+UxDP@!$%YLhLu+Uwoa?jXqpJ_98z&u)r2_I3iFvYbaTH356Us)0N zI{Bxxx&YNnM_Zk^gOA=I`W%VD8o$ZP+aXdvT)g++KO!N-1!_tUc*JbP{CdUZ3bwL>%pk2af}G_yX#!~#J1w@0z#JW!~}ji&~pL3_k`5Z39#W(HGSIvTCS z3>RJW9KqEk1&e!-Z=!?5<~UyIWh+FI1my49s&6r|12Z(kIOK7Q<56<$SvJXjr|F8OCo84Hc;+{({#r+oGq4_~Y;#B10Ya{dGi+vSM!&jMD%|-RF*{}Vb5Sw( zEHm=l;?PHgj_w{VJ`CHizVn93Z&%q2zs>EK*>BO6O)jVr@Bl zZn8NHH%{qnIy8_uCyW>s80k=wW0Zm1UXzhCs^oC6Df`GbCRFx({yf1NG?z5;JguQo z+m134j`~w>PTWwEG0S^(lIeP3CiMr%l|mNvJ6NRa13Pl>n$51e0SFDb^=^**OATuH z;QZ#nj>Jsfgtca#7d@s%TB5SaHw%oF*!IZ>X z>BppZ)^Lwr_h;VhaS1HY$5hKNUgm>JkZ|aB$%Xi%LpS`}XPKn2F#MP77wSK$0u;F+ z2t?JLd)MLy_JhW)o|^Z@ditP42z5ghbK{U~|a^`|NorteLnduU?Dig5VWQB1^>&yf$7UXNZfY3o4E9^7?F#3AuJ zHY2gL-@(p7zp~@#vyW|>zumQk4XO&U6SM-d`?tmN`E!q{Z`@GttRhUNl!h<>{cVM6 zVsvVC>5hvulwGHVz;KTV3I$(b4+>Y?2QV_O7{J zEC{>~d4Skg@x$n=DJH1dKIDehZVN^w|A)`lc}xtHe63D#Rov)}XW|jtv&B`8!&y@1 z^xQkhzWsSZeuCrAX){}CE&_36)c!{J{v=>}M*^|)*LM~hWaBU2vuxfEL+iSNNuGMo z%+FRdc5y(8FP~;q&goDBXSZU?2sTJorZHrwPlp-1le2#!B|7OB@1i+!b7WH)2EIX=D}Tv+U=Ymlg3n01f;eb|vH| z7XBNeQ!*pob>j$Y!0YL2y*tkEw#QKhjsIBTmv5b$sH>9zWmdgNsL#bQu%76{j02T0 z_MM*YHvvl__>nhpts$DVzR!Ok#HI7Ok>!p^%5l((x<$<*_X;T94I?N&OKtV5c7vS9 zo_^8Zl$_Rpu5tpls{<~!cqz7Uf??T|ov8O;nIbw7!~&SRZ6g7e(GwdK0vSxntMY8? z8Qy-svqkf^Tefja1GYaQ`{cEH{=CFH;s_HE;l$XGSJcgrzuMn;huZyg6jX(534L{8 zT|KT9e(*px_zrxL%$}x1Y!`1@IMCjEJG?ASZdO1B$@9MHxM)!jlJXMt?=BzSnDq$& zR+^0&`ipSZX-CL04pgLixunwxTE84hH|`aRbcPl}wTDWW?WG?O|AYdKl_0CMA1j%q z96~&`inE~Oq7$mxhP$ncOcpwrQ*ct9Cd<7TN8ONij->p$T!Y{ClzHh0*nZLu4`2a! zb%OIixh7}4yd)r ze&56dy!oVswloKyV~)Cae{>Ur@TM@>tptp)+pdqSP#Oje00TmIThr?6({|2*VLOTB z(zy~W-yJf!BLy;gH@(J5@42>|2h9FCHDW9-e~ktY^)b4Azytn-42%QR-$%0olow0U zyv-gnN3ez1K5)P9Co;khc(I~NetiC0T0G|>yK^8yKuF;JuksG8&ngdQ)J6p5C@aW+ zX8r14N36SSbG|HjA}LS`L++(6K?{s2VpQ)enQSa{Kp(woer^zeBkDX)K(oY#W;Q^i zaI_$Dq;eDzGTesHGv*&agZ~t*k)n)MsJ3X?C5IgAdXc8q!@(0^i@ABa=HKpL5zjC_0@8!prhCnH1%wFYdJ z<2E(@bdc7*%$ap@up(V-QT@|sb*=_9`;0Yp4p6b%V_1 z9lR*_7J}Ods;}hoRx_V*koOUK;GbiUnzyvW#qr$M$@?=e!he%uN)$oafahY|HKBz^ zZmlZ5MggkT)VVikOzD8E%UnMW)YIo(NM8}W}#T_qhFu7-Vr~TfANN`(<+ARL}*oCAvW-A2GWv&UrD~L$9^{E(B>t348#qwI{ ziHGfQ@pFC^Op(=+s^7+`M;p$MP;gz5DS^2-5iDMvTy550-a#>nH`)r_grIB7Hf2H^ z2g8{ViQ$2c&b0#RdXcqy8l8 z4HighO=Zeg3$Bf)l$&VtsJGu(Vq;jY6&se5fLc_ebD<~hn2cbvSVM*S0f&P5)vPtGr}Qm593Uw+wYlL*9mib5QHuCcnVZf%CNuLOr&&j zbA7NpMWlPHLjStkM*Kr?R`H@pMUu=tZe4${3;!xOYH)(1t+L`YTh#H?_^j8idb6Ol zM{O9SFSpv=_@Jo9$Z@_B7-yD#r!+HP-7Vgz-Vokl$)==eFKQccappjt%C)b{$c|cKvaXCL@pawZ%?O6v zPV4fjDcSjV>CR~8YVF($u+6I4AA!av4g(Xkq8G2|X1!8oirv1QY#|V;KA5;W@a30d zUX*d3%+=Ct!HJ4vUH?#E+dK~2c-exl`$eyTD2q6^j7uFklNM+3B>6XQHxtNb2koCm3Wz`B)d=6g%UFh-b z0G~4~i&!RedU$mwEpAVW94n?hs>6r&QjHesoCVfs_Jb%ew;@Gwyo8=3ZA6?g63VFz zZB2T$fP$pRjwb`PF`asRd~6^3h;I@|N1%M)xakDHab7Xfd_QL_`Wo%xnYM~2YLeqE z)=1VMRl17Dl%#!~pq|hbMW4G$b*RI=6GnLaVyf#Z_^F|L5fFVln}62U!Dmd+z^+)9 zQt{?WAY9TO`hy>0MF;NDkNR z$%boxUo+~h&dA8Jfc7^SPP;LuQXwaru0rrBKBd3=IluEgRxNF1K6=~|s%hStyV@CU zhu+Rejmp*~Ie;+AcY#xYKHcyp{4v7xsMd6K6oSdcRBwIJQ46l-&|eWWOsQw#DqrE) zy45+ypI)DU=MT5mMjnaqoEd+tQ4Bjb-LVqKs#Y3*1x>j9lY5iPo*u4ab9%0f&{GMb zr?!P1Cxg)+ALyQfT~GJ7+_PW6$&KS$7?KYI z+s66$hM*}5!muM0w>MJIpAp+Qgmp%@c=vLJY+P6OPtnaETz@3`zn?v`cj_GMHv~U= zt=O+SK>h;eH=I#d{DPRIYx)R_zuxI+j9o|a@gmAmVAc}(^)ZW~m|XspXHfR`o9_=i zXi`<&S=S`qeZVEYX-ymv>YHD0oFp6eYv4vRo$A1Si&sl{4?4ql(4$eJ;FfS{_IpA@ z_rWDxZv+UaacWq7I$K;Js(Isx7w}_0f=>CYMtpt?+N5T6*#ZR@sA~L*LiiE&Vd7cX z$o?&3*s|T4Z-(TL?(oOXFvBIVoj=W4P}R0(8qVg4-md2Kxrd#kHi z1cz`PVds~bZg9JLNP z0%i8e_M0p|HDrC_e5jxi<>&hJoF6tQxcK!FX+>ioI)aXQkdRHWjy42F(_wpMV(LXt zbg6xd3-_P3&)_ea8>QoP+LQSekRiFLxm^4ms+)!5lhNH$eDFQ0rg2P7s?zAkv#r^z z4GK}VbUBG(Y#t@49G({8uM_Gu-!@2+Tn|*6%yjM@HDnzN!;lyPZOpz$! zB!1n?+$|=(#1P!`WP9NDGf8JQVi%3dCUpQR$oPlcKu;V1Y{KmDioMJ#AN$j<5*BiX znH&AZ7HA2@f<6}jItx{;Gp`qQ&J!%?QP{OIrqtqk`1e2Ij0|Pg)aHnu8B;$9m0%7o z8HDzIsUNEB3!n-ko`hN)hREZ8-x?K2xER9l(EFh{rTuDghW29q?e~NwE2HM^InV`r z6v$JG0Q$~164Pl&#;J;*=ntsae=n~GAVVDrwHbm#7{u-%eg%l)1MedJS0-o&e?zq{ zM&3S-`W{n9le2lwfIazTDJI<9OYDiK5S6yy;y$a>+9&T1K4u*BOCpD{1tc7%H|A_5 z0oZaVOPNzcANHh-FE~HShOUdhC^S_cnrcdkf-*c0F;cYL&?`nVifuMB)La52 z_>X=&@##2=V~wOiDuhRw0O6NHUz`W;rD+p9aazRu`M-Rm$)LBB&Ruk$`fV$FrNxy7 zmO9?*wPn$dzYIy?{ArI#wB$iuov_U4Xic2Ht1F0;C;=Ydlw!?^yGlFtZ$Xae8T1F4 zgY102fzRNH^x05Tj1Vw!R2aIk+%O^S(%jTtncmFhUAFgW;AU0iMSzTtt86)3$vW!0~29Q4FC`(xKNl`&kPq{SKO zqC>;kVml@HbPDQueCi_iioip0>4fSh2OTkWo}fixM=rD_lAk!jhko92$gh+`a;Ed#vETsyp8el9H!=SIjvONhw z>YU-c!&{1fSc&4V#rE9exf+7A8igLlsm97`e#Tql-NB@%8W?ZO^FsAbn7156$U8%<5`{PX6lQRip9Jz%JM}S4b$rpTEUU=R1%ZTPgdOf8 z-XF-HU44wTv9|e<;`OQhT&^ZH#hE!@U?0*7W)>3%w>e($HrQR63tSDwS3FTcA8sq$ z(dJ_|dWJui`n%<3Hxm$Tj__(zVguT*Ii~}CBe@9j zHwiYv5bfPVAq6KYoe^PfKuqvn*$vWqNX!ze_@y&e=-TgiZ z(eJ0I?MRm>t870rAm{drRX=t|%2oflriEItsj1)( z))$P4tmx*}?@|HrcD)F8`M2beoKpEv-WbTe(zNmQwmg}B5(*rAHX(5sia4nw9{RG3 zPh2o8I5mDC8~!4Vdi8?Od&I6O4W0j~~ZiAkd3H44c1^riVHh5vtyvVJ8 z&O24u&uqWm+iMzkV*h3Z7mC;Kqv7WF)beE7&&ZVT#HKE!U`7#gMxbg|gK^n4;#<+N zAX53LA6(K;>y43BKhNgK20Bl!hz7~*oZusV7(?rzI6kny2$i8)d&WjtmP&lbb=kHh zFY>Y_B|vv^@9Nz;jdwSp4nK-742;t7lB`o##`j(mLD7b zhAdivFJ(4qt?sIGtK!JuO=LG|Qs1&S zn0^pER1+-ItyB~gFkuSIVX4^*ow~Jjn3eec2(@cs1-IFa1fZRIeDBz_togmMFSJdH za<|}6x_usOQrqp}aE$Te5|0q%P{>pQ}PwIUCYHv6+ znE$mF8Up{?A`Pj3L4SoTU_~Gn7!F7R4um6Q_!|ua;(z28djBcJ5cz-G(*N4b&){1v z!haM!=-_uC5y{_A{->5e;=jO3(EkIjLc#)9X(RsY@Si z*{qfQFEBOkKVWLGA1MV`TkHQs{!jWv=)b_f@%{x@YEk|h^*;#(ss93IQU3!}{}=S1 zMe=`xglPW(34lvk5&ur=-< +#include + +static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) { + return (*env)->GetObjectClass(env, obj); +} + +static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetMethodID(env, clazz, name, sig); +} + +static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetStaticMethodID(env, clazz, name, sig); +} + +static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { + return (*env)->CallObjectMethodA(env, obj, method, args); +} + +static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) { + (*env)->CallStaticVoidMethodA(env, cls, methodID, args); +} + +static jvalue jni_ValueObject(jobject obj) { + jvalue value; + value.l = obj; + return value; +} + +static jthrowable jni_ExceptionOccurred(JNIEnv *env) { + return (*env)->ExceptionOccurred(env); +} + +static void jni_ExceptionClear(JNIEnv *env) { + (*env)->ExceptionClear(env); +} + +static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) { + return (*env)->NewString(env, unicodeChars, len); +} + +static int jni_IsNull(jobject obj) { + return obj == NULL; +} +*/ +import "C" + +import ( + "fmt" + "strings" + "unicode/utf16" + "unsafe" + + "gioui.org/app" + _ "unsafe" +) + +type androidVaultSharer struct{} + +//go:linkname gioJavaVM gioui.org/app.javaVM +func gioJavaVM() *C.JavaVM + +//go:linkname gioRunInJVM gioui.org/app.runInJVM +func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv)) + +func newPlatformVaultSharer(goos string) vaultSharer { + return androidVaultSharer{} +} + +func (androidVaultSharer) ShareVault(path, title string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("vault path is required") + } + ctx := C.jobject(unsafe.Pointer(app.AppContext())) + if C.jni_IsNull(ctx) != 0 { + return fmt.Errorf("android app context is not available") + } + var callErr error + gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) { + sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare") + if err != nil { + callErr = err + return + } + methodName := cString("shareVault") + defer C.free(unsafe.Pointer(methodName)) + methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V") + defer C.free(unsafe.Pointer(methodSig)) + method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig) + if method == nil { + callErr = androidJNIError(env, "resolve shareVault method") + if callErr == nil { + callErr = fmt.Errorf("resolve shareVault method") + } + return + } + + jPath := androidJavaString(env, path) + jTitle := androidJavaString(env, title) + args := [3]C.jvalue{} + args[0] = C.jni_ValueObject(ctx) + args[1] = C.jni_ValueObject(C.jobject(jPath)) + args[2] = C.jni_ValueObject(C.jobject(jTitle)) + C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0]) + callErr = androidJNIError(env, "share vault") + }) + return callErr +} + +func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) { + var zeroClass C.jclass + contextClass := C.jni_GetObjectClass(env, ctx) + getClassLoaderName := cString("getClassLoader") + defer C.free(unsafe.Pointer(getClassLoaderName)) + getClassLoaderSig := cString("()Ljava/lang/ClassLoader;") + defer C.free(unsafe.Pointer(getClassLoaderSig)) + getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig) + if getClassLoader == nil { + if err := androidJNIError(env, "resolve getClassLoader"); err != nil { + return zeroClass, err + } + return zeroClass, fmt.Errorf("resolve getClassLoader") + } + classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil) + if err := androidJNIError(env, "load class loader"); err != nil { + return zeroClass, err + } + if C.jni_IsNull(classLoader) != 0 { + return zeroClass, fmt.Errorf("android class loader is nil") + } + + classLoaderClass := C.jni_GetObjectClass(env, classLoader) + loadClassName := cString("loadClass") + defer C.free(unsafe.Pointer(loadClassName)) + loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;") + defer C.free(unsafe.Pointer(loadClassSig)) + loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig) + if loadClass == nil { + if err := androidJNIError(env, "resolve loadClass"); err != nil { + return zeroClass, err + } + return zeroClass, fmt.Errorf("resolve loadClass") + } + + jClassName := androidJavaString(env, name) + args := [1]C.jvalue{} + args[0] = C.jni_ValueObject(C.jobject(jClassName)) + loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0]) + if err := androidJNIError(env, "load AndroidShare class"); err != nil { + return zeroClass, err + } + if C.jni_IsNull(loaded) != 0 { + return zeroClass, fmt.Errorf("load AndroidShare class returned nil") + } + return C.jclass(loaded), nil +} + +func androidJNIError(env *C.JNIEnv, action string) error { + if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 { + C.jni_ExceptionClear(env) + return fmt.Errorf("%s: Java exception", action) + } + return nil +} + +func androidJavaString(env *C.JNIEnv, s string) C.jstring { + chars := utf16.Encode([]rune(s)) + if len(chars) == 0 { + return C.jni_NewString(env, nil, 0) + } + return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars))) +} + +func cString(value string) *C.char { + return C.CString(value) +} diff --git a/android_share_stub.go b/android_share_stub.go new file mode 100644 index 0000000..80a101f --- /dev/null +++ b/android_share_stub.go @@ -0,0 +1,7 @@ +//go:build !android + +package main + +func newPlatformVaultSharer(goos string) vaultSharer { + return nil +} diff --git a/androidsrc/org/julianfamily/keepassgo/AndroidShare.java b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java new file mode 100644 index 0000000..9f62da8 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java @@ -0,0 +1,81 @@ +package org.julianfamily.keepassgo; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public final class AndroidShare { + private static final String DEFAULT_TITLE = "KeePassGO Vault"; + + private AndroidShare() { + } + + public static void shareVault(Context context, String path, String title) throws IOException { + File source = new File(path); + if (!source.isFile()) { + throw new IOException("vault file not found: " + path); + } + File shared = copyToSharedExport(context, source); + Uri uri = SharedVaultProvider.uriForFile(shared.getName()); + + Intent send = new Intent(Intent.ACTION_SEND); + send.setType("application/x-keepass2"); + send.putExtra(Intent.EXTRA_STREAM, uri); + send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName())); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + Intent chooser = Intent.createChooser(send, "Share vault"); + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + context.startActivity(chooser); + } + + static File sharedDirectory(Context context) { + return new File(new File(context.getFilesDir(), "keepassgo"), "shared"); + } + + private static File copyToSharedExport(Context context, File source) throws IOException { + File dir = sharedDirectory(context); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File target = new File(dir, sanitizeFilename(source.getName())); + try (FileInputStream in = new FileInputStream(source); + FileOutputStream out = new FileOutputStream(target, false)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + } + } + return target; + } + + private static String sanitizeFilename(String name) { + String trimmed = name == null ? "" : name.trim(); + if (trimmed.isEmpty()) { + return "shared-vault.kdbx"; + } + if (trimmed.endsWith(".kdbx")) { + return trimmed; + } + return trimmed + ".kdbx"; + } + + private static String sanitizeTitle(String title, String fallbackName) { + String trimmed = title == null ? "" : title.trim(); + if (!trimmed.isEmpty()) { + return trimmed; + } + String fallback = fallbackName == null ? "" : fallbackName.trim(); + if (!fallback.isEmpty()) { + return fallback; + } + return DEFAULT_TITLE; + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java new file mode 100644 index 0000000..b669fa7 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java @@ -0,0 +1,131 @@ +package org.julianfamily.keepassgo; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import android.util.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class SharedVaultImportActivity extends Activity { + private static final String TAG = "KeePassGOImport"; + private static final String DEFAULT_NAME = "shared-vault.kdbx"; + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + handleIntent(getIntent()); + launchMainActivity(); + finish(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIntent(intent); + launchMainActivity(); + finish(); + } + + private void handleIntent(Intent intent) { + Uri uri = resolveSharedUri(intent); + if (uri == null) { + Log.i(TAG, "no shared vault URI on intent"); + return; + } + try { + persistPendingImport(uri); + Log.i(TAG, "queued shared vault import from " + uri); + } catch (IOException | RuntimeException err) { + Log.e(TAG, "failed to queue shared vault import", err); + } + } + + private Uri resolveSharedUri(Intent intent) { + if (intent == null) { + return null; + } + String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + return intent.getParcelableExtra(Intent.EXTRA_STREAM); + } + if (Intent.ACTION_VIEW.equals(action)) { + return intent.getData(); + } + return null; + } + + private void persistPendingImport(Uri uri) throws IOException { + File dir = new File(getFilesDir(), "keepassgo"); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File pendingFile = new File(dir, "pending-shared-vault.kdbx"); + try (InputStream in = getContentResolver().openInputStream(uri)) { + if (in == null) { + throw new IOException("failed to open shared vault stream"); + } + try (FileOutputStream out = new FileOutputStream(pendingFile, false)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + } + } + } + + File nameFile = new File(dir, "pending-shared-vault-name.txt"); + try (FileOutputStream out = new FileOutputStream(nameFile, false)) { + out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8)); + } + } + + private String resolveDisplayName(Uri uri) { + String displayName = queryDisplayName(uri); + if (!displayName.isEmpty()) { + return displayName; + } + String lastSegment = uri.getLastPathSegment(); + if (lastSegment != null && !lastSegment.trim().isEmpty()) { + return lastSegment.trim(); + } + return DEFAULT_NAME; + } + + private String queryDisplayName(Uri uri) { + Cursor cursor = null; + try { + cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (index >= 0) { + String value = cursor.getString(index); + if (value != null) { + return value.trim(); + } + } + } + } catch (RuntimeException err) { + Log.w(TAG, "failed to query display name", err); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return ""; + } + + private void launchMainActivity() { + Intent launch = new Intent(); + launch.setClassName(this, "org.gioui.GioActivity"); + startActivity(launch); + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java new file mode 100644 index 0000000..2793516 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java @@ -0,0 +1,100 @@ +package org.julianfamily.keepassgo; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; + +import java.io.File; +import java.io.FileNotFoundException; + +public final class SharedVaultProvider extends ContentProvider { + private static final String AUTHORITY = "org.julianfamily.keepassgo.share"; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + File file = resolveSharedFile(uri); + String[] columns = projection; + if (columns == null || columns.length == 0) { + columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; + } + MatrixCursor cursor = new MatrixCursor(columns, 1); + Object[] row = new Object[columns.length]; + for (int i = 0; i < columns.length; i++) { + switch (columns[i]) { + case OpenableColumns.DISPLAY_NAME: + row[i] = file.getName(); + break; + case OpenableColumns.SIZE: + row[i] = file.length(); + break; + default: + row[i] = null; + break; + } + } + cursor.addRow(row); + return cursor; + } + + @Override + public String getType(Uri uri) { + return "application/x-keepass2"; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException("insert is not supported"); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("delete is not supported"); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("update is not supported"); + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + File file = resolveSharedFile(uri); + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + + static Uri uriForFile(String name) { + return new Uri.Builder() + .scheme("content") + .authority(AUTHORITY) + .appendPath(name) + .build(); + } + + private File resolveSharedFile(Uri uri) { + if (getContext() == null) { + throw new IllegalStateException("provider context is unavailable"); + } + String name = sanitizeFilename(uri.getLastPathSegment()); + return new File(AndroidShare.sharedDirectory(getContext()), name); + } + + private static String sanitizeFilename(String name) { + if (name == null) { + return "shared-vault.kdbx"; + } + String trimmed = name.trim(); + if (trimmed.isEmpty()) { + return "shared-vault.kdbx"; + } + return new File(trimmed).getName(); + } +} diff --git a/appstate/remote_binding.go b/appstate/remote_binding.go new file mode 100644 index 0000000..f460c56 --- /dev/null +++ b/appstate/remote_binding.go @@ -0,0 +1,141 @@ +package appstate + +import ( + "fmt" + "strings" + + "git.julianfamily.org/keepassgo/vault" +) + +type SyncMode string + +const ( + SyncModeManual SyncMode = "manual" + SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save" +) + +type RemoteBinding struct { + LocalVaultPath string `json:"localVaultPath"` + RemoteProfileID string `json:"remoteProfileId"` + CredentialEntryID string `json:"credentialEntryId"` + SyncMode SyncMode `json:"syncMode,omitempty"` +} + +type ResolvedRemoteBinding struct { + Profile vault.RemoteProfile + Credentials vault.Entry +} + +type RemoteBindingInput struct { + LocalVaultPath string + RemoteProfileID string + RemoteProfileName string + BaseURL string + RemotePath string + CredentialEntryID string + CredentialTitle string + Username string + Password string + CredentialPath []string + SyncMode SyncMode +} + +func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) { + profile, err := model.RemoteProfileByID(b.RemoteProfileID) + if err != nil { + return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err) + } + credentials, err := model.EntryByID(b.CredentialEntryID) + if err != nil { + return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err) + } + return ResolvedRemoteBinding{ + Profile: profile, + Credentials: credentials, + }, nil +} + +func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) { + if model == nil { + return RemoteBinding{}, fmt.Errorf("model is required") + } + + input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath) + input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID) + input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName) + input.BaseURL = strings.TrimSpace(input.BaseURL) + input.RemotePath = strings.TrimSpace(input.RemotePath) + input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID) + input.CredentialTitle = strings.TrimSpace(input.CredentialTitle) + input.Username = strings.TrimSpace(input.Username) + + switch { + case input.LocalVaultPath == "": + return RemoteBinding{}, fmt.Errorf("local vault path is required") + case input.RemoteProfileID == "": + return RemoteBinding{}, fmt.Errorf("remote profile id is required") + case input.BaseURL == "": + return RemoteBinding{}, fmt.Errorf("remote base URL is required") + case input.RemotePath == "": + return RemoteBinding{}, fmt.Errorf("remote path is required") + case input.CredentialEntryID == "": + return RemoteBinding{}, fmt.Errorf("credential entry id is required") + case input.Password == "": + return RemoteBinding{}, fmt.Errorf("credential password is required") + } + + if input.RemoteProfileName == "" { + input.RemoteProfileName = input.RemoteProfileID + } + if input.CredentialTitle == "" { + input.CredentialTitle = "Remote Sign-In" + } + + model.UpsertRemoteProfile(vault.RemoteProfile{ + ID: input.RemoteProfileID, + Name: input.RemoteProfileName, + Backend: vault.RemoteBackendWebDAV, + BaseURL: input.BaseURL, + Path: input.RemotePath, + }) + model.UpsertEntry(vault.Entry{ + ID: input.CredentialEntryID, + Title: input.CredentialTitle, + Username: input.Username, + Password: input.Password, + URL: input.BaseURL, + Path: append([]string(nil), input.CredentialPath...), + }) + + return RemoteBinding{ + LocalVaultPath: input.LocalVaultPath, + RemoteProfileID: input.RemoteProfileID, + CredentialEntryID: input.CredentialEntryID, + SyncMode: normalizeSyncMode(input.SyncMode), + }, nil +} + +func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error { + if model == nil { + return fmt.Errorf("model is required") + } + if strings.TrimSpace(binding.RemoteProfileID) == "" { + return fmt.Errorf("remote profile id is required") + } + if strings.TrimSpace(binding.CredentialEntryID) == "" { + return fmt.Errorf("credential entry id is required") + } + + model.RemoveRemoteProfileByID(binding.RemoteProfileID) + model.RemoveEntryByID(binding.CredentialEntryID) + return nil +} + +func normalizeSyncMode(mode SyncMode) SyncMode { + switch mode { + case SyncModeAutomaticOnOpenSave: + return SyncModeAutomaticOnOpenSave + default: + return SyncModeManual + } +} diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go new file mode 100644 index 0000000..36009f2 --- /dev/null +++ b/appstate/remote_binding_test.go @@ -0,0 +1,250 @@ +package appstate + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "git.julianfamily.org/keepassgo/vault" +) + +func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "linuscaldwell-webdav", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }, + }, + RemoteProfiles: []vault.RemoteProfile{ + { + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }, + }, + } + + binding := RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "linuscaldwell-webdav", + SyncMode: SyncModeAutomaticOnOpenSave, + } + + resolved, err := binding.Resolve(model) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got) + } + if got := resolved.Profile.Path; got != "files/family/keepass.kdbx" { + t.Fatalf("resolved profile path = %q, want files/family/keepass.kdbx", got) + } + if got := resolved.Credentials.Username; got != "linuscaldwell" { + t.Fatalf("resolved credentials username = %q, want linuscaldwell", got) + } + if got := resolved.Credentials.Password; got != "bellagio-pass-1" { + t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got) + } +} + +func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"}, + }, + } + + _, err := (RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "missing-creds", + }).Resolve(model) + if !errors.Is(err, vault.ErrRemoteProfileNotFound) { + t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err) + } + + model.RemoteProfiles = []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }} + + _, err = (RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "missing-creds", + }).Resolve(model) + if !errors.Is(err, vault.ErrEntryNotFound) { + t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err) + } +} + +func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) { + t.Parallel() + + content, err := json.Marshal(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("json.Marshal(RemoteBinding) error = %v", err) + } + + text := string(content) + for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} { + if strings.Contains(text, disallowed) { + t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed) + } + } +} + +func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) { + t.Parallel() + + var model vault.Model + + binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemoteProfileName: "Family Vault", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("ConfigureRemoteBinding() error = %v", err) + } + + if len(model.RemoteProfiles) != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles)) + } + if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got) + } + + credentials, err := model.EntryByID("remote-creds-1") + if err != nil { + t.Fatalf("EntryByID(remote-creds-1) error = %v", err) + } + if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" { + t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials) + } + if credentials.URL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL) + } + + if binding.LocalVaultPath != "/tmp/family.kdbx" { + t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath) + } + if binding.RemoteProfileID != "family-webdav" || binding.CredentialEntryID != "remote-creds-1" { + t.Fatalf("binding = %#v, want only vault references", binding) + } +} + +func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input RemoteBindingInput + }{ + { + name: "missing_local_vault_path", + input: RemoteBindingInput{ + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "bellagio-pass-1", + }, + }, + { + name: "missing_remote_base_url", + input: RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "bellagio-pass-1", + }, + }, + { + name: "missing_credential_entry_id", + input: RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + Password: "bellagio-pass-1", + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var model vault.Model + if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil { + t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input) + } + }) + } +} + +func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + + err := RemoveRemoteBinding(&model, RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + if err != nil { + t.Fatalf("RemoveRemoteBinding() error = %v", err) + } + + if got := len(model.RemoteProfiles); got != 0 { + t.Fatalf("len(RemoteProfiles) = %d, want 0", got) + } + if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) { + t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err) + } +} diff --git a/appstate/state.go b/appstate/state.go index 45dcc1a..7dfdc7f 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -133,6 +133,52 @@ func (s *State) APITokens() ([]apitokens.Token, error) { return apitokens.Entries(model) } +func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) { + model, err := s.currentModel() + if err != nil { + return nil, err + } + profiles := slices.Clone(model.RemoteProfiles) + slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int { + switch { + case a.Name < b.Name: + return -1 + case a.Name > b.Name: + return 1 + case a.ID < b.ID: + return -1 + case a.ID > b.ID: + return 1 + default: + return 0 + } + }) + return profiles, nil +} + +func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) { + model, err := s.currentModel() + if err != nil { + return nil, err + } + entries := slices.Clone(model.Entries) + slices.SortFunc(entries, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + case a.ID < b.ID: + return -1 + case a.ID > b.ID: + return 1 + default: + return 0 + } + }) + return entries, nil +} + func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) { session, ok := s.Session.(MutableSession) if !ok { @@ -834,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas return nil } +func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error { + model, err := s.currentModel() + if err != nil { + return err + } + + resolved, err := binding.Resolve(model) + if err != nil { + return err + } + + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + return s.OpenRemoteVault(client, resolved.Profile.Path, key) +} + +func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) { + session, ok := s.Session.(MutableSession) + if !ok { + return RemoteBinding{}, fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return RemoteBinding{}, err + } + + binding, err := ConfigureRemoteBinding(&model, input) + if err != nil { + return RemoteBinding{}, err + } + + session.Replace(model) + s.Dirty = true + return binding, nil +} + +func (s *State) RemoveRemoteBinding(binding RemoteBinding) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := RemoveRemoteBinding(&model, binding); err != nil { + return err + } + + session.Replace(model) + s.Dirty = true + return nil +} + func (s *State) CreateGroup(name string) error { session, ok := s.Session.(MutableSession) if !ok { diff --git a/appstate/state_test.go b/appstate/state_test.go index 270d5f3..3bf8e1c 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -54,7 +54,7 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) { {ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -164,6 +164,71 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) { } } +func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + RemoteProfiles: []vault.RemoteProfile{ + { + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }, + { + ID: "archive-webdav", + Name: "Archive Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/archive.kdbx", + }, + }, + }, + }, + } + + got, err := state.RemoteProfiles() + if err != nil { + t.Fatalf("RemoteProfiles() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got)) + } + if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" { + t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got) + } +} + +func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}}, + {ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + {ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}}, + }, + }, + }, + } + + got, err := state.RemoteCredentialEntries() + if err != nil { + t.Fatalf("RemoteCredentialEntries() error = %v", err) + } + if len(got) != 3 { + t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got)) + } + if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" { + t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got) + } +} + func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { t.Parallel() @@ -173,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -187,7 +252,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { } if len(got) != 1 || got[0].Title != "Surveillance Console" { - t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got) + t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got) } } @@ -200,7 +265,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) { Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}}, }, }, }, @@ -350,7 +415,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) { model: vault.Model{ RecycleBin: []vault.Entry{ {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}}, - {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + {ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, }, }, @@ -419,7 +484,7 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { model: vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, }, }, @@ -432,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + if !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } } @@ -603,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, } @@ -619,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) { if err != nil { t.Fatalf("VisibleEntries() error = %v", err) } - if len(got) != 1 || got[0].Password != "token-1" { + if len(got) != 1 || got[0].Password != "bellagio-pass-1" { t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got) } @@ -964,6 +1029,185 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) { } } +func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + { + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }, + }, + RemoteProfiles: []vault.RemoteProfile{ + { + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }, + }, + }, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + Dirty: true, + } + + err := state.OpenBoundRemoteVault(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: SyncModeAutomaticOnOpenSave, + }, vault.MasterKey{Password: "correct horse battery staple"}) + if err != nil { + t.Fatalf("OpenBoundRemoteVault() error = %v", err) + } + + if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got) + } + if got := sess.remoteClient.Username; got != "linuscaldwell" { + t.Fatalf("remote client username = %q, want linuscaldwell", got) + } + if got := sess.remoteClient.Password; got != "bellagio-pass-1" { + t.Fatalf("remote client password = %q, want bellagio-pass-1", got) + } + if got := sess.remotePath; got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got) + } + if len(state.CurrentPath) != 0 { + t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath) + } + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) + } + if state.Dirty { + t.Fatal("Dirty = true, want false after bound remote open") + } +} + +func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{model: vault.Model{}} + state := State{Session: sess} + + err := state.OpenBoundRemoteVault(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "missing-profile", + CredentialEntryID: "remote-creds-1", + }, vault.MasterKey{Password: "correct horse battery staple"}) + if !errors.Is(err, vault.ErrRemoteProfileNotFound) { + t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err) + } +} + +func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{}} + state := State{Session: sess} + + binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + RemoteProfileName: "Family Vault", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("ConfigureRemoteBinding() error = %v", err) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after ConfigureRemoteBinding") + } + if got := binding.RemoteProfileID; got != "family-webdav" { + t.Fatalf("binding.RemoteProfileID = %q, want family-webdav", got) + } + if got := len(sess.model.RemoteProfiles); got != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", got) + } + credentials, err := sess.model.EntryByID("remote-creds-1") + if err != nil { + t.Fatalf("EntryByID(remote-creds-1) error = %v", err) + } + if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" { + t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials) + } +} + +func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) { + t.Parallel() + + state := State{Session: stubSession{model: vault.Model{}}} + _, err := state.ConfigureRemoteBinding(RemoteBindingInput{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + BaseURL: "https://dav.example.invalid/remote.php/dav", + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + Password: "bellagio-pass-1", + }) + if err == nil { + t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error") + } +} + +func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }} + state := State{Session: sess} + + err := state.RemoveRemoteBinding(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + if err != nil { + t.Fatalf("RemoveRemoteBinding() error = %v", err) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after RemoveRemoteBinding") + } + if got := len(sess.model.RemoteProfiles); got != 0 { + t.Fatalf("len(RemoteProfiles) = %d, want 0", got) + } + if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) { + t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err) + } +} + func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) { t.Parallel() @@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) { SelectedEntryID: "vault-console", } - state.NavigateToPath([]string{"Root", "Home Assistant"}) + state.NavigateToPath([]string{"Root", "Security Office"}) - if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) { - t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath) + if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) { + t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath) } if got := state.SelectedEntryID; got != "" { t.Fatalf("SelectedEntryID = %q, want empty", got) @@ -1185,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) { if err != nil { t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + if !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } } @@ -1208,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) { t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got) + if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after CreateGroup") @@ -1282,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T) t.Fatalf("ChildGroups() error = %v", err) } - if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + if !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got) } if !state.Dirty { t.Fatal("Dirty = false, want true after DeleteCurrentGroup") @@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) { SelectedEntryID: "bellagio", } - if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil { + if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil { t.Fatalf("MoveSelectedEntry() error = %v", err) } - state.NavigateToPath([]string{"Root", "Home Assistant"}) + state.NavigateToPath([]string{"Root", "Security Office"}) got, err := state.VisibleEntries() if err != nil { t.Fatalf("VisibleEntries() error = %v", err) @@ -1512,7 +1756,7 @@ func testVaultModel() vault.Model { return vault.Model{ Entries: []vault.Entry{ {ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}}, }, } } @@ -1553,15 +1797,17 @@ func (s *saveableStubSession) Save() error { } type lifecycleStubSession struct { - createCalls int - openPath string - saveAsPath string - remotePath string - changedKey vault.MasterKey + createCalls int + model vault.Model + openPath string + saveAsPath string + remoteClient webdav.Client + remotePath string + changedKey vault.MasterKey } func (s *lifecycleStubSession) Current() (vault.Model, error) { - return vault.Model{}, nil + return s.model, nil } func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error { @@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error { return nil } -func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error { +func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { + s.remoteClient = client s.remotePath = path return nil } diff --git a/docs/local-first-remote-sync-plan.md b/docs/local-first-remote-sync-plan.md new file mode 100644 index 0000000..2f2a260 --- /dev/null +++ b/docs/local-first-remote-sync-plan.md @@ -0,0 +1,148 @@ +# Local-First Remote Sync Plan + +## Goal + +Redesign remote-backed vault handling so every platform uses the same local-first model: + +- every vault is a local KDBX file first +- remote sync is an optional binding on top of that local file +- shared remote configuration lives in the vault +- user-specific remote credentials live in the vault +- app-local state stores only non-secret binding metadata + +Android adds only one platform-specific capability on top of that model: + +- share/import the initial local KDBX file between devices + +## Product Rules + +1. A remote-backed vault must always have a local cache KDBX file. +2. Opening a remote-backed vault should open the local KDBX first. +3. Shared remote configuration must be stored in the vault, not only in app state. +4. Remote credentials must not be stored in plaintext app-local state. +5. Remote credentials should be stored in the vault and resolved by a stable reference. +6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding. +7. Sync must support both manual and automatic modes. +8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle. + +## Target Data Model + +### In-Vault Shared Remote Profile + +Store a reusable remote profile in the vault with fields such as: + +- profile ID +- profile name +- backend type, initially WebDAV +- base URL +- remote object path +- optional notes or labels +- default sync policy, if shared defaults are desirable + +### In-Vault User Credential Binding + +Store user-specific credentials in the vault as normal vault data, referenced by: + +- remote profile ID +- credential entry UUID, or another stable internal reference +- optional username field override if needed + +The credential entry should contain the actual username/password or token. + +### Local App State + +Persist only non-secret binding state such as: + +- local vault path +- selected remote profile ID +- selected credential entry reference +- sync policy override +- last sync metadata +- conflict or recovery markers + +## Core Flows + +### Create Or Configure Remote Sync + +1. Open or create a local vault. +2. Create or edit a shared remote profile in that vault. +3. Create or select a credential entry in that vault. +4. Bind the local vault to the selected remote profile and credential reference. +5. Choose manual or automatic sync behavior. + +### Reopen Existing Remote-Backed Vault + +1. Open the local vault file from app state. +2. Resolve the selected remote profile from vault contents. +3. Resolve the credential entry from vault contents. +4. Offer or perform sync based on the binding policy. + +### Bootstrap A New Android Device + +1. Share the local KDBX file through Android Sharesheet. +2. Import and open that KDBX locally on the new device. +3. Select a remote profile stored in the vault. +4. Select or create that user’s credential entry in the vault. +5. Bind the local vault as the cache for the remote-backed setup. + +## Migration Requirements + +1. Migrate existing remote connections that save credentials in app state. +2. On first open after upgrade, move any recoverable remote credentials into the vault. +3. Replace saved plaintext credential state with a vault credential reference. +4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration. +5. Remove legacy local plaintext credential persistence after migration is complete. + +## Implementation Phases + +### Phase 1: Domain Model + +- define remote profile structures independent of Gio UI +- define credential reference structures independent of Gio UI +- define sync binding state independent of Gio UI +- add behavior tests for local-first remote-backed vaults + +### Phase 2: Vault Storage + +- persist remote profiles in the vault +- persist credential references in the vault +- resolve credentials from normal vault entries +- add behavior tests for read/write and lookup semantics + +### Phase 3: State And Open Flow + +- shrink app state to non-secret metadata only +- update open flows to always prefer the local cache vault +- update reopen behavior on all platforms to use the same model +- add migration coverage for old remote state + +### Phase 4: Sync Binding + +- bind a local vault to a selected remote profile +- support manual sync +- support automatic sync on open/save +- define conflict and remote-failure handling for the local cache model + +### Phase 5: Android Bootstrap + +- add Android Sharesheet export of the current local KDBX +- add Android import flow for a shared KDBX +- keep the remote pivot flow consistent with desktop after local open + +## Open Questions + +1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure? +2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier? +3. Should automatic sync run only on open/save initially, or also on app resume? +4. How should multiple remote profiles per vault be presented in the UI? +5. What should happen when the credential entry reference no longer resolves? + +## Recommended First Slice + +Implement the shared domain model and tests first: + +- model a local vault plus optional remote binding +- define in-vault remote profile and credential reference semantics +- add tests proving app state no longer needs plaintext remote credentials + +That slice standardizes the architecture before any Android-specific sharing work begins. diff --git a/main.go b/main.go index 6c47aa0..3a6b559 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" @@ -135,12 +137,14 @@ type attachmentItem struct { } type statePaths struct { - DefaultSaveAsPath string - RecentVaultsPath string - RecentRemotesPath string - SettingsPath string - UIPreferencesPath string - AutofillCachePath string + DefaultSaveAsPath string + RecentVaultsPath string + RecentRemotesPath string + SettingsPath string + UIPreferencesPath string + AutofillCachePath string + PendingSharedVaultPath string + PendingSharedVaultNamePath string } type recentVaultRecord struct { @@ -150,12 +154,17 @@ type recentVaultRecord struct { } type recentRemoteRecord struct { - BaseURL string `json:"baseUrl"` - Path string `json:"path"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - LastGroup []string `json:"lastGroup,omitempty"` - UsedAt string `json:"usedAt,omitempty"` + BaseURL string `json:"baseUrl"` + Path string `json:"path"` + LocalVaultPath string `json:"localVaultPath,omitempty"` + RemoteProfileID string `json:"remoteProfileId,omitempty"` + CredentialEntryID string `json:"credentialEntryId,omitempty"` + SyncMode string `json:"syncMode,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + LastGroup []string `json:"lastGroup,omitempty"` + UsedAt string `json:"usedAt,omitempty"` + NeedsMigration bool `json:"-"` } type uiPreferences struct { @@ -208,273 +217,297 @@ const ( syncDirectionPush syncDirection = "push" ) +type syncDialogPurpose string + +const ( + syncDialogPurposeAdvanced syncDialogPurpose = "advanced" + syncDialogPurposeRemoteSetup syncDialogPurpose = "remote-setup" +) + type ui struct { - mode string - theme *material.Theme - fileExplorer *explorer.Explorer - logoHorizontal paint.ImageOp - splashSquare paint.ImageOp - search widget.Editor - vaultPath widget.Editor - saveAsPath widget.Editor - remoteBaseURL widget.Editor - remotePath widget.Editor - remoteUsername widget.Editor - remotePassword widget.Editor - masterPassword widget.Editor - keyFilePath widget.Editor - apiTokenName widget.Editor - apiTokenClientName widget.Editor - apiTokenExpiresAt widget.Editor - apiPolicyOperation widget.Editor - apiPolicyPath widget.Editor - apiPolicyEntryID widget.Editor - securityCipher widget.Editor - securityKDF widget.Editor - entryID widget.Editor - entryTitle widget.Editor - entryUsername widget.Editor - entryPassword widget.Editor - entryURL widget.Editor - entryNotes widget.Editor - entryTags widget.Editor - entryPath widget.Editor - entryFields widget.Editor - customFieldKeys []widget.Editor - customFieldValues []widget.Editor - historyIndex widget.Editor - groupName widget.Editor - groupParentPath widget.Editor - passwordProfile widget.Editor - attachmentName widget.Editor - attachmentPath widget.Editor - exportAttachmentPath widget.Editor - autofillBrowserAllowlist widget.Editor - autofillAppAllowlist widget.Editor - autofillPackageRules widget.Editor - list widget.List - groupList widget.List - detailList widget.List - apiPolicyList widget.List - lifecycleList widget.List - phonePanelList widget.List - securityDialogList widget.List - remotePrefsDialogList widget.List - recentVaultListState widget.List - recentRemoteListState widget.List - copyUser widget.Clickable - copyPass widget.Clickable - copyURL widget.Clickable - lockVault widget.Clickable - unlockVault widget.Clickable - createVault widget.Clickable - openVault widget.Clickable - saveVault widget.Clickable - saveAsVault widget.Clickable - openRemote widget.Clickable - changeMasterKey widget.Clickable - synchronizeVault widget.Clickable - toggleSyncMenu widget.Clickable - toggleMainMenu widget.Clickable - openAdvancedSync widget.Clickable - openSecuritySettings widget.Clickable - openRemotePrefsHelp widget.Clickable - closeAdvancedSync widget.Clickable - closeSecuritySettings widget.Clickable - closeRemotePrefsHelp widget.Clickable - runAdvancedSync widget.Clickable - saveSecuritySettings widget.Clickable - settingsDensityDense widget.Clickable - settingsDensityComfortable widget.Clickable - settingsContrastStandard widget.Clickable - settingsContrastHigh widget.Clickable - settingsReducedMotionOff widget.Clickable - settingsReducedMotionOn widget.Clickable - settingsKeyboardFocusStandard widget.Clickable - settingsKeyboardFocusProminent widget.Clickable - showSettingsSyncLocal widget.Clickable - showSettingsSyncRemote widget.Clickable - showSettingsSyncPull widget.Clickable - showSettingsSyncPush widget.Clickable - editEntry widget.Clickable - cancelEdit widget.Clickable - pickVaultPath widget.Clickable - pickKeyFile widget.Clickable - pickSyncLocalPath widget.Clickable - clearVaultSelection widget.Clickable - clearRemoteSelection widget.Clickable - dismissBanner widget.Clickable - addEntry widget.Clickable - saveEntry widget.Clickable - duplicateEntry widget.Clickable - deleteEntry widget.Clickable - restoreEntry widget.Clickable - saveTemplate widget.Clickable - deleteTemplate widget.Clickable - instantiateTemplate widget.Clickable - addAttachment widget.Clickable - replaceAttachment widget.Clickable - removeAttachment widget.Clickable - exportAttachment widget.Clickable - restoreHistory widget.Clickable - generatePassword widget.Clickable - goToRootGroup widget.Clickable - goToParentGroup widget.Clickable - createGroup widget.Clickable - moveGroup widget.Clickable - renameGroup widget.Clickable - deleteGroup widget.Clickable - confirmDeleteGroup widget.Clickable - cancelDeleteGroup widget.Clickable - addCustomField widget.Clickable - toggleGroupControls widget.Clickable - toggleLifecycleAdvanced widget.Clickable - toggleHistory widget.Clickable - togglePasswordInline widget.Clickable - toggleSyncPassword widget.Clickable - setStatusBannerShort widget.Clickable - setStatusBannerStandard widget.Clickable - setStatusBannerLong widget.Clickable - showAllAutofillNotices widget.Clickable - showApprovalAutofillOnly widget.Clickable - hideAutofillNotices widget.Clickable - showEntries widget.Clickable - showTemplates widget.Clickable - showRecycle widget.Clickable - showAPITokens widget.Clickable - showAPIAudit widget.Clickable - showAbout widget.Clickable - showLocalLifecycle widget.Clickable - showRemoteLifecycle widget.Clickable - showSyncLocal widget.Clickable - showSyncRemote widget.Clickable - showSyncPull widget.Clickable - showSyncPush widget.Clickable - showAutofillApprovalAsk widget.Clickable - showAutofillApprovalAllow widget.Clickable - showAutofillApprovalBlock widget.Clickable - allowApproval widget.Clickable - denyApproval widget.Clickable - cancelApproval widget.Clickable - cancelLifecycleProgress widget.Clickable - retryLifecycleOpen widget.Clickable - approvalPermanent widget.Bool - rememberRemoteAuth widget.Bool - apiPolicyAllow widget.Bool - apiPolicyGroupScopeW widget.Bool - apiTokenDisabled widget.Bool - settingsGroupControls widget.Bool - settingsLifecycleAdvanced widget.Bool - settingsHistory widget.Bool - settingsDenseLayout widget.Bool - entryClicks []widget.Clickable - apiTokenClicks []widget.Clickable - apiPolicyRemoves []widget.Clickable - apiAuditClicks []widget.Clickable - apiAuditTokenFilters []widget.Clickable - apiAuditDecisionFilters []widget.Clickable - apiAuditOperationFilters []widget.Clickable - clearAPIAuditFilters widget.Clickable - historyClicks []widget.Clickable - attachmentClicks []widget.Clickable - breadcrumbs []widget.Clickable - groupClicks []widget.Clickable - recentVaultClicks []widget.Clickable - recentRemoteClicks []widget.Clickable - removeCustomFields []widget.Clickable - state appstate.State - visible []entry - currentPath []string - syncedPath []string - selectedHistoryIndex int - showPassword bool - generatedPasswordDraft bool - togglePassword widget.Clickable - copyAPITokenSecret widget.Clickable - issueAPIToken widget.Clickable - saveAPIToken widget.Clickable - rotateAPIToken widget.Clickable - disableAPIToken widget.Clickable - revokeAPIToken widget.Clickable - deleteAPIToken widget.Clickable - useCurrentGroupForPolicy widget.Clickable - useSelectedEntryForPolicy widget.Clickable - clearAPIPolicyTarget widget.Clickable - addAPIPolicyRule widget.Clickable - phoneSplit widget.Float - splitDrag gesture.Drag - splitBase float32 - splitStartY float32 - phoneSpan int - phoneGroupBrowserExpanded bool - eyeIcon *widget.Icon - eyeOffIcon *widget.Icon - copyIcon *widget.Icon - expandMoreIcon *widget.Icon - expandLessIcon *widget.Icon - chevronRightIcon *widget.Icon - chevronDownIcon *widget.Icon - settingsIcon *widget.Icon - menuIcon *widget.Icon - clipboardWriter clipboard.Writer - loadingMessage string - loadingActionLabel string - lifecycleMode string - syncSourceMode syncSourceMode - syncDirection syncDirection - syncLocalImportName string - syncLocalImportContent []byte - syncLocalPath widget.Editor - syncRemoteBaseURL widget.Editor - syncRemotePath widget.Editor - syncRemoteUsername widget.Editor - syncRemotePassword widget.Editor - syncDialogOpen bool - syncMenuOpen bool - mainMenuOpen bool - selectedRemoteConnection bool - securityDialogOpen bool - remotePrefsDialogOpen bool - showSyncPassword bool - keyboardFocus focusID - defaultSaveAsPath string - recentVaultsPath string - settingsPath string - uiPreferencesPath string - recentRemotesPath string - autofillCachePath string - editingEntry bool - syncDefaultSourceMode syncSourceMode - syncDefaultDirection syncDirection - groupControlsHidden bool - lifecycleAdvancedHidden bool - historyHidden bool - denseLayout bool - statusBannerTTL time.Duration - autofillNoticePreference autofillNoticeMode - autofillFirstFillApprovalMode autofillFirstFillApprovalMode - accessibilityPrefs accessibilityPreferences - settingsDraft settingsDraft - recentVaults []string - recentRemotes []recentRemoteRecord - recentVaultGroups map[string][]string - recentVaultUsedAt map[string]time.Time - entriesState entriesSectionState - deleteGroupPath []string - apiPolicyGroupScope bool - apiTokenSecret string - selectedAuditIndex int - statusExpiresAt time.Time - now func() time.Time - apiHost *api.Host - auditLog *apiaudit.Log - grpcAddress string - backgroundResults chan backgroundActionResult - backgroundActionSerial int - activeBackgroundAction int - lastLifecycleAction string - requestMasterPassFocus bool - invalidate func() + mode string + theme *material.Theme + fileExplorer *explorer.Explorer + logoHorizontal paint.ImageOp + splashSquare paint.ImageOp + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + apiTokenName widget.Editor + apiTokenClientName widget.Editor + apiTokenExpiresAt widget.Editor + apiPolicyOperation widget.Editor + apiPolicyPath widget.Editor + apiPolicyEntryID widget.Editor + securityCipher widget.Editor + securityKDF widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + customFieldKeys []widget.Editor + customFieldValues []widget.Editor + historyIndex widget.Editor + groupName widget.Editor + groupParentPath widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + autofillBrowserAllowlist widget.Editor + autofillAppAllowlist widget.Editor + autofillPackageRules widget.Editor + list widget.List + groupList widget.List + detailList widget.List + apiPolicyList widget.List + lifecycleList widget.List + phonePanelList widget.List + securityDialogList widget.List + remotePrefsDialogList widget.List + recentVaultListState widget.List + recentRemoteListState widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + changeMasterKey widget.Clickable + synchronizeVault widget.Clickable + toggleSyncMenu widget.Clickable + toggleMainMenu widget.Clickable + openAdvancedSync widget.Clickable + useSavedAdvancedSyncRemote widget.Clickable + openSelectedVaultRemote widget.Clickable + saveCurrentRemoteBinding widget.Clickable + removeSelectedRemoteBinding widget.Clickable + openSecuritySettings widget.Clickable + openRemotePrefsHelp widget.Clickable + closeAdvancedSync widget.Clickable + closeSecuritySettings widget.Clickable + closeRemotePrefsHelp widget.Clickable + runAdvancedSync widget.Clickable + saveSecuritySettings widget.Clickable + settingsDensityDense widget.Clickable + settingsDensityComfortable widget.Clickable + settingsContrastStandard widget.Clickable + settingsContrastHigh widget.Clickable + settingsReducedMotionOff widget.Clickable + settingsReducedMotionOn widget.Clickable + settingsKeyboardFocusStandard widget.Clickable + settingsKeyboardFocusProminent widget.Clickable + showSettingsSyncLocal widget.Clickable + showSettingsSyncRemote widget.Clickable + showSettingsSyncPull widget.Clickable + showSettingsSyncPush widget.Clickable + editEntry widget.Clickable + cancelEdit widget.Clickable + pickVaultPath widget.Clickable + importSharedVault widget.Clickable + shareCurrentVault widget.Clickable + pickKeyFile widget.Clickable + pickSyncLocalPath widget.Clickable + clearVaultSelection widget.Clickable + clearRemoteSelection widget.Clickable + dismissBanner widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + replaceAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + goToRootGroup widget.Clickable + goToParentGroup widget.Clickable + createGroup widget.Clickable + moveGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + confirmDeleteGroup widget.Clickable + cancelDeleteGroup widget.Clickable + addCustomField widget.Clickable + toggleGroupControls widget.Clickable + toggleLifecycleAdvanced widget.Clickable + toggleHistory widget.Clickable + togglePasswordInline widget.Clickable + toggleSyncPassword widget.Clickable + setStatusBannerShort widget.Clickable + setStatusBannerStandard widget.Clickable + setStatusBannerLong widget.Clickable + showAllAutofillNotices widget.Clickable + showApprovalAutofillOnly widget.Clickable + hideAutofillNotices widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + showAPITokens widget.Clickable + showAPIAudit widget.Clickable + showAbout widget.Clickable + showLocalLifecycle widget.Clickable + showRemoteLifecycle widget.Clickable + showSyncLocal widget.Clickable + showSyncRemote widget.Clickable + showSyncPull widget.Clickable + showSyncPush widget.Clickable + showAutofillApprovalAsk widget.Clickable + showAutofillApprovalAllow widget.Clickable + showAutofillApprovalBlock widget.Clickable + allowApproval widget.Clickable + denyApproval widget.Clickable + cancelApproval widget.Clickable + cancelLifecycleProgress widget.Clickable + retryLifecycleOpen widget.Clickable + approvalPermanent widget.Bool + syncSetupAutomatic widget.Bool + apiPolicyAllow widget.Bool + apiPolicyGroupScopeW widget.Bool + apiTokenDisabled widget.Bool + settingsGroupControls widget.Bool + settingsLifecycleAdvanced widget.Bool + settingsHistory widget.Bool + settingsDenseLayout widget.Bool + entryClicks []widget.Clickable + apiTokenClicks []widget.Clickable + apiPolicyRemoves []widget.Clickable + apiAuditClicks []widget.Clickable + apiAuditTokenFilters []widget.Clickable + apiAuditDecisionFilters []widget.Clickable + apiAuditOperationFilters []widget.Clickable + clearAPIAuditFilters widget.Clickable + historyClicks []widget.Clickable + attachmentClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + recentVaultClicks []widget.Clickable + recentRemoteClicks []widget.Clickable + vaultRemoteProfileClicks []widget.Clickable + vaultRemoteCredentialClicks []widget.Clickable + syncRemoteCredentialClicks []widget.Clickable + removeCustomFields []widget.Clickable + state appstate.State + visible []entry + currentPath []string + syncedPath []string + selectedHistoryIndex int + showPassword bool + generatedPasswordDraft bool + togglePassword widget.Clickable + copyAPITokenSecret widget.Clickable + issueAPIToken widget.Clickable + saveAPIToken widget.Clickable + rotateAPIToken widget.Clickable + disableAPIToken widget.Clickable + revokeAPIToken widget.Clickable + deleteAPIToken widget.Clickable + useCurrentGroupForPolicy widget.Clickable + useSelectedEntryForPolicy widget.Clickable + clearAPIPolicyTarget widget.Clickable + addAPIPolicyRule widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + phoneGroupBrowserExpanded bool + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + expandMoreIcon *widget.Icon + expandLessIcon *widget.Icon + chevronRightIcon *widget.Icon + chevronDownIcon *widget.Icon + settingsIcon *widget.Icon + menuIcon *widget.Icon + clipboardWriter clipboard.Writer + vaultSharer vaultSharer + loadingMessage string + loadingActionLabel string + lifecycleMode string + syncSourceMode syncSourceMode + syncDirection syncDirection + syncLocalImportName string + syncLocalImportContent []byte + syncLocalPath widget.Editor + syncRemoteBaseURL widget.Editor + syncRemotePath widget.Editor + syncRemoteUsername widget.Editor + syncRemotePassword widget.Editor + selectedSyncRemoteCredentialEntryID string + syncDialogPurpose syncDialogPurpose + syncDialogOpen bool + syncMenuOpen bool + mainMenuOpen bool + selectedRemoteConnection bool + selectedVaultRemoteProfileID string + selectedVaultRemoteCredentialEntryID string + selectedVaultRemoteSyncMode appstate.SyncMode + securityDialogOpen bool + remotePrefsDialogOpen bool + showSyncPassword bool + keyboardFocus focusID + defaultSaveAsPath string + recentVaultsPath string + settingsPath string + uiPreferencesPath string + recentRemotesPath string + autofillCachePath string + pendingSharedVaultPath string + pendingSharedVaultNamePath string + editingEntry bool + syncDefaultSourceMode syncSourceMode + syncDefaultDirection syncDirection + groupControlsHidden bool + lifecycleAdvancedHidden bool + historyHidden bool + denseLayout bool + statusBannerTTL time.Duration + autofillNoticePreference autofillNoticeMode + autofillFirstFillApprovalMode autofillFirstFillApprovalMode + accessibilityPrefs accessibilityPreferences + settingsDraft settingsDraft + recentVaults []string + recentRemotes []recentRemoteRecord + recentVaultGroups map[string][]string + recentVaultUsedAt map[string]time.Time + entriesState entriesSectionState + deleteGroupPath []string + apiPolicyGroupScope bool + apiTokenSecret string + selectedAuditIndex int + statusExpiresAt time.Time + now func() time.Time + apiHost *api.Host + auditLog *apiaudit.Log + grpcAddress string + backgroundResults chan backgroundActionResult + backgroundActionSerial int + activeBackgroundAction int + lastLifecycleAction string + requestMasterPassFocus bool + invalidate func() } type backgroundActionResult struct { @@ -484,6 +517,10 @@ type backgroundActionResult struct { id int } +type vaultSharer interface { + ShareVault(path, title string) error +} + var ( bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255} panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255} @@ -606,6 +643,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, autofillCachePath: paths.AutofillCachePath, + pendingSharedVaultPath: paths.PendingSharedVaultPath, + pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, lifecycleAdvancedHidden: true, @@ -620,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) syncDefaultDirection: syncDirectionPull, apiPolicyGroupScope: true, autofillNoticePreference: autofillNoticeAll, + vaultSharer: newPlatformVaultSharer(runtime.GOOS), backgroundResults: make(chan backgroundActionResult, 8), phoneGroupBrowserExpanded: true, } @@ -646,6 +686,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.setCustomFieldRows(nil) u.loadRecentVaults() u.loadRecentRemotes() + if u.hasLegacyRecentRemoteCredentialMigration() { + u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.") + } + u.consumePendingSharedVaultImport() u.restoreStartupLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.loadUIPreferences() @@ -716,12 +760,14 @@ func defaultStatePaths(stateDir string) statePaths { baseDir = filepath.Join(configDir, "keepassgo") } return statePaths{ - DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), - RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), - RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), - SettingsPath: filepath.Join(baseDir, "settings.json"), - UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), - AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), + DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), + SettingsPath: filepath.Join(baseDir, "settings.json"), + UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), + AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), + PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"), + PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"), } } @@ -750,6 +796,14 @@ 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 supportsVaultShare(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 { @@ -971,6 +1025,13 @@ func (u *ui) createVaultAction() error { return err } if u.lifecycleMode == "local" { + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil { return err } @@ -1002,6 +1063,10 @@ func (u *ui) openVaultAction() error { u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) + u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + u.showStatusMessage("Remote sync on open failed: " + err.Error()) + } u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() @@ -1039,6 +1104,10 @@ func (u *ui) startOpenVaultAction() { u.resetPasswordPeek() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.restoreRecentVaultGroup(path) + u.syncSavedRemoteBindingSelection() + if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil { + u.showStatusMessage("Remote sync on open failed: " + err.Error()) + } u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() @@ -1051,6 +1120,9 @@ func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err } + if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil { + return err + } u.filter() return nil } @@ -1072,6 +1144,22 @@ func (u *ui) openRemoteAction() error { if err != nil { return err } + if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil { + return err + } else if ok { + if err := u.state.OpenBoundRemoteVault(binding, key); err != nil { + return err + } + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + } client := webdav.Client{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()), @@ -1080,12 +1168,12 @@ func (u *ui) openRemoteAction() error { if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()), - strings.TrimSpace(u.remoteUsername.Text()), - u.remotePassword.Text(), - u.rememberRemoteAuth.Value, ) u.resetPasswordPeek() u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) @@ -1116,18 +1204,67 @@ func (u *ui) startOpenRemoteAction() { remotePath := strings.TrimSpace(u.remotePath.Text()) u.lastLifecycleAction = "open remote vault" u.runBackgroundAction("open remote vault", func() (func() error, error) { + binding, bindingOK := u.selectedVaultRemoteBinding() + if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" { + preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key) + if err != nil { + return nil, err + } + resolved, err := binding.Resolve(preparedLocal.Model) + if err != nil { + return nil, err + } + preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + }, resolved.Profile.Path, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedLocalOpen(preparedLocal) + u.vaultPath.SetText(binding.LocalVaultPath) + u.noteRecentVault(binding.LocalVaultPath) + u.restoreRecentVaultGroup(binding.LocalVaultPath) + manager.ApplyPreparedRemoteOpen(preparedRemote) + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + } + if u.hasOpenVault() { + if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil { + return nil, err + } else if ok { + client = webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + remotePath = resolved.Profile.Path + u.remoteBaseURL.SetText(resolved.Profile.BaseURL) + u.remotePath.SetText(resolved.Profile.Path) + } + } prepared, err := session.PrepareRemoteOpen(client, remotePath, key) if err != nil { return nil, err } return func() error { manager.ApplyPreparedRemoteOpen(prepared) + if err := u.materializeCurrentRemoteCache(); err != nil { + return err + } u.noteRecentRemote( strings.TrimSpace(u.remoteBaseURL.Text()), remotePath, - strings.TrimSpace(u.remoteUsername.Text()), - u.remotePassword.Text(), - u.rememberRemoteAuth.Value, ) u.resetPasswordPeek() u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath) @@ -1235,11 +1372,32 @@ func (u *ui) openAdvancedSyncDialog() { u.syncDialogOpen = true u.syncMenuOpen = false u.showSyncPassword = false + u.syncDialogPurpose = syncDialogPurposeAdvanced u.syncSourceMode = u.syncDefaultSourceMode u.syncDirection = u.syncDefaultDirection if strings.TrimSpace(u.syncLocalPath.Text()) == "" { u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) } + u.syncSavedRemoteBindingSelection() + u.prefillAdvancedSyncRemoteFromSavedBinding() +} + +func (u *ui) openRemoteSyncSetupDialog() { + u.syncDialogOpen = true + u.syncMenuOpen = false + u.showSyncPassword = false + u.syncDialogPurpose = syncDialogPurposeRemoteSetup + u.syncSourceMode = syncSourceRemote + u.syncDirection = syncDirectionPush + u.syncSetupAutomatic.Value = true + if strings.TrimSpace(u.syncLocalPath.Text()) == "" { + u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text())) + } + u.syncSavedRemoteBindingSelection() + u.prefillAdvancedSyncRemoteFromSavedBinding() + if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual { + u.syncSetupAutomatic.Value = false + } } func (u *ui) clearSyncLocalImport() { @@ -1345,17 +1503,48 @@ 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: + baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.syncRemotePath.Text()) client := webdav.Client{ - BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), + BaseURL: baseURL, Username: strings.TrimSpace(u.syncRemoteUsername.Text()), Password: u.syncRemotePassword.Text(), } - if err := u.state.SynchronizeToRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { + if err := u.state.SynchronizeToRemote(client, remotePath); err != nil { return err } + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil { + return err + } + u.showStatusMessage("Remote sync is set up for this vault.") + } default: path := strings.TrimSpace(u.syncLocalPath.Text()) if path == "" { @@ -1371,6 +1560,48 @@ func (u *ui) advancedSyncToAction() error { return nil } +func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error { + baseURL = strings.TrimSpace(baseURL) + remotePath = strings.TrimSpace(remotePath) + if baseURL == "" || remotePath == "" { + return fmt.Errorf("remote setup requires base URL and path") + } + input := appstate.RemoteBindingInput{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), + RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), + BaseURL: baseURL, + RemotePath: remotePath, + CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())), + CredentialTitle: "WebDAV Sign-In" + func() string { + if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" { + return " · " + user + } + return "" + }(), + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + CredentialPath: append([]string(nil), u.currentPath...), + SyncMode: u.syncSetupMode(), + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + if err := u.state.Save(); err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode + u.remoteBaseURL.SetText(baseURL) + u.remotePath.SetText(remotePath) + u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text())) + u.remotePassword.SetText(u.syncRemotePassword.Text()) + u.noteRecentRemote(baseURL, remotePath) + return nil +} + func (u *ui) saveAsTargetPath() string { path := strings.TrimSpace(u.saveAsPath.Text()) if path != "" { @@ -1379,6 +1610,86 @@ 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) consumePendingSharedVaultImport() { + path := strings.TrimSpace(u.pendingSharedVaultPath) + if path == "" { + return + } + content, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + } + return + } + name := "shared-vault.kdbx" + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + if rawName, err := os.ReadFile(namePath); err == nil { + if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" { + name = trimmed + } + } + } + if err := u.importSharedVaultBytesAction(name, content); err != nil { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + return + } + _ = os.Remove(path) + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + _ = os.Remove(namePath) + } +} + +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) currentShareableVaultPath() string { + return strings.TrimSpace(u.vaultPath.Text()) +} + +func (u *ui) shareCurrentVaultAction() error { + if u.vaultSharer == nil { + return fmt.Errorf("vault sharing is not available on this platform") + } + path := u.currentShareableVaultPath() + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.Save(); err != nil { + return err + } + return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path)) +} + func (u *ui) noteRecentVault(path string) { path = strings.TrimSpace(path) if path == "" { @@ -1489,9 +1800,20 @@ func (u *ui) loadRecentRemotes() { for _, record := range records { record.BaseURL = strings.TrimSpace(record.BaseURL) record.Path = strings.TrimSpace(record.Path) + record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath) + record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + record.SyncMode = strings.TrimSpace(record.SyncMode) + record.Username = strings.TrimSpace(record.Username) + record.Password = strings.TrimSpace(record.Password) if record.BaseURL == "" || record.Path == "" { continue } + if record.Username != "" || record.Password != "" { + record.NeedsMigration = true + record.Username = "" + record.Password = "" + } key := record.BaseURL + "|" + record.Path if seen[key] { continue @@ -1509,6 +1831,15 @@ func (u *ui) loadRecentRemotes() { } } +func (u *ui) hasLegacyRecentRemoteCredentialMigration() bool { + for _, record := range u.recentRemotes { + if record.NeedsMigration { + return true + } + } + return false +} + func (u *ui) saveRecentVaults() { if strings.TrimSpace(u.recentVaultsPath) == "" { return @@ -1709,7 +2040,7 @@ func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) { u.saveUIPreferences() } -func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { +func (u *ui) noteRecentRemote(baseURL, path string) { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) if baseURL == "" || path == "" { @@ -1721,13 +2052,15 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember LastGroup: append([]string(nil), u.currentPath...), UsedAt: u.now().Format(time.RFC3339Nano), } + if binding, ok := u.selectedVaultRemoteBinding(); ok { + record.LocalVaultPath = binding.LocalVaultPath + record.RemoteProfileID = binding.RemoteProfileID + record.CredentialEntryID = binding.CredentialEntryID + record.SyncMode = string(binding.SyncMode) + } if len(record.LastGroup) == 0 { record.LastGroup = u.recentRemoteGroup(baseURL, path) } - if rememberAuth { - record.Username = strings.TrimSpace(username) - record.Password = password - } next := []recentRemoteRecord{record} for _, existing := range u.recentRemotes { if existing.BaseURL == baseURL && existing.Path == path { @@ -1761,12 +2094,15 @@ func (u *ui) restoreStartupLifecycleTarget() { remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote() switch { - case hasRemote && (localPath == "" || remoteUsedAt.After(localUsedAt)): - u.lifecycleMode = "remote" - u.applyRecentRemoteRecord(remoteRecord) + case hasRemote && strings.TrimSpace(remoteRecord.LocalVaultPath) != "" && (localPath == "" || remoteUsedAt.After(localUsedAt)): + u.lifecycleMode = "local" + u.vaultPath.SetText(strings.TrimSpace(remoteRecord.LocalVaultPath)) case localPath != "": u.lifecycleMode = "local" u.vaultPath.SetText(localPath) + case hasRemote: + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(remoteRecord) } } @@ -1830,7 +2166,6 @@ func (u *ui) switchToLifecycleSelection(mode string) { u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") - u.rememberRemoteAuth.Value = false u.selectedRemoteConnection = false default: u.vaultPath.SetText("") @@ -1838,7 +2173,6 @@ func (u *ui) switchToLifecycleSelection(mode string) { u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") - u.rememberRemoteAuth.Value = false u.selectedRemoteConnection = false } u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() @@ -1861,10 +2195,8 @@ func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { func (u *ui) currentRemoteRecord() recentRemoteRecord { return recentRemoteRecord{ - BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - Path: strings.TrimSpace(u.remotePath.Text()), - Username: strings.TrimSpace(u.remoteUsername.Text()), - Password: u.remotePassword.Text(), + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Path: strings.TrimSpace(u.remotePath.Text()), } } @@ -1884,33 +2216,651 @@ func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) { func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) - u.remoteUsername.SetText(record.Username) - u.remotePassword.SetText(record.Password) + u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath)) + u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) u.remotePassword.Mask = '•' - u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" u.selectedRemoteConnection = true + if record.NeedsMigration && strings.TrimSpace(record.RemoteProfileID) == "" && strings.TrimSpace(record.CredentialEntryID) == "" { + u.showStatusMessage("This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it.") + } } func (u *ui) remotePreferencesCurrentSummary() string { - selected, hasSelected := u.selectedRecentRemoteRecord() switch { - case !u.rememberRemoteAuth.Value: - return "Current choice: KeePassGO will remember only the WebDAV location for this connection." - case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""): - return "Current choice: a successful open will update the saved sign-in for this connection on this device." case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": - return "Current choice: a successful open will save the entered sign-in for this connection on this device." + return "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile." default: - return "Current choice: sign-in retention is enabled, but no username or password is entered yet." + return "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." } } func (u *ui) remotePreferencesAlwaysSavedSummary() string { - return "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." + return "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection." } func (u *ui) remotePreferencesRetentionSummary() string { - return "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." + return "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls." +} + +func (u *ui) remotePreferencesPersistenceSummary() string { + return "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself." +} + +func (u *ui) availableRemoteProfiles() []vault.RemoteProfile { + profiles, err := u.state.RemoteProfiles() + if err != nil { + return nil + } + return profiles +} + +func (u *ui) availableRemoteCredentialEntries() []vault.Entry { + entries, err := u.state.RemoteCredentialEntries() + if err != nil { + return nil + } + return entries +} + +func normalizeRemoteCredentialURL(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.TrimRight(raw, "/") + return raw +} + +func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { + if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote { + return nil + } + baseURL := normalizeRemoteCredentialURL(u.syncRemoteBaseURL.Text()) + if baseURL == "" { + return nil + } + remotePath := strings.TrimSpace(u.syncRemotePath.Text()) + entries := u.availableRemoteCredentialEntries() + byID := make(map[string]vault.Entry, len(entries)) + for _, entry := range entries { + byID[entry.ID] = entry + } + matches := make([]vault.Entry, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) + appendMatch := func(entry vault.Entry) { + if strings.TrimSpace(entry.ID) == "" { + return + } + if _, ok := seen[entry.ID]; ok { + return + } + seen[entry.ID] = struct{}{} + matches = append(matches, entry) + } + for _, entry := range entries { + if normalizeRemoteCredentialURL(entry.URL) != baseURL { + continue + } + appendMatch(entry) + } + profilesByID := make(map[string]vault.RemoteProfile) + for _, profile := range u.availableRemoteProfiles() { + profilesByID[profile.ID] = profile + } + localVaultPath := strings.TrimSpace(u.vaultPath.Text()) + for _, record := range u.recentRemotes { + if localVaultPath != "" && strings.TrimSpace(record.LocalVaultPath) != localVaultPath { + continue + } + profile, ok := profilesByID[strings.TrimSpace(record.RemoteProfileID)] + if !ok { + continue + } + if normalizeRemoteCredentialURL(profile.BaseURL) != baseURL { + continue + } + if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath { + continue + } + entry, ok := byID[strings.TrimSpace(record.CredentialEntryID)] + if !ok { + continue + } + appendMatch(entry) + } + return matches +} + +func (u *ui) applyAdvancedSyncRemoteCredentialEntry(entry vault.Entry) { + u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(entry.ID) + u.syncRemoteUsername.SetText(strings.TrimSpace(entry.Username)) + u.syncRemotePassword.SetText(entry.Password) +} + +func (u *ui) savedAdvancedSyncRemoteBinding() (appstate.ResolvedRemoteBinding, bool) { + if !u.hasOpenVault() { + return appstate.ResolvedRemoteBinding{}, false + } + _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding() + if err != nil || !ok { + return appstate.ResolvedRemoteBinding{}, false + } + return resolved, true +} + +func (u *ui) prefillAdvancedSyncRemoteFromSavedBinding() { + resolved, ok := u.savedAdvancedSyncRemoteBinding() + if !ok { + return + } + u.syncRemoteBaseURL.SetText(resolved.Profile.BaseURL) + u.syncRemotePath.SetText(resolved.Profile.Path) + u.applyAdvancedSyncRemoteCredentialEntry(resolved.Credentials) +} + +func (u *ui) syncDialogTitle() string { + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Remote Sync Settings" + } + return "Set Up Remote Sync" + } + return "Advanced Sync" +} + +func (u *ui) syncDialogDescription() string { + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Review or change this vault's saved WebDAV target, credentials, and sync mode." + } + return "Send this local vault to a WebDAV target, then use that target for future sync." + } + return "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings." +} + +func (u *ui) syncDialogConfirmButtonLabel() string { + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Save Remote Sync Settings" + } + return "Set Up Remote Sync" + } + return "Synchronize" +} + +func (u *ui) shouldShowSyncDirectionChoices() bool { + return u.syncDialogPurpose != syncDialogPurposeRemoteSetup +} + +func (u *ui) shouldShowSyncSourceChoices() bool { + return u.syncDialogPurpose != syncDialogPurposeRemoteSetup +} + +func (u *ui) syncSetupMode() appstate.SyncMode { + if u.syncSetupAutomatic.Value { + return appstate.SyncModeAutomaticOnOpenSave + } + return appstate.SyncModeManual +} + +func (u *ui) selectVaultRemoteProfile(id string) { + id = strings.TrimSpace(id) + u.selectedVaultRemoteProfileID = id + for _, profile := range u.availableRemoteProfiles() { + if profile.ID != id { + continue + } + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + return + } +} + +func (u *ui) selectVaultRemoteCredentialEntry(id string) { + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id) +} + +func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) { + selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + profiles := u.availableRemoteProfiles() + for _, profile := range profiles { + if profile.ID == selectedID { + return profile, true + } + } + if selectedID == "" && len(profiles) == 1 { + return profiles[0], true + } + return vault.RemoteProfile{}, false +} + +func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) { + selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + entries := u.availableRemoteCredentialEntries() + for _, entry := range entries { + if entry.ID == selectedID { + return entry, true + } + } + if selectedID == "" && len(entries) == 1 { + return entries[0], true + } + return vault.Entry{}, false +} + +func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { + profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + if profileID != "" && entryID != "" { + return appstate.RemoteBinding{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: profileID, + CredentialEntryID: entryID, + SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), + }, true + } + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return appstate.RemoteBinding{}, false + } + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return appstate.RemoteBinding{}, false + } + return appstate.RemoteBinding{ + LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()), + RemoteProfileID: profile.ID, + CredentialEntryID: entry.ID, + SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), + }, true +} + +func normalizeUISyncMode(mode appstate.SyncMode) appstate.SyncMode { + switch mode { + case appstate.SyncModeAutomaticOnOpenSave: + return appstate.SyncModeAutomaticOnOpenSave + default: + return appstate.SyncModeManual + } +} + +func (u *ui) newRemoteBindingSyncMode() appstate.SyncMode { + if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { + return appstate.SyncModeAutomaticOnOpenSave + } + if u.selectedVaultRemoteSyncMode == "" { + return appstate.SyncModeAutomaticOnOpenSave + } + return appstate.SyncModeManual +} + +func (u *ui) syncSavedRemoteBindingSelection() { + profiles := u.availableRemoteProfiles() + entries := u.availableRemoteCredentialEntries() + + profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + if profileID != "" { + var found bool + for _, profile := range profiles { + if profile.ID == profileID { + found = true + break + } + } + if !found { + u.selectedVaultRemoteProfileID = "" + } + } + if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" && len(profiles) == 1 { + u.selectedVaultRemoteProfileID = profiles[0].ID + } + if profile, ok := u.selectedVaultRemoteProfile(); ok { + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + } + + entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + if entryID != "" { + var found bool + for _, entry := range entries { + if entry.ID == entryID { + found = true + break + } + } + if !found { + u.selectedVaultRemoteCredentialEntryID = "" + } + } + if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 { + u.selectedVaultRemoteCredentialEntryID = entries[0].ID + } + if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" || strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" { + if record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())); ok { + u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + if profile, ok := u.selectedVaultRemoteProfile(); ok { + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + } + } + } + if binding, ok := u.selectedVaultRemoteBinding(); ok { + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) != strings.TrimSpace(binding.LocalVaultPath) { + continue + } + if strings.TrimSpace(record.RemoteProfileID) != strings.TrimSpace(binding.RemoteProfileID) { + continue + } + if strings.TrimSpace(record.CredentialEntryID) != strings.TrimSpace(binding.CredentialEntryID) { + continue + } + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + return + } + } + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual +} + +func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { + path = strings.TrimSpace(path) + if path == "" { + return recentRemoteRecord{}, false + } + var matches []recentRemoteRecord + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) != path { + continue + } + if strings.TrimSpace(record.RemoteProfileID) == "" || strings.TrimSpace(record.CredentialEntryID) == "" { + continue + } + matches = append(matches, record) + } + if len(matches) != 1 { + return recentRemoteRecord{}, false + } + return matches[0], true +} + +func (u *ui) shouldShowSavedRemoteBindingSelectors() bool { + profiles := u.availableRemoteProfiles() + entries := u.availableRemoteCredentialEntries() + if len(profiles) == 0 || len(entries) == 0 { + return false + } + return len(profiles) > 1 || len(entries) > 1 +} + +func (u *ui) savedRemoteBindingSummary() (profileLabel, credentialLabel, syncLabel string, ok bool) { + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return "", "", "", false + } + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return "", "", "", false + } + credentialLabel = entry.Title + if strings.TrimSpace(entry.Username) != "" { + credentialLabel += " · " + strings.TrimSpace(entry.Username) + } + syncLabel = "Sync manually when you choose Use Remote Sync." + if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { + syncLabel = "Syncs automatically on open and save." + } + return profile.Name, credentialLabel, syncLabel, true +} + +func (u *ui) savedRemoteBindingHeading() string { + if !u.shouldShowSavedRemoteBindingSelectors() { + return "Use this vault's saved remote sync target" + } + return "Use a saved remote profile from this vault" +} + +func (u *ui) openSelectedVaultRemoteButtonLabel() string { + if !u.shouldShowSavedRemoteBindingSelectors() { + return "Use Remote Sync" + } + return "Open Saved Remote" +} + +func (u *ui) shouldShowDirectRemoteSyncShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + _, ok := u.selectedVaultRemoteBinding() + return ok +} + +func (u *ui) directRemoteSyncShortcutLabel() string { + return "Use Remote Sync" +} + +func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + _, ok := u.selectedVaultRemoteBinding() + return ok +} + +func (u *ui) remoteSyncSettingsShortcutLabel() string { + return "Remote Sync Settings" +} + +func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool { + return u.shouldShowRemoteSyncSettingsShortcut() +} + +func (u *ui) removeRemoteSyncShortcutLabel() string { + return "Stop Using Remote Sync" +} + +func (u *ui) shouldShowRemoteSyncSetupShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + _, ok := u.selectedVaultRemoteBinding() + return !ok +} + +func (u *ui) remoteSyncSetupShortcutLabel() string { + return "Set Up Remote Sync" +} + +func remoteBindingSuffix(baseURL, path, username string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username))) + return hex.EncodeToString(sum[:8]) +} + +func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.remotePath.Text()) + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + localVaultPath := strings.TrimSpace(u.vaultPath.Text()) + + switch { + case localVaultPath == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("local vault path is required") + case baseURL == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote base URL is required") + case remotePath == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote path is required") + case username == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote username is required") + case password == "": + return appstate.RemoteBindingInput{}, fmt.Errorf("remote password is required") + } + + suffix := remoteBindingSuffix(baseURL, remotePath, username) + credentialTitle := "WebDAV Sign-In" + if username != "" { + credentialTitle += " · " + username + } + + return appstate.RemoteBindingInput{ + LocalVaultPath: localVaultPath, + RemoteProfileID: "remote-profile-" + suffix, + RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}), + BaseURL: baseURL, + RemotePath: remotePath, + CredentialEntryID: "remote-credential-" + suffix, + CredentialTitle: credentialTitle, + Username: username, + Password: password, + CredentialPath: append([]string(nil), u.currentPath...), + SyncMode: u.newRemoteBindingSyncMode(), + }, nil +} + +func (u *ui) saveCurrentRemoteBindingAction() error { + input, err := u.currentRemoteBindingInput() + if err != nil { + return err + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode + return nil +} + +func (u *ui) stripRecentRemoteBinding(binding appstate.RemoteBinding) { + localPath := strings.TrimSpace(binding.LocalVaultPath) + profileID := strings.TrimSpace(binding.RemoteProfileID) + credentialID := strings.TrimSpace(binding.CredentialEntryID) + for i := range u.recentRemotes { + record := &u.recentRemotes[i] + if strings.TrimSpace(record.LocalVaultPath) != localPath { + continue + } + if strings.TrimSpace(record.RemoteProfileID) != profileID { + continue + } + if strings.TrimSpace(record.CredentialEntryID) != credentialID { + continue + } + record.LocalVaultPath = "" + record.RemoteProfileID = "" + record.CredentialEntryID = "" + record.SyncMode = "" + } +} + +func (u *ui) removeSelectedRemoteBindingAction() error { + binding, ok := u.selectedVaultRemoteBinding() + if !ok { + return fmt.Errorf("no saved remote sync target is selected") + } + if err := u.state.RemoveRemoteBinding(binding); err != nil { + return err + } + if err := u.state.Save(); err != nil { + return err + } + u.stripRecentRemoteBinding(binding) + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + u.remoteUsername.SetText("") + u.remotePassword.SetText("") + u.showStatusMessage("Remote sync is no longer set up for this vault.") + return nil +} + +func (u *ui) saveCurrentRemoteBindingHeading() string { + return "Bind this local vault to the current remote target" +} + +func (u *ui) saveCurrentRemoteBindingButtonLabel() string { + return "Save Remote In Vault" +} + +func (u *ui) materializeCurrentRemoteCache() error { + cachePath := strings.TrimSpace(u.vaultPath.Text()) + if cachePath == "" { + cachePath = u.saveAsTargetPath() + } + if cachePath == "" { + return nil + } + u.vaultPath.SetText(cachePath) + if err := u.state.SaveAs(cachePath); err != nil { + return err + } + u.noteRecentVault(cachePath) + + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + if username == "" && password == "" { + return nil + } + + input, err := u.currentRemoteBindingInput() + if err != nil { + return err + } + binding, err := u.state.ConfigureRemoteBinding(input) + if err != nil { + return err + } + if err := u.state.SaveAs(cachePath); err != nil { + return err + } + u.selectedVaultRemoteProfileID = binding.RemoteProfileID + u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID + u.selectedVaultRemoteSyncMode = binding.SyncMode + return nil +} + +func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + if u.hasOpenVault() { + return u.resolvedSelectedVaultRemoteBinding() + } + + binding, ok := u.selectedVaultRemoteBinding() + if !ok || strings.TrimSpace(binding.LocalVaultPath) == "" { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + if err := u.state.OpenVault(binding.LocalVaultPath, key); err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + u.vaultPath.SetText(binding.LocalVaultPath) + u.noteRecentVault(binding.LocalVaultPath) + u.restoreRecentVaultGroup(binding.LocalVaultPath) + + model, err := u.state.Session.Current() + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + resolved, err := binding.Resolve(model) + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + return binding, resolved, true, nil +} + +func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + binding, ok := u.selectedVaultRemoteBinding() + if !ok { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + model, err := u.state.Session.Current() + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + resolved, err := binding.Resolve(model) + if err != nil { + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err + } + return binding, resolved, true, nil } func (u *ui) noteCurrentRemotePath() { @@ -2333,17 +3283,141 @@ func (u *ui) remoteOpenRetryAvailable() bool { return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:") } +func (u *ui) selectedRemoteUsesLocalCache() bool { + return u.hasSelectedRemoteTarget() && + strings.TrimSpace(u.vaultPath.Text()) != "" && + strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && + strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" +} + +func (u *ui) currentSessionIsRemote() bool { + session, ok := u.state.Session.(interface{ IsRemote() bool }) + return ok && session.IsRemote() +} + +func (u *ui) resolvedSelectedVaultRemoteBindingForAutoSync() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) { + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding() + if err == nil || !ok { + return binding, resolved, ok, err + } + message := err.Error() + if strings.Contains(message, "resolve remote profile:") || strings.Contains(message, "resolve remote credentials:") { + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil + } + return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err +} + +func (u *ui) synchronizeSelectedRemoteBindingOnOpen() error { + if u.currentSessionIsRemote() { + return nil + } + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync() + if err != nil || !ok { + return err + } + if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave { + return nil + } + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + if err := u.state.SynchronizeFromRemote(client, resolved.Profile.Path); err != nil { + return err + } + if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil { + return err + } + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path) + return nil +} + +func (u *ui) synchronizeSelectedRemoteBindingOnSave() error { + if u.currentSessionIsRemote() { + return nil + } + binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync() + if err != nil || !ok { + return err + } + if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave { + return nil + } + client := webdav.Client{ + BaseURL: resolved.Profile.BaseURL, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + } + if err := u.state.SynchronizeToRemote(client, resolved.Profile.Path); err != nil { + return err + } + if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil { + return err + } + if err := u.state.Save(); err != nil { + return err + } + u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path) + return nil +} + +func (u *ui) reapplyResolvedRemoteBinding(binding appstate.RemoteBinding, resolved appstate.ResolvedRemoteBinding) error { + _, err := u.state.ConfigureRemoteBinding(appstate.RemoteBindingInput{ + LocalVaultPath: binding.LocalVaultPath, + RemoteProfileID: resolved.Profile.ID, + RemoteProfileName: resolved.Profile.Name, + BaseURL: resolved.Profile.BaseURL, + RemotePath: resolved.Profile.Path, + CredentialEntryID: resolved.Credentials.ID, + CredentialTitle: resolved.Credentials.Title, + Username: resolved.Credentials.Username, + Password: resolved.Credentials.Password, + CredentialPath: append([]string(nil), resolved.Credentials.Path...), + SyncMode: binding.SyncMode, + }) + if err != nil { + return err + } + u.selectedVaultRemoteSyncMode = binding.SyncMode + return nil +} + +func (u *ui) remoteLifecycleMessage() string { + if u.selectedRemoteUsesLocalCache() { + return "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings." + } + return "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." +} + func (u *ui) remoteOpenButtonLabel() string { switch { case u.lifecycleBusy(): - return "Opening Remote Vault..." + if u.selectedRemoteUsesLocalCache() { + return "Opening Cached Vault..." + } + return "Creating Local Cache..." case u.remoteOpenRetryAvailable(): - return "Retry Remote Vault" + if u.selectedRemoteUsesLocalCache() { + return "Retry Cached Vault" + } + return "Retry Local Cache Setup" default: - return "Open Remote Vault" + if u.selectedRemoteUsesLocalCache() { + return "Open Cached Vault" + } + return "Create Local Cache" } } +func (u *ui) remoteLifecycleSetupSummary() string { + return "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." +} + func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": @@ -3198,6 +4272,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 @@ -3231,6 +4311,48 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } } } + for i := range u.vaultRemoteProfileClicks { + for u.vaultRemoteProfileClicks[i].Clicked(gtx) { + profiles := u.availableRemoteProfiles() + if i < len(profiles) { + u.selectVaultRemoteProfile(profiles[i].ID) + } + } + } + for i := range u.vaultRemoteCredentialClicks { + for u.vaultRemoteCredentialClicks[i].Clicked(gtx) { + entries := u.availableRemoteCredentialEntries() + if i < len(entries) { + u.selectVaultRemoteCredentialEntry(entries[i].ID) + } + } + } + for i := range u.syncRemoteCredentialClicks { + for u.syncRemoteCredentialClicks[i].Clicked(gtx) { + entries := u.matchingAdvancedSyncRemoteCredentialEntries() + if i < len(entries) { + u.applyAdvancedSyncRemoteCredentialEntry(entries[i]) + } + } + } + for u.useSavedAdvancedSyncRemote.Clicked(gtx) { + u.openRemoteSyncSetupDialog() + } + for u.openSelectedVaultRemote.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.startOpenRemoteAction() + } + for u.saveCurrentRemoteBinding.Clicked(gtx) { + u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) + } + for u.removeSelectedRemoteBinding.Clicked(gtx) { + u.runAction("remove remote sync binding", u.removeSelectedRemoteBindingAction) + } + for u.shareCurrentVault.Clicked(gtx) { + u.runAction("share vault", u.shareCurrentVaultAction) + } for u.clearVaultSelection.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -3257,7 +4379,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") - u.rememberRemoteAuth.Value = false u.state.ErrorMessage = "" u.state.StatusMessage = "" u.requestMasterPassFocus = true @@ -3708,7 +4829,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { - return syncDialogSummaryCard(gtx, u.theme, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault) + return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { @@ -3875,7 +4996,7 @@ func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions { }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { - return approvalFact(u.theme, "When Sign-in Saves", "Username and password or app token are only stored after a successful remote open when Remember sign-in is enabled.", "")(gtx) + return approvalFact(u.theme, "How Persistence Works", u.remotePreferencesPersistenceSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { @@ -3963,55 +5084,83 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions { } func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { + matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries() + if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) { + u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials)) + } return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) 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(20), "Advanced Sync") + lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle()) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings.") + lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + if !u.shouldShowSyncDirectionChoices() { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush) + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush) + }), + ) }), ) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + if !u.shouldShowSyncDirectionChoices() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !u.shouldShowSyncSourceChoices() { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote) + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote) + }), + ) }), ) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncDialogSummaryCard(gtx, u.theme, u.syncSourceMode, u.syncDirection) + if !u.shouldShowSyncSourceChoices() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.syncSourceMode == syncSourceRemote { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children := []layout.FlexChild{ layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)), @@ -4021,6 +5170,50 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.syncPasswordField(gtx) }), + } + if u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save") + check.Color = accentColor + return check.Layout(gtx) + }), + ) + } + if len(matchingCredentials) > 0 { + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + for i, entry := range matchingCredentials { + i := i + entry := entry + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := entry.Title + if strings.TrimSpace(entry.Username) != "" { + label += " · " + strings.TrimSpace(entry.Username) + } + selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID + return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { + return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), label) + lbl.Color = accentColor + return lbl.Layout(gtx) + }) + }) + }), + ) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children..., ) } if supportsDesktopFilePicker(runtime.GOOS) { @@ -4032,7 +5225,7 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.runAdvancedSync, "Synchronize") + return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel()) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -4340,18 +5533,154 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { } func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { + profiles := u.availableRemoteProfiles() + credentials := u.availableRemoteCredentialEntries() + if len(u.vaultRemoteProfileClicks) < len(profiles) { + u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles)) + } + if len(u.vaultRemoteCredentialClicks) < len(credentials) { + u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials)) + } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !supportsVaultShare(runtime.GOOS) || u.vaultSharer == nil || strings.TrimSpace(u.currentShareableVaultPath()) == "" { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + ) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }), - ) + } + if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.savedRemoteBindingHeading()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + ) + if !u.shouldShowSavedRemoteBindingSelectors() { + rows = append(rows, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary() + if !ok { + return layout.Dimensions{} + } + return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, 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(13), profileLabel) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+credentialLabel) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), syncLabel) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + }) + }), + ) + } else { + for i, profile := range profiles { + i := i + profile := profile + rows = append(rows, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := strings.TrimSpace(u.selectedVaultRemoteProfileID) == profile.ID + return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { + return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), profile.Name) + lbl.Color = accentColor + return lbl.Layout(gtx) + }) + }) + }) + }), + ) + } + rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + for i, entry := range credentials { + i := i + entry := entry + rows = append(rows, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == entry.ID + label := entry.Title + if strings.TrimSpace(entry.Username) != "" { + label += " · " + strings.TrimSpace(entry.Username) + } + return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { + return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), label) + lbl.Color = accentColor + return lbl.Layout(gtx) + }) + }) + }) + }), + ) + } + } + if _, ok := u.selectedVaultRemoteProfile(); ok { + if _, ok := u.selectedVaultRemoteCredentialEntry(); ok { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel()) + }), + ) + } + } + } + if u.hasOpenVault() { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + remotePath := strings.TrimSpace(u.remotePath.Text()) + username := strings.TrimSpace(u.remoteUsername.Text()) + password := u.remotePassword.Text() + if baseURL != "" && remotePath != "" && username != "" && password != "" { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.saveCurrentRemoteBindingHeading()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, u.saveCurrentRemoteBindingButtonLabel()) + }), + ) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) }) } @@ -5541,56 +6870,95 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { pathSource = append([]string{}, u.currentPath...) } crumbs, indices := u.visibleBreadcrumbs(pathSource) - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { - children := make([]layout.FlexChild, 0, len(crumbs)*2) - for i, name := range crumbs { - index := i - label := name - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - for u.breadcrumbs[index].Clicked(gtx) { - target := indices[index] - if target == 0 { - root := u.hiddenVaultRoot() - if root == "" { - u.setCurrentPath(nil) - } else { - u.setCurrentPath([]string{root}) - } - } else { - nextPath := pathSource[:target] - root := u.hiddenVaultRoot() - if root != "" { - nextPath = append([]string{root}, nextPath...) - } - u.setCurrentPath(nextPath) - } - u.filter() - } - btn := material.Button(u.theme, &u.breadcrumbs[index], label) - btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) - btn.TextSize = unit.Sp(11) - if u.mode == "phone" { - btn.TextSize = unit.Sp(9) - btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} - } else { - btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} - } - return btn.Layout(gtx) - })) - if i < len(crumbs)-1 { + crumbBar := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(crumbs)*2) + for i, name := range crumbs { + index := i + label := name children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "/") - lbl.Color = mutedColor - inset := unit.Dp(6) - if u.mode == "phone" { - inset = unit.Dp(4) + for u.breadcrumbs[index].Clicked(gtx) { + target := indices[index] + if target == 0 { + root := u.hiddenVaultRoot() + if root == "" { + u.setCurrentPath(nil) + } else { + u.setCurrentPath([]string{root}) + } + } else { + nextPath := pathSource[:target] + root := u.hiddenVaultRoot() + if root != "" { + nextPath = append([]string{root}, nextPath...) + } + u.setCurrentPath(nextPath) + } + u.filter() } - return layout.UniformInset(inset).Layout(gtx, lbl.Layout) + btn := material.Button(u.theme, &u.breadcrumbs[index], label) + btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) + btn.TextSize = unit.Sp(11) + if u.mode == "phone" { + btn.TextSize = unit.Sp(9) + btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} + } else { + btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} + } + return btn.Layout(gtx) })) + if i < len(crumbs)-1 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "/") + lbl.Color = mutedColor + inset := unit.Dp(6) + if u.mode == "phone" { + inset = unit.Dp(4) + } + return layout.UniformInset(inset).Layout(gtx, lbl.Layout) + })) + } } + return children + }()...) + } + if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() && !u.shouldShowRemoveRemoteSyncShortcut() { + return crumbBar(gtx) + } + children := []layout.FlexChild{ + layout.Rigid(crumbBar), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + } + if u.shouldShowDirectRemoteSyncShortcut() { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + })) + } + if u.shouldShowRemoteSyncSetupShortcut() { + if u.shouldShowDirectRemoteSyncShortcut() { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } - return children - }()...) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + })) + } + if u.shouldShowRemoteSyncSettingsShortcut() { + if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + })) + } + if u.shouldShowRemoveRemoteSyncShortcut() { + if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() || u.shouldShowRemoteSyncSettingsShortcut() { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { @@ -5921,7 +7289,10 @@ func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { } } -func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSourceMode, direction syncDirection) layout.Dimensions { +func syncDialogSummaryText(purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) string { + if purpose == syncDialogPurposeRemoteSetup { + return "Push this local vault to a WebDAV target and save that target for future sync." + } sourceLabel := "another local vault file" if source == syncSourceRemote { sourceLabel = "another WebDAV-backed vault" @@ -5930,6 +7301,10 @@ func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSo if direction == syncDirectionPush { action = "Push the current vault into" } + return action + " " + sourceLabel + "." +} + +func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -5940,7 +7315,7 @@ func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSo }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(th, unit.Sp(14), action+" "+sourceLabel+".") + lbl := material.Label(th, unit.Sp(14), syncDialogSummaryText(purpose, source, direction)) lbl.Color = th.Palette.Fg return lbl.Layout(gtx) }), @@ -6181,5 +7556,19 @@ func runFilePicker(name string, args ...string) (string, error) { if err != nil { return "", err } - return strings.TrimSpace(string(output)), nil + return parsePickedFilePath(output) +} + +func parsePickedFilePath(output []byte) (string, error) { + lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") { + return line, nil + } + } + return "", fmt.Errorf("file picker did not return a path") } diff --git a/main_test.go b/main_test.go index f41920d..0c95931 100644 --- a/main_test.go +++ b/main_test.go @@ -83,6 +83,49 @@ func (s summarySession) HasVault() bool func (s summarySession) IsLocked() bool { return s.locked } func (s summarySession) IsRemote() bool { return s.remote } +type remoteOpenCaptureSession struct { + model vault.Model + remoteClient webdav.Client + remotePath string +} + +func (s *remoteOpenCaptureSession) Current() (vault.Model, error) { + return s.model, nil +} + +func (s *remoteOpenCaptureSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { + s.remoteClient = client + s.remotePath = path + return nil +} + +type saveCaptureSession struct { + model vault.Model + saveCount int + saveErr error +} + +func (s *saveCaptureSession) Current() (vault.Model, error) { + return s.model, nil +} + +func (s *saveCaptureSession) Save() error { + s.saveCount++ + return s.saveErr +} + +type captureVaultSharer struct { + path string + title string + err error +} + +func (s *captureVaultSharer) ShareVault(path, title string) error { + s.path = path + s.title = title + return s.err +} + func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { t.Parallel() @@ -90,7 +133,7 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { Entries: []vault.Entry{ {ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}}, {ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, - {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + {ID: "3", Title: "Surveillance Console", Username: "bashertarr", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}}, }, }) @@ -128,7 +171,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) u := newUIWithModel(mode, vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, - {ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + {ID: "entry-2", Title: "Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, Templates: []vault.Entry{ {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, @@ -136,7 +179,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) }, RecycleBin: []vault.Entry{ {ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}}, - {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + {ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}}, }, }) @@ -144,8 +187,8 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) u.state.NavigateToPath([]string{"Root", "Internet"}) u.search.SetText("climate") u.filter() - if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) { - t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got) + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Vent"}) { + t.Fatalf("entries filteredTitles() = %v, want [Vault Vent]", got) } u.showTemplatesSection() @@ -162,11 +205,11 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) u.showRecycleBinSection() u.search.SetText("climate") u.filter() - if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) { - t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got) + if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted Vault Vent"}) { + t.Fatalf("recycle filteredTitles() = %v, want [Deleted Vault Vent]", got) } - if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) { - t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got) + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Safe House"}) { + t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Safe House]", got) } }) } @@ -246,7 +289,12 @@ func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) { func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) started := make(chan struct{}) release := make(chan struct{}) runs := 0 @@ -282,7 +330,12 @@ func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { func TestUICancelLifecycleBusyStateIgnoresLateResultAndKeepsRetryAvailable(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.vaultPath.SetText("/tmp/example.kdbx") u.lastLifecycleAction = "open vault" @@ -327,21 +380,26 @@ func TestUIChildGroupsComeFromVaultModel(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, - {ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, {ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, }, }) u.state.NavigateToPath([]string{"Crew"}) - if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("childGroups() = %v, want [Internet Security Office]", got) } } func TestUIAPITokenLifecycleManagement(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -386,7 +444,12 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) { func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -524,7 +587,12 @@ func TestPolicyRulePartsFormatsGroupAndEntryResources(t *testing.T) { func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -537,7 +605,7 @@ func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) { t.Fatalf("issueAPITokenAction() error = %v", err) } u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) - u.apiPolicyPath.SetText("Crew / codex") + u.apiPolicyPath.SetText("Crew / bashertarr") u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true if err := u.addAPIPolicyRuleAction(); err != nil { @@ -576,7 +644,7 @@ func TestUIAPITokenDetailPanelResizesPolicyRemoveClickablesAcrossTokenSelection( } firstID := u.state.SelectedEntryID u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) - u.apiPolicyPath.SetText("Crew / codex") + u.apiPolicyPath.SetText("Crew / bashertarr") u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true if err := u.addAPIPolicyRuleAction(); err != nil { @@ -1096,7 +1164,7 @@ func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -1175,7 +1243,12 @@ func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) { t.Parallel() manager := &session.Manager{} - u := newUIWithSession("desktop", manager) + u := newUIWithState("desktop", manager, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -1247,7 +1320,12 @@ func TestUISaveSettingsPersistsUIPreferences(t *testing.T) { func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -1322,7 +1400,7 @@ func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -1369,7 +1447,13 @@ func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) { t.Fatalf("WriteFile(updated.key) error = %v", err) } - u := newUIWithSession("desktop", &session.Manager{}) + stateDir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(stateDir, "default.kdbx"), + RecentVaultsPath: filepath.Join(stateDir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(stateDir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(stateDir, "ui-prefs.json"), + }) u.setMasterKeyMode(vault.MasterKeyModePasswordOnly) u.masterPassword.SetText("old-password") if err := u.createVaultAction(); err != nil { @@ -1520,7 +1604,7 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -1547,10 +1631,17 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { })) defer server.Close() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) @@ -1560,7 +1651,7 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -1585,7 +1676,7 @@ func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }}, @@ -1605,7 +1696,12 @@ func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) { defer server.Close() manager := &session.Manager{} - u := newUIWithSession("desktop", manager) + u := newUIWithState("desktop", manager, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText(key.Password) u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") @@ -1643,7 +1739,12 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { url := server.URL server.Close() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"), + }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(url) u.remotePath.SetText("vaults/main.kdbx") @@ -1658,6 +1759,783 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { } } +func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { + t.Parallel() + + sess := &remoteOpenCaptureSession{ + model: vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }, + } + + u := newUIWithSession("desktop", sess) + u.masterPassword.SetText("correct horse battery staple") + u.selectedVaultRemoteProfileID = "family-webdav" + u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got) + } + if got := sess.remoteClient.Username; got != "linuscaldwell" { + t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got) + } + if got := sess.remoteClient.Password; got != "bellagio-pass-1" { + t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got) + } + if got := sess.remotePath; got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath editor = %q, want resolved profile path", got) + } +} + +func TestUIOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { + t.Parallel() + + sess := &remoteOpenCaptureSession{ + model: vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }, + } + + u := newUIWithSession("desktop", sess) + u.masterPassword.SetText("correct horse battery staple") + u.vaultPath.SetText("/vaults/family.kdbx") + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got) + } + if got := sess.remoteClient.Username; got != "linuscaldwell" { + t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got) + } + if got := sess.remoteClient.Password; got != "bellagio-pass-1" { + t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got) + } + if got := sess.remotePath; got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got) + } +} + +func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "remote-token", + Path: []string{"Root", "Internet"}, + }}, + } + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + + localModel := vault.Model{} + if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + RemoteProfileName: "family.kdbx · dav.example.invalid", + BaseURL: server.URL, + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: appstate.SyncModeAutomaticOnOpenSave, + }); err != nil { + t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) + } + writeKDBXMainTestFile(t, localPath, localModel, key) + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := u.vaultPath.Text(); got != localPath { + t.Fatalf("vaultPath = %q, want %q", got, localPath) + } + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { + t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) + } +} + +func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { + t.Parallel() + + localKey := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "", + Path: "files/family/keepass.kdbx", + }}, + } + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "bellagio-pass-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method %s", r.Method) + } + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + localModel.RemoteProfiles[0].BaseURL = server.URL + + manager := &session.Manager{} + if err := manager.Create(localModel, localKey); err != nil { + t.Fatalf("manager.Create() error = %v", err) + } + + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(localKey.Password) + u.vaultPath.SetText(localPath) + u.selectedVaultRemoteProfileID = "family-webdav" + u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" + + u.startOpenRemoteAction() + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want server URL from selected profile", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want selected profile path", got) + } +} + +func TestUIStartOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { + t.Parallel() + + localKey := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "", + Path: "files/family/keepass.kdbx", + }}, + } + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "bellagio-pass-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method %s", r.Method) + } + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + localModel.RemoteProfiles[0].BaseURL = server.URL + + manager := &session.Manager{} + if err := manager.Create(localModel, localKey); err != nil { + t.Fatalf("manager.Create() error = %v", err) + } + + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(localKey.Password) + u.vaultPath.SetText(localPath) + + u.startOpenRemoteAction() + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want server URL from implicit profile", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want implicit profile path", got) + } +} + +func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + localPath := filepath.Join(t.TempDir(), "family.kdbx") + + remoteModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "remote-token", + Path: []string{"Root", "Internet"}, + }}, + } + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + + localModel := vault.Model{} + if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + RemoteProfileName: "family.kdbx · dav.example.invalid", + BaseURL: server.URL, + RemotePath: "files/family/keepass.kdbx", + CredentialEntryID: "remote-creds-1", + CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + CredentialPath: []string{"Crew", "Internet"}, + SyncMode: appstate.SyncModeAutomaticOnOpenSave, + }); err != nil { + t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err) + } + writeKDBXMainTestFile(t, localPath, localModel, key) + + manager := &session.Manager{} + u := newUIWithSession("desktop", manager) + u.masterPassword.SetText(key.Password) + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: localPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }) + + u.startOpenRemoteAction() + + if got := u.loadingMessage; got != "Open remote vault..." { + t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...") + } + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.vaultPath.Text(); got != localPath { + t.Fatalf("vaultPath = %q, want %q", got, localPath) + } + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { + t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) + } +} + +func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + model := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, model, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + u.selectedVaultRemoteProfileID = "stale-profile" + u.selectedVaultRemoteCredentialEntryID = "stale-credential" + u.remoteBaseURL.SetText("https://stale.example.invalid") + u.remotePath.SetText("stale/path.kdbx") + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + } + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want resolved profile path", got) + } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual from matching recent-remote state", got) + } +} + +func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + model := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, model, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + u.selectedVaultRemoteProfileID = "stale-profile" + u.selectedVaultRemoteCredentialEntryID = "stale-credential" + u.remoteBaseURL.SetText("https://stale.example.invalid") + u.remotePath.SetText("stale/path.kdbx") + + u.startOpenVaultAction() + + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + } + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want resolved profile path", got) + } +} + +func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://stale.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{ + Entries: []vault.Entry{{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(remoteBytes.Bytes()) + })) + defer server.Close() + localModel.RemoteProfiles[0].BaseURL = server.URL + writeKDBXMainTestFile(t, path, localModel, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if _, err := current.EntryByID("vault-console"); err != nil { + t.Fatalf("EntryByID(vault-console) error = %v, want remote entry merged on open", err) + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL) + } +} + +func TestUIOpenVaultActionKeepsLocalVaultOpenWhenAutoSyncFails(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Local Cache", Path: []string{"Root", "Internet"}}, + {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://unreachable.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://unreachable.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v, want local open to succeed even if auto-sync fails", err) + } + + current, err := u.state.Session.Current() + if err != nil { + t.Fatalf("Session.Current() error = %v", err) + } + if _, err := current.EntryByID("entry-1"); err != nil { + t.Fatalf("EntryByID(entry-1) error = %v, want local vault opened", err) + } + if got := u.state.StatusMessage; !strings.Contains(got, "Remote sync on open failed:") { + t.Fatalf("StatusMessage = %q, want nonfatal remote sync failure notice", got) + } + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage = %q, want empty for nonfatal remote sync failure", got) + } +} + +func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + localModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://stale.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + } + writeKDBXMainTestFile(t, path, localModel, key) + + var ( + savedRemote []byte + putCount int + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", "\"v1\"") + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, key); err != nil { + t.Fatalf("SaveKDBXWithKey(remote) error = %v", err) + } + _, _ = w.Write(encoded.Bytes()) + case http.MethodPut: + putCount++ + var err error + savedRemote, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + w.Header().Set("ETag", "\"v2\"") + w.WriteHeader(http.StatusCreated) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + localModel.RemoteProfiles[0].BaseURL = server.URL + writeKDBXMainTestFile(t, path, localModel, key) + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: server.URL, + Path: "files/family/keepass.kdbx", + LocalVaultPath: path, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + u.vaultPath.SetText(path) + u.masterPassword.SetText(key.Password) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + if err := u.state.UpsertEntry(vault.Entry{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + if err := u.saveAction(); err != nil { + t.Fatalf("saveAction() error = %v", err) + } + + if putCount == 0 { + t.Fatal("remote PUT count = 0, want automatic remote synchronize on save") + } + loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedRemote), key) + if err != nil { + t.Fatalf("LoadKDBXWithKey(savedRemote) error = %v", err) + } + if _, err := loaded.EntryByID("entry-1"); err != nil { + t.Fatalf("EntryByID(entry-1) error = %v, want saved entry on remote", err) + } +} + +func TestPickExistingFileOutputExtractsPathFromPortalNoise(t *testing.T) { + t.Parallel() + + output := strings.Join([]string{ + "(zenity:1): Gdk-DEBUG: Ignoring portal setting", + "/home/tester/vaults/family.kdbx", + "", + }, "\n") + + got, err := parsePickedFilePath([]byte(output)) + if err != nil { + t.Fatalf("parsePickedFilePath() error = %v", err) + } + if got != "/home/tester/vaults/family.kdbx" { + t.Fatalf("parsePickedFilePath() = %q, want /home/tester/vaults/family.kdbx", got) + } +} + func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { t.Parallel() @@ -1668,7 +2546,7 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -1694,10 +2572,17 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { })) defer server.Close() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") u.remoteBaseURL.SetText(server.URL) u.remotePath.SetText("vaults/main.kdbx") + u.selectedVaultRemoteProfileID = "" + u.selectedVaultRemoteCredentialEntryID = "" if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) @@ -1706,7 +2591,7 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -2077,7 +2962,7 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, - {ID: "entry-2", Title: "Home Assistant", Path: []string{"Root", "Home Assistant"}}, + {ID: "entry-2", Title: "Security Office", Path: []string{"Root", "Security Office"}}, }, }) u.showEntriesSection() @@ -2088,8 +2973,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { if err := u.createGroupAction(); err != nil { t.Fatalf("createGroupAction() error = %v", err) } - if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) { - t.Fatalf("childGroups() after create = %v, want [Finance Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) { + t.Fatalf("childGroups() after create = %v, want [Finance Internet Security Office]", got) } u.state.EnterGroup("Finance") @@ -2104,8 +2989,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { u.state.NavigateToPath([]string{"Root"}) u.filter() - if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Home Assistant", "Internet"}) { - t.Fatalf("childGroups() after rename = %v, want [Budget Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Internet", "Security Office"}) { + t.Fatalf("childGroups() after rename = %v, want [Budget Internet Security Office]", got) } u.state.NavigateToPath([]string{"Root", "Budget"}) @@ -2117,8 +3002,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { if !slices.Equal(u.state.CurrentPath, []string{"Root"}) { t.Fatalf("state.CurrentPath after delete = %v, want [Root]", u.state.CurrentPath) } - if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) { - t.Fatalf("childGroups() after delete = %v, want [Home Assistant Internet]", got) + if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) { + t.Fatalf("childGroups() after delete = %v, want [Internet Security Office]", got) } } @@ -2182,7 +3067,7 @@ func TestUIParentGroupDoesNotShowDescendantEntries(t *testing.T) { {ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, - {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}}, }, }) u.showEntriesSection() @@ -2251,17 +3136,17 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, { ID: "ha", - Title: "Home Assistant", + Title: "Security Office", Username: "rustyryan", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://ha.example.test", - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, }) @@ -2270,7 +3155,7 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() - u.entryPath.SetText("Root / Home Assistant") + u.entryPath.SetText("Root / Security Office") if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() error = %v", err) @@ -2282,10 +3167,10 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { t.Fatalf("filteredTitles() in source group = %v, want empty after move", got) } - u.state.NavigateToPath([]string{"Root", "Home Assistant"}) + u.state.NavigateToPath([]string{"Root", "Security Office"}) u.filter() - if got := u.filteredTitles(); !slices.Equal(got, []string{"Home Assistant", "Vault Console"}) { - t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Home Assistant]", got) + if got := u.filteredTitles(); !slices.Equal(got, []string{"Security Office", "Vault Console"}) { + t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Security Office]", got) } } @@ -2298,7 +3183,7 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -2309,14 +3194,14 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() - u.entryPassword.SetText("token-2") + u.entryPassword.SetText("bellagio-pass-2") if err := u.saveEntryAction(); err != nil { t.Fatalf("saveEntryAction() error = %v", err) } u.filter() - if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-2" { - t.Fatalf("selectedEntry() = %#v, want updated password token-2", entry) + if entry, ok := u.selectedEntry(); !ok || entry.Password != "bellagio-pass-2" { + t.Fatalf("selectedEntry() = %#v, want updated password bellagio-pass-2", entry) } if err := u.duplicateSelectedEntryAction(); err != nil { @@ -2358,7 +3243,7 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) { u.entryID.SetText("bellagio") u.entryTitle.SetText("Bellagio") u.entryUsername.SetText("rustyryan") - u.entryPassword.SetText("token-1") + u.entryPassword.SetText("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryNotes.SetText("Registrar account") u.entryTags.SetText("dns, registrar") @@ -2381,7 +3266,7 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) { if !ok { t.Fatal("selectedEntry() ok = false, want created entry") } - if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" { + if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "bellagio-pass-1" || item.URL != "https://bellagio.example.invalid" { t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item) } if item.Notes != "Registrar account" { @@ -2439,7 +3324,7 @@ func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -2522,7 +3407,7 @@ func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { u.entryID.SetText("entry-1") u.entryTitle.SetText("Bellagio") u.entryUsername.SetText("rustyryan") - u.entryPassword.SetText("token-1") + u.entryPassword.SetText("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryPath.SetText("Root / Internet") if err := u.instantiateSelectedTemplateAction(); err != nil { @@ -2658,7 +3543,7 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T u.entryID.SetText("entry-1") u.entryTitle.SetText("Bellagio") u.entryUsername.SetText("rustyryan") - u.entryPassword.SetText("token-1") + u.entryPassword.SetText("bellagio-pass-1") u.entryURL.SetText("https://bellagio.example.invalid") u.entryPath.SetText("Root / Internet") if err := u.instantiateSelectedTemplateAction(); err != nil { @@ -2786,7 +3671,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, History: []vault.Entry{ @@ -2794,7 +3679,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { ID: "vault-console-h1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -2813,8 +3698,8 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { t.Fatalf("restoreSelectedHistoryAction() error = %v", err) } u.filter() - if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-1" { - t.Fatalf("selectedEntry() = %#v, want restored password token-1", entry) + if entry, ok := u.selectedEntry(); !ok || entry.Password != "bellagio-pass-1" { + t.Fatalf("selectedEntry() = %#v, want restored password bellagio-pass-1", entry) } } @@ -2827,7 +3712,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, History: []vault.Entry{ @@ -2835,7 +3720,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { ID: "vault-console-h1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, Notes: "previous token", @@ -2844,7 +3729,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { ID: "vault-console-h0", Title: "Vault Console", Username: "dannyocean", - Password: "token-0", + Password: "bellagio-pass-0", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, Notes: "oldest token", @@ -2875,8 +3760,8 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { if !ok { t.Fatal("selectedHistoryEntry() ok = false, want true") } - if selected.Password != "token-0" { - t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0") + if selected.Password != "bellagio-pass-0" { + t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "bellagio-pass-0") } } func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { @@ -2888,7 +3773,7 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -3011,7 +3896,7 @@ func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -3106,7 +3991,7 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { u = newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ - {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, }, }) u.clipboardWriter = &memoryClipboardWriter{} @@ -3185,7 +4070,7 @@ func TestUIGeneratedPasswordDraftStateClearsOnReloadAndSave(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}, }, }, @@ -3636,7 +4521,7 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ {ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}}, - {ID: "2", Title: "Home Assistant", Path: []string{"keepass", "Crew", "Home"}}, + {ID: "2", Title: "Security Office", Path: []string{"keepass", "Crew", "Safe House"}}, }, }) @@ -3772,8 +4657,8 @@ func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) { first.currentPath = []string{"Root", "Internet"} first.syncedPath = []string{"Root", "Internet"} first.noteRecentVault("/tmp/one.kdbx") - first.currentPath = []string{"Root", "Home Assistant"} - first.syncedPath = []string{"Root", "Home Assistant"} + first.currentPath = []string{"Root", "Security Office"} + first.syncedPath = []string{"Root", "Security Office"} first.noteRecentVault("/tmp/two.kdbx") first.currentPath = []string{"Root", "Finance"} first.syncedPath = []string{"Root", "Finance"} @@ -3790,8 +4675,8 @@ func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) { if got := second.recentVaultGroup("/tmp/one.kdbx"); !slices.Equal(got, []string{"Root", "Finance"}) { t.Fatalf("recentVaultGroup(one) = %v, want [Root Finance]", got) } - if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Home Assistant"}) { - t.Fatalf("recentVaultGroup(two) = %v, want [Root Home Assistant]", got) + if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Security Office"}) { + t.Fatalf("recentVaultGroup(two) = %v, want [Root Security Office]", got) } } @@ -3812,7 +4697,7 @@ func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) { ID: "entry-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -3853,11 +4738,11 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { first.recentRemotesPath = configPath first.recentRemotes = nil first.currentPath = []string{"Root", "Internet"} - first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true) - first.currentPath = []string{"Root", "Home"} - first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx", "bob", "secret-2", false) + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") + first.currentPath = []string{"Root", "Safe House"} + first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx") first.currentPath = []string{"Root", "Finance"} - first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true) + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}) second.recentRemotesPath = configPath @@ -3867,17 +4752,164 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { if got := len(second.recentRemotes); got != 2 { t.Fatalf("len(recentRemotes) = %d, want 2", got) } - if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" || got.Username != "alice" || got.Password != "secret-3" { - t.Fatalf("recentRemotes[0] = %#v, want updated remembered credentials", got) + if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" { + t.Fatalf("recentRemotes[0] = %#v, want updated location-only record", got) } if got := second.recentRemotes[0].LastGroup; !slices.Equal(got, []string{"Root", "Finance"}) { t.Fatalf("recentRemotes[0].LastGroup = %v, want [Root Finance]", got) } - if got := second.recentRemotes[1]; got.Username != "" || got.Password != "" { - t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got) + if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Safe House"}) { + t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Safe House]", got) } - if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Home"}) { - t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Home]", got) +} + +func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "recent-remotes.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.recentRemotesPath = configPath + first.recentRemotes = nil + first.currentPath = []string{"Root", "Internet"} + first.vaultPath.SetText("/vaults/family.kdbx") + first.selectedVaultRemoteProfileID = "remote-profile-1" + first.selectedVaultRemoteCredentialEntryID = "remote-creds-1" + first.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") + + second := newUIWithSession("desktop", &session.Manager{}) + second.recentRemotesPath = configPath + second.recentRemotes = nil + second.loadRecentRemotes() + + if got := len(second.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + record := second.recentRemotes[0] + if record.LocalVaultPath != "/vaults/family.kdbx" { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want /vaults/family.kdbx", record.LocalVaultPath) + } + if record.RemoteProfileID != "remote-profile-1" { + t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want remote-profile-1", record.RemoteProfileID) + } + if record.CredentialEntryID != "remote-creds-1" { + t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want remote-creds-1", record.CredentialEntryID) + } + if record.SyncMode != string(appstate.SyncModeAutomaticOnOpenSave) { + t.Fatalf("recentRemotes[0].SyncMode = %q, want automatic_on_open_save", record.SyncMode) + } +} + +func TestUILoadRecentRemotesIgnoresLegacySavedCredentials(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "recent-remotes.json") + content := `[ + { + "baseUrl": "https://dav.example.com", + "path": "vaults/home.kdbx", + "username": "debbieocean", + "password": "secret-1", + "lastGroup": ["Root", "Internet"] + } +]` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(recent-remotes.json) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.recentRemotesPath = configPath + u.recentRemotes = nil + u.loadRecentRemotes() + + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + if got := u.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" { + t.Fatalf("recentRemotes[0] = %#v, want location-only record", got) + } + if !u.recentRemotes[0].NeedsMigration { + t.Fatal("recentRemotes[0].NeedsMigration = false, want true for legacy saved credentials") + } + if got := u.recentRemotes[0].Username; got != "" { + t.Fatalf("recentRemotes[0].Username = %q, want empty after migration strip", got) + } + if got := u.recentRemotes[0].Password; got != "" { + t.Fatalf("recentRemotes[0].Password = %q, want empty after migration strip", got) + } +} + +func TestUINewUIShowsMigrationStatusForLegacyRecentRemoteCredentials(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + recentRemotesPath := filepath.Join(dir, "recent-remotes.json") + content := `[ + { + "baseUrl": "https://dav.example.com", + "path": "vaults/home.kdbx", + "username": "debbieocean", + "password": "secret-1" + } +]` + if err := os.WriteFile(recentRemotesPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(recent-remotes.json) error = %v", err) + } + + u := newUIWithState("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "default.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: recentRemotesPath, + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) + + if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." { + t.Fatalf("StatusMessage = %q, want legacy recent-remote migration notice for the selected startup remote", got) + } +} + +func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }) + + if got := u.vaultPath.Text(); got != "/vaults/family.kdbx" { + t.Fatalf("vaultPath = %q, want /vaults/family.kdbx", got) + } + if got := u.selectedVaultRemoteProfileID; got != "remote-profile-1" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want remote-profile-1", got) + } + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got) + } +} + +func TestUIApplyRecentRemoteRecordShowsMigrationNoticeForLegacySavedCredentials(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", + NeedsMigration: true, + }) + + if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." { + t.Fatalf("StatusMessage = %q, want legacy per-record migration notice", got) } } @@ -3898,24 +4930,21 @@ func TestUIStartupPreselectsNewestTargetAcrossLocalAndRemote(t *testing.T) { first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) } first.noteRecentVault("/tmp/local.kdbx") first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) } - first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true) + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx") second := newUIWithSession("desktop", &session.Manager{}, paths) - if got := second.lifecycleMode; got != "remote" { - t.Fatalf("lifecycleMode = %q, want remote", got) + if got := second.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode = %q, want local", got) } - if got := second.remoteBaseURL.Text(); got != "https://dav.example.com" { - t.Fatalf("remoteBaseURL = %q, want https://dav.example.com", got) + if got := second.vaultPath.Text(); got != "/tmp/local.kdbx" { + t.Fatalf("vaultPath = %q, want /tmp/local.kdbx", got) } - if got := second.remotePath.Text(); got != "vaults/home.kdbx" { - t.Fatalf("remotePath = %q, want vaults/home.kdbx", got) + if got := second.remoteUsername.Text(); got != "" { + t.Fatalf("remoteUsername = %q, want empty for location-only recent remote", got) } - if got := second.remoteUsername.Text(); got != "alice" { - t.Fatalf("remoteUsername = %q, want alice", got) - } - if got := second.remotePassword.Text(); got != "secret-1" { - t.Fatalf("remotePassword = %q, want secret-1", got) + if got := second.remotePassword.Text(); got != "" { + t.Fatalf("remotePassword = %q, want empty for location-only recent remote", got) } } @@ -4311,13 +5340,13 @@ func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.recentRemotes = []recentRemoteRecord{{ - BaseURL: "https://dav.example.com", - Path: "vaults/home.kdbx", - Username: "alice", - Password: "secret-1", + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", }} u.recentRemoteClicks = make([]widget.Clickable, 1) + u.remoteUsername.SetText("debbieocean") + u.remotePassword.SetText("secret-1") u.remotePassword.Mask = 0 u.recentRemoteClicks[0].Click() @@ -4326,15 +5355,18 @@ func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { record := u.recentRemotes[0] u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) - u.remoteUsername.SetText(record.Username) - u.remotePassword.SetText(record.Password) u.remotePassword.Mask = '•' - u.rememberRemoteAuth.Value = true } if got := u.remotePassword.Mask; got != '•' { t.Fatalf("remotePassword.Mask = %q, want bullet mask", got) } + if got := u.remoteUsername.Text(); got != "debbieocean" { + t.Fatalf("remoteUsername = %q, want preserved manual username", got) + } + if got := u.remotePassword.Text(); got != "secret-1" { + t.Fatalf("remotePassword = %q, want preserved manual password", got) + } } func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { @@ -4387,6 +5419,31 @@ func TestRestoreStartupLifecycleTargetSelectsMostRecentLocalVault(t *testing.T) } } +func TestRestoreStartupLifecycleTargetUsesLocalCacheFromRecentRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.vaultPath.SetText("") + u.recentVaults = []string{"/tmp/older.kdbx"} + u.recentVaultUsedAt["/tmp/older.kdbx"] = time.Date(2026, time.April, 5, 1, 2, 3, 0, time.UTC) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/tmp/family-cache.kdbx", + UsedAt: time.Date(2026, time.April, 5, 2, 2, 3, 0, time.UTC).Format(time.RFC3339Nano), + }} + + u.restoreStartupLifecycleTarget() + + if got := u.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode after restore = %q, want local", got) + } + if got := u.vaultPath.Text(); got != "/tmp/family-cache.kdbx" { + t.Fatalf("vaultPath after restore = %q, want /tmp/family-cache.kdbx", got) + } +} + func TestShowLocalVaultChooser(t *testing.T) { t.Parallel() @@ -4411,7 +5468,13 @@ func TestShowLocalVaultChooser(t *testing.T) { func TestShowRemoteConnectionChooser(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + dir := t.TempDir() + u := newUIWithState("desktop", &session.Manager{}, 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"), + }) u.lifecycleMode = "remote" u.remoteBaseURL.SetText("") u.remotePath.SetText("") @@ -4442,16 +5505,20 @@ func TestShowRemoteConnectionChooser(t *testing.T) { func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + dir := t.TempDir() + u := newUIWithState("desktop", &session.Manager{}, 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"), + }) if u.hasSelectedRemoteTarget() { t.Fatal("hasSelectedRemoteTarget() = true, want false before selecting a saved remote connection") } u.applyRecentRemoteRecord(recentRemoteRecord{ - BaseURL: "https://dav.crew.example.invalid", - Path: "vaults/bellagio.kdbx", - Username: "dannyocean", - Password: "topsecret", + BaseURL: "https://dav.crew.example.invalid", + Path: "vaults/bellagio.kdbx", }) if !u.hasSelectedRemoteTarget() { @@ -4459,6 +5526,1157 @@ func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) { } } +func TestUIAvailableRemoteProfilesUsesVaultProfiles(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + RemoteProfiles: []vault.RemoteProfile{ + { + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }, + { + ID: "archive-webdav", + Name: "Archive Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/archive.kdbx", + }, + }, + }) + + got := u.availableRemoteProfiles() + if len(got) != 2 { + t.Fatalf("len(availableRemoteProfiles()) = %d, want 2", len(got)) + } + if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" { + t.Fatalf("availableRemoteProfiles() = %#v, want profiles sorted by name/id", got) + } +} + +func TestUIAvailableRemoteCredentialEntriesUsesVaultEntries(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}}, + {ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + }, + }) + + got := u.availableRemoteCredentialEntries() + if len(got) != 2 { + t.Fatalf("len(availableRemoteCredentialEntries()) = %d, want 2", len(got)) + } + if got[0].ID != "cred-1" || got[1].ID != "cred-2" { + t.Fatalf("availableRemoteCredentialEntries() = %#v, want entries sorted by title", got) + } +} + +func TestUIAvailableRemoteProfilesReturnsEmptyWhenLocked(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", summarySession{locked: true}) + if got := u.availableRemoteProfiles(); len(got) != 0 { + t.Fatalf("availableRemoteProfiles() = %#v, want empty when locked", got) + } +} + +func TestUISelectVaultRemoteProfileUpdatesSelectionAndTargetFields(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + + u.selectVaultRemoteProfile("family-webdav") + + if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got) + } + if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("remotePath = %q, want resolved profile path", got) + } +} + +func TestUISelectVaultRemoteCredentialEntryUpdatesSelection(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + }) + + u.selectVaultRemoteCredentialEntry("remote-creds-1") + + if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) + } +} + +func TestUIShouldShowSavedRemoteBindingSelectorsWhenMultipleChoices(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{ + {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"}, + {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"}, + }, + }) + + if !u.shouldShowSavedRemoteBindingSelectors() { + t.Fatal("shouldShowSavedRemoteBindingSelectors() = false, want true with multiple profiles and credentials") + } +} + +func TestUIShouldHideSavedRemoteBindingSelectorsForSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + + if u.shouldShowSavedRemoteBindingSelectors() { + t.Fatal("shouldShowSavedRemoteBindingSelectors() = true, want false with a single saved binding choice") + } +} + +func TestUISavedRemoteBindingSummaryUsesImplicitSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + + profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary() + if !ok { + t.Fatal("savedRemoteBindingSummary() ok = false, want true") + } + if profileLabel != "Family Vault" { + t.Fatalf("profileLabel = %q, want Family Vault", profileLabel) + } + if credentialLabel != "Bellagio WebDAV Sign-In · linuscaldwell" { + t.Fatalf("credentialLabel = %q, want Bellagio WebDAV Sign-In · linuscaldwell", credentialLabel) + } + if syncLabel != "Sync manually when you choose Use Remote Sync." { + t.Fatalf("syncLabel = %q, want manual sync summary", syncLabel) + } +} + +func TestUISavedRemoteBindingSummaryMentionsAutomaticSyncMode(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave + + _, _, syncLabel, ok := u.savedRemoteBindingSummary() + if !ok { + t.Fatal("savedRemoteBindingSummary() ok = false, want true") + } + if syncLabel != "Syncs automatically on open and save." { + t.Fatalf("syncLabel = %q, want automatic sync summary", syncLabel) + } +} + +func TestUISavedRemoteBindingHeadingUsesSyncLanguageForSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + + if got := u.savedRemoteBindingHeading(); got != "Use this vault's saved remote sync target" { + t.Fatalf("savedRemoteBindingHeading() = %q, want sync-target guidance", got) + } +} + +func TestUIOpenSelectedVaultRemoteButtonLabelUsesSyncLanguageForSingleChoice(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + + if got := u.openSelectedVaultRemoteButtonLabel(); got != "Use Remote Sync" { + t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Use Remote Sync", got) + } +} + +func TestUIOpenSelectedVaultRemoteButtonLabelUsesSavedRemoteLanguageForMultipleChoices(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}}, + {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{ + {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"}, + {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"}, + }, + }) + + if got := u.openSelectedVaultRemoteButtonLabel(); got != "Open Saved Remote" { + t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Open Saved Remote", got) + } +} + +func TestUIShouldShowDirectRemoteSyncShortcutForSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true for an opened vault with a saved remote binding") + } +} + +func TestUIRemoteSyncShortcutsHaveParityAcrossModes(t *testing.T) { + t.Parallel() + + for _, mode := range []string{"desktop", "phone"} { + mode := mode + t.Run(mode, func(t *testing.T) { + t.Parallel() + + u := newUIWithModel(mode, vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true") + } + if !u.shouldShowRemoteSyncSettingsShortcut() { + t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true") + } + if !u.shouldShowRemoveRemoteSyncShortcut() { + t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true") + } + if u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when a binding exists") + } + + if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" { + t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got) + } + if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" { + t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got) + } + if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" { + t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got) + } + }) + } +} + +func TestUIShouldHideDirectRemoteSyncShortcutWithoutSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.state.Section = appstate.SectionEntries + + if u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a saved remote binding") + } +} + +func TestUIDirectRemoteSyncShortcutLabelUsesSyncLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" { + t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got) + } +} + +func TestUIShouldShowRemoteSyncSetupShortcutForOpenedLocalVaultWithoutSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Path: []string{"Crew", "Internet"}, + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true for opened local vault without saved binding") + } +} + +func TestUIRemoteSetupShortcutHasParityAcrossModes(t *testing.T) { + t.Parallel() + + for _, mode := range []string{"desktop", "phone"} { + mode := mode + t.Run(mode, func(t *testing.T) { + t.Parallel() + + u := newUIWithModel(mode, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Vault Console", + Path: []string{"Crew", "Internet"}, + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true") + } + if u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a binding") + } + if u.shouldShowRemoteSyncSettingsShortcut() { + t.Fatal("shouldShowRemoteSyncSettingsShortcut() = true, want false without a binding") + } + if u.shouldShowRemoveRemoteSyncShortcut() { + t.Fatal("shouldShowRemoveRemoteSyncShortcut() = true, want false without a binding") + } + if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" { + t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got) + } + }) + } +} + +func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.state.Section = appstate.SectionEntries + + if u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when saved binding already exists") + } +} + +func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" { + t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got) + } +} + +func TestUIShouldShowRemoteSyncSettingsShortcutForSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowRemoteSyncSettingsShortcut() { + t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true when a saved binding exists") + } +} + +func TestUIRemoteSyncSettingsShortcutLabelUsesClearLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" { + t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got) + } +} + +func TestUIShouldShowRemoveRemoteSyncShortcutForSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.state.Section = appstate.SectionEntries + + if !u.shouldShowRemoveRemoteSyncShortcut() { + t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true when a saved binding exists") + } +} + +func TestUIRemoveRemoteSyncShortcutLabelUsesClearLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" { + t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got) + } +} + +func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.vaultPath.SetText("/vaults/family.kdbx") + + u.openRemoteSyncSetupDialog() + + if !u.syncDialogOpen { + t.Fatal("syncDialogOpen = false, want true") + } + if got := u.syncDialogPurpose; got != syncDialogPurposeRemoteSetup { + t.Fatalf("syncDialogPurpose = %q, want remote setup", got) + } + if got := u.syncSourceMode; got != syncSourceRemote { + t.Fatalf("syncSourceMode = %q, want remote", got) + } + if got := u.syncDirection; got != syncDirectionPush { + t.Fatalf("syncDirection = %q, want push", got) + } + if got := u.syncLocalPath.Text(); got != "/vaults/family.kdbx" { + t.Fatalf("syncLocalPath = %q, want current vault path", got) + } + if got := u.syncRemoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("syncRemoteBaseURL = %q, want saved remote base URL", got) + } + if got := u.syncRemotePath.Text(); got != "files/family/keepass.kdbx" { + t.Fatalf("syncRemotePath = %q, want saved remote path", got) + } + if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" { + t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got) + } + if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" { + t.Fatalf("syncRemotePassword = %q, want bellagio-pass-1", got) + } +} + +func TestUISelectedLocalVaultRemoteSyncSummaryMentionsSetup(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, 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"), + }) + + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Open this vault to set up a WebDAV sync target for it." { + t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want setup guidance", got) + } +} + +func TestUISelectedLocalVaultRemoteSyncSummaryMentionsAutomaticSync(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, 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"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Syncs automatically on open and save." { + t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want automatic sync guidance", got) + } +} + +func TestUISelectedLocalVaultRemoteSyncSummaryMentionsManualSync(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, 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"), + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Sync manually when you choose Use Remote Sync." { + t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want manual sync guidance", got) + } +} + +func TestUISyncDialogUsesRemoteSetupCopy(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.syncDialogPurpose = syncDialogPurposeRemoteSetup + u.syncSetupAutomatic.Value = true + + if got := u.syncDialogTitle(); got != "Set Up Remote Sync" { + t.Fatalf("syncDialogTitle() = %q, want Set Up Remote Sync", got) + } + if got := u.syncDialogDescription(); got != "Send this local vault to a WebDAV target, then use that target for future sync." { + t.Fatalf("syncDialogDescription() = %q, want remote setup guidance", got) + } + if got := u.syncDialogConfirmButtonLabel(); got != "Set Up Remote Sync" { + t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Set Up Remote Sync", got) + } + if u.shouldShowSyncDirectionChoices() { + t.Fatal("shouldShowSyncDirectionChoices() = true, want false for remote setup") + } + if u.shouldShowSyncSourceChoices() { + t.Fatal("shouldShowSyncSourceChoices() = true, want false for remote setup") + } + if got := syncDialogSummaryText(syncDialogPurposeRemoteSetup, syncSourceRemote, syncDirectionPush); got != "Push this local vault to a WebDAV target and save that target for future sync." { + t.Fatalf("syncDialogSummaryText(remote setup) = %q, want setup-specific summary", got) + } + if got := u.syncSetupMode(); got != appstate.SyncModeAutomaticOnOpenSave { + t.Fatalf("syncSetupMode() = %q, want automatic_on_open_save", got) + } +} + +func TestUISyncDialogUsesRemoteSettingsCopyForExistingBinding(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }) + u.state.Section = appstate.SectionEntries + u.vaultPath.SetText("/vaults/family.kdbx") + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: "/vaults/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeManual), + }} + + u.openRemoteSyncSetupDialog() + + if got := u.syncDialogTitle(); got != "Remote Sync Settings" { + t.Fatalf("syncDialogTitle() = %q, want Remote Sync Settings", got) + } + if got := u.syncDialogDescription(); got != "Review or change this vault's saved WebDAV target, credentials, and sync mode." { + t.Fatalf("syncDialogDescription() = %q, want settings guidance", got) + } + if got := u.syncDialogConfirmButtonLabel(); got != "Save Remote Sync Settings" { + t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Save Remote Sync Settings", got) + } + if u.syncSetupAutomatic.Value { + t.Fatal("syncSetupAutomatic.Value = true, want false for an existing manual binding") + } +} + +func TestUISyncDialogUsesAdvancedCopy(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.syncDialogPurpose = syncDialogPurposeAdvanced + + if got := u.syncDialogTitle(); got != "Advanced Sync" { + t.Fatalf("syncDialogTitle() = %q, want Advanced Sync", got) + } + if got := u.syncDialogConfirmButtonLabel(); got != "Synchronize" { + t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Synchronize", got) + } + if !u.shouldShowSyncDirectionChoices() { + t.Fatal("shouldShowSyncDirectionChoices() = false, want true for advanced sync") + } + if !u.shouldShowSyncSourceChoices() { + t.Fatal("shouldShowSyncSourceChoices() = false, want true for advanced sync") + } + if got := syncDialogSummaryText(syncDialogPurposeAdvanced, syncSourceRemote, syncDirectionPush); got != "Push the current vault into another WebDAV-backed vault." { + t.Fatalf("syncDialogSummaryText(advanced) = %q, want advanced summary", got) + } +} + +func TestUIRemoteSyncSetupPersistsBindingAfterSuccessfulPush(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + writeKDBXMainTestFile(t, currentPath, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusNotFound) + case http.MethodPut: + w.Header().Set("ETag", "\"v1\"") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, 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"), + }) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(currentPath) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + u.openRemoteSyncSetupDialog() + u.syncRemoteBaseURL.SetText(server.URL) + u.syncRemotePath.SetText("vaults/other.kdbx") + u.syncRemoteUsername.SetText("linuscaldwell") + u.syncRemotePassword.SetText("bellagio-pass-1") + + if err := u.advancedSyncAction(); err != nil { + t.Fatalf("advancedSyncAction() error = %v", err) + } + + if got := u.selectedVaultRemoteProfileID; got == "" { + t.Fatal("selectedVaultRemoteProfileID = empty, want saved binding") + } + if got := u.selectedVaultRemoteCredentialEntryID; got == "" { + t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want saved credential binding") + } + if got := u.remoteBaseURL.Text(); got != server.URL { + t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL) + } + if got := u.remotePath.Text(); got != "vaults/other.kdbx" { + t.Fatalf("remotePath = %q, want vaults/other.kdbx", got) + } + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + if got := u.recentRemotes[0].LocalVaultPath; got != currentPath { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", got, currentPath) + } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got) + } + if got := u.state.StatusMessage; got != "Remote sync is set up for this vault." { + t.Fatalf("StatusMessage = %q, want setup success message", got) + } + if u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = true after setup, want false") + } + if !u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = false after setup, want true") + } + + var reopened session.Manager + if err := reopened.Open(currentPath, key); err != nil { + t.Fatalf("reopened.Open(currentPath) error = %v", err) + } + reopenedModel, err := reopened.Current() + if err != nil { + t.Fatalf("reopened.Current() error = %v", err) + } + profiles := reopenedModel.RemoteProfiles + if len(profiles) != 1 { + t.Fatalf("len(reopened.RemoteProfiles) = %d, want 1 persisted profile", len(profiles)) + } + if profiles[0].BaseURL != server.URL || profiles[0].Path != "vaults/other.kdbx" { + t.Fatalf("reopened.RemoteProfiles[0] = %#v, want persisted setup target", profiles[0]) + } + cred, err := reopenedModel.EntryByID(u.selectedVaultRemoteCredentialEntryID) + if err != nil { + t.Fatalf("reopened.EntryByID(saved credential) error = %v", err) + } + if cred.Username != "linuscaldwell" || cred.Password != "bellagio-pass-1" { + t.Fatalf("reopened saved credential = %#v, want linuscaldwell/bellagio-pass-1", cred) + } +} + +func TestUIRemoteSyncSetupCanPersistManualSyncMode(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + writeKDBXMainTestFile(t, currentPath, vault.Model{ + Entries: []vault.Entry{{ID: "entry-current", Title: "Vault Console", Path: []string{"Root", "Internet"}}}, + }, key) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusNotFound) + case http.MethodPut: + w.Header().Set("ETag", "\"v1\"") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, 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"), + }) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(currentPath) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + u.openRemoteSyncSetupDialog() + u.syncSetupAutomatic.Value = false + u.syncRemoteBaseURL.SetText(server.URL) + u.syncRemotePath.SetText("vaults/manual.kdbx") + u.syncRemoteUsername.SetText("linuscaldwell") + u.syncRemotePassword.SetText("bellagio-pass-1") + + if err := u.advancedSyncAction(); err != nil { + t.Fatalf("advancedSyncAction() error = %v", err) + } + + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got) + } + if got := u.recentRemotes[0].SyncMode; got != string(appstate.SyncModeManual) { + t.Fatalf("recentRemotes[0].SyncMode = %q, want manual", got) + } +} + +func TestUIRemoveSelectedRemoteBindingActionClearsVaultBindingAndRecentRefs(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + writeKDBXMainTestFile(t, currentPath, vault.Model{ + Entries: []vault.Entry{{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + Path: []string{"Crew", "Internet"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }, key) + + dir := t.TempDir() + u := newUIWithSession("desktop", &session.Manager{}, 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"), + }) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(currentPath) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: currentPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), + }} + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + if err := u.removeSelectedRemoteBindingAction(); err != nil { + t.Fatalf("removeSelectedRemoteBindingAction() error = %v", err) + } + + if got := u.selectedVaultRemoteProfileID; got != "" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want empty", got) + } + if got := u.selectedVaultRemoteCredentialEntryID; got != "" { + t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want empty", got) + } + if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { + t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got) + } + if got := u.recentRemotes[0].RemoteProfileID; got != "" { + t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want empty", got) + } + if got := u.recentRemotes[0].CredentialEntryID; got != "" { + t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want empty", got) + } + if got := u.recentRemotes[0].SyncMode; got != "" { + t.Fatalf("recentRemotes[0].SyncMode = %q, want empty", got) + } + if got := u.state.StatusMessage; got != "Remote sync is no longer set up for this vault." { + t.Fatalf("StatusMessage = %q, want removal status", got) + } + if u.shouldShowDirectRemoteSyncShortcut() { + t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false after removing binding") + } + if !u.shouldShowRemoteSyncSetupShortcut() { + t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true after removing binding") + } + + reopened := newUIWithSession("desktop", &session.Manager{}) + reopened.masterPassword.SetText(key.Password) + reopened.vaultPath.SetText(currentPath) + if err := reopened.openVaultAction(); err != nil { + t.Fatalf("reopened.openVaultAction() error = %v", err) + } + if got := len(reopened.availableRemoteProfiles()); got != 0 { + t.Fatalf("len(reopened.availableRemoteProfiles()) = %d, want 0", got) + } + if got := len(reopened.availableRemoteCredentialEntries()); got != 0 { + t.Fatalf("len(reopened.availableRemoteCredentialEntries()) = %d, want 0", got) + } +} + +func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.currentPath = []string{"Crew", "Internet"} + u.vaultPath.SetText("/tmp/family.kdbx") + u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.remotePath.SetText("files/family/keepass.kdbx") + u.remoteUsername.SetText("linuscaldwell") + u.remotePassword.SetText("bellagio-pass-1") + + if err := u.saveCurrentRemoteBindingAction(); err != nil { + t.Fatalf("saveCurrentRemoteBindingAction() error = %v", err) + } + + profiles := u.availableRemoteProfiles() + if len(profiles) != 1 { + t.Fatalf("len(availableRemoteProfiles()) = %d, want 1", len(profiles)) + } + if profiles[0].BaseURL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("saved profile = %#v, want persisted base URL", profiles[0]) + } + + entries := u.availableRemoteCredentialEntries() + var found bool + for _, entry := range entries { + if entry.Username == "linuscaldwell" && entry.Password == "bellagio-pass-1" { + found = true + if !slices.Equal(entry.Path, []string{"Crew", "Internet"}) { + t.Fatalf("credential path = %v, want [Crew Internet]", entry.Path) + } + if entry.URL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("credential URL = %q, want remote.php/dav URL", entry.URL) + } + } + } + if !found { + t.Fatalf("availableRemoteCredentialEntries() = %#v, want persisted linuscaldwell/bellagio-pass-1 entry", entries) + } + + if got := u.selectedVaultRemoteProfileID; got == "" { + t.Fatal("selectedVaultRemoteProfileID = empty, want selected saved profile") + } + if got := u.selectedVaultRemoteCredentialEntryID; got == "" { + t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want selected saved credential entry") + } + if !u.state.Dirty { + t.Fatal("state.Dirty = false, want true after saving binding into vault") + } +} + +func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesBaseURL(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Bellagio", Username: "rustyryan", URL: "https://dav.example.invalid/remote.php/dav/", Path: []string{"Crew", "Internet"}}, + {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "entry-3", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Internet"}}, + }, + }) + u.syncSourceMode = syncSourceRemote + u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + + got := u.matchingAdvancedSyncRemoteCredentialEntries() + if len(got) != 2 { + t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2", len(got)) + } + if got[0].ID != "entry-1" || got[1].ID != "entry-3" { + t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want Bellagio and Bellagio WebDAV Sign-In matches", got) + } +} + +func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) { + t.Parallel() + + localVaultPath := filepath.Join(t.TempDir(), "family.kdbx") + u := newUIWithState("desktop", &uiSession{model: vault.Model{ + Entries: []vault.Entry{ + {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}}, + {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, + }, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }}, + }}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.vaultPath.SetText(localVaultPath) + u.syncSourceMode = syncSourceRemote + u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.syncRemotePath.SetText("files/family/keepass.kdbx") + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + LocalVaultPath: localVaultPath, + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + }} + + got := u.matchingAdvancedSyncRemoteCredentialEntries() + if len(got) != 1 { + t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 1 from saved binding", len(got)) + } + if got[0].ID != "remote-creds-1" { + t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want remote-creds-1 from saved binding", got) + } +} + +func TestUIApplyAdvancedSyncRemoteCredentialEntryFillsCredentials(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + entry := vault.Entry{ + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + URL: "https://dav.example.invalid/remote.php/dav", + } + + u.applyAdvancedSyncRemoteCredentialEntry(entry) + + if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" { + t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got) + } + if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" { + t.Fatalf("syncRemotePassword = %q, want bellagio-pass-1", got) + } + if got := u.selectedSyncRemoteCredentialEntryID; got != "remote-creds-1" { + t.Fatalf("selectedSyncRemoteCredentialEntryID = %q, want remote-creds-1", got) + } +} + +func TestUISaveCurrentRemoteBindingActionRequiresCompleteRemoteSignIn(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.vaultPath.SetText("/tmp/family.kdbx") + u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + u.remotePath.SetText("files/family/keepass.kdbx") + + if err := u.saveCurrentRemoteBindingAction(); err == nil { + t.Fatal("saveCurrentRemoteBindingAction() error = nil, want validation error") + } +} + func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { t.Parallel() @@ -4469,7 +6687,6 @@ func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { u.remotePath.SetText("vaults/remote.kdbx") u.remoteUsername.SetText("dannyocean") u.remotePassword.SetText("topsecret") - u.rememberRemoteAuth.Value = true u.masterPassword.SetText("correct horse battery staple") u.keyFilePath.SetText("/vaults/keyfile.keyx") u.search.SetText("crew") @@ -4501,9 +6718,6 @@ func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) { if got := u.remotePassword.Text(); got != "" { t.Fatalf("remotePassword = %q, want empty", got) } - if u.rememberRemoteAuth.Value { - t.Fatal("rememberRemoteAuth = true, want false") - } if got := u.masterPassword.Text(); got != "" { t.Fatalf("masterPassword = %q, want empty", got) } @@ -4537,7 +6751,6 @@ func TestSwitchToLifecycleSelectionResetsLockedRemoteSession(t *testing.T) { u.remotePath.SetText("vaults/remote.kdbx") u.remoteUsername.SetText("rustyryan") u.remotePassword.SetText("topsecret") - u.rememberRemoteAuth.Value = true u.switchToLifecycleSelection("remote") @@ -4562,9 +6775,6 @@ func TestSwitchToLifecycleSelectionResetsLockedRemoteSession(t *testing.T) { if got := u.remotePassword.Text(); got != "" { t.Fatalf("remotePassword = %q, want empty", got) } - if u.rememberRemoteAuth.Value { - t.Fatal("rememberRemoteAuth = true, want false") - } } func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { @@ -4644,90 +6854,43 @@ func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) { } } -func TestRecentRemoteStoredAuthSummaryDescribesSavedCredentialState(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - record recentRemoteRecord - want string - }{ - { - name: "location_only", - record: recentRemoteRecord{}, - want: "location only", - }, - { - name: "username_only", - record: recentRemoteRecord{Username: "alice"}, - want: "saved username", - }, - { - name: "password_only", - record: recentRemoteRecord{Password: "token-1"}, - want: "saved password", - }, - { - name: "full_sign_in", - record: recentRemoteRecord{Username: "alice", Password: "token-1"}, - want: "saved username and password", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := recentRemoteStoredAuthSummary(tt.record); got != tt.want { - t.Fatalf("recentRemoteStoredAuthSummary(%+v) = %q, want %q", tt.record, got, tt.want) - } - }) - } -} - -func TestUIRemotePreferencesCurrentSummaryExplainsWhatWillBeRemembered(t *testing.T) { +func TestUIRemotePreferencesCurrentSummaryExplainsVaultBackedCredentialFlow(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.remoteBaseURL.SetText("https://dav.example.com") u.remotePath.SetText("vaults/home.kdbx") - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO will remember only the WebDAV location for this connection." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only guidance", got) + if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." { + t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only vault guidance", got) } - u.rememberRemoteAuth.Value = true - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: sign-in retention is enabled, but no username or password is entered yet." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want empty-sign-in guidance", got) - } - - u.remoteUsername.SetText("alice") - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will save the entered sign-in for this connection on this device." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want pending-save guidance", got) - } - - u.recentRemotes = []recentRemoteRecord{{ - BaseURL: "https://dav.example.com", - Path: "vaults/home.kdbx", - Username: "alice", - Password: "secret-1", - }} - if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will update the saved sign-in for this connection on this device." { - t.Fatalf("remotePreferencesCurrentSummary() = %q, want saved-sign-in guidance", got) + u.remoteUsername.SetText("debbieocean") + if got := u.remotePreferencesCurrentSummary(); got != "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile." { + t.Fatalf("remotePreferencesCurrentSummary() = %q, want vault-storage guidance", got) } } -func TestUIRemotePreferencesHelpExplainsSavedFieldsAndRetention(t *testing.T) { +func TestUIRemotePreferencesHelpExplainsLocationOnlyRetention(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) - if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." { + if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection." { t.Fatalf("remotePreferencesAlwaysSavedSummary() = %q, want saved-fields guidance", got) } - if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." { - t.Fatalf("remotePreferencesRetentionSummary() = %q, want retention guidance", got) + if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls." { + t.Fatalf("remotePreferencesRetentionSummary() = %q, want vault retention guidance", got) + } +} + +func TestUIRemotePreferencesPersistenceSummaryExplainsVaultBindingFlow(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remotePreferencesPersistenceSummary(); got != "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself." { + t.Fatalf("remotePreferencesPersistenceSummary() = %q, want local-first vault-binding guidance", got) } } @@ -4760,13 +6923,171 @@ func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" - if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" { - t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault") + if got := u.remoteOpenButtonLabel(); got != "Create Local Cache" { + t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Create Local Cache") } u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" - if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" { - t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault") + if got := u.remoteOpenButtonLabel(); got != "Retry Local Cache Setup" { + t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Local Cache Setup") + } + + u.loadingMessage = "Opening..." + u.state.ErrorMessage = "" + if got := u.remoteOpenButtonLabel(); got != "Creating Local Cache..." { + t.Fatalf("remoteOpenButtonLabel() while busy = %q, want %q", got, "Creating Local Cache...") + } +} + +func TestUIRemoteLifecycleMessageUsesLocalCacheLanguageForBoundRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + }) + + if got := u.remoteLifecycleMessage(); got != "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings." { + t.Fatalf("remoteLifecycleMessage() = %q, want local-cache guidance", got) + } +} + +func TestUIRemoteLifecycleMessageUsesLocalFirstSetupLanguageForFirstRemoteOpen(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + + if got := u.remoteLifecycleMessage(); got != "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." { + t.Fatalf("remoteLifecycleMessage() = %q, want local-first remote setup guidance", got) + } +} + +func TestUIRemoteLifecycleSetupSummaryExplainsCacheAndBindingFlow(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.remoteLifecycleSetupSummary(); got != "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." { + t.Fatalf("remoteLifecycleSetupSummary() = %q, want local-cache bootstrap guidance", got) + } +} + +func TestUISaveCurrentRemoteBindingHeadingExplainsVaultBinding(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.saveCurrentRemoteBindingHeading(); got != "Bind this local vault to the current remote target" { + t.Fatalf("saveCurrentRemoteBindingHeading() = %q, want vault binding guidance", got) + } +} + +func TestUISaveCurrentRemoteBindingButtonLabelUsesSyncLanguage(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + if got := u.saveCurrentRemoteBindingButtonLabel(); got != "Save Remote In Vault" { + t.Fatalf("saveCurrentRemoteBindingButtonLabel() = %q, want sync-target language", got) + } +} + +func TestUIRemoteOpenButtonLabelUsesLocalCacheLanguageForBoundRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + }) + + if got := u.remoteOpenButtonLabel(); got != "Open Cached Vault" { + t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Cached Vault") + } + + u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" + if got := u.remoteOpenButtonLabel(); got != "Retry Cached Vault" { + t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Cached Vault") + } +} + +func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LocalVaultPath: "/vaults/cache/home.kdbx", + RemoteProfileID: "remote-profile-1", + CredentialEntryID: "remote-creds-1", + LastGroup: []string{"Root", "Internet"}, + }} + + if got := u.selectedRemoteCardHeading(); got != "CACHED VAULT" { + t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "CACHED VAULT") + } + if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx" { + t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx") + } + gotDetails := u.selectedRemoteCardDetailLines() + wantDetails := []string{ + "/vaults/cache", + "Sync target: home.kdbx · dav.example.invalid", + "Last group: Root / Internet", + } + if !slices.Equal(gotDetails, wantDetails) { + t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) + } +} + +func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(recentRemoteRecord{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + }) + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.invalid", + Path: "vaults/home.kdbx", + LastGroup: []string{"Root", "Internet"}, + }} + + if got := u.selectedRemoteCardHeading(); got != "SELECTED CONNECTION" { + t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "SELECTED CONNECTION") + } + if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx · dav.example.invalid" { + t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx · dav.example.invalid") + } + gotDetails := u.selectedRemoteCardDetailLines() + wantDetails := []string{ + "Path: vaults/home.kdbx", + "Server: https://dav.example.invalid", + "Last group: Root / Internet", + } + if !slices.Equal(gotDetails, wantDetails) { + t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) } } @@ -4796,9 +7117,14 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) })) defer server.Close() - first := newUIWithSession("desktop", &session.Manager{}) - first.recentRemotesPath = statePath - first.recentRemotes = nil + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: statePath, + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + } + + first := newUIWithState("desktop", &session.Manager{}, paths) first.lifecycleMode = "remote" first.masterPassword.SetText("correct horse battery staple") first.remoteBaseURL.SetText(server.URL) @@ -4811,10 +7137,7 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) first.syncedPath = []string{"Root", "Internet"} first.noteCurrentRemotePath() - reopened := newUIWithSession("desktop", &session.Manager{}) - reopened.recentRemotesPath = statePath - reopened.recentRemotes = nil - reopened.loadRecentRemotes() + reopened := newUIWithState("desktop", &session.Manager{}, paths) reopened.lifecycleMode = "remote" reopened.masterPassword.SetText("correct horse battery staple") reopened.remoteBaseURL.SetText(server.URL) @@ -4828,6 +7151,161 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) } } +func TestUIOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cachePath := filepath.Join(dir, "remote-cache.kdbx") + paths := statePaths{ + DefaultSaveAsPath: cachePath, + 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) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + u := newUIWithState("desktop", &session.Manager{}, paths) + u.lifecycleMode = "remote" + u.masterPassword.SetText(key.Password) + u.remoteBaseURL.SetText(server.URL) + u.remotePath.SetText("vault.kdbx") + u.remoteUsername.SetText("linuscaldwell") + u.remotePassword.SetText("bellagio-pass-1") + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := u.vaultPath.Text(); got != cachePath { + t.Fatalf("vaultPath = %q, want %q", got, cachePath) + } + if _, err := os.Stat(cachePath); err != nil { + t.Fatalf("Stat(cachePath) error = %v", err) + } + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + record := u.recentRemotes[0] + if record.LocalVaultPath != cachePath { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath) + } + if record.RemoteProfileID == "" || record.CredentialEntryID == "" { + t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record) + } + + var reopened session.Manager + if err := reopened.Open(cachePath, key); err != nil { + t.Fatalf("Open(cachePath) error = %v", err) + } + model, err := reopened.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + if got := len(model.RemoteProfiles); got != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", got) + } + if got := model.RemoteProfiles[0].BaseURL; got != server.URL { + t.Fatalf("RemoteProfiles[0].BaseURL = %q, want %q", got, server.URL) + } + entry, err := model.EntryByID(record.CredentialEntryID) + if err != nil { + t.Fatalf("EntryByID(%q) error = %v", record.CredentialEntryID, err) + } + if entry.Username != "linuscaldwell" || entry.Password != "bellagio-pass-1" { + t.Fatalf("credential entry = %#v, want linuscaldwell/bellagio-pass-1", entry) + } +} + +func TestUIStartOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cachePath := filepath.Join(dir, "remote-cache.kdbx") + paths := statePaths{ + DefaultSaveAsPath: cachePath, + 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) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" { + t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok) + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + u := newUIWithState("desktop", &session.Manager{}, paths) + u.lifecycleMode = "remote" + u.masterPassword.SetText(key.Password) + u.remoteBaseURL.SetText(server.URL) + u.remotePath.SetText("vault.kdbx") + u.remoteUsername.SetText("linuscaldwell") + u.remotePassword.SetText("bellagio-pass-1") + + u.startOpenRemoteAction() + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.ErrorMessage; got != "" { + t.Fatalf("ErrorMessage after apply = %q, want empty", got) + } + if got := u.vaultPath.Text(); got != cachePath { + t.Fatalf("vaultPath = %q, want %q", got, cachePath) + } + if _, err := os.Stat(cachePath); err != nil { + t.Fatalf("Stat(cachePath) error = %v", err) + } + if got := len(u.recentRemotes); got != 1 { + t.Fatalf("len(recentRemotes) = %d, want 1", got) + } + record := u.recentRemotes[0] + if record.LocalVaultPath != cachePath { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath) + } + if record.RemoteProfileID == "" || record.CredentialEntryID == "" { + t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record) + } +} + func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { t.Parallel() @@ -4852,6 +7330,191 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") { t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json")) } + if got := paths.PendingSharedVaultPath; got != filepath.Join(base, "pending-shared-vault.kdbx") { + t.Fatalf("PendingSharedVaultPath = %q, want %q", got, filepath.Join(base, "pending-shared-vault.kdbx")) + } + if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") { + t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt")) + } +} + +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 TestUIConsumesPendingSharedVaultImportOnStartup(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"), + PendingSharedVaultPath: filepath.Join(dir, "pending-shared-vault.kdbx"), + PendingSharedVaultNamePath: filepath.Join(dir, "pending-shared-vault-name.txt"), + } + 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: "Bellagio", Path: []string{"Crew", "Internet"}}, + }, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + if err := os.WriteFile(paths.PendingSharedVaultPath, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedVaultPath) error = %v", err) + } + if err := os.WriteFile(paths.PendingSharedVaultNamePath, []byte("crew-shared.kdbx\n"), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedVaultNamePath) error = %v", err) + } + + u := newUIWithState("phone", &session.Manager{}, paths) + + wantPath := filepath.Join(dir, "crew-shared.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 pending import") + } + if _, err := os.Stat(paths.PendingSharedVaultPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(PendingSharedVaultPath) error = %v, want not exist", err) + } + if _, err := os.Stat(paths.PendingSharedVaultNamePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(PendingSharedVaultNamePath) error = %v, want not exist", 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{"Crew", "Internet"}) + reopened.filter() + if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { + t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) + } +} + +func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) { + t.Parallel() + + u := newUIWithSession("phone", &session.Manager{}) + u.vaultPath.SetText("/vaults/crew-shared.kdbx") + + if got := u.currentShareableVaultPath(); got != "/vaults/crew-shared.kdbx" { + t.Fatalf("currentShareableVaultPath() = %q, want %q", got, "/vaults/crew-shared.kdbx") + } +} + +func TestUIShareCurrentVaultActionSavesAndSharesCurrentVault(t *testing.T) { + t.Parallel() + + session := &saveCaptureSession{} + sharer := &captureVaultSharer{} + u := newUIWithSession("phone", session) + u.vaultSharer = sharer + u.vaultPath.SetText("/vaults/crew-shared.kdbx") + + if err := u.shareCurrentVaultAction(); err != nil { + t.Fatalf("shareCurrentVaultAction() error = %v", err) + } + if session.saveCount != 1 { + t.Fatalf("shareCurrentVaultAction() saveCount = %d, want 1", session.saveCount) + } + if got := sharer.path; got != "/vaults/crew-shared.kdbx" { + t.Fatalf("ShareVault path = %q, want %q", got, "/vaults/crew-shared.kdbx") + } + if got := sharer.title; got != "crew-shared.kdbx" { + t.Fatalf("ShareVault title = %q, want %q", got, "crew-shared.kdbx") + } +} + +func TestUIShareCurrentVaultActionRequiresVaultPath(t *testing.T) { + t.Parallel() + + u := newUIWithSession("phone", &saveCaptureSession{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.vaultSharer = &captureVaultSharer{} + + err := u.shareCurrentVaultAction() + if err == nil || err.Error() != errVaultPathRequired { + t.Fatalf("shareCurrentVaultAction() error = %v, want %q", err, errVaultPathRequired) + } } func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { @@ -4966,6 +7629,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() @@ -5030,7 +7704,7 @@ func TestMasterPasswordPeekResetsAfterOpeningVault(t *testing.T) { var encoded bytes.Buffer if err := vault.SaveKDBX(&encoded, vault.Model{ Entries: []vault.Entry{ - {ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}}, }, }, "correct horse battery staple"); err != nil { t.Fatalf("SaveKDBX() error = %v", err) @@ -5057,8 +7731,8 @@ func TestPasswordPeekResetsWhenChangingSelectedEntry(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ - {ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}}, - {ID: "bellagio", Title: "Bellagio", Password: "token-2", Path: []string{"Root", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}}, + {ID: "bellagio", Title: "Bellagio", Password: "bellagio-pass-2", Path: []string{"Root", "Internet"}}, }, }) u.showEntriesSection() @@ -5143,7 +7817,7 @@ func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *test ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -5157,7 +7831,7 @@ func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *test want string }{ {name: "username", target: clipboard.TargetUsername, label: "copy username", want: "dannyocean"}, - {name: "password", target: clipboard.TargetPassword, label: "copy password", want: "token-1"}, + {name: "password", target: clipboard.TargetPassword, label: "copy password", want: "bellagio-pass-1"}, {name: "url", target: clipboard.TargetURL, label: "copy URL", want: "https://vault.crew.example.invalid"}, } @@ -5198,7 +7872,7 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -5215,7 +7889,7 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) { if u.state.ErrorMessage != clipboard.ErrWriteFailed.Error() { t.Fatalf("state.ErrorMessage = %q, want %q", u.state.ErrorMessage, clipboard.ErrWriteFailed.Error()) } - if strings.Contains(u.state.ErrorMessage, "token-1") { + if strings.Contains(u.state.ErrorMessage, "bellagio-pass-1") { t.Fatalf("state.ErrorMessage = %q, must not contain copied password", u.state.ErrorMessage) } if u.state.StatusMessage != "" { @@ -5232,7 +7906,7 @@ func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -5250,8 +7924,8 @@ func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) { } generated := u.entryPassword.Text() - if generated == "token-1" { - t.Fatal("entryPassword.Text() = token-1, want a newly generated password") + if generated == "bellagio-pass-1" { + t.Fatal("entryPassword.Text() = bellagio-pass-1, want a newly generated password") } if len(generated) < passwords.DefaultProfiles()["strong"].Length { t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["strong"].Length) @@ -5279,7 +7953,7 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}, }, }, @@ -5289,13 +7963,13 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" - if got := u.detailPasswordValue(); got != "••••••••" { - t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, "••••••••") + if got, want := u.detailPasswordValue(), strings.Repeat("•", len("bellagio-pass-1")); got != want { + t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, want) } u.showPassword = true - if got := u.detailPasswordValue(); got != "token-1" { - t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "token-1") + if got := u.detailPasswordValue(); got != "bellagio-pass-1" { + t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "bellagio-pass-1") } if err := u.lockAction(); err != nil { @@ -5351,7 +8025,7 @@ func TestUILocalLifecycleActionsUpdateVisibleStatusMessages(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }); err != nil { @@ -5600,18 +8274,18 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ Entries: []vault.Entry{ - {ID: "lights", Title: "Home Assistant", Path: []string{"Crew", "codex"}}, + {ID: "lights", Title: "Security Office", Path: []string{"Crew", "bashertarr"}}, }, }) - u.state.NavigateToPath([]string{"Crew", "codex"}) + u.state.NavigateToPath([]string{"Crew", "bashertarr"}) u.filter() u.state.SelectedEntryID = "lights" if err := u.useCurrentGroupForPolicyAction(); err != nil { t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) } - if got := u.apiPolicyPath.Text(); got != "codex" { - t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "codex") + if got := u.apiPolicyPath.Text(); got != "bashertarr" { + t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") } if !u.apiPolicyGroupScopeW.Value { t.Fatal("apiPolicyGroupScopeW.Value = false, want true") diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl similarity index 89% rename from packaging/archlinux/keepassgo-git/PKGBUILD rename to packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index 03e6578..358b845 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -1,5 +1,5 @@ pkgname=keepassgo-git -pkgver=r165.1c72a50 +pkgver=@PKGVER@ pkgrel=1 pkgdesc='KeePass-compatible password manager written in Go' arch=('x86_64' 'aarch64') @@ -27,13 +27,7 @@ source=('git+https://git.julianfamily.org/joejulian/keepassgo.git') sha256sums=('SKIP') _repo_dir() { - if [[ -d "${srcdir}/keepassgo/.git" ]]; then - printf '%s\n' "${srcdir}/keepassgo" - return - fi - - cd "${startdir}/../../.." || exit 1 - pwd + printf '%s\n' "@REPO_DIR@" } pkgver() { diff --git a/ui_forms.go b/ui_forms.go index a31f9f1..20f1264 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -21,7 +21,6 @@ import ( func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() showLocalChooser := u.showLocalVaultChooser() - showRemoteChooser := u.showRemoteConnectionChooser() selectedLocalPath := strings.TrimSpace(u.vaultPath.Text()) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -31,154 +30,13 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - message := "Choose a recent vault or enter a .kdbx path, then unlock it." - if u.lifecycleMode == "remote" { - message = "Connect to a remote vault, then unlock it with the KeePass master key." - } + message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens." lbl := material.Label(u.theme, unit.Sp(14), message) lbl.Color = accentColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local") - } - return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote") - } - return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote") - }), - ) - }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.lifecycleMode == "remote" { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(12), "LOCATION") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if showRemoteChooser || !u.hasSelectedRemoteTarget() { - return layout.Dimensions{} - } - return layout.Dimensions{} - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if showRemoteChooser && !busy { - return u.recentRemoteList(gtx) - } - return layout.Dimensions{} - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device") - box.Color = accentColor - return box.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help") - }) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showRemoteChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) - }), - ) - } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showLocalChooser { @@ -200,6 +58,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{} @@ -296,29 +166,6 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.lifecycleMode == "remote" { - label := u.remoteOpenButtonLabel() - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveTonedButton(gtx, u.theme, label) - } - return tonedButton(gtx, u.theme, &u.openRemote, label) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.hasSelectedRemoteTarget() { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.hasSelectedRemoteTarget() { - return layout.Dimensions{} - } - return u.selectedRemoteConnectionCard(gtx) - }), - ) - } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Open Vault" @@ -361,58 +208,81 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions { - record := u.currentRemoteRecord() - lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path) + heading := u.selectedRemoteCardHeading() + primary := u.selectedRemoteCardPrimaryText() + details := u.selectedRemoteCardDetailLines() return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION") + lbl := material.Label(u.theme, unit.Sp(12), heading) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record)) + lbl := material.Label(u.theme, unit.Sp(14), primary) lbl.Color = accentColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path)) + } + for _, line := range details { + line := line + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout)) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), line) lbl.Color = mutedColor return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL)) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{ - Username: strings.TrimSpace(u.remoteUsername.Text()), - Password: u.remotePassword.Text(), - })) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(lastGroup) == 0 { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), + })) + } + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection") }), ) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) }) }) } +func (u *ui) selectedRemoteCardHeading() string { + if u.selectedRemoteUsesLocalCache() { + return "CACHED VAULT" + } + return "SELECTED CONNECTION" +} + +func (u *ui) selectedRemoteCardPrimaryText() string { + record := u.currentRemoteRecord() + if u.selectedRemoteUsesLocalCache() { + path := strings.TrimSpace(u.vaultPath.Text()) + if label := friendlyRecentVaultLabel(path); label != "" { + return label + } + } + return friendlyRecentRemoteLabel(record) +} + +func (u *ui) selectedRemoteCardDetailLines() []string { + record := u.currentRemoteRecord() + lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path) + lines := make([]string, 0, 3) + if u.selectedRemoteUsesLocalCache() { + if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" { + lines = append(lines, dir) + } + lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record)) + } else { + lines = append(lines, "Path: "+strings.TrimSpace(record.Path)) + lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL)) + } + if len(lastGroup) > 0 { + lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) + } + return lines +} + func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions { lastGroup := u.recentVaultGroup(path) return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { @@ -446,6 +316,11 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime lbl.Color = mutedColor return lbl.Layout(gtx) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.selectedLocalVaultRemoteSyncSummary(path)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault") @@ -455,6 +330,17 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime }) } +func (u *ui) selectedLocalVaultRemoteSyncSummary(path string) string { + if record, ok := u.boundRecentRemoteForLocalVault(path); ok { + summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record) + if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave { + return summary + " · Syncs automatically on open and save." + } + return summary + " · Sync manually when you choose Use Remote Sync." + } + return "Open this vault to set up a WebDAV sync target for it." +} + func (u *ui) lifecycleSecuritySettingsSummary() string { return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." } @@ -617,11 +503,6 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions { lbl.Color = mutedColor return lbl.Layout(gtx) }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record)) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(record.LastGroup) == 0 { return layout.Dimensions{} @@ -750,21 +631,6 @@ func normalizedRemoteHost(baseURL string) string { return strings.TrimSuffix(host, "/") } -func recentRemoteStoredAuthSummary(record recentRemoteRecord) string { - username := strings.TrimSpace(record.Username) - hasPassword := record.Password != "" - switch { - case username != "" && hasPassword: - return "saved username and password" - case username != "": - return "saved username" - case hasPassword: - return "saved password" - default: - return "location only" - } -} - func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() if len(items) == 0 { diff --git a/vault/kdbx.go b/vault/kdbx.go index ff63bda..b4e5850 100644 --- a/vault/kdbx.go +++ b/vault/kdbx.go @@ -3,6 +3,7 @@ package vault import ( "crypto/rand" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -23,9 +24,10 @@ type KDBXConfig struct { var ErrInvalidMasterKey = errors.New("invalid master key") const ( - templatesRoot = "Templates" - recycleBinRoot = "Recycle Bin" - keepassGOIDField = "KeePassGO-ID" + templatesRoot = "Templates" + recycleBinRoot = "Recycle Bin" + keepassGOIDField = "KeePassGO-ID" + remoteProfilesKey = "keepassgo.remoteProfiles" ) func LoadKDBX(r io.Reader, password string) (Model, error) { @@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config * db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) db.Credentials = credentials db.Content.Meta = gokeepasslib.NewMetaData() + db.Content.Meta.CustomData = customDataForModel(model) db.Content.Root = &gokeepasslib.RootData{} if config != nil && config.Header != nil { db.Header = cloneHeader(config.Header) @@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error) for _, group := range db.Content.Root.Groups { appendGroupEntries(&model, db, group, nil) } + model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta) return model, &KDBXConfig{ Header: cloneHeader(db.Header), @@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error) }, nil } +func customDataForModel(model Model) []gokeepasslib.CustomData { + if len(model.RemoteProfiles) == 0 { + return nil + } + + content, err := json.Marshal(model.RemoteProfiles) + if err != nil { + return nil + } + + return []gokeepasslib.CustomData{{ + Key: remoteProfilesKey, + Value: string(content), + }} +} + +func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile { + if meta == nil { + return nil + } + for _, item := range meta.CustomData { + if item.Key != remoteProfilesKey { + continue + } + var profiles []RemoteProfile + if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil { + return nil + } + return profiles + } + return nil +} + func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) { switch { case key.Password != "" && len(key.KeyFileData) > 0: diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go index f856c72..371edbf 100644 --- a/vault/kdbx_test.go +++ b/vault/kdbx_test.go @@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) { mustGroup("Root", mustGroup("Internet", mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"), - mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"), + mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"), ), - mustGroup("Home Assistant", - mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"), + mustGroup("Security Office", + mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"), ), ), }, @@ -62,15 +62,15 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) { } groups := model.ChildGroups([]string{"Root"}) - if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" { - t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups) + if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" { + t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups) } } func TestLoadKDBXPreservesEntryDetails(t *testing.T) { t.Parallel() - entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2") + entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2") entry.Tags = "automation; home" entry.Values = append(entry.Values, mkValue("Notes", "Long-lived token used by Codex for home automation tasks."), @@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Home Assistant", entry)), + mustGroup("Root", mustGroup("Security Office", entry)), }, }, }, @@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) { t.Fatalf("LoadKDBX failed: %v", err) } - got := model.EntriesInPath([]string{"Root", "Home Assistant"}) + got := model.EntriesInPath([]string{"Root", "Security Office"}) if len(got) != 1 { t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) } - if got[0].Password != "token-2" { - t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2") + if got[0].Password != "bellagio-pass-2" { + t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2") } if got[0].Notes != "Long-lived token used by Codex for home automation tasks." { @@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) { ID: "entry-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Notes: "Personal git server token entry used for automation and CLI auth.", Tags: []string{"git", "infra"}, @@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) { { ID: "entry-2", Title: "Surveillance Console", - Username: "codex", - Password: "token-2", + Username: "bashertarr", + Password: "bellagio-pass-2", URL: "https://surveillance.crew.example.invalid", Notes: "Long-lived token used by Codex for home automation tasks.", Tags: []string{"automation", "home"}, - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, } @@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) { t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation") } - homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) + homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"}) if len(homeAssistant) != 1 { - t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant)) + t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant)) } - if homeAssistant[0].Password != "token-2" { - t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2") + if homeAssistant[0].Password != "bellagio-pass-2" { + t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2") } } @@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) { } } +func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) { + t.Parallel() + + model := Model{ + RemoteProfiles: []RemoteProfile{ + { + ID: "family-webdav", + Name: "Family Vault", + Backend: RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + + loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX() error = %v", err) + } + + if len(loaded.RemoteProfiles) != 1 { + t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles)) + } + + got := loaded.RemoteProfiles[0] + if got.ID != "family-webdav" || got.Name != "Family Vault" { + t.Fatalf("loaded remote profile = %#v, want family-webdav Family Vault", got) + } + if got.Backend != RemoteBackendWebDAV { + t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV) + } + if got.BaseURL != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL) + } + if got.Path != "files/family/keepass.kdbx" { + t.Fatalf("remote path = %q, want files/family/keepass.kdbx", got.Path) + } +} + func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) { t.Parallel() @@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) { { ID: "entry-1", Title: "Surveillance Console", - Username: "codex", - Password: "token-2", + Username: "bashertarr", + Password: "bellagio-pass-2", URL: "https://surveillance.crew.example.invalid", - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, } @@ -323,8 +367,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) { t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console") } - if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" { - t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path) + if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" { + t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path) } if len(loaded.Entries) != 0 { @@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), + mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))), }, }, }, @@ -379,7 +423,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) { } got := model.Search("vault") - if len(got) != 1 || got[0].Entry.Password != "token-1" { + if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" { t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got) } } @@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))), + mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))), }, }, }, @@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) { t.Fatalf("LoadKDBXWithKey() error = %v", err) } - got := model.EntriesInPath([]string{"Root", "Home Assistant"}) - if len(got) != 1 || got[0].Password != "token-2" { - t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got) + got := model.EntriesInPath([]string{"Root", "Security Office"}) + if len(got) != 1 || got[0].Password != "bellagio-pass-2" { + t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got) } } @@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) { Meta: gokeepasslib.NewMetaData(), Root: &gokeepasslib.RootData{ Groups: []gokeepasslib.Group{ - mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), + mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))), }, }, }, @@ -493,7 +537,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, @@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) { } got := loaded.Search("vault") - if len(got) != 1 || got[0].Entry.Password != "token-1" { + if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" { t.Fatalf("round-trip with key file = %#v, want vault entry with password", got) } } @@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) { { ID: "surveillance-console", Title: "Surveillance Console", - Username: "codex", - Password: "token-2", + Username: "bashertarr", + Password: "bellagio-pass-2", URL: "https://surveillance.crew.example.invalid", - Path: []string{"Root", "Home Assistant"}, + Path: []string{"Root", "Security Office"}, }, }, } @@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) { t.Fatalf("LoadKDBXWithKey() error = %v", err) } - got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) - if len(got) != 1 || got[0].Password != "token-2" { - t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got) + got := loaded.EntriesInPath([]string{"Root", "Security Office"}) + if len(got) != 1 || got[0].Password != "bellagio-pass-2" { + t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got) } } @@ -573,7 +617,7 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, Attachments: map[string][]byte{ @@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) { ID: "entry-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-2", + Password: "bellagio-pass-2", URL: "https://vault.crew.example.invalid", Notes: "Current credential", Path: []string{"Root", "Internet"}, @@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) { ID: "entry-1-history-1", Title: "Vault Console", Username: "dannyocean", - Password: "token-1", + Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Notes: "Original credential", Path: []string{"Root", "Internet"}, diff --git a/vault/model.go b/vault/model.go index e2d12c9..31e171f 100644 --- a/vault/model.go +++ b/vault/model.go @@ -8,6 +8,21 @@ import ( var ErrEntryNotFound = errors.New("entry not found") var ErrGroupNotEmpty = errors.New("group is not empty") +var ErrRemoteProfileNotFound = errors.New("remote profile not found") + +type RemoteBackend string + +const ( + RemoteBackendWebDAV RemoteBackend = "webdav" +) + +type RemoteProfile struct { + ID string + Name string + Backend RemoteBackend + BaseURL string + Path string +} type Entry struct { ID string @@ -29,10 +44,11 @@ type SearchResult struct { } type Model struct { - Entries []Entry - Templates []Entry - RecycleBin []Entry - Groups [][]string + Entries []Entry + Templates []Entry + RecycleBin []Entry + Groups [][]string + RemoteProfiles []RemoteProfile } func (m Model) ChildGroups(path []string) []string { @@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry Entry) { m.Entries = append(m.Entries, cloneEntry(entry)) } +func (m *Model) RemoveEntryByID(id string) bool { + for i := range m.Entries { + if m.Entries[i].ID != id { + continue + } + m.Entries = append(m.Entries[:i], m.Entries[i+1:]...) + return true + } + return false +} + +func (m *Model) EntryByID(id string) (Entry, error) { + for _, entry := range m.Entries { + if entry.ID == id { + return cloneEntry(entry), nil + } + } + return Entry{}, ErrEntryNotFound +} + +func (m *Model) UpsertRemoteProfile(profile RemoteProfile) { + for i := range m.RemoteProfiles { + if m.RemoteProfiles[i].ID != profile.ID { + continue + } + m.RemoteProfiles[i] = profile + return + } + m.RemoteProfiles = append(m.RemoteProfiles, profile) +} + +func (m *Model) RemoveRemoteProfileByID(id string) bool { + for i := range m.RemoteProfiles { + if m.RemoteProfiles[i].ID != id { + continue + } + m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...) + return true + } + return false +} + +func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) { + for _, profile := range m.RemoteProfiles { + if profile.ID == id { + return profile, nil + } + } + return RemoteProfile{}, ErrRemoteProfileNotFound +} + func (m *Model) UpsertTemplate(entry Entry) { for i := range m.Templates { if m.Templates[i].ID != entry.ID {