From 332ab58f5880ae9ec0b6cdfce114efe6421aad32 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 21:47:44 -0700 Subject: [PATCH 01/53] 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 { From 739d918c217383fb99b7258b37ad5aa190ddbc0e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 21:56:29 -0700 Subject: [PATCH 02/53] Add lifecycle remote sync shortcut --- main.go | 66 ++++++++++++++++++++++++- main_test.go | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++ ui_forms.go | 12 +++++ 3 files changed, 213 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 3a6b559..97576fd 100644 --- a/main.go +++ b/main.go @@ -114,6 +114,14 @@ type uiSurface struct { Locked bool } +type lifecycleOpenIntent string + +const ( + lifecycleOpenIntentNone lifecycleOpenIntent = "" + lifecycleOpenIntentRemoteSyncSetup lifecycleOpenIntent = "remote_sync_setup" + lifecycleOpenIntentRemoteSyncSettings lifecycleOpenIntent = "remote_sync_settings" +) + type emptyState struct { Title string Body string @@ -285,6 +293,7 @@ type ui struct { unlockVault widget.Clickable createVault widget.Clickable openVault widget.Clickable + lifecycleRemoteSyncAction widget.Clickable saveVault widget.Clickable saveAsVault widget.Clickable openRemote widget.Clickable @@ -506,6 +515,7 @@ type ui struct { backgroundActionSerial int activeBackgroundAction int lastLifecycleAction string + pendingLifecycleOpenIntent lifecycleOpenIntent requestMasterPassFocus bool invalidate func() } @@ -1070,6 +1080,7 @@ func (u *ui) openVaultAction() error { u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() + u.applyPendingLifecycleOpenIntent() return nil } @@ -1111,11 +1122,49 @@ func (u *ui) startOpenVaultAction() { u.loadSecuritySettingsFromSession() u.editingEntry = false u.filter() + u.applyPendingLifecycleOpenIntent() return nil }, nil }) } +func (u *ui) shouldShowLifecycleRemoteSyncAction() bool { + return strings.TrimSpace(u.vaultPath.Text()) != "" +} + +func (u *ui) lifecycleRemoteSyncActionLabel() string { + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return "Open Vault And Set Up Remote Sync" + } + if hasBoundRecentRemote(u.recentRemotes, path) { + return "Open Vault And Open Remote Sync Settings" + } + return "Open Vault And Set Up Remote Sync" +} + +func (u *ui) beginLifecycleRemoteSyncOpen() { + path := strings.TrimSpace(u.vaultPath.Text()) + switch { + case path == "": + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + case hasBoundRecentRemote(u.recentRemotes, path): + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings + default: + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup + } + u.startOpenVaultAction() +} + +func (u *ui) applyPendingLifecycleOpenIntent() { + intent := u.pendingLifecycleOpenIntent + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + switch intent { + case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings: + u.openRemoteSyncSetupDialog() + } +} + func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err @@ -2568,8 +2617,17 @@ func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bo if path == "" { return recentRemoteRecord{}, false } + return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path) +} + +func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool { + _, ok := boundRecentRemoteForLocalVaultRecords(records, strings.TrimSpace(path)) + return ok +} + +func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) { var matches []recentRemoteRecord - for _, record := range u.recentRemotes { + for _, record := range records { if strings.TrimSpace(record.LocalVaultPath) != path { continue } @@ -3982,6 +4040,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.openVault.Clicked(gtx) { u.startOpenVaultAction() } + for u.lifecycleRemoteSyncAction.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.beginLifecycleRemoteSyncOpen() + } for u.saveVault.Clicked(gtx) { u.runAction("save vault", u.saveAction) } diff --git a/main_test.go b/main_test.go index 0c95931..3137b5f 100644 --- a/main_test.go +++ b/main_test.go @@ -5988,6 +5988,45 @@ func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) { } } +func TestUILifecycleRemoteSyncActionLabelUsesSetupLanguageWithoutSavedBinding(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.vaultPath.SetText("/vaults/family.kdbx") + + if !u.shouldShowLifecycleRemoteSyncAction() { + t.Fatal("shouldShowLifecycleRemoteSyncAction() = false, want true with a selected vault") + } + if got := u.lifecycleRemoteSyncActionLabel(); got != "Open Vault And Set Up Remote Sync" { + t.Fatalf("lifecycleRemoteSyncActionLabel() = %q, want setup label", got) + } +} + +func TestUILifecycleRemoteSyncActionLabelUsesSettingsLanguageWithSavedBinding(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.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), + }} + + if got := u.lifecycleRemoteSyncActionLabel(); got != "Open Vault And Open Remote Sync Settings" { + t.Fatalf("lifecycleRemoteSyncActionLabel() = %q, want settings label", got) + } +} + func TestUIShouldShowRemoteSyncSettingsShortcutForSavedBinding(t *testing.T) { t.Parallel() @@ -6110,6 +6149,103 @@ func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) } } +func TestUILifecycleRemoteSyncActionOpensSetupAfterVaultOpen(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + writeKDBXMainTestFile(t, path, 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"}, + }}, + }, key) + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(path) + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + if !u.syncDialogOpen { + t.Fatal("syncDialogOpen = false, want remote sync setup dialog") + } + if got := u.syncDialogTitle(); got != "Set Up Remote Sync" { + t.Fatalf("syncDialogTitle() = %q, want Set Up Remote Sync", got) + } +} + +func TestUILifecycleRemoteSyncActionOpensSettingsAfterVaultOpen(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + path := filepath.Join(t.TempDir(), "family.kdbx") + writeKDBXMainTestFile(t, path, 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"}, + }, + { + ID: "remote-creds-1", + Title: "Bellagio WebDAV Sign-In", + Username: "linuscaldwell", + Password: "bellagio-pass-1", + URL: "https://dav.example.invalid/remote.php/dav", + 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(path) + 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.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings + + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + if !u.syncDialogOpen { + t.Fatal("syncDialogOpen = false, want remote sync settings dialog") + } + if got := u.syncDialogTitle(); got != "Remote Sync Settings" { + t.Fatalf("syncDialogTitle() = %q, want Remote Sync Settings", got) + } +} + func TestUISelectedLocalVaultRemoteSyncSummaryMentionsSetup(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 20f1264..57438c1 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -177,6 +177,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return tonedButton(gtx, u.theme, &u.openVault, label) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel()) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?") From cb6fbd05a33ddcc5ca1024009b441d64df79b568 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 22:00:10 -0700 Subject: [PATCH 03/53] Retheme remote sync test fixtures --- appstate/remote_binding_test.go | 72 +++---- appstate/state_test.go | 54 ++--- main_test.go | 372 ++++++++++++++++---------------- vault/kdbx_test.go | 14 +- 4 files changed, 256 insertions(+), 256 deletions(-) diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go index 36009f2..8a25ba6 100644 --- a/appstate/remote_binding_test.go +++ b/appstate/remote_binding_test.go @@ -24,18 +24,18 @@ func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) { }, RemoteProfiles: []vault.RemoteProfile{ { - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }, }, } binding := RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "linuscaldwell-webdav", SyncMode: SyncModeAutomaticOnOpenSave, } @@ -47,8 +47,8 @@ func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) { 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.Profile.Path; got != "files/bellagio/keepass.kdbx" { + t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got) } if got := resolved.Credentials.Username; got != "linuscaldwell" { t.Fatalf("resolved credentials username = %q, want linuscaldwell", got) @@ -68,8 +68,8 @@ func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) { } _, err := (RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "missing-creds", }).Resolve(model) if !errors.Is(err, vault.ErrRemoteProfileNotFound) { @@ -77,16 +77,16 @@ func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) { } model.RemoteProfiles = []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }} _, err = (RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "missing-creds", }).Resolve(model) if !errors.Is(err, vault.ErrEntryNotFound) { @@ -98,8 +98,8 @@ func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) { t.Parallel() content, err := json.Marshal(RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: SyncModeAutomaticOnOpenSave, }) @@ -121,11 +121,11 @@ func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) var model vault.Model binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", - RemoteProfileName: "Family Vault", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", + RemoteProfileName: "Bellagio Vault", BaseURL: "https://dav.example.invalid/remote.php/dav", - RemotePath: "files/family/keepass.kdbx", + RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", CredentialTitle: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", @@ -155,10 +155,10 @@ func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) 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.LocalVaultPath != "/tmp/bellagio.kdbx" { + t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath) } - if binding.RemoteProfileID != "family-webdav" || binding.CredentialEntryID != "remote-creds-1" { + if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" { t.Fatalf("binding = %#v, want only vault references", binding) } } @@ -173,9 +173,9 @@ func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) { { name: "missing_local_vault_path", input: RemoteBindingInput{ - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", BaseURL: "https://dav.example.invalid/remote.php/dav", - RemotePath: "files/family/keepass.kdbx", + RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", Password: "bellagio-pass-1", }, @@ -183,9 +183,9 @@ func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) { { name: "missing_remote_base_url", input: RemoteBindingInput{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", - RemotePath: "files/family/keepass.kdbx", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", + RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", Password: "bellagio-pass-1", }, @@ -193,10 +193,10 @@ func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) { { name: "missing_credential_entry_id", input: RemoteBindingInput{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", BaseURL: "https://dav.example.invalid/remote.php/dav", - RemotePath: "files/family/keepass.kdbx", + RemotePath: "files/bellagio/keepass.kdbx", Password: "bellagio-pass-1", }, }, @@ -224,17 +224,17 @@ func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) Password: "bellagio-pass-1", }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } err := RemoveRemoteBinding(&model, RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }) if err != nil { diff --git a/appstate/state_test.go b/appstate/state_test.go index 3bf8e1c..e57bbcd 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -172,18 +172,18 @@ func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) { model: vault.Model{ RemoteProfiles: []vault.RemoteProfile{ { - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }, { ID: "archive-webdav", Name: "Archive Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/archive.kdbx", + Path: "files/bellagio/archive.kdbx", }, }, }, @@ -197,7 +197,7 @@ func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) { if len(got) != 2 { t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got)) } - if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" { + if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" { t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got) } } @@ -1045,11 +1045,11 @@ func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) { }, RemoteProfiles: []vault.RemoteProfile{ { - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }, }, }, @@ -1062,8 +1062,8 @@ func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) { } err := state.OpenBoundRemoteVault(RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: SyncModeAutomaticOnOpenSave, }, vault.MasterKey{Password: "correct horse battery staple"}) @@ -1080,8 +1080,8 @@ func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) { 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 got := sess.remotePath; got != "files/bellagio/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got) } if len(state.CurrentPath) != 0 { t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath) @@ -1101,7 +1101,7 @@ func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) { state := State{Session: sess} err := state.OpenBoundRemoteVault(RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", + LocalVaultPath: "/tmp/bellagio.kdbx", RemoteProfileID: "missing-profile", CredentialEntryID: "remote-creds-1", }, vault.MasterKey{Password: "correct horse battery staple"}) @@ -1117,11 +1117,11 @@ func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing. state := State{Session: sess} binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", - RemoteProfileName: "Family Vault", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", + RemoteProfileName: "Bellagio Vault", BaseURL: "https://dav.example.invalid/remote.php/dav", - RemotePath: "files/family/keepass.kdbx", + RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", CredentialTitle: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", @@ -1136,8 +1136,8 @@ func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing. 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 := binding.RemoteProfileID; got != "bellagio-webdav" { + t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got) } if got := len(sess.model.RemoteProfiles); got != 1 { t.Fatalf("len(RemoteProfiles) = %d, want 1", got) @@ -1156,10 +1156,10 @@ func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) { state := State{Session: stubSession{model: vault.Model{}}} _, err := state.ConfigureRemoteBinding(RemoteBindingInput{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", BaseURL: "https://dav.example.invalid/remote.php/dav", - RemotePath: "files/family/keepass.kdbx", + RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", Password: "bellagio-pass-1", }) @@ -1179,18 +1179,18 @@ func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) { Password: "bellagio-pass-1", }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }} state := State{Session: sess} err := state.RemoveRemoteBinding(RemoteBinding{ - LocalVaultPath: "/tmp/family.kdbx", - RemoteProfileID: "family-webdav", + LocalVaultPath: "/tmp/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }) if err != nil { diff --git a/main_test.go b/main_test.go index 3137b5f..117a648 100644 --- a/main_test.go +++ b/main_test.go @@ -222,13 +222,13 @@ func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() u := newUIWithSession("phone", summarySession{hasVault: true}) - u.vaultPath.SetText("/vaults/family.kdbx") - u.recentVaultGroups["/vaults/family.kdbx"] = []string{"Root", "Internet"} + u.vaultPath.SetText("/vaults/bellagio.kdbx") + u.recentVaultGroups["/vaults/bellagio.kdbx"] = []string{"Root", "Internet"} got := u.currentVaultSummary() want := vaultSummary{ - Title: "family.kdbx", - Detail: "/vaults/family.kdbx", + Title: "bellagio.kdbx", + Detail: "/vaults/bellagio.kdbx", Context: "Resume in: Root / Internet", } if got != want { @@ -1772,18 +1772,18 @@ func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }, } u := newUIWithSession("desktop", sess) u.masterPassword.SetText("correct horse battery staple") - u.selectedVaultRemoteProfileID = "family-webdav" + u.selectedVaultRemoteProfileID = "bellagio-webdav" u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" if err := u.openRemoteAction(); err != nil { @@ -1799,13 +1799,13 @@ func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { 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 := sess.remotePath; got != "files/bellagio/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/bellagio/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" { + if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath editor = %q, want resolved profile path", got) } } @@ -1823,18 +1823,18 @@ func TestUIOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }, } u := newUIWithSession("desktop", sess) u.masterPassword.SetText("correct horse battery staple") - u.vaultPath.SetText("/vaults/family.kdbx") + u.vaultPath.SetText("/vaults/bellagio.kdbx") if err := u.openRemoteAction(); err != nil { t.Fatalf("openRemoteAction() error = %v", err) @@ -1849,8 +1849,8 @@ func TestUIOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { 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 := sess.remotePath; got != "files/bellagio/keepass.kdbx" { + t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got) } } @@ -1858,7 +1858,7 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - localPath := filepath.Join(t.TempDir(), "family.kdbx") + localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") remoteModel := vault.Model{ Entries: []vault.Entry{{ @@ -1890,10 +1890,10 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { localModel := vault.Model{} if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ LocalVaultPath: localPath, - RemoteProfileID: "family-webdav", - RemoteProfileName: "family.kdbx · dav.example.invalid", + RemoteProfileID: "bellagio-webdav", + RemoteProfileName: "bellagio.kdbx · dav.example.invalid", BaseURL: server.URL, - RemotePath: "files/family/keepass.kdbx", + RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", Username: "linuscaldwell", @@ -1909,9 +1909,9 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { u.masterPassword.SetText(key.Password) u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: server.URL, - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: localPath, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }) @@ -1935,7 +1935,7 @@ func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { t.Parallel() localKey := vault.MasterKey{Password: "correct horse battery staple"} - localPath := filepath.Join(t.TempDir(), "family.kdbx") + localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", @@ -1945,11 +1945,11 @@ func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } @@ -1987,7 +1987,7 @@ func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { u := newUIWithSession("desktop", manager) u.masterPassword.SetText(localKey.Password) u.vaultPath.SetText(localPath) - u.selectedVaultRemoteProfileID = "family-webdav" + u.selectedVaultRemoteProfileID = "bellagio-webdav" u.selectedVaultRemoteCredentialEntryID = "remote-creds-1" u.startOpenRemoteAction() @@ -2001,7 +2001,7 @@ func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) { 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" { + if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want selected profile path", got) } } @@ -2010,7 +2010,7 @@ func TestUIStartOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { t.Parallel() localKey := vault.MasterKey{Password: "correct horse battery staple"} - localPath := filepath.Join(t.TempDir(), "family.kdbx") + localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", @@ -2020,11 +2020,11 @@ func TestUIStartOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } @@ -2074,7 +2074,7 @@ func TestUIStartOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) { 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" { + if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want implicit profile path", got) } } @@ -2083,7 +2083,7 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - localPath := filepath.Join(t.TempDir(), "family.kdbx") + localPath := filepath.Join(t.TempDir(), "bellagio.kdbx") remoteModel := vault.Model{ Entries: []vault.Entry{{ @@ -2115,10 +2115,10 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { localModel := vault.Model{} if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{ LocalVaultPath: localPath, - RemoteProfileID: "family-webdav", - RemoteProfileName: "family.kdbx · dav.example.invalid", + RemoteProfileID: "bellagio-webdav", + RemoteProfileName: "bellagio.kdbx · dav.example.invalid", BaseURL: server.URL, - RemotePath: "files/family/keepass.kdbx", + RemotePath: "files/bellagio/keepass.kdbx", CredentialEntryID: "remote-creds-1", CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell", Username: "linuscaldwell", @@ -2135,9 +2135,9 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) { u.masterPassword.SetText(key.Password) u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: server.URL, - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: localPath, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }) @@ -2169,7 +2169,7 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - path := filepath.Join(t.TempDir(), "family.kdbx") + path := filepath.Join(t.TempDir(), "bellagio.kdbx") model := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", @@ -2179,11 +2179,11 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, model, key) @@ -2196,9 +2196,9 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} @@ -2213,8 +2213,8 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { t.Fatalf("openVaultAction() error = %v", err) } - if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { - t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + if got := u.selectedVaultRemoteProfileID; got != "bellagio-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want bellagio-webdav", got) } if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) @@ -2222,7 +2222,7 @@ func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { 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" { + if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want resolved profile path", got) } if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual { @@ -2234,7 +2234,7 @@ func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - path := filepath.Join(t.TempDir(), "family.kdbx") + path := filepath.Join(t.TempDir(), "bellagio.kdbx") model := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", @@ -2244,11 +2244,11 @@ func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, model, key) @@ -2274,8 +2274,8 @@ func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { 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.selectedVaultRemoteProfileID; got != "bellagio-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want bellagio-webdav", got) } if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" { t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got) @@ -2283,7 +2283,7 @@ func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) { 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" { + if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want resolved profile path", got) } } @@ -2292,7 +2292,7 @@ func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing. t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - path := filepath.Join(t.TempDir(), "family.kdbx") + path := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", @@ -2302,11 +2302,11 @@ func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing. Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://stale.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, localModel, key) @@ -2341,9 +2341,9 @@ func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing. }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: server.URL, - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} @@ -2370,18 +2370,18 @@ func TestUIOpenVaultActionKeepsLocalVaultOpenWhenAutoSyncFails(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - path := filepath.Join(t.TempDir(), "family.kdbx") + path := filepath.Join(t.TempDir(), "bellagio.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", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://unreachable.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, localModel, key) @@ -2394,9 +2394,9 @@ func TestUIOpenVaultActionKeepsLocalVaultOpenWhenAutoSyncFails(t *testing.T) { }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://unreachable.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} @@ -2426,7 +2426,7 @@ func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - path := filepath.Join(t.TempDir(), "family.kdbx") + path := filepath.Join(t.TempDir(), "bellagio.kdbx") localModel := vault.Model{ Entries: []vault.Entry{{ ID: "remote-creds-1", @@ -2436,11 +2436,11 @@ func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://stale.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, } writeKDBXMainTestFile(t, path, localModel, key) @@ -2487,9 +2487,9 @@ func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) { }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: server.URL, - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} @@ -2523,7 +2523,7 @@ func TestPickExistingFileOutputExtractsPathFromPortalNoise(t *testing.T) { output := strings.Join([]string{ "(zenity:1): Gdk-DEBUG: Ignoring portal setting", - "/home/tester/vaults/family.kdbx", + "/home/tester/vaults/bellagio.kdbx", "", }, "\n") @@ -2531,8 +2531,8 @@ func TestPickExistingFileOutputExtractsPathFromPortalNoise(t *testing.T) { 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) + if got != "/home/tester/vaults/bellagio.kdbx" { + t.Fatalf("parsePickedFilePath() = %q, want /home/tester/vaults/bellagio.kdbx", got) } } @@ -4164,9 +4164,9 @@ func TestUIBannerActionLabelsExposeCancelAndRetryForLifecycleOpen(t *testing.T) func TestCompactPathDirectorySummaryCollapsesLongPaths(t *testing.T) { t.Parallel() - got := compactPathDirectorySummary("/home/julian/vaults/family/main.kdbx") - if got != "home/.../family" { - t.Fatalf("compactPathDirectorySummary() = %q, want %q", got, "home/.../family") + got := compactPathDirectorySummary("/home/julian/vaults/bellagio/main.kdbx") + if got != "home/.../bellagio" { + t.Fatalf("compactPathDirectorySummary() = %q, want %q", got, "home/.../bellagio") } short := compactPathDirectorySummary("/tmp/main.kdbx") @@ -4772,7 +4772,7 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) { first.recentRemotesPath = configPath first.recentRemotes = nil first.currentPath = []string{"Root", "Internet"} - first.vaultPath.SetText("/vaults/family.kdbx") + first.vaultPath.SetText("/vaults/bellagio.kdbx") first.selectedVaultRemoteProfileID = "remote-profile-1" first.selectedVaultRemoteCredentialEntryID = "remote-creds-1" first.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave @@ -4787,8 +4787,8 @@ func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) { 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.LocalVaultPath != "/vaults/bellagio.kdbx" { + t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want /vaults/bellagio.kdbx", record.LocalVaultPath) } if record.RemoteProfileID != "remote-profile-1" { t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want remote-profile-1", record.RemoteProfileID) @@ -4877,14 +4877,14 @@ func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) { u.applyRecentRemoteRecord(recentRemoteRecord{ BaseURL: "https://dav.example.com", Path: "vaults/home.kdbx", - LocalVaultPath: "/vaults/family.kdbx", + LocalVaultPath: "/vaults/bellagio.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.vaultPath.Text(); got != "/vaults/bellagio.kdbx" { + t.Fatalf("vaultPath = %q, want /vaults/bellagio.kdbx", got) } if got := u.selectedVaultRemoteProfileID; got != "remote-profile-1" { t.Fatalf("selectedVaultRemoteProfileID = %q, want remote-profile-1", got) @@ -5429,8 +5429,8 @@ func TestRestoreStartupLifecycleTargetUsesLocalCacheFromRecentRemote(t *testing. 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", + Path: "files/bellagio/keepass.kdbx", + LocalVaultPath: "/tmp/bellagio-cache.kdbx", UsedAt: time.Date(2026, time.April, 5, 2, 2, 3, 0, time.UTC).Format(time.RFC3339Nano), }} @@ -5439,8 +5439,8 @@ func TestRestoreStartupLifecycleTargetUsesLocalCacheFromRecentRemote(t *testing. 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) + if got := u.vaultPath.Text(); got != "/tmp/bellagio-cache.kdbx" { + t.Fatalf("vaultPath after restore = %q, want /tmp/bellagio-cache.kdbx", got) } } @@ -5532,18 +5532,18 @@ func TestUIAvailableRemoteProfilesUsesVaultProfiles(t *testing.T) { u := newUIWithModel("desktop", vault.Model{ RemoteProfiles: []vault.RemoteProfile{ { - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }, { ID: "archive-webdav", Name: "Archive Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/archive.kdbx", + Path: "files/bellagio/archive.kdbx", }, }, }) @@ -5552,7 +5552,7 @@ func TestUIAvailableRemoteProfilesUsesVaultProfiles(t *testing.T) { if len(got) != 2 { t.Fatalf("len(availableRemoteProfiles()) = %d, want 2", len(got)) } - if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" { + if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" { t.Fatalf("availableRemoteProfiles() = %#v, want profiles sorted by name/id", got) } } @@ -5590,23 +5590,23 @@ func TestUISelectVaultRemoteProfileUpdatesSelectionAndTargetFields(t *testing.T) u := newUIWithModel("desktop", vault.Model{ RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) - u.selectVaultRemoteProfile("family-webdav") + u.selectVaultRemoteProfile("bellagio-webdav") - if got := u.selectedVaultRemoteProfileID; got != "family-webdav" { - t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got) + if got := u.selectedVaultRemoteProfileID; got != "bellagio-webdav" { + t.Fatalf("selectedVaultRemoteProfileID = %q, want bellagio-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" { + if got := u.remotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("remotePath = %q, want resolved profile path", got) } } @@ -5660,11 +5660,11 @@ func TestUIShouldHideSavedRemoteBindingSelectorsForSingleChoice(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) @@ -5684,11 +5684,11 @@ func TestUISavedRemoteBindingSummaryUsesImplicitSingleChoice(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) @@ -5696,8 +5696,8 @@ func TestUISavedRemoteBindingSummaryUsesImplicitSingleChoice(t *testing.T) { if !ok { t.Fatal("savedRemoteBindingSummary() ok = false, want true") } - if profileLabel != "Family Vault" { - t.Fatalf("profileLabel = %q, want Family Vault", profileLabel) + if profileLabel != "Bellagio Vault" { + t.Fatalf("profileLabel = %q, want Bellagio Vault", profileLabel) } if credentialLabel != "Bellagio WebDAV Sign-In · linuscaldwell" { t.Fatalf("credentialLabel = %q, want Bellagio WebDAV Sign-In · linuscaldwell", credentialLabel) @@ -5718,11 +5718,11 @@ func TestUISavedRemoteBindingSummaryMentionsAutomaticSyncMode(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) u.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave @@ -5747,11 +5747,11 @@ func TestUISavedRemoteBindingHeadingUsesSyncLanguageForSingleChoice(t *testing.T Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) @@ -5771,11 +5771,11 @@ func TestUIOpenSelectedVaultRemoteButtonLabelUsesSyncLanguageForSingleChoice(t * Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) @@ -5814,11 +5814,11 @@ func TestUIShouldShowDirectRemoteSyncShortcutForSavedBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries @@ -5844,11 +5844,11 @@ func TestUIRemoteSyncShortcutsHaveParityAcrossModes(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries @@ -5964,11 +5964,11 @@ func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries @@ -5992,7 +5992,7 @@ func TestUILifecycleRemoteSyncActionLabelUsesSetupLanguageWithoutSavedBinding(t t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) - u.vaultPath.SetText("/vaults/family.kdbx") + u.vaultPath.SetText("/vaults/bellagio.kdbx") if !u.shouldShowLifecycleRemoteSyncAction() { t.Fatal("shouldShowLifecycleRemoteSyncAction() = false, want true with a selected vault") @@ -6012,12 +6012,12 @@ func TestUILifecycleRemoteSyncActionLabelUsesSettingsLanguageWithSavedBinding(t RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), }) - u.vaultPath.SetText("/vaults/family.kdbx") + u.vaultPath.SetText("/vaults/bellagio.kdbx") u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", - LocalVaultPath: "/vaults/family.kdbx", - RemoteProfileID: "family-webdav", + Path: "files/bellagio/keepass.kdbx", + LocalVaultPath: "/vaults/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} @@ -6038,11 +6038,11 @@ func TestUIShouldShowRemoteSyncSettingsShortcutForSavedBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries @@ -6073,11 +6073,11 @@ func TestUIShouldShowRemoveRemoteSyncShortcutForSavedBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries @@ -6109,14 +6109,14 @@ func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) - u.vaultPath.SetText("/vaults/family.kdbx") + u.vaultPath.SetText("/vaults/bellagio.kdbx") u.openRemoteSyncSetupDialog() @@ -6132,13 +6132,13 @@ func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) if got := u.syncDirection; got != syncDirectionPush { t.Fatalf("syncDirection = %q, want push", got) } - if got := u.syncLocalPath.Text(); got != "/vaults/family.kdbx" { + if got := u.syncLocalPath.Text(); got != "/vaults/bellagio.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" { + if got := u.syncRemotePath.Text(); got != "files/bellagio/keepass.kdbx" { t.Fatalf("syncRemotePath = %q, want saved remote path", got) } if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" { @@ -6153,7 +6153,7 @@ func TestUILifecycleRemoteSyncActionOpensSetupAfterVaultOpen(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - path := filepath.Join(t.TempDir(), "family.kdbx") + path := filepath.Join(t.TempDir(), "bellagio.kdbx") writeKDBXMainTestFile(t, path, vault.Model{ Entries: []vault.Entry{{ ID: "vault-console", @@ -6186,7 +6186,7 @@ func TestUILifecycleRemoteSyncActionOpensSettingsAfterVaultOpen(t *testing.T) { t.Parallel() key := vault.MasterKey{Password: "correct horse battery staple"} - path := filepath.Join(t.TempDir(), "family.kdbx") + path := filepath.Join(t.TempDir(), "bellagio.kdbx") writeKDBXMainTestFile(t, path, vault.Model{ Entries: []vault.Entry{ { @@ -6207,11 +6207,11 @@ func TestUILifecycleRemoteSyncActionOpensSettingsAfterVaultOpen(t *testing.T) { }, }, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }, key) @@ -6226,9 +6226,9 @@ func TestUILifecycleRemoteSyncActionOpensSettingsAfterVaultOpen(t *testing.T) { u.vaultPath.SetText(path) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: path, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} @@ -6257,7 +6257,7 @@ func TestUISelectedLocalVaultRemoteSyncSummaryMentionsSetup(t *testing.T) { 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." { + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/bellagio.kdbx"); got != "Open this vault to set up a WebDAV sync target for it." { t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want setup guidance", got) } } @@ -6274,14 +6274,14 @@ func TestUISelectedLocalVaultRemoteSyncSummaryMentionsAutomaticSync(t *testing.T }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", - LocalVaultPath: "/vaults/family.kdbx", - RemoteProfileID: "family-webdav", + Path: "files/bellagio/keepass.kdbx", + LocalVaultPath: "/vaults/bellagio.kdbx", + RemoteProfileID: "bellagio-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." { + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/bellagio.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) } } @@ -6298,14 +6298,14 @@ func TestUISelectedLocalVaultRemoteSyncSummaryMentionsManualSync(t *testing.T) { }) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", - LocalVaultPath: "/vaults/family.kdbx", - RemoteProfileID: "family-webdav", + Path: "files/bellagio/keepass.kdbx", + LocalVaultPath: "/vaults/bellagio.kdbx", + RemoteProfileID: "bellagio-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." { + if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/bellagio.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) } } @@ -6352,20 +6352,20 @@ func TestUISyncDialogUsesRemoteSettingsCopyForExistingBinding(t *testing.T) { Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }) u.state.Section = appstate.SectionEntries - u.vaultPath.SetText("/vaults/family.kdbx") + u.vaultPath.SetText("/vaults/bellagio.kdbx") u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", - LocalVaultPath: "/vaults/family.kdbx", - RemoteProfileID: "family-webdav", + Path: "files/bellagio/keepass.kdbx", + LocalVaultPath: "/vaults/bellagio.kdbx", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeManual), }} @@ -6584,11 +6584,11 @@ func TestUIRemoveSelectedRemoteBindingActionClearsVaultBindingAndRecentRefs(t *t Path: []string{"Crew", "Internet"}, }}, RemoteProfiles: []vault.RemoteProfile{{ - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }, key) @@ -6603,9 +6603,9 @@ func TestUIRemoveSelectedRemoteBindingActionClearsVaultBindingAndRecentRefs(t *t u.vaultPath.SetText(currentPath) u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: currentPath, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", SyncMode: string(appstate.SyncModeAutomaticOnOpenSave), }} @@ -6664,9 +6664,9 @@ func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) u := newUIWithModel("desktop", vault.Model{}) u.currentPath = []string{"Crew", "Internet"} - u.vaultPath.SetText("/tmp/family.kdbx") + u.vaultPath.SetText("/tmp/bellagio.kdbx") u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") - u.remotePath.SetText("files/family/keepass.kdbx") + u.remotePath.SetText("files/bellagio/keepass.kdbx") u.remoteUsername.SetText("linuscaldwell") u.remotePassword.SetText("bellagio-pass-1") @@ -6735,18 +6735,18 @@ func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesBaseURL(t *testing.T) func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) { t.Parallel() - localVaultPath := filepath.Join(t.TempDir(), "family.kdbx") + localVaultPath := filepath.Join(t.TempDir(), "bellagio.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", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }}, }}, statePaths{ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"), @@ -6757,12 +6757,12 @@ func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrent 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.syncRemotePath.SetText("files/bellagio/keepass.kdbx") u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", LocalVaultPath: localVaultPath, - RemoteProfileID: "family-webdav", + RemoteProfileID: "bellagio-webdav", CredentialEntryID: "remote-creds-1", }} @@ -6804,9 +6804,9 @@ func TestUISaveCurrentRemoteBindingActionRequiresCompleteRemoteSignIn(t *testing t.Parallel() u := newUIWithModel("desktop", vault.Model{}) - u.vaultPath.SetText("/tmp/family.kdbx") + u.vaultPath.SetText("/tmp/bellagio.kdbx") u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") - u.remotePath.SetText("files/family/keepass.kdbx") + u.remotePath.SetText("files/bellagio/keepass.kdbx") if err := u.saveCurrentRemoteBindingAction(); err == nil { t.Fatal("saveCurrentRemoteBindingAction() error = nil, want validation error") @@ -6982,9 +6982,9 @@ func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) { got := friendlyRecentRemoteLabel(recentRemoteRecord{ BaseURL: "https://dav.example.com/remote.php/webdav/", - Path: "vaults/family/home.kdbx", + Path: "vaults/bellagio/mint.kdbx", }) - want := "home.kdbx · dav.example.com" + want := "mint.kdbx · dav.example.com" if got != want { t.Fatalf("friendlyRecentRemoteLabel() = %q, want %q", got, want) } diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go index 371edbf..5a41a89 100644 --- a/vault/kdbx_test.go +++ b/vault/kdbx_test.go @@ -244,11 +244,11 @@ func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) { model := Model{ RemoteProfiles: []RemoteProfile{ { - ID: "family-webdav", - Name: "Family Vault", + ID: "bellagio-webdav", + Name: "Bellagio Vault", Backend: RemoteBackendWebDAV, BaseURL: "https://dav.example.invalid/remote.php/dav", - Path: "files/family/keepass.kdbx", + Path: "files/bellagio/keepass.kdbx", }, }, } @@ -268,8 +268,8 @@ func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) { } 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.ID != "bellagio-webdav" || got.Name != "Bellagio Vault" { + t.Fatalf("loaded remote profile = %#v, want bellagio-webdav Bellagio Vault", got) } if got.Backend != RemoteBackendWebDAV { t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV) @@ -277,8 +277,8 @@ func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) { 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) + if got.Path != "files/bellagio/keepass.kdbx" { + t.Fatalf("remote path = %q, want files/bellagio/keepass.kdbx", got.Path) } } From 43ef58936bec9f201c564e0592065f6fbe3c4d7d Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 22:22:18 -0700 Subject: [PATCH 04/53] Match remote sync credentials by host --- main.go | 33 +++++++++++++++++++++++++++++++-- main_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 97576fd..6040394 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "image" "image/color" "io" + "net/url" "os" "os/exec" "path/filepath" @@ -2319,6 +2320,34 @@ func normalizeRemoteCredentialURL(raw string) string { return raw } +func remoteCredentialURLMatches(candidate, target string) bool { + candidate = normalizeRemoteCredentialURL(candidate) + target = normalizeRemoteCredentialURL(target) + if candidate == "" || target == "" { + return false + } + if candidate == target { + return true + } + candidateURL, err := url.Parse(candidate) + if err != nil { + return false + } + targetURL, err := url.Parse(target) + if err != nil { + return false + } + if !strings.EqualFold(candidateURL.Hostname(), targetURL.Hostname()) { + return false + } + candidatePath := strings.TrimRight(candidateURL.EscapedPath(), "/") + targetPath := strings.TrimRight(targetURL.EscapedPath(), "/") + if candidatePath == "" || candidatePath == "/" || targetPath == "" || targetPath == "/" { + return true + } + return strings.HasPrefix(targetPath, candidatePath) || strings.HasPrefix(candidatePath, targetPath) +} + func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote { return nil @@ -2346,7 +2375,7 @@ func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { matches = append(matches, entry) } for _, entry := range entries { - if normalizeRemoteCredentialURL(entry.URL) != baseURL { + if !remoteCredentialURLMatches(entry.URL, baseURL) { continue } appendMatch(entry) @@ -2364,7 +2393,7 @@ func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { if !ok { continue } - if normalizeRemoteCredentialURL(profile.BaseURL) != baseURL { + if !remoteCredentialURLMatches(profile.BaseURL, baseURL) { continue } if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath { diff --git a/main_test.go b/main_test.go index 117a648..b81e564 100644 --- a/main_test.go +++ b/main_test.go @@ -6732,6 +6732,55 @@ func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesBaseURL(t *testing.T) } } +func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesMatchingHost(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Mint WebDAV", Username: "charliecroker", URL: "https://dav.example.invalid", Path: []string{"Crew", "Signals"}}, + {ID: "entry-2", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Signals"}}, + {ID: "entry-3", Title: "Bank Console", Username: "stevefrezelli", URL: "https://insidejob.example.invalid", Path: []string{"Crew", "Signals"}}, + }, + }) + 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)) + } + gotIDs := []string{got[0].ID, got[1].ID} + slices.Sort(gotIDs) + if !slices.Equal(gotIDs, []string{"entry-1", "entry-2"}) { + t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() ids = %v, want [entry-1 entry-2]", gotIDs) + } +} + +func TestUIRemoteSyncSetupMatchingCredentialsUsesMatchingHost(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Mint WebDAV", Username: "charliecroker", URL: "https://dav.example.invalid", Path: []string{"Crew", "Signals"}}, + {ID: "entry-2", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Signals"}}, + }, + }) + u.syncDialogPurpose = syncDialogPurposeRemoteSetup + u.syncSourceMode = syncSourceRemote + u.syncDirection = syncDirectionPush + u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav") + + got := u.matchingAdvancedSyncRemoteCredentialEntries() + if len(got) != 2 { + t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2 in remote setup flow", len(got)) + } + gotIDs := []string{got[0].ID, got[1].ID} + slices.Sort(gotIDs) + if !slices.Equal(gotIDs, []string{"entry-1", "entry-2"}) { + t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() ids = %v, want [entry-1 entry-2] in remote setup flow", gotIDs) + } +} + func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) { t.Parallel() From 7868a77c8a48ad33dc6998c58688e2c657ef47a6 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 22:28:14 -0700 Subject: [PATCH 05/53] Reset remote sync dialog scroll state --- main.go | 8 +++++++- main_test.go | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 6040394..827793b 100644 --- a/main.go +++ b/main.go @@ -282,6 +282,7 @@ type ui struct { detailList widget.List apiPolicyList widget.List lifecycleList widget.List + syncDialogList widget.List phonePanelList widget.List securityDialogList widget.List remotePrefsDialogList widget.List @@ -629,6 +630,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, + syncDialogList: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, phonePanelList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, @@ -1422,6 +1426,7 @@ func (u *ui) openAdvancedSyncDialog() { u.syncDialogOpen = true u.syncMenuOpen = false u.showSyncPassword = false + u.syncDialogList.Position = layout.Position{} u.syncDialogPurpose = syncDialogPurposeAdvanced u.syncSourceMode = u.syncDefaultSourceMode u.syncDirection = u.syncDefaultDirection @@ -1436,6 +1441,7 @@ func (u *ui) openRemoteSyncSetupDialog() { u.syncDialogOpen = true u.syncMenuOpen = false u.showSyncPassword = false + u.syncDialogList.Position = layout.Position{} u.syncDialogPurpose = syncDialogPurposeRemoteSetup u.syncSourceMode = syncSourceRemote u.syncDirection = syncDirectionPush @@ -5181,7 +5187,7 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { 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 material.List(u.theme, &u.syncDialogList).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), u.syncDialogTitle()) diff --git a/main_test.go b/main_test.go index b81e564..50f7aae 100644 --- a/main_test.go +++ b/main_test.go @@ -6781,6 +6781,19 @@ func TestUIRemoteSyncSetupMatchingCredentialsUsesMatchingHost(t *testing.T) { } } +func TestUIOpenRemoteSyncSetupDialogResetsDialogScrollPosition(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.syncDialogList.Position = layout.Position{First: 2, Offset: 48, BeforeEnd: true} + + u.openRemoteSyncSetupDialog() + + if got := u.syncDialogList.Position; got != (layout.Position{}) { + t.Fatalf("syncDialogList.Position = %#v, want zero position after opening setup dialog", got) + } +} + func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) { t.Parallel() From 5d435f1f1ff06595c79cde623f459c6519924d5a Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 22:30:05 -0700 Subject: [PATCH 06/53] Move remote sync actions into sync menu --- main.go | 87 ++++++++++++++++++++++++++++++---------------------- main_test.go | 49 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 37 deletions(-) diff --git a/main.go b/main.go index 827793b..bed5e88 100644 --- a/main.go +++ b/main.go @@ -2764,6 +2764,23 @@ func (u *ui) remoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" } +func (u *ui) syncMenuActionLabels() []string { + labels := []string{"Open Advanced Sync"} + if u.shouldShowRemoteSyncSetupShortcut() { + labels = append(labels, u.remoteSyncSetupShortcutLabel()) + } + if u.shouldShowDirectRemoteSyncShortcut() { + labels = append(labels, u.directRemoteSyncShortcutLabel()) + } + if u.shouldShowRemoteSyncSettingsShortcut() { + labels = append(labels, u.remoteSyncSettingsShortcutLabel()) + } + if u.shouldShowRemoveRemoteSyncShortcut() { + labels = append(labels, u.removeRemoteSyncShortcutLabel()) + } + return labels +} + 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]) @@ -5663,6 +5680,38 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }), } + if u.shouldShowRemoteSyncSetupShortcut() { + 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.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + }), + ) + } + if u.shouldShowDirectRemoteSyncShortcut() { + 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.directRemoteSyncShortcutLabel()) + }), + ) + } + if u.shouldShowRemoteSyncSettingsShortcut() { + 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.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + }), + ) + } + if u.shouldShowRemoveRemoteSyncShortcut() { + 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.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + }), + ) + } if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), @@ -7021,43 +7070,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { 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)) - } - 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...) + return crumbBar(gtx) } func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { diff --git a/main_test.go b/main_test.go index 50f7aae..fa09fb6 100644 --- a/main_test.go +++ b/main_test.go @@ -5953,6 +5953,55 @@ func TestUIRemoteSetupShortcutHasParityAcrossModes(t *testing.T) { } } +func TestUISyncMenuActionLabelsIncludeRemoteSetupForUnboundVault(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-1", + Title: "Mint Console", + Path: []string{"Crew", "Signals"}, + }}, + }) + u.state.Section = appstate.SectionEntries + + got := u.syncMenuActionLabels() + if !slices.Contains(got, "Set Up Remote Sync") { + t.Fatalf("syncMenuActionLabels() = %v, want Set Up Remote Sync", got) + } + if slices.Contains(got, "Use Remote Sync") { + t.Fatalf("syncMenuActionLabels() = %v, want no Use Remote Sync without saved binding", got) + } +} + +func TestUISyncMenuActionLabelsIncludeSavedRemoteActionsForBoundVault(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", "Signals"}, + }}, + RemoteProfiles: []vault.RemoteProfile{{ + ID: "bellagio-webdav", + Name: "Bellagio Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/bellagio/keepass.kdbx", + }}, + }) + u.state.Section = appstate.SectionEntries + + got := u.syncMenuActionLabels() + for _, want := range []string{"Use Remote Sync", "Remote Sync Settings", "Stop Using Remote Sync"} { + if !slices.Contains(got, want) { + t.Fatalf("syncMenuActionLabels() = %v, want %q", got, want) + } + } +} + func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) { t.Parallel() From 1aab5367a884107df81906c8bba51652803ddafc Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 06:53:13 -0700 Subject: [PATCH 07/53] Prioritize phone vault actions --- main_test.go | 22 +++++- ui_forms.go | 194 +++++++++++++++++++++++++++------------------------ 2 files changed, 123 insertions(+), 93 deletions(-) diff --git a/main_test.go b/main_test.go index fa09fb6..80ffeb8 100644 --- a/main_test.go +++ b/main_test.go @@ -746,6 +746,20 @@ func TestUILifecycleControlsWithSelectedRecentVaultDoesNotPanic(t *testing.T) { _ = u.lifecycleControls(gtx) } +func TestUIShouldPrioritizeLifecyclePrimaryActionsOnPhone(t *testing.T) { + t.Parallel() + + phone := newUIWithSession("phone", &session.Manager{}) + if !phone.shouldPrioritizeLifecyclePrimaryActions() { + t.Fatal("phone.shouldPrioritizeLifecyclePrimaryActions() = false, want true") + } + + desktop := newUIWithSession("desktop", &session.Manager{}) + if desktop.shouldPrioritizeLifecyclePrimaryActions() { + t.Fatal("desktop.shouldPrioritizeLifecyclePrimaryActions() = true, want false") + } +} + func TestUIRecentVaultListWithSelectedRecentVaultDoesNotPanic(t *testing.T) { t.Parallel() @@ -6040,7 +6054,13 @@ func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) { func TestUILifecycleRemoteSyncActionLabelUsesSetupLanguageWithoutSavedBinding(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + 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.vaultPath.SetText("/vaults/bellagio.kdbx") if !u.shouldShowLifecycleRemoteSyncAction() { diff --git a/ui_forms.go b/ui_forms.go index 57438c1..94bdd52 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -22,6 +22,92 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() showLocalChooser := u.showLocalVaultChooser() selectedLocalPath := strings.TrimSpace(u.vaultPath.Text()) + advancedSection := func(gtx layout.Context) layout.Dimensions { + if busy { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.lifecycleAdvancedDisclosure), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.lifecycleAdvancedHidden { + return layout.Dimensions{} + } + if u.lifecycleMode == "remote" { + return layout.Dimensions{} + } + return compactCard(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), "Vault settings") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings") + }), + ) + }) + }), + ) + } + primaryActionsSection := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := "Open Vault" + if busy { + label = "Opening Vault..." + } + if busy { + return passiveTonedButton(gtx, u.theme, label) + } + return tonedButton(gtx, u.theme, &u.openVault, label) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel()) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return passiveSectionTab(gtx, u.theme, "Create New Vault", false) + } + return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false) + }), + ) + } + selectedVaultSection := func(gtx layout.Context) layout.Dimensions { + if busy || selectedLocalPath == "" { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.selectedLocalVaultCard(gtx, selectedLocalPath) + }), + ) + } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT") @@ -119,106 +205,30 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx) }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return layout.Dimensions{} - } - return u.lifecycleAdvancedDisclosure(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || u.lifecycleAdvancedHidden { - return layout.Dimensions{} - } - if u.lifecycleMode == "remote" { - return layout.Dimensions{} - } - return compactCard(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), "Vault settings") - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings") - }), - ) - }) - }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.shouldPrioritizeLifecyclePrimaryActions() { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(primaryActionsSection), + layout.Rigid(selectedVaultSection), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(advancedSection), + ) + } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - label := "Open Vault" - if busy { - label = "Opening Vault..." - } - if busy { - return passiveTonedButton(gtx, u.theme, label) - } - return tonedButton(gtx, u.theme, &u.openVault, label) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.shouldShowLifecycleRemoteSyncAction() { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.shouldShowLifecycleRemoteSyncAction() { - return layout.Dimensions{} - } - return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel()) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveSectionTab(gtx, u.theme, "Create New Vault", false) - } - return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || selectedLocalPath == "" { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || selectedLocalPath == "" { - return layout.Dimensions{} - } - return u.selectedLocalVaultCard(gtx, selectedLocalPath) - }), + layout.Rigid(advancedSection), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(primaryActionsSection), + layout.Rigid(selectedVaultSection), ) }), ) } +func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool { + return u.mode == "phone" +} + func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions { heading := u.selectedRemoteCardHeading() primary := u.selectedRemoteCardPrimaryText() From a867ac466497c4a3764be192fd8c49027eb9eacc Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 07:12:29 -0700 Subject: [PATCH 08/53] Codify workflow parity requirements --- AGENTS.md | 19 +++++++++++++++++++ TODO.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8f8cfc6..5ef5d63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,17 @@ 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. +- Keep the product feeling like the same application on desktop, Android phone, + and Android tablet. +- Platform adaptation is allowed for layout and spacing, not for changing the + user's mental model of the workflow. +- Use the same action names, the same primary next steps, and the same workflow + order across platforms unless there is a hard platform constraint. +- Treat workflow prominence and reachability as product behavior, not visual + polish. A feature is not parity-complete if it technically exists but is + harder to discover or reach on one platform. +- Prefer shared workflow decisions with platform-specific presentation, rather + than platform-specific workflow branches. - 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. @@ -117,6 +128,14 @@ These features are product requirements, not “nice to have” ideas. implement the minimum code to satisfy them, verify with `go test ./...` and relevant lint checks, and commit each completed behavior. +- For cross-platform UI work, behavior tests must cover workflow parity, not + just feature or label parity. +- For lifecycle, open, unlock, sync, and other primary flows, tests should + assert the same conceptual next step across desktop, phone, and tablet + layouts. +- When Android or phone UX is part of the slice, verify real reachability on an + emulator or device for the exact flow being changed. Do not count “the same + buttons exist somewhere on screen” as sufficient validation. - Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo. - If blocked, state the blocker concretely and stop only at that point. diff --git a/TODO.md b/TODO.md index a1c7457..f7ebc9e 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,36 @@ The product is not complete until the global exit criteria at the end of this fi These items came from a hands-on emulator and desktop walkthrough. They should be treated as usability work, not just polish. +### Cross-Platform Workflow Parity + +These items are required to keep desktop, Android phone, and Android tablet +feeling like the same application rather than three related UIs. + +- Workflow parity: + define canonical workflows for open, unlock, set up remote sync, use remote + sync, browse entries, and edit entries. +- Workflow parity: + ensure desktop, phone, and tablet use the same action names and the same + primary next steps for those workflows. +- Workflow parity: + remove or reduce platform-specific workflow exceptions where the same user + intent currently takes a different route on different form factors. +- Testing: + add cross-mode behavior tests that assert workflow order and action + prominence, not just label presence. +- Testing: + add explicit lifecycle/open-screen tests for reachability of the primary + action on desktop, phone, and tablet layouts. +- Testing: + add explicit remote-sync workflow tests that prove setup, settings, use, and + removal are reachable from the same primary affordance family across modes. +- Android verification: + validate changed lifecycle/open/sync workflows on the emulator or a device, + including with the on-screen keyboard visible. +- Android verification: + treat “present but below the fold or behind an unexpected branch” as a parity + failure, not as acceptable platform variation. + ### Primary Workflow Changes These should remain in the main user flow rather than being hidden behind a settings gear. From e88d1fd87588c9be7850de5199e7e3c99c7e0faa Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 07:20:39 -0700 Subject: [PATCH 09/53] Use Android picker for local vaults --- .gitignore | 1 + android/keepassgo-android.jar | Bin 23284 -> 0 bytes main.go | 40 +++++++++++++++++++++++++++++++++- main_test.go | 36 ++++++++++++++++++++++++++++++ ui_forms.go | 10 ++++++--- 5 files changed, 83 insertions(+), 4 deletions(-) delete mode 100644 android/keepassgo-android.jar diff --git a/.gitignore b/.gitignore index 5564ee6..c7b1e68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ build/ *.apk keepassgo +android/keepassgo-android.jar packaging/archlinux/keepassgo-git/*.pkg.tar.zst packaging/archlinux/keepassgo-git/PKGBUILD packaging/archlinux/keepassgo-git/pkg/ diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar deleted file mode 100644 index 556c6a86b6d7a7f273ad829d6265750b0aba9629..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23284 zcmbrmbC74xmn~XVUAAr8w*4vFwrzIVwr$&XRhMnMs>|%#-GYNa3$3Di94TTGzAFBNr{UotIz|)9{|%+vNCk^^KdeB)U(rbO-hVQEPKaJ^wI!YDZspI zWwR0^^CHVH7NuVfw9>N*cN5bzHNTFI4~{|p&ISS5XP4>0`*&>azwMvd|MwpX`uDB9 zlR3lx*#hZ5EUa8@EDi0<3~eoKJpY9`&i~Hb+SJs+(AnAC{@*(O@BahczwRhxXX0dU zX`*al=wwQ7Z1Z=t81*-GoK>`MaMGA~3UCu#5+28t%wZeh*302q$#NlZ?FJx@M8}4t z(qPt5V5Xw!Xhu>?<*-tKQkm@n8Ixsao1FWWm_pL1z;pID+EOye{L^M26VTyvUF7Fh^3~ZgTp5nhu1#^!u^-TGMY)46+PF?3x{33Y zhVo(>M(zIWMaOvCtU=ATaY5u)DfRYRISU7+35q#Uoh$=6*X5zcG9ETGwpmiZtb~Yy zEW*ssG@Jopq`2%g_h%+VWmThSu3B?5Vr8s!bsNv^Sl4WgsTJnRD3xM7!B{K;Y3$o= zHh*k(njt45R*JO_BF$un=3yzYF)phYbE-#6jpUA12^|#%$`DfthO?tmf4S0p5m-*4Ho7Wc|7>`Lqp=?3A{oIyn{ z+bh|RI=P{)Hx_GND4k!g(LaCS*bttiZlp*HEbj79y71QSL1oA9&0%Ew-2;SuV92Vt zWqG{xT9~!u@SG1@YHsH2p8JYx zY*|?}LLs||e*XM;5=j|V!OSeDNp&3(WM<+P(AgTeOwdmlY645c88_DiWut5dVk6O# zt!H)+eKQRe*P29wJx{0vz0fW=rr}q}d9}zrV{fsoP~oSOtk9-qT<9PnmefVMuW;$L zQlphiFxR-cZf6*|^ja8ndQS%RM276z*pi8UZM)fifXdOUO98oY7CQDx)&*v znLBxrDwC;@ABC^|%r$T?!2bo|OQbWT0LJz@#O8 zFR54r#`F*h5bVe12q3fr7cc6_Hx#(sMj>lpfC9tJa~gWvZSM{Sw+z)Bd+^!n3X6w)^P0pF z;T1<9VB|7q8C_|chXvo&lb4%wLvYp;FDr1{N($1W36w$2aTcCQg9wn{ns%y?zX)Yl zNylHTZobRo2)B0De5{b~yyq`qh*5ByRAf;Ow(2GJf9h;A{nQekIp#-JHVfQtcd=zt z(;v|lrYyK+0gRdL&LCLL{>*6kdi|li0>9Kh+?_i25sQm|WvGz@1UKcB&cMGMa_Q2)1X{hugQfmL;N!ZdXspq0Ti|0uWV{|_e2vk*O!DK( z!$VUZq$j!q`|}2y$lOM~@eFI4qN`%HW5PScu~FL-v*A!IwCNBBa42L?v4s(VZ9R^2#OYjF&H9PrR6Y~_42QD-S@rB$Fq;Utlr{k)rn$B3qq#@7lY3YZ<^ zrKy)7X_28E?v63^Iig6;jjsz|pQku_nMeE`>eJ#4H8aI=`=t%^v77~&P}ZyrStJki zqBZU=F?-6*H9fi%e}L?r?FCWYY!({)oAQomb-s5zQX zn_D6v0zP#pveyThlfdL32fUMLijPe2{U^V4@MLqibyOK`FQDCO3OM%&BjNY;^`8KN z*Au{a^xYHbkNao1*PvbPE6DHs@E5!O&4zK_KScHInE@YIt`7ZMF=do>VHNI?R4Y@D zkzIN>p=I>+hc#W0cUbv)@I|IQbq-Gse4TQ2l8J2?d)GQwJleo?!Qwg(@1mJ7Grrv%%l zbe}WVkjQq2BtFr<{s8~uoD4O6HMK(k0TsdjE7yqYzi(tC7XO0)J8tS4W_Vwi6Q3`^npzo0+@i_WAz#MC(WDrgj?Frd*>e99GD> zd)b2VIPs&JEL=!Hm?p5M*3}h}sO*n)r2_@lM082Do=ZhC%yX!FYg% z^CZi`TjS?g0^?3JfK2&lmqF{S*9zZ9Jpt=WxzNu%J+Pqy^d z{L?mBI4}#qLb_zl&y4f>`Um^%P&xgia)?sV*pU<$I*fl6c%Gcp~qR)TkWCO}zW_9GQBNr*s)1P-?fd(wn|lO(Jsd{!uA4_%^`OV!lAVF zEeZvTaMmSt9pFh>udA{~Qe8i0QP0PD1;xImocXc-c@Q>Ul+yse1>5$EtXipr`5uqe z|KEII94ml?p&#n?j8)zHS$GidVsb z5*Why%yyPYY-D06BiK-|t^#;d2MZ}4wmVJw@CX=z{y65HMrOxjqmyhN=bOW=y4yH; zAX^m}Ng~_9`goz#f!yEfH3Uqgh!n$Au++F8uIeg>VVm)S);UEgQHO7ig(*nq> zfy^)?S`6KNZ!h6IJmi(oocDkM3$VGMg0+<1vyr9*$h=p{d6@!&;R2csW~fPjoILVc zOea31^De>#a(GO+Z#Evdm3v>LR#Ylc(S3UsP7(;J4GUeC6Zf=->du#qGr<-m`h-hqv%Q}pLInP+{!1!Z|(e|##AYSNQC&5L}L*0DTr*X8No z5&&&*0!V@^Ktdxh4`A29{0~wOVr}`NF^mpahH(}W{E~>YDq@u+iBkkOhT&yHORH?H zktio}u=J&$Z1|uX4sy-AR&4jWSSnPad_J9gr3 z`d1ek?1fACP%;!oH5~o%kw3_th>+l#joNE%(!N3}V#`gZa-c(H<+%$9Xvc&R-ZYSs z@Y@4(!&_kyO0J3MhumN%dhb5r`S=5wMZWa;8Pj#gkUC2!MLdq)?TsD8hQ!ap3dcj` zl#}6V)(q@|qt#$N2SLQ(CfOljS~%)A-moJ!MzFhD-m%wL0nfnKVwFIb{RXB;PnQ2Kp8pPCjRur~ z%5l!0ucV3Yi6gK;Q+dsO04qpU% zUk>hj%YalSXY`5*mjk9mvA2S%b5`uCSWQB-r8!uynsE+6UJ?yBG4;tMXQt{N27_IG z=%HXSf{i3t$eho{D*S@x(6${=Fh8f?v}-eJNR>Q~jP)qy!i27A#MZv1yO&%-H(`eu z0%R&+!8YXOVbl)-<|XSZnwgMeH}^fxsh4kJ)Q=!oX$B-KGPU*xKnIFtY`jU_^}C2V zIv_NIG*cI+Y*n;Re=pC%NgQ;{B*Z!-VerecGmeO|J5d_`?vM3gRKX5!h9sp-&%?k( z_LbqqhHX=cE;&-C0+VIgA9E2BUuw-<`N?H62HN3C!sP}kiHj0FLIXREDl0czu~Bbk zU$l*nAU=qnEk=|{S8FvHBjL2%+CgHfBz%;a9>Zml7OCwdeFa~%)GAKmT@5~>t_pr~ zp!4HFc3L9WU{I1DOq{W`w1#h|0??}~rDITtCMz0 z>CNVz60xiyGCD*GSxV}Tcak!^pfvziR)!t2BxgE9jdJM-Rnm%9vJvSlZIiRI@ZP=1 znz!S^E5BZwtYTXTjuTE&RUs9h!=V-r+RL7?R?RtyH9Id(Gg*5e1E^qYKy!8J99@wl zHM?{M%j4Ab8|$}iPRphDLxH|KP+G!SvpZncp-=ZpEEj+FEQLmBSQg6Uqp?>>XK?`Q zvEiHuj$`D6ZHSMC8R4c+zKRwb)VmT4hTe+_auydWaHLf}1tuBqAUmqzT2p!t)b{Rw z<$O^wqlNY=D79fR&Bz!HWWhlMyM{M z+Rqp{6!&xB{H6zLK3a_02w_}1 zYgS^$ZrQwv6f_&n)bnN13#}_^dY%aeUIx=9=E%#ABzDT?-%)#?1uS8Xs*b-$D%sb= zHkW_TF}onp$wOX7|H+ErQO!x3H)sPLf;y&tcY12y9Oi?D#z*t8u4aGV>0yn|8NZv< zc*%I}jx@LXsD!6MRgSvhxDVKvuX11V6b`9^0J!WE33)Y&8`N)bs$54mE@4UJ_<9Tv z7hU+o;n_LK?S?UAy+#t3j=6ztUZU2cwrma`W=>X!6bcv~NQnWgVVP>N&!otE6I_Ea z^u_8ThR%$!#vFP}ihB60#*Em1d^C68)4+*m)earGo|8eIMGFD=-TiNPiYwlyo_EMG$gA@_r7B;I5%z~@T7nsL z`?%Gz0+OqToP`i|nXj?GFQx~uDH6P>?{&c{|DZSL*v-3cBpX^v4n293_r?*_qgu3G z>ptR%7c(g>i@U8GssRKB_MaE6!4^*LtbklcB#yr6V_5=|BPsZ@37xPd@^aqa)EgjmDoX3 zX}7_yI1GWiKz=nv{2AhLx$q!GTsM|b*JSdhjE2wSi#_Li^;h*9IlMkDDY0tn%k)5y zfT93@u;48v{wQBCtshoggpkEm?sp5ja**j(O^~}YBQ5N%DK-Cf?EpoH zBa=&GRo*fx$L8HKxm7*yA4yTQqSUp$R5dkQe)X=nSBkqUOuo2EEpf|oL4L0C>Hcu_ zGL1CBNi?2R$w;l{8-%Ua=1^Cx6d$eQY~eaR!kWp@DR9fQn}ju04W6yo4r|sd%}yz< zn3gtA(or^}F_*E2U02gkgyRY;gUwMygI=dovk+@8DT+bzxNAHTsmz`NrF{U!$k`~d z-5RFxEIp#(sp_w&xeA8Ksn#kPPc|}DQjZoAM3M$*5}qp7ybCi;Cd-AYDd`$a3h%ob z83p4-QC!IAPC@5#4t2hZptqG}sj;?YrEdh?&qlgvl44*rt|dEFASUO%slnbq>&f`a z#$Q{lmXy*a9fRVqNIE;%3wpytEYGN@r8h22NVKAq?rus%cDph713N^yQys5z$5Pt#+Onqwufr{S>qA*KH7AB z>jRMrJN;0*&21$ zCWL>3%i5F_*<8A5qw3njx|r$&DV2;1kBQiN38PBqUNXx-oRis0&_5R@bonP_LDIC} zmEH(1gN>(B?S zAKxtI%P!?)3wwG$VJY6XyE89QA#{t9^D?-Md4Pi47?@@cb;nlIWYR5r?){kgSt&jO z%BOSyFnL@&eFB^MC`rQ!>ZwB)kEcM^tHqmvFbjY{-dC3_mMx%|eh!4Ngc#iCu20LJwZc|jkha}3@&SM_2UuRTW%@uLDxGV(eZNYjId;jTF>S}v2D!U=i_OvE~gDVL~ zqFXc5ZWp@2Rmu|yj}2lEh^3b+JZrdVOu^E?4$@cHWZbYkyf6RtfcKwTo~T?B3Csbs zy^Eq)jCf7etLR{QZcCVyX9KMlg(Q?TS7Wj7#JeeB24tuQh?(LjnGjGqn_9}*I@(v8 zY))1Ub+)WT%%YMSapG>(>eV~VPj1O&Z;M_I%fLsk%*)!{2;RUUc>Y@Ms}brx8KC2A zzONcVl-tnt8}ye2{EM_?#c4?WQKr{`R{;JJ_Y^qLRHHZV;H}$#PeV_=$ z0r;`Fqu>|bBzlA7H4j*lJfX{4O+4TV1$p2t-2odVdS9&Gl`GctuvX}6X-n1u&&w}| z;@@_)Wn(@Egx}|g{LYYnv~-t(d0u%OfG`?5szzV9fy?*HP39+@8mkd)vA@WDRg!4zq=(-eRE#%KEOMbZ`T~SHqx#naQ*nWG1I<%dCgtEsFR_Sxg8XL{-gXtqg(0E z^f7ntrG}G3++8##@*hIwtr|wmOq_R8IlKsVk z14pkf>%1#*lkbCtMrf;_HzFRk@#qLb_KQV_l&KM23Wq+pInNxN}c8g<` z8hL~fdd1Oz*QdE}$~oSDr^Z?j54#==JtfBYcAswddv61r=!=X zrMIY6Uz=49S+E7s(tUy;FtLeXB);=taK}nT<;g4kPk4tId9fP&6dSld) z?*^y~^~lO_rN7>VKJSD)-kwA2`m$yi!iLp2`1agtzi1aX@lapnW|XKsH1T_%-0u`A zZjZS==R|Z*S?pa9O8MJLcZb$PV2>kSz2a3;p7j_!@RQyZm2NN9{lo4AB9k}}4qo_P zmGghS*dcrm4j1e1+zvQq`fKf*NX;KXMQ{KZrm3%yRu?kdhqTkVndDM#v_XCiPi0 zgNIb_N3%#zINa~|pKglG3J0673lz@cq=O&jZF9;UFDR-AjP*VVz%N1vT~S2OhM`Lm z^&wQYJiXKPztKXI#=_gjBASE45219XRNP5KM(AJ%+L%ChBlvID=GOI3HRtMIP=kLj zdZ5o)87~uVa|Gz1d4MxYE!~0!9y%(J$W0N1uVaPEat(ZP?9^j2kHOWw6Ypz$=t&nI zjFo^%FzQw$JdNp&7m6d}akmb{vYQ2%O`avZK`{2juQ>4m=^Ix1Su3fXbKk@80sD7E zrCua~5Grtjj&MbL^gA!@BB8@FCLD) zNU$G&LvpElcPn#NJ8{#7bPMiCz?R^rCZejsl3ABomDNa5Idh$-MXX5mAw8X-%G;r7 z9QlM~P?TH&k)ywDPf$Ko7Sg@pZs}6Qo~_zU@-@BI>(I*X&$99)})i};`%J-^4B6}}2J3`9ERTuHB zJ?BIc*g|++HmFFI(j&cD)H&WTT1`BLNyau3%@u;}VM$;5heN54mC`F#j_P*)JXUGz zMN=K$*oo_=KQ#Y7v8oBBWb$Od_=S6n9>?2ee%zStxd~?3I_AFl zFnC6#>3FC$P?P1Mae91tLkeT8m@RaR0=_Tp%i$X^*>?%1H{k%F(1^tmc z4ttY6`f`^Nf)s$`M=18D=({dw77II1VJ;~!^uAu3r}z~;#g++&;d-55eNK^LQ#ZtVbC_`HIq{jkMaVJmhvOd|$no3Z zyzFl>K_uzF(t)J@wGJd>YO3%z^e-VVWNd8e>}+Xd`HuvvZ0h7@X>3X=V&P^W(3$XIWMARj<`|2>@+@Q0Ik;Bm`^G&?-3K+s;{8vL#;MmiAADK;m8M z5|GZ;4Y?Qz4m_G6jpJZoxh1GW0XkiqCJAvt&mcBqq^W0&CpmvrddYEf7qp{f^e$|M zmIqtt@^0fg0%Z*2h1NukqBPXYx3k&B675awqzIcN;CT^6V1|4j?I7+vIV@H4N z4D^u}>d!QC$R-U8&Ubqr&`7wNi51^cg?_oxuL0l#MPSRFE!W|y=nzm06x7dI5tC|% z)*-Ao=#UK<(V9fx(Jf_Rt%C;{t034N4Tp?FRJHodJ#2Pl@U+wR$P0>pK!yBs>idtl zNB1wqJ^ud-Q2$fs^Fdw4`_^+yTDKu5rG-L-#UwIVBNc^50-+=8(CRs6FPR;p~d=W~wzyPvEhsb=Wzt?cwN6X;=y2_;#!tvNN#~T$feb#je(>NHaE7R?S7B>#gH5 zsc+PygA@vDrJGfBQ}CI~M4M+TtJr317f9*q>`m+?25ki|#nu_MdOc_>!DdxrgJ6^@ zjCv!WvZ(EQvQUuG1)OkNOFfjMCSb}c*-^-WpNCHv<8Yd#HCb*mC++AFP}$0E%=j!ElJ%vP>7h!wEYlik z_>Di{2?BQ-S;j2PuGh8NSqI!Q(5`ioW3V{&g)$;D?qEQ7z%o_jbI%$I_f13bpgf1^67joDVE?BM(ozV<4zl{%fVE#aY`H+IW0 z3)=u=j|IwQHB=SkZ(EC!b9qoE_9bpxz+Q}fUZ|oc-9`FkHh1&H^v$5u0E7V}d$z>w z@*CFjS!>Adl+w()6hM)w$esyV1iDL1tL6|9DRYnSWiFl9ZYx@8Mdww<2s%p;vPO9n zyk0(v3QxqD&UB%&WQ?|0l?!_|XnbC`(H_AxrI0%784|h7Y~-ZO&C^SdX0Ox$J_EwVw4Lh3 zI$s5vniulBXuL-V+bSpWX+b(yCHqZ7u{JqgXJpvl3thlbdVp>Ei)4{aTi7ejR-+W+ z7L8IGrCpR;P^!wTUR+XQtDG2AywjkXj$b*8mC5zd0*7QeQ3yc`Y*ywpg*Jo?{0A#` zjQhHmhLE?k+SqPYkz!5TO8B~7^T2V5ug(H05?T%;*cP8wvB=&{Q>zehxupMmSE8#q zyNvy%QUjKvcsNqk`BkHLj#V!a1p5JWr|P2t42&kXKlU~uH1?33flp;A*VMq#XCbam z)FrA;-GOgn_w6v<(R%I3#d*5ZZ49;D;Ed>m3vUZyjS_i|UZH1I`8@JTU7xj(wf^HHlc?Y1@UvLD%QNFOtg8baTqXoa|U|ylIvTQ2Tr3Tjr zQX6fn?7+{{?emN*Lvlmy;3otEvku1O5mrpI%~Ih4H-XwcW2@%es@E@u_zHLCM?TCwMxz!iStN*xa@i22hq8|Y}MLW_~A%z>H4jV}^Cf*x|Z9-aks zZKZvMUXKqz7vQTbV+>H1QJ(26P3@1oSdL*+udb*-SQ`IgsPX{YMH#=0+&t&k@Vqd| z*b!mxQUH;p^D94)@KANr@@h`O5_{f4N7??_XmAaM%%gh9)?K?tLA}#24@O>kT>z89 zzFKWpO#iqyT0)~8^aPkb76KE%NOx-Np$k-%vBoZ8?Nhm@8QU3liLq4JGSCoy$3g2G zxs`0vjv9Vatr>e_ukhm5qNDF8Q2A604GhbOyjEOeTF4_C=^K}XjdhA~ky|;bN!*a-JD%56vI|mV*mTv^ zii*+rM&6+_c2l{mT)cLp!F>paWrr?RO)QlxSxj@8v{4;wr$oZvH;YAHedv?8Yve0g z?pRIb)ReJ1m%XAnHwoN=I}JArEF$)3VmYyRTJl6SjoN^6CZSC1u`}?F&d2ivQUv`tO9Nb=a$PMuep;P3RH0pZDy9~$j$Z4X+>9T;g`=HLp>h(ZR2YKp1;42>U(n-a{mZ;?87 zn4*S;Mn||Il;LR?t|oT2#W1SxLR#uHlQSEEa5$)1o_OuktW)cP;f;;$VR1iL z?w~3nmQo+NQLtHO_F9MBs&%c&Q*?Bhw`qqxW=D8`n1~yX+`RrpvGF3jNG<^^Wk$Hu zL^-Gm*%6~&)uQCb(j+Y`ZpI|muVRgeOGcQs-<{ux?l*g`z7!`$2y`ClFG(b#7oIvl z(;S(;0d_F)ETDBkK}6B2YprktW%Zr}!Vy6iM6&7%yg!ZZ;iXI|oeup8>nhaE7n3Ez zdJMc4U?Ah<-LD+75p;yT^(XIHBeU-UZ65<=Yr7pGZ7*Mj%K3g``PfxHq@j$)LI-Uj zRK@+3EkleyxsD+OC2Hz;fyBKDN}U{n4Fr@4b4-BR9))TsootmZTK(2%NbW))BZ*5k4v3dwbS-<`W=16{IivMhN1`^yHNI@pd8{ z>%3YJoTPjp>`lN{1J>qG-EC5o_uXaL_ycWocZIe|%yD!cwnOaj{obWypD{W=?xC=5 zg745xxn~`-v=I-thsIZoas7GH&50kWlCU*?yGfXEun37c5*@&%+YxVx;I<()=`}y> zEKhuBGT0FgavZ?x4czR1{D8%a*x{ti5Ggm3O@0rJCyI(49g>vVtj?E@dEBDsxdI7_sXw+3 z;Rj?*WL+d{uvTn;uqAb-BrK)>l%YNjBO466BUYYmyQKEnB|5%+_uVzedLMw?zn2U3 zij;b_w5kt2f5j5Nk_$_~!SYO;a}u`;n!A>Q#gKHdnmVsNj@{~d-Wz*M$}@<#8e;&z zyVOPA>4dITCtZ2_{s8{8BvqcyzYowo>dh+Rn@0C$Li!l@7ErzhY8nAX4-$#R^+X=< z>9pKI{A~j6en=PgYtv&4DT%M|XBb?M3?Za%Y-%M|Q`@r?(f_!jNJZSsFN%Bv}!uTz*+cs_G!(3a*S*8jSjsoc%mR z@O#;VmNDY^B_z+H9160-{hNg%pLtKH?o$_ocRRDJG`n$8Tqr!mr-nQ0J*c zRVaasGUH=b;KoaLu@%r_gQ3?xuSWuMWKN7=%KuU z7Ctg>5gcIC<1`-D3|3@JjngB{h4G5nl~(kTfVvb*!oC2rGl*g`Vc+w>>C*oPA(WN3 zPWt~c-RJ)fq5nDICr43621F6{yEblgtk}%HKsR2CHE@a;dLU91Y~;(8S5AElD+4RU zPYni2>+y%y^DECh77=tYAj-S}<%Sun~ zhkL$F7hp#=qj@(mSN<#Zk@n#6ya9H)%brgEk7Yid$7F-tP$CxIdcB?(@k~Nn>VCFAI>l} z{!HU1!~Jyg42kGtnkf#_`&d|hP6OWruOC@3TZ8b%ktE2|?uxG<|G?N*K3=K(7e=&y zDFpl%37!A1y7RwowExfmG@*P@SAD;~7i!H~u;Vr34Y*bbadPycjcsHCkc0#3L7@|g z0%G_!@T7iJ;d#2F%L?bPlFzKKklP}U3khfc>LhK&=^l^glv|i=x!eJ0w@GF0PyoD& z0lGG)-Yi`kXxtGWA5srH-7nuSZ)W;_hYJrtt&u3AP||F#F0YE@;1BOD-I+k``BQ)Xacbr;wsSxz_cGVl3zB_E6{nTQ@60 zFUq9)Xp=rB6S7z_gshYL1UZDYd2iXKtR~y8+5v)o+<|VWE?^ZWt}z|bR0@<~-k+?~ zs|`S^K{&saOmJzCuA3o1jiA(4-SC-XG3S3K;D$eQQqgezVsAoQL|T%q7_afUVkekPx0FD{WX!37GA6vPY#yQ_;k)_yrx{qPL(mjGjtGEr%+-Pp5wqIlP!M3bP)=QfkbveE5ZjG?k`7z8}rVN zR7&I1@PzU7uJ@EOS(gE+LiL7+fLI7!w`AzpjS3G5UQnStX-K94-zI7OCTT~MG?09C z1{2m(ONsZCWs)y9@`)|>03o>4SP_F^c7Snfcj`v;TK@Xs8nwpBKz}bk(_SHmJk&97pLAo#VX2SFUBUZhZy5 z2v|H1& z7CbRR+hFlZiRE$=SygB_f}Glhbt6u2q!pW4jfnOP=mSze&51XrfykgDxh1=(3orga zWo)_2S5kg54L09{wIU@DWs$y|-|f9!ZqlXZ<3jY6=pz^AF4+U-=RT-J5`?-u&fr;S z-<=gv_iWPEiuC?!W2f7k<7J3&`UZ{ZszMAFf1r-7DDG*v{m|r@r{9;rVcf2#x0)%> z9=LmdGO$Eo-+S2U{$c^>CqU$a|1rt#9L`nK;U_82itTcYk@*|v`H{t}nUwT+DY;ct zyRSL$8N|^nfXfnwHzfz|kbrZzQR_D(?Ll|z%uT<~oTaVh`}K5^vNMqEyL2Y=YZgyP zr+?Ljw~kpX-fw5bE;>Wl)rH~g6}}VU^|;53aB3@I@uB&FO?cj1TDTMXM;sEgQ~-{G5{2KmQQn zUP8}WXU(p}xmwlsU^?9zTA}%??VbYrTV*V7f0>vd53PA@$ZpdOCA|$fM^!*G3Bp3L z;)}t>2V%Fxbj$A7(8HOH*8oW7pSV(oM3o){IO*fv)qC|<>s0K>O>F0Cl2qsF8GIwc z`Xd-S+Qg1u5J#=53B;v+BVBBtIR@*^5W!dX-$KV%{@G7t_VSSYpG}q0f1Chu?7DEB zLH>((Kkx)p0v8VKkE*C`S4!9YDqcG4uOo3?wMlAp zT{O(c>c{5w^^^;8T|2c$wgtu46l`nPiX^X0&bZOz=nPwF9p&?C#k5i%tn=`$nMSSH zi$-y~r*Nny zP#dMB_%Auv3f2_Wvlk8?Ez2b)Eu zD;+@>GxTIPO2(baGoqbCiR9@NQNAC+|?iI642+ z)`%7V)L1M#QjS*6P?44-!VKO3oHq=ivnL!xYU8!0c;%F*%e|;VkSYyhp@s|HnKbAc z{dxJmRh7-NpbYqU^=xoM{7(ExqD(uI0_LleaudUqH@s_*a=OH{V#^Mx=GMFH+IC0q z8{0Q-rpz4(yIDiUx<8iov+nT9Nn^c$4ttR83@T~0Za?3}{jxctm zl(Xii7P>r}dVG_oL!PWWk;o9P)Gc>_5ktQ#q zFr)-%??W%#9|Y;#R9ja&sw?60|4LQTF-Gt6tm%7R)Q33B3Wn{0zZwtx4wqiy>}7aV z!if3Y#F1HjtMr+gHFEVud;0v){B&#*xU$C_$XkS&YJA)A?9_o#H{U{siRisr_JA4Oe!GG2r6ae0-)q?Cn^^%&|PFJV3 zABFS5CqIO0D4Qj2&v;5NX~=ygu2ZH?gQ9K|M1xi!tU5a98X<%;r0=YR5LU z&lfHNm@Hf0>5ur#p3)UTd#F!W?5SGSMH>$)5A?+n=YzBTdC373S=cxfQK<+75#$Jq zi7Ym8dcZI1T>B!W=zQ%eLzXY=sk?)Ad7&ez``UMU6@5E-J$QS4*cl5g#5_vx^&~$k zI=Kef-;RO!07cyiZABx@8w*4~)pGEQ$9ZR`O(elS#1YTFh`grL919;yq$FQar&>4i zxumqKE9ej(rBG6w1anbT)Xhnszmq*s`@fEdmOR|$vZRmgH?TlDy?&eTzvR^AF$5w} z2?&guusH&ky?v6R{4PTrz&h!h>lpq)>6z08VhE?GUW(f?CJ7*WWmn2-@{72I{VX}< zgR7kIflBDN+!|l^ac_-pqIPEwYYNHE2|D3{(6I>vl^MevU*wiWtT!A1;xuaDNo z=0^jD&-QOq+8>n@5gc&$Je0tX5yvQw>i^yXts6&_c}I(tN{*sKkiy>2`?ot{E?(RE z%73pR))D+GiRhmy3jfqe_+P3ZSpR2PgMTYaV4`N|YU3hh>tOHXB4q4h>Gq$u!(ud` zJaCqgzVmD?(adv1xKiQ?odOPI;=!e_L9z8Cg5p@+aI=97ZEde&N_2^8d<3b~1Ai%< z328f=`4+q5Ngv}1ollRC{VL108;iU!rFb&}YNn0mCFswfp1WkzhKku=Q7x`wjWi zex^#hYVWV$(^W`pwzy2k4x^|o;By#=x7kU^YIE2Ik`=HS)3|EFGhNN9PK|t0pBVNA zY!peMh?k^>mZCV>>XaKLJO&7qmdw=VlFu+oq@^wXjl3Q;yCd)=TqN*wN{ldf{F0rr zS^3a4O?=Wp6OzhHz;4l0QZLttPz7;L^qP!(I=?Y%qks!+JO|)pF}m`|o>&pgVx;?q z)Y_NKvA4AQ{U#!|4}5U8wLIUda+mU#Wg3;oRj()?^$HYotWmFPnWmrF zF}T=XuY$_si^unfsdm&hAd!^K+9LPc+M=p(mVfSBku&@0R-PNuh04wnw^@ z+?byc(3#ScqqhNMH6f}=n)E$GRCk%ACt%>b3b&rADEBaMo|&kllXD1~XR;&6Y%!7+ z)8Rm8#Vb*uvl(66#%w7n-1HDvH9Z19o>av8NSs+CCqydM{j?vpZPdn~)v9D3CTwKk z?vN5Pse)_Md=*9WxH{+>$VL5%Xqt;*C0!+v{|%lSs$5lNRWLh76>69l&T-_dfKy6b|9o! zE+a^b+C@h~@JK80EDwcPcul%n5|ZC5jt$EU>#_d=<$ZCk=EC6T_puCigiW6jSm^R( zis@t#D6RHo^)9R=TJg10ci^THRZ5_<&5(EwRhLRcEL}Xvd%BAts!E+|i-aE`Kd2P` zuKj+KgdL$quRkHe9b}F`2F)cE&d#E(P)GDm_u0G!P)%1TqzJXt%9=&08zfWI9Tl&Z zYSUDNVy(EZGR0)Ob2-1N%}4-X!7)p6+<<73MducVoUr;w%`_;h1nh3d==yLVdL zfiuD1Bfg}uEppCe znfKdZDE={kuV@Yo|0auvw>&P+XFd=)0QsJ@cVuZ}G1lX)C$R`vVN|SS z6w!IUFM?KnN``5HWR(*6`PpD_;kYuT*6G_9r*UPTw!LSM3pzVxdi82$(N`?xne4^e z^b+)gBJ#s&4e|OtGl3)#Tr}Wx`Nf2eGxOoP#a{S2^l&IAO>Xeon;Hxqtaxtf2mlW` zCeceB3bR9ed0u>LJz>Lm^MqJtM%c!mC9YIXprxxq6O~b z_l=CXUY#kTusl9y8JQsQh~0u?c#pMIy-b;Aujf=4GgKV&=Qy4YG(@dkO}ZoDf}`Qt z&MIwv$v8E${yO&sQt$inoh9avr>Qs+s!Zff8LBN3&l#VowC(P>lHBuAvI?P|zDoQ- zdy*8}k+#4e*yI*}h~d7DbT>dwik+INF0opq5wgyIQWjRlxm{-Q&g$ji1Hx*Ks%`6; zF(o;o5s18k2?s&WOmdjhM%R--mTs2A53iCcd=84{(s1&nWk(OZ1e%ztp0pXTtw8Jw zVpfnDgUN7gCf3iN)vpg4MEM?UPL7}wI%ys6u2P(O_;wQI$=+JEVbxR`Y8hju#h~$O{#(ef zsrtwD)&#;5WG^f4K)2lQ?|D)NlyEDr(n{SpZ#riMo?1REjG2>GEd{mXsX zm|^9(;Yzg)2m}(_WPj3`8LA5A>&vbp1963`_TbhdywAE&9%8nUUUa8hLTPMM=yQ=- zrcM8$dit2thn*WeHr535sF;c0XOy^!5Ke;UO{6iaWg(}}1o1s$WxNtM%g(U4D2lkp z%kRkAf-z@D8s)lqv@0HFMh*oZ>J{dQco{E9U5{@JlI}j` z?C37T*$dE1tc7;X3J*d^1SAp~*uA&IGY!TXK2x7`+8&lXa%`rNQHM{PB6V^>?x_WU zYhzCGn(Yg7=?S09W@aO|(S1v8r9j-2r0WVMO4Dpe_l+DP3knz7OpmHNo2F#Wf-fa! zTz>ng`7Cp+ziD&`>l-}f8b3|K=kw91@&s0!9C;2^T*M&Rbm6ieyS6SzFO>jcb5_c! zwSqhg1z(YZ?#!Ug4A=#hkviN-eN%1g8QJ8Dp6tOi*EM&350fnXG)ILV-sVj?22y>; z9gz+@k90b34ARE0!0@x=v~#6wpP?VRjZu{wlok2;;_pf5?Bg(;4UhScFwqV7PSDU( zT`Il5Eh^VwRhKV+z;RmC4r3qdTQLsM>TH?1#7s3>sBhdCsrOPOnpUg884v&&V|A_W zb*p;HgmfB9iXB(^jzUGvt%dL)ZyQzKmxdOwDL64y+O*@0g_-0nt82%mcEo6AXu)*x zC>EXI9Aw`Kc{-vnoG97$(ZiHW9YT7<#{*e!)_ew-#!$`|toV~c$B_-B%0kjDV_#S_` z+VVmKo}ec5b4qDiyH@bW-m!2&6PSn+M#E8A)nS==Yd}8d!Kh)FMzkE~R}E$X9_oNh z4}Ft26P8cuw%6EBru)(exn}$}?e1CIYE~i63<3y(4+qiapRRM)FOe;Ob);PYcTiri zWg8aSO`Ov%a8?RjJO&YN07t;9!kj@p#FWQI2I|mDRs1t!f3$Pbc8i)>_*R)hJ`MwP z*J;F>n8?RZCoa4%KM1x-*f%Wk=wKnr663sk?rdr?$9{c6^q|x|5e^j&Zukm#_CMbP zXZy$V?C172j%J2N_J17?#i(vN5Dw#BYDpbQZXQat(`>ShQE`gPTH;o-ho)d-(<0H9 z5mQW(-SMe>5y7Fo3p{0h0@~g4<4Z@pQ}G;S=@}``r%rqxpLx^-JwF@RH;a20L5?Wf zwRL46sdqI;Fzs2_zLOM(zD1{E{>!6G2DtV;&yc4+R6e*vnVuOMV27&g)kyWvFKX9m z#Ju;jtU&8r)?hZGA(`oqzT^NoSa$#!P*A1+zOPGFQZabE-NiYy2`U zLNfVVsb8>eK{`6k-apCZ;^E4O@LiVgb7a&Iz2_KU3Pl@jm&*lCKf;%TNsiUFH9>33 zc{aOEo0$+tGm^+zi@6RmRl-i~i10K<8OB0+0lFT&gJ}L4l&SeXk0ho*nRu?$Q+Z@z z2;K@L_(=49(-Kc{TG;o6D{X{a(!9>sXt|L%4tDBy8}0BV&Ym~Rpdjs2`mMsLj zJ4t|f_zvNoxt=+5m5h;Kv77{YkxxmNK$x7mU%kz6kg^>)PYa!c+cxv{$}nM9t%fbx zzlKU8QHL;3)j-({o@!_4jx+Vi6Ikh-joHWIwUT0;g|Mb4p;u3nOs}(ms!g70wlkv# zGEPIVIuyu!NX5i?J09$O1s>iH`;w!Yj*>SM&8#v)7aPR`9apywKpnU63X>Jy zwzE;UboDyOKRB<{e?D%q8Bd7?Q(%w>V?=WtG>U>TIN#~`X4C-;eKPV^Y7b(I>KOD7 zE4*A=^QFyJjCZnDdV`lw0m)70LlzFrVRYF*JW+&97=5SLkrB{1?RBh{Uy4z)MyJ_$ z_I-IhM;j8`DQ0JF@E4}+;zI4bk-AZr3|hjqpsiOlW*dIG$oNG5hK!AGOY#~4pb>?6 z%4Z=&Qf6Tc=&>s&BK#Xz06_BHQb<82mI!uAfN-J*$4(LcCBgZ6b5u#zoFJnj!x;u2 zFA?W5nD%7Ngvv>&8v2>h)t4OWve#C*A4=xyFI;?!4mcR|r+;+7PRlmF#%V!qZhdyNm1%i!3B{)nZF2({cdE zPQaWarmW()5gtv2*KU%Jj$+0rT+E(*n`j`#UF2qY+QSaBhd5Dib^k!1F6?+i@yPvj2>#|yIvI+|;i(tylVAAgJv@2H4#G8c(*=Eoo zy`G!~vC zV1^yefDG|8Gn$q&L0|tdW6uWjGYl4*RQqr4VJeOTou-Am8i=R;s?MpO9$e^y-E|sI zB285#DwxjVP--22dPT{d<(oBra=XQ9g zQ~V`w1W)_&UMO-U%ZQ3_AL0u?zvy7oY?nHIoBJ5_#NB=*UZlhP@Fl9Dk=cF}*5W!o zANbbmLf{j{+LE*TK5s3&wq$=N8hl(zP`lI;&_e22)8ZsR!I{uJ=$X>0QAc+)IuG~3 z4#4XiK4ba1g~UyqTeSo)?MuNHaNAwbrttAVxDK`uQRr;?GofN1ol3W0SqW*6mQR8T zLO2%kAEIMlzq`T3P6vuV>b1Y$uzU3F6MRm@U{^aq@qr&e@@5iUW>P{{4v~>A7j*~-{anibN zR_j?FudOum5G9+I-SuMTqD#;G6XLT{!@Wlm_tjoLfWm+@28tq6SV{B?y`Ly{m=!h{3Ij?0o2CgH%jPx&VDf}G(+zqkwHdXYR zTzVshtKz?_-r|`4$^2()=v52Xdu)HIaZMw=(dVn;Z@Y4Ts(wu;z0r@W;&0V&HubI> zy50BN(6Vb<>5afw$(KJg^gFxs*QRfSs;)yaHxfnubJPEwl=*uG+OIaZ*-8R89#^HJ ztC`uKdhol=?+mP8-ENbAu6a#2(z_az|C!t0)0=*EynV;)nkaN57L5PU@vmv+uLig8 zJ6&H@yAc=p9}RAzP5=AZifyrgH6Kj0L&>cTL1t6 diff --git a/main.go b/main.go index bed5e88..b2743ed 100644 --- a/main.go +++ b/main.go @@ -1559,6 +1559,44 @@ func (u *ui) startChooseSyncLocalSourceAction() { }) } +func pickedDocumentName(file io.ReadCloser, fallback string) string { + if named, ok := file.(interface{ Name() string }); ok { + if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) { + return base + } + } + fallback = filepath.Base(strings.TrimSpace(fallback)) + if fallback == "" || fallback == "." || fallback == string(filepath.Separator) { + return "selected-vault.kdbx" + } + return fallback +} + +func (u *ui) startChooseVaultPathAction() { + if runtime.GOOS != "android" || u.fileExplorer == nil { + u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) + return + } + u.runBackgroundAction("choose vault file", 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 + } + name := pickedDocumentName(file, "selected-vault.kdbx") + return func() error { + return u.importSharedVaultBytesAction(name, content) + }, nil + }) +} + func (u *ui) startImportSharedVaultAction() { if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil { return @@ -4386,7 +4424,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if u.lifecycleBusy() { continue } - u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) + u.startChooseVaultPathAction() } for u.importSharedVault.Clicked(gtx) { if u.lifecycleBusy() { diff --git a/main_test.go b/main_test.go index 80ffeb8..1e58c8c 100644 --- a/main_test.go +++ b/main_test.go @@ -159,6 +159,42 @@ func TestUIMasterPasswordUsesPasswordInputHint(t *testing.T) { } } +func TestLocalVaultPathHelpForAndroidUsesChooserLanguage(t *testing.T) { + t.Parallel() + + if got := localVaultPathHelpForRuntime("android"); got != "Choose the existing .kdbx file to open." { + t.Fatalf("localVaultPathHelpForRuntime(android) = %q, want chooser guidance", got) + } +} + +func TestPickedDocumentNameUsesFileBaseName(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "mint-ledger.kdbx") + if err := os.WriteFile(path, []byte("mint"), 0o600); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + file, err := os.Open(path) + if err != nil { + t.Fatalf("Open(%q) error = %v", path, err) + } + t.Cleanup(func() { _ = file.Close() }) + + if got := pickedDocumentName(file, "selected-vault.kdbx"); got != "mint-ledger.kdbx" { + t.Fatalf("pickedDocumentName(file, fallback) = %q, want mint-ledger.kdbx", got) + } +} + +func TestPickedDocumentNameFallsBackWhenUnnamed(t *testing.T) { + t.Parallel() + + reader := io.NopCloser(strings.NewReader("mint")) + if got := pickedDocumentName(reader, "crew-ledger.kdbx"); got != "crew-ledger.kdbx" { + t.Fatalf("pickedDocumentName(reader, fallback) = %q, want crew-ledger.kdbx", got) + } +} + func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 94bdd52..28be39e 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -1140,13 +1140,17 @@ func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Ed return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false) } -func localVaultPathHelp() string { - if supportsDesktopFilePicker(runtime.GOOS) { +func localVaultPathHelpForRuntime(goos string) string { + if supportsDesktopFilePicker(goos) || supportsSharedVaultImport(goos) { return "Choose the existing .kdbx file to open." } return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx." } +func localVaultPathHelp() string { + return localVaultPathHelpForRuntime(runtime.GOOS) +} + func keyFileHelp() string { if supportsDesktopFilePicker(runtime.GOOS) { return "Optional path to a KeePass-compatible key file." @@ -1155,7 +1159,7 @@ func keyFileHelp() string { } func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget { - if supportsDesktopFilePicker(runtime.GOOS) { + if supportsDesktopFilePicker(runtime.GOOS) || supportsSharedVaultImport(runtime.GOOS) { return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false) } return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false) From 288cb34f1a28a7a92e3de7b3eca8df69de958f6f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:05:45 -0700 Subject: [PATCH 10/53] Align desktop entries pane order with phone --- main.go | 251 +++++++++++++++++++++++++-------------------------- main_test.go | 24 +++++ 2 files changed, 146 insertions(+), 129 deletions(-) diff --git a/main.go b/main.go index b2743ed..6ec4f08 100644 --- a/main.go +++ b/main.go @@ -76,6 +76,17 @@ const ( autofillNoticeSuppressed autofillNoticeMode = "suppressed" ) +type listPanelTopSection string + +const ( + listPanelTopSearch listPanelTopSection = "search" + listPanelTopNavigation listPanelTopSection = "navigation" + listPanelTopPath listPanelTopSection = "path" + listPanelTopGroup listPanelTopSection = "group" + listPanelTopGroupTools listPanelTopSection = "group_tools" + listPanelTopPrimary listPanelTopSection = "primary" +) + type bannerKind string const ( @@ -5917,6 +5928,63 @@ func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, uni return inset, titleSize, metaSize, urlSize, pathSize, dividerGap } +func (u *ui) listPanelTopSections() []listPanelTopSection { + sections := make([]listPanelTopSection, 0, 6) + if u.state.Section != appstate.SectionAbout { + sections = append(sections, listPanelTopSearch) + } + if !u.isVaultLocked() { + sections = append(sections, listPanelTopNavigation) + } + if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) { + sections = append(sections, listPanelTopPath) + } + if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries { + sections = append(sections, listPanelTopGroup, listPanelTopGroupTools) + } + if !u.isVaultLocked() { + sections = append(sections, listPanelTopPrimary) + } + return sections +} + +func (u *ui) listPanelSearchRow(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAbout { + return layout.Dimensions{} + } + if u.mode == "phone" { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + } + return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(u.theme, &u.search, u.searchPlaceholder()) + editor.Color = u.theme.Palette.Fg + editor.HintColor = mutedColor + return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) + }) +} + +func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAbout { + return layout.Dimensions{} + } + if u.isVaultLocked() { + return layout.Dimensions{} + } + switch u.state.Section { + case appstate.SectionEntries: + label := "Add Entry" + if u.mode == "phone" { + label = "+ " + label + } + btn := material.Button(u.theme, &u.addEntry, label) + return btn.Layout(gtx) + case appstate.SectionAPITokens: + return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") + default: + return layout.Dimensions{} + } +} + func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card spacing := u.sectionSpacing() @@ -5928,58 +5996,21 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { return panel(gtx, func(gtx layout.Context) layout.Dimensions { visibleEntries, entryClicks := u.visibleEntrySnapshot() rows := make([]layout.Widget, 0, 16+len(visibleEntries)) - if u.state.Section != appstate.SectionAbout { - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { - editor := material.Editor(u.theme, &u.search, u.searchPlaceholder()) - editor.Color = u.theme.Palette.Fg - editor.HintColor = mutedColor - return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) - }) - }) - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Height: spacing}.Layout(gtx) - }) - } - if !u.isVaultLocked() { - rows = append(rows, u.navigationHeader) - if u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionAbout { - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Height: spacing}.Layout(gtx) - }) + for _, section := range u.listPanelTopSections() { + switch section { + case listPanelTopSearch: + rows = append(rows, u.listPanelSearchRow) + case listPanelTopNavigation: + rows = append(rows, u.navigationHeader) + case listPanelTopPath: + rows = append(rows, u.pathBar) + case listPanelTopGroup: + rows = append(rows, u.groupBar) + case listPanelTopGroupTools: + rows = append(rows, u.groupControlsSection) + case listPanelTopPrimary: + rows = append(rows, u.listPanelPrimaryActionRow) } - } - if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) { - rows = append(rows, u.pathBar) - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Height: spacing}.Layout(gtx) - }) - } - if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries { - rows = append(rows, u.groupBar) - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Height: spacing}.Layout(gtx) - }) - rows = append(rows, u.groupControlsSection) - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Height: spacing}.Layout(gtx) - }) - } - if !u.isVaultLocked() { - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - switch u.state.Section { - case appstate.SectionEntries: - btn := material.Button(u.theme, &u.addEntry, "+ Add Entry") - return btn.Layout(gtx) - case appstate.SectionAPITokens: - return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") - case appstate.SectionAbout: - return emptyStatePanel(gtx, u.theme, u.listEmptyState()) - default: - return layout.Dimensions{} - } - }) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) @@ -6008,84 +6039,45 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { }) } return panel(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() { - return layout.Dimensions{} - } - return u.navigationHeader(gtx) - }), - layout.Rigid(layout.Spacer{Height: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.state.Section == appstate.SectionAbout { - return emptyStatePanel(gtx, u.theme, u.listEmptyState()) - } - return layout.Dimensions{} - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.state.Section == appstate.SectionAbout { - return layout.Dimensions{} - } - return layout.Spacer{Height: spacing}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { - return layout.Dimensions{} - } - return u.pathBar(gtx) - }), - layout.Rigid(layout.Spacer{Height: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { - return layout.Dimensions{} - } - return u.groupBar(gtx) - }), - layout.Rigid(layout.Spacer{Height: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { - return layout.Dimensions{} - } - return u.groupControlsSection(gtx) - }), - layout.Rigid(layout.Spacer{Height: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.state.Section == appstate.SectionAbout { - return layout.Dimensions{} - } - if u.mode == "phone" { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - } - return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { - editor := material.Editor(u.theme, &u.search, u.searchPlaceholder()) - editor.Color = u.theme.Palette.Fg - editor.HintColor = mutedColor - return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) - }) - }), - layout.Rigid(layout.Spacer{Height: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.state.Section == appstate.SectionAbout { - return layout.Dimensions{} - } - if u.isVaultLocked() { - return layout.Dimensions{} - } - switch u.state.Section { - case appstate.SectionEntries: - label := "Add Entry" - if u.mode == "phone" { - label = "+ " + label + children := make([]layout.FlexChild, 0, 16) + for _, section := range u.listPanelTopSections() { + switch section { + case listPanelTopSearch: + children = append(children, layout.Rigid(u.listPanelSearchRow)) + case listPanelTopNavigation: + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() { + return layout.Dimensions{} } - btn := material.Button(u.theme, &u.addEntry, label) - return btn.Layout(gtx) - case appstate.SectionAPITokens: - return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") - default: - return layout.Dimensions{} - } - }), - layout.Rigid(layout.Spacer{Height: spacing}.Layout), + return u.navigationHeader(gtx) + })) + case listPanelTopPath: + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { + return layout.Dimensions{} + } + return u.pathBar(gtx) + })) + case listPanelTopGroup: + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return layout.Dimensions{} + } + return u.groupBar(gtx) + })) + case listPanelTopGroupTools: + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return layout.Dimensions{} + } + return u.groupControlsSection(gtx) + })) + case listPanelTopPrimary: + children = append(children, layout.Rigid(u.listPanelPrimaryActionRow)) + } + children = append(children, layout.Rigid(layout.Spacer{Height: spacing}.Layout)) + } + children = append(children, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionAPITokens { return u.apiTokenListPanel(gtx) @@ -6094,7 +6086,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { return u.apiAuditListPanel(gtx) } if u.state.Section == appstate.SectionAbout { - return layout.Dimensions{} + return emptyStatePanel(gtx, u.theme, u.listEmptyState()) } if len(u.visible) == 0 { return emptyStatePanel(gtx, u.theme, u.listEmptyState()) @@ -6106,6 +6098,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { }) }), ) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) }) } diff --git a/main_test.go b/main_test.go index 1e58c8c..b0ea704 100644 --- a/main_test.go +++ b/main_test.go @@ -251,6 +251,30 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) } } +func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T) { + t.Parallel() + + desktop := newUIWithModel("desktop", vault.Model{}) + desktop.state.Section = appstate.SectionEntries + phone := newUIWithModel("phone", vault.Model{}) + phone.state.Section = appstate.SectionEntries + + want := []listPanelTopSection{ + listPanelTopSearch, + listPanelTopNavigation, + listPanelTopPath, + listPanelTopGroup, + listPanelTopGroupTools, + listPanelTopPrimary, + } + if got := desktop.listPanelTopSections(); !slices.Equal(got, want) { + t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want) + } + if got := phone.listPanelTopSections(); !slices.Equal(got, want) { + t.Fatalf("phone.listPanelTopSections() = %v, want %v", got, want) + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From edac0f50a6fab4bbd7d68c97499f8b039fda510f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:11:48 -0700 Subject: [PATCH 11/53] Remove desktop list pane tabs --- main.go | 44 ++++++++++++++++++-------------------------- main_test.go | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/main.go b/main.go index 6ec4f08..086e174 100644 --- a/main.go +++ b/main.go @@ -6103,33 +6103,25 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { } func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { - if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout { - return layout.Dimensions{} - } - if u.state.Section == appstate.SectionAbout { - lbl := material.Label(u.theme, unit.Sp(18), "About") - lbl.Color = accentColor - return lbl.Layout(gtx) - } - return u.groupControlsDisclosure(gtx) + if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout { + return layout.Dimensions{} } - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - if u.state.Section == appstate.SectionAbout { - lbl := material.Label(u.theme, unit.Sp(18), "About") - lbl.Color = accentColor - return lbl.Layout(gtx) - } - return u.sectionBar(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.state.Section != appstate.SectionEntries { - return layout.Dimensions{} - } - return u.groupControlsDisclosure(gtx) - }), - ) + if u.state.Section == appstate.SectionAbout { + lbl := material.Label(u.theme, unit.Sp(18), "About") + lbl.Color = accentColor + return lbl.Layout(gtx) + } + return u.groupControlsDisclosure(gtx) +} + +func (u *ui) navigationHeaderLabel() string { + if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout { + return "" + } + if u.state.Section == appstate.SectionAbout { + return "About" + } + return "Group Tools" } func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { diff --git a/main_test.go b/main_test.go index b0ea704..511ad1b 100644 --- a/main_test.go +++ b/main_test.go @@ -275,6 +275,22 @@ func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T } } +func TestUINavigationHeaderMatchesAcrossDesktopAndPhoneForEntries(t *testing.T) { + t.Parallel() + + desktop := newUIWithModel("desktop", vault.Model{}) + desktop.state.Section = appstate.SectionEntries + phone := newUIWithModel("phone", vault.Model{}) + phone.state.Section = appstate.SectionEntries + + if got := desktop.navigationHeaderLabel(); got != "Group Tools" { + t.Fatalf("desktop.navigationHeaderLabel() = %q, want %q", got, "Group Tools") + } + if got := phone.navigationHeaderLabel(); got != "Group Tools" { + t.Fatalf("phone.navigationHeaderLabel() = %q, want %q", got, "Group Tools") + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From f1f5d80ed836217b84b078152895fe70c444c98e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:16:13 -0700 Subject: [PATCH 12/53] Align desktop pane workflow with phone --- main.go | 58 +++++++++++++++------------------------------------- main_test.go | 33 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/main.go b/main.go index 086e174..29d3e31 100644 --- a/main.go +++ b/main.go @@ -6347,6 +6347,11 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { icon := u.menuIcon if icon == nil { @@ -6365,11 +6370,6 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { } return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.mainMenu) }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.lockVault, "Lock") - return btn.Layout(gtx) - }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), @@ -7126,11 +7126,9 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { if len(u.groupClicks) < len(groups) { u.groupClicks = make([]widget.Clickable, len(groups)) } - displayPath := u.displayPath() - atRoot := len(displayPath) == 0 return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { - if atRoot { + if len(u.displayPath()) == 0 { u.phoneGroupBrowserExpanded = true } children := make([]layout.FlexChild, 0, len(groups)) @@ -7151,39 +7149,6 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if atRoot { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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 { - for u.goToRootGroup.Clicked(gtx) { - root := u.hiddenVaultRoot() - if root == "" { - u.setCurrentPath(nil) - } else { - u.setCurrentPath([]string{root}) - } - u.filter() - } - return tonedButton(gtx, u.theme, &u.goToRootGroup, "Back to Root") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - for u.goToParentGroup.Clicked(gtx) { - u.setCurrentPath(u.currentPath[:len(u.currentPath)-1]) - u.filter() - } - return tonedButton(gtx, u.theme, &u.goToParentGroup, "Up One Group") - }), - ) - }), - ) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(groups) == 0 { return layout.Dimensions{} @@ -7216,6 +7181,17 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) groupBarShowsExplicitNavigationButtons() bool { + return false +} + +func (u *ui) topRightActionOrder() []string { + if u.isVaultLocked() { + return nil + } + return []string{"Sync", "Lock", "Menu"} +} + func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) diff --git a/main_test.go b/main_test.go index 511ad1b..4d83059 100644 --- a/main_test.go +++ b/main_test.go @@ -291,6 +291,39 @@ func TestUINavigationHeaderMatchesAcrossDesktopAndPhoneForEntries(t *testing.T) } } +func TestUIGroupBarDoesNotShowExplicitNavigationButtonsAcrossModes(t *testing.T) { + t.Parallel() + + desktop := newUIWithModel("desktop", vault.Model{}) + desktop.state.Section = appstate.SectionEntries + phone := newUIWithModel("phone", vault.Model{}) + phone.state.Section = appstate.SectionEntries + + if desktop.groupBarShowsExplicitNavigationButtons() { + t.Fatal("desktop.groupBarShowsExplicitNavigationButtons() = true, want false") + } + if phone.groupBarShowsExplicitNavigationButtons() { + t.Fatal("phone.groupBarShowsExplicitNavigationButtons() = true, want false") + } +} + +func TestUITopRightActionOrderMatchesAcrossModes(t *testing.T) { + t.Parallel() + + desktop := newUIWithSession("desktop", summarySession{hasVault: true}) + desktop.state.Section = appstate.SectionEntries + phone := newUIWithSession("phone", summarySession{hasVault: true}) + phone.state.Section = appstate.SectionEntries + + want := []string{"Sync", "Lock", "Menu"} + if got := desktop.topRightActionOrder(); !slices.Equal(got, want) { + t.Fatalf("desktop.topRightActionOrder() = %v, want %v", got, want) + } + if got := phone.topRightActionOrder(); !slices.Equal(got, want) { + t.Fatalf("phone.topRightActionOrder() = %v, want %v", got, want) + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From cbfbe3be144422dd146d5c120c596a41d9aa9695 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:21:13 -0700 Subject: [PATCH 13/53] Drop sync menu below trigger --- main.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 29d3e31..cda10e5 100644 --- a/main.go +++ b/main.go @@ -5667,19 +5667,23 @@ func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { spacing = unit.Dp(3) } - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") - }), - layout.Rigid(layout.Spacer{Width: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.syncMenuToggle(gtx) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") + }), + layout.Rigid(layout.Spacer{Width: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.syncMenuToggle(gtx) + }), + ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !u.syncMenuOpen { return layout.Dimensions{} } - return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.syncMenu) + return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, u.syncMenu) }), ) } From 9b3f10f08629491d3dcc0f1dedb443d2228ce840 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:25:56 -0700 Subject: [PATCH 14/53] Anchor sync menu below chevron --- main.go | 57 ++++++++++++++++++++++++++++++++++------------------ main_test.go | 16 +++++++++++++++ 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/main.go b/main.go index cda10e5..b120a85 100644 --- a/main.go +++ b/main.go @@ -5667,25 +5667,36 @@ func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { spacing = unit.Dp(3) } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") - }), - layout.Rigid(layout.Spacer{Width: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.syncMenuToggle(gtx) - }), - ) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !u.syncMenuOpen { - return layout.Dimensions{} - } - return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, u.syncMenu) - }), - ) + row := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") + }), + layout.Rigid(layout.Spacer{Width: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.syncMenuToggle(gtx) + }), + ) + } + rowDims := row(gtx) + if !u.syncMenuOpen { + return rowDims + } + + menuGTX := gtx + menuGTX.Constraints.Min = image.Point{} + menuOps := op.Record(gtx.Ops) + menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.syncMenu) + menuCall := menuOps.Stop() + + menuX := rowDims.Size.X - menuDims.Size.X + if menuX < 0 { + menuX = 0 + } + stack := op.Offset(image.Pt(menuX, rowDims.Size.Y)).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + return rowDims } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { @@ -7196,6 +7207,14 @@ func (u *ui) topRightActionOrder() []string { return []string{"Sync", "Lock", "Menu"} } +func (u *ui) syncMenuDropsBelowTrigger() bool { + return true +} + +func (u *ui) syncMenuRightAlignsToTrigger() bool { + return true +} + func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) diff --git a/main_test.go b/main_test.go index 4d83059..064e72f 100644 --- a/main_test.go +++ b/main_test.go @@ -324,6 +324,22 @@ func TestUITopRightActionOrderMatchesAcrossModes(t *testing.T) { } } +func TestUISyncMenuAnchorsMatchAcrossModes(t *testing.T) { + t.Parallel() + + desktop := newUIWithSession("desktop", summarySession{hasVault: true}) + desktop.state.Section = appstate.SectionEntries + phone := newUIWithSession("phone", summarySession{hasVault: true}) + phone.state.Section = appstate.SectionEntries + + if !desktop.syncMenuDropsBelowTrigger() || !phone.syncMenuDropsBelowTrigger() { + t.Fatal("sync menu should drop below trigger across desktop and phone") + } + if !desktop.syncMenuRightAlignsToTrigger() || !phone.syncMenuRightAlignsToTrigger() { + t.Fatal("sync menu should right-align to trigger across desktop and phone") + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From edf0a9090d76067b716ae84eb4386990f022e4c4 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:34:33 -0700 Subject: [PATCH 15/53] Anchor main menu below trigger --- main.go | 81 +++++++++++++++++++++++++++++----------------------- main_test.go | 16 +++++++++++ 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/main.go b/main.go index b120a85..c4e686f 100644 --- a/main.go +++ b/main.go @@ -5502,12 +5502,6 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.X = gtx.Constraints.Max.X return u.headerActions(gtx) }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !u.mainMenuOpen { - return layout.Dimensions{} - } - return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, u.mainMenu) - }), ) } if u.shouldShowDesktopWorkingHeader() { @@ -5542,18 +5536,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - icon := u.menuIcon - if icon == nil { - icon = u.settingsIcon - } - btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") - btn.Background = selectedColor - btn.Color = accentColor - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - return btn.Layout(gtx) - }), + layout.Rigid(u.mainMenuButtonGroup), ) } if u.mode == "phone" { @@ -6367,24 +6350,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - icon := u.menuIcon - if icon == nil { - icon = u.settingsIcon - } - btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") - btn.Background = selectedColor - btn.Color = accentColor - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - return btn.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !u.mainMenuOpen { - return layout.Dimensions{} - } - return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, u.mainMenu) - }), + layout.Rigid(u.mainMenuButtonGroup), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), @@ -7207,6 +7173,41 @@ func (u *ui) topRightActionOrder() []string { return []string{"Sync", "Lock", "Menu"} } +func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { + button := func(gtx layout.Context) layout.Dimensions { + icon := u.menuIcon + if icon == nil { + icon = u.settingsIcon + } + btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") + btn.Background = selectedColor + btn.Color = accentColor + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) + } + + buttonDims := button(gtx) + if !u.mainMenuOpen { + return buttonDims + } + + menuGTX := gtx + menuGTX.Constraints.Min = image.Point{} + menuOps := op.Record(gtx.Ops) + menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.mainMenu) + menuCall := menuOps.Stop() + + menuX := buttonDims.Size.X - menuDims.Size.X + if menuX < 0 { + menuX = 0 + } + stack := op.Offset(image.Pt(menuX, buttonDims.Size.Y)).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + return buttonDims +} + func (u *ui) syncMenuDropsBelowTrigger() bool { return true } @@ -7215,6 +7216,14 @@ func (u *ui) syncMenuRightAlignsToTrigger() bool { return true } +func (u *ui) mainMenuDropsBelowTrigger() bool { + return true +} + +func (u *ui) mainMenuRightAlignsToTrigger() bool { + return true +} + func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) diff --git a/main_test.go b/main_test.go index 064e72f..3aabdac 100644 --- a/main_test.go +++ b/main_test.go @@ -340,6 +340,22 @@ func TestUISyncMenuAnchorsMatchAcrossModes(t *testing.T) { } } +func TestUIMainMenuAnchorsMatchAcrossModes(t *testing.T) { + t.Parallel() + + desktop := newUIWithSession("desktop", summarySession{hasVault: true}) + desktop.state.Section = appstate.SectionEntries + phone := newUIWithSession("phone", summarySession{hasVault: true}) + phone.state.Section = appstate.SectionEntries + + if !desktop.mainMenuDropsBelowTrigger() || !phone.mainMenuDropsBelowTrigger() { + t.Fatal("main menu should drop below trigger across desktop and phone") + } + if !desktop.mainMenuRightAlignsToTrigger() || !phone.mainMenuRightAlignsToTrigger() { + t.Fatal("main menu should right-align to trigger across desktop and phone") + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From b33f4905ab455b6258968f4744b9c23c9d131dcd Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:41:52 -0700 Subject: [PATCH 16/53] Keep dropdowns right-aligned --- main.go | 14 ++++++-------- main_test.go | 11 +++++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/main.go b/main.go index c4e686f..88c941e 100644 --- a/main.go +++ b/main.go @@ -5672,10 +5672,7 @@ func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.syncMenu) menuCall := menuOps.Stop() - menuX := rowDims.Size.X - menuDims.Size.X - if menuX < 0 { - menuX = 0 - } + menuX := anchoredMenuX(rowDims.Size.X, menuDims.Size.X) stack := op.Offset(image.Pt(menuX, rowDims.Size.Y)).Push(gtx.Ops) menuCall.Add(gtx.Ops) stack.Pop() @@ -7198,10 +7195,7 @@ func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.mainMenu) menuCall := menuOps.Stop() - menuX := buttonDims.Size.X - menuDims.Size.X - if menuX < 0 { - menuX = 0 - } + menuX := anchoredMenuX(buttonDims.Size.X, menuDims.Size.X) stack := op.Offset(image.Pt(menuX, buttonDims.Size.Y)).Push(gtx.Ops) menuCall.Add(gtx.Ops) stack.Pop() @@ -7224,6 +7218,10 @@ func (u *ui) mainMenuRightAlignsToTrigger() bool { return true } +func anchoredMenuX(triggerWidth, menuWidth int) int { + return triggerWidth - menuWidth +} + func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) diff --git a/main_test.go b/main_test.go index 3aabdac..cebd319 100644 --- a/main_test.go +++ b/main_test.go @@ -356,6 +356,17 @@ func TestUIMainMenuAnchorsMatchAcrossModes(t *testing.T) { } } +func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { + t.Parallel() + + if got := anchoredMenuX(48, 160); got != -112 { + t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got) + } + if got := anchoredMenuX(160, 48); got != 112 { + t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got) + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From 81f1bcfca8b05357f5e994ea1c278bac0f327a33 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:50:25 -0700 Subject: [PATCH 17/53] Right-align dropdown actions --- main.go | 59 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index 88c941e..74795bf 100644 --- a/main.go +++ b/main.go @@ -5554,27 +5554,39 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAbout, "About") + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAbout, "About") + }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + }) }), ) }) @@ -5715,20 +5727,26 @@ func (u *ui) syncMenu(gtx layout.Context) 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") + return rightAlignedMenuButton(gtx, 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") + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") + }) }), } if u.shouldShowRemoteSyncSetupShortcut() { 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.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + }) }), ) } @@ -5736,7 +5754,9 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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.directRemoteSyncShortcutLabel()) + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + }) }), ) } @@ -5744,7 +5764,9 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + }) }), ) } @@ -5752,7 +5774,9 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + }) }), ) } @@ -7422,6 +7446,17 @@ func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clic return btn.Layout(gtx) } +func rightAlignedMenuButton(gtx layout.Context, child layout.Widget) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Dimensions{} + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return child(gtx) + }), + ) +} + func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text)) From 101a87583717bf906cc04b163b043422d81df3ca Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 21:52:57 -0700 Subject: [PATCH 18/53] Revert "Right-align dropdown actions" This reverts commit 81f1bcfca8b05357f5e994ea1c278bac0f327a33. --- main.go | 59 ++++++++++++--------------------------------------------- 1 file changed, 12 insertions(+), 47 deletions(-) diff --git a/main.go b/main.go index 74795bf..88c941e 100644 --- a/main.go +++ b/main.go @@ -5554,39 +5554,27 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") - }) + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") - }) + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") - }) + return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") - }) + return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAbout, "About") - }) + return tonedButton(gtx, u.theme, &u.showAbout, "About") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") - }) + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") }), ) }) @@ -5727,26 +5715,20 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") - }) + 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 rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") - }) + return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }), } if u.shouldShowRemoteSyncSetupShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) - }) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) }), ) } @@ -5754,9 +5736,7 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) - }) + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) }), ) } @@ -5764,9 +5744,7 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) - }) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) }), ) } @@ -5774,9 +5752,7 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuButton(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) - }) + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) }), ) } @@ -7446,17 +7422,6 @@ func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clic return btn.Layout(gtx) } -func rightAlignedMenuButton(gtx layout.Context, child layout.Widget) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Dimensions{} - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return child(gtx) - }), - ) -} - func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text)) From 36c66871685f734f659a242cb73b7d5feec7c004 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 7 Apr 2026 22:05:13 -0700 Subject: [PATCH 19/53] Use Gio east alignment for dropdown actions --- main.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index 88c941e..5a73bac 100644 --- a/main.go +++ b/main.go @@ -5551,30 +5551,39 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { } func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showEntries, "Entries") }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") }, + } + rowWidth := menuActionWidth(gtx, rows) return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + return rightAlignedMenuAction(gtx, rowWidth, rows[0]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + return rightAlignedMenuAction(gtx, rowWidth, rows[1]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + return rightAlignedMenuAction(gtx, rowWidth, rows[2]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + return rightAlignedMenuAction(gtx, rowWidth, rows[3]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAbout, "About") + return rightAlignedMenuAction(gtx, rowWidth, rows[4]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + return rightAlignedMenuAction(gtx, rowWidth, rows[5]) }), ) }) @@ -5701,6 +5710,35 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { if len(u.vaultRemoteCredentialClicks) < len(credentials) { u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials)) } + actionRows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }, + } + if supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "" { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") + }) + } + if u.shouldShowRemoteSyncSetupShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + }) + } + if u.shouldShowDirectRemoteSyncShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + }) + } + if u.shouldShowRemoteSyncSettingsShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + }) + } + if u.shouldShowRemoveRemoteSyncShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + }) + } + actionWidth := menuActionWidth(gtx, actionRows) return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -5715,20 +5753,26 @@ func (u *ui) syncMenu(gtx layout.Context) 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") + return rightAlignedMenuAction(gtx, actionWidth, 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") + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") + }) }), } if u.shouldShowRemoteSyncSetupShortcut() { 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.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + }) }), ) } @@ -5736,7 +5780,9 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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.directRemoteSyncShortcutLabel()) + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + }) }), ) } @@ -5744,7 +5790,9 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + }) }), ) } @@ -5752,7 +5800,9 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + }) }), ) } @@ -7422,6 +7472,30 @@ func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clic return btn.Layout(gtx) } +func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { + width := 0 + for _, row := range rows { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + macro := op.Record(gtx.Ops) + dims := row(measureGTX) + _ = macro.Stop() + if dims.Size.X > width { + width = dims.Size.X + } + } + return width +} + +func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { + if width <= 0 { + return child(gtx) + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return layout.E.Layout(gtx, child) +} + func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text)) From 4f9792d0276974fa51473282da66b5e16a74e435 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 08:40:00 -0700 Subject: [PATCH 20/53] Fix password visibility icon state --- main.go | 14 ++++++++------ main_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 5a73bac..66a9e93 100644 --- a/main.go +++ b/main.go @@ -7320,12 +7320,7 @@ func (u *ui) passwordLine(label, value string) layout.Widget { } func (u *ui) inlinePasswordToggle(gtx layout.Context, click *widget.Clickable, showing bool) layout.Dimensions { - icon := u.eyeIcon - desc := "Show password" - if showing { - icon = u.eyeOffIcon - desc = "Hide password" - } + icon, desc := u.passwordTogglePresentation(showing) btn := material.IconButton(u.theme, click, icon, desc) btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} btn.Color = accentColor @@ -7334,6 +7329,13 @@ func (u *ui) inlinePasswordToggle(gtx layout.Context, click *widget.Clickable, s return btn.Layout(gtx) } +func (u *ui) passwordTogglePresentation(showing bool) (*widget.Icon, string) { + if showing { + return u.eyeIcon, "Hide password" + } + return u.eyeOffIcon, "Show password" +} + func (u *ui) detailPasswordValue() string { item, ok := u.selectedEntry() if !ok { diff --git a/main_test.go b/main_test.go index cebd319..6678494 100644 --- a/main_test.go +++ b/main_test.go @@ -8399,6 +8399,28 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { } } +func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + + icon, desc := u.passwordTogglePresentation(false) + if icon != u.eyeOffIcon { + t.Fatal("passwordTogglePresentation(false) should use slashed eye icon") + } + if desc != "Show password" { + t.Fatalf("passwordTogglePresentation(false) desc = %q, want %q", desc, "Show password") + } + + icon, desc = u.passwordTogglePresentation(true) + if icon != u.eyeIcon { + t.Fatal("passwordTogglePresentation(true) should use unslashed eye icon") + } + if desc != "Hide password" { + t.Fatalf("passwordTogglePresentation(true) desc = %q, want %q", desc, "Hide password") + } +} + type memoryClipboardWriter struct { content string } From 09e6425b1c5c5514dc0b9e5feae40d742894eb0b Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 15:47:34 -0700 Subject: [PATCH 21/53] Fix Android header menu anchoring --- main.go | 108 ++++++++++++++++++++++++++++++--------------------- main_test.go | 11 ++++++ 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/main.go b/main.go index 66a9e93..8cfc4d4 100644 --- a/main.go +++ b/main.go @@ -5527,27 +5527,67 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { if u.shouldShowDesktopWorkingHeader() { return layout.Dimensions{} } + spacing := gtx.Dp(unit.Dp(8)) + rowOriginX := 0 + var syncDims, lockDims, mainDims layout.Dimensions row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(u.syncButtonGroup), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + syncDims = u.syncButtonGroup(gtx) + return syncDims + }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") - return btn.Layout(gtx) + lockDims = btn.Layout(gtx) + return lockDims }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(u.mainMenuButtonGroup), - ) - } - if u.mode == "phone" { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Dimensions{Size: gtx.Constraints.Min} + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + mainDims = u.mainMenuButtonGroup(gtx) + return mainDims }), - layout.Rigid(row), ) } - return row(gtx) + + rowOps := op.Record(gtx.Ops) + rowDims := row(gtx) + rowCall := rowOps.Stop() + + if u.mode == "phone" { + rowOriginX = max(0, gtx.Constraints.Max.X-rowDims.Size.X) + } + + drawMenu := func(menu layout.Widget, triggerRightX, triggerBottomY int) { + menuGTX := gtx + menuGTX.Constraints.Min = image.Point{} + menuGTX.Constraints.Max.X = gtx.Constraints.Max.X + menuOps := op.Record(gtx.Ops) + menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) + menuCall := menuOps.Stop() + menuX := anchoredMenuOriginX(gtx.Constraints.Max.X, rowOriginX, triggerRightX, menuDims.Size.X) + stack := op.Offset(image.Pt(menuX, triggerBottomY)).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + } + + rowStack := op.Offset(image.Pt(rowOriginX, 0)).Push(gtx.Ops) + rowCall.Add(gtx.Ops) + rowStack.Pop() + + if u.syncMenuOpen { + drawMenu(u.syncMenu, syncDims.Size.X, rowDims.Size.Y) + } + if u.mainMenuOpen { + triggerRightX := syncDims.Size.X + spacing + lockDims.Size.X + spacing + mainDims.Size.X + drawMenu(u.mainMenu, triggerRightX, rowDims.Size.Y) + } + + width := rowDims.Size.X + if u.mode == "phone" { + width = gtx.Constraints.Max.X + } + return layout.Dimensions{Size: image.Pt(width, rowDims.Size.Y)} } func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { @@ -5670,22 +5710,7 @@ func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { }), ) } - rowDims := row(gtx) - if !u.syncMenuOpen { - return rowDims - } - - menuGTX := gtx - menuGTX.Constraints.Min = image.Point{} - menuOps := op.Record(gtx.Ops) - menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.syncMenu) - menuCall := menuOps.Stop() - - menuX := anchoredMenuX(rowDims.Size.X, menuDims.Size.X) - stack := op.Offset(image.Pt(menuX, rowDims.Size.Y)).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - return rowDims + return row(gtx) } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { @@ -7233,23 +7258,7 @@ func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.Layout(gtx) } - - buttonDims := button(gtx) - if !u.mainMenuOpen { - return buttonDims - } - - menuGTX := gtx - menuGTX.Constraints.Min = image.Point{} - menuOps := op.Record(gtx.Ops) - menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, u.mainMenu) - menuCall := menuOps.Stop() - - menuX := anchoredMenuX(buttonDims.Size.X, menuDims.Size.X) - stack := op.Offset(image.Pt(menuX, buttonDims.Size.Y)).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - return buttonDims + return button(gtx) } func (u *ui) syncMenuDropsBelowTrigger() bool { @@ -7272,6 +7281,17 @@ func anchoredMenuX(triggerWidth, menuWidth int) int { return triggerWidth - menuWidth } +func anchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int { + x := rowOriginX + triggerRightX - menuWidth + if x < 0 { + return 0 + } + if x+menuWidth > containerWidth { + return max(0, containerWidth-menuWidth) + } + return x +} + func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) diff --git a/main_test.go b/main_test.go index 6678494..72ae6e3 100644 --- a/main_test.go +++ b/main_test.go @@ -367,6 +367,17 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { } } +func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { + t.Parallel() + + if got := anchoredMenuOriginX(360, 312, 360, 140); got != 220 { + t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got) + } + if got := anchoredMenuOriginX(360, 0, 44, 160); got != 0 { + t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got) + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From 5a98fe1a7529b38468d2e4e64d8d0a0d795baf54 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 15:58:45 -0700 Subject: [PATCH 22/53] Document local-first sync rules --- AGENTS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5ef5d63..189c080 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,20 @@ These features are product requirements, not “nice to have” ideas. - Prefer behavior-oriented tests that describe expected product behavior rather than implementation details. - Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state. - Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols. +- Treat the vault model as local-first across all platforms: + every usable vault is a local KDBX file first, and remote sync attaches to + that local vault rather than replacing it as the primary object. +- Keep the remote-sync model standardized across desktop, Android phone, and + Android tablet: + shared remote configuration belongs in the vault, + cross-platform workflow stays the same, + and only Android's initial KDBX share/import transport may differ. +- Do not persist remote credentials in plaintext app-local state. + Keep only non-secret binding metadata outside the vault. +- When working on remote-sync behavior, preserve the local cache / local-first + design: + opening or creating the local vault is the main workflow, + and remote setup, remote settings, and remote use attach to that vault. ## Delivery Discipline @@ -144,6 +158,13 @@ These features are product requirements, not “nice to have” ideas. - Plan for direct KDBX support. - Plan for direct WebDAV-based workflows. - Avoid adding npm-based or browser-stack dependencies. +- Keep remote configuration and synchronization local-first: + the app should maintain a live local KDBX cache even when using a remote + store, so remote outage does not eliminate vault access. +- Prefer vault-backed remote setup and lookup over ad hoc local credential + storage. +- On Android, use system picker/share mechanisms for local vault import/export + rather than raw path entry when a user is selecting or sharing a vault file. ## Tooling @@ -166,6 +187,10 @@ These features are product requirements, not “nice to have” ideas. Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the user’s real vault files whenever possible. - When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config. - Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory. +- For the Arch package, treat + `packaging/archlinux/keepassgo-git/PKGBUILD.tmpl` + as the source of truth and regenerate `PKGBUILD` from the template before + building. - Do not assume the agent can decrypt SOPS-encrypted secrets in this repository. - If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext. - Do not commit generated binaries. From d7741d14f52cdd4f6416a62e957c4a0a8d2b3cdd Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 16:27:46 -0700 Subject: [PATCH 23/53] Fix Android header menus --- main.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index 8cfc4d4..3815c5b 100644 --- a/main.go +++ b/main.go @@ -518,6 +518,10 @@ type ui struct { deleteGroupPath []string apiPolicyGroupScope bool apiTokenSecret string + phoneSyncMenuAnchor image.Point + phoneMainMenuAnchor image.Point + phoneSyncMenuVisible bool + phoneMainMenuVisible bool selectedAuditIndex int statusExpiresAt time.Time now func() time.Time @@ -4132,6 +4136,8 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { // Clear the full frame explicitly so mobile surfaces don't start from an // unpainted black buffer before nested background widgets run. paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) + u.phoneSyncMenuVisible = false + u.phoneMainMenuVisible = false u.syncHostedAPI() u.filter() u.processShortcuts(gtx) @@ -4164,9 +4170,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } for u.toggleSyncMenu.Clicked(gtx) { u.syncMenuOpen = !u.syncMenuOpen + if u.syncMenuOpen { + u.mainMenuOpen = false + } } for u.toggleMainMenu.Clicked(gtx) { u.mainMenuOpen = !u.mainMenuOpen + if u.mainMenuOpen { + u.syncMenuOpen = false + } } for u.openAdvancedSync.Clicked(gtx) { u.openAdvancedSyncDialog() @@ -4773,6 +4785,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } return u.approvalDialog(gtx) }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return u.phoneHeaderMenus(gtx) + }), layout.Stacked(u.statusToast), ) } @@ -5502,6 +5517,29 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.X = gtx.Constraints.Max.X return u.headerActions(gtx) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !u.syncMenuOpen && !u.mainMenuOpen { + return layout.Dimensions{} + } + return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + var menu layout.Widget + if u.syncMenuOpen { + menu = u.syncMenu + } else { + menu = u.mainMenu + } + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + macro := op.Record(gtx.Ops) + dims := menu(measureGTX) + _ = macro.Stop() + gtx.Constraints.Min.X = dims.Size.X + gtx.Constraints.Max.X = dims.Size.X + return menu(gtx) + }) + }) + }), ) } if u.shouldShowDesktopWorkingHeader() { @@ -5575,6 +5613,20 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { rowCall.Add(gtx.Ops) rowStack.Pop() + if u.mode == "phone" { + if u.syncMenuOpen { + u.phoneSyncMenuVisible = true + u.phoneSyncMenuAnchor = image.Pt(rowOriginX+syncDims.Size.X, rowDims.Size.Y) + } + if u.mainMenuOpen { + triggerRightX := syncDims.Size.X + spacing + lockDims.Size.X + spacing + mainDims.Size.X + u.phoneMainMenuVisible = true + u.phoneMainMenuAnchor = image.Pt(rowOriginX+triggerRightX, rowDims.Size.Y) + } + width := gtx.Constraints.Max.X + return layout.Dimensions{Size: image.Pt(width, rowDims.Size.Y)} + } + if u.syncMenuOpen { drawMenu(u.syncMenu, syncDims.Size.X, rowDims.Size.Y) } @@ -5584,9 +5636,6 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { } width := rowDims.Size.X - if u.mode == "phone" { - width = gtx.Constraints.Max.X - } return layout.Dimensions{Size: image.Pt(width, rowDims.Size.Y)} } @@ -5600,7 +5649,7 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") }, } rowWidth := menuActionWidth(gtx, rows) - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[0]) @@ -5715,8 +5764,13 @@ func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions") - btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} - btn.Color = accentColor + if u.syncMenuOpen { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} + btn.Color = accentColor + } btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) if u.mode == "phone" { @@ -5764,7 +5818,7 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { }) } actionWidth := menuActionWidth(gtx, actionRows) - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?") @@ -5951,6 +6005,20 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { }) } +func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + macro := op.Record(gtx.Ops) + contentDims := w(measureGTX) + _ = macro.Stop() + width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) + if width > 0 { + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + } + return compactCard(gtx, w) +} + func (u *ui) sectionSpacing() unit.Dp { if u.mode == "phone" { if u.denseLayout { @@ -7252,8 +7320,13 @@ func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { icon = u.settingsIcon } btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") - btn.Background = selectedColor - btn.Color = accentColor + if u.mainMenuOpen { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = selectedColor + btn.Color = accentColor + } btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.Layout(gtx) @@ -7261,6 +7334,42 @@ func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { return button(gtx) } +func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { + if u.mode != "phone" { + return layout.Dimensions{} + } + if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { + return layout.Dimensions{} + } + gtx.Constraints.Min = gtx.Constraints.Max + + drawMenu := func(anchor image.Point, menu layout.Widget) layout.Dimensions { + _ = anchor + return layout.NE.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: unit.Dp(56), + Right: unit.Dp(16), + }.Layout(gtx, menu) + }) + } + + if u.syncMenuVisibleOnPhone() { + drawMenu(u.phoneSyncMenuAnchor, u.syncMenu) + } + if u.mainMenuVisibleOnPhone() { + drawMenu(u.phoneMainMenuAnchor, u.mainMenu) + } + return layout.Dimensions{Size: gtx.Constraints.Max} +} + +func (u *ui) syncMenuVisibleOnPhone() bool { + return u.mode == "phone" && u.phoneSyncMenuVisible && u.syncMenuOpen +} + +func (u *ui) mainMenuVisibleOnPhone() bool { + return u.mode == "phone" && u.phoneMainMenuVisible && u.mainMenuOpen +} + func (u *ui) syncMenuDropsBelowTrigger() bool { return true } @@ -7510,6 +7619,9 @@ func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { } func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { + if width < gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X + } if width <= 0 { return child(gtx) } From 7a5013864055cab6071d8772726f43aded799117 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 17:43:17 -0700 Subject: [PATCH 24/53] Align Android header menus --- main.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 3815c5b..11d860e 100644 --- a/main.go +++ b/main.go @@ -5521,7 +5521,7 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { if !u.syncMenuOpen && !u.mainMenuOpen { return layout.Dimensions{} } - return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(6), Left: unit.Dp(16), Right: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { var menu layout.Widget if u.syncMenuOpen { @@ -5531,6 +5531,7 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { } measureGTX := gtx measureGTX.Constraints.Min = image.Point{} + measureGTX.Constraints.Max.X = gtx.Constraints.Max.X macro := op.Record(gtx.Ops) dims := menu(measureGTX) _ = macro.Stop() @@ -6008,10 +6009,15 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { measureGTX := gtx measureGTX.Constraints.Min = image.Point{} + measureGTX.Constraints.Max.X = gtx.Constraints.Max.X macro := op.Record(gtx.Ops) contentDims := w(measureGTX) _ = macro.Stop() width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) + maxWidth := gtx.Constraints.Max.X + if maxWidth > 0 && width > maxWidth { + width = maxWidth + } if width > 0 { gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width @@ -7619,9 +7625,6 @@ func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { } func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { - if width < gtx.Constraints.Max.X { - width = gtx.Constraints.Max.X - } if width <= 0 { return child(gtx) } From 168927713c1ea21568a3797b2590c882bf9fbc55 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 20:40:20 -0700 Subject: [PATCH 25/53] Use desktop overlay model for phone header menus --- main.go | 61 +++++++++++++++++++--------------------------------- main_test.go | 13 +++++++++++ 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/main.go b/main.go index 11d860e..415d06f 100644 --- a/main.go +++ b/main.go @@ -5512,36 +5512,8 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { if u.shouldShowLifecycleSetup() || u.isVaultLocked() { return layout.Dimensions{} } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return u.headerActions(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !u.syncMenuOpen && !u.mainMenuOpen { - return layout.Dimensions{} - } - return layout.Inset{Top: unit.Dp(6), Left: unit.Dp(16), Right: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - var menu layout.Widget - if u.syncMenuOpen { - menu = u.syncMenu - } else { - menu = u.mainMenu - } - measureGTX := gtx - measureGTX.Constraints.Min = image.Point{} - measureGTX.Constraints.Max.X = gtx.Constraints.Max.X - macro := op.Record(gtx.Ops) - dims := menu(measureGTX) - _ = macro.Stop() - gtx.Constraints.Min.X = dims.Size.X - gtx.Constraints.Max.X = dims.Size.X - return menu(gtx) - }) - }) - }), - ) + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return u.headerActions(gtx) } if u.shouldShowDesktopWorkingHeader() { return layout.Dimensions{} @@ -7348,22 +7320,29 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } gtx.Constraints.Min = gtx.Constraints.Max + contentInsetPx := gtx.Dp(unit.Dp(16)) + contentWidth := max(0, gtx.Constraints.Max.X-(contentInsetPx*2)) drawMenu := func(anchor image.Point, menu layout.Widget) layout.Dimensions { - _ = anchor - return layout.NE.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Inset{ - Top: unit.Dp(56), - Right: unit.Dp(16), - }.Layout(gtx, menu) - }) + menuGTX := gtx + menuGTX.Constraints.Min = image.Point{} + menuGTX.Constraints.Max.X = contentWidth + menuOps := op.Record(gtx.Ops) + menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) + menuCall := menuOps.Stop() + menuX := contentInsetPx + anchoredMenuOriginX(contentWidth, 0, anchor.X, menuDims.Size.X) + menuY := contentInsetPx + anchor.Y + stack := op.Offset(image.Pt(menuX, menuY)).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + return layout.Dimensions{Size: gtx.Constraints.Max} } if u.syncMenuVisibleOnPhone() { - drawMenu(u.phoneSyncMenuAnchor, u.syncMenu) + _ = drawMenu(u.phoneSyncMenuAnchor, u.syncMenu) } if u.mainMenuVisibleOnPhone() { - drawMenu(u.phoneMainMenuAnchor, u.mainMenu) + _ = drawMenu(u.phoneMainMenuAnchor, u.mainMenu) } return layout.Dimensions{Size: gtx.Constraints.Max} } @@ -7384,6 +7363,10 @@ func (u *ui) syncMenuRightAlignsToTrigger() bool { return true } +func (u *ui) headerMenusUseOverlayModel() bool { + return true +} + func (u *ui) mainMenuDropsBelowTrigger() bool { return true } diff --git a/main_test.go b/main_test.go index 72ae6e3..eeefb47 100644 --- a/main_test.go +++ b/main_test.go @@ -356,6 +356,19 @@ func TestUIMainMenuAnchorsMatchAcrossModes(t *testing.T) { } } +func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) { + t.Parallel() + + desktop := newUIWithSession("desktop", summarySession{hasVault: true}) + desktop.state.Section = appstate.SectionEntries + phone := newUIWithSession("phone", summarySession{hasVault: true}) + phone.state.Section = appstate.SectionEntries + + if !desktop.headerMenusUseOverlayModel() || !phone.headerMenusUseOverlayModel() { + t.Fatal("header menus should use the same overlay model across desktop and phone") + } +} + func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { t.Parallel() From 74a2bbdc92f619603a2baeb340351de6ca2c2425 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:15:10 -0700 Subject: [PATCH 26/53] Split lifecycle and sync dialog UI --- main.go | 252 +++-------------------------------------- ui_layout_lifecycle.go | 24 ++++ ui_sync_dialog.go | 223 ++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 234 deletions(-) create mode 100644 ui_layout_lifecycle.go create mode 100644 ui_sync_dialog.go diff --git a/main.go b/main.go index 415d06f..3e3072c 100644 --- a/main.go +++ b/main.go @@ -4801,46 +4801,6 @@ func (u *ui) syncHostedAPI() { } } -func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { - panel := card - if u.mode == "phone" { - panel = compactCard - } - return panel(gtx, func(gtx layout.Context) layout.Dimensions { - rows := []layout.Widget{ - u.lifecycleBranding, - layout.Spacer{Height: unit.Dp(8)}.Layout, - u.lifecycleControls, - } - return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { - return rows[i](gtx) - }) - }) -} - -func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions { - return layout.Stack{}.Layout(gtx, - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) - return layout.Dimensions{Size: gtx.Constraints.Max} - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - width := gtx.Dp(unit.Dp(620)) - if width > gtx.Constraints.Max.X { - width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) - } - if width < 1 { - width = gtx.Constraints.Max.X - } - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width - return card(gtx, u.syncDialogContent) - }) - }), - ) -} - func (u *ui) securityDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { @@ -5263,160 +5223,6 @@ 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.syncDialogList).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), 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), u.syncDialogDescription()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - 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 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(func(gtx layout.Context) layout.Dimensions { - 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 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(func(gtx layout.Context) layout.Dimensions { - 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 { - 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)), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - 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) { - return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx) - } - return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.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 { - 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 { - return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel") - }), - ) - }), - ) - }) -} - func (u *ui) pendingApproval() (apiapproval.Request, bool) { pending := u.state.PendingApprovals() if len(pending) == 0 { @@ -5473,40 +5279,6 @@ func approvalFact(theme *material.Theme, title, primary, secondary string) layou } } -func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - field := func(gtx layout.Context) layout.Dimensions { - editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password") - editor.Color = u.theme.Palette.Fg - editor.HintColor = mutedColor - return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) - } - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return u.outlinedFieldState(gtx, false, field) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword) - }), - ) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - ) -} - func (u *ui) header(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { if u.shouldShowLifecycleSetup() || u.isVaultLocked() { @@ -5614,12 +5386,22 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showEntries, "Entries") }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + }, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + }, } rowWidth := menuActionWidth(gtx, rows) return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { @@ -5763,7 +5545,9 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials)) } actionRows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") + }, } if supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "" { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { diff --git a/ui_layout_lifecycle.go b/ui_layout_lifecycle.go new file mode 100644 index 0000000..25b67ac --- /dev/null +++ b/ui_layout_lifecycle.go @@ -0,0 +1,24 @@ +package main + +import ( + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" +) + +func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { + panel := card + if u.mode == "phone" { + panel = compactCard + } + return panel(gtx, func(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + u.lifecycleBranding, + layout.Spacer{Height: unit.Dp(8)}.Layout, + u.lifecycleControls, + } + return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) + }) +} diff --git a/ui_sync_dialog.go b/ui_sync_dialog.go new file mode 100644 index 0000000..bb794fd --- /dev/null +++ b/ui_sync_dialog.go @@ -0,0 +1,223 @@ +package main + +import ( + "image/color" + "runtime" + "strings" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) + return layout.Dimensions{Size: gtx.Constraints.Max} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + width := gtx.Dp(unit.Dp(620)) + if width > gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) + } + if width < 1 { + width = gtx.Constraints.Max.X + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return card(gtx, u.syncDialogContent) + }) + }), + ) +} + +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.syncDialogList).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), 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), u.syncDialogDescription()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + 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 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(func(gtx layout.Context) layout.Dimensions { + 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 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(func(gtx layout.Context) layout.Dimensions { + 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 { + 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)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + 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) { + return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx) + } + return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.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 { + 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 { + return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel") + }), + ) + }), + ) + }) +} + +func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + field := func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password") + editor.Color = u.theme.Palette.Fg + editor.HintColor = mutedColor + return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return u.outlinedFieldState(gtx, false, field) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) +} From 0a9201e0d1b5e974f961038c8d9a3bba997419f3 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:19:04 -0700 Subject: [PATCH 27/53] Add explicit header dropdown layout types --- main.go | 73 ++++++++++++++----------------------------- main_test.go | 36 +++++++++++++++++++++ ui_header_dropdown.go | 73 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 49 deletions(-) create mode 100644 ui_header_dropdown.go diff --git a/main.go b/main.go index 3e3072c..dcc63c8 100644 --- a/main.go +++ b/main.go @@ -5311,77 +5311,63 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } spacing := gtx.Dp(unit.Dp(8)) - rowOriginX := 0 - var syncDims, lockDims, mainDims layout.Dimensions + metrics := headerActionMetrics{Spacing: spacing} row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - syncDims = u.syncButtonGroup(gtx) - return syncDims + metrics.SyncDims = u.syncButtonGroup(gtx) + return metrics.SyncDims }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") - lockDims = btn.Layout(gtx) - return lockDims + metrics.LockDims = btn.Layout(gtx) + return metrics.LockDims }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - mainDims = u.mainMenuButtonGroup(gtx) - return mainDims + metrics.MainDims = u.mainMenuButtonGroup(gtx) + return metrics.MainDims }), ) } rowOps := op.Record(gtx.Ops) - rowDims := row(gtx) + metrics.RowDims = row(gtx) rowCall := rowOps.Stop() if u.mode == "phone" { - rowOriginX = max(0, gtx.Constraints.Max.X-rowDims.Size.X) + metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) } - drawMenu := func(menu layout.Widget, triggerRightX, triggerBottomY int) { - menuGTX := gtx - menuGTX.Constraints.Min = image.Point{} - menuGTX.Constraints.Max.X = gtx.Constraints.Max.X - menuOps := op.Record(gtx.Ops) - menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) - menuCall := menuOps.Stop() - menuX := anchoredMenuOriginX(gtx.Constraints.Max.X, rowOriginX, triggerRightX, menuDims.Size.X) - stack := op.Offset(image.Pt(menuX, triggerBottomY)).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - } + surface := dropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} - rowStack := op.Offset(image.Pt(rowOriginX, 0)).Push(gtx.Ops) + rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) rowCall.Add(gtx.Ops) rowStack.Pop() if u.mode == "phone" { if u.syncMenuOpen { u.phoneSyncMenuVisible = true - u.phoneSyncMenuAnchor = image.Pt(rowOriginX+syncDims.Size.X, rowDims.Size.Y) + u.phoneSyncMenuAnchor = metrics.syncAnchor().point() } if u.mainMenuOpen { - triggerRightX := syncDims.Size.X + spacing + lockDims.Size.X + spacing + mainDims.Size.X u.phoneMainMenuVisible = true - u.phoneMainMenuAnchor = image.Pt(rowOriginX+triggerRightX, rowDims.Size.Y) + u.phoneMainMenuAnchor = metrics.mainAnchor().point() } width := gtx.Constraints.Max.X - return layout.Dimensions{Size: image.Pt(width, rowDims.Size.Y)} + return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} } if u.syncMenuOpen { - drawMenu(u.syncMenu, syncDims.Size.X, rowDims.Size.Y) + surface.draw(gtx, metrics.syncAnchor(), u.syncMenu) } if u.mainMenuOpen { - triggerRightX := syncDims.Size.X + spacing + lockDims.Size.X + spacing + mainDims.Size.X - drawMenu(u.mainMenu, triggerRightX, rowDims.Size.Y) + surface.draw(gtx, metrics.mainAnchor(), u.mainMenu) } - width := rowDims.Size.X - return layout.Dimensions{Size: image.Pt(width, rowDims.Size.Y)} + width := metrics.RowDims.Size.X + return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} } func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { @@ -7105,28 +7091,17 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } gtx.Constraints.Min = gtx.Constraints.Max contentInsetPx := gtx.Dp(unit.Dp(16)) - contentWidth := max(0, gtx.Constraints.Max.X-(contentInsetPx*2)) - - drawMenu := func(anchor image.Point, menu layout.Widget) layout.Dimensions { - menuGTX := gtx - menuGTX.Constraints.Min = image.Point{} - menuGTX.Constraints.Max.X = contentWidth - menuOps := op.Record(gtx.Ops) - menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) - menuCall := menuOps.Stop() - menuX := contentInsetPx + anchoredMenuOriginX(contentWidth, 0, anchor.X, menuDims.Size.X) - menuY := contentInsetPx + anchor.Y - stack := op.Offset(image.Pt(menuX, menuY)).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - return layout.Dimensions{Size: gtx.Constraints.Max} + surface := dropdownSurface{ + ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), + LeftInset: contentInsetPx, + TopInset: contentInsetPx, } if u.syncMenuVisibleOnPhone() { - _ = drawMenu(u.phoneSyncMenuAnchor, u.syncMenu) + surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) } if u.mainMenuVisibleOnPhone() { - _ = drawMenu(u.phoneMainMenuAnchor, u.mainMenu) + surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) } return layout.Dimensions{Size: gtx.Constraints.Max} } diff --git a/main_test.go b/main_test.go index eeefb47..a809dc9 100644 --- a/main_test.go +++ b/main_test.go @@ -391,6 +391,42 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { } } +func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { + t.Parallel() + + metrics := headerActionMetrics{ + RowOriginX: 24, + Spacing: 8, + RowDims: layout.Dimensions{Size: image.Pt(180, 40)}, + SyncDims: layout.Dimensions{Size: image.Pt(52, 40)}, + LockDims: layout.Dimensions{Size: image.Pt(44, 40)}, + MainDims: layout.Dimensions{Size: image.Pt(36, 40)}, + } + + if got := metrics.syncAnchor(); got != (dropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { + t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got) + } + if got := metrics.mainAnchor(); got != (dropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { + t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got) + } +} + +func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) { + t.Parallel() + + surface := dropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} + anchor := dropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} + + if got := surface.origin(anchor, 140); got != image.Pt(176, 58) { + t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got) + } + + leftAnchor := dropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} + if got := surface.origin(leftAnchor, 120); got != image.Pt(16, 58) { + t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got) + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() diff --git a/ui_header_dropdown.go b/ui_header_dropdown.go new file mode 100644 index 0000000..4e1bbe2 --- /dev/null +++ b/ui_header_dropdown.go @@ -0,0 +1,73 @@ +package main + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" +) + +type dropdownAnchor struct { + TriggerRightX int + TriggerBottomY int +} + +func (a dropdownAnchor) point() image.Point { + return image.Pt(a.TriggerRightX, a.TriggerBottomY) +} + +type dropdownSurface struct { + ContainerWidth int + LeftInset int + TopInset int +} + +func (s dropdownSurface) menuConstraints(gtx layout.Context) layout.Context { + menuGTX := gtx + menuGTX.Constraints.Min = image.Point{} + menuGTX.Constraints.Max.X = max(0, s.ContainerWidth) + return menuGTX +} + +func (s dropdownSurface) origin(anchor dropdownAnchor, menuWidth int) image.Point { + x := s.LeftInset + anchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth) + y := s.TopInset + anchor.TriggerBottomY + return image.Pt(x, y) +} + +func (s dropdownSurface) draw(gtx layout.Context, anchor dropdownAnchor, menu layout.Widget) layout.Dimensions { + menuGTX := s.menuConstraints(gtx) + menuOps := op.Record(gtx.Ops) + menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) + menuCall := menuOps.Stop() + menuOrigin := s.origin(anchor, menuDims.Size.X) + stack := op.Offset(menuOrigin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + return layout.Dimensions{Size: gtx.Constraints.Max} +} + +type headerActionMetrics struct { + RowOriginX int + Spacing int + RowDims layout.Dimensions + SyncDims layout.Dimensions + LockDims layout.Dimensions + MainDims layout.Dimensions +} + +func (m headerActionMetrics) syncAnchor() dropdownAnchor { + return dropdownAnchor{ + TriggerRightX: m.RowOriginX + m.SyncDims.Size.X, + TriggerBottomY: m.RowDims.Size.Y, + } +} + +func (m headerActionMetrics) mainAnchor() dropdownAnchor { + triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X + return dropdownAnchor{ + TriggerRightX: m.RowOriginX + triggerRightX, + TriggerBottomY: m.RowDims.Size.Y, + } +} From 9660369851bf6f40c8c09372f316472deb5bafe5 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:23:47 -0700 Subject: [PATCH 28/53] Extract header and menu rendering --- main.go | 543 ------------------------------------------ ui_layout_header.go | 557 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 557 insertions(+), 543 deletions(-) create mode 100644 ui_layout_header.go diff --git a/main.go b/main.go index dcc63c8..fd92f4e 100644 --- a/main.go +++ b/main.go @@ -5279,146 +5279,6 @@ func approvalFact(theme *material.Theme, title, primary, secondary string) layou } } -func (u *ui) header(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { - if u.shouldShowLifecycleSetup() || u.isVaultLocked() { - return layout.Dimensions{} - } - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return u.headerActions(gtx) - } - if u.shouldShowDesktopWorkingHeader() { - return layout.Dimensions{} - } - return card(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return u.brandMark(gtx, 196, 56) - }), - layout.Rigid(u.headerActions), - ) - }) -} - -func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { - if u.shouldShowLifecycleSetup() { - return layout.Dimensions{} - } - if u.isVaultLocked() { - return layout.Dimensions{} - } - if u.shouldShowDesktopWorkingHeader() { - return layout.Dimensions{} - } - spacing := gtx.Dp(unit.Dp(8)) - metrics := headerActionMetrics{Spacing: spacing} - row := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.SyncDims = u.syncButtonGroup(gtx) - return metrics.SyncDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.lockVault, "Lock") - metrics.LockDims = btn.Layout(gtx) - return metrics.LockDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.MainDims = u.mainMenuButtonGroup(gtx) - return metrics.MainDims - }), - ) - } - - rowOps := op.Record(gtx.Ops) - metrics.RowDims = row(gtx) - rowCall := rowOps.Stop() - - if u.mode == "phone" { - metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) - } - - surface := dropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} - - rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) - rowCall.Add(gtx.Ops) - rowStack.Pop() - - if u.mode == "phone" { - if u.syncMenuOpen { - u.phoneSyncMenuVisible = true - u.phoneSyncMenuAnchor = metrics.syncAnchor().point() - } - if u.mainMenuOpen { - u.phoneMainMenuVisible = true - u.phoneMainMenuAnchor = metrics.mainAnchor().point() - } - width := gtx.Constraints.Max.X - return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} - } - - if u.syncMenuOpen { - surface.draw(gtx, metrics.syncAnchor(), u.syncMenu) - } - if u.mainMenuOpen { - surface.draw(gtx, metrics.mainAnchor(), u.mainMenu) - } - - width := metrics.RowDims.Size.X - return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} -} - -func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { - rows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") - }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") - }, - } - rowWidth := menuActionWidth(gtx, rows) - return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[0]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[1]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[2]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[3]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[4]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[5]) - }), - ) - }) -} - func (u *ui) aboutDetailPanel(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { @@ -5483,290 +5343,6 @@ func aboutFact(theme *material.Theme, title, primary, secondary string) layout.W } } -func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { - label := "Sync" - spacing := unit.Dp(4) - if u.mode == "phone" { - spacing = unit.Dp(3) - } - row := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") - }), - layout.Rigid(layout.Spacer{Width: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.syncMenuToggle(gtx) - }), - ) - } - return row(gtx) -} - -func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { - btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions") - if u.syncMenuOpen { - btn.Background = accentColor - btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} - } else { - btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} - btn.Color = accentColor - } - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - if u.mode == "phone" { - btn.Size = unit.Dp(16) - btn.Inset = layout.UniformInset(unit.Dp(7)) - } - return btn.Layout(gtx) -} - -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)) - } - actionRows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") - }, - } - if supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "" { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") - }) - } - if u.shouldShowRemoteSyncSetupShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) - }) - } - if u.shouldShowDirectRemoteSyncShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) - }) - } - if u.shouldShowRemoteSyncSettingsShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) - }) - } - if u.shouldShowRemoveRemoteSyncShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) - }) - } - actionWidth := menuActionWidth(gtx, actionRows) - return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - 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 rightAlignedMenuAction(gtx, actionWidth, 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 rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") - }) - }), - } - if u.shouldShowRemoteSyncSetupShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) - }) - }), - ) - } - if u.shouldShowDirectRemoteSyncShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) - }) - }), - ) - } - if u.shouldShowRemoteSyncSettingsShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) - }) - }), - ) - } - if u.shouldShowRemoveRemoteSyncShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) - }) - }), - ) - } - 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...) - }) -} - -func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { - measureGTX := gtx - measureGTX.Constraints.Min = image.Point{} - measureGTX.Constraints.Max.X = gtx.Constraints.Max.X - macro := op.Record(gtx.Ops) - contentDims := w(measureGTX) - _ = macro.Stop() - width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) - maxWidth := gtx.Constraints.Max.X - if maxWidth > 0 && width > maxWidth { - width = maxWidth - } - if width > 0 { - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width - } - return compactCard(gtx, w) -} - func (u *ui) sectionSpacing() unit.Dp { if u.mode == "phone" { if u.denseLayout { @@ -7054,101 +6630,6 @@ func (u *ui) groupBarShowsExplicitNavigationButtons() bool { return false } -func (u *ui) topRightActionOrder() []string { - if u.isVaultLocked() { - return nil - } - return []string{"Sync", "Lock", "Menu"} -} - -func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { - button := func(gtx layout.Context) layout.Dimensions { - icon := u.menuIcon - if icon == nil { - icon = u.settingsIcon - } - btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") - if u.mainMenuOpen { - btn.Background = accentColor - btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} - } else { - btn.Background = selectedColor - btn.Color = accentColor - } - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - return btn.Layout(gtx) - } - return button(gtx) -} - -func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { - if u.mode != "phone" { - return layout.Dimensions{} - } - if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { - return layout.Dimensions{} - } - gtx.Constraints.Min = gtx.Constraints.Max - contentInsetPx := gtx.Dp(unit.Dp(16)) - surface := dropdownSurface{ - ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), - LeftInset: contentInsetPx, - TopInset: contentInsetPx, - } - - if u.syncMenuVisibleOnPhone() { - surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) - } - if u.mainMenuVisibleOnPhone() { - surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) - } - return layout.Dimensions{Size: gtx.Constraints.Max} -} - -func (u *ui) syncMenuVisibleOnPhone() bool { - return u.mode == "phone" && u.phoneSyncMenuVisible && u.syncMenuOpen -} - -func (u *ui) mainMenuVisibleOnPhone() bool { - return u.mode == "phone" && u.phoneMainMenuVisible && u.mainMenuOpen -} - -func (u *ui) syncMenuDropsBelowTrigger() bool { - return true -} - -func (u *ui) syncMenuRightAlignsToTrigger() bool { - return true -} - -func (u *ui) headerMenusUseOverlayModel() bool { - return true -} - -func (u *ui) mainMenuDropsBelowTrigger() bool { - return true -} - -func (u *ui) mainMenuRightAlignsToTrigger() bool { - return true -} - -func anchoredMenuX(triggerWidth, menuWidth int) int { - return triggerWidth - menuWidth -} - -func anchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int { - x := rowOriginX + triggerRightX - menuWidth - if x < 0 { - return 0 - } - if x+menuWidth > containerWidth { - return max(0, containerWidth-menuWidth) - } - return x -} - func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) @@ -7351,30 +6832,6 @@ func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clic return btn.Layout(gtx) } -func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { - width := 0 - for _, row := range rows { - measureGTX := gtx - measureGTX.Constraints.Min = image.Point{} - macro := op.Record(gtx.Ops) - dims := row(measureGTX) - _ = macro.Stop() - if dims.Size.X > width { - width = dims.Size.X - } - } - return width -} - -func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { - if width <= 0 { - return child(gtx) - } - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width - return layout.E.Layout(gtx, child) -} - func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text)) diff --git a/ui_layout_header.go b/ui_layout_header.go new file mode 100644 index 0000000..3b81d7e --- /dev/null +++ b/ui_layout_header.go @@ -0,0 +1,557 @@ +package main + +import ( + "image" + "image/color" + "runtime" + "strings" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +func (u *ui) header(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" { + if u.shouldShowLifecycleSetup() || u.isVaultLocked() { + return layout.Dimensions{} + } + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return u.headerActions(gtx) + } + if u.shouldShowDesktopWorkingHeader() { + return layout.Dimensions{} + } + return card(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return u.brandMark(gtx, 196, 56) + }), + layout.Rigid(u.headerActions), + ) + }) +} + +func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { + if u.shouldShowLifecycleSetup() { + return layout.Dimensions{} + } + if u.isVaultLocked() { + return layout.Dimensions{} + } + if u.shouldShowDesktopWorkingHeader() { + return layout.Dimensions{} + } + spacing := gtx.Dp(unit.Dp(8)) + metrics := headerActionMetrics{Spacing: spacing} + row := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + metrics.SyncDims = u.syncButtonGroup(gtx) + return metrics.SyncDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + metrics.LockDims = btn.Layout(gtx) + return metrics.LockDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + metrics.MainDims = u.mainMenuButtonGroup(gtx) + return metrics.MainDims + }), + ) + } + + rowOps := op.Record(gtx.Ops) + metrics.RowDims = row(gtx) + rowCall := rowOps.Stop() + + if u.mode == "phone" { + metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) + } + + surface := dropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} + + rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) + rowCall.Add(gtx.Ops) + rowStack.Pop() + + if u.mode == "phone" { + if u.syncMenuOpen { + u.phoneSyncMenuVisible = true + u.phoneSyncMenuAnchor = metrics.syncAnchor().point() + } + if u.mainMenuOpen { + u.phoneMainMenuVisible = true + u.phoneMainMenuAnchor = metrics.mainAnchor().point() + } + width := gtx.Constraints.Max.X + return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} + } + + if u.syncMenuOpen { + surface.draw(gtx, metrics.syncAnchor(), u.syncMenu) + } + if u.mainMenuOpen { + surface.draw(gtx, metrics.mainAnchor(), u.mainMenu) + } + + width := metrics.RowDims.Size.X + return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} +} + +func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + }, + } + rowWidth := menuActionWidth(gtx, rows) + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, rowWidth, rows[0]) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, rowWidth, rows[1]) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, rowWidth, rows[2]) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, rowWidth, rows[3]) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, rowWidth, rows[4]) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, rowWidth, rows[5]) + }), + ) + }) +} + +func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { + label := "Sync" + spacing := unit.Dp(4) + if u.mode == "phone" { + spacing = unit.Dp(3) + } + row := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") + }), + layout.Rigid(layout.Spacer{Width: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.syncMenuToggle(gtx) + }), + ) + } + return row(gtx) +} + +func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { + btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions") + if u.syncMenuOpen { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} + btn.Color = accentColor + } + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + if u.mode == "phone" { + btn.Size = unit.Dp(16) + btn.Inset = layout.UniformInset(unit.Dp(7)) + } + return btn.Layout(gtx) +} + +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)) + } + actionRows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") + }, + } + if supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "" { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") + }) + } + if u.shouldShowRemoteSyncSetupShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + }) + } + if u.shouldShowDirectRemoteSyncShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + }) + } + if u.shouldShowRemoteSyncSettingsShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + }) + } + if u.shouldShowRemoveRemoteSyncShortcut() { + actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + }) + } + actionWidth := menuActionWidth(gtx, actionRows) + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + 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 rightAlignedMenuAction(gtx, actionWidth, 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 rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") + }) + }), + } + if u.shouldShowRemoteSyncSetupShortcut() { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + }) + }), + ) + } + if u.shouldShowDirectRemoteSyncShortcut() { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + }) + }), + ) + } + if u.shouldShowRemoteSyncSettingsShortcut() { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + }) + }), + ) + } + if u.shouldShowRemoveRemoteSyncShortcut() { + rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + }) + }), + ) + } + 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...) + }) +} + +func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + measureGTX.Constraints.Max.X = gtx.Constraints.Max.X + macro := op.Record(gtx.Ops) + contentDims := w(measureGTX) + _ = macro.Stop() + width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) + maxWidth := gtx.Constraints.Max.X + if maxWidth > 0 && width > maxWidth { + width = maxWidth + } + if width > 0 { + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + } + return compactCard(gtx, w) +} + +func (u *ui) topRightActionOrder() []string { + if u.isVaultLocked() { + return nil + } + return []string{"Sync", "Lock", "Menu"} +} + +func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { + button := func(gtx layout.Context) layout.Dimensions { + icon := u.menuIcon + if icon == nil { + icon = u.settingsIcon + } + btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") + if u.mainMenuOpen { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = selectedColor + btn.Color = accentColor + } + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) + } + return button(gtx) +} + +func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { + if u.mode != "phone" { + return layout.Dimensions{} + } + if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { + return layout.Dimensions{} + } + gtx.Constraints.Min = gtx.Constraints.Max + contentInsetPx := gtx.Dp(unit.Dp(16)) + surface := dropdownSurface{ + ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), + LeftInset: contentInsetPx, + TopInset: contentInsetPx, + } + + if u.syncMenuVisibleOnPhone() { + surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + } + if u.mainMenuVisibleOnPhone() { + surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + } + return layout.Dimensions{Size: gtx.Constraints.Max} +} + +func (u *ui) syncMenuVisibleOnPhone() bool { + return u.mode == "phone" && u.phoneSyncMenuVisible && u.syncMenuOpen +} + +func (u *ui) mainMenuVisibleOnPhone() bool { + return u.mode == "phone" && u.phoneMainMenuVisible && u.mainMenuOpen +} + +func (u *ui) syncMenuDropsBelowTrigger() bool { + return true +} + +func (u *ui) syncMenuRightAlignsToTrigger() bool { + return true +} + +func (u *ui) headerMenusUseOverlayModel() bool { + return true +} + +func (u *ui) mainMenuDropsBelowTrigger() bool { + return true +} + +func (u *ui) mainMenuRightAlignsToTrigger() bool { + return true +} + +func anchoredMenuX(triggerWidth, menuWidth int) int { + return triggerWidth - menuWidth +} + +func anchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int { + x := rowOriginX + triggerRightX - menuWidth + if x < 0 { + return 0 + } + if x+menuWidth > containerWidth { + return max(0, containerWidth-menuWidth) + } + return x +} + +func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { + width := 0 + for _, row := range rows { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + macro := op.Record(gtx.Ops) + dims := row(measureGTX) + _ = macro.Stop() + if dims.Size.X > width { + width = dims.Size.X + } + } + return width +} + +func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { + if width <= 0 { + return child(gtx) + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return layout.E.Layout(gtx, child) +} From 16f603ccba549df04d25188097ed6590531f18ff Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:27:47 -0700 Subject: [PATCH 29/53] Move sync menu state decisions out of renderers --- main.go | 67 +++++--------------- ui_layout_header.go | 84 ++++++++++++------------- ui_sync_menu_model.go | 142 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 97 deletions(-) create mode 100644 ui_sync_menu_model.go diff --git a/main.go b/main.go index fd92f4e..fbe0339 100644 --- a/main.go +++ b/main.go @@ -2740,98 +2740,61 @@ func (u *ui) shouldShowSavedRemoteBindingSelectors() bool { } 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 + summary := u.computeSavedRemoteBindingSummary() + return summary.profileLabel, summary.credentialLabel, summary.syncLabel, summary.ok } 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" + return u.buildSyncMenuModel().savedBindingHeading() } func (u *ui) openSelectedVaultRemoteButtonLabel() string { - if !u.shouldShowSavedRemoteBindingSelectors() { - return "Use Remote Sync" - } - return "Open Saved Remote" + return u.buildSyncMenuModel().openSelectedButtonLabel() } func (u *ui) shouldShowDirectRemoteSyncShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } - _, ok := u.selectedVaultRemoteBinding() - return ok + return u.buildSyncMenuModel().showDirectRemoteSyncShortcut() } func (u *ui) directRemoteSyncShortcutLabel() string { - return "Use Remote Sync" + return u.buildSyncMenuModel().directRemoteSyncShortcutLabel() } func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } - _, ok := u.selectedVaultRemoteBinding() - return ok + return u.buildSyncMenuModel().showRemoteSyncSettingsShortcut() } func (u *ui) remoteSyncSettingsShortcutLabel() string { - return "Remote Sync Settings" + return u.buildSyncMenuModel().remoteSyncSettingsShortcutLabel() } func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool { - return u.shouldShowRemoteSyncSettingsShortcut() + return u.buildSyncMenuModel().showRemoveRemoteSyncShortcut() } func (u *ui) removeRemoteSyncShortcutLabel() string { - return "Stop Using Remote Sync" + return u.buildSyncMenuModel().removeRemoteSyncShortcutLabel() } func (u *ui) shouldShowRemoteSyncSetupShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } - _, ok := u.selectedVaultRemoteBinding() - return !ok + return u.buildSyncMenuModel().showRemoteSyncSetupShortcut() } func (u *ui) remoteSyncSetupShortcutLabel() string { - return "Set Up Remote Sync" + return u.buildSyncMenuModel().remoteSyncSetupShortcutLabel() } func (u *ui) syncMenuActionLabels() []string { - labels := []string{"Open Advanced Sync"} - if u.shouldShowRemoteSyncSetupShortcut() { - labels = append(labels, u.remoteSyncSetupShortcutLabel()) - } - if u.shouldShowDirectRemoteSyncShortcut() { - labels = append(labels, u.directRemoteSyncShortcutLabel()) - } - if u.shouldShowRemoteSyncSettingsShortcut() { - labels = append(labels, u.remoteSyncSettingsShortcutLabel()) - } - if u.shouldShowRemoveRemoteSyncShortcut() { - labels = append(labels, u.removeRemoteSyncShortcutLabel()) - } - return labels + return u.buildSyncMenuModel().actionLabels() } func remoteBindingSuffix(baseURL, path, username string) string { @@ -2939,11 +2902,11 @@ func (u *ui) removeSelectedRemoteBindingAction() error { } func (u *ui) saveCurrentRemoteBindingHeading() string { - return "Bind this local vault to the current remote target" + return u.buildSyncMenuModel().saveCurrentRemoteBindingHeading() } func (u *ui) saveCurrentRemoteBindingButtonLabel() string { - return "Save Remote In Vault" + return u.buildSyncMenuModel().saveCurrentRemoteBindingButtonLabel() } func (u *ui) materializeCurrentRemoteCache() error { diff --git a/ui_layout_header.go b/ui_layout_header.go index 3b81d7e..8961231 100644 --- a/ui_layout_header.go +++ b/ui_layout_header.go @@ -3,7 +3,6 @@ package main import ( "image" "image/color" - "runtime" "strings" "gioui.org/layout" @@ -192,6 +191,7 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { } func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { + model := u.buildSyncMenuModel() profiles := u.availableRemoteProfiles() credentials := u.availableRemoteCredentialEntries() if len(u.vaultRemoteProfileClicks) < len(profiles) { @@ -205,29 +205,29 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }, } - if supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "" { + if model.showShare { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") }) } - if u.shouldShowRemoteSyncSetupShortcut() { + if model.showRemoteSyncSetupShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSetupShortcutLabel()) }) } - if u.shouldShowDirectRemoteSyncShortcut() { + if model.showDirectRemoteSyncShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.directRemoteSyncShortcutLabel()) }) } - if u.shouldShowRemoteSyncSettingsShortcut() { + if model.showRemoteSyncSettingsShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSettingsShortcutLabel()) }) } - if u.shouldShowRemoveRemoteSyncShortcut() { + if model.showRemoveRemoteSyncShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.removeRemoteSyncShortcutLabel()) }) } actionWidth := menuActionWidth(gtx, actionRows) @@ -240,7 +240,7 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { }), 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()) == "" { + if !model.showShare { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -258,42 +258,42 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { }) }), } - if u.shouldShowRemoteSyncSetupShortcut() { + if model.showRemoteSyncSetupShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSetupShortcutLabel()) }) }), ) } - if u.shouldShowDirectRemoteSyncShortcut() { + if model.showDirectRemoteSyncShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.directRemoteSyncShortcutLabel()) }) }), ) } - if u.shouldShowRemoteSyncSettingsShortcut() { + if model.showRemoteSyncSettingsShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSettingsShortcutLabel()) }) }), ) } - if u.shouldShowRemoveRemoteSyncShortcut() { + if model.showRemoveRemoteSyncShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.removeRemoteSyncShortcutLabel()) }) }), ) @@ -302,36 +302,36 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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 := material.Label(u.theme, unit.Sp(11), model.savedBindingHeading()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), ) - if !u.shouldShowSavedRemoteBindingSelectors() { + if !model.showSelectors { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary() - if !ok { + summary := model.savedBindingSummary + if !summary.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 := material.Label(u.theme, unit.Sp(13), summary.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 := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.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 := material.Label(u.theme, unit.Sp(12), summary.syncLabel) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -394,25 +394,19 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { } } } - 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()) - }), - ) - } + if model.showSaveCurrentBinding { + 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), model.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, model.saveCurrentRemoteBindingButtonLabel()) + }), + ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) }) diff --git a/ui_sync_menu_model.go b/ui_sync_menu_model.go new file mode 100644 index 0000000..34893be --- /dev/null +++ b/ui_sync_menu_model.go @@ -0,0 +1,142 @@ +package main + +import ( + "runtime" + "strings" + + "git.julianfamily.org/keepassgo/appstate" +) + +type syncMenuModel struct { + hasOpenVault bool + hasSelectedBinding bool + showSelectors bool + showShare bool + showSaveCurrentBinding bool + savedBindingSummary syncMenuBindingSummary + remoteBaseURL string + remotePath string + remoteUsername string + remotePassword string + selectedVaultSyncMode appstate.SyncMode +} + +type syncMenuBindingSummary struct { + profileLabel string + credentialLabel string + syncLabel string + ok bool +} + +func (u *ui) buildSyncMenuModel() syncMenuModel { + model := syncMenuModel{ + hasOpenVault: u.hasOpenVault(), + showSelectors: u.shouldShowSavedRemoteBindingSelectors(), + showShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "", + remoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + remotePath: strings.TrimSpace(u.remotePath.Text()), + remoteUsername: strings.TrimSpace(u.remoteUsername.Text()), + remotePassword: u.remotePassword.Text(), + selectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), + } + _, model.hasSelectedBinding = u.selectedVaultRemoteBinding() + model.savedBindingSummary = u.computeSavedRemoteBindingSummary() + model.showSaveCurrentBinding = model.hasOpenVault && model.remoteBaseURL != "" && model.remotePath != "" && model.remoteUsername != "" && model.remotePassword != "" + return model +} + +func (u *ui) computeSavedRemoteBindingSummary() syncMenuBindingSummary { + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return syncMenuBindingSummary{} + } + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return syncMenuBindingSummary{} + } + 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 syncMenuBindingSummary{ + profileLabel: profile.Name, + credentialLabel: credentialLabel, + syncLabel: syncLabel, + ok: true, + } +} + +func (m syncMenuModel) savedBindingHeading() string { + if !m.showSelectors { + return "Use this vault's saved remote sync target" + } + return "Use a saved remote profile from this vault" +} + +func (m syncMenuModel) openSelectedButtonLabel() string { + if !m.showSelectors { + return "Use Remote Sync" + } + return "Open Saved Remote" +} + +func (m syncMenuModel) showDirectRemoteSyncShortcut() bool { + return m.hasOpenVault && m.hasSelectedBinding +} + +func (m syncMenuModel) directRemoteSyncShortcutLabel() string { + return "Use Remote Sync" +} + +func (m syncMenuModel) showRemoteSyncSettingsShortcut() bool { + return m.hasOpenVault && m.hasSelectedBinding +} + +func (m syncMenuModel) remoteSyncSettingsShortcutLabel() string { + return "Remote Sync Settings" +} + +func (m syncMenuModel) showRemoveRemoteSyncShortcut() bool { + return m.showRemoteSyncSettingsShortcut() +} + +func (m syncMenuModel) removeRemoteSyncShortcutLabel() string { + return "Stop Using Remote Sync" +} + +func (m syncMenuModel) showRemoteSyncSetupShortcut() bool { + return m.hasOpenVault && !m.hasSelectedBinding +} + +func (m syncMenuModel) remoteSyncSetupShortcutLabel() string { + return "Set Up Remote Sync" +} + +func (m syncMenuModel) actionLabels() []string { + labels := []string{"Open Advanced Sync"} + if m.showRemoteSyncSetupShortcut() { + labels = append(labels, m.remoteSyncSetupShortcutLabel()) + } + if m.showDirectRemoteSyncShortcut() { + labels = append(labels, m.directRemoteSyncShortcutLabel()) + } + if m.showRemoteSyncSettingsShortcut() { + labels = append(labels, m.remoteSyncSettingsShortcutLabel()) + } + if m.showRemoveRemoteSyncShortcut() { + labels = append(labels, m.removeRemoteSyncShortcutLabel()) + } + return labels +} + +func (m syncMenuModel) saveCurrentRemoteBindingHeading() string { + return "Bind this local vault to the current remote target" +} + +func (m syncMenuModel) saveCurrentRemoteBindingButtonLabel() string { + return "Save Remote In Vault" +} From 74d10535a1134a152deddaf921f0814dfc07a9d8 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:29:35 -0700 Subject: [PATCH 30/53] Add behavior tests for extracted menu model --- main_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/main_test.go b/main_test.go index a809dc9..002d28b 100644 --- a/main_test.go +++ b/main_test.go @@ -427,6 +427,114 @@ func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) { } } +func TestBuildSyncMenuModelForUnboundVault(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "mint-ledger", Title: "Mint Ledger"}, + }, + }) + u.state.Section = appstate.SectionEntries + + model := u.buildSyncMenuModel() + if !model.showRemoteSyncSetupShortcut() { + t.Fatal("model.showRemoteSyncSetupShortcut() = false, want true for an unbound open vault") + } + if model.showDirectRemoteSyncShortcut() { + t.Fatal("model.showDirectRemoteSyncShortcut() = true, want false without a saved binding") + } + if got := model.actionLabels(); !slices.Equal(got, []string{"Open Advanced Sync", "Set Up Remote Sync"}) { + t.Fatalf("model.actionLabels() = %v, want [Open Advanced Sync Set Up Remote Sync]", got) + } +} + +func TestBuildSyncMenuModelForBoundVault(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "mint-credential", + Title: "Mint Credentials", + Username: "verbal-kint", + }, + }, + RemoteProfiles: []vault.RemoteProfile{ + { + ID: "mint-profile", + Name: "Downtown Mint", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://mint.example.invalid/remote.php/dav", + Path: "/files/kint/mint.kdbx", + }, + }, + }) + u.state.Section = appstate.SectionEntries + u.selectedVaultRemoteProfileID = "mint-profile" + u.selectedVaultRemoteCredentialEntryID = "mint-credential" + u.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave + + model := u.buildSyncMenuModel() + if model.showRemoteSyncSetupShortcut() { + t.Fatal("model.showRemoteSyncSetupShortcut() = true, want false for a bound vault") + } + if !model.showDirectRemoteSyncShortcut() { + t.Fatal("model.showDirectRemoteSyncShortcut() = false, want true for a bound vault") + } + if !model.showRemoteSyncSettingsShortcut() { + t.Fatal("model.showRemoteSyncSettingsShortcut() = false, want true for a bound vault") + } + if !model.showRemoveRemoteSyncShortcut() { + t.Fatal("model.showRemoveRemoteSyncShortcut() = false, want true for a bound vault") + } + summary := model.savedBindingSummary + if !summary.ok { + t.Fatal("model.savedBindingSummary.ok = false, want true") + } + if summary.profileLabel != "Downtown Mint" { + t.Fatalf("model.savedBindingSummary.profileLabel = %q, want Downtown Mint", summary.profileLabel) + } + if summary.credentialLabel != "Mint Credentials · verbal-kint" { + t.Fatalf("model.savedBindingSummary.credentialLabel = %q, want Mint Credentials · verbal-kint", summary.credentialLabel) + } + if summary.syncLabel != "Syncs automatically on open and save." { + t.Fatalf("model.savedBindingSummary.syncLabel = %q, want automatic-sync summary", summary.syncLabel) + } +} + +func TestBuildSyncMenuModelShowsSaveCurrentBindingOnlyWithCompleteRemoteInput(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "mint-ledger", Title: "Mint Ledger"}, + }, + }) + u.state.Section = appstate.SectionEntries + + model := u.buildSyncMenuModel() + if model.showSaveCurrentBinding { + t.Fatal("model.showSaveCurrentBinding = true, want false without remote input") + } + + u.remoteBaseURL.SetText("https://mint.example.invalid/remote.php/dav") + u.remotePath.SetText("/files/kint/mint.kdbx") + u.remoteUsername.SetText("verbal-kint") + u.remotePassword.SetText("kobayashi") + + model = u.buildSyncMenuModel() + if !model.showSaveCurrentBinding { + t.Fatal("model.showSaveCurrentBinding = false, want true with complete remote input") + } + if got := model.saveCurrentRemoteBindingHeading(); got != "Bind this local vault to the current remote target" { + t.Fatalf("model.saveCurrentRemoteBindingHeading() = %q, want vault binding guidance", got) + } + if got := model.saveCurrentRemoteBindingButtonLabel(); got != "Save Remote In Vault" { + t.Fatalf("model.saveCurrentRemoteBindingButtonLabel() = %q, want Save Remote In Vault", got) + } +} + func TestUICurrentVaultSummary(t *testing.T) { t.Parallel() From b256a77d0c46a28f942aa0e9941ad7994e564729 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:36:22 -0700 Subject: [PATCH 31/53] Split command entrypoint from app package --- .gitea/workflows/ci.yml | 2 +- .gitignore | 2 +- APK.md | 2 +- Makefile | 4 ++-- README.md | 6 +++--- android_share_android.go | 2 +- android_share_stub.go | 2 +- main.go => app.go | 4 ++-- buildapk/config.go | 4 ++-- buildapk/config_test.go | 2 +- clipboard_gio.go | 2 +- clipboard_gio_test.go | 2 +- cmd/keepassgo/main.go | 7 +++++++ main_test.go | 2 +- packaging/archlinux/keepassgo-git/PKGBUILD.tmpl | 2 +- ui_accessibility.go | 2 +- ui_api.go | 2 +- ui_branding.go | 2 +- ui_editor.go | 2 +- ui_forms.go | 2 +- ui_header_dropdown.go | 2 +- ui_keyboard.go | 2 +- ui_layout_header.go | 2 +- ui_layout_lifecycle.go | 2 +- ui_preferences.go | 2 +- ui_shortcuts.go | 2 +- ui_sync_dialog.go | 2 +- ui_sync_menu_model.go | 2 +- 28 files changed, 39 insertions(+), 32 deletions(-) rename main.go => app.go (99%) create mode 100644 cmd/keepassgo/main.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8eb6b95..1f6e61d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: fi out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}" GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \ - go build -ldflags "-X main.appVersion=${app_version}" -o "${out}" . + go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo done - name: Build APK diff --git a/.gitignore b/.gitignore index c7b1e68..17e2268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ build/ *.apk -keepassgo +/keepassgo android/keepassgo-android.jar packaging/archlinux/keepassgo-git/*.pkg.tar.zst packaging/archlinux/keepassgo-git/PKGBUILD diff --git a/APK.md b/APK.md index c6bf885..6cb6606 100644 --- a/APK.md +++ b/APK.md @@ -29,7 +29,7 @@ Installed machine prerequisites expected by this repo: The repo tracks `gogio` as a Go tool, so the build runs through: ```sh -go tool gogio -target android ... +go tool gogio -target android ./cmd/keepassgo ... ``` The Android build uses the branded icon asset at: diff --git a/Makefile b/Makefile index 53ecf78..2473faf 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ APP_ID ?= org.julianfamily.keepassgo APK_OUT ?= build/keepassgo.apk APK_VERSION ?= 0.1.0.1 APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -GO_LDFLAGS ?= -X main.appVersion=$(APP_VERSION) +GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo.appVersion=$(APP_VERSION) ANDROID_MIN_SDK ?= 28 ANDROID_TARGET_SDK ?= 35 SIGNKEY ?= @@ -48,7 +48,7 @@ apk: android/keepassgo-android.jar -minsdk $(ANDROID_MIN_SDK) \ -targetsdk $(ANDROID_TARGET_SDK) \ -icon assets/keepassgo-icon.png \ - . + ./cmd/keepassgo android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) @test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } diff --git a/README.md b/README.md index e6e5c99..0691450 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,14 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib Desktop build: ```bash -go build ./... +go build ./cmd/keepassgo ``` By default, build outputs stamp the app version from `git describe --tags --always --dirty`. You can override the version shown in KeePassGO with: ```bash -go build -ldflags "-X main.appVersion=v0.0.1" ./... +go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=v0.0.1" ./cmd/keepassgo ``` ## Arch Linux Package @@ -89,7 +89,7 @@ go get -tool gioui.org/cmd/gogio@latest Package: ```bash -go tool gogio -target android -icon assets/keepassgo-icon.png . +go tool gogio -target android -icon assets/keepassgo-icon.png ./cmd/keepassgo ``` You will need the Android SDK and NDK installed and configured for real device or release packaging. diff --git a/android_share_android.go b/android_share_android.go index 865520e..56022b7 100644 --- a/android_share_android.go +++ b/android_share_android.go @@ -1,6 +1,6 @@ //go:build android -package main +package keepassgo /* #cgo CFLAGS: -Werror diff --git a/android_share_stub.go b/android_share_stub.go index 80a101f..a57b5e7 100644 --- a/android_share_stub.go +++ b/android_share_stub.go @@ -1,6 +1,6 @@ //go:build !android -package main +package keepassgo func newPlatformVaultSharer(goos string) vaultSharer { return nil diff --git a/main.go b/app.go similarity index 99% rename from main.go rename to app.go index fbe0339..1f70b59 100644 --- a/main.go +++ b/app.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "crypto/sha256" @@ -6917,7 +6917,7 @@ func fill(c color.NRGBA) layout.Widget { } } -func main() { +func Main() { mode := flag.String("mode", "", "window mode: desktop or phone") stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") diff --git a/buildapk/config.go b/buildapk/config.go index 310bf47..6658884 100644 --- a/buildapk/config.go +++ b/buildapk/config.go @@ -13,7 +13,7 @@ const ( DefaultAppID = "org.julianfamily.keepassgo" DefaultAPKOut = "build/keepassgo.apk" DefaultVersion = "0.1.0.1" - DefaultLdflags = "-X main.appVersion=dev" + DefaultLdflags = "-X git.julianfamily.org/keepassgo.appVersion=dev" DefaultMinSDK = "28" DefaultTargetSDK = "35" DefaultIconPath = "assets/keepassgo-icon.png" @@ -58,7 +58,7 @@ func (c Config) GogioArgs() []string { "-minsdk", c.MinSDK, "-targetsdk", c.TargetSDK, "-icon", c.IconPath, - ".", + "./cmd/keepassgo", } } diff --git a/buildapk/config_test.go b/buildapk/config_test.go index 7d6d6b1..f36c791 100644 --- a/buildapk/config_test.go +++ b/buildapk/config_test.go @@ -21,7 +21,7 @@ func TestDefaultConfigGogioArgs(t *testing.T) { "-minsdk", DefaultMinSDK, "-targetsdk", DefaultTargetSDK, "-icon", DefaultIconPath, - ".", + "./cmd/keepassgo", } if got := cfg.GogioArgs(); !slices.Equal(got, want) { diff --git a/clipboard_gio.go b/clipboard_gio.go index 08a1c37..fd2c1fb 100644 --- a/clipboard_gio.go +++ b/clipboard_gio.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "io" diff --git a/clipboard_gio_test.go b/clipboard_gio_test.go index aa4dcff..800e88a 100644 --- a/clipboard_gio_test.go +++ b/clipboard_gio_test.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "slices" diff --git a/cmd/keepassgo/main.go b/cmd/keepassgo/main.go new file mode 100644 index 0000000..c0387a2 --- /dev/null +++ b/cmd/keepassgo/main.go @@ -0,0 +1,7 @@ +package main + +import keepassgo "git.julianfamily.org/keepassgo" + +func main() { + keepassgo.Main() +} diff --git a/main_test.go b/main_test.go index 002d28b..a8427a7 100644 --- a/main_test.go +++ b/main_test.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "bytes" diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index 358b845..83624c3 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -41,7 +41,7 @@ build() { export GOFLAGS="-trimpath" local app_version app_version="$(git describe --tags --always --dirty)" - go build -ldflags "-X main.appVersion=${app_version}" -o keepassgo . + go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo } package() { diff --git a/ui_accessibility.go b/ui_accessibility.go index 4165fdf..625eef0 100644 --- a/ui_accessibility.go +++ b/ui_accessibility.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "fmt" diff --git a/ui_api.go b/ui_api.go index 178bb53..1ea02fb 100644 --- a/ui_api.go +++ b/ui_api.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "fmt" diff --git a/ui_branding.go b/ui_branding.go index 379d418..6ab5804 100644 --- a/ui_branding.go +++ b/ui_branding.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "image" diff --git a/ui_editor.go b/ui_editor.go index 9083869..f7d8a00 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "fmt" diff --git a/ui_forms.go b/ui_forms.go index 28be39e..b057199 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "fmt" diff --git a/ui_header_dropdown.go b/ui_header_dropdown.go index 4e1bbe2..94c6c00 100644 --- a/ui_header_dropdown.go +++ b/ui_header_dropdown.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "image" diff --git a/ui_keyboard.go b/ui_keyboard.go index 9dfdc26..1af147d 100644 --- a/ui_keyboard.go +++ b/ui_keyboard.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "fmt" diff --git a/ui_layout_header.go b/ui_layout_header.go index 8961231..8becd22 100644 --- a/ui_layout_header.go +++ b/ui_layout_header.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "image" diff --git a/ui_layout_lifecycle.go b/ui_layout_lifecycle.go index 25b67ac..f21f680 100644 --- a/ui_layout_lifecycle.go +++ b/ui_layout_lifecycle.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "gioui.org/layout" diff --git a/ui_preferences.go b/ui_preferences.go index e1715ca..13324f8 100644 --- a/ui_preferences.go +++ b/ui_preferences.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "encoding/json" diff --git a/ui_shortcuts.go b/ui_shortcuts.go index 9d8e304..fb4554d 100644 --- a/ui_shortcuts.go +++ b/ui_shortcuts.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "strings" diff --git a/ui_sync_dialog.go b/ui_sync_dialog.go index bb794fd..bf847a1 100644 --- a/ui_sync_dialog.go +++ b/ui_sync_dialog.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "image/color" diff --git a/ui_sync_menu_model.go b/ui_sync_menu_model.go index 34893be..8ccc03c 100644 --- a/ui_sync_menu_model.go +++ b/ui_sync_menu_model.go @@ -1,4 +1,4 @@ -package main +package keepassgo import ( "runtime" From 07a071503abfae5f185a748807a8b01c63b6bc49 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:49:07 -0700 Subject: [PATCH 32/53] Use viewport width for adaptive layout --- app.go | 59 +++++++++++++++++++++++++--------------- main_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++ ui_branding.go | 4 +-- ui_forms.go | 10 +++---- ui_layout_header.go | 18 ++++++------- ui_layout_lifecycle.go | 2 +- 6 files changed, 115 insertions(+), 39 deletions(-) diff --git a/app.go b/app.go index 1f70b59..f32b357 100644 --- a/app.go +++ b/app.go @@ -453,6 +453,8 @@ type ui struct { splitBase float32 splitStartY float32 phoneSpan int + compactViewport bool + viewportMeasured bool phoneGroupBrowserExpanded bool eyeIcon *widget.Icon eyeOffIcon *widget.Icon @@ -3896,16 +3898,28 @@ func (u *ui) lifecycleBusy() bool { return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" } +func (u *ui) updateViewportLayoutMode(gtx layout.Context) { + u.viewportMeasured = true + u.compactViewport = gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) +} + +func (u *ui) usesCompactViewport() bool { + if u.viewportMeasured { + return u.compactViewport + } + return u.mode == "phone" +} + func (u *ui) shouldUseLockedSinglePane() bool { return u.isVaultLocked() && !u.shouldShowLifecycleSetup() } func (u *ui) shouldShowDesktopWorkingHeader() bool { - return u.mode == "desktop" && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() + return !u.usesCompactViewport() && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() } func (u *ui) shouldUseCompactPhoneDetailPane() bool { - if u.mode != "phone" { + if !u.usesCompactViewport() { return false } if u.isVaultLocked() || u.editingEntry { @@ -4044,7 +4058,7 @@ func (u *ui) ensureNavClickables() { } func (u *ui) syncPhoneGroupBrowser(path []string) { - if u.mode != "phone" { + if !u.usesCompactViewport() { return } u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 @@ -4651,6 +4665,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if _, changed := u.search.Update(gtx); changed { u.filter() } + u.updateViewportLayoutMode(gtx) inset := layout.UniformInset(unit.Dp(16)) return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { @@ -4688,7 +4703,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if u.shouldUseLockedSinglePane() { return u.detailPanel(gtx) } - if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { + if u.usesCompactViewport() { u.phoneSpan = gtx.Constraints.Max.Y listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) if listHeight < gtx.Dp(unit.Dp(180)) { @@ -5053,7 +5068,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Package rules", "One rule per line, for example `com.android.chrome=hostname` or `org.keepassgo.browser=view-id`.", &u.autofillPackageRules, false), func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, @@ -5307,7 +5322,7 @@ func aboutFact(theme *material.Theme, title, primary, secondary string) layout.W } func (u *ui) sectionSpacing() unit.Dp { - if u.mode == "phone" { + if u.usesCompactViewport() { if u.denseLayout { return unit.Dp(4) } @@ -5334,7 +5349,7 @@ func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, uni pathSize = unit.Sp(10) dividerGap = unit.Dp(5) } - if u.mode == "phone" { + if u.usesCompactViewport() { inset = unit.Dp(9) titleSize = unit.Sp(15) metaSize = unit.Sp(12) @@ -5377,7 +5392,7 @@ func (u *ui) listPanelSearchRow(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionAbout { return layout.Dimensions{} } - if u.mode == "phone" { + if u.usesCompactViewport() { gtx.Constraints.Min.X = gtx.Constraints.Max.X } return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { @@ -5398,7 +5413,7 @@ func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionEntries: label := "Add Entry" - if u.mode == "phone" { + if u.usesCompactViewport() { label = "+ " + label } btn := material.Button(u.theme, &u.addEntry, label) @@ -5413,11 +5428,11 @@ func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions { func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card spacing := u.sectionSpacing() - if u.mode == "phone" { + if u.usesCompactViewport() { panel = compactCard } u.ensureNavClickables() - if u.mode == "phone" { + if u.usesCompactViewport() { return panel(gtx, func(gtx layout.Context) layout.Dimensions { visibleEntries, entryClicks := u.visibleEntrySnapshot() rows := make([]layout.Widget, 0, 16+len(visibleEntries)) @@ -5561,7 +5576,7 @@ func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { {click: &u.showAPITokens, label: "API Tokens", compact: "Tokens", active: u.state.Section == appstate.SectionAPITokens}, {click: &u.showAPIAudit, label: "API Audit", compact: "Audit", active: u.state.Section == appstate.SectionAPIAudit}, } - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, @@ -5713,7 +5728,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item } func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { - if u.mode != "phone" { + if !u.usesCompactViewport() { return layout.Dimensions{} } for { @@ -5759,7 +5774,7 @@ func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { panel := card - if u.mode == "phone" { + if u.usesCompactViewport() { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions { @@ -5874,7 +5889,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { sectionGap = unit.Dp(4) cardGap = unit.Dp(6) } - if u.mode == "phone" { + if u.usesCompactViewport() { titleSize = unit.Sp(18) titlePad = unit.Dp(4) sectionGap = unit.Dp(4) @@ -5898,7 +5913,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { layout.Spacer{Height: titlePad}.Layout, func(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionRecycleBin { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { return compactTonedButton(gtx, u.theme, &u.copyUser, "Copy Username") @@ -5955,7 +5970,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { layout.Rigid(u.passwordLine("Password", password)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return compactTonedButton(gtx, u.theme, &u.copyURL, "Copy URL") } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, @@ -6478,7 +6493,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { 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" { + if u.usesCompactViewport() { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} } else { @@ -6491,7 +6506,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "/") lbl.Color = mutedColor inset := unit.Dp(6) - if u.mode == "phone" { + if u.usesCompactViewport() { inset = unit.Dp(4) } return layout.UniformInset(inset).Layout(gtx, lbl.Layout) @@ -6515,7 +6530,7 @@ func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { return indices }() } - if u.mode != "phone" || len(displayPath) <= 2 { + if !u.usesCompactViewport() || len(displayPath) <= 2 { crumbs := append([]string{"/"}, append([]string{}, displayPath...)...) indices := make([]int, 0, len(crumbs)) indices = append(indices, 0) @@ -6535,7 +6550,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { u.groupClicks = make([]widget.Clickable, len(groups)) } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { if len(u.displayPath()) == 0 { u.phoneGroupBrowserExpanded = true } @@ -6562,7 +6577,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } maxGroupListHeight := 200 - if u.mode == "phone" { + if u.usesCompactViewport() { maxGroupListHeight = 96 } maxY := gtx.Dp(unit.Dp(maxGroupListHeight)) diff --git a/main_test.go b/main_test.go index a8427a7..31e5dcf 100644 --- a/main_test.go +++ b/main_test.go @@ -1334,6 +1334,67 @@ func TestUIPhoneBackClosesSettingsDialog(t *testing.T) { } } +func TestUIWidePhoneViewportUsesDesktopLayout(t *testing.T) { + t.Parallel() + + u := newUIWithModel("phone", vault.Model{ + Entries: []vault.Entry{{ID: "entry-1", Title: "Vault Console"}}, + }) + u.state.ShowSection(appstate.SectionEntries) + u.updateViewportLayoutMode(layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(1200, 900)), + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + }) + + if u.usesCompactViewport() { + t.Fatal("usesCompactViewport() = true, want false for wide phone viewport") + } + if !u.shouldShowDesktopWorkingHeader() { + t.Fatal("shouldShowDesktopWorkingHeader() = false, want true for wide phone viewport") + } +} + +func TestUINarrowDesktopViewportUsesCompactLayout(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ID: "entry-1", Title: "Vault Console"}}, + }) + u.state.ShowSection(appstate.SectionEntries) + u.updateViewportLayoutMode(layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(540, 900)), + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + }) + + if !u.usesCompactViewport() { + t.Fatal("usesCompactViewport() = false, want true for narrow desktop viewport") + } + if u.shouldShowDesktopWorkingHeader() { + t.Fatal("shouldShowDesktopWorkingHeader() = true, want false for narrow desktop viewport") + } +} + +func TestUIWidePhoneViewportKeepsAndroidBackBehavior(t *testing.T) { + t.Parallel() + + u := newUIWithModel("phone", vault.Model{}) + u.securityDialogOpen = true + u.updateViewportLayoutMode(layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(1200, 900)), + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + }) + + if !u.handlePhoneBack() { + t.Fatal("handlePhoneBack() = false, want true for wide phone viewport") + } + if u.securityDialogOpen { + t.Fatal("securityDialogOpen = true after back, want false") + } +} + func TestUISecurityDialogContentDoesNotPanicWithSmallViewport(t *testing.T) { t.Parallel() diff --git a/ui_branding.go b/ui_branding.go index 6ab5804..d9da84a 100644 --- a/ui_branding.go +++ b/ui_branding.go @@ -10,14 +10,14 @@ import ( ) func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions { - if u.mode != "phone" { + if !u.usesCompactViewport() { return layout.Dimensions{} } return layout.Dimensions{} } func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return u.brandImage(gtx, u.splashSquare, widthDP, heightDP) } return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP) diff --git a/ui_forms.go b/ui_forms.go index b057199..8110614 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -226,7 +226,7 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool { - return u.mode == "phone" + return u.usesCompactViewport() } func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions { @@ -938,7 +938,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Group Tools" size := unit.Sp(12) - if u.mode == "phone" { + if u.usesCompactViewport() { size = unit.Sp(11) } lbl := material.Label(u.theme, size, label) @@ -948,7 +948,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions { ) }) } - if u.mode == "phone" { + if u.usesCompactViewport() { return content(gtx) } return compactCard(gtx, content) @@ -1013,7 +1013,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") @@ -1072,7 +1072,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") diff --git a/ui_layout_header.go b/ui_layout_header.go index 8becd22..7fabfa2 100644 --- a/ui_layout_header.go +++ b/ui_layout_header.go @@ -13,7 +13,7 @@ import ( ) func (u *ui) header(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { if u.shouldShowLifecycleSetup() || u.isVaultLocked() { return layout.Dimensions{} } @@ -69,7 +69,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { metrics.RowDims = row(gtx) rowCall := rowOps.Stop() - if u.mode == "phone" { + if u.usesCompactViewport() { metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) } @@ -79,7 +79,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { rowCall.Add(gtx.Ops) rowStack.Pop() - if u.mode == "phone" { + if u.usesCompactViewport() { if u.syncMenuOpen { u.phoneSyncMenuVisible = true u.phoneSyncMenuAnchor = metrics.syncAnchor().point() @@ -155,13 +155,13 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { label := "Sync" spacing := unit.Dp(4) - if u.mode == "phone" { + if u.usesCompactViewport() { spacing = unit.Dp(3) } row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport()) }), layout.Rigid(layout.Spacer{Width: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -183,7 +183,7 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { } btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) - if u.mode == "phone" { + if u.usesCompactViewport() { btn.Size = unit.Dp(16) btn.Inset = layout.UniformInset(unit.Dp(7)) } @@ -460,7 +460,7 @@ func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { } func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { - if u.mode != "phone" { + if !u.usesCompactViewport() { return layout.Dimensions{} } if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { @@ -484,11 +484,11 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } func (u *ui) syncMenuVisibleOnPhone() bool { - return u.mode == "phone" && u.phoneSyncMenuVisible && u.syncMenuOpen + return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen } func (u *ui) mainMenuVisibleOnPhone() bool { - return u.mode == "phone" && u.phoneMainMenuVisible && u.mainMenuOpen + return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen } func (u *ui) syncMenuDropsBelowTrigger() bool { diff --git a/ui_layout_lifecycle.go b/ui_layout_lifecycle.go index f21f680..ecca665 100644 --- a/ui_layout_lifecycle.go +++ b/ui_layout_lifecycle.go @@ -8,7 +8,7 @@ import ( func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { panel := card - if u.mode == "phone" { + if u.usesCompactViewport() { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions { From 7751b5472add24d85ea65288d63373e80253338b Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 06:35:59 -0700 Subject: [PATCH 33/53] Move app UI package under internal/appui --- Makefile | 2 +- README.md | 2 +- buildapk/config.go | 2 +- cmd/keepassgo/main.go | 4 ++-- .../appui/android_share_android.go | 2 +- android_share_stub.go => internal/appui/android_share_stub.go | 2 +- app.go => internal/appui/app.go | 2 +- clipboard_gio.go => internal/appui/clipboard_gio.go | 2 +- clipboard_gio_test.go => internal/appui/clipboard_gio_test.go | 2 +- main_test.go => internal/appui/main_test.go | 2 +- ui_accessibility.go => internal/appui/ui_accessibility.go | 2 +- ui_api.go => internal/appui/ui_api.go | 2 +- ui_branding.go => internal/appui/ui_branding.go | 2 +- ui_editor.go => internal/appui/ui_editor.go | 2 +- ui_forms.go => internal/appui/ui_forms.go | 2 +- ui_header_dropdown.go => internal/appui/ui_header_dropdown.go | 2 +- ui_keyboard.go => internal/appui/ui_keyboard.go | 2 +- ui_layout_header.go => internal/appui/ui_layout_header.go | 2 +- .../appui/ui_layout_lifecycle.go | 2 +- ui_preferences.go => internal/appui/ui_preferences.go | 2 +- ui_shortcuts.go => internal/appui/ui_shortcuts.go | 2 +- ui_sync_dialog.go => internal/appui/ui_sync_dialog.go | 2 +- ui_sync_menu_model.go => internal/appui/ui_sync_menu_model.go | 2 +- packaging/archlinux/keepassgo-git/PKGBUILD.tmpl | 2 +- 24 files changed, 25 insertions(+), 25 deletions(-) rename android_share_android.go => internal/appui/android_share_android.go (99%) rename android_share_stub.go => internal/appui/android_share_stub.go (83%) rename app.go => internal/appui/app.go (99%) rename clipboard_gio.go => internal/appui/clipboard_gio.go (98%) rename clipboard_gio_test.go => internal/appui/clipboard_gio_test.go (98%) rename main_test.go => internal/appui/main_test.go (99%) rename ui_accessibility.go => internal/appui/ui_accessibility.go (99%) rename ui_api.go => internal/appui/ui_api.go (99%) rename ui_branding.go => internal/appui/ui_branding.go (98%) rename ui_editor.go => internal/appui/ui_editor.go (99%) rename ui_forms.go => internal/appui/ui_forms.go (99%) rename ui_header_dropdown.go => internal/appui/ui_header_dropdown.go (99%) rename ui_keyboard.go => internal/appui/ui_keyboard.go (99%) rename ui_layout_header.go => internal/appui/ui_layout_header.go (99%) rename ui_layout_lifecycle.go => internal/appui/ui_layout_lifecycle.go (96%) rename ui_preferences.go => internal/appui/ui_preferences.go (99%) rename ui_shortcuts.go => internal/appui/ui_shortcuts.go (99%) rename ui_sync_dialog.go => internal/appui/ui_sync_dialog.go (99%) rename ui_sync_menu_model.go => internal/appui/ui_sync_menu_model.go (99%) diff --git a/Makefile b/Makefile index 2473faf..599a156 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ APP_ID ?= org.julianfamily.keepassgo APK_OUT ?= build/keepassgo.apk APK_VERSION ?= 0.1.0.1 APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo.appVersion=$(APP_VERSION) +GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION) ANDROID_MIN_SDK ?= 28 ANDROID_TARGET_SDK ?= 35 SIGNKEY ?= diff --git a/README.md b/README.md index 0691450..29f4929 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ By default, build outputs stamp the app version from `git describe --tags --alwa You can override the version shown in KeePassGO with: ```bash -go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=v0.0.1" ./cmd/keepassgo +go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo ``` ## Arch Linux Package diff --git a/buildapk/config.go b/buildapk/config.go index 6658884..35e53b3 100644 --- a/buildapk/config.go +++ b/buildapk/config.go @@ -13,7 +13,7 @@ const ( DefaultAppID = "org.julianfamily.keepassgo" DefaultAPKOut = "build/keepassgo.apk" DefaultVersion = "0.1.0.1" - DefaultLdflags = "-X git.julianfamily.org/keepassgo.appVersion=dev" + DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev" DefaultMinSDK = "28" DefaultTargetSDK = "35" DefaultIconPath = "assets/keepassgo-icon.png" diff --git a/cmd/keepassgo/main.go b/cmd/keepassgo/main.go index c0387a2..b8a9aee 100644 --- a/cmd/keepassgo/main.go +++ b/cmd/keepassgo/main.go @@ -1,7 +1,7 @@ package main -import keepassgo "git.julianfamily.org/keepassgo" +import "git.julianfamily.org/keepassgo/internal/appui" func main() { - keepassgo.Main() + appui.Main() } diff --git a/android_share_android.go b/internal/appui/android_share_android.go similarity index 99% rename from android_share_android.go rename to internal/appui/android_share_android.go index 56022b7..a7d0769 100644 --- a/android_share_android.go +++ b/internal/appui/android_share_android.go @@ -1,6 +1,6 @@ //go:build android -package keepassgo +package appui /* #cgo CFLAGS: -Werror diff --git a/android_share_stub.go b/internal/appui/android_share_stub.go similarity index 83% rename from android_share_stub.go rename to internal/appui/android_share_stub.go index a57b5e7..9a557c5 100644 --- a/android_share_stub.go +++ b/internal/appui/android_share_stub.go @@ -1,6 +1,6 @@ //go:build !android -package keepassgo +package appui func newPlatformVaultSharer(goos string) vaultSharer { return nil diff --git a/app.go b/internal/appui/app.go similarity index 99% rename from app.go rename to internal/appui/app.go index f32b357..5650081 100644 --- a/app.go +++ b/internal/appui/app.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "crypto/sha256" diff --git a/clipboard_gio.go b/internal/appui/clipboard_gio.go similarity index 98% rename from clipboard_gio.go rename to internal/appui/clipboard_gio.go index fd2c1fb..ac753fb 100644 --- a/clipboard_gio.go +++ b/internal/appui/clipboard_gio.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "io" diff --git a/clipboard_gio_test.go b/internal/appui/clipboard_gio_test.go similarity index 98% rename from clipboard_gio_test.go rename to internal/appui/clipboard_gio_test.go index 800e88a..18a6b35 100644 --- a/clipboard_gio_test.go +++ b/internal/appui/clipboard_gio_test.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "slices" diff --git a/main_test.go b/internal/appui/main_test.go similarity index 99% rename from main_test.go rename to internal/appui/main_test.go index 31e5dcf..fbc4b16 100644 --- a/main_test.go +++ b/internal/appui/main_test.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "bytes" diff --git a/ui_accessibility.go b/internal/appui/ui_accessibility.go similarity index 99% rename from ui_accessibility.go rename to internal/appui/ui_accessibility.go index 625eef0..2954f2b 100644 --- a/ui_accessibility.go +++ b/internal/appui/ui_accessibility.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "fmt" diff --git a/ui_api.go b/internal/appui/ui_api.go similarity index 99% rename from ui_api.go rename to internal/appui/ui_api.go index 1ea02fb..d1ba144 100644 --- a/ui_api.go +++ b/internal/appui/ui_api.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "fmt" diff --git a/ui_branding.go b/internal/appui/ui_branding.go similarity index 98% rename from ui_branding.go rename to internal/appui/ui_branding.go index d9da84a..396d742 100644 --- a/ui_branding.go +++ b/internal/appui/ui_branding.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "image" diff --git a/ui_editor.go b/internal/appui/ui_editor.go similarity index 99% rename from ui_editor.go rename to internal/appui/ui_editor.go index f7d8a00..f8a8155 100644 --- a/ui_editor.go +++ b/internal/appui/ui_editor.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "fmt" diff --git a/ui_forms.go b/internal/appui/ui_forms.go similarity index 99% rename from ui_forms.go rename to internal/appui/ui_forms.go index 8110614..eb80c7b 100644 --- a/ui_forms.go +++ b/internal/appui/ui_forms.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "fmt" diff --git a/ui_header_dropdown.go b/internal/appui/ui_header_dropdown.go similarity index 99% rename from ui_header_dropdown.go rename to internal/appui/ui_header_dropdown.go index 94c6c00..5a4f952 100644 --- a/ui_header_dropdown.go +++ b/internal/appui/ui_header_dropdown.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "image" diff --git a/ui_keyboard.go b/internal/appui/ui_keyboard.go similarity index 99% rename from ui_keyboard.go rename to internal/appui/ui_keyboard.go index 1af147d..0c0c70e 100644 --- a/ui_keyboard.go +++ b/internal/appui/ui_keyboard.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "fmt" diff --git a/ui_layout_header.go b/internal/appui/ui_layout_header.go similarity index 99% rename from ui_layout_header.go rename to internal/appui/ui_layout_header.go index 7fabfa2..83b73ac 100644 --- a/ui_layout_header.go +++ b/internal/appui/ui_layout_header.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "image" diff --git a/ui_layout_lifecycle.go b/internal/appui/ui_layout_lifecycle.go similarity index 96% rename from ui_layout_lifecycle.go rename to internal/appui/ui_layout_lifecycle.go index ecca665..77ee870 100644 --- a/ui_layout_lifecycle.go +++ b/internal/appui/ui_layout_lifecycle.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "gioui.org/layout" diff --git a/ui_preferences.go b/internal/appui/ui_preferences.go similarity index 99% rename from ui_preferences.go rename to internal/appui/ui_preferences.go index 13324f8..4ae7040 100644 --- a/ui_preferences.go +++ b/internal/appui/ui_preferences.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "encoding/json" diff --git a/ui_shortcuts.go b/internal/appui/ui_shortcuts.go similarity index 99% rename from ui_shortcuts.go rename to internal/appui/ui_shortcuts.go index fb4554d..6597259 100644 --- a/ui_shortcuts.go +++ b/internal/appui/ui_shortcuts.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "strings" diff --git a/ui_sync_dialog.go b/internal/appui/ui_sync_dialog.go similarity index 99% rename from ui_sync_dialog.go rename to internal/appui/ui_sync_dialog.go index bf847a1..02ad2a3 100644 --- a/ui_sync_dialog.go +++ b/internal/appui/ui_sync_dialog.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "image/color" diff --git a/ui_sync_menu_model.go b/internal/appui/ui_sync_menu_model.go similarity index 99% rename from ui_sync_menu_model.go rename to internal/appui/ui_sync_menu_model.go index 8ccc03c..e827f1e 100644 --- a/ui_sync_menu_model.go +++ b/internal/appui/ui_sync_menu_model.go @@ -1,4 +1,4 @@ -package keepassgo +package appui import ( "runtime" diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index 83624c3..275088d 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -41,7 +41,7 @@ build() { export GOFLAGS="-trimpath" local app_version app_version="$(git describe --tags --always --dirty)" - go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo + go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo } package() { From fe921b879075bcf2afed3e44069b8ca41321c646 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 06:42:21 -0700 Subject: [PATCH 34/53] Move app packages under internal --- APK.md | 2 +- Makefile | 2 +- README.md | 2 +- buildapk/config.go | 2 +- {api => internal/api}/host.go | 26 ++-- {api => internal/api}/host_test.go | 6 +- {api => internal/api}/server.go | 16 +-- {api => internal/api}/server_test.go | 122 +++++++++--------- .../apiapproval}/approval.go | 24 ++-- .../apiapproval}/approval_test.go | 2 +- {apiaudit => internal/apiaudit}/audit.go | 2 +- {apiaudit => internal/apiaudit}/audit_test.go | 2 +- {apitokens => internal/apitokens}/tokens.go | 2 +- .../apitokens}/tokens_test.go | 2 +- .../appstate}/remote_binding.go | 2 +- .../appstate}/remote_binding_test.go | 2 +- {appstate => internal/appstate}/state.go | 8 +- {appstate => internal/appstate}/state_test.go | 10 +- internal/appui/app.go | 24 ++-- internal/appui/clipboard_gio.go | 2 +- internal/appui/main_test.go | 18 +-- internal/appui/ui_api.go | 4 +- internal/appui/ui_editor.go | 6 +- internal/appui/ui_forms.go | 2 +- internal/appui/ui_keyboard.go | 2 +- internal/appui/ui_preferences.go | 2 +- internal/appui/ui_shortcuts.go | 2 +- internal/appui/ui_sync_menu_model.go | 2 +- {assets => internal/assets}/assets.go | 0 .../assets}/keepassgo-icon.png | Bin .../assets}/keepassgo-icon.svg | 0 .../assets}/keepassgo-logo-horizontal.png | Bin .../assets}/keepassgo-logo-horizontal.svg | 0 .../assets}/keepassgo-splash-light.svg | 0 .../assets}/keepassgo-splash-square.png | Bin .../assets}/keepassgo-splash-square.svg | 0 .../autofillcache}/cache.go | 2 +- .../autofillcache}/cache_test.go | 2 +- {clipboard => internal/clipboard}/service.go | 2 +- .../clipboard}/service_test.go | 2 +- .../passwords}/generator.go | 0 .../passwords}/generator_test.go | 0 {session => internal/session}/session.go | 4 +- {session => internal/session}/session_test.go | 4 +- {vault => internal/vault}/history_test.go | 0 {vault => internal/vault}/kdbx.go | 0 {vault => internal/vault}/kdbx_test.go | 0 {vault => internal/vault}/masterkey.go | 0 {vault => internal/vault}/model.go | 0 {vault => internal/vault}/model_test.go | 0 {vault => internal/vault}/security.go | 6 +- {vault => internal/vault}/security_test.go | 0 {webdav => internal/webdav}/client.go | 0 {webdav => internal/webdav}/client_test.go | 0 .../archlinux/keepassgo-git/PKGBUILD.tmpl | 4 +- 55 files changed, 162 insertions(+), 162 deletions(-) rename {api => internal/api}/host.go (83%) rename {api => internal/api}/host_test.go (93%) rename {api => internal/api}/server.go (98%) rename {api => internal/api}/server_test.go (92%) rename {apiapproval => internal/apiapproval}/approval.go (90%) rename {apiapproval => internal/apiapproval}/approval_test.go (98%) rename {apiaudit => internal/apiaudit}/audit.go (96%) rename {apiaudit => internal/apiaudit}/audit_test.go (97%) rename {apitokens => internal/apitokens}/tokens.go (99%) rename {apitokens => internal/apitokens}/tokens_test.go (99%) rename {appstate => internal/appstate}/remote_binding.go (98%) rename {appstate => internal/appstate}/remote_binding_test.go (99%) rename {appstate => internal/appstate}/state.go (99%) rename {appstate => internal/appstate}/state_test.go (99%) rename {assets => internal/assets}/assets.go (100%) rename {assets => internal/assets}/keepassgo-icon.png (100%) rename {assets => internal/assets}/keepassgo-icon.svg (100%) rename {assets => internal/assets}/keepassgo-logo-horizontal.png (100%) rename {assets => internal/assets}/keepassgo-logo-horizontal.svg (100%) rename {assets => internal/assets}/keepassgo-splash-light.svg (100%) rename {assets => internal/assets}/keepassgo-splash-square.png (100%) rename {assets => internal/assets}/keepassgo-splash-square.svg (100%) rename {autofillcache => internal/autofillcache}/cache.go (99%) rename {autofillcache => internal/autofillcache}/cache_test.go (99%) rename {clipboard => internal/clipboard}/service.go (97%) rename {clipboard => internal/clipboard}/service_test.go (98%) rename {passwords => internal/passwords}/generator.go (100%) rename {passwords => internal/passwords}/generator_test.go (100%) rename {session => internal/session}/session.go (99%) rename {session => internal/session}/session_test.go (99%) rename {vault => internal/vault}/history_test.go (100%) rename {vault => internal/vault}/kdbx.go (100%) rename {vault => internal/vault}/kdbx_test.go (100%) rename {vault => internal/vault}/masterkey.go (100%) rename {vault => internal/vault}/model.go (100%) rename {vault => internal/vault}/model_test.go (100%) rename {vault => internal/vault}/security.go (97%) rename {vault => internal/vault}/security_test.go (100%) rename {webdav => internal/webdav}/client.go (100%) rename {webdav => internal/webdav}/client_test.go (100%) diff --git a/APK.md b/APK.md index 6cb6606..3f7e7d9 100644 --- a/APK.md +++ b/APK.md @@ -34,7 +34,7 @@ go tool gogio -target android ./cmd/keepassgo ... The Android build uses the branded icon asset at: -- `assets/keepassgo-icon.png` +- `internal/assets/keepassgo-icon.png` Note: diff --git a/Makefile b/Makefile index 599a156..0e8fed1 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ apk: android/keepassgo-android.jar -version $(APK_VERSION) \ -minsdk $(ANDROID_MIN_SDK) \ -targetsdk $(ANDROID_TARGET_SDK) \ - -icon assets/keepassgo-icon.png \ + -icon internal/assets/keepassgo-icon.png \ ./cmd/keepassgo android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) diff --git a/README.md b/README.md index 29f4929..8118850 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ go get -tool gioui.org/cmd/gogio@latest Package: ```bash -go tool gogio -target android -icon assets/keepassgo-icon.png ./cmd/keepassgo +go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo ``` You will need the Android SDK and NDK installed and configured for real device or release packaging. diff --git a/buildapk/config.go b/buildapk/config.go index 35e53b3..66541e2 100644 --- a/buildapk/config.go +++ b/buildapk/config.go @@ -16,7 +16,7 @@ const ( DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev" DefaultMinSDK = "28" DefaultTargetSDK = "35" - DefaultIconPath = "assets/keepassgo-icon.png" + DefaultIconPath = "internal/assets/keepassgo-icon.png" ) type Config struct { diff --git a/api/host.go b/internal/api/host.go similarity index 83% rename from api/host.go rename to internal/api/host.go index c0e67f1..3dadb64 100644 --- a/api/host.go +++ b/internal/api/host.go @@ -7,26 +7,26 @@ import ( "strings" "sync" - "git.julianfamily.org/keepassgo/clipboard" - "git.julianfamily.org/keepassgo/passwords" + "git.julianfamily.org/keepassgo/internal/clipboard" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" - "git.julianfamily.org/keepassgo/session" - "git.julianfamily.org/keepassgo/vault" "google.golang.org/grpc" ) type DirtyProvider func() bool type Host struct { - server *Server - grpcServer *grpc.Server - listener net.Listener - lifecycle lifecycleBackend - dirty DirtyProvider - mu sync.Mutex - lastModel vault.Model - started bool - listenAddr string + server *Server + grpcServer *grpc.Server + listener net.Listener + lifecycle lifecycleBackend + dirty DirtyProvider + mu sync.Mutex + lastModel vault.Model + started bool + listenAddr string } func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) { diff --git a/api/host_test.go b/internal/api/host_test.go similarity index 93% rename from api/host_test.go rename to internal/api/host_test.go index 0ab10c1..8de2477 100644 --- a/api/host_test.go +++ b/internal/api/host_test.go @@ -5,10 +5,10 @@ import ( "net" "testing" - "git.julianfamily.org/keepassgo/passwords" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" - "git.julianfamily.org/keepassgo/session" - "git.julianfamily.org/keepassgo/vault" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) diff --git a/api/server.go b/internal/api/server.go similarity index 98% rename from api/server.go rename to internal/api/server.go index 22be4e6..7daa9d6 100644 --- a/api/server.go +++ b/internal/api/server.go @@ -10,15 +10,15 @@ import ( "sync" "time" - "git.julianfamily.org/keepassgo/apiaudit" - "git.julianfamily.org/keepassgo/apiapproval" - "git.julianfamily.org/keepassgo/apitokens" - "git.julianfamily.org/keepassgo/clipboard" - "git.julianfamily.org/keepassgo/passwords" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/clipboard" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" - "git.julianfamily.org/keepassgo/session" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" diff --git a/api/server_test.go b/internal/api/server_test.go similarity index 92% rename from api/server_test.go rename to internal/api/server_test.go index 3a4d86e..0dcf686 100644 --- a/api/server_test.go +++ b/internal/api/server_test.go @@ -10,14 +10,14 @@ import ( "testing" "time" - "git.julianfamily.org/keepassgo/apiaudit" - "git.julianfamily.org/keepassgo/apiapproval" - "git.julianfamily.org/keepassgo/apitokens" - "git.julianfamily.org/keepassgo/passwords" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" - "git.julianfamily.org/keepassgo/session" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" @@ -1053,64 +1053,64 @@ func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { t.Helper() model := vault.Model{ - Entries: []vault.Entry{ - { - ID: "vault-console", - Title: "Vault Console", - Username: "dannyocean", - Password: "token-1", - URL: "https://vault.crew.example.invalid", - Fields: map[string]string{ - "X-Role": "automation", - }, - History: []vault.Entry{ - { - ID: "vault-console-h1", - Title: "Vault Console", - Username: "dannyocean", - Password: "token-0", - URL: "https://vault.crew.example.invalid", - Path: []string{"Root", "Internet"}, - }, - }, - Path: []string{"Root", "Internet"}, + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Fields: map[string]string{ + "X-Role": "automation", }, - { - ID: "surveillance-console", - Title: "Surveillance Console", - Username: "codex", - Password: "token-2", - URL: "https://surveillance.crew.example.invalid", - Path: []string{"Root", "Home Assistant"}, + History: []vault.Entry{ + { + ID: "vault-console-h1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-0", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, }, - testAPITokenEntry(t, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, - apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, - ), + Path: []string{"Root", "Internet"}, }, - Templates: []vault.Entry{ - { - ID: "website-login", - Title: "Website Login", - Username: "template-user", - Password: "template-password", - URL: "https://example.com", - Notes: "Reusable template for website accounts.", - Fields: map[string]string{ - "Environment": "prod", - }, - Tags: []string{"template", "web"}, - Path: []string{"Templates"}, - }, + { + ID: "surveillance-console", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, }, - } + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, + ), + }, + Templates: []vault.Entry{ + { + ID: "website-login", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + URL: "https://example.com", + Notes: "Reusable template for website accounts.", + Fields: map[string]string{ + "Environment": "prod", + }, + Tags: []string{"template", "web"}, + Path: []string{"Templates"}, + }, + }, + } return newTestClientForModel(t, model) } diff --git a/apiapproval/approval.go b/internal/apiapproval/approval.go similarity index 90% rename from apiapproval/approval.go rename to internal/apiapproval/approval.go index b569ae9..022f658 100644 --- a/apiapproval/approval.go +++ b/internal/apiapproval/approval.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "git.julianfamily.org/keepassgo/apitokens" + "git.julianfamily.org/keepassgo/internal/apitokens" ) var ( @@ -22,20 +22,20 @@ var ( type Outcome string const ( - OutcomeAllowOnce Outcome = "allow-once" - OutcomeDenyOnce Outcome = "deny-once" - OutcomeAllowPermanent Outcome = "allow-permanent" - OutcomeDenyPermanent Outcome = "deny-permanent" - OutcomeCancel Outcome = "cancel" + OutcomeAllowOnce Outcome = "allow-once" + OutcomeDenyOnce Outcome = "deny-once" + OutcomeAllowPermanent Outcome = "allow-permanent" + OutcomeDenyPermanent Outcome = "deny-permanent" + OutcomeCancel Outcome = "cancel" ) type Request struct { - ID string - TokenID string - TokenName string - ClientName string - Operation apitokens.Operation - Resource apitokens.Resource + ID string + TokenID string + TokenName string + ClientName string + Operation apitokens.Operation + Resource apitokens.Resource RequestedAt time.Time } diff --git a/apiapproval/approval_test.go b/internal/apiapproval/approval_test.go similarity index 98% rename from apiapproval/approval_test.go rename to internal/apiapproval/approval_test.go index 89bbadd..9d1230d 100644 --- a/apiapproval/approval_test.go +++ b/internal/apiapproval/approval_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "git.julianfamily.org/keepassgo/apitokens" + "git.julianfamily.org/keepassgo/internal/apitokens" ) func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) { diff --git a/apiaudit/audit.go b/internal/apiaudit/audit.go similarity index 96% rename from apiaudit/audit.go rename to internal/apiaudit/audit.go index f64b1e4..aabdb43 100644 --- a/apiaudit/audit.go +++ b/internal/apiaudit/audit.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "git.julianfamily.org/keepassgo/apitokens" + "git.julianfamily.org/keepassgo/internal/apitokens" ) type EventType string diff --git a/apiaudit/audit_test.go b/internal/apiaudit/audit_test.go similarity index 97% rename from apiaudit/audit_test.go rename to internal/apiaudit/audit_test.go index c42f978..7a13f02 100644 --- a/apiaudit/audit_test.go +++ b/internal/apiaudit/audit_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "git.julianfamily.org/keepassgo/apitokens" + "git.julianfamily.org/keepassgo/internal/apitokens" ) func TestLogKeepsNewestEventsWithinBound(t *testing.T) { diff --git a/apitokens/tokens.go b/internal/apitokens/tokens.go similarity index 99% rename from apitokens/tokens.go rename to internal/apitokens/tokens.go index 267ed52..b23ea60 100644 --- a/apitokens/tokens.go +++ b/internal/apitokens/tokens.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) const ( diff --git a/apitokens/tokens_test.go b/internal/apitokens/tokens_test.go similarity index 99% rename from apitokens/tokens_test.go rename to internal/apitokens/tokens_test.go index c00615c..79f553a 100644 --- a/apitokens/tokens_test.go +++ b/internal/apitokens/tokens_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) { diff --git a/appstate/remote_binding.go b/internal/appstate/remote_binding.go similarity index 98% rename from appstate/remote_binding.go rename to internal/appstate/remote_binding.go index f460c56..f7edfa4 100644 --- a/appstate/remote_binding.go +++ b/internal/appstate/remote_binding.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) type SyncMode string diff --git a/appstate/remote_binding_test.go b/internal/appstate/remote_binding_test.go similarity index 99% rename from appstate/remote_binding_test.go rename to internal/appstate/remote_binding_test.go index 8a25ba6..591b03e 100644 --- a/appstate/remote_binding_test.go +++ b/internal/appstate/remote_binding_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) { diff --git a/appstate/state.go b/internal/appstate/state.go similarity index 99% rename from appstate/state.go rename to internal/appstate/state.go index 7dfdc7f..03bfb95 100644 --- a/appstate/state.go +++ b/internal/appstate/state.go @@ -7,10 +7,10 @@ import ( "strings" "time" - "git.julianfamily.org/keepassgo/apiapproval" - "git.julianfamily.org/keepassgo/apitokens" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" ) type Section string diff --git a/appstate/state_test.go b/internal/appstate/state_test.go similarity index 99% rename from appstate/state_test.go rename to internal/appstate/state_test.go index e57bbcd..2010960 100644 --- a/appstate/state_test.go +++ b/internal/appstate/state_test.go @@ -6,11 +6,11 @@ import ( "testing" "time" - "git.julianfamily.org/keepassgo/apiapproval" - "git.julianfamily.org/keepassgo/apitokens" - "git.julianfamily.org/keepassgo/session" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" ) func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { diff --git a/internal/appui/app.go b/internal/appui/app.go index 5650081..5bdbec3 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -31,18 +31,18 @@ import ( "gioui.org/widget" "gioui.org/widget/material" "gioui.org/x/explorer" - "git.julianfamily.org/keepassgo/api" - "git.julianfamily.org/keepassgo/apiapproval" - "git.julianfamily.org/keepassgo/apiaudit" - "git.julianfamily.org/keepassgo/apitokens" - "git.julianfamily.org/keepassgo/appstate" - keepassassets "git.julianfamily.org/keepassgo/assets" - "git.julianfamily.org/keepassgo/autofillcache" - "git.julianfamily.org/keepassgo/clipboard" - "git.julianfamily.org/keepassgo/passwords" - "git.julianfamily.org/keepassgo/session" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/internal/api" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/appstate" + keepassassets "git.julianfamily.org/keepassgo/internal/assets" + "git.julianfamily.org/keepassgo/internal/autofillcache" + "git.julianfamily.org/keepassgo/internal/clipboard" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" "golang.org/x/exp/shiny/materialdesign/icons" ) diff --git a/internal/appui/clipboard_gio.go b/internal/appui/clipboard_gio.go index ac753fb..d4033ac 100644 --- a/internal/appui/clipboard_gio.go +++ b/internal/appui/clipboard_gio.go @@ -8,7 +8,7 @@ import ( gioclipboard "gioui.org/io/clipboard" "gioui.org/layout" - appclipboard "git.julianfamily.org/keepassgo/clipboard" + appclipboard "git.julianfamily.org/keepassgo/internal/clipboard" ) type clipboardCommandWriter struct { diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index fbc4b16..73c7687 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -21,15 +21,15 @@ import ( "gioui.org/unit" "gioui.org/widget" - "git.julianfamily.org/keepassgo/apiapproval" - "git.julianfamily.org/keepassgo/apiaudit" - "git.julianfamily.org/keepassgo/apitokens" - "git.julianfamily.org/keepassgo/appstate" - "git.julianfamily.org/keepassgo/clipboard" - "git.julianfamily.org/keepassgo/passwords" - "git.julianfamily.org/keepassgo/session" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/appstate" + "git.julianfamily.org/keepassgo/internal/clipboard" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" ) func TestMain(m *testing.M) { diff --git a/internal/appui/ui_api.go b/internal/appui/ui_api.go index d1ba144..e2c015d 100644 --- a/internal/appui/ui_api.go +++ b/internal/appui/ui_api.go @@ -10,8 +10,8 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" - "git.julianfamily.org/keepassgo/apiaudit" - "git.julianfamily.org/keepassgo/apitokens" + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" ) func apiOperations() []apitokens.Operation { diff --git a/internal/appui/ui_editor.go b/internal/appui/ui_editor.go index f8a8155..7542092 100644 --- a/internal/appui/ui_editor.go +++ b/internal/appui/ui_editor.go @@ -8,9 +8,9 @@ import ( "strings" "gioui.org/widget" - "git.julianfamily.org/keepassgo/clipboard" - "git.julianfamily.org/keepassgo/passwords" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/clipboard" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/vault" ) func (u *ui) attachmentInput() (string, []byte, error) { diff --git a/internal/appui/ui_forms.go b/internal/appui/ui_forms.go index eb80c7b..4b3d120 100644 --- a/internal/appui/ui_forms.go +++ b/internal/appui/ui_forms.go @@ -15,7 +15,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" - "git.julianfamily.org/keepassgo/appstate" + "git.julianfamily.org/keepassgo/internal/appstate" ) func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/ui_keyboard.go b/internal/appui/ui_keyboard.go index 0c0c70e..302dedc 100644 --- a/internal/appui/ui_keyboard.go +++ b/internal/appui/ui_keyboard.go @@ -6,7 +6,7 @@ import ( "strings" "gioui.org/io/key" - "git.julianfamily.org/keepassgo/appstate" + "git.julianfamily.org/keepassgo/internal/appstate" ) type focusID string diff --git a/internal/appui/ui_preferences.go b/internal/appui/ui_preferences.go index 4ae7040..8cb0edf 100644 --- a/internal/appui/ui_preferences.go +++ b/internal/appui/ui_preferences.go @@ -12,7 +12,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) const ( diff --git a/internal/appui/ui_shortcuts.go b/internal/appui/ui_shortcuts.go index 6597259..9144b0e 100644 --- a/internal/appui/ui_shortcuts.go +++ b/internal/appui/ui_shortcuts.go @@ -7,7 +7,7 @@ import ( "gioui.org/io/key" "gioui.org/layout" - "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/internal/clipboard" ) const ( diff --git a/internal/appui/ui_sync_menu_model.go b/internal/appui/ui_sync_menu_model.go index e827f1e..eca8d6f 100644 --- a/internal/appui/ui_sync_menu_model.go +++ b/internal/appui/ui_sync_menu_model.go @@ -4,7 +4,7 @@ import ( "runtime" "strings" - "git.julianfamily.org/keepassgo/appstate" + "git.julianfamily.org/keepassgo/internal/appstate" ) type syncMenuModel struct { diff --git a/assets/assets.go b/internal/assets/assets.go similarity index 100% rename from assets/assets.go rename to internal/assets/assets.go diff --git a/assets/keepassgo-icon.png b/internal/assets/keepassgo-icon.png similarity index 100% rename from assets/keepassgo-icon.png rename to internal/assets/keepassgo-icon.png diff --git a/assets/keepassgo-icon.svg b/internal/assets/keepassgo-icon.svg similarity index 100% rename from assets/keepassgo-icon.svg rename to internal/assets/keepassgo-icon.svg diff --git a/assets/keepassgo-logo-horizontal.png b/internal/assets/keepassgo-logo-horizontal.png similarity index 100% rename from assets/keepassgo-logo-horizontal.png rename to internal/assets/keepassgo-logo-horizontal.png diff --git a/assets/keepassgo-logo-horizontal.svg b/internal/assets/keepassgo-logo-horizontal.svg similarity index 100% rename from assets/keepassgo-logo-horizontal.svg rename to internal/assets/keepassgo-logo-horizontal.svg diff --git a/assets/keepassgo-splash-light.svg b/internal/assets/keepassgo-splash-light.svg similarity index 100% rename from assets/keepassgo-splash-light.svg rename to internal/assets/keepassgo-splash-light.svg diff --git a/assets/keepassgo-splash-square.png b/internal/assets/keepassgo-splash-square.png similarity index 100% rename from assets/keepassgo-splash-square.png rename to internal/assets/keepassgo-splash-square.png diff --git a/assets/keepassgo-splash-square.svg b/internal/assets/keepassgo-splash-square.svg similarity index 100% rename from assets/keepassgo-splash-square.svg rename to internal/assets/keepassgo-splash-square.svg diff --git a/autofillcache/cache.go b/internal/autofillcache/cache.go similarity index 99% rename from autofillcache/cache.go rename to internal/autofillcache/cache.go index e267526..0f84440 100644 --- a/autofillcache/cache.go +++ b/internal/autofillcache/cache.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) type Entry struct { diff --git a/autofillcache/cache_test.go b/internal/autofillcache/cache_test.go similarity index 99% rename from autofillcache/cache_test.go rename to internal/autofillcache/cache_test.go index f47050c..7d65470 100644 --- a/autofillcache/cache_test.go +++ b/internal/autofillcache/cache_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) func TestBuildFiltersAndNormalizesEntries(t *testing.T) { diff --git a/clipboard/service.go b/internal/clipboard/service.go similarity index 97% rename from clipboard/service.go rename to internal/clipboard/service.go index 7ef9ef7..639d67d 100644 --- a/clipboard/service.go +++ b/internal/clipboard/service.go @@ -5,7 +5,7 @@ import ( systemclipboard "github.com/atotto/clipboard" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) var ErrUnsupportedTarget = errors.New("unsupported clipboard target") diff --git a/clipboard/service_test.go b/internal/clipboard/service_test.go similarity index 98% rename from clipboard/service_test.go rename to internal/clipboard/service_test.go index c11730a..a601bb1 100644 --- a/clipboard/service_test.go +++ b/internal/clipboard/service_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/internal/vault" ) func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) { diff --git a/passwords/generator.go b/internal/passwords/generator.go similarity index 100% rename from passwords/generator.go rename to internal/passwords/generator.go diff --git a/passwords/generator_test.go b/internal/passwords/generator_test.go similarity index 100% rename from passwords/generator_test.go rename to internal/passwords/generator_test.go diff --git a/session/session.go b/internal/session/session.go similarity index 99% rename from session/session.go rename to internal/session/session.go index 8711559..b59ebbd 100644 --- a/session/session.go +++ b/internal/session/session.go @@ -11,8 +11,8 @@ import ( "slices" "strings" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" ) var ( diff --git a/session/session_test.go b/internal/session/session_test.go similarity index 99% rename from session/session_test.go rename to internal/session/session_test.go index bfda523..d2f38ab 100644 --- a/session/session_test.go +++ b/internal/session/session_test.go @@ -10,8 +10,8 @@ import ( "path/filepath" "testing" - "git.julianfamily.org/keepassgo/vault" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" "github.com/tobischo/gokeepasslib/v3" w "github.com/tobischo/gokeepasslib/v3/wrappers" ) diff --git a/vault/history_test.go b/internal/vault/history_test.go similarity index 100% rename from vault/history_test.go rename to internal/vault/history_test.go diff --git a/vault/kdbx.go b/internal/vault/kdbx.go similarity index 100% rename from vault/kdbx.go rename to internal/vault/kdbx.go diff --git a/vault/kdbx_test.go b/internal/vault/kdbx_test.go similarity index 100% rename from vault/kdbx_test.go rename to internal/vault/kdbx_test.go diff --git a/vault/masterkey.go b/internal/vault/masterkey.go similarity index 100% rename from vault/masterkey.go rename to internal/vault/masterkey.go diff --git a/vault/model.go b/internal/vault/model.go similarity index 100% rename from vault/model.go rename to internal/vault/model.go diff --git a/vault/model_test.go b/internal/vault/model_test.go similarity index 100% rename from vault/model_test.go rename to internal/vault/model_test.go diff --git a/vault/security.go b/internal/vault/security.go similarity index 97% rename from vault/security.go rename to internal/vault/security.go index 618f948..741bd23 100644 --- a/vault/security.go +++ b/internal/vault/security.go @@ -13,10 +13,10 @@ type SecuritySettings struct { } const ( - CipherAES256 = "aes256" + CipherAES256 = "aes256" CipherChaCha20 = "chacha20" - KDFAES = "aes-kdf" - KDFArgon2 = "argon2" + KDFAES = "aes-kdf" + KDFArgon2 = "argon2" ) func SupportedSecuritySettings() (ciphers []string, kdfs []string) { diff --git a/vault/security_test.go b/internal/vault/security_test.go similarity index 100% rename from vault/security_test.go rename to internal/vault/security_test.go diff --git a/webdav/client.go b/internal/webdav/client.go similarity index 100% rename from webdav/client.go rename to internal/webdav/client.go diff --git a/webdav/client_test.go b/internal/webdav/client_test.go similarity index 100% rename from webdav/client_test.go rename to internal/webdav/client_test.go diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index 275088d..fc6e40b 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -48,9 +48,9 @@ package() { cd "$(_repo_dir)" install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo" - install -Dm644 assets/keepassgo-icon.png \ + install -Dm644 internal/assets/keepassgo-icon.png \ "${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png" - install -Dm644 assets/keepassgo-icon.svg \ + install -Dm644 internal/assets/keepassgo-icon.svg \ "${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg" install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \ "${pkgdir}/usr/share/applications/keepassgo.desktop" From b593b1e6a7c230845ad9a386f1c8b29783c48685 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 06:47:36 -0700 Subject: [PATCH 35/53] Drop vendored gioui cmd fork --- go.mod | 2 - go.sum | 2 + third_party/gioui-cmd/.builds/apple.yml | 67 - third_party/gioui-cmd/.builds/freebsd.yml | 22 - third_party/gioui-cmd/.builds/linux.yml | 91 -- third_party/gioui-cmd/.builds/openbsd.yml | 18 - third_party/gioui-cmd/LICENSE | 63 - third_party/gioui-cmd/README.md | 21 - third_party/gioui-cmd/go.mod | 28 - third_party/gioui-cmd/go.sum | 44 - third_party/gioui-cmd/gogio/android_test.go | 143 --- third_party/gioui-cmd/gogio/androidbuild.go | 1110 ----------------- third_party/gioui-cmd/gogio/build_info.go | 206 --- .../gioui-cmd/gogio/build_info_test.go | 32 - third_party/gioui-cmd/gogio/doc.go | 10 - third_party/gioui-cmd/gogio/e2e_test.go | 337 ----- third_party/gioui-cmd/gogio/help.go | 83 -- .../gogio/internal/custom/testdata.go | 371 ------ .../gogio/internal/normal/testdata.go | 147 --- third_party/gioui-cmd/gogio/iosbuild.go | 546 -------- third_party/gioui-cmd/gogio/js_test.go | 123 -- third_party/gioui-cmd/gogio/jsbuild.go | 200 --- third_party/gioui-cmd/gogio/macosbuild.go | 262 ---- third_party/gioui-cmd/gogio/main.go | 230 ---- third_party/gioui-cmd/gogio/main_test.go | 17 - third_party/gioui-cmd/gogio/permission.go | 36 - third_party/gioui-cmd/gogio/race_test.go | 8 - third_party/gioui-cmd/gogio/wayland_test.go | 196 --- third_party/gioui-cmd/gogio/windows_test.go | 152 --- third_party/gioui-cmd/gogio/windowsbuild.go | 410 ------ third_party/gioui-cmd/gogio/x11_test.go | 170 --- third_party/gioui-cmd/svg2gio/main.go | 582 --------- 32 files changed, 2 insertions(+), 5727 deletions(-) delete mode 100644 third_party/gioui-cmd/.builds/apple.yml delete mode 100644 third_party/gioui-cmd/.builds/freebsd.yml delete mode 100644 third_party/gioui-cmd/.builds/linux.yml delete mode 100644 third_party/gioui-cmd/.builds/openbsd.yml delete mode 100644 third_party/gioui-cmd/LICENSE delete mode 100644 third_party/gioui-cmd/README.md delete mode 100644 third_party/gioui-cmd/go.mod delete mode 100644 third_party/gioui-cmd/go.sum delete mode 100644 third_party/gioui-cmd/gogio/android_test.go delete mode 100644 third_party/gioui-cmd/gogio/androidbuild.go delete mode 100644 third_party/gioui-cmd/gogio/build_info.go delete mode 100644 third_party/gioui-cmd/gogio/build_info_test.go delete mode 100644 third_party/gioui-cmd/gogio/doc.go delete mode 100644 third_party/gioui-cmd/gogio/e2e_test.go delete mode 100644 third_party/gioui-cmd/gogio/help.go delete mode 100644 third_party/gioui-cmd/gogio/internal/custom/testdata.go delete mode 100644 third_party/gioui-cmd/gogio/internal/normal/testdata.go delete mode 100644 third_party/gioui-cmd/gogio/iosbuild.go delete mode 100644 third_party/gioui-cmd/gogio/js_test.go delete mode 100644 third_party/gioui-cmd/gogio/jsbuild.go delete mode 100644 third_party/gioui-cmd/gogio/macosbuild.go delete mode 100644 third_party/gioui-cmd/gogio/main.go delete mode 100644 third_party/gioui-cmd/gogio/main_test.go delete mode 100644 third_party/gioui-cmd/gogio/permission.go delete mode 100644 third_party/gioui-cmd/gogio/race_test.go delete mode 100644 third_party/gioui-cmd/gogio/wayland_test.go delete mode 100644 third_party/gioui-cmd/gogio/windows_test.go delete mode 100644 third_party/gioui-cmd/gogio/windowsbuild.go delete mode 100644 third_party/gioui-cmd/gogio/x11_test.go delete mode 100644 third_party/gioui-cmd/svg2gio/main.go diff --git a/go.mod b/go.mod index ecf68ef..487dee9 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module git.julianfamily.org/keepassgo go 1.26 -replace gioui.org/cmd => ./third_party/gioui-cmd - require ( gioui.org v0.8.0 gioui.org/x v0.8.0 diff --git a/go.sum b/go.sum index baebf9e..6bdda20 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= +gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ= +gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= diff --git a/third_party/gioui-cmd/.builds/apple.yml b/third_party/gioui-cmd/.builds/apple.yml deleted file mode 100644 index d683fff..0000000 --- a/third_party/gioui-cmd/.builds/apple.yml +++ /dev/null @@ -1,67 +0,0 @@ -# SPDX-License-Identifier: Unlicense OR MIT -image: debian/testing -packages: - - clang - - cmake - - curl - - autoconf - - libxml2-dev - - libssl-dev - - libz-dev - - llvm-dev # for cctools - - uuid-dev ## for cctools - - libplist-utils # for gogio -sources: - - https://git.sr.ht/~eliasnaur/gio-cmd - - https://git.sr.ht/~eliasnaur/applesdks - - https://git.sr.ht/~eliasnaur/giouiorg - - https://github.com/tpoechtrager/cctools-port.git - - https://github.com/tpoechtrager/apple-libtapi.git - - https://github.com/mackyle/xar.git -environment: - APPLE_TOOLCHAIN_ROOT: /home/build/appletools - PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin -tasks: - - install_go: | - mkdir -p /home/build/sdk - curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf - - - prepare_toolchain: | - mkdir -p $APPLE_TOOLCHAIN_ROOT - cd $APPLE_TOOLCHAIN_ROOT - tar xJf /home/build/applesdks/applesdks.tar.xz - mkdir bin tools - cd bin - ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld - ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar - ln -s /home/build/cctools-port/cctools/misc/lipo lipo - ln -s ../tools/appletoolchain xcrun - ln -s /usr/bin/plistutil plutil - cd ../tools - ln -s appletoolchain clang-ios - ln -s appletoolchain clang-macos - - install_appletoolchain: | - cd giouiorg - go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain - - build_xar: | - cd xar/xar - ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr - make - sudo make install - - build_libtapi: | - cd apple-libtapi - INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh - ./install.sh - - build_cctools: | - cd cctools-port/cctools - ./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19 - make install - - install_gogio: | - cd gio-cmd - go install ./gogio - - test_ios_gogio: | - mkdir tmp - cd tmp - go mod init example.com - go get -d gioui.org/example/kitchen - export PATH=/home/build/appletools/bin:$PATH - gogio -target ios -o app.app gioui.org/example/kitchen diff --git a/third_party/gioui-cmd/.builds/freebsd.yml b/third_party/gioui-cmd/.builds/freebsd.yml deleted file mode 100644 index fe3734c..0000000 --- a/third_party/gioui-cmd/.builds/freebsd.yml +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-License-Identifier: Unlicense OR MIT -image: freebsd/13.x -packages: - - libX11 - - libxkbcommon - - libXcursor - - libXfixes - - vulkan-headers - - wayland - - mesa-libs - - xorg-vfbserver -sources: - - https://git.sr.ht/~eliasnaur/gio-cmd -environment: - PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin -tasks: - - install_go: | - mkdir -p /home/build/sdk - curl https://dl.google.com/go/go1.19.8.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf - - - test_cmd: | - cd gio-cmd - go test ./... diff --git a/third_party/gioui-cmd/.builds/linux.yml b/third_party/gioui-cmd/.builds/linux.yml deleted file mode 100644 index 1fcba05..0000000 --- a/third_party/gioui-cmd/.builds/linux.yml +++ /dev/null @@ -1,91 +0,0 @@ -# SPDX-License-Identifier: Unlicense OR MIT -image: debian/bookworm -packages: - - curl - - pkg-config - - libwayland-dev - - libx11-dev - - libx11-xcb-dev - - libxkbcommon-dev - - libxkbcommon-x11-dev - - libgles2-mesa-dev - - libegl1-mesa-dev - - libffi-dev - - libvulkan-dev - - libxcursor-dev - - libxrandr-dev - - libxinerama-dev - - libxi-dev - - libxxf86vm-dev - - mesa-vulkan-drivers - - wine - - xvfb - - xdotool - - scrot - - sway - - grim - - wine - - unzip -sources: - - https://git.sr.ht/~eliasnaur/gio-cmd -environment: - PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin - ANDROID_SDK_ROOT: /home/build/android - android_sdk_tools_zip: sdk-tools-linux-3859397.zip - android_ndk_zip: android-ndk-r20-linux-x86_64.zip - github_mirror: git@github.com:gioui/gio-cmd -secrets: - - fdc570bf-87f4-4528-8aee-4d1711b1c86f -tasks: - - install_go: | - mkdir -p /home/build/sdk - curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf - - - check_gofmt: | - cd gio-cmd - test -z "$(gofmt -s -l .)" - - check_sign_off: | - set +x -e - cd gio-cmd - for hash in $(git log -n 20 --format="%H"); do - message=$(git log -1 --format=%B $hash) - if [[ ! "$message" =~ "Signed-off-by: " ]]; then - echo "Missing 'Signed-off-by' in commit $hash" - exit 1 - fi - done - - mirror: | - # mirror to github - ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring" - - install_chrome: | - curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - - sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' - sudo apt-get -qq update - sudo apt-get -qq install -y google-chrome-stable - - test: | - cd gio-cmd - go test ./... - go test -race ./... - - install_jdk8: | - curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb" - sudo apt-get -qq install -y -f ./jdk.deb - - install_android: | - mkdir android - cd android - curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip - unzip -q sdk-tools.zip - rm sdk-tools.zip - curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip - unzip -q ndk.zip - rm ndk.zip - mv android-ndk-* ndk-bundle - yes|sdkmanager --licenses - sdkmanager "platforms;android-31" "build-tools;32.0.0" - - install_gogio: | - cd gio-cmd - go install ./gogio - - test_android_gogio: | - mkdir tmp - cd tmp - go mod init example.com - go get -d gioui.org/example/kitchen - gogio -target android gioui.org/example/kitchen diff --git a/third_party/gioui-cmd/.builds/openbsd.yml b/third_party/gioui-cmd/.builds/openbsd.yml deleted file mode 100644 index afcf5d1..0000000 --- a/third_party/gioui-cmd/.builds/openbsd.yml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-License-Identifier: Unlicense OR MIT -image: openbsd/latest -packages: - - libxkbcommon - - go -sources: - - https://git.sr.ht/~eliasnaur/gio-cmd -environment: - PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin -tasks: - - install_go: | - mkdir -p /home/build/sdk - curl https://dl.google.com/go/go1.19.8.src.tar.gz | tar -C /home/build/sdk -xzf - - cd /home/build/sdk/go/src - ./make.bash - - test_cmd: | - cd gio-cmd - go test ./... diff --git a/third_party/gioui-cmd/LICENSE b/third_party/gioui-cmd/LICENSE deleted file mode 100644 index 81f4733..0000000 --- a/third_party/gioui-cmd/LICENSE +++ /dev/null @@ -1,63 +0,0 @@ -This project is provided under the terms of the UNLICENSE or -the MIT license denoted by the following SPDX identifier: - -SPDX-License-Identifier: Unlicense OR MIT - -You may use the project under the terms of either license. - -Both licenses are reproduced below. - ----- -The MIT License (MIT) - -Copyright (c) 2019 The Gio authors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. ---- - - - ---- -The UNLICENSE - -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - -For more information, please refer to ---- diff --git a/third_party/gioui-cmd/README.md b/third_party/gioui-cmd/README.md deleted file mode 100644 index 75e9166..0000000 --- a/third_party/gioui-cmd/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Gio Tools - -Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs. - -[![builds.sr.ht status](https://builds.sr.ht/~eliasnaur/gio-cmd.svg)](https://builds.sr.ht/~eliasnaur/gio-cmd) - -## Issues - -File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email -to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the -mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht). - -## Contributing - -Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to -[gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut -account is required and you can post without being subscribed. - -See the [contribution guide](https://gioui.org/doc/contribute) for more details. - -An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available. diff --git a/third_party/gioui-cmd/go.mod b/third_party/gioui-cmd/go.mod deleted file mode 100644 index 9a506b0..0000000 --- a/third_party/gioui-cmd/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module gioui.org/cmd - -go 1.21 - -require ( - gioui.org v0.8.0 - github.com/akavel/rsrc v0.10.1 - github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 - github.com/chromedp/chromedp v0.5.2 - golang.org/x/image v0.18.0 - golang.org/x/sync v0.7.0 - golang.org/x/text v0.16.0 - golang.org/x/tools v0.23.0 -) - -require ( - gioui.org/shader v1.0.8 // indirect - github.com/go-text/typesetting v0.2.1 // indirect - github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect - github.com/gobwas/pool v0.2.0 // indirect - github.com/gobwas/ws v1.0.2 // indirect - github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect - github.com/mailru/easyjson v0.7.0 // indirect - golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect - golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect - golang.org/x/mod v0.19.0 // indirect - golang.org/x/sys v0.22.0 // indirect -) diff --git a/third_party/gioui-cmd/go.sum b/third_party/gioui-cmd/go.sum deleted file mode 100644 index bc2c6ae..0000000 --- a/third_party/gioui-cmd/go.sum +++ /dev/null @@ -1,44 +0,0 @@ -eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= -eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= -gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= -gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= -gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= -gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= -gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= -github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o= -github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= -github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg= -github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= -github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194= -github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA= -github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= -github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= -github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= -github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= -github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= -github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= -github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= -github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs= -github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0= -github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= -github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU= -golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= -golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= diff --git a/third_party/gioui-cmd/gogio/android_test.go b/third_party/gioui-cmd/gogio/android_test.go deleted file mode 100644 index e73386f..0000000 --- a/third_party/gioui-cmd/gogio/android_test.go +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main_test - -import ( - "bytes" - "context" - "fmt" - "image" - "image/png" - "os" - "os/exec" - "path/filepath" - "regexp" -) - -type AndroidTestDriver struct { - driverBase - - sdkDir string - adbPath string -} - -var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`) - -func (d *AndroidTestDriver) Start(path string) { - d.sdkDir = os.Getenv("ANDROID_SDK_ROOT") - if d.sdkDir == "" { - d.Skipf("Android SDK is required; set $ANDROID_SDK_ROOT") - } - d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb") - if _, err := os.Stat(d.adbPath); os.IsNotExist(err) { - d.Skipf("adb not found") - } - - devOut := bytes.TrimSpace(d.adb("devices")) - devices := rxAdbDevice.FindAllSubmatch(devOut, -1) - switch len(devices) { - case 0: - d.Skipf("no Android devices attached via adb; skipping") - case 1: - default: - d.Skipf("multiple Android devices attached via adb; skipping") - } - - // If the device is attached but asleep, it's probably just charging. - // Don't use it; the screen needs to be on and unlocked for the test to - // work. - if !bytes.Contains( - d.adb("shell", "dumpsys", "power"), - []byte(" mWakefulness=Awake"), - ) { - d.Skipf("Android device isn't awake; skipping") - } - - // First, build the app. - apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk") - d.gogio("-target=android", "-appid="+appid, "-o="+apk, path) - - // Make sure the app isn't installed already, and try to uninstall it - // when we finish. Previous failed test runs might have left the app. - d.tryUninstall() - d.adb("install", apk) - d.Cleanup(d.tryUninstall) - - // Force our e2e app to be fullscreen, so that the android system bar at - // the top doesn't mess with our screenshots. - // TODO(mvdan): is there a way to do this via gio, so that we don't need - // to set up a global Android setting via the shell? - d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid) - - // Make sure the app isn't already running. - d.adb("shell", "pm", "clear", appid) - - // Start listening for log messages. - { - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, d.adbPath, - "logcat", - "-s", // suppress other logs - "-T1", // don't show previous log messages - appid+":*", // show all logs from our gio app ID - ) - output, err := cmd.StdoutPipe() - if err != nil { - d.Fatal(err) - } - cmd.Stderr = cmd.Stdout - d.output = output - if err := cmd.Start(); err != nil { - d.Fatal(err) - } - d.Cleanup(cancel) - } - - // Start the app. - d.adb("shell", "monkey", "-p", appid, "1") - - // Wait for the gio app to render. - d.waitForFrame() -} - -func (d *AndroidTestDriver) Screenshot() image.Image { - out := d.adb("shell", "screencap", "-p") - img, err := png.Decode(bytes.NewReader(out)) - if err != nil { - d.Fatal(err) - } - return img -} - -func (d *AndroidTestDriver) tryUninstall() { - cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid) - out, err := cmd.CombinedOutput() - if err != nil { - if bytes.Contains(out, []byte("Unknown package")) { - // The package is not installed. Don't log anything. - return - } - d.Logf("could not uninstall: %v\n%s", err, out) - } -} - -func (d *AndroidTestDriver) adb(args ...interface{}) []byte { - strs := []string{} - for _, arg := range args { - strs = append(strs, fmt.Sprint(arg)) - } - cmd := exec.Command(d.adbPath, strs...) - out, err := cmd.CombinedOutput() - if err != nil { - d.Errorf("%s", out) - d.Fatal(err) - } - return out -} - -func (d *AndroidTestDriver) Click(x, y int) { - d.adb("shell", "input", "tap", x, y) - - // Wait for the gio app to render after this click. - d.waitForFrame() -} diff --git a/third_party/gioui-cmd/gogio/androidbuild.go b/third_party/gioui-cmd/gogio/androidbuild.go deleted file mode 100644 index 75c8e19..0000000 --- a/third_party/gioui-cmd/gogio/androidbuild.go +++ /dev/null @@ -1,1110 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main - -import ( - "archive/zip" - "bytes" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "text/template" - - "golang.org/x/sync/errgroup" - "golang.org/x/tools/go/packages" -) - -type androidTools struct { - buildtools string - androidjar string -} - -// zip.Writer with a sticky error. -type zipWriter struct { - err error - w *zip.Writer -} - -// Writer that saves any errors. -type errWriter struct { - w io.Writer - err *error -} - -var exeSuffix string - -type manifestData struct { - AppID string - Version Semver - MinSDK int - TargetSDK int - Permissions []string - Features []string - IconSnip string - AppName string - ManifestSnip string - AppSnip string -} - -const ( - themes = ` - - -` - themesV21 = ` - - -` -) - -func init() { - if runtime.GOOS == "windows" { - exeSuffix = ".exe" - } -} - -func buildAndroid(tmpDir string, bi *buildInfo) error { - sdk := os.Getenv("ANDROID_SDK_ROOT") - if sdk == "" { - return errors.New("please set ANDROID_SDK_ROOT to the Android SDK path") - } - if _, err := os.Stat(sdk); err != nil { - return err - } - platform, err := latestPlatform(sdk) - if err != nil { - return err - } - buildtools, err := latestTools(sdk) - if err != nil { - return err - } - - tools := &androidTools{ - buildtools: buildtools, - androidjar: filepath.Join(platform, "android.jar"), - } - perms := []string{"default"} - const permPref = "gioui.org/app/permission/" - cfg := &packages.Config{ - Mode: packages.NeedName + - packages.NeedFiles + - packages.NeedImports + - packages.NeedDeps, - Env: append( - os.Environ(), - "GOOS=android", - "CGO_ENABLED=1", - ), - } - pkgs, err := packages.Load(cfg, bi.pkgPath) - if err != nil { - return err - } - var extraJars []string - visitedPkgs := make(map[string]bool) - var visitPkg func(*packages.Package) error - visitPkg = func(p *packages.Package) error { - if len(p.GoFiles) == 0 { - return nil - } - dir := filepath.Dir(p.GoFiles[0]) - for _, pattern := range []string{ - filepath.Join(dir, "*.jar"), - filepath.Join(dir, "android", "*.jar"), - } { - jars, err := filepath.Glob(pattern) - if err != nil { - return err - } - extraJars = append(extraJars, jars...) - } - switch { - case p.PkgPath == "net": - perms = append(perms, "network") - case strings.HasPrefix(p.PkgPath, permPref): - perms = append(perms, p.PkgPath[len(permPref):]) - } - - for _, imp := range p.Imports { - if !visitedPkgs[imp.ID] { - visitPkg(imp) - visitedPkgs[imp.ID] = true - } - } - return nil - } - if err := visitPkg(pkgs[0]); err != nil { - return err - } - - if err := compileAndroid(tmpDir, tools, bi); err != nil { - return err - } - switch *buildMode { - case "archive": - return archiveAndroid(tmpDir, bi, perms) - case "exe": - file := *destPath - if file == "" { - file = fmt.Sprintf("%s.apk", bi.name) - } - - isBundle := false - switch filepath.Ext(file) { - case ".apk": - case ".aab": - isBundle = true - default: - return fmt.Errorf("the specified output %q does not end in '.apk' or '.aab'", file) - } - - if err := exeAndroid(tmpDir, tools, bi, extraJars, perms, isBundle); err != nil { - return err - } - if isBundle { - return signAAB(tmpDir, file, tools, bi) - } - return signAPK(tmpDir, file, tools, bi) - default: - panic("unreachable") - } -} - -func compileAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err error) { - androidHome := os.Getenv("ANDROID_SDK_ROOT") - if androidHome == "" { - return errors.New("ANDROID_SDK_ROOT is not set. Please point it to the root of the Android SDK") - } - javac, err := findJavaC() - if err != nil { - return fmt.Errorf("could not find javac: %v", err) - } - ndkRoot, err := findNDK(androidHome) - if err != nil { - return err - } - minSDK := 17 - if bi.minsdk > minSDK { - minSDK = bi.minsdk - } - tcRoot := filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK()) - var builds errgroup.Group - for _, a := range bi.archs { - arch := allArchs[a] - clang, err := latestCompiler(tcRoot, a, minSDK) - if err != nil { - return fmt.Errorf("%s. Please make sure you have NDK >= r19c installed. Use the command `sdkmanager ndk-bundle` to install it.", err) - } - if runtime.GOOS == "windows" { - // Because of https://github.com/android-ndk/ndk/issues/920, - // we need NDK r19c, not just r19b. Check for the presence of - // clang++.cmd which is only available in r19c. - clangpp := clang + "++.cmd" - if _, err := os.Stat(clangpp); err != nil { - return fmt.Errorf("NDK version r19b detected, but >= r19c is required. Use the command `sdkmanager ndk-bundle` to install it") - } - } - archDir := filepath.Join(tmpDir, "jni", arch.jniArch) - if err := os.MkdirAll(archDir, 0755); err != nil { - return fmt.Errorf("failed to create %q: %v", archDir, err) - } - libFile := filepath.Join(archDir, "libgio.so") - cmd := exec.Command( - "go", - "build", - "-ldflags=-w -s "+bi.ldflags, - "-buildmode=c-shared", - "-tags", bi.tags, - "-o", libFile, - bi.pkgPath, - ) - cmd.Env = append( - os.Environ(), - "GOOS=android", - "GOARCH="+a, - "GOARM=7", // Avoid softfloat. - "CGO_ENABLED=1", - "CC="+clang, - ) - builds.Go(func() error { - _, err := runCmd(cmd) - return err - }) - } - appDir, err := runCmd(exec.Command("go", "list", "-tags", bi.tags, "-f", "{{.Dir}}", "gioui.org/app/")) - if err != nil { - return err - } - javaFiles, err := filepath.Glob(filepath.Join(appDir, "*.java")) - if err != nil { - return err - } - if len(javaFiles) == 0 { - return fmt.Errorf("the gioui.org/app package contains no .java files (gioui.org module too old?)") - } - if len(javaFiles) > 0 { - classes := filepath.Join(tmpDir, "classes") - if err := os.MkdirAll(classes, 0755); err != nil { - return err - } - javac := exec.Command( - javac, - "-target", "1.8", - "-source", "1.8", - "-sourcepath", appDir, - "-bootclasspath", tools.androidjar, - "-d", classes, - ) - javac.Args = append(javac.Args, javaFiles...) - builds.Go(func() error { - _, err := runCmd(javac) - return err - }) - } - return builds.Wait() -} - -func archiveAndroid(tmpDir string, bi *buildInfo, perms []string) (err error) { - aarFile := *destPath - if aarFile == "" { - aarFile = fmt.Sprintf("%s.aar", bi.name) - } - if filepath.Ext(aarFile) != ".aar" { - return fmt.Errorf("the specified output %q does not end in '.aar'", aarFile) - } - aar, err := os.Create(aarFile) - if err != nil { - return err - } - defer func() { - if cerr := aar.Close(); err == nil { - err = cerr - } - }() - aarw := newZipWriter(aar) - defer aarw.Close() - aarw.Create("R.txt") - themesXML := aarw.Create("res/values/themes.xml") - themesXML.Write([]byte(themes)) - themesXML21 := aarw.Create("res/values-v21/themes.xml") - themesXML21.Write([]byte(themesV21)) - permissions, features := getPermissions(perms) - // Disable input emulation on ChromeOS. - manifest := aarw.Create("AndroidManifest.xml") - manifestSrc := manifestData{ - AppID: bi.appID, - MinSDK: bi.minsdk, - Permissions: permissions, - Features: features, - } - tmpl, err := template.New("manifest").Parse( - ` - -{{range .Permissions}} -{{end}}{{range .Features}} -{{end}} -`) - if err != nil { - panic(err) - } - err = tmpl.Execute(manifest, manifestSrc) - proguard := aarw.Create("proguard.txt") - proguard.Write([]byte(`-keep class org.gioui.** { *; }`)) - - for _, a := range bi.archs { - arch := allArchs[a] - libFile := filepath.Join("jni", arch.jniArch, "libgio.so") - aarw.Add(filepath.ToSlash(libFile), filepath.Join(tmpDir, libFile)) - } - classes := filepath.Join(tmpDir, "classes") - if _, err := os.Stat(classes); err == nil { - jarFile := filepath.Join(tmpDir, "classes.jar") - if err := writeJar(jarFile, classes); err != nil { - return err - } - aarw.Add("classes.jar", jarFile) - } - return aarw.Close() -} - -func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, perms []string, isBundle bool) (err error) { - classes := filepath.Join(tmpDir, "classes") - var classFiles []string - err = filepath.Walk(classes, func(path string, f os.FileInfo, err error) error { - if err != nil { - return err - } - if filepath.Ext(path) == ".class" { - classFiles = append(classFiles, path) - } - return nil - }) - classFiles = append(classFiles, extraJars...) - dexDir := filepath.Join(tmpDir, "apk") - if err := os.MkdirAll(dexDir, 0755); err != nil { - return err - } - minSDK := 16 - if bi.minsdk > minSDK { - minSDK = bi.minsdk - } - // https://developer.android.com/distribute/best-practices/develop/target-sdk - targetSDK := 33 - if bi.targetsdk > 0 { - targetSDK = bi.targetsdk - } - if minSDK > targetSDK { - targetSDK = minSDK - } - if len(classFiles) > 0 { - d8 := exec.Command( - filepath.Join(tools.buildtools, "d8"), - "--lib", tools.androidjar, - "--output", dexDir, - "--min-api", strconv.Itoa(minSDK), - ) - d8.Args = append(d8.Args, classFiles...) - if _, err := runCmd(d8); err != nil { - major, minor, ok := determineJDKVersion() - if ok && (major != 1 || minor != 8) { - return fmt.Errorf("unsupported JDK version %d.%d, expected 1.8\nd8 error: %v", major, minor, err) - } - return err - } - } - - // Compile resources. - resDir := filepath.Join(tmpDir, "res") - valDir := filepath.Join(resDir, "values") - v21Dir := filepath.Join(resDir, "values-v21") - v26mipmapDir := filepath.Join(resDir, `mipmap-anydpi-v26`) - for _, dir := range []string{valDir, v21Dir, v26mipmapDir} { - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - } - iconSnip := "" - if _, err := os.Stat(bi.iconPath); err == nil { - err := buildIcons(resDir, bi.iconPath, []iconVariant{ - {path: filepath.Join("mipmap-hdpi", "ic_launcher.png"), size: 72}, - {path: filepath.Join("mipmap-xhdpi", "ic_launcher.png"), size: 96}, - {path: filepath.Join("mipmap-xxhdpi", "ic_launcher.png"), size: 144}, - {path: filepath.Join("mipmap-xxxhdpi", "ic_launcher.png"), size: 192}, - {path: filepath.Join("mipmap-mdpi", "ic_launcher_adaptive.png"), size: 108}, - {path: filepath.Join("mipmap-hdpi", "ic_launcher_adaptive.png"), size: 162}, - {path: filepath.Join("mipmap-xhdpi", "ic_launcher_adaptive.png"), size: 216}, - {path: filepath.Join("mipmap-xxhdpi", "ic_launcher_adaptive.png"), size: 324}, - {path: filepath.Join("mipmap-xxxhdpi", "ic_launcher_adaptive.png"), size: 432}, - }) - if err != nil { - return err - } - err = os.WriteFile(filepath.Join(v26mipmapDir, `ic_launcher.xml`), []byte(` - - - -`), 0660) - if err != nil { - return err - } - iconSnip = `android:icon="@mipmap/ic_launcher"` - } - err = os.WriteFile(filepath.Join(valDir, "themes.xml"), []byte(themes), 0660) - if err != nil { - return err - } - err = os.WriteFile(filepath.Join(v21Dir, "themes.xml"), []byte(themesV21), 0660) - if err != nil { - return err - } - extraResDir := filepath.Join(bi.pkgDir, "android", "res") - if err := copyTree(extraResDir, resDir); err != nil { - return err - } - resZip := filepath.Join(tmpDir, "resources.zip") - aapt2 := filepath.Join(tools.buildtools, "aapt2") - _, err = runCmd(exec.Command( - aapt2, - "compile", - "-o", resZip, - "--dir", resDir)) - if err != nil { - return err - } - - // Link APK. - permissions, features := getPermissions(perms) - appName := UppercaseName(bi.name) - manifestSrc := manifestData{ - AppID: bi.appID, - Version: bi.version, - MinSDK: minSDK, - TargetSDK: targetSDK, - Permissions: permissions, - Features: features, - IconSnip: iconSnip, - AppName: appName, - ManifestSnip: readOptionalText(filepath.Join(bi.pkgDir, "android", "manifest_snippets.xml")), - AppSnip: readOptionalText(filepath.Join(bi.pkgDir, "android", "application_snippets.xml")), - } - tmpl, err := template.New("test").Parse( - ` - - -{{range .Permissions}} -{{end}}{{range .Features}} -{{end}}{{.ManifestSnip}} -{{.AppSnip}} - - - - - - - -`) - var manifestBuffer bytes.Buffer - if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil { - return err - } - manifest := filepath.Join(tmpDir, "AndroidManifest.xml") - if err := os.WriteFile(manifest, manifestBuffer.Bytes(), 0660); err != nil { - return err - } - - linkAPK := filepath.Join(tmpDir, "link.apk") - - args := []string{ - "link", - "--manifest", manifest, - "-I", tools.androidjar, - "-o", linkAPK, - } - if isBundle { - args = append(args, "--proto-format") - } - args = append(args, resZip) - - if _, err := runCmd(exec.Command(aapt2, args...)); err != nil { - return err - } - - // The Go standard library archive/zip doesn't support appending to zip - // files. Copy files from `link.apk` (generated by aapt2) along with classes.dex and - // the Go libraries to a new `app.zip` file. - - // Load link.apk as zip. - linkAPKZip, err := zip.OpenReader(linkAPK) - if err != nil { - return err - } - defer linkAPKZip.Close() - - // Create new "APK". - unsignedAPK := filepath.Join(tmpDir, "app.zip") - unsignedAPKFile, err := os.Create(unsignedAPK) - if err != nil { - return err - } - defer func() { - if cerr := unsignedAPKFile.Close(); err == nil { - err = cerr - } - }() - unsignedAPKZip := zip.NewWriter(unsignedAPKFile) - defer unsignedAPKZip.Close() - - // Copy files from linkAPK to unsignedAPK. - for _, f := range linkAPKZip.File { - header := zip.FileHeader{ - Name: f.FileHeader.Name, - Method: f.FileHeader.Method, - } - - if isBundle { - // AAB have pre-defined folders. - switch header.Name { - case "AndroidManifest.xml": - header.Name = "manifest/AndroidManifest.xml" - } - } - - w, err := unsignedAPKZip.CreateHeader(&header) - if err != nil { - return err - } - r, err := f.Open() - if err != nil { - return err - } - if _, err := io.Copy(w, r); err != nil { - return err - } - } - - // Append new files (that doesn't exists inside the link.apk). - appendToZip := func(path string, file string) error { - f, err := os.Open(file) - if err != nil { - return err - } - defer f.Close() - w, err := unsignedAPKZip.CreateHeader(&zip.FileHeader{ - Name: filepath.ToSlash(path), - Method: zip.Deflate, - }) - if err != nil { - return err - } - _, err = io.Copy(w, f) - return err - } - - // Append Go binaries (libgio.so). - for _, a := range bi.archs { - arch := allArchs[a] - libFile := filepath.Join(arch.jniArch, "libgio.so") - if err := appendToZip(filepath.Join("lib", libFile), filepath.Join(tmpDir, "jni", libFile)); err != nil { - return err - } - } - - // Append classes.dex. - if len(classFiles) > 0 { - classesFolder := "classes.dex" - if isBundle { - classesFolder = "dex/classes.dex" - } - if err := appendToZip(classesFolder, filepath.Join(dexDir, "classes.dex")); err != nil { - return err - } - } - - return unsignedAPKZip.Close() -} - -func determineJDKVersion() (int, int, bool) { - path, err := findJavaC() - if err != nil { - return 0, 0, false - } - java := exec.Command(filepath.Join(filepath.Dir(path), "java"), "-version") - out, err := java.CombinedOutput() - if err != nil { - return 0, 0, false - } - var vendor string - var major, minor int - _, err = fmt.Sscanf(string(out), "%s version \"%d.%d", &vendor, &major, &minor) - return major, minor, err == nil -} - -func signAPK(tmpDir string, apkFile string, tools *androidTools, bi *buildInfo) error { - if err := zipalign(tools, filepath.Join(tmpDir, "app.zip"), apkFile); err != nil { - return err - } - - if bi.key == "" { - if err := defaultAndroidKeystore(tmpDir, bi); err != nil { - return err - } - } - - _, err := runCmd(exec.Command( - filepath.Join(tools.buildtools, "apksigner"), - "sign", - "--ks-pass", "pass:"+bi.password, - "--ks", bi.key, - apkFile, - )) - - return err -} - -func signAAB(tmpDir string, aabFile string, tools *androidTools, bi *buildInfo) error { - allBundleTools, err := filepath.Glob(filepath.Join(tools.buildtools, "bundletool*.jar")) - if err != nil { - return err - } - - bundletool := "" - for _, v := range allBundleTools { - bundletool = v - break - } - - if bundletool == "" { - return fmt.Errorf("bundletool was not found at %s. Download it from https://github.com/google/bundletool/releases and move to the respective folder", tools.buildtools) - } - - _, err = runCmd(exec.Command( - "java", - "-jar", bundletool, - "build-bundle", - "--modules="+filepath.Join(tmpDir, "app.zip"), - "--output="+filepath.Join(tmpDir, "app.aab"), - )) - if err != nil { - return err - } - - if err := zipalign(tools, filepath.Join(tmpDir, "app.aab"), aabFile); err != nil { - return err - } - - if bi.key == "" { - if err := defaultAndroidKeystore(tmpDir, bi); err != nil { - return err - } - } - - keytoolList, err := runCmd(exec.Command( - "keytool", - "-keystore", bi.key, - "-list", - "-keypass", bi.password, - "-v", - )) - if err != nil { - return err - } - - var alias string - for _, t := range strings.Split(keytoolList, "\n") { - if i, _ := fmt.Sscanf(t, "Alias name: %s", &alias); i > 0 { - break - } - } - - _, err = runCmd(exec.Command( - filepath.Join("jarsigner"), - "-sigalg", "SHA256withRSA", - "-digestalg", "SHA-256", - "-keystore", bi.key, - "-storepass", bi.password, - aabFile, - strings.TrimSpace(alias), - )) - - return err -} - -func zipalign(tools *androidTools, input, output string) error { - _, err := runCmd(exec.Command( - filepath.Join(tools.buildtools, "zipalign"), - "-f", - "4", // 32-bit alignment. - input, - output, - )) - return err -} - -func defaultAndroidKeystore(tmpDir string, bi *buildInfo) error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - - // Use debug.keystore, if exists. - bi.key = filepath.Join(home, ".android", "debug.keystore") - bi.password = "android" - if _, err := os.Stat(bi.key); err == nil { - return nil - } - - // Generate new key. - bi.key = filepath.Join(tmpDir, "sign.keystore") - keytool, err := findKeytool() - if err != nil { - return err - } - _, err = runCmd(exec.Command( - keytool, - "-genkey", - "-keystore", bi.key, - "-storepass", bi.password, - "-alias", "android", - "-keyalg", "RSA", "-keysize", "2048", - "-validity", "10000", - "-noprompt", - "-dname", "CN=android", - )) - return err -} - -func readOptionalText(path string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - if len(data) == 0 { - return "" - } - return "\n" + string(data) + "\n" -} - -func copyTree(src, dst string) error { - info, err := os.Stat(src) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - if !info.IsDir() { - return fmt.Errorf("extra Android resources path is not a directory: %s", src) - } - return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - if rel == "." { - return nil - } - target := filepath.Join(dst, rel) - if entry.IsDir() { - return os.MkdirAll(target, 0755) - } - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(target, data, 0660) - }) -} - -func findNDK(androidHome string) (string, error) { - ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*")) - if err != nil { - return "", err - } - if bestNDK, found := latestVersionPath(ndks); found { - return bestNDK, nil - } - // The old NDK path was $ANDROID_SDK_ROOT/ndk-bundle. - ndkBundle := filepath.Join(androidHome, "ndk-bundle") - if _, err := os.Stat(ndkBundle); err == nil { - return ndkBundle, nil - } - // Certain non-standard NDK isntallations set the $ANDROID_NDK_ROOT - // environment variable - if ndkBundle, ok := os.LookupEnv("ANDROID_NDK_ROOT"); ok { - if _, err := os.Stat(ndkBundle); err == nil { - return ndkBundle, nil - } - } - - return "", fmt.Errorf("no NDK found in $ANDROID_SDK_ROOT (%s). Set $ANDROID_NDK_ROOT or use `sdkmanager ndk-bundle` to install the NDK", androidHome) -} - -func findKeytool() (string, error) { - javaHome := os.Getenv("JAVA_HOME") - if javaHome == "" { - return exec.LookPath("keytool") - } - - // bin, instead of "jre". "jre" was for older JVM it seems. - keytool := filepath.Join(javaHome, "bin", "keytool"+exeSuffix) - if _, err := os.Stat(keytool); err != nil { - return "", err - } - return keytool, nil -} - -func findJavaC() (string, error) { - javaHome := os.Getenv("JAVA_HOME") - if javaHome == "" { - return exec.LookPath("javac") - } - javac := filepath.Join(javaHome, "bin", "javac"+exeSuffix) - if _, err := os.Stat(javac); err != nil { - return "", err - } - return javac, nil -} - -func writeJar(jarFile, dir string) (err error) { - jar, err := os.Create(jarFile) - if err != nil { - return err - } - defer func() { - if cerr := jar.Close(); err == nil { - err = cerr - } - }() - jarw := newZipWriter(jar) - const manifestHeader = `Manifest-Version: 1.0 -Created-By: 1.0 (Go) - -` - jarw.Create("META-INF/MANIFEST.MF").Write([]byte(manifestHeader)) - err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { - if err != nil { - return err - } - if f.IsDir() { - return nil - } - if filepath.Ext(path) == ".class" { - rel := filepath.ToSlash(path[len(dir)+1:]) - jarw.Add(rel, path) - } - return nil - }) - if err != nil { - return err - } - return jarw.Close() -} - -func archNDK() string { - var arch string - switch runtime.GOARCH { - case "386": - arch = "x86" - case "amd64": - arch = "x86_64" - case "arm64": - if runtime.GOOS == "darwin" { - // Workaround for arm64 macOS. This will keep working until - // Apple deprecates Rosetta 2. - arch = "x86_64" - } else { - panic("unsupported GOARCH: " + runtime.GOARCH) - } - default: - panic("unsupported GOARCH: " + runtime.GOARCH) - } - return runtime.GOOS + "-" + arch -} - -func getPermissions(ps []string) ([]string, []string) { - var permissions, features []string - seenPermissions := make(map[string]bool) - seenFeatures := make(map[string]bool) - for _, perm := range ps { - for _, x := range AndroidPermissions[perm] { - if !seenPermissions[x] { - permissions = append(permissions, x) - seenPermissions[x] = true - } - } - for _, x := range AndroidFeatures[perm] { - if !seenFeatures[x] { - features = append(features, x) - seenFeatures[x] = true - } - } - } - return permissions, features -} - -func latestPlatform(sdk string) (string, error) { - allPlats, err := filepath.Glob(filepath.Join(sdk, "platforms", "android-*")) - if err != nil { - return "", err - } - var bestVer int - var bestPlat string - for _, platform := range allPlats { - _, name := filepath.Split(platform) - // The glob above guarantees the "android-" prefix. - verStr := name[len("android-"):] - ver, err := strconv.Atoi(verStr) - if err != nil { - continue - } - if ver < bestVer { - continue - } - bestVer = ver - bestPlat = platform - } - if bestPlat == "" { - return "", fmt.Errorf("no platforms found in %q", sdk) - } - return bestPlat, nil -} - -func latestCompiler(tcRoot, a string, minsdk int) (string, error) { - arch := allArchs[a] - allComps, err := filepath.Glob(filepath.Join(tcRoot, "bin", arch.clangArch+"*-clang")) - if err != nil { - return "", err - } - var bestVer int - var firstVer int - var bestCompiler string - var firstCompiler string - for _, compiler := range allComps { - var ver int - pattern := filepath.Join(tcRoot, "bin", arch.clangArch) + "%d-clang" - if n, err := fmt.Sscanf(compiler, pattern, &ver); n < 1 || err != nil { - continue - } - if firstCompiler == "" || ver < firstVer { - firstVer = ver - firstCompiler = compiler - } - if ver < bestVer { - continue - } - if ver > minsdk { - continue - } - bestVer = ver - bestCompiler = compiler - } - if bestCompiler == "" { - bestCompiler = firstCompiler - } - if bestCompiler == "" { - return "", fmt.Errorf("no NDK compiler found for architecture %s in %s", a, tcRoot) - } - return bestCompiler, nil -} - -func latestTools(sdk string) (string, error) { - allTools, err := filepath.Glob(filepath.Join(sdk, "build-tools", "*")) - if err != nil { - return "", err - } - tools, found := latestVersionPath(allTools) - if !found { - return "", fmt.Errorf("no build-tools found in %q", sdk) - } - return tools, nil -} - -// latestVersionFile finds the path with the highest version -// among paths on the form -// -// /some/path/major.minor.patch -func latestVersionPath(paths []string) (string, bool) { - var bestVer [3]int - var bestDir string -loop: - for _, path := range paths { - name := filepath.Base(path) - s := strings.SplitN(name, ".", 3) - var version [3]int - for i, v := range s { - v, err := strconv.Atoi(v) - if err != nil { - continue loop - } - if v < bestVer[i] { - continue loop - } - if v > bestVer[i] { - break - } - version[i] = v - } - bestVer = version - bestDir = path - } - return bestDir, bestDir != "" -} - -func newZipWriter(w io.Writer) *zipWriter { - return &zipWriter{ - w: zip.NewWriter(w), - } -} - -func (z *zipWriter) Close() error { - err := z.w.Close() - if z.err == nil { - z.err = err - } - return z.err -} - -func (z *zipWriter) Create(name string) io.Writer { - if z.err != nil { - return io.Discard - } - w, err := z.w.Create(name) - if err != nil { - z.err = err - return io.Discard - } - return &errWriter{w: w, err: &z.err} -} - -func (z *zipWriter) Store(name, file string) { - z.add(name, file, false) -} - -func (z *zipWriter) Add(name, file string) { - z.add(name, file, true) -} - -func (z *zipWriter) add(name, file string, compressed bool) { - if z.err != nil { - return - } - f, err := os.Open(file) - if err != nil { - z.err = err - return - } - defer f.Close() - fh := &zip.FileHeader{ - Name: name, - } - if compressed { - fh.Method = zip.Deflate - } - w, err := z.w.CreateHeader(fh) - if err != nil { - z.err = err - return - } - if _, err := io.Copy(w, f); err != nil { - z.err = err - return - } -} - -func (w *errWriter) Write(p []byte) (n int, err error) { - if err := *w.err; err != nil { - return 0, err - } - n, err = w.w.Write(p) - *w.err = err - return -} diff --git a/third_party/gioui-cmd/gogio/build_info.go b/third_party/gioui-cmd/gogio/build_info.go deleted file mode 100644 index cbdb7fe..0000000 --- a/third_party/gioui-cmd/gogio/build_info.go +++ /dev/null @@ -1,206 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - "strings" - "unicode" - "unicode/utf8" -) - -type buildInfo struct { - appID string - archs []string - ldflags string - minsdk int - targetsdk int - name string - pkgDir string - pkgPath string - iconPath string - tags string - target string - version Semver - key string - password string - notaryAppleID string - notaryPassword string - notaryTeamID string -} - -type Semver struct { - Major, Minor, Patch int - VersionCode uint32 -} - -func newBuildInfo(pkgPath string) (*buildInfo, error) { - pkgMetadata, err := getPkgMetadata(pkgPath) - if err != nil { - return nil, err - } - appID := getAppID(pkgMetadata) - appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png") - if *iconPath != "" { - appIcon = *iconPath - } - appName := getPkgName(pkgMetadata) - if *name != "" { - appName = *name - } - ver, err := parseSemver(*version) - if err != nil { - return nil, err - } - bi := &buildInfo{ - appID: appID, - archs: getArchs(), - ldflags: getLdFlags(appID), - minsdk: *minsdk, - targetsdk: *targetsdk, - name: appName, - pkgDir: pkgMetadata.Dir, - pkgPath: pkgPath, - iconPath: appIcon, - tags: *extraTags, - target: *target, - version: ver, - key: *signKey, - password: *signPass, - notaryAppleID: *notaryID, - notaryPassword: *notaryPass, - notaryTeamID: *notaryTeamID, - } - return bi, nil -} - -// UppercaseName returns a string with its first rune in uppercase. -func UppercaseName(name string) string { - ch, w := utf8.DecodeRuneInString(name) - return string(unicode.ToUpper(ch)) + name[w:] -} - -func (s Semver) String() string { - return fmt.Sprintf("%d.%d.%d.%d", s.Major, s.Minor, s.Patch, s.VersionCode) -} - -func parseSemver(v string) (Semver, error) { - var sv Semver - _, err := fmt.Sscanf(v, "%d.%d.%d.%d", &sv.Major, &sv.Minor, &sv.Patch, &sv.VersionCode) - if err != nil { - return Semver{}, fmt.Errorf("invalid semver: %q", v) - } - if sv.String() != v { - return Semver{}, fmt.Errorf("invalid semver: %q", v) - } - return sv, nil -} - -func getArchs() []string { - if *archNames != "" { - return strings.Split(*archNames, ",") - } - switch *target { - case "js": - return []string{"wasm"} - case "ios", "tvos": - // Only 64-bit support. - return []string{"arm64", "amd64"} - case "android": - return []string{"arm", "arm64", "386", "amd64"} - case "windows": - goarch := os.Getenv("GOARCH") - if goarch == "" { - goarch = runtime.GOARCH - } - return []string{goarch} - case "macos": - return []string{"arm64", "amd64"} - default: - // TODO: Add flag tests. - panic("The target value has already been validated, this will never execute.") - } -} - -func getLdFlags(appID string) string { - var ldflags []string - if extra := *extraLdflags; extra != "" { - ldflags = append(ldflags, strings.Split(extra, " ")...) - } - // Pass appID along, to be used for logging on platforms like Android. - ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.ID=%s", appID)) - // Support earlier Gio versions that had a separate app id recorded. - // TODO: delete this in the future. - ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID)) - // Pass along all remaining arguments to the app. - if appArgs := flag.Args()[1:]; len(appArgs) > 0 { - ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|"))) - } - if m := *linkMode; m != "" { - ldflags = append(ldflags, "-linkmode="+m) - } - return strings.Join(ldflags, " ") -} - -type packageMetadata struct { - PkgPath string - Dir string -} - -func getPkgMetadata(pkgPath string) (*packageMetadata, error) { - pkgImportPath, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.ImportPath}}", pkgPath)) - if err != nil { - return nil, err - } - pkgDir, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.Dir}}", pkgPath)) - if err != nil { - return nil, err - } - return &packageMetadata{ - PkgPath: pkgImportPath, - Dir: pkgDir, - }, nil -} - -func getAppID(pkgMetadata *packageMetadata) string { - if *appID != "" { - return *appID - } - elems := strings.Split(pkgMetadata.PkgPath, "/") - domain := strings.Split(elems[0], ".") - name := "" - if len(elems) > 1 { - name = "." + elems[len(elems)-1] - } - if len(elems) < 2 && len(domain) < 2 { - name = "." + domain[0] - domain[0] = "localhost" - } else { - for i := 0; i < len(domain)/2; i++ { - opp := len(domain) - 1 - i - domain[i], domain[opp] = domain[opp], domain[i] - } - } - - pkgDomain := strings.Join(domain, ".") - appid := []rune(pkgDomain + name) - - // a Java-language-style package name may contain upper- and lower-case - // letters and underscores with individual parts separated by '.'. - // https://developer.android.com/guide/topics/manifest/manifest-element - for i, c := range appid { - if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || - c == '_' || c == '.') { - appid[i] = '_' - } - } - return string(appid) -} - -func getPkgName(pkgMetadata *packageMetadata) string { - return path.Base(pkgMetadata.PkgPath) -} diff --git a/third_party/gioui-cmd/gogio/build_info_test.go b/third_party/gioui-cmd/gogio/build_info_test.go deleted file mode 100644 index 397e2a3..0000000 --- a/third_party/gioui-cmd/gogio/build_info_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import "testing" - -type expval struct { - in, out string -} - -func TestAppID(t *testing.T) { - t.Parallel() - - tests := []expval{ - {"example", "localhost.example"}, - {"example.com", "com.example"}, - {"www.example.com", "com.example.www"}, - {"examplecom/app", "examplecom.app"}, - {"example.com/app", "com.example.app"}, - {"www.example.com/app", "com.example.www.app"}, - {"www.en.example.com/app", "com.example.en.www.app"}, - {"example.com/dir/app", "com.example.app"}, - {"example.com/dir.ext/app", "com.example.app"}, - {"example.com/dir/app.ext", "com.example.app.ext"}, - {"example-com.net/dir/app", "net.example_com.app"}, - } - - for i, test := range tests { - got := getAppID(&packageMetadata{PkgPath: test.in}) - if exp := test.out; got != exp { - t.Errorf("(%d): expected '%s', got '%s'", i, exp, got) - } - } -} diff --git a/third_party/gioui-cmd/gogio/doc.go b/third_party/gioui-cmd/gogio/doc.go deleted file mode 100644 index 82da812..0000000 --- a/third_party/gioui-cmd/gogio/doc.go +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -/* -The gogio tool builds and packages Gio programs for Android, iOS/tvOS -and WebAssembly. - -Run gogio with no arguments for instructions, or see the examples at -https://gioui.org. -*/ -package main diff --git a/third_party/gioui-cmd/gogio/e2e_test.go b/third_party/gioui-cmd/gogio/e2e_test.go deleted file mode 100644 index 46eee7f..0000000 --- a/third_party/gioui-cmd/gogio/e2e_test.go +++ /dev/null @@ -1,337 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main_test - -import ( - "bufio" - "errors" - "flag" - "fmt" - "image" - "image/color" - "io" - "os" - "os/exec" - "runtime" - "strings" - "testing" - "time" -) - -var raceEnabled = false - -var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode") - -const appid = "localhost.gogio.endtoend" - -// TestDriver is implemented by each of the platforms we can run end-to-end -// tests on. None of its methods return any errors, as the errors are directly -// reported to testing.T via methods like Fatal. -type TestDriver interface { - initBase(t *testing.T, width, height int) - - // Start opens the Gio app found at path. The driver should attempt to - // run the app with the base driver's width and height, and the - // platform's background should be white. - // - // When the function returns, the gio app must be ready to use on the - // platform, with its initial frame fully drawn. - Start(path string) - - // Screenshot takes a screenshot of the Gio app on the platform. - Screenshot() image.Image - - // Click performs a pointer click at the specified coordinates, - // including both press and release. It returns when the next frame is - // fully drawn. - Click(x, y int) -} - -type driverBase struct { - *testing.T - - width, height int - - output io.Reader - frameNotifs chan bool -} - -func (d *driverBase) initBase(t *testing.T, width, height int) { - d.T = t - d.width, d.height = width, height -} - -func TestEndToEnd(t *testing.T) { - if testing.Short() { - t.Skipf("end-to-end tests tend to be slow") - } - - t.Parallel() - - const ( - testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/internal/normal" - testdataWithRelativePkgPath = "internal/normal/testdata.go" - customRenderTestdataWithRelativePkgPath = "internal/custom/testdata.go" - ) - // Keep this list local, to not reuse TestDriver objects. - subtests := []struct { - name string - driver TestDriver - pkgPath string - skipGeese string - }{ - {"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath, ""}, - {"X11", &X11TestDriver{}, testdataWithRelativePkgPath, ""}, - {"X11 with custom rendering", &X11TestDriver{}, customRenderTestdataWithRelativePkgPath, "openbsd,darwin,windows,netbsd"}, - // Doesn't work on the builders. - //{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath}, - {"JS", &JSTestDriver{}, testdataWithRelativePkgPath, ""}, - {"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath, ""}, - {"Windows", &WineTestDriver{}, testdataWithRelativePkgPath, ""}, - } - - for _, subtest := range subtests { - t.Run(subtest.name, func(t *testing.T) { - subtest := subtest // copy the changing loop variable - if strings.Contains(subtest.skipGeese, runtime.GOOS) { - t.Skipf("not supported on %s", runtime.GOOS) - } - t.Parallel() - runEndToEndTest(t, subtest.driver, subtest.pkgPath) - }) - } -} - -func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) { - size := image.Point{X: 800, Y: 600} - driver.initBase(t, size.X, size.Y) - - t.Log("starting driver and gio app") - driver.Start(pkgPath) - - beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff} - white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} - black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff} - gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff} - red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} - - // These are the four colors at the beginning. - t.Log("taking initial screenshot") - withRetries(t, 4*time.Second, func() error { - img := driver.Screenshot() - size = img.Bounds().Size() // override the default size - return checkImageCorners(img, beef, white, black, gray) - }) - - // TODO(mvdan): implement this properly in the Wayland driver; swaymsg - // almost works to automate clicks, but the button presses end up in the - // wrong coordinates. - if _, ok := driver.(*WaylandTestDriver); ok { - return - } - - // Click the first and last sections to turn them red. - t.Log("clicking twice and taking another screenshot") - driver.Click(1*(size.X/4), 1*(size.Y/4)) - driver.Click(3*(size.X/4), 3*(size.Y/4)) - withRetries(t, 4*time.Second, func() error { - img := driver.Screenshot() - return checkImageCorners(img, red, white, black, red) - }) -} - -// withRetries keeps retrying fn until it succeeds, or until the timeout is hit. -// It uses a rudimentary kind of backoff, which starts with 100ms delays. As -// such, timeout should generally be in the order of seconds. -func withRetries(t *testing.T, timeout time.Duration, fn func() error) { - t.Helper() - - timeoutTimer := time.NewTimer(timeout) - defer timeoutTimer.Stop() - backoff := 100 * time.Millisecond - - tries := 0 - var lastErr error - for { - if lastErr = fn(); lastErr == nil { - return - } - tries++ - t.Logf("retrying after %s", backoff) - - // Use a timer instead of a sleep, so that the timeout can stop - // the backoff early. Don't reuse this timer, since we're not in - // a hot loop, and we don't want tricky code. - backoffTimer := time.NewTimer(backoff) - defer backoffTimer.Stop() - - select { - case <-timeoutTimer.C: - t.Errorf("last error: %v", lastErr) - t.Fatalf("hit timeout of %s after %d tries", timeout, tries) - case <-backoffTimer.C: - } - - // Keep doubling it until a maximum. With the start at 100ms, - // we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever. - backoff *= 2 - if max := 2 * time.Second; backoff > max { - backoff = max - } - } -} - -type colorMismatch struct { - x, y int - wantRGB, gotRGB [3]uint32 -} - -func (m colorMismatch) String() string { - return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x", - m.x, m.y, - m.gotRGB[0], m.gotRGB[1], m.gotRGB[2], - m.wantRGB[0], m.wantRGB[1], m.wantRGB[2], - ) -} - -func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error { - // The colors are split in four rectangular sections. Check the corners - // of each of the sections. We check the corners left to right, top to - // bottom, like when reading left-to-right text. - - size := img.Bounds().Size() - var mismatches []colorMismatch - - checkColor := func(x, y int, want color.Color) { - r, g, b, _ := want.RGBA() - got := img.At(x, y) - r_, g_, b_, _ := got.RGBA() - if r_ != r || g_ != g || b_ != b { - mismatches = append(mismatches, colorMismatch{ - x: x, - y: y, - wantRGB: [3]uint32{r, g, b}, - gotRGB: [3]uint32{r_, g_, b_}, - }) - } - } - - { - minX, minY := 5, 5 - maxX, maxY := (size.X/2)-5, (size.Y/2)-5 - checkColor(minX, minY, topLeft) - checkColor(maxX, minY, topLeft) - checkColor(minX, maxY, topLeft) - checkColor(maxX, maxY, topLeft) - } - { - minX, minY := (size.X/2)+5, 5 - maxX, maxY := size.X-5, (size.Y/2)-5 - checkColor(minX, minY, topRight) - checkColor(maxX, minY, topRight) - checkColor(minX, maxY, topRight) - checkColor(maxX, maxY, topRight) - } - { - minX, minY := 5, (size.Y/2)+5 - maxX, maxY := (size.X/2)-5, size.Y-5 - checkColor(minX, minY, botLeft) - checkColor(maxX, minY, botLeft) - checkColor(minX, maxY, botLeft) - checkColor(maxX, maxY, botLeft) - } - { - minX, minY := (size.X/2)+5, (size.Y/2)+5 - maxX, maxY := size.X-5, size.Y-5 - checkColor(minX, minY, botRight) - checkColor(maxX, minY, botRight) - checkColor(minX, maxY, botRight) - checkColor(maxX, maxY, botRight) - } - if n := len(mismatches); n > 0 { - b := new(strings.Builder) - fmt.Fprintf(b, "encountered %d color mismatches:\n", n) - for _, m := range mismatches { - fmt.Fprintf(b, "%s\n", m) - } - return errors.New(b.String()) - } - return nil -} - -func (d *driverBase) waitForFrame() { - d.Helper() - - if d.frameNotifs == nil { - // Start the goroutine that reads output lines and notifies of - // new frames via frameNotifs. The test doesn't wait for this - // goroutine to finish; it will naturally end when the output - // reader reaches an error like EOF. - d.frameNotifs = make(chan bool, 1) - if d.output == nil { - d.Fatal("need an output reader to be notified of frames") - } - go func() { - scanner := bufio.NewScanner(d.output) - for scanner.Scan() { - line := scanner.Text() - d.Log(line) - if strings.Contains(line, "gio frame ready") { - d.frameNotifs <- true - } - } - // Since we're only interested in the output while the - // app runs, and we don't know when it finishes here, - // ignore "already closed" pipe errors. - if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) { - d.Errorf("reading app output: %v", err) - } - }() - } - - // Unfortunately, there isn't a way to select on a test failing, since - // testing.T doesn't have anything like a context or a "done" channel. - // - // We can't let selects block forever, since the default -test.timeout - // is ten minutes - far too long for tests that take seconds. - // - // For now, a static short timeout is better than nothing. 5s is plenty - // for our simple test app to render on any device. - select { - case <-d.frameNotifs: - case <-time.After(5 * time.Second): - d.Fatalf("timed out waiting for a frame to be ready") - } -} - -func (d *driverBase) needPrograms(names ...string) { - d.Helper() - for _, name := range names { - if _, err := exec.LookPath(name); err != nil { - d.Skipf("%s needed to run", name) - } - } -} - -func (d *driverBase) tempDir(name string) string { - d.Helper() - dir, err := os.MkdirTemp("", name) - if err != nil { - d.Fatal(err) - } - d.Cleanup(func() { os.RemoveAll(dir) }) - return dir -} - -func (d *driverBase) gogio(args ...string) { - d.Helper() - prog, err := os.Executable() - if err != nil { - d.Fatal(err) - } - cmd := exec.Command(prog, args...) - cmd.Env = append(os.Environ(), "RUN_GOGIO=1") - if out, err := cmd.CombinedOutput(); err != nil { - d.Fatalf("gogio error: %s:\n%s", err, out) - } -} diff --git a/third_party/gioui-cmd/gogio/help.go b/third_party/gioui-cmd/gogio/help.go deleted file mode 100644 index 0399e39..0000000 --- a/third_party/gioui-cmd/gogio/help.go +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main - -const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs. - -Usage: - - gogio -target [flags] [run arguments] - -The gogio tool builds and packages Gio programs for platforms where additional -metadata or support files are required. - -The package argument specifies an import path or a single Go source file to -package. Any run arguments are appended to os.Args at runtime. - -Compiled Java class files from jar files in the package directory are -included in Android builds. - -The mandatory -target flag selects the target platform: ios or android for the -mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for -MacOS and windows for Windows. - -The -arch flag specifies a comma separated list of GOARCHs to include. The -default is all supported architectures. - -The -o flag specifies an output file or directory, depending on the target. - -The -buildmode flag selects the build mode. Two build modes are available, exe -and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file -for Android or a directory with the WebAssembly module and support files for -a browser. - -The -ldflags and -tags flags pass extra linker flags and tags to the go tool. - -As a special case for iOS or tvOS, specifying a path that ends with ".app" -will output an app directory suitable for a simulator. - -The other buildmode is archive, which will output an .aar library for Android -or a .framework for iOS and tvOS. - -The -icon flag specifies a path to a PNG image to use as app icon on iOS and Android. -If left unspecified, the appicon.png file from the main package is used -(if it exists). - -The -appid flag specifies the package name for Android or the bundle id for -iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio -tool can use it. - -The -version flag specifies the integer version code for Android and the last -component of the 1.0.X version for iOS and tvOS. - -For Android builds the -minsdk flag specify the minimum SDK level. For example, -use -minsdk 22 to target Android 5.1 (Lollipop) and later. - -For Windows builds the -minsdk flag specify the minimum OS version. For example, -use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later. - -For iOS builds the -minsdk flag specify the minimum iOS version. For example, -use -mindk 15 to target iOS 15.0 and later. - -For Android builds the -targetsdk flag specify the target SDK level. For example, -use -targetsdk 33 to target Android 13 (Tiramisu) and later. - -The -work flag prints the path to the working directory and suppress -its deletion. - -The -x flag will print all the external commands executed by the gogio tool. - -The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files -or specifies the name of key on Keychain to sign MacOS app. - -The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided. - -The -notaryid flag specifies the Apple ID to use for notarization of MacOS app. - -The -notarypass flag specifies the password of the Apple ID, ignored if -notaryid is not -provided. That must be an app-specific password, see https://support.apple.com/en-us/HT204397 -for details. If not provided, the password will be prompted. - -The -notaryteamid flag specifies the team ID to use for notarization of MacOS app, ignored if --notaryid is not provided. -` diff --git a/third_party/gioui-cmd/gogio/internal/custom/testdata.go b/third_party/gioui-cmd/gogio/internal/custom/testdata.go deleted file mode 100644 index 5a17448..0000000 --- a/third_party/gioui-cmd/gogio/internal/custom/testdata.go +++ /dev/null @@ -1,371 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -//go:build linux -// +build linux - -// This program demonstrates the use of a custom OpenGL ES context with -// app.Window. -package main - -import ( - "errors" - "fmt" - "image" - "image/color" - "log" - "os" - "runtime" - "strings" - "unsafe" - - "gioui.org/app" - "gioui.org/gpu" - "gioui.org/io/event" - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" -) - -/* -#cgo linux pkg-config: egl wayland-egl -#cgo freebsd openbsd CFLAGS: -I/usr/local/include -#cgo openbsd CFLAGS: -I/usr/X11R6/include -#cgo freebsd LDFLAGS: -L/usr/local/lib -#cgo openbsd LDFLAGS: -L/usr/X11R6/lib -#cgo freebsd openbsd LDFLAGS: -lwayland-egl -#cgo CFLAGS: -DEGL_NO_X11 -#cgo LDFLAGS: -lEGL -lGLESv2 - -#include -#include -#include -#include -#define EGL_EGLEXT_PROTOTYPES -#include - -*/ -import "C" - -func getDisplay(ve app.ViewEvent) C.EGLDisplay { - switch ve := ve.(type) { - case app.X11ViewEvent: - return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display)) - case app.WaylandViewEvent: - return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display)) - } - panic("no display available") -} - -func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) { - switch e := e.(type) { - case app.X11ViewEvent: - return C.EGLNativeWindowType(uintptr(e.Window)), func() {} - case app.WaylandViewEvent: - eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y)) - return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() { - C.wl_egl_window_destroy(eglWin) - } - } - panic("no native view available") -} - -type ( - C = layout.Context - D = layout.Dimensions -) - -type notifyFrame int - -const ( - notifyNone notifyFrame = iota - notifyInvalidate - notifyPrint -) - -// notify keeps track of whether we want to print to stdout to notify the user -// when a frame is ready. Initially we want to notify about the first frame. -var notify = notifyInvalidate - -type eglContext struct { - disp C.EGLDisplay - ctx C.EGLContext - surf C.EGLSurface - cleanup func() -} - -func main() { - go func() { - // Set CustomRenderer so we can provide our own rendering context. - w := new(app.Window) - w.Option(app.CustomRenderer(true)) - if err := loop(w); err != nil { - log.Fatal(err) - } - os.Exit(0) - }() - app.Main() -} - -func loop(w *app.Window) error { - var ops op.Ops - var ( - ctx *eglContext - gioCtx gpu.GPU - ve app.ViewEvent - init bool - size image.Point - ) - - recreateContext := func() { - w.Run(func() { - if gioCtx != nil { - gioCtx.Release() - gioCtx = nil - } - if ctx != nil { - C.eglMakeCurrent(ctx.disp, nil, nil, nil) - ctx.Release() - ctx = nil - } - c, err := createContext(ve, size) - if err != nil { - log.Fatal(err) - } - ctx = c - }) - if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE { - err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError()) - log.Fatal(err) - } - glGetString := func(e C.GLenum) string { - return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e)))) - } - fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER)) - var err error - gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true}) - if err != nil { - log.Fatal(err) - } - } - - topLeft := quarterWidget{ - color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}, - } - topRight := quarterWidget{ - color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, - } - botLeft := quarterWidget{ - color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}, - } - botRight := quarterWidget{ - color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80}, - } - - // eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread. - runtime.LockOSThread() - for { - switch e := w.Event().(type) { - case app.ViewEvent: - ve = e - init = true - if size != (image.Point{}) { - recreateContext() - } - case app.DestroyEvent: - return e.Err - case app.FrameEvent: - if init && size != e.Size { - size = e.Size - recreateContext() - } - if gioCtx == nil || !init { - break - } - // Build ops. - gtx := app.NewContext(&ops, e) - - // Clear background to white, even on embedded platforms such as webassembly. - paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff}) - layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - // r1c1 - layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }), - // r1c2 - layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }), - ) - }), - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - // r2c1 - layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }), - // r2c2 - layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }), - ) - }), - ) - gtx.Execute(op.InvalidateCmd{}) - log.Println("frame") - - // Trigger window resize detection in ANGLE. - C.eglWaitClient() - // Draw custom OpenGL content. - drawGL() - - // Render drawing ops. - if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil { - log.Fatal(fmt.Errorf("render failed: %v", err)) - } - - // Process non-drawing ops. - e.Frame(gtx.Ops) - switch notify { - case notifyInvalidate: - notify = notifyPrint - w.Invalidate() - case notifyPrint: - notify = notifyNone - fmt.Println("gio frame ready") - } - - if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE { - log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError())) - } - - } - } - return nil -} - -func drawGL() { - C.glClearColor(0, 0, 0, 1) - C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT) -} - -func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) { - view, cleanup := nativeViewFor(ve, size) - var nilv C.EGLNativeWindowType - if view == nilv { - return nil, fmt.Errorf("failed creating native view") - } - disp := getDisplay(ve) - if disp == 0 { - return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError()) - } - var major, minor C.EGLint - if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE { - return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError()) - } - exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ") - srgb := hasExtension(exts, "EGL_KHR_gl_colorspace") - attribs := []C.EGLint{ - C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT, - C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT, - C.EGL_BLUE_SIZE, 8, - C.EGL_GREEN_SIZE, 8, - C.EGL_RED_SIZE, 8, - C.EGL_CONFIG_CAVEAT, C.EGL_NONE, - } - if srgb { - // Some drivers need alpha for sRGB framebuffers to work. - attribs = append(attribs, C.EGL_ALPHA_SIZE, 8) - } - attribs = append(attribs, C.EGL_NONE) - var ( - cfg C.EGLConfig - numCfgs C.EGLint - ) - if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE { - return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError()) - } - if numCfgs == 0 { - supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context") - if !supportsNoCfg { - return nil, errors.New("eglChooseConfig returned no configs") - } - } - ctxAttribs := []C.EGLint{ - C.EGL_CONTEXT_CLIENT_VERSION, 3, - C.EGL_NONE, - } - ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0]) - if ctx == nil { - return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError()) - } - var surfAttribs []C.EGLint - if srgb { - surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB) - } - surfAttribs = append(surfAttribs, C.EGL_NONE) - surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0]) - if surf == nil { - return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError()) - } - return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil -} - -func (c *eglContext) Release() { - if c.ctx != nil { - C.eglDestroyContext(c.disp, c.ctx) - } - if c.surf != nil { - C.eglDestroySurface(c.disp, c.surf) - } - if c.cleanup != nil { - c.cleanup() - } - *c = eglContext{} -} - -func hasExtension(exts []string, ext string) bool { - for _, e := range exts { - if ext == e { - return true - } - } - return false -} - -// quarterWidget paints a quarter of the screen with one color. When clicked, it -// turns red, going back to its normal color when clicked again. -type quarterWidget struct { - color color.NRGBA - - clicked bool -} - -var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} - -func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions { - var color color.NRGBA - if w.clicked { - color = red - } else { - color = w.color - } - - r := image.Rectangle{Max: gtx.Constraints.Max} - paint.FillShape(gtx.Ops, color, clip.Rect(r).Op()) - - defer clip.Rect(image.Rectangle{ - Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y), - }).Push(gtx.Ops).Pop() - event.Op(gtx.Ops, w) - for { - e, ok := gtx.Event(pointer.Filter{ - Target: w, - Kinds: pointer.Press, - }) - if !ok { - break - } - if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press { - w.clicked = !w.clicked - // notify when we're done updating the frame. - notify = notifyInvalidate - } - } - return layout.Dimensions{Size: gtx.Constraints.Max} -} diff --git a/third_party/gioui-cmd/gogio/internal/normal/testdata.go b/third_party/gioui-cmd/gogio/internal/normal/testdata.go deleted file mode 100644 index a78f884..0000000 --- a/third_party/gioui-cmd/gogio/internal/normal/testdata.go +++ /dev/null @@ -1,147 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -// A simple app used for gogio's end-to-end tests. -package main - -import ( - "fmt" - "image" - "image/color" - "log" - - "gioui.org/app" - "gioui.org/io/event" - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" -) - -func main() { - go func() { - w := new(app.Window) - if err := loop(w); err != nil { - log.Fatal(err) - } - }() - app.Main() -} - -type notifyFrame int - -const ( - notifyNone notifyFrame = iota - notifyInvalidate - notifyPrint -) - -// notify keeps track of whether we want to print to stdout to notify the user -// when a frame is ready. Initially we want to notify about the first frame. -var notify = notifyInvalidate - -type ( - C = layout.Context - D = layout.Dimensions -) - -func loop(w *app.Window) error { - topLeft := quarterWidget{ - color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}, - } - topRight := quarterWidget{ - color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, - } - botLeft := quarterWidget{ - color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}, - } - botRight := quarterWidget{ - color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80}, - } - - var ops op.Ops - for { - e := w.Event() - switch e := e.(type) { - case app.DestroyEvent: - return e.Err - case app.FrameEvent: - gtx := app.NewContext(&ops, e) - // Clear background to white, even on embedded platforms such as webassembly. - paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff}) - layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - // r1c1 - layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }), - // r1c2 - layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }), - ) - }), - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - // r2c1 - layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }), - // r2c2 - layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }), - ) - }), - ) - - e.Frame(gtx.Ops) - - switch notify { - case notifyInvalidate: - notify = notifyPrint - w.Invalidate() - case notifyPrint: - notify = notifyNone - fmt.Println("gio frame ready") - } - } - } -} - -// quarterWidget paints a quarter of the screen with one color. When clicked, it -// turns red, going back to its normal color when clicked again. -type quarterWidget struct { - color color.NRGBA - - clicked bool -} - -var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} - -func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions { - var color color.NRGBA - if w.clicked { - color = red - } else { - color = w.color - } - - r := image.Rectangle{Max: gtx.Constraints.Max} - paint.FillShape(gtx.Ops, color, clip.Rect(r).Op()) - - defer clip.Rect(image.Rectangle{ - Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y), - }).Push(gtx.Ops).Pop() - event.Op(gtx.Ops, w) - filter := pointer.Filter{ - Target: w, - Kinds: pointer.Press, - } - - for { - e, ok := gtx.Event(filter) - if !ok { - break - } - if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press { - w.clicked = !w.clicked - // notify when we're done updating the frame. - notify = notifyInvalidate - } - } - return layout.Dimensions{Size: gtx.Constraints.Max} -} diff --git a/third_party/gioui-cmd/gogio/iosbuild.go b/third_party/gioui-cmd/gogio/iosbuild.go deleted file mode 100644 index 1126cd5..0000000 --- a/third_party/gioui-cmd/gogio/iosbuild.go +++ /dev/null @@ -1,546 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main - -import ( - "archive/zip" - "crypto/sha1" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "golang.org/x/sync/errgroup" -) - -const ( - minIOSVersion = 10 - // Some Metal features require tvOS 11 - minTVOSVersion = 11 - // Metal is available from iOS 8 on devices, yet from version 13 on the - // simulator. - minSimulatorVersion = 13 -) - -func buildIOS(tmpDir, target string, bi *buildInfo) error { - appName := bi.name - switch *buildMode { - case "archive": - framework := *destPath - if framework == "" { - framework = fmt.Sprintf("%s.framework", UppercaseName(appName)) - } - return archiveIOS(tmpDir, target, framework, bi) - case "exe": - out := *destPath - if out == "" { - out = appName + ".ipa" - } - forDevice := strings.HasSuffix(out, ".ipa") - // Filter out unsupported architectures. - for i := len(bi.archs) - 1; i >= 0; i-- { - switch bi.archs[i] { - case "arm", "arm64": - if forDevice { - continue - } - case "386", "amd64": - if !forDevice { - continue - } - } - - bi.archs = append(bi.archs[:i], bi.archs[i+1:]...) - } - if !forDevice && !strings.HasSuffix(out, ".app") { - return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out) - } - if !forDevice { - return exeIOS(tmpDir, target, out, bi) - } - payload := filepath.Join(tmpDir, "Payload") - appDir := filepath.Join(payload, appName+".app") - if err := os.MkdirAll(appDir, 0755); err != nil { - return err - } - if err := exeIOS(tmpDir, target, appDir, bi); err != nil { - return err - } - if err := signIOS(bi, tmpDir, appDir); err != nil { - return err - } - return zipDir(out, tmpDir, "Payload") - default: - panic("unreachable") - } -} - -func signIOS(bi *buildInfo, tmpDir, app string) error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision") - provisions, err := filepath.Glob(provPattern) - if err != nil { - return err - } - provInfo := filepath.Join(tmpDir, "provision.plist") - var avail []string - for _, prov := range provisions { - // Decode the provision file to a plist. - _, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo)) - if err != nil { - return err - } - expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo)) - if err != nil { - return err - } - exp, err := time.Parse(time.UnixDate, expUnix) - if err != nil { - return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err) - } - if exp.Before(time.Now()) { - continue - } - appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo)) - if err != nil { - return err - } - provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo)) - if err != nil { - return err - } - expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID) - avail = append(avail, provAppID) - if expAppID != provAppID { - continue - } - // Copy provisioning file. - embedded := filepath.Join(app, "embedded.mobileprovision") - if err := copyFile(embedded, prov); err != nil { - return err - } - certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo)) - if err != nil { - return err - } - // Omit trailing newline. - certDER = certDER[:len(certDER)-1] - entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo)) - if err != nil { - return err - } - entFile := filepath.Join(tmpDir, "entitlements.plist") - if err := os.WriteFile(entFile, []byte(entitlements), 0660); err != nil { - return err - } - identity := sha1.Sum(certDER) - idHex := hex.EncodeToString(identity[:]) - _, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app)) - return err - } - return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail) -} - -func exeIOS(tmpDir, target, app string, bi *buildInfo) error { - if bi.appID == "" { - return errors.New("app id is empty; use -appid to set it") - } - if err := os.RemoveAll(app); err != nil { - return err - } - if err := os.Mkdir(app, 0755); err != nil { - return err - } - appName := UppercaseName(bi.name) - exe := filepath.Join(app, appName) - lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create") - var builds errgroup.Group - for _, a := range bi.archs { - clang, cflags, err := iosCompilerFor(target, a, bi.minsdk) - if err != nil { - return err - } - cflags = append(cflags, - "-fobjc-arc", - ) - cflagsLine := strings.Join(cflags, " ") - exeSlice := filepath.Join(tmpDir, "app-"+a) - lipo.Args = append(lipo.Args, exeSlice) - compile := exec.Command( - "go", - "build", - "-ldflags=-s -w "+bi.ldflags, - "-o", exeSlice, - "-tags", bi.tags, - bi.pkgPath, - ) - compile.Env = append( - os.Environ(), - "GOOS=ios", - "GOARCH="+a, - "CGO_ENABLED=1", - "CC="+clang, - "CGO_CFLAGS="+cflagsLine, - "CGO_LDFLAGS=-lresolv "+cflagsLine, - ) - builds.Go(func() error { - _, err := runCmd(compile) - return err - }) - } - if err := builds.Wait(); err != nil { - return err - } - if _, err := runCmd(lipo); err != nil { - return err - } - infoPlist := buildInfoPlist(bi) - plistFile := filepath.Join(app, "Info.plist") - if err := os.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil { - return err - } - if _, err := os.Stat(bi.iconPath); err == nil { - assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath) - if err != nil { - return err - } - // Merge assets plist with Info.plist - cmd := exec.Command( - "/usr/libexec/PlistBuddy", - "-c", "Merge "+assetPlist, - plistFile, - ) - if _, err := runCmd(cmd); err != nil { - return err - } - } - if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil { - return err - } - return nil -} - -// iosIcons builds an asset catalog and compile it with the Xcode command actool. -// iosIcons returns the asset plist file to be merged into Info.plist. -func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) { - assets := filepath.Join(tmpDir, "Assets.xcassets") - if err := os.Mkdir(assets, 0700); err != nil { - return "", err - } - appIcon := filepath.Join(assets, "AppIcon.appiconset") - err := buildIcons(appIcon, icon, []iconVariant{ - {path: "ios_2x.png", size: 120}, - {path: "ios_3x.png", size: 180}, - // The App Store icon is not allowed to contain - // transparent pixels. - {path: "ios_store.png", size: 1024, fill: true}, - }) - if err != nil { - return "", err - } - contentJson := `{ - "images" : [ - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "ios_2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "ios_3x.png", - "scale" : "3x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "ios_store.png", - "scale" : "1x" - } - ] -}` - contentFile := filepath.Join(appIcon, "Contents.json") - if err := os.WriteFile(contentFile, []byte(contentJson), 0600); err != nil { - return "", err - } - assetPlist := filepath.Join(tmpDir, "assets.plist") - - minsdk := bi.minsdk - if minsdk == 0 { - minsdk = minIOSVersion - } - compile := exec.Command( - "actool", - "--compile", appDir, - "--platform", iosPlatformFor(bi.target), - "--minimum-deployment-target", strconv.Itoa(minsdk), - "--app-icon", "AppIcon", - "--output-partial-info-plist", assetPlist, - assets) - _, err = runCmd(compile) - return assetPlist, err -} - -func buildInfoPlist(bi *buildInfo) string { - appName := UppercaseName(bi.name) - platform := iosPlatformFor(bi.target) - var supportPlatform string - switch bi.target { - case "ios": - supportPlatform = "iPhoneOS" - case "tvos": - supportPlatform = "AppleTVOS" - } - return fmt.Sprintf(` - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - %s - CFBundleIdentifier - %s - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - %s - CFBundlePackageType - APPL - CFBundleShortVersionString - %s - CFBundleVersion - %d - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - arm64 - DTPlatformName - %s - DTPlatformVersion - 12.4 - MinimumOSVersion - %d - UIDeviceFamily - - 1 - 2 - - CFBundleSupportedPlatforms - - %s - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - DTCompiler - com.apple.compilers.llvm.clang.1_0 - DTPlatformBuild - 16G73 - DTSDKBuild - 16G73 - DTSDKName - %s12.4 - DTXcode - 1030 - DTXcodeBuild - 10G8 - -`, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform) -} - -func iosPlatformFor(target string) string { - switch target { - case "ios": - return "iphoneos" - case "tvos": - return "appletvos" - default: - panic("invalid platform " + target) - } -} - -func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error { - framework := filepath.Base(frameworkRoot) - const suf = ".framework" - if !strings.HasSuffix(framework, suf) { - return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot) - } - framework = framework[:len(framework)-len(suf)] - if err := os.RemoveAll(frameworkRoot); err != nil { - return err - } - frameworkDir := filepath.Join(frameworkRoot, "Versions", "A") - for _, dir := range []string{"Headers", "Modules"} { - p := filepath.Join(frameworkDir, dir) - if err := os.MkdirAll(p, 0755); err != nil { - return err - } - } - symlinks := [][2]string{ - {"Versions/Current/Headers", "Headers"}, - {"Versions/Current/Modules", "Modules"}, - {"Versions/Current/" + framework, framework}, - {"A", filepath.Join("Versions", "Current")}, - } - for _, l := range symlinks { - if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) { - return err - } - } - exe := filepath.Join(frameworkDir, framework) - lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create") - var builds errgroup.Group - tags := bi.tags - for _, a := range bi.archs { - clang, cflags, err := iosCompilerFor(target, a, bi.minsdk) - if err != nil { - return err - } - lib := filepath.Join(tmpDir, "gio-"+a) - cmd := exec.Command( - "go", - "build", - "-ldflags=-s -w "+bi.ldflags, - "-buildmode=c-archive", - "-o", lib, - "-tags", tags, - bi.pkgPath, - ) - lipo.Args = append(lipo.Args, lib) - cflagsLine := strings.Join(cflags, " ") - cmd.Env = append( - os.Environ(), - "GOOS=ios", - "GOARCH="+a, - "CGO_ENABLED=1", - "CC="+clang, - "CGO_CFLAGS="+cflagsLine, - "CGO_LDFLAGS="+cflagsLine, - ) - builds.Go(func() error { - _, err := runCmd(cmd) - return err - }) - } - if err := builds.Wait(); err != nil { - return err - } - if _, err := runCmd(lipo); err != nil { - return err - } - appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/")) - if err != nil { - return err - } - headerDst := filepath.Join(frameworkDir, "Headers", framework+".h") - headerSrc := filepath.Join(appDir, "framework_ios.h") - if err := copyFile(headerDst, headerSrc); err != nil { - return err - } - module := fmt.Sprintf(`framework module "%s" { - header "%[1]s.h" - - export * -}`, framework) - moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap") - return os.WriteFile(moduleFile, []byte(module), 0644) -} - -func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) { - var ( - platformSDK string - platformOS string - ) - switch target { - case "ios": - platformOS = "ios" - platformSDK = "iphone" - case "tvos": - platformOS = "tvos" - platformSDK = "appletv" - } - switch arch { - case "arm", "arm64": - platformSDK += "os" - if minsdk == 0 { - minsdk = minIOSVersion - if target == "tvos" { - minsdk = minTVOSVersion - } - } - case "386", "amd64": - platformOS += "-simulator" - platformSDK += "simulator" - if minsdk == 0 { - minsdk = minSimulatorVersion - } - default: - return "", nil, fmt.Errorf("unsupported -arch: %s", arch) - } - sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path")) - if err != nil { - return "", nil, err - } - clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang")) - if err != nil { - return "", nil, err - } - cflags := []string{ - "-fembed-bitcode", - "-arch", allArchs[arch].iosArch, - "-isysroot", sdkPath, - "-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk), - } - return clang, cflags, nil -} - -func zipDir(dst, base, dir string) (err error) { - f, err := os.Create(dst) - if err != nil { - return err - } - defer func() { - if cerr := f.Close(); err == nil { - err = cerr - } - }() - zipf := zip.NewWriter(f) - err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error { - if err != nil { - return err - } - if f.IsDir() { - return nil - } - rel := filepath.ToSlash(path[len(base)+1:]) - entry, err := zipf.Create(rel) - if err != nil { - return err - } - src, err := os.Open(path) - if err != nil { - return err - } - defer src.Close() - _, err = io.Copy(entry, src) - return err - }) - if err != nil { - return err - } - return zipf.Close() -} diff --git a/third_party/gioui-cmd/gogio/js_test.go b/third_party/gioui-cmd/gogio/js_test.go deleted file mode 100644 index 8584894..0000000 --- a/third_party/gioui-cmd/gogio/js_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main_test - -import ( - "bytes" - "context" - "errors" - "image" - "image/png" - "io" - "net/http" - "net/http/httptest" - "os/exec" - - "github.com/chromedp/cdproto/runtime" - "github.com/chromedp/chromedp" - - _ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there -) - -type JSTestDriver struct { - driverBase - - // ctx is the chromedp context. - ctx context.Context -} - -func (d *JSTestDriver) Start(path string) { - if raceEnabled { - d.Skipf("js/wasm doesn't support -race; skipping") - } - - // First, build the app. - dir := d.tempDir("gio-endtoend-js") - d.gogio("-target=js", "-o="+dir, path) - - // Second, start Chrome. - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", *headless), - ) - - actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) - d.Cleanup(cancel) - - ctx, cancel := chromedp.NewContext(actx, - // Send all logf/errf calls to t.Logf - chromedp.WithLogf(d.Logf), - ) - d.Cleanup(cancel) - d.ctx = ctx - - if err := chromedp.Run(ctx); err != nil { - if errors.Is(err, exec.ErrNotFound) { - d.Skipf("test requires Chrome to be installed: %v", err) - return - } - d.Fatal(err) - } - pr, pw := io.Pipe() - d.Cleanup(func() { pw.Close() }) - d.output = pr - chromedp.ListenTarget(ctx, func(ev interface{}) { - switch ev := ev.(type) { - case *runtime.EventConsoleAPICalled: - switch ev.Type { - case "log", "info", "warning", "error": - var b bytes.Buffer - b.WriteString("console.") - b.WriteString(string(ev.Type)) - b.WriteString("(") - for i, arg := range ev.Args { - if i > 0 { - b.WriteString(", ") - } - b.Write(arg.Value) - } - b.WriteString(")\n") - pw.Write(b.Bytes()) - } - } - }) - - // Third, serve the app folder, set the browser tab dimensions, and - // navigate to the folder. - ts := httptest.NewServer(http.FileServer(http.Dir(dir))) - d.Cleanup(ts.Close) - - if err := chromedp.Run(ctx, - chromedp.EmulateViewport(int64(d.width), int64(d.height)), - chromedp.Navigate(ts.URL), - ); err != nil { - d.Fatal(err) - } - - // Wait for the gio app to render. - d.waitForFrame() -} - -func (d *JSTestDriver) Screenshot() image.Image { - var buf []byte - if err := chromedp.Run(d.ctx, - chromedp.CaptureScreenshot(&buf), - ); err != nil { - d.Fatal(err) - } - img, err := png.Decode(bytes.NewReader(buf)) - if err != nil { - d.Fatal(err) - } - return img -} - -func (d *JSTestDriver) Click(x, y int) { - if err := chromedp.Run(d.ctx, - chromedp.MouseClickXY(float64(x), float64(y)), - ); err != nil { - d.Fatal(err) - } - - // Wait for the gio app to render after this click. - d.waitForFrame() -} diff --git a/third_party/gioui-cmd/gogio/jsbuild.go b/third_party/gioui-cmd/gogio/jsbuild.go deleted file mode 100644 index b99f048..0000000 --- a/third_party/gioui-cmd/gogio/jsbuild.go +++ /dev/null @@ -1,200 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main - -import ( - "bytes" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "text/template" - - "golang.org/x/tools/go/packages" -) - -func buildJS(bi *buildInfo) error { - out := *destPath - if out == "" { - out = bi.name - } - if err := os.MkdirAll(out, 0700); err != nil { - return err - } - cmd := exec.Command( - "go", - "build", - "-ldflags="+bi.ldflags, - "-tags="+bi.tags, - "-o", filepath.Join(out, "main.wasm"), - bi.pkgPath, - ) - cmd.Env = append( - os.Environ(), - "GOOS=js", - "GOARCH=wasm", - ) - _, err := runCmd(cmd) - if err != nil { - return err - } - - var faviconPath string - if _, err := os.Stat(bi.iconPath); err == nil { - // Copy icon to the output folder - icon, err := os.ReadFile(bi.iconPath) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0600); err != nil { - return err - } - faviconPath = filepath.Base(bi.iconPath) - } - - indexTemplate, err := template.New("").Parse(jsIndex) - if err != nil { - return err - } - - var b bytes.Buffer - if err := indexTemplate.Execute(&b, struct { - Name string - Icon string - }{ - Name: bi.name, - Icon: faviconPath, - }); err != nil { - return err - } - - if err := os.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 0600); err != nil { - return err - } - - goroot, err := runCmd(exec.Command("go", "env", "GOROOT")) - if err != nil { - return err - } - wasmJS := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js") - if _, err := os.Stat(wasmJS); err != nil { - return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err) - } - pkgs, err := packages.Load(&packages.Config{ - Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps, - Env: append(os.Environ(), "GOOS=js", "GOARCH=wasm"), - }, bi.pkgPath) - if err != nil { - return err - } - extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool)) - if err != nil { - return err - } - - return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...) -} - -func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) { - if len(p.GoFiles) == 0 { - return nil, nil - } - js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js")) - if err != nil { - return nil, err - } - extraJS = append(extraJS, js...) - for _, imp := range p.Imports { - if !visited[imp.ID] { - extra, err := findPackagesJS(imp, visited) - if err != nil { - return nil, err - } - extraJS = append(extraJS, extra...) - visited[imp.ID] = true - } - } - return extraJS, nil -} - -// mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo -// and append the jsStartGo. -func mergeJSFiles(dst string, files ...string) (err error) { - w, err := os.Create(dst) - if err != nil { - return err - } - defer func() { - if cerr := w.Close(); err != nil { - err = cerr - } - }() - _, err = io.Copy(w, strings.NewReader(jsSetGo)) - if err != nil { - return err - } - for i := range files { - r, err := os.Open(files[i]) - if err != nil { - return err - } - _, err = io.Copy(w, r) - r.Close() - if err != nil { - return err - } - } - _, err = io.Copy(w, strings.NewReader(jsStartGo)) - return err -} - -const ( - jsIndex = ` - - - - - - {{ if .Icon }}{{ end }} - {{ if .Name }}{{.Name}}{{ end }} - - - - - -` - // jsSetGo sets the `window.go` variable. - jsSetGo = `(() => { - window.go = {argv: [], env: {}, importObject: {go: {}}}; - const argv = new URLSearchParams(location.search).get("argv"); - if (argv) { - window.go["argv"] = argv.split(" "); - } -})();` - // jsStartGo initializes the main.wasm. - jsStartGo = `(() => { - defaultGo = new Go(); - Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"])); - Object.assign(defaultGo["env"], go["env"]); - for (let key in go["importObject"]) { - if (typeof defaultGo["importObject"][key] === "undefined") { - defaultGo["importObject"][key] = {}; - } - Object.assign(defaultGo["importObject"][key], go["importObject"][key]); - } - window.go = defaultGo; - if (!WebAssembly.instantiateStreaming) { // polyfill - WebAssembly.instantiateStreaming = async (resp, importObject) => { - const source = await (await resp).arrayBuffer(); - return await WebAssembly.instantiate(source, importObject); - }; - } - WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => { - go.run(result.instance); - }); -})();` -) diff --git a/third_party/gioui-cmd/gogio/macosbuild.go b/third_party/gioui-cmd/gogio/macosbuild.go deleted file mode 100644 index 88e9463..0000000 --- a/third_party/gioui-cmd/gogio/macosbuild.go +++ /dev/null @@ -1,262 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "text/template" -) - -func buildMac(tmpDir string, bi *buildInfo) error { - builder := &macBuilder{TempDir: tmpDir} - builder.DestDir = *destPath - if builder.DestDir == "" { - builder.DestDir = bi.pkgPath - } - - name := bi.name - if *destPath != "" { - if filepath.Ext(*destPath) != ".app" { - return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath) - } - name = filepath.Base(*destPath) - } - name = strings.TrimSuffix(name, ".app") - - if bi.appID == "" { - return errors.New("app id is empty; use -appid to set it") - } - - if err := builder.setIcon(bi.iconPath); err != nil { - return err - } - - if err := builder.setInfo(bi, name); err != nil { - return fmt.Errorf("can't build the resources: %v", err) - } - - for _, arch := range bi.archs { - tmpDest := filepath.Join(builder.TempDir, filepath.Base(builder.DestDir)) - finalDest := builder.DestDir - if len(bi.archs) > 1 { - tmpDest = filepath.Join(builder.TempDir, name+"_"+arch+".app") - finalDest = filepath.Join(builder.DestDir, name+"_"+arch+".app") - } - - if err := builder.buildProgram(bi, tmpDest, name, arch); err != nil { - return err - } - - if bi.key != "" { - if err := builder.signProgram(bi, tmpDest, name, arch); err != nil { - return err - } - } - - if err := dittozip(tmpDest, tmpDest+".zip"); err != nil { - return err - } - - if bi.notaryAppleID != "" { - if err := builder.notarize(bi, tmpDest+".zip"); err != nil { - return err - } - } - - if err := dittounzip(tmpDest+".zip", finalDest); err != nil { - return err - } - } - - return nil -} - -type macBuilder struct { - TempDir string - DestDir string - - Icons []byte - Manifest []byte - Entitlements []byte -} - -func (b *macBuilder) setIcon(path string) (err error) { - if _, err := os.Stat(path); err != nil { - return nil - } - - out := filepath.Join(b.TempDir, "iconset.iconset") - if err := os.MkdirAll(out, 0777); err != nil { - return err - } - - err = buildIcons(out, path, []iconVariant{ - {path: "icon_512x512@2x.png", size: 1024}, - {path: "icon_512x512.png", size: 512}, - {path: "icon_256x256@2x.png", size: 512}, - {path: "icon_256x256.png", size: 256}, - {path: "icon_128x128@2x.png", size: 256}, - {path: "icon_128x128.png", size: 128}, - {path: "icon_64x64@2x.png", size: 128}, - {path: "icon_64x64.png", size: 64}, - {path: "icon_32x32@2x.png", size: 64}, - {path: "icon_32x32.png", size: 32}, - {path: "icon_16x16@2x.png", size: 32}, - {path: "icon_16x16.png", size: 16}, - }) - - if err != nil { - return err - } - - cmd := exec.Command("iconutil", - "-c", "icns", out, - "-o", filepath.Join(b.TempDir, "icon.icns")) - if _, err := runCmd(cmd); err != nil { - return err - } - - b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns")) - return err -} - -func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error { - t, err := template.New("manifest").Parse(` - - - - CFBundleExecutable - {{.Name}} - CFBundleIconFile - icon.icns - CFBundleIdentifier - {{.Bundle}} - NSHighResolutionCapable - - CFBundlePackageType - APPL - -`) - if err != nil { - return err - } - - var manifest bufferCoff - if err := t.Execute(&manifest, struct { - Name, Bundle string - }{ - Name: name, - Bundle: buildInfo.appID, - }); err != nil { - return err - } - b.Manifest = manifest.Bytes() - - b.Entitlements = []byte(` - - - -com.apple.security.cs.allow-unsigned-executable-memory - -com.apple.security.cs.allow-jit - - -`) - - return nil -} - -func (b *macBuilder) buildProgram(buildInfo *buildInfo, binDest string, name string, arch string) error { - for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} { - if err := os.MkdirAll(filepath.Join(binDest, path), 0755); err != nil { - return err - } - } - - if len(b.Icons) > 0 { - if err := os.WriteFile(filepath.Join(binDest, "/Contents/Resources/icon.icns"), b.Icons, 0755); err != nil { - return err - } - } - - if err := os.WriteFile(filepath.Join(binDest, "/Contents/Info.plist"), b.Manifest, 0755); err != nil { - return err - } - - cmd := exec.Command( - "go", - "build", - "-ldflags="+buildInfo.ldflags, - "-tags="+buildInfo.tags, - "-o", filepath.Join(binDest, "/Contents/MacOS/"+name), - buildInfo.pkgPath, - ) - cmd.Env = append( - os.Environ(), - "GOOS=darwin", - "GOARCH="+arch, - "CGO_ENABLED=1", // Required to cross-compile between AMD/ARM - ) - _, err := runCmd(cmd) - return err -} - -func (b *macBuilder) signProgram(buildInfo *buildInfo, binDest string, name string, arch string) error { - options := filepath.Join(b.TempDir, "ent.ent") - if err := os.WriteFile(options, b.Entitlements, 0777); err != nil { - return err - } - - xattr := exec.Command("xattr", "-rc", binDest) - if _, err := runCmd(xattr); err != nil { - return err - } - - cmd := exec.Command( - "codesign", - "--deep", - "--force", - "--options", "runtime", - "--entitlements", options, - "--sign", buildInfo.key, - binDest, - ) - _, err := runCmd(cmd) - return err -} - -func (b *macBuilder) notarize(buildInfo *buildInfo, binDest string) error { - cmd := exec.Command( - "xcrun", - "notarytool", - "submit", - binDest, - "--apple-id", buildInfo.notaryAppleID, - "--team-id", buildInfo.notaryTeamID, - "--wait", - ) - - if buildInfo.notaryPassword != "" { - cmd.Args = append(cmd.Args, "--password", buildInfo.notaryPassword) - } - - _, err := runCmd(cmd) - return err -} - -func dittozip(input, output string) error { - cmd := exec.Command("ditto", "-c", "-k", "-X", "--rsrc", input, output) - - _, err := runCmd(cmd) - return err -} - -func dittounzip(input, output string) error { - cmd := exec.Command("ditto", "-x", "-k", "-X", "--rsrc", input, output) - - _, err := runCmd(cmd) - return err -} diff --git a/third_party/gioui-cmd/gogio/main.go b/third_party/gioui-cmd/gogio/main.go deleted file mode 100644 index 9917918..0000000 --- a/third_party/gioui-cmd/gogio/main.go +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main - -import ( - "bytes" - "errors" - "flag" - "fmt" - "image" - "image/color" - "image/png" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - - "golang.org/x/image/draw" - "golang.org/x/sync/errgroup" -) - -var ( - target = flag.String("target", "", "specify target (ios, tvos, android, js).\n") - archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).") - minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level") - targetsdk = flag.Int("targetsdk", 0, "specify the target supported operating system level for Android") - buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)") - destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.") - appID = flag.String("appid", "", "app identifier (for -buildmode=exe)") - name = flag.String("name", "", "app name (for -buildmode=exe)") - version = flag.String("version", "1.0.0.1", "semver app version (for -buildmode=exe) on the form major.minor.patch.versioncode") - printCommands = flag.Bool("x", false, "print the commands") - keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.") - linkMode = flag.String("linkmode", "", "set the -linkmode flag of the go tool") - extraLdflags = flag.String("ldflags", "", "extra flags to the Go linker") - extraTags = flag.String("tags", "", "extra tags to the Go tool") - iconPath = flag.String("icon", "", "specify an icon for iOS and Android") - signKey = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.") - signPass = flag.String("signpass", "", "specify the password to decrypt the signkey.") - notaryID = flag.String("notaryid", "", "specify the apple id to use for notarization.") - notaryPass = flag.String("notarypass", "", "specify app-specific password of the Apple ID to be used for notarization.") - notaryTeamID = flag.String("notaryteamid", "", "specify the team id to use for notarization.") -) - -func main() { - flag.Usage = func() { - fmt.Fprint(os.Stderr, mainUsage) - } - flag.Parse() - if err := flagValidate(); err != nil { - fmt.Fprintf(os.Stderr, "gogio: %v\n", err) - os.Exit(1) - } - buildInfo, err := newBuildInfo(flag.Arg(0)) - if err != nil { - fmt.Fprintf(os.Stderr, "gogio: %v\n", err) - os.Exit(1) - } - if err := build(buildInfo); err != nil { - fmt.Fprintf(os.Stderr, "gogio: %v\n", err) - os.Exit(1) - } - os.Exit(0) -} - -func flagValidate() error { - pkgPathArg := flag.Arg(0) - if pkgPathArg == "" { - return errors.New("specify a package") - } - if *target == "" { - return errors.New("please specify -target") - } - switch *target { - case "ios", "tvos", "android", "js", "windows", "macos": - default: - return fmt.Errorf("invalid -target %s", *target) - } - switch *buildMode { - case "archive", "exe": - default: - return fmt.Errorf("invalid -buildmode %s", *buildMode) - } - return nil -} - -func build(bi *buildInfo) error { - tmpDir, err := os.MkdirTemp("", "gogio-") - if err != nil { - return err - } - if *keepWorkdir { - fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir) - } else { - defer os.RemoveAll(tmpDir) - } - switch *target { - case "js": - return buildJS(bi) - case "ios", "tvos": - return buildIOS(tmpDir, *target, bi) - case "android": - return buildAndroid(tmpDir, bi) - case "windows": - return buildWindows(tmpDir, bi) - case "macos": - return buildMac(tmpDir, bi) - default: - panic("unreachable") - } -} - -func runCmdRaw(cmd *exec.Cmd) ([]byte, error) { - if *printCommands { - fmt.Printf("%s\n", strings.Join(cmd.Args, " ")) - } - out, err := cmd.Output() - if err == nil { - return out, nil - } - if err, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr) - } - return nil, err -} - -func runCmd(cmd *exec.Cmd) (string, error) { - out, err := runCmdRaw(cmd) - return string(bytes.TrimSpace(out)), err -} - -func copyFile(dst, src string) (err error) { - r, err := os.Open(src) - if err != nil { - return err - } - defer r.Close() - w, err := os.Create(dst) - if err != nil { - return err - } - defer func() { - if cerr := w.Close(); err == nil { - err = cerr - } - }() - _, err = io.Copy(w, r) - return err -} - -type arch struct { - iosArch string - jniArch string - clangArch string -} - -var allArchs = map[string]arch{ - "arm": { - iosArch: "armv7", - jniArch: "armeabi-v7a", - clangArch: "armv7a-linux-androideabi", - }, - "arm64": { - iosArch: "arm64", - jniArch: "arm64-v8a", - clangArch: "aarch64-linux-android", - }, - "386": { - iosArch: "i386", - jniArch: "x86", - clangArch: "i686-linux-android", - }, - "amd64": { - iosArch: "x86_64", - jniArch: "x86_64", - clangArch: "x86_64-linux-android", - }, -} - -type iconVariant struct { - path string - size int - fill bool -} - -func buildIcons(baseDir, icon string, variants []iconVariant) error { - f, err := os.Open(icon) - if err != nil { - return err - } - defer f.Close() - img, _, err := image.Decode(f) - if err != nil { - return err - } - var resizes errgroup.Group - for _, v := range variants { - v := v - resizes.Go(func() (err error) { - path := filepath.Join(baseDir, v.path) - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - f, err := os.Create(path) - if err != nil { - return err - } - defer func() { - if cerr := f.Close(); err == nil { - err = cerr - } - }() - return png.Encode(f, resizeIcon(v, img)) - }) - } - return resizes.Wait() -} - -func resizeIcon(v iconVariant, img image.Image) *image.NRGBA { - scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}}) - op := draw.Src - if v.fill { - op = draw.Over - draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) - } - draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil) - - return scaled -} diff --git a/third_party/gioui-cmd/gogio/main_test.go b/third_party/gioui-cmd/gogio/main_test.go deleted file mode 100644 index 98dcb27..0000000 --- a/third_party/gioui-cmd/gogio/main_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "os" - "testing" -) - -func TestMain(m *testing.M) { - if os.Getenv("RUN_GOGIO") != "" { - // Allow the end-to-end tests to call the gogio tool without - // having to build it from scratch, nor having to refactor the - // main function to avoid using global variables. - main() - os.Exit(0) // main already exits, but just in case. - } - os.Exit(m.Run()) -} diff --git a/third_party/gioui-cmd/gogio/permission.go b/third_party/gioui-cmd/gogio/permission.go deleted file mode 100644 index c3227ac..0000000 --- a/third_party/gioui-cmd/gogio/permission.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -var AndroidPermissions = map[string][]string{ - "network": { - "android.permission.INTERNET", - }, - "networkstate": { - "android.permission.ACCESS_NETWORK_STATE", - }, - "bluetooth": { - "android.permission.BLUETOOTH", - "android.permission.BLUETOOTH_ADMIN", - "android.permission.ACCESS_FINE_LOCATION", - }, - "camera": { - "android.permission.CAMERA", - }, - "storage": { - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE", - }, - "wakelock": { - "android.permission.WAKE_LOCK", - }, -} - -var AndroidFeatures = map[string][]string{ - "default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`}, - "bluetooth": { - `name="android.hardware.bluetooth"`, - `name="android.hardware.bluetooth_le"`, - }, - "camera": { - `name="android.hardware.camera"`, - }, -} diff --git a/third_party/gioui-cmd/gogio/race_test.go b/third_party/gioui-cmd/gogio/race_test.go deleted file mode 100644 index 1f3c689..0000000 --- a/third_party/gioui-cmd/gogio/race_test.go +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -//go:build race -// +build race - -package main_test - -func init() { raceEnabled = true } diff --git a/third_party/gioui-cmd/gogio/wayland_test.go b/third_party/gioui-cmd/gogio/wayland_test.go deleted file mode 100644 index df10410..0000000 --- a/third_party/gioui-cmd/gogio/wayland_test.go +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main_test - -import ( - "bufio" - "bytes" - "context" - "fmt" - "image" - "image/png" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "sync" - "text/template" - "time" -) - -type WaylandTestDriver struct { - driverBase - - runtimeDir string - socket string - display string -} - -// No bars or anything fancy. Just a white background with our dimensions. -var tmplSwayConfig = template.Must(template.New("").Parse(` -output * bg #FFFFFF solid_color -output * mode {{.Width}}x{{.Height}} -default_border none -`)) - -var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`) - -func (d *WaylandTestDriver) Start(path string) { - // We want os.Environ, so that it can e.g. find $DISPLAY to run within - // X11. wlroots env vars are documented at: - // https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md - env := os.Environ() - if *headless { - env = append(env, "WLR_BACKENDS=headless") - } - - d.needPrograms( - "sway", // to run a wayland compositor - "grim", // to take screenshots - "swaymsg", // to send input - ) - - // First, build the app. - dir := d.tempDir("gio-endtoend-wayland") - bin := filepath.Join(dir, "red") - flags := []string{"build", "-tags", "nox11", "-o=" + bin} - if raceEnabled { - flags = append(flags, "-race") - } - flags = append(flags, path) - cmd := exec.Command("go", flags...) - if out, err := cmd.CombinedOutput(); err != nil { - d.Fatalf("could not build app: %s:\n%s", err, out) - } - - conf := filepath.Join(dir, "config") - f, err := os.Create(conf) - if err != nil { - d.Fatal(err) - } - defer f.Close() - if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{ - d.width, d.height, - }); err != nil { - d.Fatal(err) - } - - d.socket = filepath.Join(dir, "socket") - env = append(env, "SWAYSOCK="+d.socket) - d.runtimeDir = dir - env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir) - - var wg sync.WaitGroup - d.Cleanup(wg.Wait) - - // First, start sway. - { - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose") - cmd.Env = env - stderr, err := cmd.StderrPipe() - if err != nil { - d.Fatal(err) - } - if err := cmd.Start(); err != nil { - d.Fatal(err) - } - d.Cleanup(cancel) - d.Cleanup(func() { - // Give it a chance to exit gracefully, cleaning up - // after itself. After 10ms, the deferred cancel above - // will signal an os.Kill. - cmd.Process.Signal(os.Interrupt) - time.Sleep(10 * time.Millisecond) - }) - - // Wait for sway to be ready. We probably don't need a deadline - // here. - br := bufio.NewReader(stderr) - for { - line, err := br.ReadString('\n') - if err != nil { - d.Fatal(err) - } - if m := rxSwayReady.FindStringSubmatch(line); m != nil { - d.display = m[1] - break - } - } - - wg.Add(1) - go func() { - if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") { - // Don't print all stderr, since we use --verbose. - // TODO(mvdan): if it's useful, probably filter - // errors and show them. - d.Error(err) - } - wg.Done() - }() - } - - // Then, start our program on the sway compositor above. - { - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, bin) - cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display} - output, err := cmd.StdoutPipe() - if err != nil { - d.Fatal(err) - } - cmd.Stderr = cmd.Stdout - d.output = output - if err := cmd.Start(); err != nil { - d.Fatal(err) - } - d.Cleanup(cancel) - wg.Add(1) - go func() { - if err := cmd.Wait(); err != nil && ctx.Err() == nil { - d.Error(err) - } - wg.Done() - }() - } - - // Wait for the gio app to render. - d.waitForFrame() -} - -func (d *WaylandTestDriver) Screenshot() image.Image { - cmd := exec.Command("grim", "/dev/stdout") - cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display} - out, err := cmd.CombinedOutput() - if err != nil { - d.Errorf("%s", out) - d.Fatal(err) - } - img, err := png.Decode(bytes.NewReader(out)) - if err != nil { - d.Fatal(err) - } - return img -} - -func (d *WaylandTestDriver) swaymsg(args ...interface{}) { - strs := []string{"--socket", d.socket} - for _, arg := range args { - strs = append(strs, fmt.Sprint(arg)) - } - cmd := exec.Command("swaymsg", strs...) - if out, err := cmd.CombinedOutput(); err != nil { - d.Errorf("%s", out) - d.Fatal(err) - } -} - -func (d *WaylandTestDriver) Click(x, y int) { - d.swaymsg("seat", "-", "cursor", "set", x, y) - d.swaymsg("seat", "-", "cursor", "press", "button1") - d.swaymsg("seat", "-", "cursor", "release", "button1") - - // Wait for the gio app to render after this click. - d.waitForFrame() -} diff --git a/third_party/gioui-cmd/gogio/windows_test.go b/third_party/gioui-cmd/gogio/windows_test.go deleted file mode 100644 index 996b511..0000000 --- a/third_party/gioui-cmd/gogio/windows_test.go +++ /dev/null @@ -1,152 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main_test - -import ( - "context" - "image" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "sync" - "time" - - "golang.org/x/image/draw" -) - -// Wine is tightly coupled with X11 at the moment, and we can reuse the same -// methods to automate screenshots and clicks. The main difference is how we -// build and run the app. - -// The only quirk is that it seems impossible for the Wine window to take the -// entirety of the X server's dimensions, even if we try to resize it to take -// the entire display. It seems to want to leave some vertical space empty, -// presumably for window decorations or the "start" bar on Windows. To work -// around that, make the X server 50x50px bigger, and crop the screenshots back -// to the original size. - -type WineTestDriver struct { - X11TestDriver -} - -func (d *WineTestDriver) Start(path string) { - d.needPrograms("wine") - - // First, build the app. - bin := filepath.Join(d.tempDir("gio-endtoend-windows"), "red.exe") - flags := []string{"build", "-o=" + bin} - if raceEnabled { - if runtime.GOOS != "windows" { - // cross-compilation disables CGo, which breaks -race. - d.Skipf("can't cross-compile -race for Windows; skipping") - } - flags = append(flags, "-race") - } - flags = append(flags, path) - cmd := exec.Command("go", flags...) - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, "GOOS=windows") - if out, err := cmd.CombinedOutput(); err != nil { - d.Fatalf("could not build app: %s:\n%s", err, out) - } - - var wg sync.WaitGroup - d.Cleanup(wg.Wait) - - // Add 50x50px to the display dimensions, as discussed earlier. - d.startServer(&wg, d.width+50, d.height+50) - - // Then, start our program via Wine on the X server above. - { - cacheDir, err := os.UserCacheDir() - if err != nil { - d.Fatal(err) - } - // Use a wine directory separate from the default ~/.wine, so - // that the user's winecfg doesn't affect our test. This will - // default to ~/.cache/gio-e2e-wine. We use the user's cache, - // to reuse a previously set up wineprefix. - wineprefix := filepath.Join(cacheDir, "gio-e2e-wine") - - // First, ensure that wineprefix is up to date with wineboot. - // Wait for this separately from the first frame, as setting up - // a new prefix might take 5s on its own. - env := []string{ - "DISPLAY=" + d.display, - "WINEDEBUG=fixme-all", // hide "fixme" noise - "WINEPREFIX=" + wineprefix, - - // Disable wine-gecko (Explorer) and wine-mono (.NET). - // Otherwise, if not installed, wineboot will get stuck - // with a prompt to install them on the virtual X - // display. Moreover, Gio doesn't need either, and wine - // is faster without them. - "WINEDLLOVERRIDES=mscoree,mshtml=", - } - { - start := time.Now() - cmd := exec.Command("wine", "wineboot", "-i") - cmd.Env = env - // Use a combined output pipe instead of CombinedOutput, - // so that we only wait for the child process to exit, - // and we don't need to wait for all of wine's - // grandchildren to exit and stop writing. This is - // relevant as wine leaves "wineserver" lingering for - // three seconds by default, to be reused later. - stdout, err := cmd.StdoutPipe() - if err != nil { - d.Fatal(err) - } - cmd.Stderr = cmd.Stdout - if err := cmd.Run(); err != nil { - io.Copy(os.Stderr, stdout) - d.Fatal(err) - } - d.Logf("set up WINEPREFIX in %s", time.Since(start)) - } - - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, "wine", bin) - cmd.Env = env - output, err := cmd.StdoutPipe() - if err != nil { - d.Fatal(err) - } - cmd.Stderr = cmd.Stdout - d.output = output - if err := cmd.Start(); err != nil { - d.Fatal(err) - } - d.Cleanup(cancel) - wg.Add(1) - go func() { - if err := cmd.Wait(); err != nil && ctx.Err() == nil { - d.Error(err) - } - wg.Done() - }() - } - // Wait for the gio app to render. - d.waitForFrame() - - // xdotool seems to fail at actually moving the window if we use it - // immediately after Gio is ready. Why? - // We can't tell if the windowmove operation worked until we take a - // screenshot, because the getwindowgeometry op reports the 0x0 - // coordinates even if the window wasn't moved properly. - // A sleep of ~20ms seems to be enough on an idle laptop. Use 20x that. - // TODO(mvdan): revisit this, when you have a spare three hours. - time.Sleep(400 * time.Millisecond) - id := d.xdotool("search", "--sync", "--onlyvisible", "--name", "Gio") - d.xdotool("windowmove", "--sync", id, 0, 0) -} - -func (d *WineTestDriver) Screenshot() image.Image { - img := d.X11TestDriver.Screenshot() - // Crop the screenshot back to the original dimensions. - cropped := image.NewRGBA(image.Rect(0, 0, d.width, d.height)) - draw.Draw(cropped, cropped.Bounds(), img, image.Point{}, draw.Src) - return cropped -} diff --git a/third_party/gioui-cmd/gogio/windowsbuild.go b/third_party/gioui-cmd/gogio/windowsbuild.go deleted file mode 100644 index c867e03..0000000 --- a/third_party/gioui-cmd/gogio/windowsbuild.go +++ /dev/null @@ -1,410 +0,0 @@ -package main - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "image/png" - "io" - "os" - "os/exec" - "path/filepath" - "reflect" - "strings" - "text/template" - - "github.com/akavel/rsrc/binutil" - "github.com/akavel/rsrc/coff" - "golang.org/x/text/encoding/unicode" -) - -func buildWindows(tmpDir string, bi *buildInfo) error { - builder := &windowsBuilder{TempDir: tmpDir} - builder.DestDir = *destPath - if builder.DestDir == "" { - builder.DestDir = bi.pkgPath - } - - name := bi.name - if *destPath != "" { - if filepath.Ext(*destPath) != ".exe" { - return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath) - } - name = filepath.Base(*destPath) - } - name = strings.TrimSuffix(name, ".exe") - sdk := bi.minsdk - if sdk > 10 { - return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk) - } - - for _, arch := range bi.archs { - builder.Coff = coff.NewRSRC() - builder.Coff.Arch(arch) - - if err := builder.embedIcon(bi.iconPath); err != nil { - return err - } - - if err := builder.embedManifest(windowsManifest{ - Version: bi.version.String(), - WindowsVersion: sdk, - Name: name, - }); err != nil { - return fmt.Errorf("can't create manifest: %v", err) - } - - if err := builder.embedInfo(windowsResources{ - Version: [2]uint32{uint32(bi.version.Major), uint32(bi.version.Minor)<<16 | uint32(bi.version.Patch)}, - VersionHuman: bi.version.String(), - Name: name, - Language: 0x0400, // Process Default Language: https://docs.microsoft.com/en-us/previous-versions/ms957130(v=msdn.10) - }); err != nil { - return fmt.Errorf("can't create info: %v", err) - } - - if err := builder.buildResource(bi, name, arch); err != nil { - return fmt.Errorf("can't build the resources: %v", err) - } - - if err := builder.buildProgram(bi, name, arch); err != nil { - return err - } - } - - return nil -} - -type ( - windowsResources struct { - Version [2]uint32 - VersionHuman string - Language uint16 - Name string - } - windowsManifest struct { - Version string - WindowsVersion int - Name string - } - windowsBuilder struct { - TempDir string - DestDir string - Coff *coff.Coff - } -) - -const ( - // https://docs.microsoft.com/en-us/windows/win32/menurc/resource-types - windowsResourceIcon = 3 - windowsResourceIconGroup = windowsResourceIcon + 11 - windowsResourceManifest = 24 - windowsResourceVersion = 16 -) - -type bufferCoff struct { - bytes.Buffer -} - -func (b *bufferCoff) Size() int64 { - return int64(b.Len()) -} - -func (b *windowsBuilder) embedIcon(path string) (err error) { - iconFile, err := os.Open(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("can't read the icon located at %s: %v", path, err) - } - defer iconFile.Close() - - iconImage, err := png.Decode(iconFile) - if err != nil { - return fmt.Errorf("can't decode the PNG file (%s): %v", path, err) - } - - sizes := []int{16, 32, 48, 64, 128, 256} - var iconHeader bufferCoff - - // GRPICONDIR structure. - if err := binary.Write(&iconHeader, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil { - return err - } - - for _, size := range sizes { - var iconBuffer bufferCoff - - if err := png.Encode(&iconBuffer, resizeIcon(iconVariant{size: size, fill: false}, iconImage)); err != nil { - return fmt.Errorf("can't encode image: %v", err) - } - - b.Coff.AddResource(windowsResourceIcon, uint16(size), &iconBuffer) - - if err := binary.Write(&iconHeader, binary.LittleEndian, struct { - Size [2]uint8 - Color [2]uint8 - Planes uint16 - BitCount uint16 - Length uint32 - Id uint16 - }{ - Size: [2]uint8{uint8(size % 256), uint8(size % 256)}, // "0" means 256px. - Planes: 1, - BitCount: 32, - Length: uint32(iconBuffer.Len()), - Id: uint16(size), - }); err != nil { - return err - } - } - - b.Coff.AddResource(windowsResourceIconGroup, 1, &iconHeader) - - return nil -} - -func (b *windowsBuilder) buildResource(buildInfo *buildInfo, name string, arch string) error { - out, err := os.Create(filepath.Join(buildInfo.pkgPath, name+"_windows_"+arch+".syso")) - if err != nil { - return err - } - defer out.Close() - b.Coff.Freeze() - - // See https://github.com/akavel/rsrc/internal/write.go#L13. - w := binutil.Writer{W: out} - binutil.Walk(b.Coff, func(v reflect.Value, path string) error { - if binutil.Plain(v.Kind()) { - w.WriteLE(v.Interface()) - return nil - } - vv, ok := v.Interface().(binutil.SizedReader) - if ok { - w.WriteFromSized(vv) - return binutil.WALK_SKIP - } - return nil - }) - - if w.Err != nil { - return fmt.Errorf("error writing output file: %s", w.Err) - } - - return nil -} - -func (b *windowsBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error { - dest := b.DestDir - if len(buildInfo.archs) > 1 { - dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".exe") - } - - cmd := exec.Command( - "go", - "build", - "-ldflags=-H=windowsgui "+buildInfo.ldflags, - "-tags="+buildInfo.tags, - "-o", dest, - buildInfo.pkgPath, - ) - cmd.Env = append( - os.Environ(), - "GOOS=windows", - "GOARCH="+arch, - ) - _, err := runCmd(cmd) - return err -} - -func (b *windowsBuilder) embedManifest(v windowsManifest) error { - t, err := template.New("manifest").Parse(` - - - {{.Name}} - - - {{if (le .WindowsVersion 10)}} -{{end}} - {{if (le .WindowsVersion 9)}} -{{end}} - {{if (le .WindowsVersion 8)}} -{{end}} - {{if (le .WindowsVersion 7)}} -{{end}} - {{if (le .WindowsVersion 6)}} -{{end}} - - - - - - - - - - - - true - - -`) - if err != nil { - return err - } - - var manifest bufferCoff - if err := t.Execute(&manifest, v); err != nil { - return err - } - - b.Coff.AddResource(windowsResourceManifest, 1, &manifest) - - return nil -} - -func (b *windowsBuilder) embedInfo(v windowsResources) error { - page := uint16(1) - - // https://docs.microsoft.com/pt-br/windows/win32/menurc/vs-versioninfo - t := newValue(valueBinary, "VS_VERSION_INFO", []io.WriterTo{ - // https://docs.microsoft.com/pt-br/windows/win32/api/VerRsrc/ns-verrsrc-vs_fixedfileinfo - windowsInfoValueFixed{ - Signature: 0xFEEF04BD, - StructVersion: 0x00010000, - FileVersion: v.Version, - ProductVersion: v.Version, - FileFlagMask: 0x3F, - FileFlags: 0, - FileOS: 0x40004, - FileType: 0x1, - FileSubType: 0, - }, - // https://docs.microsoft.com/pt-br/windows/win32/menurc/stringfileinfo - newValue(valueText, "StringFileInfo", []io.WriterTo{ - // https://docs.microsoft.com/pt-br/windows/win32/menurc/stringtable - newValue(valueText, fmt.Sprintf("%04X%04X", v.Language, page), []io.WriterTo{ - // https://docs.microsoft.com/pt-br/windows/win32/menurc/string-str - newValue(valueText, "ProductVersion", v.VersionHuman), - newValue(valueText, "FileVersion", v.VersionHuman), - newValue(valueText, "FileDescription", v.Name), - newValue(valueText, "ProductName", v.Name), - // TODO include more data: gogio must have some way to provide such information (like Company Name, Copyright...) - }), - }), - // https://docs.microsoft.com/pt-br/windows/win32/menurc/varfileinfo - newValue(valueBinary, "VarFileInfo", []io.WriterTo{ - // https://docs.microsoft.com/pt-br/windows/win32/menurc/var-str - newValue(valueBinary, "Translation", uint32(page)<<16|uint32(v.Language)), - }), - }) - - // For some reason the ValueLength of the VS_VERSIONINFO must be the byte-length of `windowsInfoValueFixed`: - t.ValueLength = 52 - - var verrsrc bufferCoff - if _, err := t.WriteTo(&verrsrc); err != nil { - return err - } - - b.Coff.AddResource(windowsResourceVersion, 1, &verrsrc) - - return nil -} - -type windowsInfoValueFixed struct { - Signature uint32 - StructVersion uint32 - FileVersion [2]uint32 - ProductVersion [2]uint32 - FileFlagMask uint32 - FileFlags uint32 - FileOS uint32 - FileType uint32 - FileSubType uint32 - FileDate [2]uint32 -} - -func (v windowsInfoValueFixed) WriteTo(w io.Writer) (_ int64, err error) { - return 0, binary.Write(w, binary.LittleEndian, v) -} - -type windowsInfoValue struct { - Length uint16 - ValueLength uint16 - Type uint16 - Key []byte - Value []byte -} - -func (v windowsInfoValue) WriteTo(w io.Writer) (_ int64, err error) { - // binary.Write doesn't support []byte inside struct. - if err = binary.Write(w, binary.LittleEndian, [3]uint16{v.Length, v.ValueLength, v.Type}); err != nil { - return 0, err - } - if _, err = w.Write(v.Key); err != nil { - return 0, err - } - if _, err = w.Write(v.Value); err != nil { - return 0, err - } - return 0, nil -} - -const ( - valueBinary uint16 = 0 - valueText uint16 = 1 -) - -func newValue(valueType uint16, key string, input interface{}) windowsInfoValue { - v := windowsInfoValue{ - Type: valueType, - Length: 6, - } - - padding := func(in []byte) []byte { - if l := uint16(len(in)) + v.Length; l%4 != 0 { - return append(in, make([]byte, 4-l%4)...) - } - return in - } - - v.Key = padding(utf16Encode(key)) - v.Length += uint16(len(v.Key)) - - switch in := input.(type) { - case string: - v.Value = padding(utf16Encode(in)) - v.ValueLength = uint16(len(v.Value) / 2) - case []io.WriterTo: - var buff bytes.Buffer - for k := range in { - if _, err := in[k].WriteTo(&buff); err != nil { - panic(err) - } - } - v.Value = buff.Bytes() - default: - var buff bytes.Buffer - if err := binary.Write(&buff, binary.LittleEndian, in); err != nil { - panic(err) - } - v.ValueLength = uint16(buff.Len()) - v.Value = buff.Bytes() - } - - v.Length += uint16(len(v.Value)) - - return v -} - -// utf16Encode encodes the string to UTF16 with null-termination. -func utf16Encode(s string) []byte { - b, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes([]byte(s)) - if err != nil { - panic(err) - } - return append(b, 0x00, 0x00) // null-termination. -} diff --git a/third_party/gioui-cmd/gogio/x11_test.go b/third_party/gioui-cmd/gogio/x11_test.go deleted file mode 100644 index 9bb3174..0000000 --- a/third_party/gioui-cmd/gogio/x11_test.go +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -package main_test - -import ( - "bytes" - "context" - "fmt" - "image" - "image/png" - "io" - "math/rand" - "os" - "os/exec" - "path/filepath" - "sync" - "time" -) - -type X11TestDriver struct { - driverBase - - display string -} - -func (d *X11TestDriver) Start(path string) { - // First, build the app. - bin := filepath.Join(d.tempDir("gio-endtoend-x11"), "red") - flags := []string{"build", "-tags", "nowayland", "-o=" + bin} - if raceEnabled { - flags = append(flags, "-race") - } - flags = append(flags, path) - cmd := exec.Command("go", flags...) - if out, err := cmd.CombinedOutput(); err != nil { - d.Fatalf("could not build app: %s:\n%s", err, out) - } - - var wg sync.WaitGroup - d.Cleanup(wg.Wait) - - d.startServer(&wg, d.width, d.height) - - // Then, start our program on the X server above. - { - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, bin) - cmd.Env = []string{"DISPLAY=" + d.display} - output, err := cmd.StdoutPipe() - if err != nil { - d.Fatal(err) - } - cmd.Stderr = cmd.Stdout - d.output = output - if err := cmd.Start(); err != nil { - d.Fatal(err) - } - d.Cleanup(cancel) - wg.Add(1) - go func() { - if err := cmd.Wait(); err != nil && ctx.Err() == nil { - d.Error(err) - } - wg.Done() - }() - } - - // Wait for the gio app to render. - d.waitForFrame() -} - -func (d *X11TestDriver) startServer(wg *sync.WaitGroup, width, height int) { - // Pick a random display number between 1 and 100,000. Most machines - // will only be using :0, so there's only a 0.001% chance of two - // concurrent test runs to run into a conflict. - rnd := rand.New(rand.NewSource(time.Now().UnixNano())) - d.display = fmt.Sprintf(":%d", rnd.Intn(100000)+1) - - var xprog string - xflags := []string{ - "-wr", // we want a white background; the default is black - } - if *headless { - xprog = "Xvfb" // virtual X server - xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height)) - } else { - xprog = "Xephyr" // nested X server as a window - xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height)) - } - xflags = append(xflags, d.display) - - d.needPrograms( - xprog, // to run the X server - "scrot", // to take screenshots - "xdotool", // to send input - ) - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, xprog, xflags...) - combined := &bytes.Buffer{} - cmd.Stdout = combined - cmd.Stderr = combined - if err := cmd.Start(); err != nil { - d.Fatal(err) - } - d.Cleanup(cancel) - d.Cleanup(func() { - // Give it a chance to exit gracefully, cleaning up - // after itself. After 10ms, the deferred cancel above - // will signal an os.Kill. - cmd.Process.Signal(os.Interrupt) - time.Sleep(10 * time.Millisecond) - }) - - // Wait for the X server to be ready. The socket path isn't - // terribly portable, but that's okay for now. - withRetries(d.T, time.Second, func() error { - socket := fmt.Sprintf("/tmp/.X11-unix/X%s", d.display[1:]) - _, err := os.Stat(socket) - return err - }) - - wg.Add(1) - go func() { - if err := cmd.Wait(); err != nil && ctx.Err() == nil { - // Print all output and error. - io.Copy(os.Stdout, combined) - d.Error(err) - } - wg.Done() - }() -} - -func (d *X11TestDriver) Screenshot() image.Image { - cmd := exec.Command("scrot", "--silent", "--overwrite", "/dev/stdout") - cmd.Env = []string{"DISPLAY=" + d.display} - out, err := cmd.CombinedOutput() - if err != nil { - d.Errorf("%s", out) - d.Fatal(err) - } - img, err := png.Decode(bytes.NewReader(out)) - if err != nil { - d.Fatal(err) - } - return img -} - -func (d *X11TestDriver) xdotool(args ...interface{}) string { - d.Helper() - strs := make([]string, len(args)) - for i, arg := range args { - strs[i] = fmt.Sprint(arg) - } - cmd := exec.Command("xdotool", strs...) - cmd.Env = []string{"DISPLAY=" + d.display} - out, err := cmd.CombinedOutput() - if err != nil { - d.Errorf("%s", out) - d.Fatal(err) - } - return string(bytes.TrimSpace(out)) -} - -func (d *X11TestDriver) Click(x, y int) { - d.xdotool("mousemove", "--sync", x, y) - d.xdotool("click", "1") - - // Wait for the gio app to render after this click. - d.waitForFrame() -} diff --git a/third_party/gioui-cmd/svg2gio/main.go b/third_party/gioui-cmd/svg2gio/main.go deleted file mode 100644 index 81a0227..0000000 --- a/third_party/gioui-cmd/svg2gio/main.go +++ /dev/null @@ -1,582 +0,0 @@ -// SPDX-License-Identifier: Unlicense OR MIT - -// Command svg2gio converts SVG files to Gio functions. Only a limited subset of -// SVG files are supported. -package main - -import ( - "bytes" - "encoding/xml" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "strings" - "unicode" - - "go/format" - - "gioui.org/f32" -) - -var ( - pkg = flag.String("pkg", "", "Go package") - output = flag.String("o", "svg.go", "Output Go file") -) - -func main() { - flag.Parse() - if *pkg == "" { - fmt.Fprintf(os.Stderr, "specify a package name (-pkg)\n") - os.Exit(1) - } - args := flag.Args() - if err := convertAll(args); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(2) - } -} - -type Points []float32 - -func (p *Points) UnmarshalText(text []byte) error { - for { - text = bytes.TrimLeft(text, "\t\n") - if len(text) == 0 { - break - } - var num []byte - end := bytes.IndexAny(text, " ,") - if end != -1 { - num = text[:end] - text = text[end+1:] - } else { - num = text - text = nil - } - f, err := strconv.ParseFloat(string(num), 32) - if err != nil { - return err - } - *p = append(*p, float32(f)) - } - return nil -} - -type Transform f32.Affine2D - -func (t *Transform) UnmarshalText(text []byte) error { - switch { - case bytes.HasPrefix(text, []byte("matrix(")) && bytes.HasSuffix(text, []byte(")")): - trans := text[7 : len(text)-1] - var p Points - if err := p.UnmarshalText(trans); err != nil { - return err - } - if len(p) != 6 { - return fmt.Errorf("malformed transform matrix: %q", text) - } - *t = Transform(f32.NewAffine2D(p[0], p[2], p[4], p[1], p[3], p[5])) - return nil - default: - return fmt.Errorf("unsupported transform: %q", text) - } -} - -type Fill struct { - Transform Transform `xml:"transform,attr"` - Fill Color `xml:"fill,attr"` - Stroke Color `xml:"stroke,attr"` - StrokeLinejoin string `xml:"stroke-linejoin,attr"` - StrokeLinecap string `xml:"stroke-linecap,attr"` - StrokeWidth float32 `xml:"stroke-width,attr"` -} - -type Color struct { - Set bool - Value int -} - -func (c *Color) UnmarshalText(text []byte) error { - if string(text) == "none" { - *c = Color{} - return nil - } - if !bytes.HasPrefix(text, []byte("#")) { - return fmt.Errorf("invalid color: %q", text) - } - text = text[1:] - i, err := strconv.ParseInt(string(text), 16, 32) - // Implied alpha. - if len(text) == 6 { - i |= 0xff000000 - } - *c = Color{ - Set: true, - Value: int(i), - } - return err -} - -func convertAll(files []string) error { - w := new(bytes.Buffer) - fmt.Fprintf(w, "// Code generated by gioui.org/cmd/svg2gio; DO NOT EDIT.\n\n") - fmt.Fprintf(w, "package %s\n\n", *pkg) - fmt.Fprintf(w, "import \"image/color\"\n") - fmt.Fprintf(w, "import \"math\"\n") - fmt.Fprintf(w, "import \"gioui.org/op\"\n") - fmt.Fprintf(w, "import \"gioui.org/op/clip\"\n") - fmt.Fprintf(w, "import \"gioui.org/op/paint\"\n") - fmt.Fprintf(w, "import \"gioui.org/f32\"\n\n") - fmt.Fprintf(w, "var ops op.Ops\n\n") - fmt.Fprintf(w, funcs) - for _, filename := range files { - if err := convert(w, filename); err != nil { - return err - } - } - src, err := format.Source(w.Bytes()) - if err != nil { - return err - } - return os.WriteFile(*output, src, 0o660) -} - -func convert(w io.Writer, filename string) error { - base := filepath.Base(filename) - ext := filepath.Ext(base) - name := "Image_" + base[:len(base)-len(ext)] - - fmt.Fprintf(w, "var %s struct {\n", name) - fmt.Fprintf(w, "ViewBox struct { Min, Max f32.Point }\n") - fmt.Fprintf(w, "Call op.CallOp\n\n") - fmt.Fprintf(w, "}\n") - fmt.Fprintf(w, "func init() {\n") - defer fmt.Fprintf(w, "}\n") - f, err := os.Open(filename) - if err != nil { - return err - } - defer f.Close() - d := xml.NewDecoder(f) - if err := parse(w, d, name); err != nil { - line, col := d.InputPos() - return fmt.Errorf("%s:%d:%d: %w", filename, line, col, err) - } - return nil -} - -func parse(w io.Writer, d *xml.Decoder, name string) error { - for { - tok, err := d.Token() - if err != nil { - if err == io.EOF { - return errors.New("unexpected end of file") - } - return err - } - switch tok := tok.(type) { - case xml.StartElement: - if n := tok.Name.Local; n != "svg" { - return fmt.Errorf("invalid SVG root: <%s>", n) - } - if n := tok.Name.Space; n != "http://www.w3.org/2000/svg" { - return fmt.Errorf("unsupported SVG namespace: %s", n) - } - fmt.Fprintf(w, "m := op.Record(&ops)\n") - defer fmt.Fprintf(w, "%s.Call = m.Stop()\n", name) - for _, a := range tok.Attr { - if a.Name.Local == "viewBox" { - var p Points - if err := p.UnmarshalText([]byte(a.Value)); err != nil { - return fmt.Errorf("invalid viewBox attribute: %s", a.Value) - } - if len(p) != 4 { - return fmt.Errorf("invalid viewBox attribute: %s", a.Value) - } - fmt.Fprintf(w, "%s.ViewBox.Min = %s\n", name, point(f32.Pt(p[0], p[1]))) - fmt.Fprintf(w, "%s.ViewBox.Max = %s\n", name, point(f32.Pt(p[2], p[3]))) - } - } - return parseSVG(w, d) - } - } -} - -func point(p f32.Point) string { - return fmt.Sprintf("f32.Pt(%g, %g)", p.X, p.Y) -} - -type Poly struct { - XMLName xml.Name - Points Points `xml:"points,attr"` - Fill -} - -func (p *Poly) Path(w io.Writer) error { - if len(p.Points) <= 1 { - return nil - } - pen := f32.Pt(p.Points[0], p.Points[1]) - fmt.Fprintf(w, "p.MoveTo(%s)\n", point(pen)) - last := pen - for i := 2; i < len(p.Points); i += 2 { - last = f32.Pt(p.Points[i], p.Points[i+1]) - fmt.Fprintf(w, "p.LineTo(%s)\n", point(last)) - } - if p.XMLName.Local == "polygon" && last != pen { - fmt.Fprintf(w, "p.LineTo(%s)\n", point(pen)) - } - return nil -} - -type Path struct { - D string `xml:"d,attr"` - Fill -} - -func (p *Path) Path(w io.Writer) error { - return printPathCommands(w, p.D) -} - -type Line struct { - X1 float32 `xml:"x1,attr"` - Y1 float32 `xml:"y1,attr"` - X2 float32 `xml:"x2,attr"` - Y2 float32 `xml:"y2,attr"` - Fill -} - -func (l *Line) Path(w io.Writer) error { - fmt.Fprintf(w, "p.MoveTo(%s)\n", point(f32.Pt(l.X1, l.Y1))) - fmt.Fprintf(w, "p.LineTo(%s)\n", point(f32.Pt(l.X2, l.Y2))) - return nil -} - -type Ellipse struct { - Cx float32 `xml:"cx,attr"` - Cy float32 `xml:"cy,attr"` - Rx float32 `xml:"rx,attr"` - Ry float32 `xml:"ry,attr"` - Fill -} - -func (e *Ellipse) Path(w io.Writer) error { - c := f32.Pt(e.Cx, e.Cy) - r := f32.Pt(e.Rx, e.Ry) - fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(c), point(r)) - return nil -} - -type Rect struct { - X float32 `xml:"x,attr"` - Y float32 `xml:"y,attr"` - Width float32 `xml:"width,attr"` - Height float32 `xml:"height,attr"` - Fill -} - -func (r *Rect) Path(w io.Writer) error { - o := f32.Pt(r.X, r.Y) - sz := f32.Pt(r.Width, r.Height) - fmt.Fprintf(w, "rect(&p, %s, %s)\n", point(o), point(sz)) - return nil -} - -type Circle struct { - Cx float32 `xml:"cx,attr"` - Cy float32 `xml:"cy,attr"` - R float32 `xml:"r,attr"` - Fill -} - -func (c *Circle) Path(w io.Writer) error { - center := f32.Pt(c.Cx, c.Cy) - r := f32.Pt(c.R, c.R) - fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(center), point(r)) - return nil -} - -func parseSVG(w io.Writer, d *xml.Decoder) error { - for { - tok, err := d.Token() - if err != nil { - if err == io.EOF { - return errors.New("unexpected end of element") - } - return err - } - var start xml.StartElement - switch tok := tok.(type) { - case xml.EndElement: - return nil - case xml.StartElement: - start = tok - default: - continue - } - var elem interface { - Path(w io.Writer) error - } - var fill *Fill - switch n := start.Name.Local; n { - case "g": - // Flatten groups. - if err := parseSVG(w, d); err != nil { - return err - } - continue - case "title": - d.Skip() - continue - case "polygon", "polyline": - p := new(Poly) - elem = p - fill = &p.Fill - case "path": - p := new(Path) - elem = p - fill = &p.Fill - case "line": - l := new(Line) - elem = l - fill = &l.Fill - case "ellipse": - e := new(Ellipse) - elem = e - fill = &e.Fill - case "rect": - r := new(Rect) - elem = r - fill = &r.Fill - case "circle": - c := new(Circle) - elem = c - fill = &c.Fill - default: - return fmt.Errorf("unsupported tag: <%s>", n) - } - if err := d.DecodeElement(elem, &start); err != nil { - return err - } - if !fill.Fill.Set && !fill.Stroke.Set { - continue - } - fmt.Fprintf(w, "{\n") - trans := f32.Affine2D(fill.Transform) - if trans != (f32.Affine2D{}) { - sx, hx, ox, sy, hy, oy := trans.Elems() - fmt.Fprintf(w, "t := op.Affine(f32.NewAffine2D(%g, %g, %g, %g, %g, %g)).Push(&ops)\n", sx, hx, ox, sy, hy, oy) - } - fmt.Fprintf(w, "var p clip.Path\n") - fmt.Fprintf(w, "p.Begin(&ops)\n") - if err := elem.Path(w); err != nil { - return err - } - fmt.Fprintf(w, "spec := p.End()\n") - if fill.Fill.Set { - fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Outline{Path: spec}.Op())\n", fill.Fill.Value) - } - if fill.Stroke.Set { - fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Stroke{Width: %g, Path: spec}.Op())\n", fill.Stroke.Value, fill.StrokeWidth) - } - if trans != (f32.Affine2D{}) { - fmt.Fprintf(w, "t.Pop()\n") - } - fmt.Fprintf(w, "}\n") - } -} - -func printPathCommands(w io.Writer, cmds string) error { - moveTo := func(p f32.Point) { - fmt.Fprintf(w, "p.MoveTo(%s)\n", point(p)) - } - lineTo := func(p f32.Point) { - fmt.Fprintf(w, "p.LineTo(%s)\n", point(p)) - } - cubeTo := func(p0, p1, p2 f32.Point) { - fmt.Fprintf(w, "p.CubeTo(%s, %s, %s)\n", point(p0), point(p1), point(p2)) - } - cmds = strings.TrimSpace(cmds) - var pen f32.Point - initPoint := pen - ctrl2 := pen - for { - cmds = strings.TrimLeft(cmds, " ,\t\n") - if len(cmds) == 0 { - break - } - orig := cmds - op := rune(cmds[0]) - cmds = cmds[1:] - switch op { - case 'M', 'm', 'V', 'v', 'L', 'l', 'H', 'h', 'C', 'c', 'S', 's': - case 'Z', 'z': - if pen != initPoint { - lineTo(initPoint) - pen = initPoint - } - ctrl2 = initPoint - continue - default: - return fmt.Errorf("unknown command %s in %q", string(op), orig) - } - var coords []float64 - for { - cmds = strings.TrimLeft(cmds, " ,\t\n") - if len(cmds) == 0 { - break - } - n, x, ok := parseFloat(cmds) - if !ok { - break - } - cmds = cmds[n:] - coords = append(coords, x) - } - rel := unicode.IsLower(op) - newPen := pen - switch unicode.ToLower(op) { - case 'h': - for _, x := range coords { - p := f32.Pt(float32(x), pen.Y) - if rel { - p.X += pen.X - } - lineTo(p) - newPen = p - } - pen = newPen - ctrl2 = newPen - continue - case 'v': - for _, y := range coords { - p := f32.Pt(pen.X, float32(y)) - if rel { - p.Y += pen.Y - } - lineTo(p) - newPen = p - } - pen = newPen - ctrl2 = newPen - continue - } - if len(coords)%2 != 0 { - return fmt.Errorf("odd number of coordinates in data: %q", orig) - } - var off f32.Point - if rel { - // Relative command. - off = pen - } else { - off = f32.Pt(0, 0) - } - var points []f32.Point - for i := 0; i < len(coords); i += 2 { - p := f32.Pt(float32(coords[i]), float32(coords[i+1])) - p = p.Add(off) - points = append(points, p) - } - newCtrl2 := ctrl2 - switch op := unicode.ToLower(op); op { - case 'm', 'l': - sop := moveTo - if op == 'l' { - sop = lineTo - } - for _, p := range points { - sop(p) - newPen = p - } - if op == 'm' { - initPoint = newPen - } - case 'c': - for i := 0; i < len(points); i += 3 { - p1, p2, p3 := points[i], points[i+1], points[i+2] - cubeTo(p1, p2, p3) - newPen = p3 - newCtrl2 = p2 - } - case 's': - for i := 0; i < len(points); i += 2 { - p2, p3 := points[i], points[i+1] - // Compute p1 by reflecting p2 on to the line that contains pen and p2. - p1 := pen.Mul(2).Sub(ctrl2) - cubeTo(p1, p2, p3) - newPen = p3 - newCtrl2 = p2 - } - } - pen = newPen - ctrl2 = newCtrl2 - } - return nil -} - -func parseFloat(s string) (int, float64, bool) { - n := 0 - if len(s) > 0 && s[0] == '-' { - n++ - } - for ; n < len(s); n++ { - if !(unicode.IsDigit(rune(s[n])) || s[n] == '.') { - break - } - } - f, err := strconv.ParseFloat(s[:n], 64) - return n, f, err == nil -} - -const funcs = ` -func argb(c uint32) color.NRGBA { - return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} -} - -func rect(p *clip.Path, origin, size f32.Point) { - p.MoveTo(origin) - p.LineTo(origin.Add(f32.Pt(size.X, 0))) - p.LineTo(origin.Add(size)) - p.LineTo(origin.Add(f32.Pt(0, size.Y))) - p.Close() -} - -func ellipse(p *clip.Path, center, radius f32.Point) { - r := radius.X - // We'll model the ellipse as a circle scaled in the Y - // direction. - scale := radius.Y / r - - // https://pomax.github.io/bezierinfo/#circles_cubic. - const q = 4 * (math.Sqrt2 - 1) / 3 - - curve := r * q - top := f32.Point{X: center.X, Y: center.Y - r*scale} - - p.MoveTo(top) - p.CubeTo( - f32.Point{X: center.X + curve, Y: center.Y - r*scale}, - f32.Point{X: center.X + r, Y: center.Y - curve*scale}, - f32.Point{X: center.X + r, Y: center.Y}, - ) - p.CubeTo( - f32.Point{X: center.X + r, Y: center.Y + curve*scale}, - f32.Point{X: center.X + curve, Y: center.Y + r*scale}, - f32.Point{X: center.X, Y: center.Y + r*scale}, - ) - p.CubeTo( - f32.Point{X: center.X - curve, Y: center.Y + r*scale}, - f32.Point{X: center.X - r, Y: center.Y + curve*scale}, - f32.Point{X: center.X - r, Y: center.Y}, - ) - p.CubeTo( - f32.Point{X: center.X - r, Y: center.Y - curve*scale}, - f32.Point{X: center.X - curve, Y: center.Y - r*scale}, - top, - ) -} -` From c3a9c0fddbc4803f073e27b2a44842efb5b8e871 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 06:50:16 -0700 Subject: [PATCH 36/53] Extract app UI platform glue --- internal/appui/android_share_stub.go | 7 ------- internal/appui/app.go | 13 +++++-------- .../appui/{ => platform}/android_share_android.go | 4 ++-- internal/appui/platform/android_share_stub.go | 7 +++++++ internal/appui/{ => platform}/clipboard_gio.go | 10 +++++++--- internal/appui/{ => platform}/clipboard_gio_test.go | 10 +++++----- 6 files changed, 26 insertions(+), 25 deletions(-) delete mode 100644 internal/appui/android_share_stub.go rename internal/appui/{ => platform}/android_share_android.go (98%) create mode 100644 internal/appui/platform/android_share_stub.go rename internal/appui/{ => platform}/clipboard_gio.go (83%) rename internal/appui/{ => platform}/clipboard_gio_test.go (73%) diff --git a/internal/appui/android_share_stub.go b/internal/appui/android_share_stub.go deleted file mode 100644 index 9a557c5..0000000 --- a/internal/appui/android_share_stub.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !android - -package appui - -func newPlatformVaultSharer(goos string) vaultSharer { - return nil -} diff --git a/internal/appui/app.go b/internal/appui/app.go index 5bdbec3..6be8cf9 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -36,6 +36,7 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" + "git.julianfamily.org/keepassgo/internal/appui/platform" keepassassets "git.julianfamily.org/keepassgo/internal/assets" "git.julianfamily.org/keepassgo/internal/autofillcache" "git.julianfamily.org/keepassgo/internal/clipboard" @@ -466,7 +467,7 @@ type ui struct { settingsIcon *widget.Icon menuIcon *widget.Icon clipboardWriter clipboard.Writer - vaultSharer vaultSharer + vaultSharer platform.VaultSharer loadingMessage string loadingActionLabel string lifecycleMode string @@ -546,10 +547,6 @@ 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} @@ -691,7 +688,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) syncDefaultDirection: syncDirectionPull, apiPolicyGroupScope: true, autofillNoticePreference: autofillNoticeAll, - vaultSharer: newPlatformVaultSharer(runtime.GOOS), + vaultSharer: platform.NewVaultSharer(runtime.GOOS), backgroundResults: make(chan backgroundActionResult, 8), phoneGroupBrowserExpanded: true, } @@ -6980,7 +6977,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { ui := newUIWithSession(mode, manager, paths) ui.fileExplorer = explorer.NewExplorer(w) ui.invalidate = w.Invalidate - ui.clipboardWriter = newPlatformClipboardWriter(runtime.GOOS, w.Invalidate) + ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate) host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) if err != nil { ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) @@ -7001,7 +6998,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { gtx := app.NewContext(&ops, e) ui.processBackgroundActions() ui.layout(gtx) - processClipboardWrites(gtx, ui.clipboardWriter) + platform.ProcessClipboardWrites(gtx, ui.clipboardWriter) e.Frame(gtx.Ops) } } diff --git a/internal/appui/android_share_android.go b/internal/appui/platform/android_share_android.go similarity index 98% rename from internal/appui/android_share_android.go rename to internal/appui/platform/android_share_android.go index a7d0769..81adc82 100644 --- a/internal/appui/android_share_android.go +++ b/internal/appui/platform/android_share_android.go @@ -1,6 +1,6 @@ //go:build android -package appui +package platform /* #cgo CFLAGS: -Werror @@ -71,7 +71,7 @@ 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 { +func NewVaultSharer(goos string) VaultSharer { return androidVaultSharer{} } diff --git a/internal/appui/platform/android_share_stub.go b/internal/appui/platform/android_share_stub.go new file mode 100644 index 0000000..9343c74 --- /dev/null +++ b/internal/appui/platform/android_share_stub.go @@ -0,0 +1,7 @@ +//go:build !android + +package platform + +func NewVaultSharer(goos string) VaultSharer { + return nil +} diff --git a/internal/appui/clipboard_gio.go b/internal/appui/platform/clipboard_gio.go similarity index 83% rename from internal/appui/clipboard_gio.go rename to internal/appui/platform/clipboard_gio.go index d4033ac..6eb8fd4 100644 --- a/internal/appui/clipboard_gio.go +++ b/internal/appui/platform/clipboard_gio.go @@ -1,4 +1,4 @@ -package appui +package platform import ( "io" @@ -11,20 +11,24 @@ import ( appclipboard "git.julianfamily.org/keepassgo/internal/clipboard" ) +type VaultSharer interface { + ShareVault(path, title string) error +} + type clipboardCommandWriter struct { mu sync.Mutex pending []string invalidate func() } -func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer { +func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer { if strings.EqualFold(goos, "android") { return &clipboardCommandWriter{invalidate: invalidate} } return nil } -func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) { +func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) { commandWriter, ok := writer.(*clipboardCommandWriter) if !ok { return diff --git a/internal/appui/clipboard_gio_test.go b/internal/appui/platform/clipboard_gio_test.go similarity index 73% rename from internal/appui/clipboard_gio_test.go rename to internal/appui/platform/clipboard_gio_test.go index 18a6b35..2f6e865 100644 --- a/internal/appui/clipboard_gio_test.go +++ b/internal/appui/platform/clipboard_gio_test.go @@ -1,4 +1,4 @@ -package appui +package platform import ( "slices" @@ -8,17 +8,17 @@ import ( func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) { t.Parallel() - writer := newPlatformClipboardWriter("android", nil) + writer := NewClipboardWriter("android", nil) if _, ok := writer.(*clipboardCommandWriter); !ok { - t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer) + t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer) } } func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) { t.Parallel() - if writer := newPlatformClipboardWriter("linux", nil); writer != nil { - t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer) + if writer := NewClipboardWriter("linux", nil); writer != nil { + t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer) } } From 6ccff23804aa3caa8b62fe612f50738ada1c41f4 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 06:53:21 -0700 Subject: [PATCH 37/53] Extract app UI layout primitives --- .../dropdown.go} | 45 ++++++++++++------- internal/appui/main_test.go | 25 ++++++----- internal/appui/ui_layout_header.go | 34 +++++--------- 3 files changed, 53 insertions(+), 51 deletions(-) rename internal/appui/{ui_header_dropdown.go => layout/dropdown.go} (55%) diff --git a/internal/appui/ui_header_dropdown.go b/internal/appui/layout/dropdown.go similarity index 55% rename from internal/appui/ui_header_dropdown.go rename to internal/appui/layout/dropdown.go index 5a4f952..91d5430 100644 --- a/internal/appui/ui_header_dropdown.go +++ b/internal/appui/layout/dropdown.go @@ -1,4 +1,4 @@ -package appui +package layout import ( "image" @@ -8,47 +8,62 @@ import ( "gioui.org/unit" ) -type dropdownAnchor struct { +func AnchoredMenuX(triggerWidth, menuWidth int) int { + return triggerWidth - menuWidth +} + +func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int { + x := rowOriginX + triggerRightX - menuWidth + if x < 0 { + return 0 + } + if x+menuWidth > containerWidth { + return max(0, containerWidth-menuWidth) + } + return x +} + +type DropdownAnchor struct { TriggerRightX int TriggerBottomY int } -func (a dropdownAnchor) point() image.Point { +func (a DropdownAnchor) Point() image.Point { return image.Pt(a.TriggerRightX, a.TriggerBottomY) } -type dropdownSurface struct { +type DropdownSurface struct { ContainerWidth int LeftInset int TopInset int } -func (s dropdownSurface) menuConstraints(gtx layout.Context) layout.Context { +func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context { menuGTX := gtx menuGTX.Constraints.Min = image.Point{} menuGTX.Constraints.Max.X = max(0, s.ContainerWidth) return menuGTX } -func (s dropdownSurface) origin(anchor dropdownAnchor, menuWidth int) image.Point { - x := s.LeftInset + anchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth) +func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point { + x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth) y := s.TopInset + anchor.TriggerBottomY return image.Pt(x, y) } -func (s dropdownSurface) draw(gtx layout.Context, anchor dropdownAnchor, menu layout.Widget) layout.Dimensions { - menuGTX := s.menuConstraints(gtx) +func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions { + menuGTX := s.MenuConstraints(gtx) menuOps := op.Record(gtx.Ops) menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) menuCall := menuOps.Stop() - menuOrigin := s.origin(anchor, menuDims.Size.X) + menuOrigin := s.Origin(anchor, menuDims.Size.X) stack := op.Offset(menuOrigin).Push(gtx.Ops) menuCall.Add(gtx.Ops) stack.Pop() return layout.Dimensions{Size: gtx.Constraints.Max} } -type headerActionMetrics struct { +type HeaderActionMetrics struct { RowOriginX int Spacing int RowDims layout.Dimensions @@ -57,16 +72,16 @@ type headerActionMetrics struct { MainDims layout.Dimensions } -func (m headerActionMetrics) syncAnchor() dropdownAnchor { - return dropdownAnchor{ +func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor { + return DropdownAnchor{ TriggerRightX: m.RowOriginX + m.SyncDims.Size.X, TriggerBottomY: m.RowDims.Size.Y, } } -func (m headerActionMetrics) mainAnchor() dropdownAnchor { +func (m HeaderActionMetrics) MainAnchor() DropdownAnchor { triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X - return dropdownAnchor{ + return DropdownAnchor{ TriggerRightX: m.RowOriginX + triggerRightX, TriggerBottomY: m.RowDims.Size.Y, } diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 73c7687..34a1b82 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -25,6 +25,7 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" + appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" @@ -372,10 +373,10 @@ func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) { func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { t.Parallel() - if got := anchoredMenuX(48, 160); got != -112 { + if got := appuilayout.AnchoredMenuX(48, 160); got != -112 { t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got) } - if got := anchoredMenuX(160, 48); got != 112 { + if got := appuilayout.AnchoredMenuX(160, 48); got != 112 { t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got) } } @@ -383,10 +384,10 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { t.Parallel() - if got := anchoredMenuOriginX(360, 312, 360, 140); got != 220 { + if got := appuilayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 { t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got) } - if got := anchoredMenuOriginX(360, 0, 44, 160); got != 0 { + if got := appuilayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 { t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got) } } @@ -394,7 +395,7 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { t.Parallel() - metrics := headerActionMetrics{ + metrics := appuilayout.HeaderActionMetrics{ RowOriginX: 24, Spacing: 8, RowDims: layout.Dimensions{Size: image.Pt(180, 40)}, @@ -403,10 +404,10 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { MainDims: layout.Dimensions{Size: image.Pt(36, 40)}, } - if got := metrics.syncAnchor(); got != (dropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { + if got := metrics.SyncAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got) } - if got := metrics.mainAnchor(); got != (dropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { + if got := metrics.MainAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got) } } @@ -414,15 +415,15 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) { t.Parallel() - surface := dropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} - anchor := dropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} + surface := appuilayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} + anchor := appuilayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} - if got := surface.origin(anchor, 140); got != image.Pt(176, 58) { + if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) { t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got) } - leftAnchor := dropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} - if got := surface.origin(leftAnchor, 120); got != image.Pt(16, 58) { + leftAnchor := appuilayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} + if got := surface.Origin(leftAnchor, 120); got != image.Pt(16, 58) { t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got) } } diff --git a/internal/appui/ui_layout_header.go b/internal/appui/ui_layout_header.go index 83b73ac..8d078ba 100644 --- a/internal/appui/ui_layout_header.go +++ b/internal/appui/ui_layout_header.go @@ -10,6 +10,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout" ) func (u *ui) header(gtx layout.Context) layout.Dimensions { @@ -44,7 +45,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } spacing := gtx.Dp(unit.Dp(8)) - metrics := headerActionMetrics{Spacing: spacing} + metrics := appuilayout.HeaderActionMetrics{Spacing: spacing} row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -73,7 +74,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) } - surface := dropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} + surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) rowCall.Add(gtx.Ops) @@ -82,21 +83,21 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { if u.usesCompactViewport() { if u.syncMenuOpen { u.phoneSyncMenuVisible = true - u.phoneSyncMenuAnchor = metrics.syncAnchor().point() + u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point() } if u.mainMenuOpen { u.phoneMainMenuVisible = true - u.phoneMainMenuAnchor = metrics.mainAnchor().point() + u.phoneMainMenuAnchor = metrics.MainAnchor().Point() } width := gtx.Constraints.Max.X return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} } if u.syncMenuOpen { - surface.draw(gtx, metrics.syncAnchor(), u.syncMenu) + surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu) } if u.mainMenuOpen { - surface.draw(gtx, metrics.mainAnchor(), u.mainMenu) + surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) } width := metrics.RowDims.Size.X @@ -468,17 +469,17 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } gtx.Constraints.Min = gtx.Constraints.Max contentInsetPx := gtx.Dp(unit.Dp(16)) - surface := dropdownSurface{ + surface := appuilayout.DropdownSurface{ ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), LeftInset: contentInsetPx, TopInset: contentInsetPx, } if u.syncMenuVisibleOnPhone() { - surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) } if u.mainMenuVisibleOnPhone() { - surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) } return layout.Dimensions{Size: gtx.Constraints.Max} } @@ -511,21 +512,6 @@ func (u *ui) mainMenuRightAlignsToTrigger() bool { return true } -func anchoredMenuX(triggerWidth, menuWidth int) int { - return triggerWidth - menuWidth -} - -func anchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int { - x := rowOriginX + triggerRightX - menuWidth - if x < 0 { - return 0 - } - if x+menuWidth > containerWidth { - return max(0, containerWidth-menuWidth) - } - return x -} - func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { width := 0 for _, row := range rows { From cdf0c0c2c7e5c3ce10d19d4255e87194a850a456 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 06:58:05 -0700 Subject: [PATCH 38/53] Extract app UI action models --- internal/appui/actions/sync_menu.go | 95 +++++++++++++++++ internal/appui/app.go | 28 ++--- internal/appui/main_test.go | 62 +++++------ internal/appui/ui_layout_header.go | 56 +++++----- internal/appui/ui_sync_menu_actions.go | 51 +++++++++ internal/appui/ui_sync_menu_model.go | 142 ------------------------- 6 files changed, 219 insertions(+), 215 deletions(-) create mode 100644 internal/appui/actions/sync_menu.go create mode 100644 internal/appui/ui_sync_menu_actions.go delete mode 100644 internal/appui/ui_sync_menu_model.go diff --git a/internal/appui/actions/sync_menu.go b/internal/appui/actions/sync_menu.go new file mode 100644 index 0000000..15a56e0 --- /dev/null +++ b/internal/appui/actions/sync_menu.go @@ -0,0 +1,95 @@ +package actions + +import "git.julianfamily.org/keepassgo/internal/appstate" + +type SyncMenuModel struct { + HasOpenVault bool + HasSelectedBinding bool + ShowSelectors bool + ShowShare bool + ShowSaveCurrentBinding bool + SavedBindingSummary SyncMenuBindingSummary + RemoteBaseURL string + RemotePath string + RemoteUsername string + RemotePassword string + SelectedVaultSyncMode appstate.SyncMode +} + +type SyncMenuBindingSummary struct { + ProfileLabel string + CredentialLabel string + SyncLabel string + OK bool +} + +func (m SyncMenuModel) SavedBindingHeading() string { + if !m.ShowSelectors { + return "Use this vault's saved remote sync target" + } + return "Use a saved remote profile from this vault" +} + +func (m SyncMenuModel) OpenSelectedButtonLabel() string { + if !m.ShowSelectors { + return "Use Remote Sync" + } + return "Open Saved Remote" +} + +func (m SyncMenuModel) ShowDirectRemoteSyncShortcut() bool { + return m.HasOpenVault && m.HasSelectedBinding +} + +func (m SyncMenuModel) DirectRemoteSyncShortcutLabel() string { + return "Use Remote Sync" +} + +func (m SyncMenuModel) ShowRemoteSyncSettingsShortcut() bool { + return m.HasOpenVault && m.HasSelectedBinding +} + +func (m SyncMenuModel) RemoteSyncSettingsShortcutLabel() string { + return "Remote Sync Settings" +} + +func (m SyncMenuModel) ShowRemoveRemoteSyncShortcut() bool { + return m.ShowRemoteSyncSettingsShortcut() +} + +func (m SyncMenuModel) RemoveRemoteSyncShortcutLabel() string { + return "Stop Using Remote Sync" +} + +func (m SyncMenuModel) ShowRemoteSyncSetupShortcut() bool { + return m.HasOpenVault && !m.HasSelectedBinding +} + +func (m SyncMenuModel) RemoteSyncSetupShortcutLabel() string { + return "Set Up Remote Sync" +} + +func (m SyncMenuModel) ActionLabels() []string { + labels := []string{"Open Advanced Sync"} + if m.ShowRemoteSyncSetupShortcut() { + labels = append(labels, m.RemoteSyncSetupShortcutLabel()) + } + if m.ShowDirectRemoteSyncShortcut() { + labels = append(labels, m.DirectRemoteSyncShortcutLabel()) + } + if m.ShowRemoteSyncSettingsShortcut() { + labels = append(labels, m.RemoteSyncSettingsShortcutLabel()) + } + if m.ShowRemoveRemoteSyncShortcut() { + labels = append(labels, m.RemoveRemoteSyncShortcutLabel()) + } + return labels +} + +func (m SyncMenuModel) SaveCurrentRemoteBindingHeading() string { + return "Bind this local vault to the current remote target" +} + +func (m SyncMenuModel) SaveCurrentRemoteBindingButtonLabel() string { + return "Save Remote In Vault" +} diff --git a/internal/appui/app.go b/internal/appui/app.go index 6be8cf9..b9a57bf 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -2740,60 +2740,60 @@ func (u *ui) shouldShowSavedRemoteBindingSelectors() bool { func (u *ui) savedRemoteBindingSummary() (profileLabel, credentialLabel, syncLabel string, ok bool) { summary := u.computeSavedRemoteBindingSummary() - return summary.profileLabel, summary.credentialLabel, summary.syncLabel, summary.ok + return summary.ProfileLabel, summary.CredentialLabel, summary.SyncLabel, summary.OK } func (u *ui) savedRemoteBindingHeading() string { - return u.buildSyncMenuModel().savedBindingHeading() + return u.buildSyncMenuModel().SavedBindingHeading() } func (u *ui) openSelectedVaultRemoteButtonLabel() string { - return u.buildSyncMenuModel().openSelectedButtonLabel() + return u.buildSyncMenuModel().OpenSelectedButtonLabel() } func (u *ui) shouldShowDirectRemoteSyncShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } - return u.buildSyncMenuModel().showDirectRemoteSyncShortcut() + return u.buildSyncMenuModel().ShowDirectRemoteSyncShortcut() } func (u *ui) directRemoteSyncShortcutLabel() string { - return u.buildSyncMenuModel().directRemoteSyncShortcutLabel() + return u.buildSyncMenuModel().DirectRemoteSyncShortcutLabel() } func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } - return u.buildSyncMenuModel().showRemoteSyncSettingsShortcut() + return u.buildSyncMenuModel().ShowRemoteSyncSettingsShortcut() } func (u *ui) remoteSyncSettingsShortcutLabel() string { - return u.buildSyncMenuModel().remoteSyncSettingsShortcutLabel() + return u.buildSyncMenuModel().RemoteSyncSettingsShortcutLabel() } func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool { - return u.buildSyncMenuModel().showRemoveRemoteSyncShortcut() + return u.buildSyncMenuModel().ShowRemoveRemoteSyncShortcut() } func (u *ui) removeRemoteSyncShortcutLabel() string { - return u.buildSyncMenuModel().removeRemoteSyncShortcutLabel() + return u.buildSyncMenuModel().RemoveRemoteSyncShortcutLabel() } func (u *ui) shouldShowRemoteSyncSetupShortcut() bool { if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return false } - return u.buildSyncMenuModel().showRemoteSyncSetupShortcut() + return u.buildSyncMenuModel().ShowRemoteSyncSetupShortcut() } func (u *ui) remoteSyncSetupShortcutLabel() string { - return u.buildSyncMenuModel().remoteSyncSetupShortcutLabel() + return u.buildSyncMenuModel().RemoteSyncSetupShortcutLabel() } func (u *ui) syncMenuActionLabels() []string { - return u.buildSyncMenuModel().actionLabels() + return u.buildSyncMenuModel().ActionLabels() } func remoteBindingSuffix(baseURL, path, username string) string { @@ -2901,11 +2901,11 @@ func (u *ui) removeSelectedRemoteBindingAction() error { } func (u *ui) saveCurrentRemoteBindingHeading() string { - return u.buildSyncMenuModel().saveCurrentRemoteBindingHeading() + return u.buildSyncMenuModel().SaveCurrentRemoteBindingHeading() } func (u *ui) saveCurrentRemoteBindingButtonLabel() string { - return u.buildSyncMenuModel().saveCurrentRemoteBindingButtonLabel() + return u.buildSyncMenuModel().SaveCurrentRemoteBindingButtonLabel() } func (u *ui) materializeCurrentRemoteCache() error { diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 34a1b82..cf09378 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -439,14 +439,14 @@ func TestBuildSyncMenuModelForUnboundVault(t *testing.T) { u.state.Section = appstate.SectionEntries model := u.buildSyncMenuModel() - if !model.showRemoteSyncSetupShortcut() { - t.Fatal("model.showRemoteSyncSetupShortcut() = false, want true for an unbound open vault") + if !model.ShowRemoteSyncSetupShortcut() { + t.Fatal("model.ShowRemoteSyncSetupShortcut() = false, want true for an unbound open vault") } - if model.showDirectRemoteSyncShortcut() { - t.Fatal("model.showDirectRemoteSyncShortcut() = true, want false without a saved binding") + if model.ShowDirectRemoteSyncShortcut() { + t.Fatal("model.ShowDirectRemoteSyncShortcut() = true, want false without a saved binding") } - if got := model.actionLabels(); !slices.Equal(got, []string{"Open Advanced Sync", "Set Up Remote Sync"}) { - t.Fatalf("model.actionLabels() = %v, want [Open Advanced Sync Set Up Remote Sync]", got) + if got := model.ActionLabels(); !slices.Equal(got, []string{"Open Advanced Sync", "Set Up Remote Sync"}) { + t.Fatalf("model.ActionLabels() = %v, want [Open Advanced Sync Set Up Remote Sync]", got) } } @@ -477,30 +477,30 @@ func TestBuildSyncMenuModelForBoundVault(t *testing.T) { u.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave model := u.buildSyncMenuModel() - if model.showRemoteSyncSetupShortcut() { - t.Fatal("model.showRemoteSyncSetupShortcut() = true, want false for a bound vault") + if model.ShowRemoteSyncSetupShortcut() { + t.Fatal("model.ShowRemoteSyncSetupShortcut() = true, want false for a bound vault") } - if !model.showDirectRemoteSyncShortcut() { - t.Fatal("model.showDirectRemoteSyncShortcut() = false, want true for a bound vault") + if !model.ShowDirectRemoteSyncShortcut() { + t.Fatal("model.ShowDirectRemoteSyncShortcut() = false, want true for a bound vault") } - if !model.showRemoteSyncSettingsShortcut() { - t.Fatal("model.showRemoteSyncSettingsShortcut() = false, want true for a bound vault") + if !model.ShowRemoteSyncSettingsShortcut() { + t.Fatal("model.ShowRemoteSyncSettingsShortcut() = false, want true for a bound vault") } - if !model.showRemoveRemoteSyncShortcut() { - t.Fatal("model.showRemoveRemoteSyncShortcut() = false, want true for a bound vault") + if !model.ShowRemoveRemoteSyncShortcut() { + t.Fatal("model.ShowRemoveRemoteSyncShortcut() = false, want true for a bound vault") } - summary := model.savedBindingSummary - if !summary.ok { - t.Fatal("model.savedBindingSummary.ok = false, want true") + summary := model.SavedBindingSummary + if !summary.OK { + t.Fatal("model.SavedBindingSummary.OK = false, want true") } - if summary.profileLabel != "Downtown Mint" { - t.Fatalf("model.savedBindingSummary.profileLabel = %q, want Downtown Mint", summary.profileLabel) + if summary.ProfileLabel != "Downtown Mint" { + t.Fatalf("model.SavedBindingSummary.ProfileLabel = %q, want Downtown Mint", summary.ProfileLabel) } - if summary.credentialLabel != "Mint Credentials · verbal-kint" { - t.Fatalf("model.savedBindingSummary.credentialLabel = %q, want Mint Credentials · verbal-kint", summary.credentialLabel) + if summary.CredentialLabel != "Mint Credentials · verbal-kint" { + t.Fatalf("model.SavedBindingSummary.CredentialLabel = %q, want Mint Credentials · verbal-kint", summary.CredentialLabel) } - if summary.syncLabel != "Syncs automatically on open and save." { - t.Fatalf("model.savedBindingSummary.syncLabel = %q, want automatic-sync summary", summary.syncLabel) + if summary.SyncLabel != "Syncs automatically on open and save." { + t.Fatalf("model.SavedBindingSummary.SyncLabel = %q, want automatic-sync summary", summary.SyncLabel) } } @@ -515,8 +515,8 @@ func TestBuildSyncMenuModelShowsSaveCurrentBindingOnlyWithCompleteRemoteInput(t u.state.Section = appstate.SectionEntries model := u.buildSyncMenuModel() - if model.showSaveCurrentBinding { - t.Fatal("model.showSaveCurrentBinding = true, want false without remote input") + if model.ShowSaveCurrentBinding { + t.Fatal("model.ShowSaveCurrentBinding = true, want false without remote input") } u.remoteBaseURL.SetText("https://mint.example.invalid/remote.php/dav") @@ -525,14 +525,14 @@ func TestBuildSyncMenuModelShowsSaveCurrentBindingOnlyWithCompleteRemoteInput(t u.remotePassword.SetText("kobayashi") model = u.buildSyncMenuModel() - if !model.showSaveCurrentBinding { - t.Fatal("model.showSaveCurrentBinding = false, want true with complete remote input") + if !model.ShowSaveCurrentBinding { + t.Fatal("model.ShowSaveCurrentBinding = false, want true with complete remote input") } - if got := model.saveCurrentRemoteBindingHeading(); got != "Bind this local vault to the current remote target" { - t.Fatalf("model.saveCurrentRemoteBindingHeading() = %q, want vault binding guidance", got) + if got := model.SaveCurrentRemoteBindingHeading(); got != "Bind this local vault to the current remote target" { + t.Fatalf("model.SaveCurrentRemoteBindingHeading() = %q, want vault binding guidance", got) } - if got := model.saveCurrentRemoteBindingButtonLabel(); got != "Save Remote In Vault" { - t.Fatalf("model.saveCurrentRemoteBindingButtonLabel() = %q, want Save Remote In Vault", got) + if got := model.SaveCurrentRemoteBindingButtonLabel(); got != "Save Remote In Vault" { + t.Fatalf("model.SaveCurrentRemoteBindingButtonLabel() = %q, want Save Remote In Vault", got) } } diff --git a/internal/appui/ui_layout_header.go b/internal/appui/ui_layout_header.go index 8d078ba..8e7a75b 100644 --- a/internal/appui/ui_layout_header.go +++ b/internal/appui/ui_layout_header.go @@ -206,29 +206,29 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }, } - if model.showShare { + if model.ShowShare { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") }) } - if model.showRemoteSyncSetupShortcut() { + if model.ShowRemoteSyncSetupShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSetupShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()) }) } - if model.showDirectRemoteSyncShortcut() { + if model.ShowDirectRemoteSyncShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.directRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()) }) } - if model.showRemoteSyncSettingsShortcut() { + if model.ShowRemoteSyncSettingsShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSettingsShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()) }) } - if model.showRemoveRemoteSyncShortcut() { + if model.ShowRemoveRemoteSyncShortcut() { actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.removeRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()) }) } actionWidth := menuActionWidth(gtx, actionRows) @@ -241,7 +241,7 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !model.showShare { + if !model.ShowShare { return layout.Dimensions{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -259,42 +259,42 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { }) }), } - if model.showRemoteSyncSetupShortcut() { + if model.ShowRemoteSyncSetupShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSetupShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()) }) }), ) } - if model.showDirectRemoteSyncShortcut() { + if model.ShowDirectRemoteSyncShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.directRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()) }) }), ) } - if model.showRemoteSyncSettingsShortcut() { + if model.ShowRemoteSyncSettingsShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSettingsShortcutLabel()) + return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()) }) }), ) } - if model.showRemoveRemoteSyncShortcut() { + if model.ShowRemoveRemoteSyncShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.removeRemoteSyncShortcutLabel()) + return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()) }) }), ) @@ -303,36 +303,36 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { 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), model.savedBindingHeading()) + lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), ) - if !model.showSelectors { + if !model.ShowSelectors { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - summary := model.savedBindingSummary - if !summary.ok { + summary := model.SavedBindingSummary + if !summary.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), summary.profileLabel) + lbl := material.Label(u.theme, unit.Sp(13), summary.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: "+summary.credentialLabel) + lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.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), summary.syncLabel) + lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -395,17 +395,17 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { } } } - if model.showSaveCurrentBinding { + if model.ShowSaveCurrentBinding { 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), model.saveCurrentRemoteBindingHeading()) + lbl := material.Label(u.theme, unit.Sp(11), model.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, model.saveCurrentRemoteBindingButtonLabel()) + return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel()) }), ) } diff --git a/internal/appui/ui_sync_menu_actions.go b/internal/appui/ui_sync_menu_actions.go new file mode 100644 index 0000000..edc3bce --- /dev/null +++ b/internal/appui/ui_sync_menu_actions.go @@ -0,0 +1,51 @@ +package appui + +import ( + "runtime" + "strings" + + "git.julianfamily.org/keepassgo/internal/appstate" + appuiactions "git.julianfamily.org/keepassgo/internal/appui/actions" +) + +func (u *ui) buildSyncMenuModel() appuiactions.SyncMenuModel { + model := appuiactions.SyncMenuModel{ + HasOpenVault: u.hasOpenVault(), + ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(), + ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "", + RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + RemotePath: strings.TrimSpace(u.remotePath.Text()), + RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()), + RemotePassword: u.remotePassword.Text(), + SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), + } + _, model.HasSelectedBinding = u.selectedVaultRemoteBinding() + model.SavedBindingSummary = u.computeSavedRemoteBindingSummary() + model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != "" + return model +} + +func (u *ui) computeSavedRemoteBindingSummary() appuiactions.SyncMenuBindingSummary { + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return appuiactions.SyncMenuBindingSummary{} + } + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return appuiactions.SyncMenuBindingSummary{} + } + 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 appuiactions.SyncMenuBindingSummary{ + ProfileLabel: profile.Name, + CredentialLabel: credentialLabel, + SyncLabel: syncLabel, + OK: true, + } +} diff --git a/internal/appui/ui_sync_menu_model.go b/internal/appui/ui_sync_menu_model.go deleted file mode 100644 index eca8d6f..0000000 --- a/internal/appui/ui_sync_menu_model.go +++ /dev/null @@ -1,142 +0,0 @@ -package appui - -import ( - "runtime" - "strings" - - "git.julianfamily.org/keepassgo/internal/appstate" -) - -type syncMenuModel struct { - hasOpenVault bool - hasSelectedBinding bool - showSelectors bool - showShare bool - showSaveCurrentBinding bool - savedBindingSummary syncMenuBindingSummary - remoteBaseURL string - remotePath string - remoteUsername string - remotePassword string - selectedVaultSyncMode appstate.SyncMode -} - -type syncMenuBindingSummary struct { - profileLabel string - credentialLabel string - syncLabel string - ok bool -} - -func (u *ui) buildSyncMenuModel() syncMenuModel { - model := syncMenuModel{ - hasOpenVault: u.hasOpenVault(), - showSelectors: u.shouldShowSavedRemoteBindingSelectors(), - showShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "", - remoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - remotePath: strings.TrimSpace(u.remotePath.Text()), - remoteUsername: strings.TrimSpace(u.remoteUsername.Text()), - remotePassword: u.remotePassword.Text(), - selectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), - } - _, model.hasSelectedBinding = u.selectedVaultRemoteBinding() - model.savedBindingSummary = u.computeSavedRemoteBindingSummary() - model.showSaveCurrentBinding = model.hasOpenVault && model.remoteBaseURL != "" && model.remotePath != "" && model.remoteUsername != "" && model.remotePassword != "" - return model -} - -func (u *ui) computeSavedRemoteBindingSummary() syncMenuBindingSummary { - profile, ok := u.selectedVaultRemoteProfile() - if !ok { - return syncMenuBindingSummary{} - } - entry, ok := u.selectedVaultRemoteCredentialEntry() - if !ok { - return syncMenuBindingSummary{} - } - 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 syncMenuBindingSummary{ - profileLabel: profile.Name, - credentialLabel: credentialLabel, - syncLabel: syncLabel, - ok: true, - } -} - -func (m syncMenuModel) savedBindingHeading() string { - if !m.showSelectors { - return "Use this vault's saved remote sync target" - } - return "Use a saved remote profile from this vault" -} - -func (m syncMenuModel) openSelectedButtonLabel() string { - if !m.showSelectors { - return "Use Remote Sync" - } - return "Open Saved Remote" -} - -func (m syncMenuModel) showDirectRemoteSyncShortcut() bool { - return m.hasOpenVault && m.hasSelectedBinding -} - -func (m syncMenuModel) directRemoteSyncShortcutLabel() string { - return "Use Remote Sync" -} - -func (m syncMenuModel) showRemoteSyncSettingsShortcut() bool { - return m.hasOpenVault && m.hasSelectedBinding -} - -func (m syncMenuModel) remoteSyncSettingsShortcutLabel() string { - return "Remote Sync Settings" -} - -func (m syncMenuModel) showRemoveRemoteSyncShortcut() bool { - return m.showRemoteSyncSettingsShortcut() -} - -func (m syncMenuModel) removeRemoteSyncShortcutLabel() string { - return "Stop Using Remote Sync" -} - -func (m syncMenuModel) showRemoteSyncSetupShortcut() bool { - return m.hasOpenVault && !m.hasSelectedBinding -} - -func (m syncMenuModel) remoteSyncSetupShortcutLabel() string { - return "Set Up Remote Sync" -} - -func (m syncMenuModel) actionLabels() []string { - labels := []string{"Open Advanced Sync"} - if m.showRemoteSyncSetupShortcut() { - labels = append(labels, m.remoteSyncSetupShortcutLabel()) - } - if m.showDirectRemoteSyncShortcut() { - labels = append(labels, m.directRemoteSyncShortcutLabel()) - } - if m.showRemoteSyncSettingsShortcut() { - labels = append(labels, m.remoteSyncSettingsShortcutLabel()) - } - if m.showRemoveRemoteSyncShortcut() { - labels = append(labels, m.removeRemoteSyncShortcutLabel()) - } - return labels -} - -func (m syncMenuModel) saveCurrentRemoteBindingHeading() string { - return "Bind this local vault to the current remote target" -} - -func (m syncMenuModel) saveCurrentRemoteBindingButtonLabel() string { - return "Save Remote In Vault" -} From c442a20d3e91aca8826c075f1fd23f4d57f6cb96 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 07:23:10 -0700 Subject: [PATCH 39/53] Refactor app UI to satisfy gocyclo --- .golangci.yml | 11 + internal/appui/app.go | 1840 +++++++++++++++------------- internal/appui/ui_forms.go | 448 +++---- internal/appui/ui_layout_header.go | 369 +++--- 4 files changed, 1396 insertions(+), 1272 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index df12035..af27be7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,8 +1,19 @@ linters: enable: - errcheck + - gocyclo - gosimple - govet - ineffassign - staticcheck - unused + +linters-settings: + gocyclo: + min-complexity: 15 + +issues: + exclude-rules: + - path: _test\.go + linters: + - gocyclo diff --git a/internal/appui/app.go b/internal/appui/app.go index b9a57bf..29aff49 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -363,8 +363,6 @@ type ui struct { exportAttachment widget.Clickable restoreHistory widget.Clickable generatePassword widget.Clickable - goToRootGroup widget.Clickable - goToParentGroup widget.Clickable createGroup widget.Clickable moveGroup widget.Clickable renameGroup widget.Clickable @@ -2308,19 +2306,6 @@ func (u *ui) currentRemoteRecord() recentRemoteRecord { } } -func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) { - record := u.currentRemoteRecord() - if record.BaseURL == "" || record.Path == "" { - return recentRemoteRecord{}, false - } - for _, existing := range u.recentRemotes { - if existing.BaseURL == record.BaseURL && existing.Path == record.Path { - return existing, true - } - } - return recentRemoteRecord{}, false -} - func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) @@ -2416,32 +2401,14 @@ func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { } 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 - } + byID := u.remoteCredentialEntryMap(entries) 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 !remoteCredentialURLMatches(entry.URL, baseURL) { - continue - } - appendMatch(entry) - } - profilesByID := make(map[string]vault.RemoteProfile) - for _, profile := range u.availableRemoteProfiles() { - profilesByID[profile.ID] = profile + u.appendRemoteCredentialMatch(&matches, seen, entry) } + u.appendURLMatchedRemoteCredentials(baseURL, entries, appendMatch) + profilesByID := u.remoteProfileMap() localVaultPath := strings.TrimSpace(u.vaultPath.Text()) for _, record := range u.recentRemotes { if localVaultPath != "" && strings.TrimSpace(record.LocalVaultPath) != localVaultPath { @@ -2466,6 +2433,119 @@ func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry { return matches } +func (u *ui) validRemoteProfileSelection(profiles []vault.RemoteProfile) string { + selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + if u.hasRemoteProfileSelection(selectedID, profiles) { + return selectedID + } + if len(profiles) == 1 { + return profiles[0].ID + } + return "" +} + +func (u *ui) validRemoteCredentialSelection(entries []vault.Entry) string { + selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + if u.hasRemoteCredentialSelection(selectedID, entries) { + return selectedID + } + if len(entries) == 1 { + return entries[0].ID + } + return "" +} + +func (u *ui) hasRemoteProfileSelection(selectedID string, profiles []vault.RemoteProfile) bool { + for _, profile := range profiles { + if profile.ID == selectedID { + return true + } + } + return false +} + +func (u *ui) hasRemoteCredentialSelection(selectedID string, entries []vault.Entry) bool { + for _, entry := range entries { + if entry.ID == selectedID { + return true + } + } + return false +} + +func (u *ui) applySelectedRemoteProfileFields() { + if profile, ok := u.selectedVaultRemoteProfile(); ok { + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + } +} + +func (u *ui) syncRecentRemoteBindingSelection() { + if strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" { + return + } + record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())) + if !ok { + return + } + u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + u.applySelectedRemoteProfileFields() +} + +func (u *ui) syncSelectedRemoteBindingMode() { + binding, ok := u.selectedVaultRemoteBinding() + if !ok { + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + return + } + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) == strings.TrimSpace(binding.LocalVaultPath) && + strings.TrimSpace(record.RemoteProfileID) == strings.TrimSpace(binding.RemoteProfileID) && + strings.TrimSpace(record.CredentialEntryID) == strings.TrimSpace(binding.CredentialEntryID) { + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + return + } + } + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual +} + +func (u *ui) remoteCredentialEntryMap(entries []vault.Entry) map[string]vault.Entry { + byID := make(map[string]vault.Entry, len(entries)) + for _, entry := range entries { + byID[entry.ID] = entry + } + return byID +} + +func (u *ui) remoteProfileMap() map[string]vault.RemoteProfile { + profilesByID := make(map[string]vault.RemoteProfile) + for _, profile := range u.availableRemoteProfiles() { + profilesByID[profile.ID] = profile + } + return profilesByID +} + +func (u *ui) appendRemoteCredentialMatch(matches *[]vault.Entry, seen map[string]struct{}, entry vault.Entry) { + if strings.TrimSpace(entry.ID) == "" { + return + } + if _, ok := seen[entry.ID]; ok { + return + } + seen[entry.ID] = struct{}{} + *matches = append(*matches, entry) +} + +func (u *ui) appendURLMatchedRemoteCredentials(baseURL string, entries []vault.Entry, appendMatch func(vault.Entry)) { + for _, entry := range entries { + if remoteCredentialURLMatches(entry.URL, baseURL) { + appendMatch(entry) + } + } +} + func (u *ui) applyAdvancedSyncRemoteCredentialEntry(entry vault.Entry) { u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(entry.ID) u.syncRemoteUsername.SetText(strings.TrimSpace(entry.Username)) @@ -2632,71 +2712,11 @@ func (u *ui) newRemoteBindingSyncMode() appstate.SyncMode { 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 + u.selectedVaultRemoteProfileID = u.validRemoteProfileSelection(profiles) + u.selectedVaultRemoteCredentialEntryID = u.validRemoteCredentialSelection(entries) + u.applySelectedRemoteProfileFields() + u.syncRecentRemoteBindingSelection() + u.syncSelectedRemoteBindingMode() } func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { @@ -4115,6 +4135,35 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.syncHostedAPI() u.filter() u.processShortcuts(gtx) + u.handleLifecycleClicks(gtx) + u.handleHeaderAndDialogClicks(gtx) + u.handleSettingsClicks(gtx) + u.handleSectionAndSyncClicks(gtx) + u.handleApprovalAndAPIClicks(gtx) + u.handleSelectionClicks(gtx) + u.handleVaultAndEntryClicks(gtx) + u.handleGroupClicks(gtx) + u.handleInputUpdates(gtx) + u.updateViewportLayoutMode(gtx) + inset := layout.UniformInset(unit.Dp(16)) + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { + return inset.Layout(gtx, u.mainFrame) + }) + }), + layout.Stacked(u.syncDialogOverlay), + layout.Stacked(u.securityDialogOverlay), + layout.Stacked(u.remotePrefsDialogOverlay), + layout.Stacked(u.approvalDialogOverlay), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return u.phoneHeaderMenus(gtx) + }), + layout.Stacked(u.statusToast), + ) +} + +func (u *ui) handleLifecycleClicks(gtx layout.Context) { for u.createVault.Clicked(gtx) { u.runAction("create vault", u.createVaultAction) } @@ -4122,11 +4171,35 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.startOpenVaultAction() } for u.lifecycleRemoteSyncAction.Clicked(gtx) { - if u.lifecycleBusy() { - continue + if !u.lifecycleBusy() { + u.beginLifecycleRemoteSyncOpen() } - u.beginLifecycleRemoteSyncOpen() } + for u.unlockVault.Clicked(gtx) { + u.startUnlockAction() + } + for u.cancelLifecycleProgress.Clicked(gtx) { + u.cancelLifecycleBusyState() + } + for u.retryLifecycleOpen.Clicked(gtx) { + u.state.ErrorMessage = "" + u.retryLastLifecycleOpen() + } + for u.toggleLifecycleAdvanced.Clicked(gtx) { + if !u.lifecycleBusy() { + u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden + u.saveUIPreferences() + } + } +} + +func (u *ui) handleHeaderAndDialogClicks(gtx layout.Context) { + u.handleHeaderActionClicks(gtx) + u.handleDialogControlClicks(gtx) + u.handleBannerClicks(gtx) +} + +func (u *ui) handleHeaderActionClicks(gtx layout.Context) { for u.saveVault.Clicked(gtx) { u.runAction("save vault", u.saveAction) } @@ -4167,24 +4240,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.openRemotePrefsHelp.Clicked(gtx) { u.remotePrefsDialogOpen = true } - for u.setStatusBannerShort.Clicked(gtx) { - u.setStatusBannerTTL(2 * time.Second) - } - for u.setStatusBannerStandard.Clicked(gtx) { - u.setStatusBannerTTL(statusBannerDuration) - } - for u.setStatusBannerLong.Clicked(gtx) { - u.setStatusBannerTTL(statusBannerLong) - } - for u.showAllAutofillNotices.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeAll) - } - for u.showApprovalAutofillOnly.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeApprovals) - } - for u.hideAutofillNotices.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeSuppressed) + for u.lockVault.Clicked(gtx) { + u.runAction("lock vault", u.lockAction) } +} + +func (u *ui) handleDialogControlClicks(gtx layout.Context) { for u.closeAdvancedSync.Clicked(gtx) { u.syncDialogOpen = false u.showSyncPassword = false @@ -4201,6 +4262,60 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.saveSecuritySettings.Clicked(gtx) { u.runAction("save settings", u.saveSecuritySettingsAction) } +} + +func (u *ui) handleBannerClicks(gtx layout.Context) { + for u.dismissBanner.Clicked(gtx) { + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + } +} + +func (u *ui) handleSettingsClicks(gtx layout.Context) { + u.handleStatusPreferenceClicks(gtx) + u.handleAutofillPreferenceClicks(gtx) + u.handleAccessibilityClicks(gtx) + u.handleSettingsSyncDefaultClicks(gtx) +} + +func (u *ui) handleStatusPreferenceClicks(gtx layout.Context) { + for u.setStatusBannerShort.Clicked(gtx) { + u.setStatusBannerTTL(2 * time.Second) + } + for u.setStatusBannerStandard.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerDuration) + } + for u.setStatusBannerLong.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerLong) + } +} + +func (u *ui) handleAutofillPreferenceClicks(gtx layout.Context) { + for u.showAllAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeAll) + } + for u.showApprovalAutofillOnly.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeApprovals) + } + for u.hideAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeSuppressed) + } + for u.showAutofillApprovalAsk.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk + u.saveUIPreferences() + } + for u.showAutofillApprovalAllow.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow + u.saveUIPreferences() + } + for u.showAutofillApprovalBlock.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock + u.saveUIPreferences() + } +} + +func (u *ui) handleAccessibilityClicks(gtx layout.Context) { for u.settingsDensityDense.Clicked(gtx) { u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense _ = u.applySecuritySettingsLive() @@ -4233,16 +4348,35 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusProminent _ = u.applySecuritySettingsLive() } - for u.unlockVault.Clicked(gtx) { - u.startUnlockAction() +} + +func (u *ui) handleSettingsSyncDefaultClicks(gtx layout.Context) { + for u.showSettingsSyncLocal.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceLocal + _ = u.applySecuritySettingsLive() } - for u.cancelLifecycleProgress.Clicked(gtx) { - u.cancelLifecycleBusyState() + for u.showSettingsSyncRemote.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceRemote + _ = u.applySecuritySettingsLive() } - for u.retryLifecycleOpen.Clicked(gtx) { - u.state.ErrorMessage = "" - u.retryLastLifecycleOpen() + for u.showSettingsSyncPull.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPull + _ = u.applySecuritySettingsLive() } + for u.showSettingsSyncPush.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPush + _ = u.applySecuritySettingsLive() + } +} + +func (u *ui) handleSectionAndSyncClicks(gtx layout.Context) { + u.handleSectionClicks(gtx) + u.handleLifecycleModeClicks(gtx) + u.handleSyncChoiceClicks(gtx) + u.handleRemoteBindingClicks(gtx) +} + +func (u *ui) handleSectionClicks(gtx layout.Context) { for u.showEntries.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showEntriesSection() @@ -4267,28 +4401,25 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.clearDeleteGroupConfirmation() u.showAboutSection() } +} + +func (u *ui) handleLifecycleModeClicks(gtx layout.Context) { for u.showLocalLifecycle.Clicked(gtx) { - if u.lifecycleBusy() { - continue + if !u.lifecycleBusy() { + u.lifecycleMode = "local" + u.requestMasterPassFocus = true } - u.lifecycleMode = "local" - u.requestMasterPassFocus = true } for u.showRemoteLifecycle.Clicked(gtx) { - if u.lifecycleBusy() { - continue + if !u.lifecycleBusy() { + u.lifecycleMode = "remote" + u.selectedRemoteConnection = false + u.requestMasterPassFocus = true } - u.lifecycleMode = "remote" - u.selectedRemoteConnection = false - u.requestMasterPassFocus = true - } - for u.toggleLifecycleAdvanced.Clicked(gtx) { - if u.lifecycleBusy() { - continue - } - u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden - u.saveUIPreferences() } +} + +func (u *ui) handleSyncChoiceClicks(gtx layout.Context) { for u.showSyncLocal.Clicked(gtx) { u.syncSourceMode = syncSourceLocal } @@ -4301,34 +4432,35 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.showSyncPush.Clicked(gtx) { u.syncDirection = syncDirectionPush } - for u.showSettingsSyncLocal.Clicked(gtx) { - u.settingsDraft.Sync.SourceDefault = syncSourceLocal - _ = u.applySecuritySettingsLive() +} + +func (u *ui) handleRemoteBindingClicks(gtx layout.Context) { + for u.useSavedAdvancedSyncRemote.Clicked(gtx) { + u.openRemoteSyncSetupDialog() } - for u.showSettingsSyncRemote.Clicked(gtx) { - u.settingsDraft.Sync.SourceDefault = syncSourceRemote - _ = u.applySecuritySettingsLive() + for u.openSelectedVaultRemote.Clicked(gtx) { + if !u.lifecycleBusy() { + u.startOpenRemoteAction() + } } - for u.showSettingsSyncPull.Clicked(gtx) { - u.settingsDraft.Sync.DirectionDefault = syncDirectionPull - _ = u.applySecuritySettingsLive() + for u.saveCurrentRemoteBinding.Clicked(gtx) { + u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) } - for u.showSettingsSyncPush.Clicked(gtx) { - u.settingsDraft.Sync.DirectionDefault = syncDirectionPush - _ = u.applySecuritySettingsLive() + for u.removeSelectedRemoteBinding.Clicked(gtx) { + u.runAction("remove remote sync binding", u.removeSelectedRemoteBindingAction) } - for u.showAutofillApprovalAsk.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk - u.saveUIPreferences() - } - for u.showAutofillApprovalAllow.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow - u.saveUIPreferences() - } - for u.showAutofillApprovalBlock.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock - u.saveUIPreferences() + for u.shareCurrentVault.Clicked(gtx) { + u.runAction("share vault", u.shareCurrentVaultAction) } +} + +func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) { + u.handleApprovalClicks(gtx) + u.handleAPITokenClicks(gtx) + u.handleAPIPolicyClicks(gtx) +} + +func (u *ui) handleApprovalClicks(gtx layout.Context) { for u.allowApproval.Clicked(gtx) { u.runAction("allow API request", func() error { outcome := apiapproval.OutcomeAllowOnce @@ -4358,9 +4490,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { return err }) } - for u.lockVault.Clicked(gtx) { - u.runAction("lock vault", u.lockAction) - } +} + +func (u *ui) handleAPITokenClicks(gtx layout.Context) { for u.issueAPIToken.Clicked(gtx) { u.runAction("issue API token", u.issueAPITokenAction) } @@ -4379,6 +4511,21 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.deleteAPIToken.Clicked(gtx) { u.runAction("delete API token", u.deleteAPITokenAction) } + for u.copyAPITokenSecret.Clicked(gtx) { + secret := u.apiTokenSecret + u.runAction("copy API token secret", func() error { + if strings.TrimSpace(secret) == "" { + return fmt.Errorf("no API token secret to copy") + } + if u.clipboardWriter != nil { + return u.clipboardWriter.WriteText(secret) + } + return clipboard.WriteText(secret) + }) + } +} + +func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { for u.addAPIPolicyRule.Clicked(gtx) { u.runAction("add API policy rule", u.addAPIPolicyRuleAction) } @@ -4397,53 +4544,40 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) }) } } - for u.copyAPITokenSecret.Clicked(gtx) { - secret := u.apiTokenSecret - u.runAction("copy API token secret", func() error { - if strings.TrimSpace(secret) == "" { - return fmt.Errorf("no API token secret to copy") - } - if u.clipboardWriter != nil { - return u.clipboardWriter.WriteText(secret) - } - return clipboard.WriteText(secret) - }) - } - for u.editEntry.Clicked(gtx) { - u.editingEntry = true - u.loadSelectedEntryIntoEditor() - } - for u.cancelEdit.Clicked(gtx) { - u.editingEntry = false - u.loadSelectedEntryIntoEditor() - } +} + +func (u *ui) handleSelectionClicks(gtx layout.Context) { + u.handleFileSelectionClicks(gtx) + u.handleRecentSelectionClicks(gtx) + u.handleRemoteSelectionClicks(gtx) + u.handleClearSelectionClicks(gtx) +} + +func (u *ui) handleFileSelectionClicks(gtx layout.Context) { for u.pickVaultPath.Clicked(gtx) { - if u.lifecycleBusy() { - continue + if !u.lifecycleBusy() { + u.startChooseVaultPathAction() } - u.startChooseVaultPathAction() } for u.importSharedVault.Clicked(gtx) { - if u.lifecycleBusy() { - continue + if !u.lifecycleBusy() { + u.startImportSharedVaultAction() } - u.startImportSharedVaultAction() } for u.pickKeyFile.Clicked(gtx) { - if u.lifecycleBusy() { - continue + if !u.lifecycleBusy() { + u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } - u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } for u.pickSyncLocalPath.Clicked(gtx) { u.startChooseSyncLocalSourceAction() } +} + +func (u *ui) handleRecentSelectionClicks(gtx layout.Context) { for i := range u.recentVaultClicks { for u.recentVaultClicks[i].Clicked(gtx) { - if u.lifecycleBusy() { - continue - } - if i < len(u.recentVaults) { + if !u.lifecycleBusy() && i < len(u.recentVaults) { u.lifecycleMode = "local" u.vaultPath.SetText(u.recentVaults[i]) u.requestMasterPassFocus = true @@ -4452,16 +4586,16 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } for i := range u.recentRemoteClicks { for u.recentRemoteClicks[i].Clicked(gtx) { - if u.lifecycleBusy() { - continue - } - if i < len(u.recentRemotes) { + if !u.lifecycleBusy() && i < len(u.recentRemotes) { u.lifecycleMode = "remote" u.applyRecentRemoteRecord(u.recentRemotes[i]) u.requestMasterPassFocus = true } } } +} + +func (u *ui) handleRemoteSelectionClicks(gtx layout.Context) { for i := range u.vaultRemoteProfileClicks { for u.vaultRemoteProfileClicks[i].Clicked(gtx) { profiles := u.availableRemoteProfiles() @@ -4486,24 +4620,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } } } - 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) - } +} + +func (u *ui) handleClearSelectionClicks(gtx layout.Context) { for u.clearVaultSelection.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -4534,10 +4653,22 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.state.StatusMessage = "" u.requestMasterPassFocus = true } - for u.dismissBanner.Clicked(gtx) { - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} +} + +func (u *ui) handleVaultAndEntryClicks(gtx layout.Context) { + u.handleEntryEditorClicks(gtx) + u.handleEntryMutationClicks(gtx) + u.handleAttachmentAndCopyClicks(gtx) +} + +func (u *ui) handleEntryEditorClicks(gtx layout.Context) { + for u.editEntry.Clicked(gtx) { + u.editingEntry = true + u.loadSelectedEntryIntoEditor() + } + for u.cancelEdit.Clicked(gtx) { + u.editingEntry = false + u.loadSelectedEntryIntoEditor() } for u.addEntry.Clicked(gtx) { u.state.BeginNewEntry() @@ -4545,6 +4676,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.editingEntry = true } +} + +func (u *ui) handleEntryMutationClicks(gtx layout.Context) { for u.saveEntry.Clicked(gtx) { u.runAction("save entry", u.saveEntryAction) } @@ -4566,6 +4700,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.instantiateTemplate.Clicked(gtx) { u.runAction("instantiate template", u.instantiateSelectedTemplateAction) } +} + +func (u *ui) handleAttachmentAndCopyClicks(gtx layout.Context) { for u.addAttachment.Clicked(gtx) { u.runAction("add attachment", u.addAttachmentAction) } @@ -4593,6 +4730,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.restoreHistory.Clicked(gtx) { u.runAction("restore history", u.restoreSelectedHistoryAction) } +} + +func (u *ui) handleGroupClicks(gtx layout.Context) { for u.createGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.runAction("create group", u.createGroupAction) @@ -4628,6 +4768,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} } +} + +func (u *ui) handleInputUpdates(gtx layout.Context) { if u.securityDialogOpen { if _, changed := u.securityCipher.Update(gtx); changed { _ = u.applySecuritySettingsLive() @@ -4662,111 +4805,117 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if _, changed := u.search.Update(gtx); changed { u.filter() } - u.updateViewportLayoutMode(gtx) - inset := layout.UniformInset(unit.Dp(16)) - return layout.Stack{}.Layout(gtx, - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { - return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(u.header), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.bannerSurface().Kind == bannerNone { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(u.banner), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - ) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.bannerSurface().Kind != bannerNone { - return layout.Dimensions{} - } - if u.autofillStatusSurface().Kind == autofillStatusNone { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(u.autofillStatusCard), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - ) - }), - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - if u.shouldShowLifecycleSetup() { - return u.lifecycleScreen(gtx) - } - if u.shouldUseLockedSinglePane() { - return u.detailPanel(gtx) - } - if u.usesCompactViewport() { - u.phoneSpan = gtx.Constraints.Max.Y - listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) - if listHeight < gtx.Dp(unit.Dp(180)) { - listHeight = gtx.Dp(unit.Dp(180)) - } - if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) { - listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.Y = listHeight - gtx.Constraints.Max.Y = listHeight - return u.listPanel(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(u.phoneSlider), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - func() layout.FlexChild { - if u.shouldUseCompactPhoneDetailPane() { - return layout.Rigid(u.detailPanel) - } - return layout.Flexed(1, u.detailPanel) - }(), - ) - } - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Flexed(0.38, u.listPanel), - layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), - layout.Flexed(0.62, u.detailPanel), - ) - }), - ) - }) - }) - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - if !u.syncDialogOpen { - return layout.Dimensions{} - } - return u.syncDialog(gtx) - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - if !u.securityDialogOpen { - return layout.Dimensions{} - } - return u.securityDialog(gtx) - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - if !u.remotePrefsDialogOpen { - return layout.Dimensions{} - } - return u.remotePrefsDialog(gtx) - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - if _, ok := u.pendingApproval(); !ok { - return layout.Dimensions{} - } - return u.approvalDialog(gtx) - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - return u.phoneHeaderMenus(gtx) - }), - layout.Stacked(u.statusToast), +} + +func (u *ui) mainFrame(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.header), + layout.Rigid(u.bannerRow), + layout.Rigid(u.autofillStatusRow), + layout.Flexed(1, u.primaryContent), ) } +func (u *ui) bannerRow(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind == bannerNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(u.banner), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + ) +} + +func (u *ui) autofillStatusRow(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind != bannerNone || u.autofillStatusSurface().Kind == autofillStatusNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(u.autofillStatusCard), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + ) +} + +func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { + switch { + case u.shouldShowLifecycleSetup(): + return u.lifecycleScreen(gtx) + case u.shouldUseLockedSinglePane(): + return u.detailPanel(gtx) + case u.usesCompactViewport(): + return u.compactPrimaryContent(gtx) + default: + return u.widePrimaryContent(gtx) + } +} + +func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { + u.phoneSpan = gtx.Constraints.Max.Y + listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) + if min := gtx.Dp(unit.Dp(180)); listHeight < min { + listHeight = min + } + if max := gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)); listHeight > max { + listHeight = max + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.Y = listHeight + gtx.Constraints.Max.Y = listHeight + return u.listPanel(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(u.phoneSlider), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + u.compactDetailFlexChild(), + ) +} + +func (u *ui) compactDetailFlexChild() layout.FlexChild { + if u.shouldUseCompactPhoneDetailPane() { + return layout.Rigid(u.detailPanel) + } + return layout.Flexed(1, u.detailPanel) +} + +func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(0.38, u.listPanel), + layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), + layout.Flexed(0.62, u.detailPanel), + ) +} + +func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.syncDialogOpen { + return layout.Dimensions{} + } + return u.syncDialog(gtx) +} + +func (u *ui) securityDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.securityDialogOpen { + return layout.Dimensions{} + } + return u.securityDialog(gtx) +} + +func (u *ui) remotePrefsDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.remotePrefsDialogOpen { + return layout.Dimensions{} + } + return u.remotePrefsDialog(gtx) +} + +func (u *ui) approvalDialogOverlay(gtx layout.Context) layout.Dimensions { + if _, ok := u.pendingApproval(); !ok { + return layout.Dimensions{} + } + return u.approvalDialog(gtx) +} + func (u *ui) syncHostedAPI() { if u.apiHost == nil { return @@ -5424,119 +5573,148 @@ func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions { func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card - spacing := u.sectionSpacing() if u.usesCompactViewport() { panel = compactCard } u.ensureNavClickables() if u.usesCompactViewport() { - return panel(gtx, func(gtx layout.Context) layout.Dimensions { - visibleEntries, entryClicks := u.visibleEntrySnapshot() - rows := make([]layout.Widget, 0, 16+len(visibleEntries)) - for _, section := range u.listPanelTopSections() { - switch section { - case listPanelTopSearch: - rows = append(rows, u.listPanelSearchRow) - case listPanelTopNavigation: - rows = append(rows, u.navigationHeader) - case listPanelTopPath: - rows = append(rows, u.pathBar) - case listPanelTopGroup: - rows = append(rows, u.groupBar) - case listPanelTopGroupTools: - rows = append(rows, u.groupControlsSection) - case listPanelTopPrimary: - rows = append(rows, u.listPanelPrimaryActionRow) - } - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Height: spacing}.Layout(gtx) - }) - } - switch { - case u.state.Section == appstate.SectionAPITokens: - rows = append(rows, u.apiTokenListPanel) - case u.state.Section == appstate.SectionAPIAudit: - rows = append(rows, u.apiAuditListPanel) - case u.state.Section == appstate.SectionAbout: - case len(visibleEntries) == 0: - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return emptyStatePanel(gtx, u.theme, u.listEmptyState()) - }) - default: - for i := range visibleEntries { - idx := i - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return u.entryRow(gtx, entryClicks[idx], idx, visibleEntries[idx]) - }) - } - } - return material.List(u.theme, &u.phonePanelList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { - return rows[i](gtx) + return panel(gtx, u.compactListPanel) + } + return panel(gtx, u.wideListPanel) +} + +func (u *ui) compactListPanel(gtx layout.Context) layout.Dimensions { + visibleEntries, entryClicks := u.visibleEntrySnapshot() + rows := u.compactListPanelRows(visibleEntries, entryClicks) + return material.List(u.theme, &u.phonePanelList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) +} + +func (u *ui) compactListPanelRows(visibleEntries []entry, entryClicks []*widget.Clickable) []layout.Widget { + rows := u.compactListPanelTopRows() + switch { + case u.state.Section == appstate.SectionAPITokens: + rows = append(rows, u.apiTokenListPanel) + case u.state.Section == appstate.SectionAPIAudit: + rows = append(rows, u.apiAuditListPanel) + case u.state.Section == appstate.SectionAbout: + case len(visibleEntries) == 0: + rows = append(rows, func(gtx layout.Context) layout.Dimensions { + return emptyStatePanel(gtx, u.theme, u.listEmptyState()) + }) + default: + for i := range visibleEntries { + idx := i + rows = append(rows, func(gtx layout.Context) layout.Dimensions { + return u.entryRow(gtx, entryClicks[idx], idx, visibleEntries[idx]) }) + } + } + return rows +} + +func (u *ui) compactListPanelTopRows() []layout.Widget { + spacing := u.sectionSpacing() + rows := make([]layout.Widget, 0, 16) + for _, section := range u.listPanelTopSections() { + rows = append(rows, u.listPanelTopSectionWidget(section)) + rows = append(rows, func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Height: spacing}.Layout(gtx) }) } - return panel(gtx, func(gtx layout.Context) layout.Dimensions { - children := make([]layout.FlexChild, 0, 16) - for _, section := range u.listPanelTopSections() { - switch section { - case listPanelTopSearch: - children = append(children, layout.Rigid(u.listPanelSearchRow)) - case listPanelTopNavigation: - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() { - return layout.Dimensions{} - } - return u.navigationHeader(gtx) - })) - case listPanelTopPath: - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { - return layout.Dimensions{} - } - return u.pathBar(gtx) - })) - case listPanelTopGroup: - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { - return layout.Dimensions{} - } - return u.groupBar(gtx) - })) - case listPanelTopGroupTools: - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { - return layout.Dimensions{} - } - return u.groupControlsSection(gtx) - })) - case listPanelTopPrimary: - children = append(children, layout.Rigid(u.listPanelPrimaryActionRow)) + return rows +} + +func (u *ui) listPanelTopSectionWidget(section listPanelTopSection) layout.Widget { + switch section { + case listPanelTopSearch: + return u.listPanelSearchRow + case listPanelTopNavigation: + return u.navigationHeader + case listPanelTopPath: + return u.pathBar + case listPanelTopGroup: + return u.groupBar + case listPanelTopGroupTools: + return u.groupControlsSection + case listPanelTopPrimary: + return u.listPanelPrimaryActionRow + default: + return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } + } +} + +func (u *ui) wideListPanel(gtx layout.Context) layout.Dimensions { + children := u.wideListPanelChildren() + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + +func (u *ui) wideListPanelChildren() []layout.FlexChild { + spacing := u.sectionSpacing() + children := make([]layout.FlexChild, 0, 16) + for _, section := range u.listPanelTopSections() { + children = append(children, layout.Rigid(u.wideListPanelTopSectionWidget(section))) + children = append(children, layout.Rigid(layout.Spacer{Height: spacing}.Layout)) + } + children = append(children, layout.Flexed(1, u.wideListPanelBody)) + return children +} + +func (u *ui) wideListPanelTopSectionWidget(section listPanelTopSection) layout.Widget { + switch section { + case listPanelTopSearch: + return u.listPanelSearchRow + case listPanelTopNavigation: + return func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() { + return layout.Dimensions{} } - children = append(children, layout.Rigid(layout.Spacer{Height: spacing}.Layout)) + return u.navigationHeader(gtx) } - children = append(children, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - if u.state.Section == appstate.SectionAPITokens { - return u.apiTokenListPanel(gtx) - } - if u.state.Section == appstate.SectionAPIAudit { - return u.apiAuditListPanel(gtx) - } - if u.state.Section == appstate.SectionAbout { - return emptyStatePanel(gtx, u.theme, u.listEmptyState()) - } - if len(u.visible) == 0 { - return emptyStatePanel(gtx, u.theme, u.listEmptyState()) - } - return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions { - item := u.visible[i] - click := &u.entryClicks[i] - return u.entryRow(gtx, click, i, item) - }) - }), - ) - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) - }) + case listPanelTopPath: + return func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { + return layout.Dimensions{} + } + return u.pathBar(gtx) + } + case listPanelTopGroup: + return func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return layout.Dimensions{} + } + return u.groupBar(gtx) + } + case listPanelTopGroupTools: + return func(gtx layout.Context) layout.Dimensions { + if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return layout.Dimensions{} + } + return u.groupControlsSection(gtx) + } + case listPanelTopPrimary: + return u.listPanelPrimaryActionRow + default: + return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } + } +} + +func (u *ui) wideListPanelBody(gtx layout.Context) layout.Dimensions { + switch { + case u.state.Section == appstate.SectionAPITokens: + return u.apiTokenListPanel(gtx) + case u.state.Section == appstate.SectionAPIAudit: + return u.apiAuditListPanel(gtx) + case u.state.Section == appstate.SectionAbout, len(u.visible) == 0: + return emptyStatePanel(gtx, u.theme, u.listEmptyState()) + default: + return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions { + item := u.visible[i] + click := &u.entryClicks[i] + return u.entryRow(gtx, click, i, item) + }) + } } func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { @@ -5561,169 +5739,119 @@ func (u *ui) navigationHeaderLabel() string { return "Group Tools" } -func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { - tabs := []struct { - click *widget.Clickable - label string - compact string - active bool - }{ - {click: &u.showEntries, label: "Entries", compact: "Entries", active: u.state.Section == appstate.SectionEntries}, - {click: &u.showRecycle, label: "Recycle Bin", compact: "Recycle", active: u.state.Section == appstate.SectionRecycleBin}, - {click: &u.showAPITokens, label: "API Tokens", compact: "Tokens", active: u.state.Section == appstate.SectionAPITokens}, - {click: &u.showAPIAudit, label: "API Audit", compact: "Audit", active: u.state.Section == appstate.SectionAPIAudit}, - } - if u.usesCompactViewport() { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].compact, tabs[0].active) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].compact, tabs[1].active) - }), - ) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].compact, tabs[2].active) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].compact, tabs[3].active) - }), - ) - }), - ) - } - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].label, tabs[0].active) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].label, tabs[1].active) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].label, tabs[2].active) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].label, tabs[3].active) - }), - ) -} - func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions { for click.Clicked(gtx) { _ = u.state.ToggleVisibleIndex(idx) u.loadSelectedEntryIntoEditor() } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics() selected := item.ID == u.state.SelectedEntryID focused := u.isFocused(listFocusID(idx)) rowColors := u.listRowColors(selected, focused, u.state.Section == appstate.SectionRecycleBin) row := func(gtx layout.Context) layout.Dimensions { - return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions { - showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin - hasUsername := strings.TrimSpace(item.Username) != "" - hasURL := strings.TrimSpace(item.URL) != "" - pathText := strings.Join(u.displayEntryPath(item.Path), " / ") - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, titleSize, item.Title) - lbl.Color = rowColors.Title - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !hasUsername { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(3)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !hasUsername { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, metaSize, item.Username) - lbl.Color = rowColors.Meta - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !hasURL { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(2)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !hasURL { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, urlSize, item.URL) - lbl.Color = rowColors.Secondary - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showPath { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showPath { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, pathSize, pathText) - lbl.Color = rowColors.Secondary - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: dividerGap}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - w := gtx.Constraints.Max.X - if w < 1 { - w = 1 - } - paint.FillShape(gtx.Ops, rowColors.Divider, clip.Rect{Max: image.Pt(w, 1)}.Op()) - return layout.Dimensions{Size: image.Pt(w, 1)} - }), - ) - }) + return u.entryRowContent(gtx, item, rowColors) } if selected || focused { - return layout.Stack{}.Layout(gtx, - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - size := gtx.Constraints.Min - if size.X == 0 { - size.X = gtx.Constraints.Max.X - } - if size.Y == 0 { - size.Y = gtx.Constraints.Max.Y - } - paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op()) - paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op()) - return layout.Dimensions{Size: size} - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - return row(gtx) - }), - ) + return u.highlightedEntryRow(gtx, rowColors, row) } - bg := panelColor - if u.state.Section == appstate.SectionRecycleBin { - bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255} - } - return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { - return row(gtx) - }) + return u.standardEntryRow(gtx, row) }) } +func (u *ui) entryRowContent(gtx layout.Context, item entry, rowColors listRowColors) layout.Dimensions { + inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics() + showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin + hasUsername := strings.TrimSpace(item.Username) != "" + hasURL := strings.TrimSpace(item.URL) != "" + pathText := strings.Join(u.displayEntryPath(item.Path), " / ") + return layout.UniformInset(inset).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, titleSize, item.Title) + lbl.Color = rowColors.Title + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasUsername { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(3)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasUsername { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, metaSize, item.Username) + lbl.Color = rowColors.Meta + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasURL { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(2)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasURL { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, urlSize, item.URL) + lbl.Color = rowColors.Secondary + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showPath { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showPath { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, pathSize, pathText) + lbl.Color = rowColors.Secondary + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: dividerGap}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + w := gtx.Constraints.Max.X + if w < 1 { + w = 1 + } + paint.FillShape(gtx.Ops, rowColors.Divider, clip.Rect{Max: image.Pt(w, 1)}.Op()) + return layout.Dimensions{Size: image.Pt(w, 1)} + }), + ) + }) +} + +func (u *ui) highlightedEntryRow(gtx layout.Context, rowColors listRowColors, row layout.Widget) layout.Dimensions { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + size := gtx.Constraints.Min + if size.X == 0 { + size.X = gtx.Constraints.Max.X + } + if size.Y == 0 { + size.Y = gtx.Constraints.Max.Y + } + paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op()) + paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op()) + return layout.Dimensions{Size: size} + }), + layout.Stacked(row), + ) +} + +func (u *ui) standardEntryRow(gtx layout.Context, row layout.Widget) layout.Dimensions { + bg := panelColor + if u.state.Section == appstate.SectionRecycleBin { + bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255} + } + return layout.Background{}.Layout(gtx, fill(bg), row) +} + func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { if !u.usesCompactViewport() { return layout.Dimensions{} @@ -5803,256 +5931,318 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { } func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { - panel := layout.Flex{Axis: layout.Vertical} - _ = panel - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { - if u.isVaultLocked() { - return []layout.FlexChild{ - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault") - 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), "Enter the master password, choose a key file, or provide both.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(u.unlockPanel), + if u.isVaultLocked() { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.lockedDetailChildren()...) + } + if panel := u.staticDetailPanel(); panel != nil { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...) + } + item, ok := u.selectedEntry() + if !ok && !u.editingEntry { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.emptyDetailChildren()...) + } + if u.editingEntry { + return u.detailEditorContent(gtx, ok) + } + return u.detailViewContent(gtx, item) +} + +func (u *ui) lockedDetailChildren() []layout.FlexChild { + return []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault") + 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), "Enter the master password, choose a key file, or provide both.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(u.unlockPanel), + } +} + +func (u *ui) staticDetailPanel() []layout.FlexChild { + switch u.state.Section { + case appstate.SectionAPITokens: + return []layout.FlexChild{layout.Flexed(1, u.apiTokenDetailPanel)} + case appstate.SectionAPIAudit: + return []layout.FlexChild{layout.Flexed(1, u.apiAuditDetailPanel)} + case appstate.SectionAbout: + return []layout.FlexChild{layout.Flexed(1, u.aboutDetailPanel)} + default: + return nil + } +} + +func (u *ui) emptyDetailChildren() []layout.FlexChild { + return []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(18), "Entry details") + 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(16), u.detailPlaceholderMessage()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + } +} + +func (u *ui) detailEditorContent(gtx layout.Context, hasSelected bool) layout.Dimensions { + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + title := "New Entry" + if hasSelected { + title = "Edit Entry" } - } - if u.state.Section == appstate.SectionAPITokens { - return []layout.FlexChild{ - layout.Flexed(1, u.apiTokenDetailPanel), - } - } - if u.state.Section == appstate.SectionAPIAudit { - return []layout.FlexChild{ - layout.Flexed(1, u.apiAuditDetailPanel), - } - } - if u.state.Section == appstate.SectionAbout { - return []layout.FlexChild{ - layout.Flexed(1, u.aboutDetailPanel), - } - } - item, ok := u.selectedEntry() - if !ok && !u.editingEntry { - return []layout.FlexChild{ - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(18), "Entry details") - 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(16), u.detailPlaceholderMessage()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - } - } - if u.editingEntry { - rows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { - title := "New Entry" - if ok { - title = "Edit Entry" - } - lbl := material.Label(u.theme, unit.Sp(18), title) - lbl.Color = accentColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(8)}.Layout, - u.entryEditorPanel, - } - return []layout.FlexChild{ - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { - return rows[i](gtx) - }) - }), - } - } - password := u.detailPasswordValue() - titleSize := unit.Sp(26) - titlePad := unit.Dp(10) - sectionGap := unit.Dp(6) - cardGap := unit.Dp(8) + lbl := material.Label(u.theme, unit.Sp(18), title) + lbl.Color = accentColor + return lbl.Layout(gtx) + }, + layout.Spacer{Height: unit.Dp(8)}.Layout, + u.entryEditorPanel, + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) + }), + ) +} + +type detailViewMetrics struct { + titleSize unit.Sp + titlePad unit.Dp + sectionGap unit.Dp + cardGap unit.Dp +} + +func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions { + rows := u.detailViewRows(item) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) + }), + ) +} + +func (u *ui) detailViewRows(item entry) []layout.Widget { + password := u.detailPasswordValue() + metrics := u.detailViewMetrics() + rows := []layout.Widget{ + u.detailTitleRow(item, metrics), + layout.Spacer{Height: metrics.titlePad}.Layout, + u.detailCompactCopyRow(item), + layout.Spacer{Height: u.recycleDetailGap()}.Layout, + u.recycleDetailNotice, + layout.Spacer{Height: u.recycleDetailCardGap()}.Layout, + u.detailMetadataCard(item, metrics), + layout.Spacer{Height: metrics.sectionGap}.Layout, + u.detailPasswordCard(password), + layout.Spacer{Height: unit.Dp(8)}.Layout, + u.detailNotesCard(item), + layout.Spacer{Height: metrics.cardGap}.Layout, + u.attachmentSummaryPanel, + layout.Spacer{Height: metrics.cardGap}.Layout, + u.historyPanel, + layout.Spacer{Height: metrics.cardGap}.Layout, + u.detailActionRow, + } + return rows +} + +func (u *ui) detailViewMetrics() detailViewMetrics { + metrics := detailViewMetrics{ + titleSize: unit.Sp(26), + titlePad: unit.Dp(10), + sectionGap: unit.Dp(6), + cardGap: unit.Dp(8), + } + if u.denseLayout { + metrics.titlePad = unit.Dp(6) + metrics.sectionGap = unit.Dp(4) + metrics.cardGap = unit.Dp(6) + } + if u.usesCompactViewport() { + metrics.titleSize = unit.Sp(18) + metrics.titlePad = unit.Dp(4) + metrics.sectionGap = unit.Dp(4) + metrics.cardGap = unit.Dp(6) if u.denseLayout { - titlePad = unit.Dp(6) - sectionGap = unit.Dp(4) - cardGap = unit.Dp(6) + metrics.titlePad = unit.Dp(3) + metrics.sectionGap = unit.Dp(3) + metrics.cardGap = unit.Dp(4) } - if u.usesCompactViewport() { - titleSize = unit.Sp(18) - titlePad = unit.Dp(4) - sectionGap = unit.Dp(4) - cardGap = unit.Dp(6) - if u.denseLayout { - titlePad = unit.Dp(3) - sectionGap = unit.Dp(3) - cardGap = unit.Dp(4) - } + } + return metrics +} + +func (u *ui) detailTitleRow(item entry, metrics detailViewMetrics) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + title := item.Title + if u.state.Section == appstate.SectionRecycleBin { + title = "Recycle Bin Entry" } - rows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { - title := item.Title - if u.state.Section == appstate.SectionRecycleBin { - title = "Recycle Bin Entry" - } - lbl := material.Label(u.theme, titleSize, title) - lbl.Color = accentColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: titlePad}.Layout, - func(gtx layout.Context) layout.Dimensions { - if u.state.Section != appstate.SectionRecycleBin { - if u.usesCompactViewport() { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { - return compactTonedButton(gtx, u.theme, &u.copyUser, "Copy Username") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { - return compactTonedButton(gtx, u.theme, &u.copyPass, "Copy Password") - }), - ) - } - return layout.Dimensions{} - } - return recycleDetailTitle(gtx, u.theme, item.Title) - }, - layout.Spacer{Height: func() unit.Dp { - if u.state.Section == appstate.SectionRecycleBin { - return unit.Dp(10) - } - return 0 - }()}.Layout, - func(gtx layout.Context) layout.Dimensions { - if u.state.Section != appstate.SectionRecycleBin { - return layout.Dimensions{} - } - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "This entry is in the recycle bin. Review it, copy from it, or restore it back into the vault.") + lbl := material.Label(u.theme, metrics.titleSize, title) + lbl.Color = accentColor + return lbl.Layout(gtx) + } +} + +func (u *ui) detailCompactCopyRow(item entry) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionRecycleBin { + return recycleDetailTitle(gtx, u.theme, item.Title) + } + if !u.usesCompactViewport() { + return layout.Dimensions{} + } + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { + return compactTonedButton(gtx, u.theme, &u.copyUser, "Copy Username") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { + return compactTonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), + ) + } +} + +func (u *ui) recycleDetailGap() unit.Dp { + if u.state.Section == appstate.SectionRecycleBin { + return unit.Dp(10) + } + return 0 +} + +func (u *ui) recycleDetailCardGap() unit.Dp { + if u.state.Section == appstate.SectionRecycleBin { + return unit.Dp(8) + } + return 0 +} + +func (u *ui) recycleDetailNotice(gtx layout.Context) layout.Dimensions { + if u.state.Section != appstate.SectionRecycleBin { + return layout.Dimensions{} + } + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "This entry is in the recycle bin. Review it, copy from it, or restore it back into the vault.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }) +} + +func (u *ui) detailMetadataCard(item entry, metrics detailViewMetrics) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / "))), + layout.Rigid(layout.Spacer{Height: metrics.sectionGap}.Layout), + layout.Rigid(detailLine(u.theme, "Username", item.Username)), + layout.Rigid(layout.Spacer{Height: metrics.sectionGap}.Layout), + layout.Rigid(detailLine(u.theme, "URL", item.URL)), + layout.Rigid(layout.Spacer{Height: metrics.sectionGap}.Layout), + layout.Rigid(detailLine(u.theme, "Tags", strings.Join(item.Tags, ", "))), + ) + }) + } +} + +func (u *ui) detailPasswordCard(password string) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.passwordLine("Password", password)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(u.detailCopyActionRow), + ) + }) + } +} + +func (u *ui) detailCopyActionRow(gtx layout.Context) layout.Dimensions { + if u.usesCompactViewport() { + return compactTonedButton(gtx, u.theme, &u.copyURL, "Copy URL") + } + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") + }), + ) +} + +func (u *ui) detailNotesCard(item entry) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return compactCard(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(12), "NOTES") lbl.Color = mutedColor return lbl.Layout(gtx) - }) - }, - layout.Spacer{Height: func() unit.Dp { - if u.state.Section == appstate.SectionRecycleBin { - return unit.Dp(8) - } - return 0 - }()}.Layout, - func(gtx layout.Context) layout.Dimensions { - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / "))), - layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), - layout.Rigid(detailLine(u.theme, "Username", item.Username)), - layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), - layout.Rigid(detailLine(u.theme, "URL", item.URL)), - layout.Rigid(layout.Spacer{Height: sectionGap}.Layout), - layout.Rigid(detailLine(u.theme, "Tags", strings.Join(item.Tags, ", "))), - ) - }) - }, - layout.Spacer{Height: sectionGap}.Layout, - func(gtx layout.Context) layout.Dimensions { - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(u.passwordLine("Password", password)), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.usesCompactViewport() { - return compactTonedButton(gtx, u.theme, &u.copyURL, "Copy URL") - } - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") - }), - ) - }), - ) - }) - }, - layout.Spacer{Height: unit.Dp(8)}.Layout, - func(gtx layout.Context) layout.Dimensions { - return compactCard(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(12), "NOTES") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Body1(u.theme, item.Notes) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - ) - }) - }, - layout.Spacer{Height: cardGap}.Layout, - u.attachmentSummaryPanel, - layout.Spacer{Height: cardGap}.Layout, - u.historyPanel, - layout.Spacer{Height: cardGap}.Layout, - func(gtx layout.Context) layout.Dimensions { - switch u.state.Section { - case appstate.SectionTemplates: - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.editEntry, "Edit Template") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") - }), - ) - case appstate.SectionRecycleBin: - return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry To Vault") - default: - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.editEntry, "Edit") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") - }), - ) - } - }, - } - return []layout.FlexChild{ - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { - return rows[i](gtx) - }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Body1(u.theme, item.Notes) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + } +} + +func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions { + switch u.state.Section { + case appstate.SectionTemplates: + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.editEntry, "Edit Template") }), - } - }()...) + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") + }), + ) + case appstate.SectionRecycleBin: + return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry To Vault") + default: + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.editEntry, "Edit") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") + }), + ) + } } func (u *ui) banner(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/ui_forms.go b/internal/appui/ui_forms.go index 4b3d120..1acd90c 100644 --- a/internal/appui/ui_forms.go +++ b/internal/appui/ui_forms.go @@ -22,92 +22,6 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() showLocalChooser := u.showLocalVaultChooser() selectedLocalPath := strings.TrimSpace(u.vaultPath.Text()) - advancedSection := func(gtx layout.Context) layout.Dimensions { - if busy { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(u.lifecycleAdvancedDisclosure), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.lifecycleAdvancedHidden { - return layout.Dimensions{} - } - if u.lifecycleMode == "remote" { - return layout.Dimensions{} - } - return compactCard(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), "Vault settings") - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings") - }), - ) - }) - }), - ) - } - primaryActionsSection := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - label := "Open Vault" - if busy { - label = "Opening Vault..." - } - if busy { - return passiveTonedButton(gtx, u.theme, label) - } - return tonedButton(gtx, u.theme, &u.openVault, label) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.shouldShowLifecycleRemoteSyncAction() { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy || !u.shouldShowLifecycleRemoteSyncAction() { - return layout.Dimensions{} - } - return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel()) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return passiveSectionTab(gtx, u.theme, "Create New Vault", false) - } - return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false) - }), - ) - } - selectedVaultSection := func(gtx layout.Context) layout.Dimensions { - if busy || selectedLocalPath == "" { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.selectedLocalVaultCard(gtx, selectedLocalPath) - }), - ) - } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT") @@ -123,70 +37,7 @@ 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 { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showLocalChooser { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showLocalChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showLocalChooser || busy { - return 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{} - } - return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showLocalChooser { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !showLocalChooser { - return layout.Dimensions{} - } - return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - switch { - case busy: - return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx) - case selectedLocalPath == "": - return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx) - default: - return layout.Dimensions{} - } - }), - ) + return u.lifecycleVaultChooserSection(gtx, busy, showLocalChooser, selectedLocalPath) }), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -207,20 +58,187 @@ 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.shouldPrioritizeLifecyclePrimaryActions() { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(primaryActionsSection), - layout.Rigid(selectedVaultSection), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(advancedSection), - ) + return u.lifecycleControlsFooter(gtx, busy, selectedLocalPath) + }), + ) +} + +func (u *ui) lifecycleVaultChooserSection(gtx layout.Context, busy, showLocalChooser bool, selectedLocalPath string) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(advancedSection), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(primaryActionsSection), - layout.Rigid(selectedVaultSection), - ) + lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser || busy { + return layout.Dimensions{} + } + return u.recentVaultList(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.lifecycleImportSharedVaultButton(gtx, busy, showLocalChooser) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.lifecycleVaultPathSelector(gtx, busy, selectedLocalPath) + }), + ) +} + +func (u *ui) lifecycleImportSharedVaultButton(gtx layout.Context, busy, showLocalChooser bool) layout.Dimensions { + if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.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.importSharedVault, "Import Shared Vault") + }), + ) +} + +func (u *ui) lifecycleVaultPathSelector(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions { + switch { + case busy: + return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx) + case selectedLocalPath == "": + return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx) + default: + return layout.Dimensions{} + } +} + +func (u *ui) lifecycleControlsFooter(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions { + if u.shouldPrioritizeLifecyclePrimaryActions() { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }), + ) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath) + }), + ) +} + +func (u *ui) lifecycleAdvancedSection(gtx layout.Context, busy bool) layout.Dimensions { + if busy { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.lifecycleAdvancedDisclosure), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(u.lifecycleAdvancedCard), + ) +} + +func (u *ui) lifecycleAdvancedCard(gtx layout.Context) layout.Dimensions { + if u.lifecycleAdvancedHidden || u.lifecycleMode == "remote" { + return layout.Dimensions{} + } + return compactCard(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), "Vault settings") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings") + }), + ) + }) +} + +func (u *ui) lifecyclePrimaryActionsSection(gtx layout.Context, busy bool) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := "Open Vault" + if busy { + return passiveTonedButton(gtx, u.theme, "Opening Vault...") + } + return tonedButton(gtx, u.theme, &u.openVault, label) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy || !u.shouldShowLifecycleRemoteSyncAction() { + return layout.Dimensions{} + } + return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel()) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return passiveSectionTab(gtx, u.theme, "Create New Vault", false) + } + return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false) + }), + ) +} + +func (u *ui) lifecycleSelectedVaultSection(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions { + if busy || selectedLocalPath == "" { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.selectedLocalVaultCard(gtx, selectedLocalPath) }), ) } @@ -229,45 +247,6 @@ func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool { return u.usesCompactViewport() } -func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions { - 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 { - children := []layout.FlexChild{ - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - 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), primary) - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - } - 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) - })) - } - 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" @@ -478,71 +457,6 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions { ) } -func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions { - if len(u.recentRemotes) == 0 { - return layout.Dimensions{} - } - if len(u.recentRemoteClicks) < len(u.recentRemotes) { - u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - maxY := gtx.Dp(unit.Dp(180)) - if gtx.Constraints.Max.Y > maxY { - gtx.Constraints.Max.Y = maxY - } - if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { - gtx.Constraints.Min.Y = gtx.Constraints.Max.Y - } - return material.List(u.theme, &u.recentRemoteListState).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions { - record := u.recentRemotes[i] - label := friendlyRecentRemoteLabel(record) - selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path - return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { - return u.recentRemoteClicks[i].Layout(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, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), label) - 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)) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL)) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(record.LastGroup) == 0 { - return layout.Dimensions{} - } - lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(record.LastGroup), " / ")) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - ) - }) - }) - }) - }) - }) - }), - ) -} - func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions { if !selected { return compactCard(gtx, w) diff --git a/internal/appui/ui_layout_header.go b/internal/appui/ui_layout_header.go index 8e7a75b..31fdf51 100644 --- a/internal/appui/ui_layout_header.go +++ b/internal/appui/ui_layout_header.go @@ -3,14 +3,15 @@ package appui import ( "image" "image/color" - "strings" "gioui.org/layout" "gioui.org/op" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "git.julianfamily.org/keepassgo/internal/appui/actions" appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout" + "git.julianfamily.org/keepassgo/internal/vault" ) func (u *ui) header(gtx layout.Context) layout.Dimensions { @@ -201,216 +202,224 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { if len(u.vaultRemoteCredentialClicks) < len(credentials) { u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials)) } - actionRows := []layout.Widget{ + actionRows := u.syncMenuActionRows(model) + actionWidth := menuActionWidth(gtx, actionRows) + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + rows := u.syncMenuRows(model, profiles, credentials, actionWidth) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) + }) +} + +func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget { + rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }, } if model.ShowShare { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") }) } if model.ShowRemoteSyncSetupShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()) }) } if model.ShowDirectRemoteSyncShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()) }) } if model.ShowRemoteSyncSettingsShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()) }) } if model.ShowRemoveRemoteSyncShortcut() { - actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions { + rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()) }) } - actionWidth := menuActionWidth(gtx, actionRows) - return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - 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 !model.ShowShare { - return layout.Dimensions{} - } + return rows +} + +func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild { + rows := u.syncMenuPrimaryRows(model, actionWidth) + rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...) + if model.ShowSaveCurrentBinding { + rows = append(rows, u.syncMenuSaveBindingRows(model)...) + } + return rows +} + +func (u *ui) syncMenuPrimaryRows(model actions.SyncMenuModel, actionWidth int) []layout.FlexChild { + 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), + } + if model.ShowShare { + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + ) + })) + } + rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync")) + if model.ShowRemoteSyncSetupShortcut() { + rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())) + } + if model.ShowDirectRemoteSyncShortcut() { + rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())) + } + if model.ShowRemoteSyncSettingsShortcut() { + rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())) + } + if model.ShowRemoveRemoteSyncShortcut() { + rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())) + } + return rows +} + +func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild { + return layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, click, label) + }) + }) +} + +func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { + if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 { + return nil + } + rows := []layout.FlexChild{ + 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), model.SavedBindingHeading()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + } + if !model.ShowSelectors { + rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model))) + } else { + rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...) + } + 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()) + }), + ) + } + } + return rows +} + +func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + summary := model.SavedBindingSummary + if !summary.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 { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") - }) + lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel) + 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 rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") - }) - }), - } - if model.ShowRemoteSyncSetupShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()) - }) - }), - ) - } - if model.ShowDirectRemoteSyncShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()) - }) - }), - ) - } - if model.ShowRemoteSyncSettingsShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()) - }) - }), - ) - } - if model.ShowRemoveRemoteSyncShortcut() { - rows = append(rows, - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()) - }) - }), - ) - } - 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), model.SavedBindingHeading()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - ) - if !model.ShowSelectors { - rows = append(rows, + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - summary := model.SavedBindingSummary - if !summary.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), summary.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: "+summary.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), summary.SyncLabel) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - ) - }) - }) + lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.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), summary.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) - }) - }) - }) - }), - ) - } + }) + }) + } +} + +func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexChild { + return []layout.FlexChild{ + 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), model.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, model.SaveCurrentRemoteBindingButtonLabel()) + }), + } +} + +func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { + rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4) + for i, profile := range profiles { + i := i + profile := profile + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := 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 := u.selectedVaultRemoteCredentialEntryID == entry.ID + label := entry.Title + if entry.Username != "" { + label += " · " + entry.Username } - 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 model.ShowSaveCurrentBinding { - 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), model.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, model.SaveCurrentRemoteBindingButtonLabel()) - }), - ) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) - }) + 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) + }) + }) + }) + })) + } + return rows } func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { From ccaee9fa34eeb6efd3ab91552443a0eb6a1c19bd Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 09:20:57 -0700 Subject: [PATCH 40/53] Split app UI layout packages --- internal/appui/app.go | 4134 +---------------- internal/appui/layout/detail/mode.go | 26 + .../appui/layout/{ => header}/dropdown.go | 8 +- internal/appui/layout/list/sections.go | 12 + internal/appui/main_test.go | 37 +- internal/appui/ui_actions_lifecycle.go | 778 ++++ internal/appui/ui_frame.go | 1375 ++++++ internal/appui/ui_layout_header.go | 12 +- internal/appui/ui_recent_state.go | 1787 +++++++ internal/appui/ui_runtime.go | 191 + 10 files changed, 4230 insertions(+), 4130 deletions(-) create mode 100644 internal/appui/layout/detail/mode.go rename internal/appui/layout/{ => header}/dropdown.go (92%) create mode 100644 internal/appui/layout/list/sections.go create mode 100644 internal/appui/ui_actions_lifecycle.go create mode 100644 internal/appui/ui_frame.go create mode 100644 internal/appui/ui_recent_state.go create mode 100644 internal/appui/ui_runtime.go diff --git a/internal/appui/app.go b/internal/appui/app.go index 29aff49..9a438b5 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -1,30 +1,20 @@ package appui import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "flag" "fmt" "image" "image/color" - "io" - "net/url" "os" - "os/exec" "path/filepath" "runtime" "slices" "strings" "time" - "gioui.org/app" "gioui.org/gesture" "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" - "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" @@ -36,14 +26,13 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" + detaillayout "git.julianfamily.org/keepassgo/internal/appui/layout/detail" + listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" "git.julianfamily.org/keepassgo/internal/appui/platform" keepassassets "git.julianfamily.org/keepassgo/internal/assets" - "git.julianfamily.org/keepassgo/internal/autofillcache" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" - "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" - "git.julianfamily.org/keepassgo/internal/webdav" "golang.org/x/exp/shiny/materialdesign/icons" ) @@ -77,17 +66,6 @@ const ( autofillNoticeSuppressed autofillNoticeMode = "suppressed" ) -type listPanelTopSection string - -const ( - listPanelTopSearch listPanelTopSection = "search" - listPanelTopNavigation listPanelTopSection = "navigation" - listPanelTopPath listPanelTopSection = "path" - listPanelTopGroup listPanelTopSection = "group" - listPanelTopGroupTools listPanelTopSection = "group_tools" - listPanelTopPrimary listPanelTopSection = "primary" -) - type bannerKind string const ( @@ -1036,3886 +1014,6 @@ func (u *ui) currentMasterKey() (vault.MasterKey, error) { func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {} -func (u *ui) createVaultAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - if err := u.state.ConfigureSecurity(vault.SecuritySettings{ - Cipher: strings.TrimSpace(u.securityCipher.Text()), - KDF: strings.TrimSpace(u.securityKDF.Text()), - }); err != nil { - return err - } - if err := u.state.CreateVault(key); err != nil { - 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 - } - u.vaultPath.SetText(u.saveAsTargetPath()) - u.noteRecentVault(u.saveAsTargetPath()) - } - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) openVaultAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return errors.New(errVaultPathRequired) - } - if err := u.state.OpenVault(path, key); err != nil { - return err - } - u.noteRecentVault(path) - 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() - u.applyPendingLifecycleOpenIntent() - return nil -} - -func (u *ui) startOpenVaultAction() { - manager, ok := u.state.Session.(*session.Manager) - if !ok { - u.runAction("open vault", u.openVaultAction) - return - } - key, err := u.currentMasterKey() - u.clearMasterPassword() - if err != nil { - u.state.ErrorMessage = u.describeActionError("open vault", err) - u.requestMasterPassFocus = true - return - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired)) - u.requestMasterPassFocus = true - return - } - u.lastLifecycleAction = "open vault" - u.runBackgroundAction("open vault", func() (func() error, error) { - prepared, err := session.PrepareLocalOpen(path, key) - if err != nil { - return nil, err - } - return func() error { - manager.ApplyPreparedLocalOpen(prepared) - u.noteRecentVault(path) - 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() - u.applyPendingLifecycleOpenIntent() - return nil - }, nil - }) -} - -func (u *ui) shouldShowLifecycleRemoteSyncAction() bool { - return strings.TrimSpace(u.vaultPath.Text()) != "" -} - -func (u *ui) lifecycleRemoteSyncActionLabel() string { - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return "Open Vault And Set Up Remote Sync" - } - if hasBoundRecentRemote(u.recentRemotes, path) { - return "Open Vault And Open Remote Sync Settings" - } - return "Open Vault And Set Up Remote Sync" -} - -func (u *ui) beginLifecycleRemoteSyncOpen() { - path := strings.TrimSpace(u.vaultPath.Text()) - switch { - case path == "": - u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone - case hasBoundRecentRemote(u.recentRemotes, path): - u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings - default: - u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup - } - u.startOpenVaultAction() -} - -func (u *ui) applyPendingLifecycleOpenIntent() { - intent := u.pendingLifecycleOpenIntent - u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone - switch intent { - case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings: - u.openRemoteSyncSetupDialog() - } -} - -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 -} - -func (u *ui) saveAsAction() error { - path := u.saveAsTargetPath() - if err := u.state.SaveAs(path); err != nil { - return err - } - u.vaultPath.SetText(path) - u.noteRecentVault(path) - u.filter() - return nil -} - -func (u *ui) openRemoteAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - 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()), - Password: u.remotePassword.Text(), - } - 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()), - ) - u.resetPasswordPeek() - u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) startOpenRemoteAction() { - manager, ok := u.state.Session.(*session.Manager) - if !ok { - u.runAction("open remote vault", u.openRemoteAction) - return - } - key, err := u.currentMasterKey() - u.clearMasterPassword() - if err != nil { - u.state.ErrorMessage = u.describeActionError("open remote vault", err) - u.requestMasterPassFocus = true - return - } - client := webdav.Client{ - BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - Username: strings.TrimSpace(u.remoteUsername.Text()), - Password: u.remotePassword.Text(), - } - 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, - ) - u.resetPasswordPeek() - u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil - }, nil - }) -} - -func (u *ui) lockAction() error { - u.clearMasterPassword() - if err := u.state.Lock(); err != nil { - return err - } - u.requestMasterPassFocus = true - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.resetPasswordPeek() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) unlockAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - if err := u.state.Unlock(key); err != nil { - return err - } - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil -} - -func (u *ui) startUnlockAction() { - manager, ok := u.state.Session.(*session.Manager) - if !ok { - u.runAction("unlock vault", u.unlockAction) - return - } - key, err := u.currentMasterKey() - u.clearMasterPassword() - if err != nil { - u.state.ErrorMessage = u.describeActionError("unlock vault", err) - u.requestMasterPassFocus = true - return - } - encoded := append([]byte(nil), manager.EncodedBytes()...) - u.runBackgroundAction("unlock vault", func() (func() error, error) { - prepared, err := session.PrepareUnlock(encoded, key) - if err != nil { - return nil, err - } - return func() error { - manager.ApplyPreparedUnlock(prepared) - u.resetPasswordPeek() - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.loadSecuritySettingsFromSession() - u.editingEntry = false - u.filter() - return nil - }, nil - }) -} - -func (u *ui) changeMasterKeyAction() error { - key, err := u.currentMasterKey() - defer u.clearMasterPassword() - if err != nil { - return err - } - return u.state.ChangeMasterKey(key) -} - -func (u *ui) loadSecuritySettingsFromSession() { - settings, err := u.state.SecuritySettings() - if err != nil { - return - } - u.securityCipher.SetText(settings.Cipher) - u.securityKDF.SetText(settings.KDF) -} - -func (u *ui) clearMasterPassword() { - u.masterPassword.SetText("") -} - -func (u *ui) synchronizeAction() error { - if err := u.state.Synchronize(); err != nil { - return err - } - u.syncMenuOpen = false - u.filter() - return nil -} - -func (u *ui) openAdvancedSyncDialog() { - u.syncDialogOpen = true - u.syncMenuOpen = false - u.showSyncPassword = false - u.syncDialogList.Position = layout.Position{} - 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.syncDialogList.Position = layout.Position{} - 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() { - u.syncLocalImportName = "" - u.syncLocalImportContent = nil -} - -func (u *ui) selectedSyncLocalImport() (string, []byte, bool) { - name := strings.TrimSpace(u.syncLocalImportName) - if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 { - return "", nil, false - } - return name, append([]byte(nil), u.syncLocalImportContent...), true -} - -func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode { - switch mode { - case syncSourceRemote: - return syncSourceRemote - default: - return syncSourceLocal - } -} - -func sanitizeSyncDirection(direction syncDirection) syncDirection { - switch direction { - case syncDirectionPush: - return syncDirectionPush - default: - return syncDirectionPull - } -} - -func (u *ui) advancedSyncAction() error { - switch u.syncDirection { - case syncDirectionPush: - return u.advancedSyncToAction() - default: - return u.advancedSyncFromAction() - } -} - -func (u *ui) advancedSyncFromAction() error { - switch u.syncSourceMode { - case syncSourceRemote: - client := webdav.Client{ - BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), - Username: strings.TrimSpace(u.syncRemoteUsername.Text()), - Password: u.syncRemotePassword.Text(), - } - if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { - return err - } - default: - if name, content, ok := u.selectedSyncLocalImport(); ok { - if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil { - return err - } - break - } - path := strings.TrimSpace(u.syncLocalPath.Text()) - if path == "" { - return errors.New(errVaultPathRequired) - } - if err := u.state.SynchronizeFromLocal(path); err != nil { - return err - } - } - u.syncDialogOpen = false - u.showSyncPassword = false - u.filter() - return nil -} - -func (u *ui) startChooseSyncLocalSourceAction() { - if runtime.GOOS != "android" || u.fileExplorer == nil { - u.runAction("choose sync path", func() error { - u.clearSyncLocalImport() - return u.chooseExistingFileAction(&u.syncLocalPath) - }) - return - } - u.runBackgroundAction("choose sync file", 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 - } - label := "Selected Android vault" - return func() error { - u.syncLocalImportName = label - u.syncLocalImportContent = append([]byte(nil), content...) - u.syncLocalPath.SetText(label) - return nil - }, nil - }) -} - -func pickedDocumentName(file io.ReadCloser, fallback string) string { - if named, ok := file.(interface{ Name() string }); ok { - if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) { - return base - } - } - fallback = filepath.Base(strings.TrimSpace(fallback)) - if fallback == "" || fallback == "." || fallback == string(filepath.Separator) { - return "selected-vault.kdbx" - } - return fallback -} - -func (u *ui) startChooseVaultPathAction() { - if runtime.GOOS != "android" || u.fileExplorer == nil { - u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) - return - } - u.runBackgroundAction("choose vault file", 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 - } - name := pickedDocumentName(file, "selected-vault.kdbx") - return func() error { - return u.importSharedVaultBytesAction(name, content) - }, nil - }) -} - -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: baseURL, - Username: strings.TrimSpace(u.syncRemoteUsername.Text()), - Password: u.syncRemotePassword.Text(), - } - 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 == "" { - return errors.New(errVaultPathRequired) - } - if err := u.state.SynchronizeToLocal(path); err != nil { - return err - } - } - u.syncDialogOpen = false - u.showSyncPassword = false - u.filter() - 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 != "" { - return path - } - 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 == "" { - return - } - if u.recentVaultGroups == nil { - u.recentVaultGroups = map[string][]string{} - } - if u.recentVaultUsedAt == nil { - u.recentVaultUsedAt = map[string]time.Time{} - } - if len(u.currentPath) > 0 { - u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) - } else if _, ok := u.recentVaultGroups[path]; !ok { - u.recentVaultGroups[path] = nil - } - u.recentVaultUsedAt[path] = u.now() - next := []string{path} - for _, existing := range u.recentVaults { - if existing == path { - continue - } - next = append(next, existing) - if len(next) == 6 { - break - } - } - u.recentVaults = next - if len(u.recentVaultClicks) < len(u.recentVaults) { - u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) - } - u.saveRecentVaults() -} - -func (u *ui) loadRecentVaults() { - if strings.TrimSpace(u.recentVaultsPath) == "" { - return - } - content, err := os.ReadFile(u.recentVaultsPath) - if err != nil { - return - } - u.recentVaults = nil - u.recentVaultGroups = map[string][]string{} - u.recentVaultUsedAt = map[string]time.Time{} - var records []recentVaultRecord - switch { - case json.Unmarshal(content, &records) == nil: - u.applyRecentVaultRecords(records) - return - default: - var paths []string - if err := json.Unmarshal(content, &paths); err != nil { - return - } - records = make([]recentVaultRecord, 0, len(paths)) - for _, path := range paths { - records = append(records, recentVaultRecord{Path: path}) - } - u.applyRecentVaultRecords(records) - } -} - -func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) { - filtered := make([]string, 0, len(records)) - seen := map[string]bool{} - for _, record := range records { - path := strings.TrimSpace(record.Path) - if path == "" || seen[path] { - continue - } - seen[path] = true - filtered = append(filtered, path) - if u.recentVaultGroups == nil { - u.recentVaultGroups = map[string][]string{} - } - if u.recentVaultUsedAt == nil { - u.recentVaultUsedAt = map[string]time.Time{} - } - u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...) - if usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)); err == nil { - u.recentVaultUsedAt[path] = usedAt - } - if len(filtered) == 6 { - break - } - } - u.recentVaults = filtered - if len(u.recentVaultClicks) < len(u.recentVaults) { - u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) - } -} - -func (u *ui) loadRecentRemotes() { - if strings.TrimSpace(u.recentRemotesPath) == "" { - return - } - content, err := os.ReadFile(u.recentRemotesPath) - if err != nil { - return - } - var records []recentRemoteRecord - if err := json.Unmarshal(content, &records); err != nil { - return - } - filtered := make([]recentRemoteRecord, 0, len(records)) - seen := map[string]bool{} - 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 - } - seen[key] = true - record.LastGroup = append([]string(nil), record.LastGroup...) - filtered = append(filtered, record) - if len(filtered) == 6 { - break - } - } - u.recentRemotes = filtered - if len(u.recentRemoteClicks) < len(u.recentRemotes) { - u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) - } -} - -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 - } - if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil { - return - } - records := make([]recentVaultRecord, 0, len(u.recentVaults)) - for _, path := range u.recentVaults { - records = append(records, recentVaultRecord{ - Path: path, - LastGroup: append([]string(nil), u.recentVaultGroups[path]...), - UsedAt: u.recentVaultUsedAt[path].Format(time.RFC3339Nano), - }) - } - content, err := json.MarshalIndent(records, "", " ") - if err != nil { - return - } - _ = os.WriteFile(u.recentVaultsPath, content, 0o600) -} - -func (u *ui) saveRecentRemotes() { - if strings.TrimSpace(u.recentRemotesPath) == "" { - return - } - if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil { - return - } - content, err := json.MarshalIndent(u.recentRemotes, "", " ") - if err != nil { - return - } - _ = os.WriteFile(u.recentRemotesPath, content, 0o600) -} - -func (u *ui) loadUIPreferences() { - if strings.TrimSpace(u.uiPreferencesPath) == "" { - return - } - content, err := os.ReadFile(u.uiPreferencesPath) - if err != nil { - return - } - var prefs uiPreferences - if err := json.Unmarshal(content, &prefs); err != nil { - return - } - u.groupControlsHidden = prefs.GroupControlsHidden - u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden - u.historyHidden = prefs.HistoryHidden - u.denseLayout = prefs.DenseLayout - u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis) - u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode) - displayDensity := strings.TrimSpace(prefs.DisplayDensity) - if displayDensity == "" { - displayDensity = displayDensityForDenseLayout(prefs.DenseLayout) - } - u.applyAccessibilityPreferences(accessibilityPreferences{ - DisplayDensity: displayDensity, - Contrast: prefs.Contrast, - ReducedMotion: prefs.ReducedMotion, - KeyboardFocus: prefs.KeyboardFocus, - }) - if mode := parseAutofillFirstFillApprovalMode(prefs.AutofillPrivacy.FirstFillApprovalMode); mode != "" { - u.autofillFirstFillApprovalMode = mode - } - u.autofillBrowserAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.BrowserAllowlist)) - u.autofillAppAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.AppAllowlist)) - u.autofillPackageRules.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.PackageRules)) -} - -func (u *ui) saveUIPreferences() { - if strings.TrimSpace(u.uiPreferencesPath) == "" { - return - } - if err := os.MkdirAll(filepath.Dir(u.uiPreferencesPath), 0o700); err != nil { - return - } - content, err := json.MarshalIndent(uiPreferences{ - GroupControlsHidden: u.groupControlsHidden, - LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, - HistoryHidden: u.historyHidden, - DenseLayout: u.denseLayout, - StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond), - AutofillNoticeMode: string(u.autofillNoticePreference), - DisplayDensity: u.accessibilityPrefs.DisplayDensity, - Contrast: u.accessibilityPrefs.Contrast, - ReducedMotion: u.accessibilityPrefs.ReducedMotion, - KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, - AutofillPrivacy: autofillPrivacySettings{ - FirstFillApprovalMode: string(u.autofillFirstFillApprovalMode), - BrowserAllowlist: autofillPrivacyLines(u.autofillBrowserAllowlist.Text()), - AppAllowlist: autofillPrivacyLines(u.autofillAppAllowlist.Text()), - PackageRules: autofillPrivacyLines(u.autofillPackageRules.Text()), - }, - }, "", " ") - if err != nil { - return - } - _ = os.WriteFile(u.uiPreferencesPath, content, 0o600) -} - -func (u *ui) loadSettingsFormFromPreferences() { - u.settingsGroupControls.Value = u.groupControlsHidden - u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden - u.settingsHistory.Value = u.historyHidden - u.settingsDenseLayout.Value = u.denseLayout -} - -func (u *ui) applySettingsFormToPreferences() { - u.groupControlsHidden = u.settingsGroupControls.Value - u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value - u.historyHidden = u.settingsHistory.Value - u.denseLayout = u.settingsDenseLayout.Value -} - -func normalizedStatusBannerTTL(valueMillis int) time.Duration { - switch { - case valueMillis <= 0: - return statusBannerDuration - case time.Duration(valueMillis)*time.Millisecond > statusBannerLong: - return statusBannerLong - default: - return time.Duration(valueMillis) * time.Millisecond - } -} - -func normalizedAutofillNoticeMode(value string) autofillNoticeMode { - switch autofillNoticeMode(strings.TrimSpace(value)) { - case autofillNoticeApprovals: - return autofillNoticeApprovals - case autofillNoticeSuppressed: - return autofillNoticeSuppressed - default: - return autofillNoticeAll - } -} - -func parseAutofillFirstFillApprovalMode(raw string) autofillFirstFillApprovalMode { - switch autofillFirstFillApprovalMode(strings.TrimSpace(raw)) { - case autofillFirstFillApprovalAsk, autofillFirstFillApprovalAllow, autofillFirstFillApprovalBlock: - return autofillFirstFillApprovalMode(strings.TrimSpace(raw)) - default: - return "" - } -} - -func autofillPrivacyLines(text string) []string { - lines := strings.Split(text, "\n") - result := make([]string, 0, len(lines)) - seen := make(map[string]struct{}, len(lines)) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if _, ok := seen[line]; ok { - continue - } - seen[line] = struct{}{} - result = append(result, line) - } - return result -} - -func joinAutofillPrivacyLines(lines []string) string { - if len(lines) == 0 { - return "" - } - return strings.Join(autofillPrivacyLines(strings.Join(lines, "\n")), "\n") -} - -func (u *ui) autofillRuleCount() int { - return len(autofillPrivacyLines(u.autofillBrowserAllowlist.Text())) + - len(autofillPrivacyLines(u.autofillAppAllowlist.Text())) + - len(autofillPrivacyLines(u.autofillPackageRules.Text())) -} - -func (u *ui) autofillFirstFillApprovalSummary() string { - switch u.autofillFirstFillApprovalMode { - case autofillFirstFillApprovalAllow: - return "New apps and packages can fill immediately until a persistent rule is created." - case autofillFirstFillApprovalBlock: - return "New apps and packages stay blocked until you add an allowlist entry or a package rule." - default: - return "KeePassGO asks before the first fill into a newly seen app or package." - } -} - -func (u *ui) setStatusBannerTTL(value time.Duration) { - u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond)) - u.saveUIPreferences() -} - -func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) { - u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value)) - u.saveUIPreferences() -} - -func (u *ui) noteRecentRemote(baseURL, path string) { - baseURL = strings.TrimSpace(baseURL) - path = strings.TrimSpace(path) - if baseURL == "" || path == "" { - return - } - record := recentRemoteRecord{ - BaseURL: baseURL, - Path: path, - 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) - } - next := []recentRemoteRecord{record} - for _, existing := range u.recentRemotes { - if existing.BaseURL == baseURL && existing.Path == path { - continue - } - next = append(next, existing) - if len(next) == 6 { - break - } - } - u.recentRemotes = next - if len(u.recentRemoteClicks) < len(u.recentRemotes) { - u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) - } - u.saveRecentRemotes() -} - -func (u *ui) recentRemoteGroup(baseURL, path string) []string { - baseURL = strings.TrimSpace(baseURL) - path = strings.TrimSpace(path) - for _, record := range u.recentRemotes { - if record.BaseURL == baseURL && record.Path == path { - return append([]string(nil), record.LastGroup...) - } - } - return nil -} - -func (u *ui) restoreStartupLifecycleTarget() { - localPath, localUsedAt := u.latestRecentVault() - remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote() - - switch { - 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) - } -} - -func (u *ui) hasSelectedLifecycleTarget() bool { - switch strings.TrimSpace(u.lifecycleMode) { - case "remote": - return u.hasSelectedRemoteTarget() - default: - return strings.TrimSpace(u.vaultPath.Text()) != "" - } -} - -func (u *ui) hasSelectedRemoteTarget() bool { - return u.selectedRemoteConnection -} - -func (u *ui) latestRecentVault() (string, time.Time) { - for _, path := range u.recentVaults { - if strings.TrimSpace(path) == "" { - continue - } - return path, u.recentVaultUsedAt[path] - } - return "", time.Time{} -} - -func (u *ui) hasSelectedVaultPath() bool { - return strings.TrimSpace(u.vaultPath.Text()) != "" -} - -func (u *ui) showLocalVaultChooser() bool { - return u.lifecycleMode != "local" || !u.hasSelectedVaultPath() -} - -func (u *ui) showRemoteConnectionChooser() bool { - return u.lifecycleMode != "remote" || !u.hasSelectedRemoteTarget() -} - -func (u *ui) switchToLifecycleSelection(mode string) { - u.state.Session = &session.Manager{} - u.state.CurrentPath = nil - u.state.SelectedEntryID = "" - u.state.Section = appstate.SectionEntries - u.state.Dirty = false - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.loadingMessage = "" - u.loadingActionLabel = "" - u.lastLifecycleAction = "" - u.lifecycleMode = mode - u.editingEntry = false - u.currentPath = nil - u.syncedPath = nil - u.clearMasterPassword() - u.keyFilePath.SetText("") - u.search.SetText("") - switch mode { - case "remote": - u.vaultPath.SetText("") - u.remoteBaseURL.SetText("") - u.remotePath.SetText("") - u.remoteUsername.SetText("") - u.remotePassword.SetText("") - u.selectedRemoteConnection = false - default: - u.vaultPath.SetText("") - u.remoteBaseURL.SetText("") - u.remotePath.SetText("") - u.remoteUsername.SetText("") - u.remotePassword.SetText("") - u.selectedRemoteConnection = false - } - u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() - u.filter() -} - -func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { - for _, record := range u.recentRemotes { - if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" { - continue - } - usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)) - if err != nil { - usedAt = time.Time{} - } - return record, true, usedAt - } - return recentRemoteRecord{}, false, time.Time{} -} - -func (u *ui) currentRemoteRecord() recentRemoteRecord { - return recentRemoteRecord{ - BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - Path: strings.TrimSpace(u.remotePath.Text()), - } -} - -func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { - u.remoteBaseURL.SetText(record.BaseURL) - u.remotePath.SetText(record.Path) - 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.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 { - switch { - case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": - 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: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." - } -} - -func (u *ui) remotePreferencesAlwaysSavedSummary() string { - 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. 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 remoteCredentialURLMatches(candidate, target string) bool { - candidate = normalizeRemoteCredentialURL(candidate) - target = normalizeRemoteCredentialURL(target) - if candidate == "" || target == "" { - return false - } - if candidate == target { - return true - } - candidateURL, err := url.Parse(candidate) - if err != nil { - return false - } - targetURL, err := url.Parse(target) - if err != nil { - return false - } - if !strings.EqualFold(candidateURL.Hostname(), targetURL.Hostname()) { - return false - } - candidatePath := strings.TrimRight(candidateURL.EscapedPath(), "/") - targetPath := strings.TrimRight(targetURL.EscapedPath(), "/") - if candidatePath == "" || candidatePath == "/" || targetPath == "" || targetPath == "/" { - return true - } - return strings.HasPrefix(targetPath, candidatePath) || strings.HasPrefix(candidatePath, targetPath) -} - -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 := u.remoteCredentialEntryMap(entries) - matches := make([]vault.Entry, 0, len(entries)) - seen := make(map[string]struct{}, len(entries)) - appendMatch := func(entry vault.Entry) { - u.appendRemoteCredentialMatch(&matches, seen, entry) - } - u.appendURLMatchedRemoteCredentials(baseURL, entries, appendMatch) - profilesByID := u.remoteProfileMap() - 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 !remoteCredentialURLMatches(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) validRemoteProfileSelection(profiles []vault.RemoteProfile) string { - selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID) - if u.hasRemoteProfileSelection(selectedID, profiles) { - return selectedID - } - if len(profiles) == 1 { - return profiles[0].ID - } - return "" -} - -func (u *ui) validRemoteCredentialSelection(entries []vault.Entry) string { - selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) - if u.hasRemoteCredentialSelection(selectedID, entries) { - return selectedID - } - if len(entries) == 1 { - return entries[0].ID - } - return "" -} - -func (u *ui) hasRemoteProfileSelection(selectedID string, profiles []vault.RemoteProfile) bool { - for _, profile := range profiles { - if profile.ID == selectedID { - return true - } - } - return false -} - -func (u *ui) hasRemoteCredentialSelection(selectedID string, entries []vault.Entry) bool { - for _, entry := range entries { - if entry.ID == selectedID { - return true - } - } - return false -} - -func (u *ui) applySelectedRemoteProfileFields() { - if profile, ok := u.selectedVaultRemoteProfile(); ok { - u.remoteBaseURL.SetText(profile.BaseURL) - u.remotePath.SetText(profile.Path) - } -} - -func (u *ui) syncRecentRemoteBindingSelection() { - if strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" { - return - } - record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())) - if !ok { - return - } - u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) - u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) - u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) - u.applySelectedRemoteProfileFields() -} - -func (u *ui) syncSelectedRemoteBindingMode() { - binding, ok := u.selectedVaultRemoteBinding() - if !ok { - u.selectedVaultRemoteSyncMode = appstate.SyncModeManual - return - } - for _, record := range u.recentRemotes { - if strings.TrimSpace(record.LocalVaultPath) == strings.TrimSpace(binding.LocalVaultPath) && - strings.TrimSpace(record.RemoteProfileID) == strings.TrimSpace(binding.RemoteProfileID) && - strings.TrimSpace(record.CredentialEntryID) == strings.TrimSpace(binding.CredentialEntryID) { - u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) - return - } - } - u.selectedVaultRemoteSyncMode = appstate.SyncModeManual -} - -func (u *ui) remoteCredentialEntryMap(entries []vault.Entry) map[string]vault.Entry { - byID := make(map[string]vault.Entry, len(entries)) - for _, entry := range entries { - byID[entry.ID] = entry - } - return byID -} - -func (u *ui) remoteProfileMap() map[string]vault.RemoteProfile { - profilesByID := make(map[string]vault.RemoteProfile) - for _, profile := range u.availableRemoteProfiles() { - profilesByID[profile.ID] = profile - } - return profilesByID -} - -func (u *ui) appendRemoteCredentialMatch(matches *[]vault.Entry, seen map[string]struct{}, entry vault.Entry) { - if strings.TrimSpace(entry.ID) == "" { - return - } - if _, ok := seen[entry.ID]; ok { - return - } - seen[entry.ID] = struct{}{} - *matches = append(*matches, entry) -} - -func (u *ui) appendURLMatchedRemoteCredentials(baseURL string, entries []vault.Entry, appendMatch func(vault.Entry)) { - for _, entry := range entries { - if remoteCredentialURLMatches(entry.URL, baseURL) { - appendMatch(entry) - } - } -} - -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() - u.selectedVaultRemoteProfileID = u.validRemoteProfileSelection(profiles) - u.selectedVaultRemoteCredentialEntryID = u.validRemoteCredentialSelection(entries) - u.applySelectedRemoteProfileFields() - u.syncRecentRemoteBindingSelection() - u.syncSelectedRemoteBindingMode() -} - -func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { - path = strings.TrimSpace(path) - if path == "" { - return recentRemoteRecord{}, false - } - return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path) -} - -func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool { - _, ok := boundRecentRemoteForLocalVaultRecords(records, strings.TrimSpace(path)) - return ok -} - -func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) { - var matches []recentRemoteRecord - for _, record := range records { - 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) { - summary := u.computeSavedRemoteBindingSummary() - return summary.ProfileLabel, summary.CredentialLabel, summary.SyncLabel, summary.OK -} - -func (u *ui) savedRemoteBindingHeading() string { - return u.buildSyncMenuModel().SavedBindingHeading() -} - -func (u *ui) openSelectedVaultRemoteButtonLabel() string { - return u.buildSyncMenuModel().OpenSelectedButtonLabel() -} - -func (u *ui) shouldShowDirectRemoteSyncShortcut() bool { - if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { - return false - } - return u.buildSyncMenuModel().ShowDirectRemoteSyncShortcut() -} - -func (u *ui) directRemoteSyncShortcutLabel() string { - return u.buildSyncMenuModel().DirectRemoteSyncShortcutLabel() -} - -func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool { - if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { - return false - } - return u.buildSyncMenuModel().ShowRemoteSyncSettingsShortcut() -} - -func (u *ui) remoteSyncSettingsShortcutLabel() string { - return u.buildSyncMenuModel().RemoteSyncSettingsShortcutLabel() -} - -func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool { - return u.buildSyncMenuModel().ShowRemoveRemoteSyncShortcut() -} - -func (u *ui) removeRemoteSyncShortcutLabel() string { - return u.buildSyncMenuModel().RemoveRemoteSyncShortcutLabel() -} - -func (u *ui) shouldShowRemoteSyncSetupShortcut() bool { - if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { - return false - } - return u.buildSyncMenuModel().ShowRemoteSyncSetupShortcut() -} - -func (u *ui) remoteSyncSetupShortcutLabel() string { - return u.buildSyncMenuModel().RemoteSyncSetupShortcutLabel() -} - -func (u *ui) syncMenuActionLabels() []string { - return u.buildSyncMenuModel().ActionLabels() -} - -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 u.buildSyncMenuModel().SaveCurrentRemoteBindingHeading() -} - -func (u *ui) saveCurrentRemoteBindingButtonLabel() string { - return u.buildSyncMenuModel().SaveCurrentRemoteBindingButtonLabel() -} - -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() { - status, ok := u.state.Session.(sessionStatus) - if !ok || !status.IsRemote() || status.IsLocked() { - return - } - baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) - path := strings.TrimSpace(u.remotePath.Text()) - if baseURL == "" || path == "" { - return - } - for i := range u.recentRemotes { - if u.recentRemotes[i].BaseURL != baseURL || u.recentRemotes[i].Path != path { - continue - } - u.recentRemotes[i].LastGroup = append([]string(nil), u.currentPath...) - u.saveRecentRemotes() - return - } -} - -func (u *ui) recentVaultGroup(path string) []string { - if u.recentVaultGroups == nil { - return nil - } - return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...) -} - -func (u *ui) hiddenVaultRoot() string { - if u.state.Section != appstate.SectionEntries { - return "" - } - model, err := u.state.Session.Current() - if err != nil { - return "" - } - if len(model.EntriesInPath(nil)) != 0 { - return "" - } - groups := model.ChildGroups(nil) - if len(groups) != 1 { - return "" - } - return groups[0] -} - -func (u *ui) enterHiddenVaultRoot() { - root := u.hiddenVaultRoot() - if root == "" { - return - } - u.setCurrentPath([]string{root}) -} - -func (u *ui) restoreRecentVaultGroup(path string) { - saved := u.recentVaultGroup(path) - if len(saved) == 0 { - u.enterHiddenVaultRoot() - return - } - model, err := u.state.Session.Current() - if err != nil { - u.enterHiddenVaultRoot() - return - } - root := u.hiddenVaultRoot() - if len(saved) == 1 && root != "" && saved[0] == root { - u.setCurrentPath(saved) - return - } - if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { - u.setCurrentPath(saved) - return - } - u.enterHiddenVaultRoot() -} - -func (u *ui) restoreRecentRemoteGroup(baseURL, path string) { - saved := u.recentRemoteGroup(baseURL, path) - if len(saved) == 0 { - u.enterHiddenVaultRoot() - return - } - model, err := u.state.Session.Current() - if err != nil { - u.enterHiddenVaultRoot() - return - } - root := u.hiddenVaultRoot() - if len(saved) == 1 && root != "" && saved[0] == root { - u.setCurrentPath(saved) - return - } - if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { - u.setCurrentPath(saved) - return - } - u.enterHiddenVaultRoot() -} - -func (u *ui) restoreEntriesPath(path []string) { - if len(path) == 0 { - u.enterHiddenVaultRoot() - return - } - model, err := u.state.Session.Current() - if err != nil { - u.enterHiddenVaultRoot() - return - } - root := u.hiddenVaultRoot() - if len(path) == 1 && root != "" && path[0] == root { - u.setCurrentPath(path) - return - } - if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { - u.setCurrentPath(path) - return - } - u.enterHiddenVaultRoot() -} - -func (u *ui) rememberEntriesSectionState() { - if u.state.Section != appstate.SectionEntries { - return - } - u.entriesState = entriesSectionState{ - Path: append([]string(nil), u.currentPath...), - SearchQuery: u.search.Text(), - SelectedEntryID: u.state.SelectedEntryID, - Editing: u.editingEntry, - } -} - -func (u *ui) restoreEntriesSectionState() { - u.search.SetText(u.entriesState.SearchQuery) - u.restoreEntriesPath(u.entriesState.Path) - u.state.SelectedEntryID = u.entriesState.SelectedEntryID - u.editingEntry = u.entriesState.Editing && strings.TrimSpace(u.entriesState.SelectedEntryID) != "" - if u.editingEntry || strings.TrimSpace(u.state.SelectedEntryID) != "" { - u.loadSelectedEntryIntoEditor() - } -} - -func (u *ui) displayPath() []string { - path := append([]string(nil), u.currentPath...) - root := u.hiddenVaultRoot() - if root == "" || len(path) == 0 || path[0] != root { - return path - } - return append([]string(nil), path[1:]...) -} - -func (u *ui) displayEntryPath(path []string) []string { - root := u.hiddenVaultRoot() - if root == "" || len(path) == 0 || path[0] != root { - return append([]string(nil), path...) - } - return append([]string(nil), path[1:]...) -} - -func (u *ui) currentGroupDisplayName() string { - displayPath := u.displayPath() - if len(displayPath) == 0 { - return "Vault root (/)" - } - return strings.Join(displayPath, " / ") -} - -func (u *ui) parentGroupDisplayName() string { - displayPath := u.displayPath() - if len(displayPath) <= 1 { - return "Vault root (/)" - } - return strings.Join(displayPath[:len(displayPath)-1], " / ") -} - -func (u *ui) createGroupLabel() string { - if len(u.displayPath()) == 0 { - return "Create Top-Level Group" - } - return "Create Subgroup" -} - -func pathHasPrefix(path, prefix []string) bool { - if len(prefix) > len(path) { - return false - } - return slices.Equal(path[:len(prefix)], prefix) -} - -func hasExactGroup(model vault.Model, path []string) bool { - for _, group := range model.Groups { - if slices.Equal(group, path) { - return true - } - } - return false -} - -func (u *ui) currentGroupDeletionState() (bool, string) { - u.syncCurrentPath() - if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil { - return false, "" - } - model, err := u.state.Session.Current() - if err != nil { - return false, "" - } - path := append([]string(nil), u.currentPath...) - if len(model.ChildGroups(path)) > 0 { - return false, "This group contains child groups. Move or delete them before removing the group." - } - for _, item := range model.Entries { - if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { - return false, "This group contains entries. Move or delete them before removing the group." - } - } - for _, item := range model.Templates { - if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { - return false, "This group contains templates. Move or delete them before removing the group." - } - } - return true, "Deleting this empty group will not remove any entries." -} - -func (u *ui) deleteGroupPendingConfirmation() bool { - return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) -} - -func (u *ui) clearDeleteGroupConfirmation() { - u.deleteGroupPath = nil -} - -func (u *ui) armDeleteCurrentGroupAction() { - if deletable, _ := u.currentGroupDeletionState(); !deletable { - return - } - u.syncCurrentPath() - u.deleteGroupPath = append([]string(nil), u.currentPath...) - u.state.ErrorMessage = "" - u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))) -} - -func (u *ui) runAction(label string, action func() error) { - if strings.TrimSpace(u.loadingMessage) != "" { - return - } - u.loadingMessage = actionLoadingLabel(label) - u.loadingActionLabel = strings.TrimSpace(label) - if err := action(); err != nil { - u.loadingMessage = "" - u.loadingActionLabel = "" - u.state.ErrorMessage = u.describeActionError(label, err) - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return - } - u.loadingMessage = "" - u.loadingActionLabel = "" - u.syncAutofillCache() - u.state.ErrorMessage = "" - if suppressStatusMessage(label) { - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return - } - u.showStatusMessage(label + " complete") -} - -func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { - if strings.TrimSpace(u.loadingMessage) != "" { - return - } - u.backgroundActionSerial++ - actionID := u.backgroundActionSerial - u.activeBackgroundAction = actionID - u.loadingMessage = actionLoadingLabel(label) - u.loadingActionLabel = strings.TrimSpace(label) - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - go func() { - apply, err := prepare() - u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err, id: actionID} - if u.invalidate != nil { - u.invalidate() - } - }() -} - -func (u *ui) applyBackgroundResult(result backgroundActionResult) { - if result.id != 0 && result.id != u.activeBackgroundAction { - return - } - u.activeBackgroundAction = 0 - u.loadingMessage = "" - u.loadingActionLabel = "" - if result.err != nil { - u.state.ErrorMessage = u.describeActionError(result.label, result.err) - if strings.HasPrefix(result.label, "open ") { - u.requestMasterPassFocus = true - } - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return - } - if result.apply != nil { - if err := result.apply(); err != nil { - u.state.ErrorMessage = u.describeActionError(result.label, err) - if strings.HasPrefix(result.label, "open ") { - u.requestMasterPassFocus = true - } - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return - } - } - u.syncAutofillCache() - u.state.ErrorMessage = "" - if suppressStatusMessage(result.label) { - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return - } - u.showStatusMessage(result.label + " complete") -} - -func (u *ui) cancelLifecycleBusyState() { - if !u.lifecycleBusy() { - return - } - u.activeBackgroundAction = 0 - u.loadingMessage = "" - u.loadingActionLabel = "" - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - u.requestMasterPassFocus = true -} - -func (u *ui) retryLastLifecycleOpen() { - switch strings.TrimSpace(u.lastLifecycleAction) { - case "open vault": - u.startOpenVaultAction() - case "open remote vault": - u.startOpenRemoteAction() - } -} - -func (u *ui) canRetryLifecycleOpen() bool { - if !u.shouldShowLifecycleSetup() || u.lifecycleBusy() || strings.TrimSpace(u.state.ErrorMessage) == "" { - return false - } - switch strings.TrimSpace(u.lastLifecycleAction) { - case "open vault", "open remote vault": - return true - default: - return false - } -} - -func (u *ui) processBackgroundActions() { - for { - select { - case result := <-u.backgroundResults: - u.applyBackgroundResult(result) - default: - return - } - } -} - -func (u *ui) syncAutofillCache() { - if strings.TrimSpace(u.autofillCachePath) == "" { - return - } - model, err := u.state.Session.Current() - if err != nil { - _ = autofillcache.Clear(u.autofillCachePath) - return - } - _ = autofillcache.Write(u.autofillCachePath, model, u.now()) -} - -func suppressStatusMessage(label string) bool { - switch strings.TrimSpace(label) { - case "open vault", "open remote vault": - return true - default: - return false - } -} - -func actionLoadingLabel(label string) string { - label = strings.TrimSpace(label) - if label == "" { - return "Working..." - } - runes := []rune(label) - runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] - return string(runes) + "..." -} - -func (u *ui) describeActionError(label string, err error) string { - if err == nil { - return "" - } - if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) { - return "Save conflict: the remote vault changed. Reopen it and retry the save." - } - if label == "open remote vault" { - return fmt.Sprintf("%s failed: %v", label, err) - } - return err.Error() -} - -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(): - if u.selectedRemoteUsesLocalCache() { - return "Opening Cached Vault..." - } - return "Creating Local Cache..." - case u.remoteOpenRetryAvailable(): - if u.selectedRemoteUsesLocalCache() { - return "Retry Cached Vault" - } - return "Retry Local Cache Setup" - default: - 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) != "": - return uiBanner{ - Kind: bannerLoading, - Message: strings.TrimSpace(u.loadingMessage), - Detail: u.loadingDetailMessage(), - } - case strings.TrimSpace(u.state.ErrorMessage) != "": - return uiBanner{ - Kind: bannerError, - Message: strings.TrimSpace(u.state.ErrorMessage), - Dismissable: true, - } - default: - return uiBanner{} - } -} - -func (u *ui) statusToastSurface() uiBanner { - if strings.TrimSpace(u.state.StatusMessage) == "" { - return uiBanner{} - } - if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - return uiBanner{} - } - return uiBanner{ - Kind: bannerStatus, - Message: strings.TrimSpace(u.state.StatusMessage), - } -} - -func (u *ui) autofillStatusSurface() uiAutofillStatus { - if u.autofillNoticePreference == autofillNoticeSuppressed { - return uiAutofillStatus{} - } - if request, ok := u.pendingAutofillApproval(); ok { - detail := approvalResourceText(request) - if strings.TrimSpace(detail) == "" { - detail = "Review the request to allow or deny this fill attempt." - } - return uiAutofillStatus{ - Kind: autofillStatusAwaitingApproval, - Title: "Autofill approval needed", - Message: formatAutofillRequester(request.ClientName, request.TokenName) + " is waiting to fill credentials.", - Detail: detail, - } - } - if u.auditLog == nil { - return uiAutofillStatus{} - } - if u.autofillNoticePreference == autofillNoticeApprovals { - return uiAutofillStatus{} - } - for _, event := range u.auditLog.Events() { - if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { - return status - } - } - return uiAutofillStatus{} -} - -func (u *ui) pendingAutofillApproval() (apiapproval.Request, bool) { - for _, request := range u.state.PendingApprovals() { - if isAutofillOperation(request.Operation) { - return request, true - } - } - return apiapproval.Request{}, false -} - -func autofillStatusFromAuditEvent(event apiaudit.Event, now time.Time) (uiAutofillStatus, bool) { - if !event.At.IsZero() && !now.Before(event.At) && now.Sub(event.At) > autofillStatusTTL { - return uiAutofillStatus{}, false - } - - requester := formatAutofillRequester(event.ClientName, event.TokenName) - switch event.Type { - case apiaudit.EventAutofillFound: - return uiAutofillStatus{ - Kind: autofillStatusFound, - Title: "Autofill match ready", - Message: defaultAutofillMessage(event.Message, requester+" found a credential to fill."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventAutofillAmbiguous: - return uiAutofillStatus{ - Kind: autofillStatusAmbiguous, - Title: "Autofill needs a narrower match", - Message: defaultAutofillMessage(event.Message, requester+" found more than one matching credential."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventAutofillBlocked: - return uiAutofillStatus{ - Kind: autofillStatusBlocked, - Title: "Autofill is blocked", - Message: defaultAutofillMessage(event.Message, requester+" could not fill this target."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventApprovalAllowed: - if !isAutofillOperation(event.Operation) { - return uiAutofillStatus{}, false - } - return uiAutofillStatus{ - Kind: autofillStatusFound, - Title: "Autofill approved", - Message: defaultAutofillMessage(event.Message, requester+" can fill this target now."), - Detail: autofillEventDetail(event), - }, true - case apiaudit.EventApprovalDenied, apiaudit.EventApprovalCanceled, apiaudit.EventApprovalTimedOut: - if !isAutofillOperation(event.Operation) { - return uiAutofillStatus{}, false - } - return uiAutofillStatus{ - Kind: autofillStatusBlocked, - Title: "Autofill was not allowed", - Message: defaultAutofillMessage(event.Message, autofillBlockedMessage(event.Type, requester)), - Detail: autofillEventDetail(event), - }, true - default: - return uiAutofillStatus{}, false - } -} - -func autofillEventDetail(event apiaudit.Event) string { - return strings.TrimSpace(resourceDetailText(event.Resource)) -} - -func resourceDetailText(resource apitokens.Resource) string { - switch resource.Kind { - case apitokens.ResourceEntry: - if entryID := strings.TrimSpace(resource.EntryID); entryID != "" { - return "Entry ID: " + entryID - } - case apitokens.ResourceGroup: - if len(resource.Path) > 0 { - return "Group: " + strings.Join(resource.Path, " / ") - } - } - return "" -} - -func formatAutofillRequester(clientName, tokenName string) string { - switch { - case strings.TrimSpace(clientName) != "" && strings.TrimSpace(tokenName) != "": - return strings.TrimSpace(clientName) + " (" + strings.TrimSpace(tokenName) + ")" - case strings.TrimSpace(clientName) != "": - return strings.TrimSpace(clientName) - case strings.TrimSpace(tokenName) != "": - return strings.TrimSpace(tokenName) - default: - return "A trusted client" - } -} - -func defaultAutofillMessage(value, fallback string) string { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - return fallback -} - -func autofillBlockedMessage(eventType apiaudit.EventType, requester string) string { - switch eventType { - case apiaudit.EventApprovalDenied: - return requester + " was denied for this fill request." - case apiaudit.EventApprovalCanceled: - return requester + " canceled this fill request." - case apiaudit.EventApprovalTimedOut: - return requester + " timed out while waiting for approval." - default: - return requester + " could not fill this target." - } -} - -func isAutofillOperation(operation apitokens.Operation) bool { - switch operation { - case apitokens.OperationReadEntry, apitokens.OperationCopyUsername, apitokens.OperationCopyPassword, apitokens.OperationCopyURL: - return true - default: - return false - } -} - -func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) { - if !u.shouldShowLifecycleSetup() { - if banner.Dismissable { - return "", "Dismiss" - } - return "", "" - } - switch banner.Kind { - case bannerLoading: - if strings.HasPrefix(u.loadingActionLabel, "open ") { - return "Cancel", "" - } - case bannerError: - if u.canRetryLifecycleOpen() { - return "Retry", "Dismiss" - } - if banner.Dismissable { - return "", "Dismiss" - } - } - return "", "" -} - -func (u *ui) loadingDetailMessage() string { - if !u.shouldShowLifecycleSetup() { - return "" - } - if u.lifecycleMode == "remote" { - baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) - path := strings.TrimSpace(u.remotePath.Text()) - switch { - case baseURL != "" && path != "": - return fmt.Sprintf( - "Target: %s (%s)", - friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), - path, - ) - case baseURL != "": - return "Target: " + baseURL - default: - return "Preparing remote vault access" - } - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return "Preparing local vault access" - } - return "Target: " + path -} - -func (u *ui) currentVaultSummary() vaultSummary { - status, ok := u.state.Session.(sessionStatus) - if !ok || !status.HasVault() { - return vaultSummary{} - } - if status.IsRemote() { - baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) - path := strings.TrimSpace(u.remotePath.Text()) - summary := vaultSummary{ - Title: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), - Detail: baseURL, - } - if strings.TrimSpace(summary.Title) == "" { - summary.Title = "Remote vault" - } - summary.Context = u.vaultResumeContext(u.recentRemoteGroup(baseURL, path)) - return summary - } - path := strings.TrimSpace(u.vaultPath.Text()) - summary := vaultSummary{ - Title: friendlyRecentVaultLabel(path), - Detail: path, - } - if strings.TrimSpace(summary.Title) == "" { - summary.Title = "Local vault" - } - summary.Context = u.vaultResumeContext(u.recentVaultGroup(path)) - return summary -} - -func (u *ui) vaultResumeContext(path []string) string { - if len(path) == 0 { - return "" - } - displayPath := append([]string(nil), path...) - if len(displayPath) == 0 { - return "" - } - return "Resume in: " + strings.Join(displayPath, " / ") -} - -func compactPathDirectorySummary(path string) string { - cleaned := filepath.Clean(strings.TrimSpace(path)) - if cleaned == "." || cleaned == "" { - return "" - } - dir := filepath.Dir(cleaned) - if dir == "." || dir == cleaned { - return "" - } - if dir == string(filepath.Separator) { - return dir - } - parts := strings.Split(filepath.ToSlash(dir), "/") - filtered := parts[:0] - for _, part := range parts { - if strings.TrimSpace(part) != "" { - filtered = append(filtered, part) - } - } - parts = filtered - if len(parts) <= 2 { - return filepath.ToSlash(dir) - } - return parts[0] + "/.../" + parts[len(parts)-1] -} - -func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) { - if !u.requestMasterPassFocus { - return - } - gtx.Execute(key.FocusCmd{Tag: &u.masterPassword}) - gtx.Execute(op.InvalidateCmd{}) - u.requestMasterPassFocus = false -} - -func (u *ui) sessionSurface() uiSurface { - if u.state.Session == nil { - return uiSurface{} - } - if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) { - return uiSurface{ - Title: "Vault locked", - Message: "Enter a master password, choose a key file, or provide both to unlock the vault.", - Locked: true, - } - } - return uiSurface{} -} - -func (u *ui) hasOpenVault() bool { - status, ok := u.state.Session.(sessionStatus) - if ok { - return status.HasVault() - } - _, err := u.state.Session.Current() - return err == nil -} - -func (u *ui) isVaultLocked() bool { - status, ok := u.state.Session.(sessionStatus) - if ok { - return status.IsLocked() - } - _, err := u.state.Session.Current() - return errors.Is(err, session.ErrLocked) -} - -func (u *ui) shouldShowLifecycleSetup() bool { - return !u.hasOpenVault() -} - -func (u *ui) lifecycleBusy() bool { - return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" -} - -func (u *ui) updateViewportLayoutMode(gtx layout.Context) { - u.viewportMeasured = true - u.compactViewport = gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) -} - -func (u *ui) usesCompactViewport() bool { - if u.viewportMeasured { - return u.compactViewport - } - return u.mode == "phone" -} - -func (u *ui) shouldUseLockedSinglePane() bool { - return u.isVaultLocked() && !u.shouldShowLifecycleSetup() -} - -func (u *ui) shouldShowDesktopWorkingHeader() bool { - return !u.usesCompactViewport() && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() -} - -func (u *ui) shouldUseCompactPhoneDetailPane() bool { - if !u.usesCompactViewport() { - return false - } - if u.isVaultLocked() || u.editingEntry { - return false - } - _, ok := u.selectedEntry() - return !ok -} - -func (u *ui) chooseExistingFileAction(target *widget.Editor) error { - path, err := pickExistingFile() - if err != nil { - return err - } - target.SetText(path) - return nil -} - -func (u *ui) listEmptyMessage() string { - return u.listEmptyState().Body -} - -func (u *ui) listEmptyState() emptyState { - if surface := u.sessionSurface(); surface.Locked { - return emptyState{ - Title: "Vault locked", - Body: "Unlock the vault to browse entries and groups.", - } - } - query := strings.TrimSpace(u.search.Text()) - if query != "" { - switch u.state.Section { - case appstate.SectionAPITokens: - return emptyState{ - Title: "No matching API tokens", - Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query), - } - case appstate.SectionAPIAudit: - return emptyState{ - Title: "No matching audit events", - Body: fmt.Sprintf("No audit events match %q. Clear the search or try a different quick filter.", query), - } - case appstate.SectionTemplates: - return emptyState{ - Title: "No matching templates", - Body: fmt.Sprintf("No templates match %q. Clear or refine Search vault.", query), - } - case appstate.SectionRecycleBin: - return emptyState{ - Title: "No matching deleted entries", - Body: fmt.Sprintf("No recycle-bin entries match %q. Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.", query), - } - default: - return emptyState{ - Title: "No matching entries", - Body: fmt.Sprintf("No entries match %q in this view. Clear Search vault, broaden the query, or move to another group.", query), - } - } - } - switch u.state.Section { - case appstate.SectionAPITokens: - return emptyState{ - Title: "No API tokens yet", - Body: "Issue a token to grant scoped gRPC access to an external tool.", - } - case appstate.SectionAPIAudit: - return emptyState{ - Title: "No API audit events yet", - Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.", - } - case appstate.SectionAbout: - return emptyState{ - Title: "About KeePassGO", - Body: "Product details, compatibility notes, and platform targets appear in the detail pane.", - } - case appstate.SectionTemplates: - return emptyState{ - Title: "Templates unavailable", - Body: "Templates are not available in this build.", - } - case appstate.SectionRecycleBin: - return emptyState{ - Title: "Recycle Bin is empty", - Body: "Deleted entries will appear here until restored.", - } - default: - if len(u.displayPath()) > 0 { - return emptyState{ - Title: "This group is empty", - Body: "Add an entry here, search below this point, or open a subgroup.", - } - } - return emptyState{ - Title: "No entries yet", - Body: "Create or open a vault, then add an entry to get started.", - } - } -} - -func (u *ui) detailPlaceholderMessage() string { - if surface := u.sessionSurface(); surface.Locked { - return "Unlock the vault to inspect entries, attachments, and history." - } - if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" || - strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" || - strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" { - return "Complete the form to create a new item or update the current selection." - } - switch u.state.Section { - case appstate.SectionAPITokens: - return "Select an API token, issue a new one, or search to narrow the list." - case appstate.SectionAPIAudit: - return "Select an audit event to inspect it, or use Search audit log or the quick filters above." - case appstate.SectionAbout: - return "Review the product overview, platform support, and compatibility goals." - case appstate.SectionTemplates: - return "Select a template or start a reusable entry." - case appstate.SectionRecycleBin: - return "Select a deleted entry to review or restore it." - default: - if strings.TrimSpace(u.search.Text()) != "" { - return "Select a matching entry from the filtered list or clear the search." - } - if len(u.displayPath()) == 0 { - return "Select an entry from the vault root or open a group." - } - return "Select an entry or start a new one." - } -} - -func (u *ui) ensureNavClickables() { - u.syncCurrentPath() - if len(u.breadcrumbs) < len(u.currentPath)+1 { - u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) - } -} - -func (u *ui) syncPhoneGroupBrowser(path []string) { - if !u.usesCompactViewport() { - return - } - u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 -} - -func (u *ui) setCurrentPath(path []string) { - u.currentPath = append([]string(nil), path...) - u.state.NavigateToPath(path) - u.syncedPath = append([]string(nil), path...) - u.syncPhoneGroupBrowser(path) - u.noteCurrentVaultPath() - u.clearDeleteGroupConfirmation() -} - -func (u *ui) syncCurrentPath() { - switch { - case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): - u.currentPath = append([]string(nil), u.state.CurrentPath...) - case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath): - u.state.CurrentPath = append([]string(nil), u.currentPath...) - case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): - u.state.CurrentPath = append([]string(nil), u.currentPath...) - } - u.syncedPath = append([]string(nil), u.currentPath...) - u.noteCurrentVaultPath() - if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) { - u.clearDeleteGroupConfirmation() - } -} - -func (u *ui) noteCurrentVaultPath() { - status, ok := u.state.Session.(sessionStatus) - if !ok || status.IsLocked() { - return - } - if status.IsRemote() { - u.noteCurrentRemotePath() - return - } - path := strings.TrimSpace(u.vaultPath.Text()) - if path == "" { - return - } - if u.recentVaultGroups == nil { - u.recentVaultGroups = map[string][]string{} - } - u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) - u.saveRecentVaults() -} - -func (u *ui) layout(gtx layout.Context) layout.Dimensions { - // Clear the full frame explicitly so mobile surfaces don't start from an - // unpainted black buffer before nested background widgets run. - paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) - u.phoneSyncMenuVisible = false - u.phoneMainMenuVisible = false - u.syncHostedAPI() - u.filter() - u.processShortcuts(gtx) - u.handleLifecycleClicks(gtx) - u.handleHeaderAndDialogClicks(gtx) - u.handleSettingsClicks(gtx) - u.handleSectionAndSyncClicks(gtx) - u.handleApprovalAndAPIClicks(gtx) - u.handleSelectionClicks(gtx) - u.handleVaultAndEntryClicks(gtx) - u.handleGroupClicks(gtx) - u.handleInputUpdates(gtx) - u.updateViewportLayoutMode(gtx) - inset := layout.UniformInset(unit.Dp(16)) - return layout.Stack{}.Layout(gtx, - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { - return inset.Layout(gtx, u.mainFrame) - }) - }), - layout.Stacked(u.syncDialogOverlay), - layout.Stacked(u.securityDialogOverlay), - layout.Stacked(u.remotePrefsDialogOverlay), - layout.Stacked(u.approvalDialogOverlay), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - return u.phoneHeaderMenus(gtx) - }), - layout.Stacked(u.statusToast), - ) -} - -func (u *ui) handleLifecycleClicks(gtx layout.Context) { - for u.createVault.Clicked(gtx) { - u.runAction("create vault", u.createVaultAction) - } - for u.openVault.Clicked(gtx) { - u.startOpenVaultAction() - } - for u.lifecycleRemoteSyncAction.Clicked(gtx) { - if !u.lifecycleBusy() { - u.beginLifecycleRemoteSyncOpen() - } - } - for u.unlockVault.Clicked(gtx) { - u.startUnlockAction() - } - for u.cancelLifecycleProgress.Clicked(gtx) { - u.cancelLifecycleBusyState() - } - for u.retryLifecycleOpen.Clicked(gtx) { - u.state.ErrorMessage = "" - u.retryLastLifecycleOpen() - } - for u.toggleLifecycleAdvanced.Clicked(gtx) { - if !u.lifecycleBusy() { - u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden - u.saveUIPreferences() - } - } -} - -func (u *ui) handleHeaderAndDialogClicks(gtx layout.Context) { - u.handleHeaderActionClicks(gtx) - u.handleDialogControlClicks(gtx) - u.handleBannerClicks(gtx) -} - -func (u *ui) handleHeaderActionClicks(gtx layout.Context) { - for u.saveVault.Clicked(gtx) { - u.runAction("save vault", u.saveAction) - } - for u.saveAsVault.Clicked(gtx) { - u.runAction("save-as vault", u.saveAsAction) - } - for u.openRemote.Clicked(gtx) { - u.startOpenRemoteAction() - } - for u.changeMasterKey.Clicked(gtx) { - u.runAction("change master key", u.changeMasterKeyAction) - } - for u.synchronizeVault.Clicked(gtx) { - u.runAction("synchronize vault", u.synchronizeAction) - } - for u.toggleSyncMenu.Clicked(gtx) { - u.syncMenuOpen = !u.syncMenuOpen - if u.syncMenuOpen { - u.mainMenuOpen = false - } - } - for u.toggleMainMenu.Clicked(gtx) { - u.mainMenuOpen = !u.mainMenuOpen - if u.mainMenuOpen { - u.syncMenuOpen = false - } - } - for u.openAdvancedSync.Clicked(gtx) { - u.openAdvancedSyncDialog() - } - for u.openSecuritySettings.Clicked(gtx) { - u.loadSecuritySettingsFromSession() - u.loadSettingsFormFromPreferences() - u.loadSettingsDraft() - u.mainMenuOpen = false - u.securityDialogOpen = true - } - for u.openRemotePrefsHelp.Clicked(gtx) { - u.remotePrefsDialogOpen = true - } - for u.lockVault.Clicked(gtx) { - u.runAction("lock vault", u.lockAction) - } -} - -func (u *ui) handleDialogControlClicks(gtx layout.Context) { - for u.closeAdvancedSync.Clicked(gtx) { - u.syncDialogOpen = false - u.showSyncPassword = false - } - for u.closeSecuritySettings.Clicked(gtx) { - u.securityDialogOpen = false - } - for u.closeRemotePrefsHelp.Clicked(gtx) { - u.remotePrefsDialogOpen = false - } - for u.runAdvancedSync.Clicked(gtx) { - u.runAction("advanced synchronize vault", u.advancedSyncAction) - } - for u.saveSecuritySettings.Clicked(gtx) { - u.runAction("save settings", u.saveSecuritySettingsAction) - } -} - -func (u *ui) handleBannerClicks(gtx layout.Context) { - for u.dismissBanner.Clicked(gtx) { - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - } -} - -func (u *ui) handleSettingsClicks(gtx layout.Context) { - u.handleStatusPreferenceClicks(gtx) - u.handleAutofillPreferenceClicks(gtx) - u.handleAccessibilityClicks(gtx) - u.handleSettingsSyncDefaultClicks(gtx) -} - -func (u *ui) handleStatusPreferenceClicks(gtx layout.Context) { - for u.setStatusBannerShort.Clicked(gtx) { - u.setStatusBannerTTL(2 * time.Second) - } - for u.setStatusBannerStandard.Clicked(gtx) { - u.setStatusBannerTTL(statusBannerDuration) - } - for u.setStatusBannerLong.Clicked(gtx) { - u.setStatusBannerTTL(statusBannerLong) - } -} - -func (u *ui) handleAutofillPreferenceClicks(gtx layout.Context) { - for u.showAllAutofillNotices.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeAll) - } - for u.showApprovalAutofillOnly.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeApprovals) - } - for u.hideAutofillNotices.Clicked(gtx) { - u.setAutofillNoticePreference(autofillNoticeSuppressed) - } - for u.showAutofillApprovalAsk.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk - u.saveUIPreferences() - } - for u.showAutofillApprovalAllow.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow - u.saveUIPreferences() - } - for u.showAutofillApprovalBlock.Clicked(gtx) { - u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock - u.saveUIPreferences() - } -} - -func (u *ui) handleAccessibilityClicks(gtx layout.Context) { - for u.settingsDensityDense.Clicked(gtx) { - u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense - _ = u.applySecuritySettingsLive() - } - for u.settingsDensityComfortable.Clicked(gtx) { - u.settingsDraft.Accessibility.DisplayDensity = displayDensityComfortable - _ = u.applySecuritySettingsLive() - } - for u.settingsContrastStandard.Clicked(gtx) { - u.settingsDraft.Accessibility.Contrast = contrastStandard - _ = u.applySecuritySettingsLive() - } - for u.settingsContrastHigh.Clicked(gtx) { - u.settingsDraft.Accessibility.Contrast = contrastHigh - _ = u.applySecuritySettingsLive() - } - for u.settingsReducedMotionOff.Clicked(gtx) { - u.settingsDraft.Accessibility.ReducedMotion = false - _ = u.applySecuritySettingsLive() - } - for u.settingsReducedMotionOn.Clicked(gtx) { - u.settingsDraft.Accessibility.ReducedMotion = true - _ = u.applySecuritySettingsLive() - } - for u.settingsKeyboardFocusStandard.Clicked(gtx) { - u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusStandard - _ = u.applySecuritySettingsLive() - } - for u.settingsKeyboardFocusProminent.Clicked(gtx) { - u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusProminent - _ = u.applySecuritySettingsLive() - } -} - -func (u *ui) handleSettingsSyncDefaultClicks(gtx layout.Context) { - for u.showSettingsSyncLocal.Clicked(gtx) { - u.settingsDraft.Sync.SourceDefault = syncSourceLocal - _ = u.applySecuritySettingsLive() - } - for u.showSettingsSyncRemote.Clicked(gtx) { - u.settingsDraft.Sync.SourceDefault = syncSourceRemote - _ = u.applySecuritySettingsLive() - } - for u.showSettingsSyncPull.Clicked(gtx) { - u.settingsDraft.Sync.DirectionDefault = syncDirectionPull - _ = u.applySecuritySettingsLive() - } - for u.showSettingsSyncPush.Clicked(gtx) { - u.settingsDraft.Sync.DirectionDefault = syncDirectionPush - _ = u.applySecuritySettingsLive() - } -} - -func (u *ui) handleSectionAndSyncClicks(gtx layout.Context) { - u.handleSectionClicks(gtx) - u.handleLifecycleModeClicks(gtx) - u.handleSyncChoiceClicks(gtx) - u.handleRemoteBindingClicks(gtx) -} - -func (u *ui) handleSectionClicks(gtx layout.Context) { - for u.showEntries.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showEntriesSection() - } - for u.showTemplates.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showTemplatesSection() - } - for u.showRecycle.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showRecycleBinSection() - } - for u.showAPITokens.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showAPITokensSection() - } - for u.showAPIAudit.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showAPIAuditSection() - } - for u.showAbout.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.showAboutSection() - } -} - -func (u *ui) handleLifecycleModeClicks(gtx layout.Context) { - for u.showLocalLifecycle.Clicked(gtx) { - if !u.lifecycleBusy() { - u.lifecycleMode = "local" - u.requestMasterPassFocus = true - } - } - for u.showRemoteLifecycle.Clicked(gtx) { - if !u.lifecycleBusy() { - u.lifecycleMode = "remote" - u.selectedRemoteConnection = false - u.requestMasterPassFocus = true - } - } -} - -func (u *ui) handleSyncChoiceClicks(gtx layout.Context) { - for u.showSyncLocal.Clicked(gtx) { - u.syncSourceMode = syncSourceLocal - } - for u.showSyncRemote.Clicked(gtx) { - u.syncSourceMode = syncSourceRemote - } - for u.showSyncPull.Clicked(gtx) { - u.syncDirection = syncDirectionPull - } - for u.showSyncPush.Clicked(gtx) { - u.syncDirection = syncDirectionPush - } -} - -func (u *ui) handleRemoteBindingClicks(gtx layout.Context) { - for u.useSavedAdvancedSyncRemote.Clicked(gtx) { - u.openRemoteSyncSetupDialog() - } - for u.openSelectedVaultRemote.Clicked(gtx) { - if !u.lifecycleBusy() { - 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) - } -} - -func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) { - u.handleApprovalClicks(gtx) - u.handleAPITokenClicks(gtx) - u.handleAPIPolicyClicks(gtx) -} - -func (u *ui) handleApprovalClicks(gtx layout.Context) { - for u.allowApproval.Clicked(gtx) { - u.runAction("allow API request", func() error { - outcome := apiapproval.OutcomeAllowOnce - if u.approvalPermanent.Value { - outcome = apiapproval.OutcomeAllowPermanent - } - err := u.resolvePendingApproval(outcome) - u.approvalPermanent.Value = false - return err - }) - } - for u.denyApproval.Clicked(gtx) { - u.runAction("deny API request", func() error { - outcome := apiapproval.OutcomeDenyOnce - if u.approvalPermanent.Value { - outcome = apiapproval.OutcomeDenyPermanent - } - err := u.resolvePendingApproval(outcome) - u.approvalPermanent.Value = false - return err - }) - } - for u.cancelApproval.Clicked(gtx) { - u.runAction("cancel API request", func() error { - err := u.resolvePendingApproval(apiapproval.OutcomeCancel) - u.approvalPermanent.Value = false - return err - }) - } -} - -func (u *ui) handleAPITokenClicks(gtx layout.Context) { - for u.issueAPIToken.Clicked(gtx) { - u.runAction("issue API token", u.issueAPITokenAction) - } - for u.saveAPIToken.Clicked(gtx) { - u.runAction("save API token", u.saveAPITokenAction) - } - for u.rotateAPIToken.Clicked(gtx) { - u.runAction("rotate API token", u.rotateAPITokenAction) - } - for u.disableAPIToken.Clicked(gtx) { - u.runAction("disable API token", u.disableAPITokenAction) - } - for u.revokeAPIToken.Clicked(gtx) { - u.runAction("revoke API token", u.revokeAPITokenAction) - } - for u.deleteAPIToken.Clicked(gtx) { - u.runAction("delete API token", u.deleteAPITokenAction) - } - for u.copyAPITokenSecret.Clicked(gtx) { - secret := u.apiTokenSecret - u.runAction("copy API token secret", func() error { - if strings.TrimSpace(secret) == "" { - return fmt.Errorf("no API token secret to copy") - } - if u.clipboardWriter != nil { - return u.clipboardWriter.WriteText(secret) - } - return clipboard.WriteText(secret) - }) - } -} - -func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { - for u.addAPIPolicyRule.Clicked(gtx) { - u.runAction("add API policy rule", u.addAPIPolicyRuleAction) - } - for u.useCurrentGroupForPolicy.Clicked(gtx) { - u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction) - } - for u.useSelectedEntryForPolicy.Clicked(gtx) { - u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction) - } - for u.clearAPIPolicyTarget.Clicked(gtx) { - u.runAction("clear API policy target", u.clearAPIPolicyTargetAction) - } - for i := range u.apiPolicyRemoves { - for u.apiPolicyRemoves[i].Clicked(gtx) { - index := i - u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) }) - } - } -} - -func (u *ui) handleSelectionClicks(gtx layout.Context) { - u.handleFileSelectionClicks(gtx) - u.handleRecentSelectionClicks(gtx) - u.handleRemoteSelectionClicks(gtx) - u.handleClearSelectionClicks(gtx) -} - -func (u *ui) handleFileSelectionClicks(gtx layout.Context) { - for u.pickVaultPath.Clicked(gtx) { - if !u.lifecycleBusy() { - u.startChooseVaultPathAction() - } - } - for u.importSharedVault.Clicked(gtx) { - if !u.lifecycleBusy() { - u.startImportSharedVaultAction() - } - } - for u.pickKeyFile.Clicked(gtx) { - if !u.lifecycleBusy() { - u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) - } - } - for u.pickSyncLocalPath.Clicked(gtx) { - u.startChooseSyncLocalSourceAction() - } -} - -func (u *ui) handleRecentSelectionClicks(gtx layout.Context) { - for i := range u.recentVaultClicks { - for u.recentVaultClicks[i].Clicked(gtx) { - if !u.lifecycleBusy() && i < len(u.recentVaults) { - u.lifecycleMode = "local" - u.vaultPath.SetText(u.recentVaults[i]) - u.requestMasterPassFocus = true - } - } - } - for i := range u.recentRemoteClicks { - for u.recentRemoteClicks[i].Clicked(gtx) { - if !u.lifecycleBusy() && i < len(u.recentRemotes) { - u.lifecycleMode = "remote" - u.applyRecentRemoteRecord(u.recentRemotes[i]) - u.requestMasterPassFocus = true - } - } - } -} - -func (u *ui) handleRemoteSelectionClicks(gtx layout.Context) { - 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]) - } - } - } -} - -func (u *ui) handleClearSelectionClicks(gtx layout.Context) { - for u.clearVaultSelection.Clicked(gtx) { - if u.lifecycleBusy() { - continue - } - if u.shouldUseLockedSinglePane() { - u.switchToLifecycleSelection("local") - continue - } - u.vaultPath.SetText("") - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.requestMasterPassFocus = true - } - for u.clearRemoteSelection.Clicked(gtx) { - if u.lifecycleBusy() { - continue - } - if u.shouldUseLockedSinglePane() { - u.switchToLifecycleSelection("remote") - continue - } - u.selectedRemoteConnection = false - u.remoteBaseURL.SetText("") - u.remotePath.SetText("") - u.remoteUsername.SetText("") - u.remotePassword.SetText("") - u.state.ErrorMessage = "" - u.state.StatusMessage = "" - u.requestMasterPassFocus = true - } -} - -func (u *ui) handleVaultAndEntryClicks(gtx layout.Context) { - u.handleEntryEditorClicks(gtx) - u.handleEntryMutationClicks(gtx) - u.handleAttachmentAndCopyClicks(gtx) -} - -func (u *ui) handleEntryEditorClicks(gtx layout.Context) { - for u.editEntry.Clicked(gtx) { - u.editingEntry = true - u.loadSelectedEntryIntoEditor() - } - for u.cancelEdit.Clicked(gtx) { - u.editingEntry = false - u.loadSelectedEntryIntoEditor() - } - for u.addEntry.Clicked(gtx) { - u.state.BeginNewEntry() - u.loadSelectedEntryIntoEditor() - u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) - u.editingEntry = true - } -} - -func (u *ui) handleEntryMutationClicks(gtx layout.Context) { - for u.saveEntry.Clicked(gtx) { - u.runAction("save entry", u.saveEntryAction) - } - for u.duplicateEntry.Clicked(gtx) { - u.runAction("duplicate entry", u.duplicateSelectedEntryAction) - } - for u.deleteEntry.Clicked(gtx) { - u.runAction("delete entry", u.deleteSelectedEntryAction) - } - for u.restoreEntry.Clicked(gtx) { - u.runAction("restore entry", u.restoreSelectedRecycleEntryAction) - } - for u.saveTemplate.Clicked(gtx) { - u.runAction("save template", u.saveTemplateAction) - } - for u.deleteTemplate.Clicked(gtx) { - u.runAction("delete template", u.deleteSelectedTemplateAction) - } - for u.instantiateTemplate.Clicked(gtx) { - u.runAction("instantiate template", u.instantiateSelectedTemplateAction) - } -} - -func (u *ui) handleAttachmentAndCopyClicks(gtx layout.Context) { - for u.addAttachment.Clicked(gtx) { - u.runAction("add attachment", u.addAttachmentAction) - } - for u.replaceAttachment.Clicked(gtx) { - u.runAction("replace attachment", u.replaceAttachmentAction) - } - for u.removeAttachment.Clicked(gtx) { - u.runAction("remove attachment", u.removeAttachmentAction) - } - for u.exportAttachment.Clicked(gtx) { - u.runAction("export attachment", u.exportAttachmentAction) - } - for u.copyUser.Clicked(gtx) { - u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) - } - for u.copyPass.Clicked(gtx) { - u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) - } - for u.copyURL.Clicked(gtx) { - u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) }) - } - for u.generatePassword.Clicked(gtx) { - u.runAction("generate password", u.generatePasswordAction) - } - for u.restoreHistory.Clicked(gtx) { - u.runAction("restore history", u.restoreSelectedHistoryAction) - } -} - -func (u *ui) handleGroupClicks(gtx layout.Context) { - for u.createGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.runAction("create group", u.createGroupAction) - } - for u.moveGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.runAction("move group", u.moveCurrentGroupAction) - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.syncedPath = append([]string(nil), u.state.CurrentPath...) - u.filter() - } - for u.toggleGroupControls.Clicked(gtx) { - u.groupControlsHidden = !u.groupControlsHidden - u.saveUIPreferences() - } - for u.toggleHistory.Clicked(gtx) { - u.historyHidden = !u.historyHidden - u.saveUIPreferences() - } - for u.renameGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.runAction("rename group", u.renameGroupAction) - } - for u.deleteGroup.Clicked(gtx) { - u.armDeleteCurrentGroupAction() - } - for u.confirmDeleteGroup.Clicked(gtx) { - u.runAction("delete group", u.deleteCurrentGroupAction) - u.clearDeleteGroupConfirmation() - } - for u.cancelDeleteGroup.Clicked(gtx) { - u.clearDeleteGroupConfirmation() - u.state.StatusMessage = "" - u.statusExpiresAt = time.Time{} - } -} - -func (u *ui) handleInputUpdates(gtx layout.Context) { - if u.securityDialogOpen { - if _, changed := u.securityCipher.Update(gtx); changed { - _ = u.applySecuritySettingsLive() - } - if _, changed := u.securityKDF.Update(gtx); changed { - _ = u.applySecuritySettingsLive() - } - if _, changed := u.autofillBrowserAllowlist.Update(gtx); changed { - u.saveUIPreferences() - } - if _, changed := u.autofillAppAllowlist.Update(gtx); changed { - u.saveUIPreferences() - } - if _, changed := u.autofillPackageRules.Update(gtx); changed { - u.saveUIPreferences() - } - } - for u.togglePassword.Clicked(gtx) { - u.showPassword = !u.showPassword - } - for u.togglePasswordInline.Clicked(gtx) { - u.showPassword = !u.showPassword - } - for u.toggleSyncPassword.Clicked(gtx) { - u.showSyncPassword = !u.showSyncPassword - if u.showSyncPassword { - u.syncRemotePassword.Mask = 0 - } else { - u.syncRemotePassword.Mask = '•' - } - } - if _, changed := u.search.Update(gtx); changed { - u.filter() - } -} - -func (u *ui) mainFrame(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(u.header), - layout.Rigid(u.bannerRow), - layout.Rigid(u.autofillStatusRow), - layout.Flexed(1, u.primaryContent), - ) -} - -func (u *ui) bannerRow(gtx layout.Context) layout.Dimensions { - if u.bannerSurface().Kind == bannerNone { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(u.banner), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - ) -} - -func (u *ui) autofillStatusRow(gtx layout.Context) layout.Dimensions { - if u.bannerSurface().Kind != bannerNone || u.autofillStatusSurface().Kind == autofillStatusNone { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(u.autofillStatusCard), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - ) -} - -func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { - switch { - case u.shouldShowLifecycleSetup(): - return u.lifecycleScreen(gtx) - case u.shouldUseLockedSinglePane(): - return u.detailPanel(gtx) - case u.usesCompactViewport(): - return u.compactPrimaryContent(gtx) - default: - return u.widePrimaryContent(gtx) - } -} - -func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { - u.phoneSpan = gtx.Constraints.Max.Y - listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) - if min := gtx.Dp(unit.Dp(180)); listHeight < min { - listHeight = min - } - if max := gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)); listHeight > max { - listHeight = max - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.Y = listHeight - gtx.Constraints.Max.Y = listHeight - return u.listPanel(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(u.phoneSlider), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - u.compactDetailFlexChild(), - ) -} - -func (u *ui) compactDetailFlexChild() layout.FlexChild { - if u.shouldUseCompactPhoneDetailPane() { - return layout.Rigid(u.detailPanel) - } - return layout.Flexed(1, u.detailPanel) -} - -func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Flexed(0.38, u.listPanel), - layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), - layout.Flexed(0.62, u.detailPanel), - ) -} - -func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { - if !u.syncDialogOpen { - return layout.Dimensions{} - } - return u.syncDialog(gtx) -} - -func (u *ui) securityDialogOverlay(gtx layout.Context) layout.Dimensions { - if !u.securityDialogOpen { - return layout.Dimensions{} - } - return u.securityDialog(gtx) -} - -func (u *ui) remotePrefsDialogOverlay(gtx layout.Context) layout.Dimensions { - if !u.remotePrefsDialogOpen { - return layout.Dimensions{} - } - return u.remotePrefsDialog(gtx) -} - -func (u *ui) approvalDialogOverlay(gtx layout.Context) layout.Dimensions { - if _, ok := u.pendingApproval(); !ok { - return layout.Dimensions{} - } - return u.approvalDialog(gtx) -} - func (u *ui) syncHostedAPI() { if u.apiHost == nil { return @@ -5514,22 +1612,22 @@ func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, uni return inset, titleSize, metaSize, urlSize, pathSize, dividerGap } -func (u *ui) listPanelTopSections() []listPanelTopSection { - sections := make([]listPanelTopSection, 0, 6) +func (u *ui) listPanelTopSections() []listlayout.TopSection { + sections := make([]listlayout.TopSection, 0, 6) if u.state.Section != appstate.SectionAbout { - sections = append(sections, listPanelTopSearch) + sections = append(sections, listlayout.TopSearch) } if !u.isVaultLocked() { - sections = append(sections, listPanelTopNavigation) + sections = append(sections, listlayout.TopNavigation) } if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) { - sections = append(sections, listPanelTopPath) + sections = append(sections, listlayout.TopPath) } if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries { - sections = append(sections, listPanelTopGroup, listPanelTopGroupTools) + sections = append(sections, listlayout.TopGroup, listlayout.TopGroupTools) } if !u.isVaultLocked() { - sections = append(sections, listPanelTopPrimary) + sections = append(sections, listlayout.TopPrimary) } return sections } @@ -5626,19 +1724,19 @@ func (u *ui) compactListPanelTopRows() []layout.Widget { return rows } -func (u *ui) listPanelTopSectionWidget(section listPanelTopSection) layout.Widget { +func (u *ui) listPanelTopSectionWidget(section listlayout.TopSection) layout.Widget { switch section { - case listPanelTopSearch: + case listlayout.TopSearch: return u.listPanelSearchRow - case listPanelTopNavigation: + case listlayout.TopNavigation: return u.navigationHeader - case listPanelTopPath: + case listlayout.TopPath: return u.pathBar - case listPanelTopGroup: + case listlayout.TopGroup: return u.groupBar - case listPanelTopGroupTools: + case listlayout.TopGroupTools: return u.groupControlsSection - case listPanelTopPrimary: + case listlayout.TopPrimary: return u.listPanelPrimaryActionRow default: return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } @@ -5661,39 +1759,39 @@ func (u *ui) wideListPanelChildren() []layout.FlexChild { return children } -func (u *ui) wideListPanelTopSectionWidget(section listPanelTopSection) layout.Widget { +func (u *ui) wideListPanelTopSectionWidget(section listlayout.TopSection) layout.Widget { switch section { - case listPanelTopSearch: + case listlayout.TopSearch: return u.listPanelSearchRow - case listPanelTopNavigation: + case listlayout.TopNavigation: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() { return layout.Dimensions{} } return u.navigationHeader(gtx) } - case listPanelTopPath: + case listlayout.TopPath: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { return layout.Dimensions{} } return u.pathBar(gtx) } - case listPanelTopGroup: + case listlayout.TopGroup: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupBar(gtx) } - case listPanelTopGroupTools: + case listlayout.TopGroupTools: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupControlsSection(gtx) } - case listPanelTopPrimary: + case listlayout.TopPrimary: return u.listPanelPrimaryActionRow default: return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } @@ -5931,17 +2029,19 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { } func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() { + _, hasSelectedEntry := u.selectedEntry() + mode := detaillayout.Resolve(u.isVaultLocked(), u.state.Section == appstate.SectionAPITokens || u.state.Section == appstate.SectionAPIAudit || u.state.Section == appstate.SectionAbout, hasSelectedEntry, u.editingEntry) + switch mode { + case detaillayout.ModeLocked: return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.lockedDetailChildren()...) - } - if panel := u.staticDetailPanel(); panel != nil { + case detaillayout.ModeStatic: + panel := u.staticDetailPanel() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...) - } - item, ok := u.selectedEntry() - if !ok && !u.editingEntry { + case detaillayout.ModeEmpty: return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.emptyDetailChildren()...) } - if u.editingEntry { + item, ok := u.selectedEntry() + if mode == detaillayout.ModeEditor { return u.detailEditorContent(gtx, ok) } return u.detailViewContent(gtx, item) @@ -7118,173 +3218,3 @@ func fill(c color.NRGBA) layout.Widget { return layout.Dimensions{Size: gtx.Constraints.Min} } } - -func Main() { - mode := flag.String("mode", "", "window mode: desktop or phone") - stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") - grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") - flag.Parse() - - resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) - resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") - resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS)) - - width := unit.Dp(1180) - height := unit.Dp(760) - if strings.EqualFold(resolvedMode, "phone") { - // Pixel 10 uses a 20:9 display; use a 412x915 dp viewport as a desktop-friendly preview. - width = unit.Dp(412) - height = unit.Dp(915) - } - - go func() { - w := new(app.Window) - options := []app.Option{app.Title(productName)} - if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) { - options = append(options, app.Size(width, height)) - } - w.Option(options...) - if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil { - panic(err) - } - if !strings.EqualFold(runtime.GOOS, "android") { - os.Exit(0) - } - }() - app.Main() -} - -func defaultGRPCAddr(goos string) string { - if strings.EqualFold(strings.TrimSpace(goos), "android") { - return "off" - } - return "127.0.0.1:47777" -} - -func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { - var ops op.Ops - manager := &session.Manager{} - ui := newUIWithSession(mode, manager, paths) - ui.fileExplorer = explorer.NewExplorer(w) - ui.invalidate = w.Invalidate - ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate) - host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) - if err != nil { - ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) - } else if host != nil { - ui.apiHost = host - ui.auditLog = host.Server().AuditLog() - ui.grpcAddress = host.Address() - ui.state.Approvals = &uiApprovalManager{server: host.Server()} - defer func() { _ = host.Stop() }() - } - for { - e := w.Event() - ui.fileExplorer.ListenEvents(e) - switch e := e.(type) { - case app.DestroyEvent: - return e.Err - case app.FrameEvent: - gtx := app.NewContext(&ops, e) - ui.processBackgroundActions() - ui.layout(gtx) - platform.ProcessClipboardWrites(gtx, ui.clipboardWriter) - e.Frame(gtx.Ops) - } - } -} - -type uiApprovalManager struct { - server *api.Server -} - -func (m *uiApprovalManager) Pending() []apiapproval.Request { - if m == nil || m.server == nil { - return nil - } - return m.server.ApprovalBroker().Pending() -} - -func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { - if m == nil || m.server == nil { - return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured") - } - return m.server.ResolveApproval(id, outcome) -} - -type uiSession struct { - model vault.Model - locked bool -} - -func (s *uiSession) HasVault() bool { - return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked -} - -func (s *uiSession) IsLocked() bool { - return s.locked -} - -func (s *uiSession) IsRemote() bool { - return false -} - -func (s *uiSession) Current() (vault.Model, error) { - if s.locked { - return vault.Model{}, session.ErrLocked - } - return s.model, nil -} - -func (s *uiSession) Replace(model vault.Model) { - s.model = model -} - -func (s *uiSession) Lock() error { - s.locked = true - return nil -} - -func (s *uiSession) Unlock(vault.MasterKey) error { - if !s.locked { - return nil - } - s.locked = false - return nil -} - -func pickExistingFile() (string, error) { - if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil { - return path, nil - } - if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil { - return path, nil - } - return "", fmt.Errorf("no supported file picker found; install kdialog or zenity") -} - -func runFilePicker(name string, args ...string) (string, error) { - if _, err := exec.LookPath(name); err != nil { - return "", err - } - cmd := exec.Command(name, args...) - output, err := cmd.Output() - if err != nil { - return "", err - } - 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/internal/appui/layout/detail/mode.go b/internal/appui/layout/detail/mode.go new file mode 100644 index 0000000..b19ea51 --- /dev/null +++ b/internal/appui/layout/detail/mode.go @@ -0,0 +1,26 @@ +package detail + +type Mode string + +const ( + ModeLocked Mode = "locked" + ModeStatic Mode = "static" + ModeEmpty Mode = "empty" + ModeEditor Mode = "editor" + ModeView Mode = "view" +) + +func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode { + switch { + case isLocked: + return ModeLocked + case hasStaticPanel: + return ModeStatic + case !hasSelectedEntry && !editing: + return ModeEmpty + case editing: + return ModeEditor + default: + return ModeView + } +} diff --git a/internal/appui/layout/dropdown.go b/internal/appui/layout/header/dropdown.go similarity index 92% rename from internal/appui/layout/dropdown.go rename to internal/appui/layout/header/dropdown.go index 91d5430..0e18456 100644 --- a/internal/appui/layout/dropdown.go +++ b/internal/appui/layout/header/dropdown.go @@ -1,4 +1,4 @@ -package layout +package header import ( "image" @@ -63,7 +63,7 @@ func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu la return layout.Dimensions{Size: gtx.Constraints.Max} } -type HeaderActionMetrics struct { +type ActionMetrics struct { RowOriginX int Spacing int RowDims layout.Dimensions @@ -72,14 +72,14 @@ type HeaderActionMetrics struct { MainDims layout.Dimensions } -func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor { +func (m ActionMetrics) SyncAnchor() DropdownAnchor { return DropdownAnchor{ TriggerRightX: m.RowOriginX + m.SyncDims.Size.X, TriggerBottomY: m.RowDims.Size.Y, } } -func (m HeaderActionMetrics) MainAnchor() DropdownAnchor { +func (m ActionMetrics) MainAnchor() DropdownAnchor { triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X return DropdownAnchor{ TriggerRightX: m.RowOriginX + triggerRightX, diff --git a/internal/appui/layout/list/sections.go b/internal/appui/layout/list/sections.go new file mode 100644 index 0000000..e6c6fb1 --- /dev/null +++ b/internal/appui/layout/list/sections.go @@ -0,0 +1,12 @@ +package list + +type TopSection string + +const ( + TopSearch TopSection = "search" + TopNavigation TopSection = "navigation" + TopPath TopSection = "path" + TopGroup TopSection = "group" + TopGroupTools TopSection = "group_tools" + TopPrimary TopSection = "primary" +) diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index cf09378..359b437 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -25,7 +25,8 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" - appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" + listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" @@ -260,13 +261,13 @@ func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T phone := newUIWithModel("phone", vault.Model{}) phone.state.Section = appstate.SectionEntries - want := []listPanelTopSection{ - listPanelTopSearch, - listPanelTopNavigation, - listPanelTopPath, - listPanelTopGroup, - listPanelTopGroupTools, - listPanelTopPrimary, + want := []listlayout.TopSection{ + listlayout.TopSearch, + listlayout.TopNavigation, + listlayout.TopPath, + listlayout.TopGroup, + listlayout.TopGroupTools, + listlayout.TopPrimary, } if got := desktop.listPanelTopSections(); !slices.Equal(got, want) { t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want) @@ -373,10 +374,10 @@ func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) { func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { t.Parallel() - if got := appuilayout.AnchoredMenuX(48, 160); got != -112 { + if got := headerlayout.AnchoredMenuX(48, 160); got != -112 { t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got) } - if got := appuilayout.AnchoredMenuX(160, 48); got != 112 { + if got := headerlayout.AnchoredMenuX(160, 48); got != 112 { t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got) } } @@ -384,10 +385,10 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { t.Parallel() - if got := appuilayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 { + if got := headerlayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 { t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got) } - if got := appuilayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 { + if got := headerlayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 { t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got) } } @@ -395,7 +396,7 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { t.Parallel() - metrics := appuilayout.HeaderActionMetrics{ + metrics := headerlayout.ActionMetrics{ RowOriginX: 24, Spacing: 8, RowDims: layout.Dimensions{Size: image.Pt(180, 40)}, @@ -404,10 +405,10 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { MainDims: layout.Dimensions{Size: image.Pt(36, 40)}, } - if got := metrics.SyncAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { + if got := metrics.SyncAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got) } - if got := metrics.MainAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { + if got := metrics.MainAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got) } } @@ -415,14 +416,14 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) { t.Parallel() - surface := appuilayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} - anchor := appuilayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} + surface := headerlayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} + anchor := headerlayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) { t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got) } - leftAnchor := appuilayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} + leftAnchor := headerlayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} if got := surface.Origin(leftAnchor, 120); got != image.Pt(16, 58) { t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got) } diff --git a/internal/appui/ui_actions_lifecycle.go b/internal/appui/ui_actions_lifecycle.go new file mode 100644 index 0000000..98d45e6 --- /dev/null +++ b/internal/appui/ui_actions_lifecycle.go @@ -0,0 +1,778 @@ +package appui + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "gioui.org/layout" + "gioui.org/x/explorer" + "git.julianfamily.org/keepassgo/internal/appstate" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" +) + +func (u *ui) createVaultAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + if err := u.state.ConfigureSecurity(vault.SecuritySettings{ + Cipher: strings.TrimSpace(u.securityCipher.Text()), + KDF: strings.TrimSpace(u.securityKDF.Text()), + }); err != nil { + return err + } + if err := u.state.CreateVault(key); err != nil { + 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 + } + u.vaultPath.SetText(u.saveAsTargetPath()) + u.noteRecentVault(u.saveAsTargetPath()) + } + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) openVaultAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.OpenVault(path, key); err != nil { + return err + } + u.noteRecentVault(path) + 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() + u.applyPendingLifecycleOpenIntent() + return nil +} + +func (u *ui) startOpenVaultAction() { + manager, ok := u.state.Session.(*session.Manager) + if !ok { + u.runAction("open vault", u.openVaultAction) + return + } + key, err := u.currentMasterKey() + u.clearMasterPassword() + if err != nil { + u.state.ErrorMessage = u.describeActionError("open vault", err) + u.requestMasterPassFocus = true + return + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired)) + u.requestMasterPassFocus = true + return + } + u.lastLifecycleAction = "open vault" + u.runBackgroundAction("open vault", func() (func() error, error) { + prepared, err := session.PrepareLocalOpen(path, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedLocalOpen(prepared) + u.noteRecentVault(path) + 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() + u.applyPendingLifecycleOpenIntent() + return nil + }, nil + }) +} + +func (u *ui) shouldShowLifecycleRemoteSyncAction() bool { + return strings.TrimSpace(u.vaultPath.Text()) != "" +} + +func (u *ui) lifecycleRemoteSyncActionLabel() string { + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return "Open Vault And Set Up Remote Sync" + } + if hasBoundRecentRemote(u.recentRemotes, path) { + return "Open Vault And Open Remote Sync Settings" + } + return "Open Vault And Set Up Remote Sync" +} + +func (u *ui) beginLifecycleRemoteSyncOpen() { + path := strings.TrimSpace(u.vaultPath.Text()) + switch { + case path == "": + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + case hasBoundRecentRemote(u.recentRemotes, path): + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings + default: + u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup + } + u.startOpenVaultAction() +} + +func (u *ui) applyPendingLifecycleOpenIntent() { + intent := u.pendingLifecycleOpenIntent + u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone + switch intent { + case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings: + u.openRemoteSyncSetupDialog() + } +} + +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 +} + +func (u *ui) saveAsAction() error { + path := u.saveAsTargetPath() + if err := u.state.SaveAs(path); err != nil { + return err + } + u.vaultPath.SetText(path) + u.noteRecentVault(path) + u.filter() + return nil +} + +func (u *ui) openRemoteAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + 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()), + Password: u.remotePassword.Text(), + } + 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()), + ) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) startOpenRemoteAction() { + manager, ok := u.state.Session.(*session.Manager) + if !ok { + u.runAction("open remote vault", u.openRemoteAction) + return + } + key, err := u.currentMasterKey() + u.clearMasterPassword() + if err != nil { + u.state.ErrorMessage = u.describeActionError("open remote vault", err) + u.requestMasterPassFocus = true + return + } + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Username: strings.TrimSpace(u.remoteUsername.Text()), + Password: u.remotePassword.Text(), + } + 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, + ) + u.resetPasswordPeek() + u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + }) +} + +func (u *ui) lockAction() error { + u.clearMasterPassword() + if err := u.state.Lock(); err != nil { + return err + } + u.requestMasterPassFocus = true + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.resetPasswordPeek() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) unlockAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + if err := u.state.Unlock(key); err != nil { + return err + } + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil +} + +func (u *ui) startUnlockAction() { + manager, ok := u.state.Session.(*session.Manager) + if !ok { + u.runAction("unlock vault", u.unlockAction) + return + } + key, err := u.currentMasterKey() + u.clearMasterPassword() + if err != nil { + u.state.ErrorMessage = u.describeActionError("unlock vault", err) + u.requestMasterPassFocus = true + return + } + encoded := append([]byte(nil), manager.EncodedBytes()...) + u.runBackgroundAction("unlock vault", func() (func() error, error) { + prepared, err := session.PrepareUnlock(encoded, key) + if err != nil { + return nil, err + } + return func() error { + manager.ApplyPreparedUnlock(prepared) + u.resetPasswordPeek() + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.loadSecuritySettingsFromSession() + u.editingEntry = false + u.filter() + return nil + }, nil + }) +} + +func (u *ui) changeMasterKeyAction() error { + key, err := u.currentMasterKey() + defer u.clearMasterPassword() + if err != nil { + return err + } + return u.state.ChangeMasterKey(key) +} + +func (u *ui) loadSecuritySettingsFromSession() { + settings, err := u.state.SecuritySettings() + if err != nil { + return + } + u.securityCipher.SetText(settings.Cipher) + u.securityKDF.SetText(settings.KDF) +} + +func (u *ui) clearMasterPassword() { + u.masterPassword.SetText("") +} + +func (u *ui) synchronizeAction() error { + if err := u.state.Synchronize(); err != nil { + return err + } + u.syncMenuOpen = false + u.filter() + return nil +} + +func (u *ui) openAdvancedSyncDialog() { + u.syncDialogOpen = true + u.syncMenuOpen = false + u.showSyncPassword = false + u.syncDialogList.Position = layout.Position{} + 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.syncDialogList.Position = layout.Position{} + 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() { + u.syncLocalImportName = "" + u.syncLocalImportContent = nil +} + +func (u *ui) selectedSyncLocalImport() (string, []byte, bool) { + name := strings.TrimSpace(u.syncLocalImportName) + if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 { + return "", nil, false + } + return name, append([]byte(nil), u.syncLocalImportContent...), true +} + +func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode { + switch mode { + case syncSourceRemote: + return syncSourceRemote + default: + return syncSourceLocal + } +} + +func sanitizeSyncDirection(direction syncDirection) syncDirection { + switch direction { + case syncDirectionPush: + return syncDirectionPush + default: + return syncDirectionPull + } +} + +func (u *ui) advancedSyncAction() error { + switch u.syncDirection { + case syncDirectionPush: + return u.advancedSyncToAction() + default: + return u.advancedSyncFromAction() + } +} + +func (u *ui) advancedSyncFromAction() error { + switch u.syncSourceMode { + case syncSourceRemote: + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()), + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + } + if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil { + return err + } + default: + if name, content, ok := u.selectedSyncLocalImport(); ok { + if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil { + return err + } + break + } + path := strings.TrimSpace(u.syncLocalPath.Text()) + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.SynchronizeFromLocal(path); err != nil { + return err + } + } + u.syncDialogOpen = false + u.showSyncPassword = false + u.filter() + return nil +} + +func (u *ui) startChooseSyncLocalSourceAction() { + if runtime.GOOS != "android" || u.fileExplorer == nil { + u.runAction("choose sync path", func() error { + u.clearSyncLocalImport() + return u.chooseExistingFileAction(&u.syncLocalPath) + }) + return + } + u.runBackgroundAction("choose sync file", 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 + } + label := "Selected Android vault" + return func() error { + u.syncLocalImportName = label + u.syncLocalImportContent = append([]byte(nil), content...) + u.syncLocalPath.SetText(label) + return nil + }, nil + }) +} + +func pickedDocumentName(file io.ReadCloser, fallback string) string { + if named, ok := file.(interface{ Name() string }); ok { + if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) { + return base + } + } + fallback = filepath.Base(strings.TrimSpace(fallback)) + if fallback == "" || fallback == "." || fallback == string(filepath.Separator) { + return "selected-vault.kdbx" + } + return fallback +} + +func (u *ui) startChooseVaultPathAction() { + if runtime.GOOS != "android" || u.fileExplorer == nil { + u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) + return + } + u.runBackgroundAction("choose vault file", 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 + } + name := pickedDocumentName(file, "selected-vault.kdbx") + return func() error { + return u.importSharedVaultBytesAction(name, content) + }, nil + }) +} + +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: baseURL, + Username: strings.TrimSpace(u.syncRemoteUsername.Text()), + Password: u.syncRemotePassword.Text(), + } + 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 == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.SynchronizeToLocal(path); err != nil { + return err + } + } + u.syncDialogOpen = false + u.showSyncPassword = false + u.filter() + 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 != "" { + return path + } + 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)) +} diff --git a/internal/appui/ui_frame.go b/internal/appui/ui_frame.go new file mode 100644 index 0000000..85b5396 --- /dev/null +++ b/internal/appui/ui_frame.go @@ -0,0 +1,1375 @@ +package appui + +import ( + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + "time" + + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/appstate" + "git.julianfamily.org/keepassgo/internal/clipboard" + "git.julianfamily.org/keepassgo/internal/session" +) + +func (u *ui) bannerSurface() uiBanner { + switch { + case strings.TrimSpace(u.loadingMessage) != "": + return uiBanner{ + Kind: bannerLoading, + Message: strings.TrimSpace(u.loadingMessage), + Detail: u.loadingDetailMessage(), + } + case strings.TrimSpace(u.state.ErrorMessage) != "": + return uiBanner{ + Kind: bannerError, + Message: strings.TrimSpace(u.state.ErrorMessage), + Dismissable: true, + } + default: + return uiBanner{} + } +} + +func (u *ui) statusToastSurface() uiBanner { + if strings.TrimSpace(u.state.StatusMessage) == "" { + return uiBanner{} + } + if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return uiBanner{} + } + return uiBanner{ + Kind: bannerStatus, + Message: strings.TrimSpace(u.state.StatusMessage), + } +} + +func (u *ui) autofillStatusSurface() uiAutofillStatus { + if u.autofillNoticePreference == autofillNoticeSuppressed { + return uiAutofillStatus{} + } + if request, ok := u.pendingAutofillApproval(); ok { + detail := approvalResourceText(request) + if strings.TrimSpace(detail) == "" { + detail = "Review the request to allow or deny this fill attempt." + } + return uiAutofillStatus{ + Kind: autofillStatusAwaitingApproval, + Title: "Autofill approval needed", + Message: formatAutofillRequester(request.ClientName, request.TokenName) + " is waiting to fill credentials.", + Detail: detail, + } + } + if u.auditLog == nil { + return uiAutofillStatus{} + } + if u.autofillNoticePreference == autofillNoticeApprovals { + return uiAutofillStatus{} + } + for _, event := range u.auditLog.Events() { + if status, ok := autofillStatusFromAuditEvent(event, u.now()); ok { + return status + } + } + return uiAutofillStatus{} +} + +func (u *ui) pendingAutofillApproval() (apiapproval.Request, bool) { + for _, request := range u.state.PendingApprovals() { + if isAutofillOperation(request.Operation) { + return request, true + } + } + return apiapproval.Request{}, false +} + +func autofillStatusFromAuditEvent(event apiaudit.Event, now time.Time) (uiAutofillStatus, bool) { + if !event.At.IsZero() && !now.Before(event.At) && now.Sub(event.At) > autofillStatusTTL { + return uiAutofillStatus{}, false + } + + requester := formatAutofillRequester(event.ClientName, event.TokenName) + switch event.Type { + case apiaudit.EventAutofillFound: + return uiAutofillStatus{ + Kind: autofillStatusFound, + Title: "Autofill match ready", + Message: defaultAutofillMessage(event.Message, requester+" found a credential to fill."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventAutofillAmbiguous: + return uiAutofillStatus{ + Kind: autofillStatusAmbiguous, + Title: "Autofill needs a narrower match", + Message: defaultAutofillMessage(event.Message, requester+" found more than one matching credential."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventAutofillBlocked: + return uiAutofillStatus{ + Kind: autofillStatusBlocked, + Title: "Autofill is blocked", + Message: defaultAutofillMessage(event.Message, requester+" could not fill this target."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventApprovalAllowed: + if !isAutofillOperation(event.Operation) { + return uiAutofillStatus{}, false + } + return uiAutofillStatus{ + Kind: autofillStatusFound, + Title: "Autofill approved", + Message: defaultAutofillMessage(event.Message, requester+" can fill this target now."), + Detail: autofillEventDetail(event), + }, true + case apiaudit.EventApprovalDenied, apiaudit.EventApprovalCanceled, apiaudit.EventApprovalTimedOut: + if !isAutofillOperation(event.Operation) { + return uiAutofillStatus{}, false + } + return uiAutofillStatus{ + Kind: autofillStatusBlocked, + Title: "Autofill was not allowed", + Message: defaultAutofillMessage(event.Message, autofillBlockedMessage(event.Type, requester)), + Detail: autofillEventDetail(event), + }, true + default: + return uiAutofillStatus{}, false + } +} + +func autofillEventDetail(event apiaudit.Event) string { + return strings.TrimSpace(resourceDetailText(event.Resource)) +} + +func resourceDetailText(resource apitokens.Resource) string { + switch resource.Kind { + case apitokens.ResourceEntry: + if entryID := strings.TrimSpace(resource.EntryID); entryID != "" { + return "Entry ID: " + entryID + } + case apitokens.ResourceGroup: + if len(resource.Path) > 0 { + return "Group: " + strings.Join(resource.Path, " / ") + } + } + return "" +} + +func formatAutofillRequester(clientName, tokenName string) string { + switch { + case strings.TrimSpace(clientName) != "" && strings.TrimSpace(tokenName) != "": + return strings.TrimSpace(clientName) + " (" + strings.TrimSpace(tokenName) + ")" + case strings.TrimSpace(clientName) != "": + return strings.TrimSpace(clientName) + case strings.TrimSpace(tokenName) != "": + return strings.TrimSpace(tokenName) + default: + return "A trusted client" + } +} + +func defaultAutofillMessage(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} + +func autofillBlockedMessage(eventType apiaudit.EventType, requester string) string { + switch eventType { + case apiaudit.EventApprovalDenied: + return requester + " was denied for this fill request." + case apiaudit.EventApprovalCanceled: + return requester + " canceled this fill request." + case apiaudit.EventApprovalTimedOut: + return requester + " timed out while waiting for approval." + default: + return requester + " could not fill this target." + } +} + +func isAutofillOperation(operation apitokens.Operation) bool { + switch operation { + case apitokens.OperationReadEntry, apitokens.OperationCopyUsername, apitokens.OperationCopyPassword, apitokens.OperationCopyURL: + return true + default: + return false + } +} + +func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) { + if !u.shouldShowLifecycleSetup() { + if banner.Dismissable { + return "", "Dismiss" + } + return "", "" + } + switch banner.Kind { + case bannerLoading: + if strings.HasPrefix(u.loadingActionLabel, "open ") { + return "Cancel", "" + } + case bannerError: + if u.canRetryLifecycleOpen() { + return "Retry", "Dismiss" + } + if banner.Dismissable { + return "", "Dismiss" + } + } + return "", "" +} + +func (u *ui) loadingDetailMessage() string { + if !u.shouldShowLifecycleSetup() { + return "" + } + if u.lifecycleMode == "remote" { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + path := strings.TrimSpace(u.remotePath.Text()) + switch { + case baseURL != "" && path != "": + return fmt.Sprintf( + "Target: %s (%s)", + friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), + path, + ) + case baseURL != "": + return "Target: " + baseURL + default: + return "Preparing remote vault access" + } + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return "Preparing local vault access" + } + return "Target: " + path +} + +func (u *ui) currentVaultSummary() vaultSummary { + status, ok := u.state.Session.(sessionStatus) + if !ok || !status.HasVault() { + return vaultSummary{} + } + if status.IsRemote() { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + path := strings.TrimSpace(u.remotePath.Text()) + summary := vaultSummary{ + Title: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), + Detail: baseURL, + } + if strings.TrimSpace(summary.Title) == "" { + summary.Title = "Remote vault" + } + summary.Context = u.vaultResumeContext(u.recentRemoteGroup(baseURL, path)) + return summary + } + path := strings.TrimSpace(u.vaultPath.Text()) + summary := vaultSummary{ + Title: friendlyRecentVaultLabel(path), + Detail: path, + } + if strings.TrimSpace(summary.Title) == "" { + summary.Title = "Local vault" + } + summary.Context = u.vaultResumeContext(u.recentVaultGroup(path)) + return summary +} + +func (u *ui) vaultResumeContext(path []string) string { + if len(path) == 0 { + return "" + } + displayPath := append([]string(nil), path...) + if len(displayPath) == 0 { + return "" + } + return "Resume in: " + strings.Join(displayPath, " / ") +} + +func compactPathDirectorySummary(path string) string { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "." || cleaned == "" { + return "" + } + dir := filepath.Dir(cleaned) + if dir == "." || dir == cleaned { + return "" + } + if dir == string(filepath.Separator) { + return dir + } + parts := strings.Split(filepath.ToSlash(dir), "/") + filtered := parts[:0] + for _, part := range parts { + if strings.TrimSpace(part) != "" { + filtered = append(filtered, part) + } + } + parts = filtered + if len(parts) <= 2 { + return filepath.ToSlash(dir) + } + return parts[0] + "/.../" + parts[len(parts)-1] +} + +func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) { + if !u.requestMasterPassFocus { + return + } + gtx.Execute(key.FocusCmd{Tag: &u.masterPassword}) + gtx.Execute(op.InvalidateCmd{}) + u.requestMasterPassFocus = false +} + +func (u *ui) sessionSurface() uiSurface { + if u.state.Session == nil { + return uiSurface{} + } + if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) { + return uiSurface{ + Title: "Vault locked", + Message: "Enter a master password, choose a key file, or provide both to unlock the vault.", + Locked: true, + } + } + return uiSurface{} +} + +func (u *ui) hasOpenVault() bool { + status, ok := u.state.Session.(sessionStatus) + if ok { + return status.HasVault() + } + _, err := u.state.Session.Current() + return err == nil +} + +func (u *ui) isVaultLocked() bool { + status, ok := u.state.Session.(sessionStatus) + if ok { + return status.IsLocked() + } + _, err := u.state.Session.Current() + return errors.Is(err, session.ErrLocked) +} + +func (u *ui) shouldShowLifecycleSetup() bool { + return !u.hasOpenVault() +} + +func (u *ui) lifecycleBusy() bool { + return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" +} + +func (u *ui) updateViewportLayoutMode(gtx layout.Context) { + u.viewportMeasured = true + u.compactViewport = gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) +} + +func (u *ui) usesCompactViewport() bool { + if u.viewportMeasured { + return u.compactViewport + } + return u.mode == "phone" +} + +func (u *ui) shouldUseLockedSinglePane() bool { + return u.isVaultLocked() && !u.shouldShowLifecycleSetup() +} + +func (u *ui) shouldShowDesktopWorkingHeader() bool { + return !u.usesCompactViewport() && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() +} + +func (u *ui) shouldUseCompactPhoneDetailPane() bool { + if !u.usesCompactViewport() { + return false + } + if u.isVaultLocked() || u.editingEntry { + return false + } + _, ok := u.selectedEntry() + return !ok +} + +func (u *ui) chooseExistingFileAction(target *widget.Editor) error { + path, err := pickExistingFile() + if err != nil { + return err + } + target.SetText(path) + return nil +} + +func (u *ui) listEmptyMessage() string { + return u.listEmptyState().Body +} + +func (u *ui) listEmptyState() emptyState { + if surface := u.sessionSurface(); surface.Locked { + return emptyState{ + Title: "Vault locked", + Body: "Unlock the vault to browse entries and groups.", + } + } + query := strings.TrimSpace(u.search.Text()) + if query != "" { + switch u.state.Section { + case appstate.SectionAPITokens: + return emptyState{ + Title: "No matching API tokens", + Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query), + } + case appstate.SectionAPIAudit: + return emptyState{ + Title: "No matching audit events", + Body: fmt.Sprintf("No audit events match %q. Clear the search or try a different quick filter.", query), + } + case appstate.SectionTemplates: + return emptyState{ + Title: "No matching templates", + Body: fmt.Sprintf("No templates match %q. Clear or refine Search vault.", query), + } + case appstate.SectionRecycleBin: + return emptyState{ + Title: "No matching deleted entries", + Body: fmt.Sprintf("No recycle-bin entries match %q. Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.", query), + } + default: + return emptyState{ + Title: "No matching entries", + Body: fmt.Sprintf("No entries match %q in this view. Clear Search vault, broaden the query, or move to another group.", query), + } + } + } + switch u.state.Section { + case appstate.SectionAPITokens: + return emptyState{ + Title: "No API tokens yet", + Body: "Issue a token to grant scoped gRPC access to an external tool.", + } + case appstate.SectionAPIAudit: + return emptyState{ + Title: "No API audit events yet", + Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.", + } + case appstate.SectionAbout: + return emptyState{ + Title: "About KeePassGO", + Body: "Product details, compatibility notes, and platform targets appear in the detail pane.", + } + case appstate.SectionTemplates: + return emptyState{ + Title: "Templates unavailable", + Body: "Templates are not available in this build.", + } + case appstate.SectionRecycleBin: + return emptyState{ + Title: "Recycle Bin is empty", + Body: "Deleted entries will appear here until restored.", + } + default: + if len(u.displayPath()) > 0 { + return emptyState{ + Title: "This group is empty", + Body: "Add an entry here, search below this point, or open a subgroup.", + } + } + return emptyState{ + Title: "No entries yet", + Body: "Create or open a vault, then add an entry to get started.", + } + } +} + +func (u *ui) detailPlaceholderMessage() string { + if surface := u.sessionSurface(); surface.Locked { + return "Unlock the vault to inspect entries, attachments, and history." + } + if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" || + strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" || + strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" { + return "Complete the form to create a new item or update the current selection." + } + switch u.state.Section { + case appstate.SectionAPITokens: + return "Select an API token, issue a new one, or search to narrow the list." + case appstate.SectionAPIAudit: + return "Select an audit event to inspect it, or use Search audit log or the quick filters above." + case appstate.SectionAbout: + return "Review the product overview, platform support, and compatibility goals." + case appstate.SectionTemplates: + return "Select a template or start a reusable entry." + case appstate.SectionRecycleBin: + return "Select a deleted entry to review or restore it." + default: + if strings.TrimSpace(u.search.Text()) != "" { + return "Select a matching entry from the filtered list or clear the search." + } + if len(u.displayPath()) == 0 { + return "Select an entry from the vault root or open a group." + } + return "Select an entry or start a new one." + } +} + +func (u *ui) ensureNavClickables() { + u.syncCurrentPath() + if len(u.breadcrumbs) < len(u.currentPath)+1 { + u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) + } +} + +func (u *ui) syncPhoneGroupBrowser(path []string) { + if !u.usesCompactViewport() { + return + } + u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 +} + +func (u *ui) setCurrentPath(path []string) { + u.currentPath = append([]string(nil), path...) + u.state.NavigateToPath(path) + u.syncedPath = append([]string(nil), path...) + u.syncPhoneGroupBrowser(path) + u.noteCurrentVaultPath() + u.clearDeleteGroupConfirmation() +} + +func (u *ui) syncCurrentPath() { + switch { + case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): + u.currentPath = append([]string(nil), u.state.CurrentPath...) + case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath): + u.state.CurrentPath = append([]string(nil), u.currentPath...) + case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath): + u.state.CurrentPath = append([]string(nil), u.currentPath...) + } + u.syncedPath = append([]string(nil), u.currentPath...) + u.noteCurrentVaultPath() + if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) { + u.clearDeleteGroupConfirmation() + } +} + +func (u *ui) noteCurrentVaultPath() { + status, ok := u.state.Session.(sessionStatus) + if !ok || status.IsLocked() { + return + } + if status.IsRemote() { + u.noteCurrentRemotePath() + return + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return + } + if u.recentVaultGroups == nil { + u.recentVaultGroups = map[string][]string{} + } + u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) + u.saveRecentVaults() +} + +func (u *ui) layout(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) + u.phoneSyncMenuVisible = false + u.phoneMainMenuVisible = false + u.syncHostedAPI() + u.filter() + u.processShortcuts(gtx) + u.handleLifecycleClicks(gtx) + u.handleHeaderAndDialogClicks(gtx) + u.handleSettingsClicks(gtx) + u.handleSectionAndSyncClicks(gtx) + u.handleApprovalAndAPIClicks(gtx) + u.handleSelectionClicks(gtx) + u.handleVaultAndEntryClicks(gtx) + u.handleGroupClicks(gtx) + u.handleInputUpdates(gtx) + u.updateViewportLayoutMode(gtx) + inset := layout.UniformInset(unit.Dp(16)) + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { + return inset.Layout(gtx, u.mainFrame) + }) + }), + layout.Stacked(u.syncDialogOverlay), + layout.Stacked(u.securityDialogOverlay), + layout.Stacked(u.remotePrefsDialogOverlay), + layout.Stacked(u.approvalDialogOverlay), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return u.phoneHeaderMenus(gtx) + }), + layout.Stacked(u.statusToast), + ) +} + +func (u *ui) handleLifecycleClicks(gtx layout.Context) { + for u.createVault.Clicked(gtx) { + u.runAction("create vault", u.createVaultAction) + } + for u.openVault.Clicked(gtx) { + u.startOpenVaultAction() + } + for u.lifecycleRemoteSyncAction.Clicked(gtx) { + if !u.lifecycleBusy() { + u.beginLifecycleRemoteSyncOpen() + } + } + for u.unlockVault.Clicked(gtx) { + u.startUnlockAction() + } + for u.cancelLifecycleProgress.Clicked(gtx) { + u.cancelLifecycleBusyState() + } + for u.retryLifecycleOpen.Clicked(gtx) { + u.state.ErrorMessage = "" + u.retryLastLifecycleOpen() + } + for u.toggleLifecycleAdvanced.Clicked(gtx) { + if !u.lifecycleBusy() { + u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden + u.saveUIPreferences() + } + } +} + +func (u *ui) handleHeaderAndDialogClicks(gtx layout.Context) { + u.handleHeaderActionClicks(gtx) + u.handleDialogControlClicks(gtx) + u.handleBannerClicks(gtx) +} + +func (u *ui) handleHeaderActionClicks(gtx layout.Context) { + for u.saveVault.Clicked(gtx) { + u.runAction("save vault", u.saveAction) + } + for u.saveAsVault.Clicked(gtx) { + u.runAction("save-as vault", u.saveAsAction) + } + for u.openRemote.Clicked(gtx) { + u.startOpenRemoteAction() + } + for u.changeMasterKey.Clicked(gtx) { + u.runAction("change master key", u.changeMasterKeyAction) + } + for u.synchronizeVault.Clicked(gtx) { + u.runAction("synchronize vault", u.synchronizeAction) + } + for u.toggleSyncMenu.Clicked(gtx) { + u.syncMenuOpen = !u.syncMenuOpen + if u.syncMenuOpen { + u.mainMenuOpen = false + } + } + for u.toggleMainMenu.Clicked(gtx) { + u.mainMenuOpen = !u.mainMenuOpen + if u.mainMenuOpen { + u.syncMenuOpen = false + } + } + for u.openAdvancedSync.Clicked(gtx) { + u.openAdvancedSyncDialog() + } + for u.openSecuritySettings.Clicked(gtx) { + u.loadSecuritySettingsFromSession() + u.loadSettingsFormFromPreferences() + u.loadSettingsDraft() + u.mainMenuOpen = false + u.securityDialogOpen = true + } + for u.openRemotePrefsHelp.Clicked(gtx) { + u.remotePrefsDialogOpen = true + } + for u.lockVault.Clicked(gtx) { + u.runAction("lock vault", u.lockAction) + } +} + +func (u *ui) handleDialogControlClicks(gtx layout.Context) { + for u.closeAdvancedSync.Clicked(gtx) { + u.syncDialogOpen = false + u.showSyncPassword = false + } + for u.closeSecuritySettings.Clicked(gtx) { + u.securityDialogOpen = false + } + for u.closeRemotePrefsHelp.Clicked(gtx) { + u.remotePrefsDialogOpen = false + } + for u.runAdvancedSync.Clicked(gtx) { + u.runAction("advanced synchronize vault", u.advancedSyncAction) + } + for u.saveSecuritySettings.Clicked(gtx) { + u.runAction("save settings", u.saveSecuritySettingsAction) + } +} + +func (u *ui) handleBannerClicks(gtx layout.Context) { + for u.dismissBanner.Clicked(gtx) { + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + } +} + +func (u *ui) handleSettingsClicks(gtx layout.Context) { + u.handleStatusPreferenceClicks(gtx) + u.handleAutofillPreferenceClicks(gtx) + u.handleAccessibilityClicks(gtx) + u.handleSettingsSyncDefaultClicks(gtx) +} + +func (u *ui) handleStatusPreferenceClicks(gtx layout.Context) { + for u.setStatusBannerShort.Clicked(gtx) { + u.setStatusBannerTTL(2 * time.Second) + } + for u.setStatusBannerStandard.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerDuration) + } + for u.setStatusBannerLong.Clicked(gtx) { + u.setStatusBannerTTL(statusBannerLong) + } +} + +func (u *ui) handleAutofillPreferenceClicks(gtx layout.Context) { + for u.showAllAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeAll) + } + for u.showApprovalAutofillOnly.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeApprovals) + } + for u.hideAutofillNotices.Clicked(gtx) { + u.setAutofillNoticePreference(autofillNoticeSuppressed) + } + for u.showAutofillApprovalAsk.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk + u.saveUIPreferences() + } + for u.showAutofillApprovalAllow.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow + u.saveUIPreferences() + } + for u.showAutofillApprovalBlock.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock + u.saveUIPreferences() + } +} + +func (u *ui) handleAccessibilityClicks(gtx layout.Context) { + for u.settingsDensityDense.Clicked(gtx) { + u.settingsDraft.Accessibility.DisplayDensity = displayDensityDense + _ = u.applySecuritySettingsLive() + } + for u.settingsDensityComfortable.Clicked(gtx) { + u.settingsDraft.Accessibility.DisplayDensity = displayDensityComfortable + _ = u.applySecuritySettingsLive() + } + for u.settingsContrastStandard.Clicked(gtx) { + u.settingsDraft.Accessibility.Contrast = contrastStandard + _ = u.applySecuritySettingsLive() + } + for u.settingsContrastHigh.Clicked(gtx) { + u.settingsDraft.Accessibility.Contrast = contrastHigh + _ = u.applySecuritySettingsLive() + } + for u.settingsReducedMotionOff.Clicked(gtx) { + u.settingsDraft.Accessibility.ReducedMotion = false + _ = u.applySecuritySettingsLive() + } + for u.settingsReducedMotionOn.Clicked(gtx) { + u.settingsDraft.Accessibility.ReducedMotion = true + _ = u.applySecuritySettingsLive() + } + for u.settingsKeyboardFocusStandard.Clicked(gtx) { + u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusStandard + _ = u.applySecuritySettingsLive() + } + for u.settingsKeyboardFocusProminent.Clicked(gtx) { + u.settingsDraft.Accessibility.KeyboardFocus = keyboardFocusProminent + _ = u.applySecuritySettingsLive() + } +} + +func (u *ui) handleSettingsSyncDefaultClicks(gtx layout.Context) { + for u.showSettingsSyncLocal.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceLocal + _ = u.applySecuritySettingsLive() + } + for u.showSettingsSyncRemote.Clicked(gtx) { + u.settingsDraft.Sync.SourceDefault = syncSourceRemote + _ = u.applySecuritySettingsLive() + } + for u.showSettingsSyncPull.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPull + _ = u.applySecuritySettingsLive() + } + for u.showSettingsSyncPush.Clicked(gtx) { + u.settingsDraft.Sync.DirectionDefault = syncDirectionPush + _ = u.applySecuritySettingsLive() + } +} + +func (u *ui) handleSectionAndSyncClicks(gtx layout.Context) { + u.handleSectionClicks(gtx) + u.handleLifecycleModeClicks(gtx) + u.handleSyncChoiceClicks(gtx) + u.handleRemoteBindingClicks(gtx) +} + +func (u *ui) handleSectionClicks(gtx layout.Context) { + for u.showEntries.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showEntriesSection() + } + for u.showTemplates.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showTemplatesSection() + } + for u.showRecycle.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showRecycleBinSection() + } + for u.showAPITokens.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAPITokensSection() + } + for u.showAPIAudit.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAPIAuditSection() + } + for u.showAbout.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAboutSection() + } +} + +func (u *ui) handleLifecycleModeClicks(gtx layout.Context) { + for u.showLocalLifecycle.Clicked(gtx) { + if !u.lifecycleBusy() { + u.lifecycleMode = "local" + u.requestMasterPassFocus = true + } + } + for u.showRemoteLifecycle.Clicked(gtx) { + if !u.lifecycleBusy() { + u.lifecycleMode = "remote" + u.selectedRemoteConnection = false + u.requestMasterPassFocus = true + } + } +} + +func (u *ui) handleSyncChoiceClicks(gtx layout.Context) { + for u.showSyncLocal.Clicked(gtx) { + u.syncSourceMode = syncSourceLocal + } + for u.showSyncRemote.Clicked(gtx) { + u.syncSourceMode = syncSourceRemote + } + for u.showSyncPull.Clicked(gtx) { + u.syncDirection = syncDirectionPull + } + for u.showSyncPush.Clicked(gtx) { + u.syncDirection = syncDirectionPush + } +} + +func (u *ui) handleRemoteBindingClicks(gtx layout.Context) { + for u.useSavedAdvancedSyncRemote.Clicked(gtx) { + u.openRemoteSyncSetupDialog() + } + for u.openSelectedVaultRemote.Clicked(gtx) { + if !u.lifecycleBusy() { + 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) + } +} + +func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) { + u.handleApprovalClicks(gtx) + u.handleAPITokenClicks(gtx) + u.handleAPIPolicyClicks(gtx) +} + +func (u *ui) handleApprovalClicks(gtx layout.Context) { + for u.allowApproval.Clicked(gtx) { + u.runAction("allow API request", func() error { + outcome := apiapproval.OutcomeAllowOnce + if u.approvalPermanent.Value { + outcome = apiapproval.OutcomeAllowPermanent + } + err := u.resolvePendingApproval(outcome) + u.approvalPermanent.Value = false + return err + }) + } + for u.denyApproval.Clicked(gtx) { + u.runAction("deny API request", func() error { + outcome := apiapproval.OutcomeDenyOnce + if u.approvalPermanent.Value { + outcome = apiapproval.OutcomeDenyPermanent + } + err := u.resolvePendingApproval(outcome) + u.approvalPermanent.Value = false + return err + }) + } + for u.cancelApproval.Clicked(gtx) { + u.runAction("cancel API request", func() error { + err := u.resolvePendingApproval(apiapproval.OutcomeCancel) + u.approvalPermanent.Value = false + return err + }) + } +} + +func (u *ui) handleAPITokenClicks(gtx layout.Context) { + for u.issueAPIToken.Clicked(gtx) { + u.runAction("issue API token", u.issueAPITokenAction) + } + for u.saveAPIToken.Clicked(gtx) { + u.runAction("save API token", u.saveAPITokenAction) + } + for u.rotateAPIToken.Clicked(gtx) { + u.runAction("rotate API token", u.rotateAPITokenAction) + } + for u.disableAPIToken.Clicked(gtx) { + u.runAction("disable API token", u.disableAPITokenAction) + } + for u.revokeAPIToken.Clicked(gtx) { + u.runAction("revoke API token", u.revokeAPITokenAction) + } + for u.deleteAPIToken.Clicked(gtx) { + u.runAction("delete API token", u.deleteAPITokenAction) + } + for u.copyAPITokenSecret.Clicked(gtx) { + secret := u.apiTokenSecret + u.runAction("copy API token secret", func() error { + if strings.TrimSpace(secret) == "" { + return fmt.Errorf("no API token secret to copy") + } + if u.clipboardWriter != nil { + return u.clipboardWriter.WriteText(secret) + } + return clipboard.WriteText(secret) + }) + } +} + +func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { + for u.addAPIPolicyRule.Clicked(gtx) { + u.runAction("add API policy rule", u.addAPIPolicyRuleAction) + } + for u.useCurrentGroupForPolicy.Clicked(gtx) { + u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction) + } + for u.useSelectedEntryForPolicy.Clicked(gtx) { + u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction) + } + for u.clearAPIPolicyTarget.Clicked(gtx) { + u.runAction("clear API policy target", u.clearAPIPolicyTargetAction) + } + for i := range u.apiPolicyRemoves { + for u.apiPolicyRemoves[i].Clicked(gtx) { + index := i + u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) }) + } + } +} + +func (u *ui) handleSelectionClicks(gtx layout.Context) { + u.handleFileSelectionClicks(gtx) + u.handleRecentSelectionClicks(gtx) + u.handleRemoteSelectionClicks(gtx) + u.handleClearSelectionClicks(gtx) +} + +func (u *ui) handleFileSelectionClicks(gtx layout.Context) { + for u.pickVaultPath.Clicked(gtx) { + if !u.lifecycleBusy() { + u.startChooseVaultPathAction() + } + } + for u.importSharedVault.Clicked(gtx) { + if !u.lifecycleBusy() { + u.startImportSharedVaultAction() + } + } + for u.pickKeyFile.Clicked(gtx) { + if !u.lifecycleBusy() { + u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) + } + } + for u.pickSyncLocalPath.Clicked(gtx) { + u.startChooseSyncLocalSourceAction() + } +} + +func (u *ui) handleRecentSelectionClicks(gtx layout.Context) { + for i := range u.recentVaultClicks { + for u.recentVaultClicks[i].Clicked(gtx) { + if !u.lifecycleBusy() && i < len(u.recentVaults) { + u.lifecycleMode = "local" + u.vaultPath.SetText(u.recentVaults[i]) + u.requestMasterPassFocus = true + } + } + } + for i := range u.recentRemoteClicks { + for u.recentRemoteClicks[i].Clicked(gtx) { + if !u.lifecycleBusy() && i < len(u.recentRemotes) { + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(u.recentRemotes[i]) + u.requestMasterPassFocus = true + } + } + } +} + +func (u *ui) handleRemoteSelectionClicks(gtx layout.Context) { + 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]) + } + } + } +} + +func (u *ui) handleClearSelectionClicks(gtx layout.Context) { + for u.clearVaultSelection.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + if u.shouldUseLockedSinglePane() { + u.switchToLifecycleSelection("local") + continue + } + u.vaultPath.SetText("") + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.requestMasterPassFocus = true + } + for u.clearRemoteSelection.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + if u.shouldUseLockedSinglePane() { + u.switchToLifecycleSelection("remote") + continue + } + u.selectedRemoteConnection = false + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.requestMasterPassFocus = true + } +} + +func (u *ui) handleVaultAndEntryClicks(gtx layout.Context) { + u.handleEntryEditorClicks(gtx) + u.handleEntryMutationClicks(gtx) + u.handleAttachmentAndCopyClicks(gtx) +} + +func (u *ui) handleEntryEditorClicks(gtx layout.Context) { + for u.editEntry.Clicked(gtx) { + u.editingEntry = true + u.loadSelectedEntryIntoEditor() + } + for u.cancelEdit.Clicked(gtx) { + u.editingEntry = false + u.loadSelectedEntryIntoEditor() + } + for u.addEntry.Clicked(gtx) { + u.state.BeginNewEntry() + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) + u.editingEntry = true + } +} + +func (u *ui) handleEntryMutationClicks(gtx layout.Context) { + for u.saveEntry.Clicked(gtx) { + u.runAction("save entry", u.saveEntryAction) + } + for u.duplicateEntry.Clicked(gtx) { + u.runAction("duplicate entry", u.duplicateSelectedEntryAction) + } + for u.deleteEntry.Clicked(gtx) { + u.runAction("delete entry", u.deleteSelectedEntryAction) + } + for u.restoreEntry.Clicked(gtx) { + u.runAction("restore entry", u.restoreSelectedRecycleEntryAction) + } + for u.saveTemplate.Clicked(gtx) { + u.runAction("save template", u.saveTemplateAction) + } + for u.deleteTemplate.Clicked(gtx) { + u.runAction("delete template", u.deleteSelectedTemplateAction) + } + for u.instantiateTemplate.Clicked(gtx) { + u.runAction("instantiate template", u.instantiateSelectedTemplateAction) + } +} + +func (u *ui) handleAttachmentAndCopyClicks(gtx layout.Context) { + for u.addAttachment.Clicked(gtx) { + u.runAction("add attachment", u.addAttachmentAction) + } + for u.replaceAttachment.Clicked(gtx) { + u.runAction("replace attachment", u.replaceAttachmentAction) + } + for u.removeAttachment.Clicked(gtx) { + u.runAction("remove attachment", u.removeAttachmentAction) + } + for u.exportAttachment.Clicked(gtx) { + u.runAction("export attachment", u.exportAttachmentAction) + } + for u.copyUser.Clicked(gtx) { + u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) + } + for u.copyPass.Clicked(gtx) { + u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) + } + for u.copyURL.Clicked(gtx) { + u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) }) + } + for u.generatePassword.Clicked(gtx) { + u.runAction("generate password", u.generatePasswordAction) + } + for u.restoreHistory.Clicked(gtx) { + u.runAction("restore history", u.restoreSelectedHistoryAction) + } +} + +func (u *ui) handleGroupClicks(gtx layout.Context) { + for u.createGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.runAction("create group", u.createGroupAction) + } + for u.moveGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.runAction("move group", u.moveCurrentGroupAction) + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.syncedPath = append([]string(nil), u.state.CurrentPath...) + u.filter() + } + for u.toggleGroupControls.Clicked(gtx) { + u.groupControlsHidden = !u.groupControlsHidden + u.saveUIPreferences() + } + for u.toggleHistory.Clicked(gtx) { + u.historyHidden = !u.historyHidden + u.saveUIPreferences() + } + for u.renameGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.runAction("rename group", u.renameGroupAction) + } + for u.deleteGroup.Clicked(gtx) { + u.armDeleteCurrentGroupAction() + } + for u.confirmDeleteGroup.Clicked(gtx) { + u.runAction("delete group", u.deleteCurrentGroupAction) + u.clearDeleteGroupConfirmation() + } + for u.cancelDeleteGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + } +} + +func (u *ui) handleInputUpdates(gtx layout.Context) { + if u.securityDialogOpen { + if _, changed := u.securityCipher.Update(gtx); changed { + _ = u.applySecuritySettingsLive() + } + if _, changed := u.securityKDF.Update(gtx); changed { + _ = u.applySecuritySettingsLive() + } + if _, changed := u.autofillBrowserAllowlist.Update(gtx); changed { + u.saveUIPreferences() + } + if _, changed := u.autofillAppAllowlist.Update(gtx); changed { + u.saveUIPreferences() + } + if _, changed := u.autofillPackageRules.Update(gtx); changed { + u.saveUIPreferences() + } + } + for u.togglePassword.Clicked(gtx) { + u.showPassword = !u.showPassword + } + for u.togglePasswordInline.Clicked(gtx) { + u.showPassword = !u.showPassword + } + for u.toggleSyncPassword.Clicked(gtx) { + u.showSyncPassword = !u.showSyncPassword + if u.showSyncPassword { + u.syncRemotePassword.Mask = 0 + } else { + u.syncRemotePassword.Mask = '•' + } + } + if _, changed := u.search.Update(gtx); changed { + u.filter() + } +} + +func (u *ui) mainFrame(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.header), + layout.Rigid(u.bannerRow), + layout.Rigid(u.autofillStatusRow), + layout.Flexed(1, u.primaryContent), + ) +} + +func (u *ui) bannerRow(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind == bannerNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(u.banner), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + ) +} + +func (u *ui) autofillStatusRow(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind != bannerNone || u.autofillStatusSurface().Kind == autofillStatusNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(u.autofillStatusCard), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + ) +} + +func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { + switch { + case u.shouldShowLifecycleSetup(): + return u.lifecycleScreen(gtx) + case u.shouldUseLockedSinglePane(): + return u.detailPanel(gtx) + case u.usesCompactViewport(): + return u.compactPrimaryContent(gtx) + default: + return u.widePrimaryContent(gtx) + } +} + +func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { + u.phoneSpan = gtx.Constraints.Max.Y + listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) + if min := gtx.Dp(unit.Dp(180)); listHeight < min { + listHeight = min + } + if max := gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)); listHeight > max { + listHeight = max + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.Y = listHeight + gtx.Constraints.Max.Y = listHeight + return u.listPanel(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(u.phoneSlider), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + u.compactDetailFlexChild(), + ) +} + +func (u *ui) compactDetailFlexChild() layout.FlexChild { + if u.shouldUseCompactPhoneDetailPane() { + return layout.Rigid(u.detailPanel) + } + return layout.Flexed(1, u.detailPanel) +} + +func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(0.38, u.listPanel), + layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), + layout.Flexed(0.62, u.detailPanel), + ) +} + +func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.syncDialogOpen { + return layout.Dimensions{} + } + return u.syncDialog(gtx) +} + +func (u *ui) securityDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.securityDialogOpen { + return layout.Dimensions{} + } + return u.securityDialog(gtx) +} + +func (u *ui) remotePrefsDialogOverlay(gtx layout.Context) layout.Dimensions { + if !u.remotePrefsDialogOpen { + return layout.Dimensions{} + } + return u.remotePrefsDialog(gtx) +} + +func (u *ui) approvalDialogOverlay(gtx layout.Context) layout.Dimensions { + if _, ok := u.pendingApproval(); !ok { + return layout.Dimensions{} + } + return u.approvalDialog(gtx) +} diff --git a/internal/appui/ui_layout_header.go b/internal/appui/ui_layout_header.go index 31fdf51..1c55acd 100644 --- a/internal/appui/ui_layout_header.go +++ b/internal/appui/ui_layout_header.go @@ -10,7 +10,7 @@ import ( "gioui.org/widget" "gioui.org/widget/material" "git.julianfamily.org/keepassgo/internal/appui/actions" - appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" "git.julianfamily.org/keepassgo/internal/vault" ) @@ -46,7 +46,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } spacing := gtx.Dp(unit.Dp(8)) - metrics := appuilayout.HeaderActionMetrics{Spacing: spacing} + metrics := headerlayout.ActionMetrics{Spacing: spacing} row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -75,7 +75,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) } - surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} + surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) rowCall.Add(gtx.Ops) @@ -478,17 +478,17 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } gtx.Constraints.Min = gtx.Constraints.Max contentInsetPx := gtx.Dp(unit.Dp(16)) - surface := appuilayout.DropdownSurface{ + surface := headerlayout.DropdownSurface{ ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), LeftInset: contentInsetPx, TopInset: contentInsetPx, } if u.syncMenuVisibleOnPhone() { - surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) } if u.mainMenuVisibleOnPhone() { - surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) } return layout.Dimensions{Size: gtx.Constraints.Max} } diff --git a/internal/appui/ui_recent_state.go b/internal/appui/ui_recent_state.go new file mode 100644 index 0000000..3c1948f --- /dev/null +++ b/internal/appui/ui_recent_state.go @@ -0,0 +1,1787 @@ +package appui + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "gioui.org/widget" + "git.julianfamily.org/keepassgo/internal/appstate" + "git.julianfamily.org/keepassgo/internal/autofillcache" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/webdav" +) + +func (u *ui) noteRecentVault(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + if u.recentVaultGroups == nil { + u.recentVaultGroups = map[string][]string{} + } + if u.recentVaultUsedAt == nil { + u.recentVaultUsedAt = map[string]time.Time{} + } + if len(u.currentPath) > 0 { + u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) + } else if _, ok := u.recentVaultGroups[path]; !ok { + u.recentVaultGroups[path] = nil + } + u.recentVaultUsedAt[path] = u.now() + next := []string{path} + for _, existing := range u.recentVaults { + if existing == path { + continue + } + next = append(next, existing) + if len(next) == 6 { + break + } + } + u.recentVaults = next + if len(u.recentVaultClicks) < len(u.recentVaults) { + u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) + } + u.saveRecentVaults() +} + +func (u *ui) loadRecentVaults() { + if strings.TrimSpace(u.recentVaultsPath) == "" { + return + } + content, err := os.ReadFile(u.recentVaultsPath) + if err != nil { + return + } + u.recentVaults = nil + u.recentVaultGroups = map[string][]string{} + u.recentVaultUsedAt = map[string]time.Time{} + var records []recentVaultRecord + switch { + case json.Unmarshal(content, &records) == nil: + u.applyRecentVaultRecords(records) + return + default: + var paths []string + if err := json.Unmarshal(content, &paths); err != nil { + return + } + records = make([]recentVaultRecord, 0, len(paths)) + for _, path := range paths { + records = append(records, recentVaultRecord{Path: path}) + } + u.applyRecentVaultRecords(records) + } +} + +func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) { + filtered := make([]string, 0, len(records)) + seen := map[string]bool{} + for _, record := range records { + path := strings.TrimSpace(record.Path) + if path == "" || seen[path] { + continue + } + seen[path] = true + filtered = append(filtered, path) + if u.recentVaultGroups == nil { + u.recentVaultGroups = map[string][]string{} + } + if u.recentVaultUsedAt == nil { + u.recentVaultUsedAt = map[string]time.Time{} + } + u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...) + if usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)); err == nil { + u.recentVaultUsedAt[path] = usedAt + } + if len(filtered) == 6 { + break + } + } + u.recentVaults = filtered + if len(u.recentVaultClicks) < len(u.recentVaults) { + u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) + } +} + +func (u *ui) loadRecentRemotes() { + if strings.TrimSpace(u.recentRemotesPath) == "" { + return + } + content, err := os.ReadFile(u.recentRemotesPath) + if err != nil { + return + } + var records []recentRemoteRecord + if err := json.Unmarshal(content, &records); err != nil { + return + } + filtered := make([]recentRemoteRecord, 0, len(records)) + seen := map[string]bool{} + 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 + } + seen[key] = true + record.LastGroup = append([]string(nil), record.LastGroup...) + filtered = append(filtered, record) + if len(filtered) == 6 { + break + } + } + u.recentRemotes = filtered + if len(u.recentRemoteClicks) < len(u.recentRemotes) { + u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) + } +} + +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 + } + if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil { + return + } + records := make([]recentVaultRecord, 0, len(u.recentVaults)) + for _, path := range u.recentVaults { + records = append(records, recentVaultRecord{ + Path: path, + LastGroup: append([]string(nil), u.recentVaultGroups[path]...), + UsedAt: u.recentVaultUsedAt[path].Format(time.RFC3339Nano), + }) + } + content, err := json.MarshalIndent(records, "", " ") + if err != nil { + return + } + _ = os.WriteFile(u.recentVaultsPath, content, 0o600) +} + +func (u *ui) saveRecentRemotes() { + if strings.TrimSpace(u.recentRemotesPath) == "" { + return + } + if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil { + return + } + content, err := json.MarshalIndent(u.recentRemotes, "", " ") + if err != nil { + return + } + _ = os.WriteFile(u.recentRemotesPath, content, 0o600) +} + +func (u *ui) loadUIPreferences() { + if strings.TrimSpace(u.uiPreferencesPath) == "" { + return + } + content, err := os.ReadFile(u.uiPreferencesPath) + if err != nil { + return + } + var prefs uiPreferences + if err := json.Unmarshal(content, &prefs); err != nil { + return + } + u.groupControlsHidden = prefs.GroupControlsHidden + u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden + u.historyHidden = prefs.HistoryHidden + u.denseLayout = prefs.DenseLayout + u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis) + u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode) + displayDensity := strings.TrimSpace(prefs.DisplayDensity) + if displayDensity == "" { + displayDensity = displayDensityForDenseLayout(prefs.DenseLayout) + } + u.applyAccessibilityPreferences(accessibilityPreferences{ + DisplayDensity: displayDensity, + Contrast: prefs.Contrast, + ReducedMotion: prefs.ReducedMotion, + KeyboardFocus: prefs.KeyboardFocus, + }) + if mode := parseAutofillFirstFillApprovalMode(prefs.AutofillPrivacy.FirstFillApprovalMode); mode != "" { + u.autofillFirstFillApprovalMode = mode + } + u.autofillBrowserAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.BrowserAllowlist)) + u.autofillAppAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.AppAllowlist)) + u.autofillPackageRules.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.PackageRules)) +} + +func (u *ui) saveUIPreferences() { + if strings.TrimSpace(u.uiPreferencesPath) == "" { + return + } + if err := os.MkdirAll(filepath.Dir(u.uiPreferencesPath), 0o700); err != nil { + return + } + content, err := json.MarshalIndent(uiPreferences{ + GroupControlsHidden: u.groupControlsHidden, + LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, + HistoryHidden: u.historyHidden, + DenseLayout: u.denseLayout, + StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond), + AutofillNoticeMode: string(u.autofillNoticePreference), + DisplayDensity: u.accessibilityPrefs.DisplayDensity, + Contrast: u.accessibilityPrefs.Contrast, + ReducedMotion: u.accessibilityPrefs.ReducedMotion, + KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, + AutofillPrivacy: autofillPrivacySettings{ + FirstFillApprovalMode: string(u.autofillFirstFillApprovalMode), + BrowserAllowlist: autofillPrivacyLines(u.autofillBrowserAllowlist.Text()), + AppAllowlist: autofillPrivacyLines(u.autofillAppAllowlist.Text()), + PackageRules: autofillPrivacyLines(u.autofillPackageRules.Text()), + }, + }, "", " ") + if err != nil { + return + } + _ = os.WriteFile(u.uiPreferencesPath, content, 0o600) +} + +func (u *ui) loadSettingsFormFromPreferences() { + u.settingsGroupControls.Value = u.groupControlsHidden + u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden + u.settingsHistory.Value = u.historyHidden + u.settingsDenseLayout.Value = u.denseLayout +} + +func (u *ui) applySettingsFormToPreferences() { + u.groupControlsHidden = u.settingsGroupControls.Value + u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value + u.historyHidden = u.settingsHistory.Value + u.denseLayout = u.settingsDenseLayout.Value +} + +func normalizedStatusBannerTTL(valueMillis int) time.Duration { + switch { + case valueMillis <= 0: + return statusBannerDuration + case time.Duration(valueMillis)*time.Millisecond > statusBannerLong: + return statusBannerLong + default: + return time.Duration(valueMillis) * time.Millisecond + } +} + +func normalizedAutofillNoticeMode(value string) autofillNoticeMode { + switch autofillNoticeMode(strings.TrimSpace(value)) { + case autofillNoticeApprovals: + return autofillNoticeApprovals + case autofillNoticeSuppressed: + return autofillNoticeSuppressed + default: + return autofillNoticeAll + } +} + +func parseAutofillFirstFillApprovalMode(raw string) autofillFirstFillApprovalMode { + switch autofillFirstFillApprovalMode(strings.TrimSpace(raw)) { + case autofillFirstFillApprovalAsk, autofillFirstFillApprovalAllow, autofillFirstFillApprovalBlock: + return autofillFirstFillApprovalMode(strings.TrimSpace(raw)) + default: + return "" + } +} + +func autofillPrivacyLines(text string) []string { + lines := strings.Split(text, "\n") + result := make([]string, 0, len(lines)) + seen := make(map[string]struct{}, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if _, ok := seen[line]; ok { + continue + } + seen[line] = struct{}{} + result = append(result, line) + } + return result +} + +func joinAutofillPrivacyLines(lines []string) string { + if len(lines) == 0 { + return "" + } + return strings.Join(autofillPrivacyLines(strings.Join(lines, "\n")), "\n") +} + +func (u *ui) autofillRuleCount() int { + return len(autofillPrivacyLines(u.autofillBrowserAllowlist.Text())) + + len(autofillPrivacyLines(u.autofillAppAllowlist.Text())) + + len(autofillPrivacyLines(u.autofillPackageRules.Text())) +} + +func (u *ui) autofillFirstFillApprovalSummary() string { + switch u.autofillFirstFillApprovalMode { + case autofillFirstFillApprovalAllow: + return "New apps and packages can fill immediately until a persistent rule is created." + case autofillFirstFillApprovalBlock: + return "New apps and packages stay blocked until you add an allowlist entry or a package rule." + default: + return "KeePassGO asks before the first fill into a newly seen app or package." + } +} + +func (u *ui) setStatusBannerTTL(value time.Duration) { + u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond)) + u.saveUIPreferences() +} + +func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) { + u.autofillNoticePreference = normalizedAutofillNoticeMode(string(value)) + u.saveUIPreferences() +} + +func (u *ui) noteRecentRemote(baseURL, path string) { + baseURL = strings.TrimSpace(baseURL) + path = strings.TrimSpace(path) + if baseURL == "" || path == "" { + return + } + record := recentRemoteRecord{ + BaseURL: baseURL, + Path: path, + 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) + } + next := []recentRemoteRecord{record} + for _, existing := range u.recentRemotes { + if existing.BaseURL == baseURL && existing.Path == path { + continue + } + next = append(next, existing) + if len(next) == 6 { + break + } + } + u.recentRemotes = next + if len(u.recentRemoteClicks) < len(u.recentRemotes) { + u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) + } + u.saveRecentRemotes() +} + +func (u *ui) recentRemoteGroup(baseURL, path string) []string { + baseURL = strings.TrimSpace(baseURL) + path = strings.TrimSpace(path) + for _, record := range u.recentRemotes { + if record.BaseURL == baseURL && record.Path == path { + return append([]string(nil), record.LastGroup...) + } + } + return nil +} + +func (u *ui) restoreStartupLifecycleTarget() { + localPath, localUsedAt := u.latestRecentVault() + remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote() + + switch { + 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) + } +} + +func (u *ui) hasSelectedLifecycleTarget() bool { + switch strings.TrimSpace(u.lifecycleMode) { + case "remote": + return u.hasSelectedRemoteTarget() + default: + return strings.TrimSpace(u.vaultPath.Text()) != "" + } +} + +func (u *ui) hasSelectedRemoteTarget() bool { + return u.selectedRemoteConnection +} + +func (u *ui) latestRecentVault() (string, time.Time) { + for _, path := range u.recentVaults { + if strings.TrimSpace(path) == "" { + continue + } + return path, u.recentVaultUsedAt[path] + } + return "", time.Time{} +} + +func (u *ui) hasSelectedVaultPath() bool { + return strings.TrimSpace(u.vaultPath.Text()) != "" +} + +func (u *ui) showLocalVaultChooser() bool { + return u.lifecycleMode != "local" || !u.hasSelectedVaultPath() +} + +func (u *ui) showRemoteConnectionChooser() bool { + return u.lifecycleMode != "remote" || !u.hasSelectedRemoteTarget() +} + +func (u *ui) switchToLifecycleSelection(mode string) { + u.state.Session = &session.Manager{} + u.state.CurrentPath = nil + u.state.SelectedEntryID = "" + u.state.Section = appstate.SectionEntries + u.state.Dirty = false + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.loadingMessage = "" + u.loadingActionLabel = "" + u.lastLifecycleAction = "" + u.lifecycleMode = mode + u.editingEntry = false + u.currentPath = nil + u.syncedPath = nil + u.clearMasterPassword() + u.keyFilePath.SetText("") + u.search.SetText("") + switch mode { + case "remote": + u.vaultPath.SetText("") + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") + u.selectedRemoteConnection = false + default: + u.vaultPath.SetText("") + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") + u.selectedRemoteConnection = false + } + u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() + u.filter() +} + +func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" { + continue + } + usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)) + if err != nil { + usedAt = time.Time{} + } + return record, true, usedAt + } + return recentRemoteRecord{}, false, time.Time{} +} + +func (u *ui) currentRemoteRecord() recentRemoteRecord { + return recentRemoteRecord{ + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Path: strings.TrimSpace(u.remotePath.Text()), + } +} + +func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { + u.remoteBaseURL.SetText(record.BaseURL) + u.remotePath.SetText(record.Path) + 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.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 { + switch { + case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": + 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: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." + } +} + +func (u *ui) remotePreferencesAlwaysSavedSummary() string { + 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. 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 remoteCredentialURLMatches(candidate, target string) bool { + candidate = normalizeRemoteCredentialURL(candidate) + target = normalizeRemoteCredentialURL(target) + if candidate == "" || target == "" { + return false + } + if candidate == target { + return true + } + candidateURL, err := url.Parse(candidate) + if err != nil { + return false + } + targetURL, err := url.Parse(target) + if err != nil { + return false + } + if !strings.EqualFold(candidateURL.Hostname(), targetURL.Hostname()) { + return false + } + candidatePath := strings.TrimRight(candidateURL.EscapedPath(), "/") + targetPath := strings.TrimRight(targetURL.EscapedPath(), "/") + if candidatePath == "" || candidatePath == "/" || targetPath == "" || targetPath == "/" { + return true + } + return strings.HasPrefix(targetPath, candidatePath) || strings.HasPrefix(candidatePath, targetPath) +} + +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 := u.remoteCredentialEntryMap(entries) + matches := make([]vault.Entry, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) + appendMatch := func(entry vault.Entry) { + u.appendRemoteCredentialMatch(&matches, seen, entry) + } + u.appendURLMatchedRemoteCredentials(baseURL, entries, appendMatch) + profilesByID := u.remoteProfileMap() + 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 !remoteCredentialURLMatches(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) validRemoteProfileSelection(profiles []vault.RemoteProfile) string { + selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + if u.hasRemoteProfileSelection(selectedID, profiles) { + return selectedID + } + if len(profiles) == 1 { + return profiles[0].ID + } + return "" +} + +func (u *ui) validRemoteCredentialSelection(entries []vault.Entry) string { + selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + if u.hasRemoteCredentialSelection(selectedID, entries) { + return selectedID + } + if len(entries) == 1 { + return entries[0].ID + } + return "" +} + +func (u *ui) hasRemoteProfileSelection(selectedID string, profiles []vault.RemoteProfile) bool { + for _, profile := range profiles { + if profile.ID == selectedID { + return true + } + } + return false +} + +func (u *ui) hasRemoteCredentialSelection(selectedID string, entries []vault.Entry) bool { + for _, entry := range entries { + if entry.ID == selectedID { + return true + } + } + return false +} + +func (u *ui) applySelectedRemoteProfileFields() { + if profile, ok := u.selectedVaultRemoteProfile(); ok { + u.remoteBaseURL.SetText(profile.BaseURL) + u.remotePath.SetText(profile.Path) + } +} + +func (u *ui) syncRecentRemoteBindingSelection() { + if strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" && strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != "" { + return + } + record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())) + if !ok { + return + } + u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID) + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID) + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + u.applySelectedRemoteProfileFields() +} + +func (u *ui) syncSelectedRemoteBindingMode() { + binding, ok := u.selectedVaultRemoteBinding() + if !ok { + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual + return + } + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.LocalVaultPath) == strings.TrimSpace(binding.LocalVaultPath) && + strings.TrimSpace(record.RemoteProfileID) == strings.TrimSpace(binding.RemoteProfileID) && + strings.TrimSpace(record.CredentialEntryID) == strings.TrimSpace(binding.CredentialEntryID) { + u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) + return + } + } + u.selectedVaultRemoteSyncMode = appstate.SyncModeManual +} + +func (u *ui) remoteCredentialEntryMap(entries []vault.Entry) map[string]vault.Entry { + byID := make(map[string]vault.Entry, len(entries)) + for _, entry := range entries { + byID[entry.ID] = entry + } + return byID +} + +func (u *ui) remoteProfileMap() map[string]vault.RemoteProfile { + profilesByID := make(map[string]vault.RemoteProfile) + for _, profile := range u.availableRemoteProfiles() { + profilesByID[profile.ID] = profile + } + return profilesByID +} + +func (u *ui) appendRemoteCredentialMatch(matches *[]vault.Entry, seen map[string]struct{}, entry vault.Entry) { + if strings.TrimSpace(entry.ID) == "" { + return + } + if _, ok := seen[entry.ID]; ok { + return + } + seen[entry.ID] = struct{}{} + *matches = append(*matches, entry) +} + +func (u *ui) appendURLMatchedRemoteCredentials(baseURL string, entries []vault.Entry, appendMatch func(vault.Entry)) { + for _, entry := range entries { + if remoteCredentialURLMatches(entry.URL, baseURL) { + appendMatch(entry) + } + } +} + +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.syncRemoteUsername.SetText(resolved.Credentials.Username) + u.syncRemotePassword.SetText(resolved.Credentials.Password) + u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(resolved.Credentials.ID) +} + +func (u *ui) syncDialogTitle() string { + switch { + case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Remote Sync Settings" + } + return "Set Up Remote Sync" + default: + return "Advanced Sync" + } +} + +func (u *ui) syncDialogDescription() string { + switch { + case 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." + default: + 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 { + switch { + case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: + if _, ok := u.selectedVaultRemoteBinding(); ok { + return "Save Remote Sync Settings" + } + return "Set Up Remote Sync" + default: + 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) { + u.selectedVaultRemoteProfileID = strings.TrimSpace(id) + u.applySelectedRemoteProfileFields() +} + +func (u *ui) selectVaultRemoteCredentialEntry(id string) { + u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id) +} + +func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) { + profiles := u.availableRemoteProfiles() + id := u.validRemoteProfileSelection(profiles) + if id == "" { + return vault.RemoteProfile{}, false + } + for _, profile := range profiles { + if profile.ID == id { + return profile, true + } + } + return vault.RemoteProfile{}, false +} + +func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) { + entries := u.availableRemoteCredentialEntries() + id := u.validRemoteCredentialSelection(entries) + if id == "" { + return vault.Entry{}, false + } + for _, entry := range entries { + if entry.ID == id { + return entry, true + } + } + return vault.Entry{}, false +} + +func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) { + localVaultPath := strings.TrimSpace(u.vaultPath.Text()) + profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID) + if profileID == "" { + profileID = u.validRemoteProfileSelection(u.availableRemoteProfiles()) + } + credentialID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) + if credentialID == "" { + credentialID = u.validRemoteCredentialSelection(u.availableRemoteCredentialEntries()) + } + if profileID == "" || credentialID == "" { + return appstate.RemoteBinding{}, false + } + if localVaultPath == "" { + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.RemoteProfileID) == profileID && + strings.TrimSpace(record.CredentialEntryID) == credentialID && + strings.TrimSpace(record.LocalVaultPath) != "" { + localVaultPath = strings.TrimSpace(record.LocalVaultPath) + break + } + } + } + if localVaultPath == "" { + localVaultPath, _ = u.latestRecentVault() + } + return appstate.RemoteBinding{ + LocalVaultPath: localVaultPath, + RemoteProfileID: profileID, + CredentialEntryID: credentialID, + 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 u.syncDialogPurpose == syncDialogPurposeRemoteSetup { + return u.syncSetupMode() + } + return normalizeUISyncMode(u.selectedVaultRemoteSyncMode) +} + +func (u *ui) syncSavedRemoteBindingSelection() { + profiles := u.availableRemoteProfiles() + entries := u.availableRemoteCredentialEntries() + u.syncRecentRemoteBindingSelection() + u.selectedVaultRemoteProfileID = u.validRemoteProfileSelection(profiles) + u.selectedVaultRemoteCredentialEntryID = u.validRemoteCredentialSelection(entries) + u.syncSelectedRemoteBindingMode() + u.applySelectedRemoteProfileFields() +} + +func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) { + return boundRecentRemoteForLocalVaultRecords(u.recentRemotes, path) +} + +func hasBoundRecentRemote(records []recentRemoteRecord, path string) bool { + _, ok := boundRecentRemoteForLocalVaultRecords(records, path) + return ok +} + +func boundRecentRemoteForLocalVaultRecords(records []recentRemoteRecord, path string) (recentRemoteRecord, bool) { + path = strings.TrimSpace(path) + if path == "" { + return recentRemoteRecord{}, false + } + for _, record := range records { + if strings.TrimSpace(record.LocalVaultPath) == path && + strings.TrimSpace(record.RemoteProfileID) != "" && + strings.TrimSpace(record.CredentialEntryID) != "" { + return record, true + } + } + return recentRemoteRecord{}, false +} + +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) { + summary := u.computeSavedRemoteBindingSummary() + return summary.ProfileLabel, summary.CredentialLabel, summary.SyncLabel, summary.OK +} + +func (u *ui) savedRemoteBindingHeading() string { + return u.buildSyncMenuModel().SavedBindingHeading() +} + +func (u *ui) openSelectedVaultRemoteButtonLabel() string { + return u.buildSyncMenuModel().OpenSelectedButtonLabel() +} + +func (u *ui) shouldShowDirectRemoteSyncShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + return u.buildSyncMenuModel().ShowDirectRemoteSyncShortcut() +} + +func (u *ui) directRemoteSyncShortcutLabel() string { + return u.buildSyncMenuModel().DirectRemoteSyncShortcutLabel() +} + +func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + return u.buildSyncMenuModel().ShowRemoteSyncSettingsShortcut() +} + +func (u *ui) remoteSyncSettingsShortcutLabel() string { + return u.buildSyncMenuModel().RemoteSyncSettingsShortcutLabel() +} + +func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool { + return u.buildSyncMenuModel().ShowRemoveRemoteSyncShortcut() +} + +func (u *ui) removeRemoteSyncShortcutLabel() string { + return u.buildSyncMenuModel().RemoveRemoteSyncShortcutLabel() +} + +func (u *ui) shouldShowRemoteSyncSetupShortcut() bool { + if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + return false + } + return u.buildSyncMenuModel().ShowRemoteSyncSetupShortcut() +} + +func (u *ui) remoteSyncSetupShortcutLabel() string { + return u.buildSyncMenuModel().RemoteSyncSetupShortcutLabel() +} + +func (u *ui) syncMenuActionLabels() []string { + return u.buildSyncMenuModel().ActionLabels() +} + +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 u.buildSyncMenuModel().SaveCurrentRemoteBindingHeading() +} + +func (u *ui) saveCurrentRemoteBindingButtonLabel() string { + return u.buildSyncMenuModel().SaveCurrentRemoteBindingButtonLabel() +} + +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() { + status, ok := u.state.Session.(sessionStatus) + if !ok || !status.IsRemote() || status.IsLocked() { + return + } + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + path := strings.TrimSpace(u.remotePath.Text()) + if baseURL == "" || path == "" { + return + } + for i := range u.recentRemotes { + if u.recentRemotes[i].BaseURL != baseURL || u.recentRemotes[i].Path != path { + continue + } + u.recentRemotes[i].LastGroup = append([]string(nil), u.currentPath...) + u.saveRecentRemotes() + return + } +} + +func (u *ui) recentVaultGroup(path string) []string { + if u.recentVaultGroups == nil { + return nil + } + return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...) +} + +func (u *ui) hiddenVaultRoot() string { + if u.state.Section != appstate.SectionEntries { + return "" + } + model, err := u.state.Session.Current() + if err != nil { + return "" + } + if len(model.EntriesInPath(nil)) != 0 { + return "" + } + groups := model.ChildGroups(nil) + if len(groups) != 1 { + return "" + } + return groups[0] +} + +func (u *ui) enterHiddenVaultRoot() { + root := u.hiddenVaultRoot() + if root == "" { + return + } + u.setCurrentPath([]string{root}) +} + +func (u *ui) restoreRecentVaultGroup(path string) { + saved := u.recentVaultGroup(path) + if len(saved) == 0 { + u.enterHiddenVaultRoot() + return + } + model, err := u.state.Session.Current() + if err != nil { + u.enterHiddenVaultRoot() + return + } + root := u.hiddenVaultRoot() + if len(saved) == 1 && root != "" && saved[0] == root { + u.setCurrentPath(saved) + return + } + if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { + u.setCurrentPath(saved) + return + } + u.enterHiddenVaultRoot() +} + +func (u *ui) restoreRecentRemoteGroup(baseURL, path string) { + saved := u.recentRemoteGroup(baseURL, path) + if len(saved) == 0 { + u.enterHiddenVaultRoot() + return + } + model, err := u.state.Session.Current() + if err != nil { + u.enterHiddenVaultRoot() + return + } + root := u.hiddenVaultRoot() + if len(saved) == 1 && root != "" && saved[0] == root { + u.setCurrentPath(saved) + return + } + if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { + u.setCurrentPath(saved) + return + } + u.enterHiddenVaultRoot() +} + +func (u *ui) restoreEntriesPath(path []string) { + if len(path) == 0 { + u.enterHiddenVaultRoot() + return + } + model, err := u.state.Session.Current() + if err != nil { + u.enterHiddenVaultRoot() + return + } + root := u.hiddenVaultRoot() + if len(path) == 1 && root != "" && path[0] == root { + u.setCurrentPath(path) + return + } + if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { + u.setCurrentPath(path) + return + } + u.enterHiddenVaultRoot() +} + +func (u *ui) rememberEntriesSectionState() { + if u.state.Section != appstate.SectionEntries { + return + } + u.entriesState = entriesSectionState{ + Path: append([]string(nil), u.currentPath...), + SearchQuery: u.search.Text(), + SelectedEntryID: u.state.SelectedEntryID, + Editing: u.editingEntry, + } +} + +func (u *ui) restoreEntriesSectionState() { + u.search.SetText(u.entriesState.SearchQuery) + u.restoreEntriesPath(u.entriesState.Path) + u.state.SelectedEntryID = u.entriesState.SelectedEntryID + u.editingEntry = u.entriesState.Editing && strings.TrimSpace(u.entriesState.SelectedEntryID) != "" + if u.editingEntry || strings.TrimSpace(u.state.SelectedEntryID) != "" { + u.loadSelectedEntryIntoEditor() + } +} + +func (u *ui) displayPath() []string { + path := append([]string(nil), u.currentPath...) + root := u.hiddenVaultRoot() + if root == "" || len(path) == 0 || path[0] != root { + return path + } + return append([]string(nil), path[1:]...) +} + +func (u *ui) displayEntryPath(path []string) []string { + root := u.hiddenVaultRoot() + if root == "" || len(path) == 0 || path[0] != root { + return append([]string(nil), path...) + } + return append([]string(nil), path[1:]...) +} + +func (u *ui) currentGroupDisplayName() string { + displayPath := u.displayPath() + if len(displayPath) == 0 { + return "Vault root (/)" + } + return strings.Join(displayPath, " / ") +} + +func (u *ui) parentGroupDisplayName() string { + displayPath := u.displayPath() + if len(displayPath) <= 1 { + return "Vault root (/)" + } + return strings.Join(displayPath[:len(displayPath)-1], " / ") +} + +func (u *ui) createGroupLabel() string { + if len(u.displayPath()) == 0 { + return "Create Top-Level Group" + } + return "Create Subgroup" +} + +func pathHasPrefix(path, prefix []string) bool { + if len(prefix) > len(path) { + return false + } + return slices.Equal(path[:len(prefix)], prefix) +} + +func hasExactGroup(model vault.Model, path []string) bool { + for _, group := range model.Groups { + if slices.Equal(group, path) { + return true + } + } + return false +} + +func (u *ui) currentGroupDeletionState() (bool, string) { + u.syncCurrentPath() + if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil { + return false, "" + } + model, err := u.state.Session.Current() + if err != nil { + return false, "" + } + path := append([]string(nil), u.currentPath...) + if len(model.ChildGroups(path)) > 0 { + return false, "This group contains child groups. Move or delete them before removing the group." + } + for _, item := range model.Entries { + if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { + return false, "This group contains entries. Move or delete them before removing the group." + } + } + for _, item := range model.Templates { + if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { + return false, "This group contains templates. Move or delete them before removing the group." + } + } + return true, "Deleting this empty group will not remove any entries." +} + +func (u *ui) deleteGroupPendingConfirmation() bool { + return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) +} + +func (u *ui) clearDeleteGroupConfirmation() { + u.deleteGroupPath = nil +} + +func (u *ui) armDeleteCurrentGroupAction() { + if deletable, _ := u.currentGroupDeletionState(); !deletable { + return + } + u.syncCurrentPath() + u.deleteGroupPath = append([]string(nil), u.currentPath...) + u.state.ErrorMessage = "" + u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))) +} + +func (u *ui) runAction(label string, action func() error) { + if strings.TrimSpace(u.loadingMessage) != "" { + return + } + u.loadingMessage = actionLoadingLabel(label) + u.loadingActionLabel = strings.TrimSpace(label) + if err := action(); err != nil { + u.loadingMessage = "" + u.loadingActionLabel = "" + u.state.ErrorMessage = u.describeActionError(label, err) + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return + } + u.loadingMessage = "" + u.loadingActionLabel = "" + u.syncAutofillCache() + u.state.ErrorMessage = "" + if suppressStatusMessage(label) { + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return + } + u.showStatusMessage(label + " complete") +} + +func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { + if strings.TrimSpace(u.loadingMessage) != "" { + return + } + u.backgroundActionSerial++ + actionID := u.backgroundActionSerial + u.activeBackgroundAction = actionID + u.loadingMessage = actionLoadingLabel(label) + u.loadingActionLabel = strings.TrimSpace(label) + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + go func() { + apply, err := prepare() + u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err, id: actionID} + if u.invalidate != nil { + u.invalidate() + } + }() +} + +func (u *ui) applyBackgroundResult(result backgroundActionResult) { + if result.id != 0 && result.id != u.activeBackgroundAction { + return + } + u.activeBackgroundAction = 0 + u.loadingMessage = "" + u.loadingActionLabel = "" + if result.err != nil { + u.state.ErrorMessage = u.describeActionError(result.label, result.err) + if strings.HasPrefix(result.label, "open ") { + u.requestMasterPassFocus = true + } + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return + } + if result.apply != nil { + if err := result.apply(); err != nil { + u.state.ErrorMessage = u.describeActionError(result.label, err) + if strings.HasPrefix(result.label, "open ") { + u.requestMasterPassFocus = true + } + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return + } + } + u.syncAutofillCache() + u.state.ErrorMessage = "" + if suppressStatusMessage(result.label) { + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + return + } + u.showStatusMessage(result.label + " complete") +} + +func (u *ui) cancelLifecycleBusyState() { + if !u.lifecycleBusy() { + return + } + u.activeBackgroundAction = 0 + u.loadingMessage = "" + u.loadingActionLabel = "" + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + u.requestMasterPassFocus = true +} + +func (u *ui) retryLastLifecycleOpen() { + switch strings.TrimSpace(u.lastLifecycleAction) { + case "open vault": + u.startOpenVaultAction() + case "open remote vault": + u.startOpenRemoteAction() + } +} + +func (u *ui) canRetryLifecycleOpen() bool { + if !u.shouldShowLifecycleSetup() || u.lifecycleBusy() || strings.TrimSpace(u.state.ErrorMessage) == "" { + return false + } + switch strings.TrimSpace(u.lastLifecycleAction) { + case "open vault", "open remote vault": + return true + default: + return false + } +} + +func (u *ui) processBackgroundActions() { + for { + select { + case result := <-u.backgroundResults: + u.applyBackgroundResult(result) + default: + return + } + } +} + +func (u *ui) syncAutofillCache() { + if strings.TrimSpace(u.autofillCachePath) == "" { + return + } + model, err := u.state.Session.Current() + if err != nil { + _ = autofillcache.Clear(u.autofillCachePath) + return + } + _ = autofillcache.Write(u.autofillCachePath, model, u.now()) +} + +func suppressStatusMessage(label string) bool { + switch strings.TrimSpace(label) { + case "open vault", "open remote vault": + return true + default: + return false + } +} + +func actionLoadingLabel(label string) string { + label = strings.TrimSpace(label) + if label == "" { + return "Working..." + } + runes := []rune(label) + runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] + return string(runes) + "..." +} + +func (u *ui) describeActionError(label string, err error) string { + if err == nil { + return "" + } + if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) { + return "Save conflict: the remote vault changed. Reopen it and retry the save." + } + if label == "open remote vault" { + return fmt.Sprintf("%s failed: %v", label, err) + } + return err.Error() +} + +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(): + if u.selectedRemoteUsesLocalCache() { + return "Opening Cached Vault..." + } + return "Creating Local Cache..." + case u.remoteOpenRetryAvailable(): + if u.selectedRemoteUsesLocalCache() { + return "Retry Cached Vault" + } + return "Retry Local Cache Setup" + default: + 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." +} diff --git a/internal/appui/ui_runtime.go b/internal/appui/ui_runtime.go new file mode 100644 index 0000000..450d2fa --- /dev/null +++ b/internal/appui/ui_runtime.go @@ -0,0 +1,191 @@ +package appui + +import ( + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "gioui.org/app" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/x/explorer" + "git.julianfamily.org/keepassgo/internal/api" + "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apitokens" + "git.julianfamily.org/keepassgo/internal/appui/platform" + "git.julianfamily.org/keepassgo/internal/passwords" + "git.julianfamily.org/keepassgo/internal/session" + "git.julianfamily.org/keepassgo/internal/vault" +) + +func Main() { + mode := flag.String("mode", "", "window mode: desktop or phone") + stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") + grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") + flag.Parse() + + resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) + resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") + resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS)) + + width := unit.Dp(1180) + height := unit.Dp(760) + if strings.EqualFold(resolvedMode, "phone") { + width = unit.Dp(412) + height = unit.Dp(915) + } + + go func() { + w := new(app.Window) + options := []app.Option{app.Title(productName)} + if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) { + options = append(options, app.Size(width, height)) + } + w.Option(options...) + if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil { + panic(err) + } + if !strings.EqualFold(runtime.GOOS, "android") { + os.Exit(0) + } + }() + app.Main() +} + +func defaultGRPCAddr(goos string) string { + if strings.EqualFold(strings.TrimSpace(goos), "android") { + return "off" + } + return "127.0.0.1:47777" +} + +func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { + var ops op.Ops + manager := &session.Manager{} + ui := newUIWithSession(mode, manager, paths) + ui.fileExplorer = explorer.NewExplorer(w) + ui.invalidate = w.Invalidate + ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate) + host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) + if err != nil { + ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) + } else if host != nil { + ui.apiHost = host + ui.auditLog = host.Server().AuditLog() + ui.grpcAddress = host.Address() + ui.state.Approvals = &uiApprovalManager{server: host.Server()} + defer func() { _ = host.Stop() }() + } + for { + e := w.Event() + ui.fileExplorer.ListenEvents(e) + switch e := e.(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + ui.processBackgroundActions() + ui.layout(gtx) + platform.ProcessClipboardWrites(gtx, ui.clipboardWriter) + e.Frame(gtx.Ops) + } + } +} + +type uiApprovalManager struct { + server *api.Server +} + +func (m *uiApprovalManager) Pending() []apiapproval.Request { + if m == nil || m.server == nil { + return nil + } + return m.server.ApprovalBroker().Pending() +} + +func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { + if m == nil || m.server == nil { + return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured") + } + return m.server.ResolveApproval(id, outcome) +} + +type uiSession struct { + model vault.Model + locked bool +} + +func (s *uiSession) HasVault() bool { + return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked +} + +func (s *uiSession) IsLocked() bool { + return s.locked +} + +func (s *uiSession) IsRemote() bool { + return false +} + +func (s *uiSession) Current() (vault.Model, error) { + if s.locked { + return vault.Model{}, session.ErrLocked + } + return s.model, nil +} + +func (s *uiSession) Replace(model vault.Model) { + s.model = model +} + +func (s *uiSession) Lock() error { + s.locked = true + return nil +} + +func (s *uiSession) Unlock(vault.MasterKey) error { + if !s.locked { + return nil + } + s.locked = false + return nil +} + +func pickExistingFile() (string, error) { + if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil { + return path, nil + } + if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil { + return path, nil + } + return "", fmt.Errorf("no supported file picker found; install kdialog or zenity") +} + +func runFilePicker(name string, args ...string) (string, error) { + if _, err := exec.LookPath(name); err != nil { + return "", err + } + cmd := exec.Command(name, args...) + output, err := cmd.Output() + if err != nil { + return "", err + } + 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") +} From 2f1cd7876c03eab9c5bbc237d4f6393d941c52d1 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 13:20:12 -0700 Subject: [PATCH 41/53] Normalize app UI pane packages --- internal/appui/actions/sync_menu.go | 95 ----- internal/appui/api/model.go | 90 +++++ internal/appui/{ui_api.go => api_views.go} | 97 +---- internal/appui/app.go | 70 ++-- .../{layout/detail => detail/layout}/mode.go | 2 +- internal/appui/detail/model.go | 17 + internal/appui/editor/model.go | 64 ++++ .../appui/{ui_editor.go => entry_editor.go} | 0 internal/appui/{ui_frame.go => frame.go} | 0 internal/appui/header.go | 176 +++++++++ .../header => header/layout}/dropdown.go | 2 +- internal/appui/header_main_menu.go | 64 ++++ internal/appui/header_menu_layout.go | 52 +++ ...i_layout_header.go => header_sync_menu.go} | 336 +++--------------- internal/appui/{ui_keyboard.go => input.go} | 114 ++++-- internal/appui/lifecycle/model.go | 9 + ...ions_lifecycle.go => lifecycle_actions.go} | 0 .../appui/{ui_forms.go => lifecycle_forms.go} | 17 + .../{layout/list => list/layout}/sections.go | 2 +- internal/appui/list/model.go | 8 + internal/appui/main_test.go | 4 +- .../{ui_recent_state.go => recent_state.go} | 0 internal/appui/{ui_runtime.go => runtime.go} | 0 .../appui/{ui_preferences.go => settings.go} | 149 ++++++-- internal/appui/settings/model.go | 50 +++ internal/appui/sync/model.go | 119 +++++++ .../{ui_sync_dialog.go => sync_dialog.go} | 0 internal/appui/ui_accessibility.go | 135 ------- internal/appui/ui_branding.go | 44 --- internal/appui/ui_layout_lifecycle.go | 24 -- internal/appui/ui_shortcuts.go | 83 ----- internal/appui/ui_sync_menu_actions.go | 51 --- 32 files changed, 961 insertions(+), 913 deletions(-) delete mode 100644 internal/appui/actions/sync_menu.go create mode 100644 internal/appui/api/model.go rename internal/appui/{ui_api.go => api_views.go} (93%) rename internal/appui/{layout/detail => detail/layout}/mode.go (96%) create mode 100644 internal/appui/detail/model.go create mode 100644 internal/appui/editor/model.go rename internal/appui/{ui_editor.go => entry_editor.go} (100%) rename internal/appui/{ui_frame.go => frame.go} (100%) create mode 100644 internal/appui/header.go rename internal/appui/{layout/header => header/layout}/dropdown.go (99%) create mode 100644 internal/appui/header_main_menu.go create mode 100644 internal/appui/header_menu_layout.go rename internal/appui/{ui_layout_header.go => header_sync_menu.go} (50%) rename internal/appui/{ui_keyboard.go => input.go} (72%) create mode 100644 internal/appui/lifecycle/model.go rename internal/appui/{ui_actions_lifecycle.go => lifecycle_actions.go} (100%) rename internal/appui/{ui_forms.go => lifecycle_forms.go} (99%) rename internal/appui/{layout/list => list/layout}/sections.go (94%) create mode 100644 internal/appui/list/model.go rename internal/appui/{ui_recent_state.go => recent_state.go} (100%) rename internal/appui/{ui_runtime.go => runtime.go} (100%) rename internal/appui/{ui_preferences.go => settings.go} (65%) create mode 100644 internal/appui/settings/model.go create mode 100644 internal/appui/sync/model.go rename internal/appui/{ui_sync_dialog.go => sync_dialog.go} (100%) delete mode 100644 internal/appui/ui_accessibility.go delete mode 100644 internal/appui/ui_branding.go delete mode 100644 internal/appui/ui_layout_lifecycle.go delete mode 100644 internal/appui/ui_shortcuts.go delete mode 100644 internal/appui/ui_sync_menu_actions.go diff --git a/internal/appui/actions/sync_menu.go b/internal/appui/actions/sync_menu.go deleted file mode 100644 index 15a56e0..0000000 --- a/internal/appui/actions/sync_menu.go +++ /dev/null @@ -1,95 +0,0 @@ -package actions - -import "git.julianfamily.org/keepassgo/internal/appstate" - -type SyncMenuModel struct { - HasOpenVault bool - HasSelectedBinding bool - ShowSelectors bool - ShowShare bool - ShowSaveCurrentBinding bool - SavedBindingSummary SyncMenuBindingSummary - RemoteBaseURL string - RemotePath string - RemoteUsername string - RemotePassword string - SelectedVaultSyncMode appstate.SyncMode -} - -type SyncMenuBindingSummary struct { - ProfileLabel string - CredentialLabel string - SyncLabel string - OK bool -} - -func (m SyncMenuModel) SavedBindingHeading() string { - if !m.ShowSelectors { - return "Use this vault's saved remote sync target" - } - return "Use a saved remote profile from this vault" -} - -func (m SyncMenuModel) OpenSelectedButtonLabel() string { - if !m.ShowSelectors { - return "Use Remote Sync" - } - return "Open Saved Remote" -} - -func (m SyncMenuModel) ShowDirectRemoteSyncShortcut() bool { - return m.HasOpenVault && m.HasSelectedBinding -} - -func (m SyncMenuModel) DirectRemoteSyncShortcutLabel() string { - return "Use Remote Sync" -} - -func (m SyncMenuModel) ShowRemoteSyncSettingsShortcut() bool { - return m.HasOpenVault && m.HasSelectedBinding -} - -func (m SyncMenuModel) RemoteSyncSettingsShortcutLabel() string { - return "Remote Sync Settings" -} - -func (m SyncMenuModel) ShowRemoveRemoteSyncShortcut() bool { - return m.ShowRemoteSyncSettingsShortcut() -} - -func (m SyncMenuModel) RemoveRemoteSyncShortcutLabel() string { - return "Stop Using Remote Sync" -} - -func (m SyncMenuModel) ShowRemoteSyncSetupShortcut() bool { - return m.HasOpenVault && !m.HasSelectedBinding -} - -func (m SyncMenuModel) RemoteSyncSetupShortcutLabel() string { - return "Set Up Remote Sync" -} - -func (m SyncMenuModel) ActionLabels() []string { - labels := []string{"Open Advanced Sync"} - if m.ShowRemoteSyncSetupShortcut() { - labels = append(labels, m.RemoteSyncSetupShortcutLabel()) - } - if m.ShowDirectRemoteSyncShortcut() { - labels = append(labels, m.DirectRemoteSyncShortcutLabel()) - } - if m.ShowRemoteSyncSettingsShortcut() { - labels = append(labels, m.RemoteSyncSettingsShortcutLabel()) - } - if m.ShowRemoveRemoteSyncShortcut() { - labels = append(labels, m.RemoveRemoteSyncShortcutLabel()) - } - return labels -} - -func (m SyncMenuModel) SaveCurrentRemoteBindingHeading() string { - return "Bind this local vault to the current remote target" -} - -func (m SyncMenuModel) SaveCurrentRemoteBindingButtonLabel() string { - return "Save Remote In Vault" -} diff --git a/internal/appui/api/model.go b/internal/appui/api/model.go new file mode 100644 index 0000000..3ccb55e --- /dev/null +++ b/internal/appui/api/model.go @@ -0,0 +1,90 @@ +package api + +import ( + "strings" + + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" +) + +type AuditQuickFilter struct { + Label string + Query string +} + +func Operations() []apitokens.Operation { + return []apitokens.Operation{ + apitokens.OperationListEntries, + apitokens.OperationListGroups, + apitokens.OperationReadEntry, + apitokens.OperationCopyPassword, + apitokens.OperationCopyUsername, + apitokens.OperationCopyURL, + apitokens.OperationMutateEntry, + apitokens.OperationMutateGroup, + apitokens.OperationManageVault, + } +} + +func AuditDecisionLabel(eventType apiaudit.EventType) string { + switch eventType { + case apiaudit.EventApprovalRequested: + return "Requested" + case apiaudit.EventApprovalAllowed: + return "Allowed" + case apiaudit.EventApprovalDenied: + return "Denied" + case apiaudit.EventApprovalCanceled: + return "Canceled" + case apiaudit.EventApprovalTimedOut: + return "Timed Out" + case apiaudit.EventAuthRejected: + return "Auth Rejected" + default: + return strings.ReplaceAll(string(eventType), "_", " ") + } +} + +func AuditOperationLabel(operation apitokens.Operation) string { + if strings.TrimSpace(string(operation)) == "" { + return "Other" + } + return strings.ReplaceAll(string(operation), "_", " ") +} + +func CompactAuditFilterLabel(label string) string { + label = strings.TrimSpace(label) + if len(label) <= 22 { + return label + } + return label[:19] + "..." +} + +func AuditEventSearchTerms(event apiaudit.Event) string { + parts := []string{ + string(event.Type), + AuditDecisionLabel(event.Type), + event.TokenName, + event.ClientName, + string(event.Operation), + AuditOperationLabel(event.Operation), + strings.Join(event.Resource.Path, " / "), + event.Resource.EntryID, + event.Message, + } + switch event.Type { + case apiaudit.EventApprovalAllowed: + parts = append(parts, "allow approved") + case apiaudit.EventApprovalDenied: + parts = append(parts, "deny denied") + case apiaudit.EventApprovalRequested: + parts = append(parts, "prompt requested") + case apiaudit.EventApprovalCanceled: + parts = append(parts, "cancel canceled") + case apiaudit.EventApprovalTimedOut: + parts = append(parts, "timeout timed out") + case apiaudit.EventAuthRejected: + parts = append(parts, "rejected unauthorized") + } + return strings.ToLower(strings.Join(parts, " ")) +} diff --git a/internal/appui/ui_api.go b/internal/appui/api_views.go similarity index 93% rename from internal/appui/ui_api.go rename to internal/appui/api_views.go index e2c015d..fc8c876 100644 --- a/internal/appui/ui_api.go +++ b/internal/appui/api_views.go @@ -12,89 +12,10 @@ import ( "gioui.org/widget/material" "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" + apiui "git.julianfamily.org/keepassgo/internal/appui/api" ) -func apiOperations() []apitokens.Operation { - return []apitokens.Operation{ - apitokens.OperationListEntries, - apitokens.OperationListGroups, - apitokens.OperationReadEntry, - apitokens.OperationCopyPassword, - apitokens.OperationCopyUsername, - apitokens.OperationCopyURL, - apitokens.OperationMutateEntry, - apitokens.OperationMutateGroup, - apitokens.OperationManageVault, - } -} - -type apiAuditQuickFilter struct { - Label string - Query string -} - -func apiAuditDecisionLabel(eventType apiaudit.EventType) string { - switch eventType { - case apiaudit.EventApprovalRequested: - return "Requested" - case apiaudit.EventApprovalAllowed: - return "Allowed" - case apiaudit.EventApprovalDenied: - return "Denied" - case apiaudit.EventApprovalCanceled: - return "Canceled" - case apiaudit.EventApprovalTimedOut: - return "Timed Out" - case apiaudit.EventAuthRejected: - return "Auth Rejected" - default: - return strings.ReplaceAll(string(eventType), "_", " ") - } -} - -func apiAuditOperationLabel(operation apitokens.Operation) string { - if strings.TrimSpace(string(operation)) == "" { - return "Other" - } - return strings.ReplaceAll(string(operation), "_", " ") -} - -func compactAuditFilterLabel(label string) string { - label = strings.TrimSpace(label) - if len(label) <= 22 { - return label - } - return label[:19] + "..." -} - -func apiAuditEventSearchTerms(event apiaudit.Event) string { - parts := []string{ - string(event.Type), - apiAuditDecisionLabel(event.Type), - event.TokenName, - event.ClientName, - string(event.Operation), - apiAuditOperationLabel(event.Operation), - strings.Join(event.Resource.Path, " / "), - event.Resource.EntryID, - event.Message, - } - switch event.Type { - case apiaudit.EventApprovalAllowed: - parts = append(parts, "allow approved") - case apiaudit.EventApprovalDenied: - parts = append(parts, "deny denied") - case apiaudit.EventApprovalRequested: - parts = append(parts, "prompt requested") - case apiaudit.EventApprovalCanceled: - parts = append(parts, "cancel canceled") - case apiaudit.EventApprovalTimedOut: - parts = append(parts, "timeout timed out") - case apiaudit.EventAuthRejected: - parts = append(parts, "rejected unauthorized") - } - return strings.ToLower(strings.Join(parts, " ")) -} +type apiAuditQuickFilter = apiui.AuditQuickFilter func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable { if len(filters) == 0 { @@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte } if _, ok := decisionSeen[event.Type]; !ok { decisionSeen[event.Type] = struct{}{} - label := apiAuditDecisionLabel(event.Type) + label := apiui.AuditDecisionLabel(event.Type) decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label}) } if strings.TrimSpace(string(event.Operation)) == "" { @@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte continue } operationSeen[event.Operation] = struct{}{} - label := apiAuditOperationLabel(event.Operation) + label := apiui.AuditOperationLabel(event.Operation) operations = append(operations, apiAuditQuickFilter{Label: label, Query: label}) } @@ -321,7 +242,7 @@ func parseAPITokenExpiry(text string) (*time.Time, error) { func parseAPIPolicyOperation(text string) (apitokens.Operation, error) { value := apitokens.Operation(strings.TrimSpace(text)) - for _, operation := range apiOperations() { + for _, operation := range apiui.Operations() { if operation == value { return value, nil } @@ -450,7 +371,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event { } filtered := make([]apiaudit.Event, 0, len(events)) for _, event := range events { - haystack := apiAuditEventSearchTerms(event) + haystack := apiui.AuditEventSearchTerms(event) if strings.Contains(haystack, query) { filtered = append(filtered, event) } @@ -785,7 +706,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters [] click := &buttons[i] selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query)) column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query) + return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query) })) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...) @@ -799,7 +720,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters [] click := &buttons[i] selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query)) flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query) + return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query) })) } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...) @@ -1051,7 +972,7 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)), + layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiui.Operations()), ", "), &u.apiPolicyOperation, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.apiPolicyGroupScopeW.Value { diff --git a/internal/appui/app.go b/internal/appui/app.go index 9a438b5..d1217ad 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -26,9 +26,13 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" - detaillayout "git.julianfamily.org/keepassgo/internal/appui/layout/detail" - listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" + detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail" + detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" + lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle" + listmodel "git.julianfamily.org/keepassgo/internal/appui/list" + listlayout "git.julianfamily.org/keepassgo/internal/appui/list/layout" "git.julianfamily.org/keepassgo/internal/appui/platform" + syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync" keepassassets "git.julianfamily.org/keepassgo/internal/assets" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" @@ -105,24 +109,17 @@ type uiSurface struct { Locked bool } -type lifecycleOpenIntent string +type lifecycleOpenIntent = lifecyclemodel.OpenIntent const ( - lifecycleOpenIntentNone lifecycleOpenIntent = "" - lifecycleOpenIntentRemoteSyncSetup lifecycleOpenIntent = "remote_sync_setup" - lifecycleOpenIntentRemoteSyncSettings lifecycleOpenIntent = "remote_sync_settings" + lifecycleOpenIntentNone = lifecyclemodel.OpenIntentNone + lifecycleOpenIntentRemoteSyncSetup = lifecyclemodel.OpenIntentRemoteSyncSetup + lifecycleOpenIntentRemoteSyncSettings = lifecyclemodel.OpenIntentRemoteSyncSettings ) -type emptyState struct { - Title string - Body string -} +type emptyState = detailmodel.EmptyState -type vaultSummary struct { - Title string - Detail string - Context string -} +type vaultSummary = detailmodel.VaultSummary type sessionStatus interface { HasVault() bool @@ -130,10 +127,7 @@ type sessionStatus interface { IsRemote() bool } -type attachmentItem struct { - Name string - Size int -} +type attachmentItem = detailmodel.AttachmentItem type statePaths struct { DefaultSaveAsPath string @@ -195,32 +189,27 @@ const ( autofillFirstFillApprovalBlock autofillFirstFillApprovalMode = "block" ) -type entriesSectionState struct { - Path []string - SearchQuery string - SelectedEntryID string - Editing bool -} +type entriesSectionState = listmodel.EntriesSectionState -type syncSourceMode string +type syncSourceMode = syncmodel.SourceMode const ( - syncSourceLocal syncSourceMode = "local" - syncSourceRemote syncSourceMode = "remote" + syncSourceLocal = syncmodel.SourceLocal + syncSourceRemote = syncmodel.SourceRemote ) -type syncDirection string +type syncDirection = syncmodel.Direction const ( - syncDirectionPull syncDirection = "pull" - syncDirectionPush syncDirection = "push" + syncDirectionPull = syncmodel.DirectionPull + syncDirectionPush = syncmodel.DirectionPush ) -type syncDialogPurpose string +type syncDialogPurpose = syncmodel.DialogPurpose const ( - syncDialogPurposeAdvanced syncDialogPurpose = "advanced" - syncDialogPurposeRemoteSetup syncDialogPurpose = "remote-setup" + syncDialogPurposeAdvanced = syncmodel.DialogPurposeAdvanced + syncDialogPurposeRemoteSetup = syncmodel.DialogPurposeRemoteSetup ) type ui struct { @@ -3106,18 +3095,7 @@ func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { } 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" - } - action := "Pull changes from" - if direction == syncDirectionPush { - action = "Push the current vault into" - } - return action + " " + sourceLabel + "." + return syncmodel.SummaryText(purpose, source, direction) } func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) layout.Dimensions { diff --git a/internal/appui/layout/detail/mode.go b/internal/appui/detail/layout/mode.go similarity index 96% rename from internal/appui/layout/detail/mode.go rename to internal/appui/detail/layout/mode.go index b19ea51..ed22688 100644 --- a/internal/appui/layout/detail/mode.go +++ b/internal/appui/detail/layout/mode.go @@ -1,4 +1,4 @@ -package detail +package layout type Mode string diff --git a/internal/appui/detail/model.go b/internal/appui/detail/model.go new file mode 100644 index 0000000..1a1736f --- /dev/null +++ b/internal/appui/detail/model.go @@ -0,0 +1,17 @@ +package detail + +type EmptyState struct { + Title string + Body string +} + +type VaultSummary struct { + Title string + Detail string + Context string +} + +type AttachmentItem struct { + Name string + Size int +} diff --git a/internal/appui/editor/model.go b/internal/appui/editor/model.go new file mode 100644 index 0000000..a21d1ed --- /dev/null +++ b/internal/appui/editor/model.go @@ -0,0 +1,64 @@ +package editor + +import "strings" + +type Field string + +const ( + FieldID Field = "id" + FieldTitle Field = "title" + FieldUsername Field = "username" + FieldPassword Field = "password" + FieldURL Field = "url" + FieldPath Field = "path" + FieldTags Field = "tags" + FieldPasswordProfile Field = "password-profile" + FieldNotes Field = "notes" + FieldFields Field = "fields" + FieldHistoryIndex Field = "history-index" +) + +func Label(field Field) string { + switch field { + case FieldID: + return "ID" + case FieldTitle: + return "Title" + case FieldUsername: + return "Username" + case FieldPassword: + return "Password" + case FieldURL: + return "URL" + case FieldPath: + return "Path" + case FieldTags: + return "Tags" + case FieldPasswordProfile: + return "Password Profile" + case FieldNotes: + return "Notes" + case FieldFields: + return "Custom Fields" + case FieldHistoryIndex: + return "History Index" + default: + return strings.ReplaceAll(string(field), "-", " ") + } +} + +func FocusOrder() []Field { + return []Field{ + FieldID, + FieldTitle, + FieldUsername, + FieldPassword, + FieldURL, + FieldPath, + FieldTags, + FieldPasswordProfile, + FieldNotes, + FieldFields, + FieldHistoryIndex, + } +} diff --git a/internal/appui/ui_editor.go b/internal/appui/entry_editor.go similarity index 100% rename from internal/appui/ui_editor.go rename to internal/appui/entry_editor.go diff --git a/internal/appui/ui_frame.go b/internal/appui/frame.go similarity index 100% rename from internal/appui/ui_frame.go rename to internal/appui/frame.go diff --git a/internal/appui/header.go b/internal/appui/header.go new file mode 100644 index 0000000..d937235 --- /dev/null +++ b/internal/appui/header.go @@ -0,0 +1,176 @@ +package appui + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" +) + +func (u *ui) header(gtx layout.Context) layout.Dimensions { + if u.usesCompactViewport() { + if u.shouldShowLifecycleSetup() || u.isVaultLocked() { + return layout.Dimensions{} + } + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return u.headerActions(gtx) + } + if u.shouldShowDesktopWorkingHeader() { + return layout.Dimensions{} + } + return card(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return u.brandMark(gtx, 196, 56) + }), + layout.Rigid(u.headerActions), + ) + }) +} + +func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { + if u.shouldShowLifecycleSetup() || u.isVaultLocked() || u.shouldShowDesktopWorkingHeader() { + return layout.Dimensions{} + } + spacing := gtx.Dp(unit.Dp(8)) + metrics := headerlayout.ActionMetrics{Spacing: spacing} + row := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + metrics.SyncDims = u.syncButtonGroup(gtx) + return metrics.SyncDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + metrics.LockDims = btn.Layout(gtx) + return metrics.LockDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + metrics.MainDims = u.mainMenuButtonGroup(gtx) + return metrics.MainDims + }), + ) + } + + rowOps := op.Record(gtx.Ops) + metrics.RowDims = row(gtx) + rowCall := rowOps.Stop() + + if u.usesCompactViewport() { + metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) + } + + surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} + + rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) + rowCall.Add(gtx.Ops) + rowStack.Pop() + + if u.usesCompactViewport() { + if u.syncMenuOpen { + u.phoneSyncMenuVisible = true + u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point() + } + if u.mainMenuOpen { + u.phoneMainMenuVisible = true + u.phoneMainMenuAnchor = metrics.MainAnchor().Point() + } + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, metrics.RowDims.Size.Y)} + } + + if u.syncMenuOpen { + surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu) + } + if u.mainMenuOpen { + surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) + } + + return layout.Dimensions{Size: image.Pt(metrics.RowDims.Size.X, metrics.RowDims.Size.Y)} +} + +func (u *ui) topRightActionOrder() []string { + if u.isVaultLocked() { + return nil + } + return []string{"Sync", "Lock", "Menu"} +} + +func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { + if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) { + return layout.Dimensions{} + } + gtx.Constraints.Min = gtx.Constraints.Max + contentInsetPx := gtx.Dp(unit.Dp(16)) + surface := headerlayout.DropdownSurface{ + ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), + LeftInset: contentInsetPx, + TopInset: contentInsetPx, + } + + if u.syncMenuVisibleOnPhone() { + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + } + if u.mainMenuVisibleOnPhone() { + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + } + return layout.Dimensions{Size: gtx.Constraints.Max} +} + +func (u *ui) syncMenuVisibleOnPhone() bool { + return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen +} + +func (u *ui) mainMenuVisibleOnPhone() bool { + return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen +} + +func (u *ui) syncMenuDropsBelowTrigger() bool { return true } + +func (u *ui) syncMenuRightAlignsToTrigger() bool { return true } + +func (u *ui) headerMenusUseOverlayModel() bool { return true } + +func (u *ui) mainMenuDropsBelowTrigger() bool { return true } + +func (u *ui) mainMenuRightAlignsToTrigger() bool { return true } + +func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions { + if !u.usesCompactViewport() { + return layout.Dimensions{} + } + return layout.Dimensions{} +} + +func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions { + if u.usesCompactViewport() { + return u.brandImage(gtx, u.splashSquare, widthDP, heightDP) + } + return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP) +} + +func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions { + width := gtx.Dp(unit.Dp(widthDP)) + height := gtx.Dp(unit.Dp(heightDP)) + if width > gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X + } + if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 { + height = gtx.Constraints.Max.Y + } + img := widget.Image{ + Src: src, + Fit: widget.Contain, + Position: layout.W, + Scale: 1.0 / gtx.Metric.PxPerDp, + } + gtx.Constraints.Min = image.Point{} + gtx.Constraints.Max = image.Pt(width, height) + return img.Layout(gtx) +} diff --git a/internal/appui/layout/header/dropdown.go b/internal/appui/header/layout/dropdown.go similarity index 99% rename from internal/appui/layout/header/dropdown.go rename to internal/appui/header/layout/dropdown.go index 0e18456..25bb94e 100644 --- a/internal/appui/layout/header/dropdown.go +++ b/internal/appui/header/layout/dropdown.go @@ -1,4 +1,4 @@ -package header +package layout import ( "image" diff --git a/internal/appui/header_main_menu.go b/internal/appui/header_main_menu.go new file mode 100644 index 0000000..d7fdbd2 --- /dev/null +++ b/internal/appui/header_main_menu.go @@ -0,0 +1,64 @@ +package appui + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" +) + +func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + }, + } + rowWidth := menuActionWidth(gtx, rows) + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[0]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[1]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[2]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[3]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[4]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[5]) }), + ) + }) +} + +func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { + icon := u.menuIcon + if icon == nil { + icon = u.settingsIcon + } + btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") + if u.mainMenuOpen { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = selectedColor + btn.Color = accentColor + } + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) +} diff --git a/internal/appui/header_menu_layout.go b/internal/appui/header_menu_layout.go new file mode 100644 index 0000000..458bc90 --- /dev/null +++ b/internal/appui/header_menu_layout.go @@ -0,0 +1,52 @@ +package appui + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" +) + +func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + measureGTX.Constraints.Max.X = gtx.Constraints.Max.X + macro := op.Record(gtx.Ops) + contentDims := w(measureGTX) + _ = macro.Stop() + width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) + maxWidth := gtx.Constraints.Max.X + if maxWidth > 0 && width > maxWidth { + width = maxWidth + } + if width > 0 { + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + } + return compactCard(gtx, w) +} + +func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { + width := 0 + for _, row := range rows { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + macro := op.Record(gtx.Ops) + dims := row(measureGTX) + _ = macro.Stop() + if dims.Size.X > width { + width = dims.Size.X + } + } + return width +} + +func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { + if width <= 0 { + return child(gtx) + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return layout.E.Layout(gtx, child) +} diff --git a/internal/appui/ui_layout_header.go b/internal/appui/header_sync_menu.go similarity index 50% rename from internal/appui/ui_layout_header.go rename to internal/appui/header_sync_menu.go index 1c55acd..c077e90 100644 --- a/internal/appui/ui_layout_header.go +++ b/internal/appui/header_sync_menu.go @@ -1,177 +1,33 @@ package appui import ( - "image" "image/color" + "runtime" + "strings" "gioui.org/layout" - "gioui.org/op" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" - "git.julianfamily.org/keepassgo/internal/appui/actions" - headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" + "git.julianfamily.org/keepassgo/internal/appstate" + syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync" "git.julianfamily.org/keepassgo/internal/vault" ) -func (u *ui) header(gtx layout.Context) layout.Dimensions { - if u.usesCompactViewport() { - if u.shouldShowLifecycleSetup() || u.isVaultLocked() { - return layout.Dimensions{} - } - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return u.headerActions(gtx) - } - if u.shouldShowDesktopWorkingHeader() { - return layout.Dimensions{} - } - return card(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return u.brandMark(gtx, 196, 56) - }), - layout.Rigid(u.headerActions), - ) - }) -} - -func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { - if u.shouldShowLifecycleSetup() { - return layout.Dimensions{} - } - if u.isVaultLocked() { - return layout.Dimensions{} - } - if u.shouldShowDesktopWorkingHeader() { - return layout.Dimensions{} - } - spacing := gtx.Dp(unit.Dp(8)) - metrics := headerlayout.ActionMetrics{Spacing: spacing} - row := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.SyncDims = u.syncButtonGroup(gtx) - return metrics.SyncDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.lockVault, "Lock") - metrics.LockDims = btn.Layout(gtx) - return metrics.LockDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.MainDims = u.mainMenuButtonGroup(gtx) - return metrics.MainDims - }), - ) - } - - rowOps := op.Record(gtx.Ops) - metrics.RowDims = row(gtx) - rowCall := rowOps.Stop() - - if u.usesCompactViewport() { - metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) - } - - surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} - - rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) - rowCall.Add(gtx.Ops) - rowStack.Pop() - - if u.usesCompactViewport() { - if u.syncMenuOpen { - u.phoneSyncMenuVisible = true - u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point() - } - if u.mainMenuOpen { - u.phoneMainMenuVisible = true - u.phoneMainMenuAnchor = metrics.MainAnchor().Point() - } - width := gtx.Constraints.Max.X - return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} - } - - if u.syncMenuOpen { - surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu) - } - if u.mainMenuOpen { - surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) - } - - width := metrics.RowDims.Size.X - return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} -} - -func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { - rows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") - }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") - }, - } - rowWidth := menuActionWidth(gtx, rows) - return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[0]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[1]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[2]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[3]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[4]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[5]) - }), - ) - }) -} - func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { - label := "Sync" spacing := unit.Dp(4) if u.usesCompactViewport() { spacing = unit.Dp(3) } - row := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport()) - }), - layout.Rigid(layout.Spacer{Width: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.syncMenuToggle(gtx) - }), - ) - } - return row(gtx) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport()) + }), + layout.Rigid(layout.Spacer{Width: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.syncMenuToggle(gtx) + }), + ) } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { @@ -205,12 +61,11 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { actionRows := u.syncMenuActionRows(model) actionWidth := menuActionWidth(gtx, actionRows) return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - rows := u.syncMenuRows(model, profiles, credentials, actionWidth) - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...) }) } -func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget { +func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") @@ -244,7 +99,7 @@ func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget { return rows } -func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild { +func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild { rows := u.syncMenuPrimaryRows(model, actionWidth) rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...) if model.ShowSaveCurrentBinding { @@ -253,7 +108,7 @@ func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemotePr return rows } -func (u *ui) syncMenuPrimaryRows(model actions.SyncMenuModel, actionWidth int) []layout.FlexChild { +func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild { rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?") @@ -302,7 +157,7 @@ func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label s }) } -func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { +func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 { return nil } @@ -333,7 +188,7 @@ func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []va return rows } -func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Widget { +func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget { return func(gtx layout.Context) layout.Dimensions { summary := model.SavedBindingSummary if !summary.OK { @@ -365,7 +220,7 @@ func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Wid } } -func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexChild { +func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild { return []layout.FlexChild{ layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -380,7 +235,7 @@ func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexC } } -func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { +func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4) for i, profile := range profiles { i := i @@ -422,125 +277,44 @@ func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.Remo return rows } -func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { - measureGTX := gtx - measureGTX.Constraints.Min = image.Point{} - measureGTX.Constraints.Max.X = gtx.Constraints.Max.X - macro := op.Record(gtx.Ops) - contentDims := w(measureGTX) - _ = macro.Stop() - width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) - maxWidth := gtx.Constraints.Max.X - if maxWidth > 0 && width > maxWidth { - width = maxWidth +func (u *ui) buildSyncMenuModel() syncmodel.MenuModel { + model := syncmodel.MenuModel{ + HasOpenVault: u.hasOpenVault(), + ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(), + ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "", + RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + RemotePath: strings.TrimSpace(u.remotePath.Text()), + RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()), + RemotePassword: u.remotePassword.Text(), + SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), } - if width > 0 { - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width + _, model.HasSelectedBinding = u.selectedVaultRemoteBinding() + model.SavedBindingSummary = u.computeSavedRemoteBindingSummary() + model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != "" + return model +} + +func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary { + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return syncmodel.MenuBindingSummary{} } - return compactCard(gtx, w) -} - -func (u *ui) topRightActionOrder() []string { - if u.isVaultLocked() { - return nil + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return syncmodel.MenuBindingSummary{} } - return []string{"Sync", "Lock", "Menu"} -} - -func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { - button := func(gtx layout.Context) layout.Dimensions { - icon := u.menuIcon - if icon == nil { - icon = u.settingsIcon - } - btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") - if u.mainMenuOpen { - btn.Background = accentColor - btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} - } else { - btn.Background = selectedColor - btn.Color = accentColor - } - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - return btn.Layout(gtx) + credentialLabel := entry.Title + if strings.TrimSpace(entry.Username) != "" { + credentialLabel += " · " + strings.TrimSpace(entry.Username) } - return button(gtx) -} - -func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { - if !u.usesCompactViewport() { - return layout.Dimensions{} + syncLabel := "Sync manually when you choose Use Remote Sync." + if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { + syncLabel = "Syncs automatically on open and save." } - if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { - return layout.Dimensions{} + return syncmodel.MenuBindingSummary{ + ProfileLabel: profile.Name, + CredentialLabel: credentialLabel, + SyncLabel: syncLabel, + OK: true, } - gtx.Constraints.Min = gtx.Constraints.Max - contentInsetPx := gtx.Dp(unit.Dp(16)) - surface := headerlayout.DropdownSurface{ - ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), - LeftInset: contentInsetPx, - TopInset: contentInsetPx, - } - - if u.syncMenuVisibleOnPhone() { - surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) - } - if u.mainMenuVisibleOnPhone() { - surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) - } - return layout.Dimensions{Size: gtx.Constraints.Max} -} - -func (u *ui) syncMenuVisibleOnPhone() bool { - return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen -} - -func (u *ui) mainMenuVisibleOnPhone() bool { - return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen -} - -func (u *ui) syncMenuDropsBelowTrigger() bool { - return true -} - -func (u *ui) syncMenuRightAlignsToTrigger() bool { - return true -} - -func (u *ui) headerMenusUseOverlayModel() bool { - return true -} - -func (u *ui) mainMenuDropsBelowTrigger() bool { - return true -} - -func (u *ui) mainMenuRightAlignsToTrigger() bool { - return true -} - -func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { - width := 0 - for _, row := range rows { - measureGTX := gtx - measureGTX.Constraints.Min = image.Point{} - macro := op.Record(gtx.Ops) - dims := row(measureGTX) - _ = macro.Stop() - if dims.Size.X > width { - width = dims.Size.X - } - } - return width -} - -func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { - if width <= 0 { - return child(gtx) - } - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width - return layout.E.Layout(gtx, child) } diff --git a/internal/appui/ui_keyboard.go b/internal/appui/input.go similarity index 72% rename from internal/appui/ui_keyboard.go rename to internal/appui/input.go index 302dedc..564e7ce 100644 --- a/internal/appui/ui_keyboard.go +++ b/internal/appui/input.go @@ -5,28 +5,42 @@ import ( "strconv" "strings" + "gioui.org/io/event" "gioui.org/io/key" + "gioui.org/layout" "git.julianfamily.org/keepassgo/internal/appstate" + editormodel "git.julianfamily.org/keepassgo/internal/appui/editor" + "git.julianfamily.org/keepassgo/internal/clipboard" ) type focusID string -type detailField string +type detailField = editormodel.Field const ( focusSearch focusID = "search" - detailFieldID detailField = "id" - detailFieldTitle detailField = "title" - detailFieldUsername detailField = "username" - detailFieldPassword detailField = "password" - detailFieldURL detailField = "url" - detailFieldPath detailField = "path" - detailFieldTags detailField = "tags" - detailFieldPasswordProfile detailField = "password-profile" - detailFieldNotes detailField = "notes" - detailFieldFields detailField = "fields" - detailFieldHistoryIndex detailField = "history-index" + detailFieldID = editormodel.FieldID + detailFieldTitle = editormodel.FieldTitle + detailFieldUsername = editormodel.FieldUsername + detailFieldPassword = editormodel.FieldPassword + detailFieldURL = editormodel.FieldURL + detailFieldPath = editormodel.FieldPath + detailFieldTags = editormodel.FieldTags + detailFieldPasswordProfile = editormodel.FieldPasswordProfile + detailFieldNotes = editormodel.FieldNotes + detailFieldFields = editormodel.FieldFields + detailFieldHistoryIndex = editormodel.FieldHistoryIndex +) + +const ( + shortcutSearch = "search" + shortcutSave = "save" + shortcutLock = "lock" + shortcutNewEntry = "new-entry" + shortcutCopyUser = "copy-user" + shortcutCopyPassword = "copy-password" + shortcutCopyURL = "copy-url" ) func breadcrumbFocusID(index int) focusID { @@ -41,6 +55,68 @@ func detailFocusID(field detailField) focusID { return focusID("detail:" + string(field)) } +func (u *ui) processShortcuts(gtx layout.Context) { + event.Op(gtx.Ops, u) + for { + ev, ok := gtx.Event( + key.Filter{Name: "F", Required: key.ModShortcut}, + key.Filter{Name: "S", Required: key.ModShortcut}, + key.Filter{Name: "L", Required: key.ModShortcut}, + key.Filter{Name: "N", Required: key.ModShortcut}, + key.Filter{Name: "U", Required: key.ModShortcut}, + key.Filter{Name: "P", Required: key.ModShortcut}, + key.Filter{Name: "O", Required: key.ModShortcut}, + key.Filter{Name: key.NameTab, Optional: key.ModShift}, + key.Filter{Name: key.NameLeftArrow}, + key.Filter{Name: key.NameRightArrow}, + key.Filter{Name: key.NameUpArrow}, + key.Filter{Name: key.NameDownArrow}, + key.Filter{Name: key.NameReturn}, + key.Filter{Name: key.NameBack}, + key.Filter{Name: key.NameEscape}, + ) + if !ok { + break + } + + ke, ok := ev.(key.Event) + if !ok || ke.State != key.Press { + continue + } + + u.handleKeyPress(ke.Name, ke.Modifiers) + if ke.Name == key.NameBack || ke.Name == key.NameEscape { + _ = u.handlePhoneBack() + } + } +} + +func (u *ui) performShortcut(name string) error { + switch name { + case shortcutSearch: + u.keyboardFocus = focusSearch + return nil + case shortcutSave: + return u.saveAction() + case shortcutLock: + return u.lockAction() + case shortcutNewEntry: + u.state.BeginNewEntry() + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) + u.keyboardFocus = detailFocusID(detailFieldTitle) + return nil + case shortcutCopyUser: + return u.copySelectedFieldAction(clipboard.TargetUsername) + case shortcutCopyPassword: + return u.copySelectedFieldAction(clipboard.TargetPassword) + case shortcutCopyURL: + return u.copySelectedFieldAction(clipboard.TargetURL) + default: + return nil + } +} + func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool { if u.handleShortcutKey(name, modifiers) { return true @@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int { } func detailFocusOrder() []detailField { - return []detailField{ - detailFieldID, - detailFieldTitle, - detailFieldUsername, - detailFieldPassword, - detailFieldURL, - detailFieldPath, - detailFieldTags, - detailFieldPasswordProfile, - detailFieldNotes, - detailFieldFields, - detailFieldHistoryIndex, - } + return editormodel.FocusOrder() } func canonicalFocusID(id focusID) focusID { diff --git a/internal/appui/lifecycle/model.go b/internal/appui/lifecycle/model.go new file mode 100644 index 0000000..17c4339 --- /dev/null +++ b/internal/appui/lifecycle/model.go @@ -0,0 +1,9 @@ +package lifecycle + +type OpenIntent string + +const ( + OpenIntentNone OpenIntent = "" + OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup" + OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings" +) diff --git a/internal/appui/ui_actions_lifecycle.go b/internal/appui/lifecycle_actions.go similarity index 100% rename from internal/appui/ui_actions_lifecycle.go rename to internal/appui/lifecycle_actions.go diff --git a/internal/appui/ui_forms.go b/internal/appui/lifecycle_forms.go similarity index 99% rename from internal/appui/ui_forms.go rename to internal/appui/lifecycle_forms.go index 1acd90c..f41d44f 100644 --- a/internal/appui/ui_forms.go +++ b/internal/appui/lifecycle_forms.go @@ -18,6 +18,23 @@ import ( "git.julianfamily.org/keepassgo/internal/appstate" ) +func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { + panel := card + if u.usesCompactViewport() { + panel = compactCard + } + return panel(gtx, func(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + u.lifecycleBranding, + layout.Spacer{Height: unit.Dp(8)}.Layout, + u.lifecycleControls, + } + return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) + }) +} + func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() showLocalChooser := u.showLocalVaultChooser() diff --git a/internal/appui/layout/list/sections.go b/internal/appui/list/layout/sections.go similarity index 94% rename from internal/appui/layout/list/sections.go rename to internal/appui/list/layout/sections.go index e6c6fb1..e8f2c61 100644 --- a/internal/appui/layout/list/sections.go +++ b/internal/appui/list/layout/sections.go @@ -1,4 +1,4 @@ -package list +package layout type TopSection string diff --git a/internal/appui/list/model.go b/internal/appui/list/model.go new file mode 100644 index 0000000..97c3be5 --- /dev/null +++ b/internal/appui/list/model.go @@ -0,0 +1,8 @@ +package list + +type EntriesSectionState struct { + Path []string + SearchQuery string + SelectedEntryID string + Editing bool +} diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 359b437..b745dcf 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -25,8 +25,8 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" - headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" - listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" + listlayout "git.julianfamily.org/keepassgo/internal/appui/list/layout" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" diff --git a/internal/appui/ui_recent_state.go b/internal/appui/recent_state.go similarity index 100% rename from internal/appui/ui_recent_state.go rename to internal/appui/recent_state.go diff --git a/internal/appui/ui_runtime.go b/internal/appui/runtime.go similarity index 100% rename from internal/appui/ui_runtime.go rename to internal/appui/runtime.go diff --git a/internal/appui/ui_preferences.go b/internal/appui/settings.go similarity index 65% rename from internal/appui/ui_preferences.go rename to internal/appui/settings.go index 8cb0edf..ebfa108 100644 --- a/internal/appui/ui_preferences.go +++ b/internal/appui/settings.go @@ -2,6 +2,8 @@ package appui import ( "encoding/json" + "fmt" + "image" "image/color" "os" "path/filepath" @@ -9,29 +11,28 @@ import ( "time" "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + editormodel "git.julianfamily.org/keepassgo/internal/appui/editor" + settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings" "git.julianfamily.org/keepassgo/internal/vault" ) const ( - displayDensityDense = "dense" - displayDensityComfortable = "comfortable" + displayDensityDense = settingsmodel.DisplayDensityDense + displayDensityComfortable = settingsmodel.DisplayDensityComfortable - contrastStandard = "standard" - contrastHigh = "high" + contrastStandard = settingsmodel.ContrastStandard + contrastHigh = settingsmodel.ContrastHigh - keyboardFocusStandard = "standard" - keyboardFocusProminent = "prominent" + keyboardFocusStandard = settingsmodel.KeyboardFocusStandard + keyboardFocusProminent = settingsmodel.KeyboardFocusProminent ) -type accessibilityPreferences struct { - DisplayDensity string - Contrast string - ReducedMotion bool - KeyboardFocus string -} +type accessibilityPreferences = settingsmodel.AccessibilityPreferences type settingsFile struct { Sync syncSettings `json:"sync,omitempty"` @@ -63,37 +64,23 @@ type choiceSpec struct { Active bool } +type focusAppearance struct { + BorderColor color.NRGBA + OutlineColor color.NRGBA + OutlineWidth int + MinHeight int +} + func defaultAccessibilityPreferences() accessibilityPreferences { - return accessibilityPreferences{ - DisplayDensity: displayDensityForDenseLayout(true), - Contrast: contrastStandard, - KeyboardFocus: keyboardFocusStandard, - } + return settingsmodel.DefaultAccessibilityPreferences() } func displayDensityForDenseLayout(dense bool) string { - if dense { - return displayDensityDense - } - return displayDensityComfortable + return settingsmodel.DisplayDensityForDenseLayout(dense) } func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences { - normalized := defaultAccessibilityPreferences() - switch prefs.DisplayDensity { - case displayDensityDense, displayDensityComfortable: - normalized.DisplayDensity = prefs.DisplayDensity - } - switch prefs.Contrast { - case contrastStandard, contrastHigh: - normalized.Contrast = prefs.Contrast - } - switch prefs.KeyboardFocus { - case keyboardFocusStandard, keyboardFocusProminent: - normalized.KeyboardFocus = prefs.KeyboardFocus - } - normalized.ReducedMotion = prefs.ReducedMotion - return normalized + return settingsmodel.NormalizeAccessibilityPreferences(prefs) } func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) { @@ -102,6 +89,96 @@ func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) { u.accessibilityPrefs = normalized } +func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance { + prefs = normalizeAccessibilityPreferences(prefs) + appearance := focusAppearance{ + BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, + OutlineColor: color.NRGBA{A: 0}, + OutlineWidth: max(1, metric.Dp(unit.Dp(1))), + MinHeight: metric.Dp(unit.Dp(44)), + } + if prefs.DisplayDensity == displayDensityComfortable { + appearance.MinHeight = metric.Dp(unit.Dp(52)) + } + if prefs.Contrast == contrastHigh { + appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255} + } + if focused { + appearance.BorderColor = accentColor + appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} + appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) + if prefs.Contrast == contrastHigh { + appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255} + appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124} + } + if prefs.KeyboardFocus == keyboardFocusProminent { + appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3))) + appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148} + } + } + return appearance +} + +func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) { + prefs = normalizeAccessibilityPreferences(prefs) + background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} + text = accentColor + if prefs.Contrast == contrastHigh { + background = color.NRGBA{R: 225, G: 235, B: 230, A: 255} + text = color.NRGBA{R: 19, G: 57, B: 43, A: 255} + } + if focused { + background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} + if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent { + background = color.NRGBA{R: 202, G: 222, B: 212, A: 255} + } + } + return background, text +} + +func (u *ui) accessibilityLabel(id focusID) string { + switch { + case id == focusSearch: + return "Search vault" + case strings.HasPrefix(string(id), "breadcrumb:"): + index := focusIndex(id) + crumbs := u.breadcrumbLabels() + if index >= 0 && index < len(crumbs) { + return fmt.Sprintf("Navigate to %s", crumbs[index]) + } + case strings.HasPrefix(string(id), "list:"): + index := focusIndex(id) + if index >= 0 && index < len(u.visible) { + return fmt.Sprintf("Select entry %s", u.visible[index].Title) + } + case strings.HasPrefix(string(id), "detail:"): + name := strings.TrimPrefix(string(id), "detail:") + return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name))) + } + return "" +} + +func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions { + if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 { + return layout.Dimensions{Size: size} + } + + width := appearance.OutlineWidth + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return layout.Dimensions{Size: size} +} + +func (u *ui) isFocused(id focusID) bool { + return u.keyboardFocus == id +} + +func detailFieldLabel(field detailField) string { + return editormodel.Label(field) +} + func (u *ui) loadSettingsDraft() { u.settingsDraft = settingsDraft{ Accessibility: accessibilityPreferences{ diff --git a/internal/appui/settings/model.go b/internal/appui/settings/model.go new file mode 100644 index 0000000..b55dfe8 --- /dev/null +++ b/internal/appui/settings/model.go @@ -0,0 +1,50 @@ +package settings + +type AccessibilityPreferences struct { + DisplayDensity string + Contrast string + ReducedMotion bool + KeyboardFocus string +} + +const ( + DisplayDensityDense = "dense" + DisplayDensityComfortable = "comfortable" + ContrastStandard = "standard" + ContrastHigh = "high" + KeyboardFocusStandard = "standard" + KeyboardFocusProminent = "prominent" +) + +func DefaultAccessibilityPreferences() AccessibilityPreferences { + return AccessibilityPreferences{ + DisplayDensity: DisplayDensityDense, + Contrast: ContrastStandard, + KeyboardFocus: KeyboardFocusStandard, + } +} + +func DisplayDensityForDenseLayout(dense bool) string { + if dense { + return DisplayDensityDense + } + return DisplayDensityComfortable +} + +func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences { + normalized := DefaultAccessibilityPreferences() + switch prefs.DisplayDensity { + case DisplayDensityDense, DisplayDensityComfortable: + normalized.DisplayDensity = prefs.DisplayDensity + } + switch prefs.Contrast { + case ContrastStandard, ContrastHigh: + normalized.Contrast = prefs.Contrast + } + switch prefs.KeyboardFocus { + case KeyboardFocusStandard, KeyboardFocusProminent: + normalized.KeyboardFocus = prefs.KeyboardFocus + } + normalized.ReducedMotion = prefs.ReducedMotion + return normalized +} diff --git a/internal/appui/sync/model.go b/internal/appui/sync/model.go new file mode 100644 index 0000000..b73f900 --- /dev/null +++ b/internal/appui/sync/model.go @@ -0,0 +1,119 @@ +package sync + +import "git.julianfamily.org/keepassgo/internal/appstate" + +type SourceMode string + +const ( + SourceLocal SourceMode = "local" + SourceRemote SourceMode = "remote" +) + +type Direction string + +const ( + DirectionPull Direction = "pull" + DirectionPush Direction = "push" +) + +type DialogPurpose string + +const ( + DialogPurposeAdvanced DialogPurpose = "advanced" + DialogPurposeRemoteSetup DialogPurpose = "remote-setup" +) + +type MenuModel struct { + HasOpenVault bool + HasSelectedBinding bool + ShowSelectors bool + ShowShare bool + ShowSaveCurrentBinding bool + SavedBindingSummary MenuBindingSummary + RemoteBaseURL string + RemotePath string + RemoteUsername string + RemotePassword string + SelectedVaultSyncMode appstate.SyncMode +} + +type MenuBindingSummary struct { + ProfileLabel string + CredentialLabel string + SyncLabel string + OK bool +} + +func (m MenuModel) SavedBindingHeading() string { + if !m.ShowSelectors { + return "Use this vault's saved remote sync target" + } + return "Use a saved remote profile from this vault" +} + +func (m MenuModel) OpenSelectedButtonLabel() string { + if !m.ShowSelectors { + return "Use Remote Sync" + } + return "Open Saved Remote" +} + +func (m MenuModel) ShowDirectRemoteSyncShortcut() bool { + return m.HasOpenVault && m.HasSelectedBinding +} + +func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" } + +func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool { + return m.HasOpenVault && m.HasSelectedBinding +} + +func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" } + +func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() } + +func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" } + +func (m MenuModel) ShowRemoteSyncSetupShortcut() bool { + return m.HasOpenVault && !m.HasSelectedBinding +} + +func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" } + +func (m MenuModel) ActionLabels() []string { + labels := []string{"Open Advanced Sync"} + if m.ShowRemoteSyncSetupShortcut() { + labels = append(labels, m.RemoteSyncSetupShortcutLabel()) + } + if m.ShowDirectRemoteSyncShortcut() { + labels = append(labels, m.DirectRemoteSyncShortcutLabel()) + } + if m.ShowRemoteSyncSettingsShortcut() { + labels = append(labels, m.RemoteSyncSettingsShortcutLabel()) + } + if m.ShowRemoveRemoteSyncShortcut() { + labels = append(labels, m.RemoveRemoteSyncShortcutLabel()) + } + return labels +} + +func (m MenuModel) SaveCurrentRemoteBindingHeading() string { + return "Bind this local vault to the current remote target" +} + +func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" } + +func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string { + if purpose == DialogPurposeRemoteSetup { + return "Push this local vault to a WebDAV target and save that target for future sync." + } + sourceLabel := "another local vault file" + if source == SourceRemote { + sourceLabel = "another WebDAV-backed vault" + } + action := "Pull changes from" + if direction == DirectionPush { + action = "Push the current vault into" + } + return action + " " + sourceLabel + "." +} diff --git a/internal/appui/ui_sync_dialog.go b/internal/appui/sync_dialog.go similarity index 100% rename from internal/appui/ui_sync_dialog.go rename to internal/appui/sync_dialog.go diff --git a/internal/appui/ui_accessibility.go b/internal/appui/ui_accessibility.go deleted file mode 100644 index 2954f2b..0000000 --- a/internal/appui/ui_accessibility.go +++ /dev/null @@ -1,135 +0,0 @@ -package appui - -import ( - "fmt" - "image" - "image/color" - "strings" - - "gioui.org/layout" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/unit" -) - -type focusAppearance struct { - BorderColor color.NRGBA - OutlineColor color.NRGBA - OutlineWidth int - MinHeight int -} - -func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance { - prefs = normalizeAccessibilityPreferences(prefs) - appearance := focusAppearance{ - BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, - OutlineColor: color.NRGBA{A: 0}, - OutlineWidth: max(1, metric.Dp(unit.Dp(1))), - MinHeight: metric.Dp(unit.Dp(44)), - } - if prefs.DisplayDensity == displayDensityComfortable { - appearance.MinHeight = metric.Dp(unit.Dp(52)) - } - if prefs.Contrast == contrastHigh { - appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255} - } - if focused { - appearance.BorderColor = accentColor - appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} - appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) - if prefs.Contrast == contrastHigh { - appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255} - appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124} - } - if prefs.KeyboardFocus == keyboardFocusProminent { - appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3))) - appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148} - } - } - return appearance -} - -func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) { - prefs = normalizeAccessibilityPreferences(prefs) - background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} - text = accentColor - if prefs.Contrast == contrastHigh { - background = color.NRGBA{R: 225, G: 235, B: 230, A: 255} - text = color.NRGBA{R: 19, G: 57, B: 43, A: 255} - } - if focused { - background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} - if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent { - background = color.NRGBA{R: 202, G: 222, B: 212, A: 255} - } - } - return background, text -} - -func (u *ui) accessibilityLabel(id focusID) string { - switch { - case id == focusSearch: - return "Search vault" - case strings.HasPrefix(string(id), "breadcrumb:"): - index := focusIndex(id) - crumbs := u.breadcrumbLabels() - if index >= 0 && index < len(crumbs) { - return fmt.Sprintf("Navigate to %s", crumbs[index]) - } - case strings.HasPrefix(string(id), "list:"): - index := focusIndex(id) - if index >= 0 && index < len(u.visible) { - return fmt.Sprintf("Select entry %s", u.visible[index].Title) - } - case strings.HasPrefix(string(id), "detail:"): - name := strings.TrimPrefix(string(id), "detail:") - return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name))) - } - return "" -} - -func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions { - if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 { - return layout.Dimensions{Size: size} - } - - width := appearance.OutlineWidth - paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op()) - paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op()) - paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op()) - paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op()) - return layout.Dimensions{Size: size} -} - -func (u *ui) isFocused(id focusID) bool { - return u.keyboardFocus == id -} - -func detailFieldLabel(field detailField) string { - switch field { - case detailFieldID: - return "ID" - case detailFieldTitle: - return "Title" - case detailFieldUsername: - return "Username" - case detailFieldPassword: - return "Password" - case detailFieldURL: - return "URL" - case detailFieldPath: - return "Path" - case detailFieldTags: - return "Tags" - case detailFieldPasswordProfile: - return "Password Profile" - case detailFieldNotes: - return "Notes" - case detailFieldFields: - return "Custom Fields" - case detailFieldHistoryIndex: - return "History Index" - default: - return strings.ReplaceAll(string(field), "-", " ") - } -} diff --git a/internal/appui/ui_branding.go b/internal/appui/ui_branding.go deleted file mode 100644 index 396d742..0000000 --- a/internal/appui/ui_branding.go +++ /dev/null @@ -1,44 +0,0 @@ -package appui - -import ( - "image" - - "gioui.org/layout" - "gioui.org/op/paint" - "gioui.org/unit" - "gioui.org/widget" -) - -func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions { - if !u.usesCompactViewport() { - return layout.Dimensions{} - } - return layout.Dimensions{} -} - -func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions { - if u.usesCompactViewport() { - return u.brandImage(gtx, u.splashSquare, widthDP, heightDP) - } - return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP) -} - -func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions { - width := gtx.Dp(unit.Dp(widthDP)) - height := gtx.Dp(unit.Dp(heightDP)) - if width > gtx.Constraints.Max.X { - width = gtx.Constraints.Max.X - } - if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 { - height = gtx.Constraints.Max.Y - } - img := widget.Image{ - Src: src, - Fit: widget.Contain, - Position: layout.W, - Scale: 1.0 / gtx.Metric.PxPerDp, - } - gtx.Constraints.Min = image.Point{} - gtx.Constraints.Max = image.Pt(width, height) - return img.Layout(gtx) -} diff --git a/internal/appui/ui_layout_lifecycle.go b/internal/appui/ui_layout_lifecycle.go deleted file mode 100644 index 77ee870..0000000 --- a/internal/appui/ui_layout_lifecycle.go +++ /dev/null @@ -1,24 +0,0 @@ -package appui - -import ( - "gioui.org/layout" - "gioui.org/unit" - "gioui.org/widget/material" -) - -func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { - panel := card - if u.usesCompactViewport() { - panel = compactCard - } - return panel(gtx, func(gtx layout.Context) layout.Dimensions { - rows := []layout.Widget{ - u.lifecycleBranding, - layout.Spacer{Height: unit.Dp(8)}.Layout, - u.lifecycleControls, - } - return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { - return rows[i](gtx) - }) - }) -} diff --git a/internal/appui/ui_shortcuts.go b/internal/appui/ui_shortcuts.go deleted file mode 100644 index 9144b0e..0000000 --- a/internal/appui/ui_shortcuts.go +++ /dev/null @@ -1,83 +0,0 @@ -package appui - -import ( - "strings" - - "gioui.org/io/event" - "gioui.org/io/key" - "gioui.org/layout" - - "git.julianfamily.org/keepassgo/internal/clipboard" -) - -const ( - shortcutSearch = "search" - shortcutSave = "save" - shortcutLock = "lock" - shortcutNewEntry = "new-entry" - shortcutCopyUser = "copy-user" - shortcutCopyPassword = "copy-password" - shortcutCopyURL = "copy-url" -) - -func (u *ui) processShortcuts(gtx layout.Context) { - event.Op(gtx.Ops, u) - for { - ev, ok := gtx.Event( - key.Filter{Name: "F", Required: key.ModShortcut}, - key.Filter{Name: "S", Required: key.ModShortcut}, - key.Filter{Name: "L", Required: key.ModShortcut}, - key.Filter{Name: "N", Required: key.ModShortcut}, - key.Filter{Name: "U", Required: key.ModShortcut}, - key.Filter{Name: "P", Required: key.ModShortcut}, - key.Filter{Name: "O", Required: key.ModShortcut}, - key.Filter{Name: key.NameTab, Optional: key.ModShift}, - key.Filter{Name: key.NameLeftArrow}, - key.Filter{Name: key.NameRightArrow}, - key.Filter{Name: key.NameUpArrow}, - key.Filter{Name: key.NameDownArrow}, - key.Filter{Name: key.NameReturn}, - key.Filter{Name: key.NameBack}, - key.Filter{Name: key.NameEscape}, - ) - if !ok { - break - } - - ke, ok := ev.(key.Event) - if !ok || ke.State != key.Press { - continue - } - - u.handleKeyPress(ke.Name, ke.Modifiers) - if ke.Name == key.NameBack || ke.Name == key.NameEscape { - _ = u.handlePhoneBack() - } - } -} - -func (u *ui) performShortcut(name string) error { - switch name { - case shortcutSearch: - u.keyboardFocus = focusSearch - return nil - case shortcutSave: - return u.saveAction() - case shortcutLock: - return u.lockAction() - case shortcutNewEntry: - u.state.BeginNewEntry() - u.loadSelectedEntryIntoEditor() - u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) - u.keyboardFocus = detailFocusID(detailFieldTitle) - return nil - case shortcutCopyUser: - return u.copySelectedFieldAction(clipboard.TargetUsername) - case shortcutCopyPassword: - return u.copySelectedFieldAction(clipboard.TargetPassword) - case shortcutCopyURL: - return u.copySelectedFieldAction(clipboard.TargetURL) - default: - return nil - } -} diff --git a/internal/appui/ui_sync_menu_actions.go b/internal/appui/ui_sync_menu_actions.go deleted file mode 100644 index edc3bce..0000000 --- a/internal/appui/ui_sync_menu_actions.go +++ /dev/null @@ -1,51 +0,0 @@ -package appui - -import ( - "runtime" - "strings" - - "git.julianfamily.org/keepassgo/internal/appstate" - appuiactions "git.julianfamily.org/keepassgo/internal/appui/actions" -) - -func (u *ui) buildSyncMenuModel() appuiactions.SyncMenuModel { - model := appuiactions.SyncMenuModel{ - HasOpenVault: u.hasOpenVault(), - ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(), - ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "", - RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - RemotePath: strings.TrimSpace(u.remotePath.Text()), - RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()), - RemotePassword: u.remotePassword.Text(), - SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), - } - _, model.HasSelectedBinding = u.selectedVaultRemoteBinding() - model.SavedBindingSummary = u.computeSavedRemoteBindingSummary() - model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != "" - return model -} - -func (u *ui) computeSavedRemoteBindingSummary() appuiactions.SyncMenuBindingSummary { - profile, ok := u.selectedVaultRemoteProfile() - if !ok { - return appuiactions.SyncMenuBindingSummary{} - } - entry, ok := u.selectedVaultRemoteCredentialEntry() - if !ok { - return appuiactions.SyncMenuBindingSummary{} - } - 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 appuiactions.SyncMenuBindingSummary{ - ProfileLabel: profile.Name, - CredentialLabel: credentialLabel, - SyncLabel: syncLabel, - OK: true, - } -} From 0e9fd478e57511fdfc52397df008bd457f97ca43 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 15:04:41 -0700 Subject: [PATCH 42/53] Align header action cluster with layout.E --- internal/appui/header.go | 67 +++++++++++++++---- .../layout/menu.go} | 10 +-- internal/appui/header/menu.go | 42 ++++++++++++ internal/appui/header_main_menu.go | 64 ------------------ 4 files changed, 101 insertions(+), 82 deletions(-) rename internal/appui/{header_menu_layout.go => header/layout/menu.go} (72%) create mode 100644 internal/appui/header/menu.go delete mode 100644 internal/appui/header_main_menu.go diff --git a/internal/appui/header.go b/internal/appui/header.go index d937235..3b81a32 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -9,6 +9,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + headerview "git.julianfamily.org/keepassgo/internal/appui/header" headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" ) @@ -25,10 +26,10 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { } return card(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.brandMark(gtx, 196, 56) }), - layout.Rigid(u.headerActions), + layout.Flexed(1, u.headerActions), ) }) } @@ -39,7 +40,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { } spacing := gtx.Dp(unit.Dp(8)) metrics := headerlayout.ActionMetrics{Spacing: spacing} - row := func(gtx layout.Context) layout.Dimensions { + actionCluster := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { metrics.SyncDims = u.syncButtonGroup(gtx) @@ -60,18 +61,16 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { } rowOps := op.Record(gtx.Ops) - metrics.RowDims = row(gtx) + metrics.RowDims = actionCluster(gtx) rowCall := rowOps.Stop() - - if u.usesCompactViewport() { - metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) - } + metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} - rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) - rowCall.Add(gtx.Ops) - rowStack.Pop() + rowDims := layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + rowCall.Add(gtx.Ops) + return metrics.RowDims + }) if u.usesCompactViewport() { if u.syncMenuOpen { @@ -82,7 +81,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { u.phoneMainMenuVisible = true u.phoneMainMenuAnchor = metrics.MainAnchor().Point() } - return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, metrics.RowDims.Size.Y)} + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} } if u.syncMenuOpen { @@ -92,7 +91,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) } - return layout.Dimensions{Size: image.Pt(metrics.RowDims.Size.X, metrics.RowDims.Size.Y)} + return rowDims } func (u *ui) topRightActionOrder() []string { @@ -174,3 +173,45 @@ func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP gtx.Constraints.Max = image.Pt(width, height) return img.Layout(gtx) } + +func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + }, + } + return headerview.MainMenu(gtx, u.theme, rows, compactCard) +} + +func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { + icon := u.menuIcon + if icon == nil { + icon = u.settingsIcon + } + return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor) +} + +func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { + return headerlayout.IntrinsicCompactCard(gtx, w, compactCard) +} + +func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { + return headerlayout.MenuActionWidth(gtx, rows) +} + +func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { + return headerlayout.RightAlignedAction(gtx, width, child) +} diff --git a/internal/appui/header_menu_layout.go b/internal/appui/header/layout/menu.go similarity index 72% rename from internal/appui/header_menu_layout.go rename to internal/appui/header/layout/menu.go index 458bc90..baaee43 100644 --- a/internal/appui/header_menu_layout.go +++ b/internal/appui/header/layout/menu.go @@ -1,4 +1,4 @@ -package appui +package layout import ( "image" @@ -8,7 +8,7 @@ import ( "gioui.org/unit" ) -func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { +func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions) layout.Dimensions { measureGTX := gtx measureGTX.Constraints.Min = image.Point{} measureGTX.Constraints.Max.X = gtx.Constraints.Max.X @@ -24,10 +24,10 @@ func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width } - return compactCard(gtx, w) + return card(gtx, w) } -func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { +func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int { width := 0 for _, row := range rows { measureGTX := gtx @@ -42,7 +42,7 @@ func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { return width } -func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { +func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { if width <= 0 { return child(gtx) } diff --git a/internal/appui/header/menu.go b/internal/appui/header/menu.go new file mode 100644 index 0000000..2716dbb --- /dev/null +++ b/internal/appui/header/menu.go @@ -0,0 +1,42 @@ +package header + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" +) + +func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions) layout.Dimensions { + rowWidth := headerlayout.MenuActionWidth(gtx, rows) + return headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + children := make([]layout.FlexChild, 0, (len(rows)*2)-1) + for i, row := range rows { + if i > 0 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + current := row + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return headerlayout.RightAlignedAction(gtx, rowWidth, current) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }, card) +} + +func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions { + btn := material.IconButton(theme, click, icon, "Menu") + if open { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = selectedColor + btn.Color = accentColor + } + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) +} diff --git a/internal/appui/header_main_menu.go b/internal/appui/header_main_menu.go deleted file mode 100644 index d7fdbd2..0000000 --- a/internal/appui/header_main_menu.go +++ /dev/null @@ -1,64 +0,0 @@ -package appui - -import ( - "image/color" - - "gioui.org/layout" - "gioui.org/unit" - "gioui.org/widget/material" -) - -func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { - rows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") - }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") - }, - } - rowWidth := menuActionWidth(gtx, rows) - return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[0]) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[1]) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[2]) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[3]) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[4]) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[5]) }), - ) - }) -} - -func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { - icon := u.menuIcon - if icon == nil { - icon = u.settingsIcon - } - btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") - if u.mainMenuOpen { - btn.Background = accentColor - btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} - } else { - btn.Background = selectedColor - btn.Color = accentColor - } - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - return btn.Layout(gtx) -} From 5838588fc537c06488bc0da2ecd22736b8c62566 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 15:28:33 -0700 Subject: [PATCH 43/53] Log compact header button bounds --- internal/appui/app.go | 14 ++++++ internal/appui/frame.go | 1 + internal/appui/header.go | 42 +++++++++++++++++- internal/appui/header/layout/dropdown.go | 40 ++++++++++++++--- internal/appui/header_sync_menu.go | 16 +++++-- internal/appui/main_test.go | 55 ++++++++++++++++++++++++ internal/appui/settings.go | 34 ++++++++++++++- 7 files changed, 190 insertions(+), 12 deletions(-) diff --git a/internal/appui/app.go b/internal/appui/app.go index d1217ad..04c73ed 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -377,6 +377,7 @@ type ui struct { settingsLifecycleAdvanced widget.Bool settingsHistory widget.Bool settingsDenseLayout widget.Bool + settingsDebugHeaderBounds widget.Bool entryClicks []widget.Clickable apiTokenClicks []widget.Clickable apiPolicyRemoves []widget.Clickable @@ -477,6 +478,7 @@ type ui struct { autofillNoticePreference autofillNoticeMode autofillFirstFillApprovalMode autofillFirstFillApprovalMode accessibilityPrefs accessibilityPreferences + debugLogHeaderBounds bool settingsDraft settingsDraft recentVaults []string recentRemotes []recentRemoteRecord @@ -502,6 +504,8 @@ type ui struct { lastLifecycleAction string pendingLifecycleOpenIntent lifecycleOpenIntent requestMasterPassFocus bool + lastHeaderBoundsLog string + frameInsetPx int invalidate func() } @@ -1136,6 +1140,16 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed") return check.Layout(gtx) }, + func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.settingsDebugHeaderBounds, "Log compact header button bounds") + return check.Layout(gtx) + }, + layout.Spacer{Height: unit.Dp(4)}.Layout, + func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Write compact Android header button screen coordinates to the app log so emulator taps can read exact bounds from logcat.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "Vault Security") diff --git a/internal/appui/frame.go b/internal/appui/frame.go index 85b5396..7662dc7 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -603,6 +603,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.handleGroupClicks(gtx) u.handleInputUpdates(gtx) u.updateViewportLayoutMode(gtx) + u.frameInsetPx = gtx.Dp(unit.Dp(16)) inset := layout.UniformInset(unit.Dp(16)) return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/header.go b/internal/appui/header.go index 3b81a32..d73df2c 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -1,6 +1,7 @@ package appui import ( + "fmt" "image" "gioui.org/layout" @@ -39,11 +40,14 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } spacing := gtx.Dp(unit.Dp(8)) - metrics := headerlayout.ActionMetrics{Spacing: spacing} + metrics := headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))} + if !u.usesCompactViewport() { + metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4)) + } actionCluster := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.SyncDims = u.syncButtonGroup(gtx) + metrics.SyncDims, metrics.SyncPrimaryDims, metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx) return metrics.SyncDims }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), @@ -71,6 +75,9 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { rowCall.Add(gtx.Ops) return metrics.RowDims }) + if u.usesCompactViewport() { + u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), metrics.Bounds())) + } if u.usesCompactViewport() { if u.syncMenuOpen { @@ -94,6 +101,37 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return rowDims } +type headerButtonBounds struct { + SyncPrimary image.Rectangle + SyncToggle image.Rectangle + Lock image.Rectangle + MainMenu image.Rectangle +} + +func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds { + return headerButtonBounds{ + SyncPrimary: bounds.SyncPrimary.Add(origin), + SyncToggle: bounds.SyncToggle.Add(origin), + Lock: bounds.Lock.Add(origin), + MainMenu: bounds.MainMenu.Add(origin), + } +} + +func (b headerButtonBounds) logLine(mode string) string { + return fmt.Sprintf( + "keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s", + mode, + formatHeaderRect(b.SyncPrimary), + formatHeaderRect(b.SyncToggle), + formatHeaderRect(b.Lock), + formatHeaderRect(b.MainMenu), + ) +} + +func formatHeaderRect(rect image.Rectangle) string { + return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y) +} + func (u *ui) topRightActionOrder() []string { if u.isVaultLocked() { return nil diff --git a/internal/appui/header/layout/dropdown.go b/internal/appui/header/layout/dropdown.go index 25bb94e..8e19512 100644 --- a/internal/appui/header/layout/dropdown.go +++ b/internal/appui/header/layout/dropdown.go @@ -64,12 +64,15 @@ func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu la } type ActionMetrics struct { - RowOriginX int - Spacing int - RowDims layout.Dimensions - SyncDims layout.Dimensions - LockDims layout.Dimensions - MainDims layout.Dimensions + RowOriginX int + Spacing int + SyncInnerSpacing int + RowDims layout.Dimensions + SyncDims layout.Dimensions + SyncPrimaryDims layout.Dimensions + SyncToggleDims layout.Dimensions + LockDims layout.Dimensions + MainDims layout.Dimensions } func (m ActionMetrics) SyncAnchor() DropdownAnchor { @@ -86,3 +89,28 @@ func (m ActionMetrics) MainAnchor() DropdownAnchor { TriggerBottomY: m.RowDims.Size.Y, } } + +type ActionBounds struct { + SyncPrimary image.Rectangle + SyncToggle image.Rectangle + Lock image.Rectangle + MainMenu image.Rectangle +} + +func (m ActionMetrics) Bounds() ActionBounds { + top := 0 + syncLeft := m.RowOriginX + syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y) + syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing + syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y) + lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing + lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y) + mainLeft := lock.Max.X + m.Spacing + mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y) + return ActionBounds{ + SyncPrimary: syncPrimary, + SyncToggle: syncToggle, + Lock: lock, + MainMenu: mainMenu, + } +} diff --git a/internal/appui/header_sync_menu.go b/internal/appui/header_sync_menu.go index c077e90..b3053f9 100644 --- a/internal/appui/header_sync_menu.go +++ b/internal/appui/header_sync_menu.go @@ -15,19 +15,29 @@ import ( ) func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { + group, _, _ := u.syncButtonGroupWithMetrics(gtx) + return group +} + +func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) { spacing := unit.Dp(4) if u.usesCompactViewport() { spacing = unit.Dp(3) } - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + var primaryDims layout.Dimensions + var toggleDims layout.Dimensions + groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport()) + primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport()) + return primaryDims }), layout.Rigid(layout.Spacer{Width: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.syncMenuToggle(gtx) + toggleDims = u.syncMenuToggle(gtx) + return toggleDims }), ) + return groupDims, primaryDims, toggleDims } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index b745dcf..8ded18e 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -5460,6 +5460,28 @@ func TestUISyncDefaultsPersistInSettings(t *testing.T) { } } +func TestUIDebugHeaderBoundsPersistInSettings(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "settings.json") + + first := newUIWithSession("phone", &session.Manager{}, statePaths{ + SettingsPath: configPath, + }) + first.debugLogHeaderBounds = true + first.saveSettings() + + second := newUIWithSession("phone", &session.Manager{}, statePaths{ + SettingsPath: configPath, + }) + second.debugLogHeaderBounds = false + second.loadSettings() + + if !second.debugLogHeaderBounds { + t.Fatal("debugLogHeaderBounds = false, want true after reload") + } +} + func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) { t.Parallel() @@ -5552,6 +5574,39 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) { } } +func TestUISaveSecuritySettingsPersistsDebugHeaderBounds(t *testing.T) { + t.Parallel() + + manager := &session.Manager{} + dir := t.TempDir() + u := newUIWithSession("phone", manager, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + SettingsPath: filepath.Join(dir, "settings.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) + u.masterPassword.SetText("correct horse battery staple") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + u.securityCipher.SetText(vault.CipherAES256) + u.securityKDF.SetText(vault.KDFAES) + u.loadSettingsDraft() + u.settingsDebugHeaderBounds.Value = true + + if err := u.saveSecuritySettingsAction(); err != nil { + t.Fatalf("saveSecuritySettingsAction() error = %v", err) + } + + reloaded := newUIWithSession("phone", &session.Manager{}, statePaths{ + SettingsPath: u.settingsPath, + }) + reloaded.loadSettings() + + if !reloaded.debugLogHeaderBounds { + t.Fatal("reloaded debugLogHeaderBounds = false, want true") + } +} + func TestUIAccessibilityPreferencesPersist(t *testing.T) { t.Parallel() diff --git a/internal/appui/settings.go b/internal/appui/settings.go index ebfa108..9243c93 100644 --- a/internal/appui/settings.go +++ b/internal/appui/settings.go @@ -5,6 +5,7 @@ import ( "fmt" "image" "image/color" + "log" "os" "path/filepath" "strings" @@ -35,7 +36,8 @@ const ( type accessibilityPreferences = settingsmodel.AccessibilityPreferences type settingsFile struct { - Sync syncSettings `json:"sync,omitempty"` + Sync syncSettings `json:"sync,omitempty"` + Debug debugSettings `json:"debug,omitempty"` } type syncSettings struct { @@ -43,6 +45,10 @@ type syncSettings struct { DirectionDefault string `json:"directionDefault,omitempty"` } +type debugSettings struct { + LogHeaderBounds bool `json:"logHeaderBounds,omitempty"` +} + type syncSettingsDraft struct { SourceDefault syncSourceMode DirectionDefault syncDirection @@ -51,6 +57,7 @@ type syncSettingsDraft struct { type settingsDraft struct { Accessibility accessibilityPreferences Sync syncSettingsDraft + Debug debugSettings } type legacySyncPreferences struct { @@ -191,7 +198,11 @@ func (u *ui) loadSettingsDraft() { SourceDefault: u.syncDefaultSourceMode, DirectionDefault: u.syncDefaultDirection, }, + Debug: debugSettings{ + LogHeaderBounds: u.debugLogHeaderBounds, + }, } + u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds } func (u *ui) saveSecuritySettingsAction() error { @@ -213,9 +224,14 @@ func (u *ui) applySecuritySettingsLive() error { if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) { u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value) } + u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault) u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault) + u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds + if !u.debugLogHeaderBounds { + u.lastHeaderBoundsLog = "" + } u.applySettingsFormToPreferences() u.applyAccessibilityPreferences(u.settingsDraft.Accessibility) u.saveSettings() @@ -234,6 +250,7 @@ func (u *ui) loadSettings() { if json.Unmarshal(content, &settings) == nil { u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault)) u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault)) + u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds return } } @@ -270,6 +287,9 @@ func (u *ui) saveSettings() { SourceDefault: string(u.syncDefaultSourceMode), DirectionDefault: string(u.syncDefaultDirection), }, + Debug: debugSettings{ + LogHeaderBounds: u.debugLogHeaderBounds, + }, }, "", " ") if err != nil { return @@ -277,6 +297,18 @@ func (u *ui) saveSettings() { _ = os.WriteFile(u.settingsPath, content, 0o600) } +func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) { + if !u.debugLogHeaderBounds { + return + } + line := bounds.logLine(u.mode) + if line == u.lastHeaderBoundsLog { + return + } + log.Print(line) + u.lastHeaderBoundsLog = line +} + func (u *ui) showStatusMessage(message string) { u.state.StatusMessage = message if u.accessibilityPrefs.ReducedMotion { From b59cf8044b47369911b8f0a9f3a942a75d82f459 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 15:32:35 -0700 Subject: [PATCH 44/53] Log header bounds to Android logcat --- .../appui/platform/android_log_android.go | 22 +++++++++++++++++++ internal/appui/platform/android_share_stub.go | 6 +++++ internal/appui/settings.go | 4 ++-- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 internal/appui/platform/android_log_android.go diff --git a/internal/appui/platform/android_log_android.go b/internal/appui/platform/android_log_android.go new file mode 100644 index 0000000..a3c57d0 --- /dev/null +++ b/internal/appui/platform/android_log_android.go @@ -0,0 +1,22 @@ +//go:build android + +package platform + +/* +#cgo CFLAGS: -Werror +#cgo LDFLAGS: -landroid + +#include +#include +*/ +import "C" + +import "unsafe" + +func LogInfo(tag, msg string) { + ctag := C.CString(tag) + defer C.free(unsafe.Pointer(ctag)) + cmsg := C.CString(msg) + defer C.free(unsafe.Pointer(cmsg)) + C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg) +} diff --git a/internal/appui/platform/android_share_stub.go b/internal/appui/platform/android_share_stub.go index 9343c74..f23ba0a 100644 --- a/internal/appui/platform/android_share_stub.go +++ b/internal/appui/platform/android_share_stub.go @@ -2,6 +2,12 @@ package platform +import "log" + func NewVaultSharer(goos string) VaultSharer { return nil } + +func LogInfo(tag, msg string) { + log.Printf("%s: %s", tag, msg) +} diff --git a/internal/appui/settings.go b/internal/appui/settings.go index 9243c93..078110b 100644 --- a/internal/appui/settings.go +++ b/internal/appui/settings.go @@ -5,7 +5,6 @@ import ( "fmt" "image" "image/color" - "log" "os" "path/filepath" "strings" @@ -18,6 +17,7 @@ import ( "gioui.org/widget" "gioui.org/widget/material" editormodel "git.julianfamily.org/keepassgo/internal/appui/editor" + "git.julianfamily.org/keepassgo/internal/appui/platform" settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings" "git.julianfamily.org/keepassgo/internal/vault" ) @@ -305,7 +305,7 @@ func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) { if line == u.lastHeaderBoundsLog { return } - log.Print(line) + platform.LogInfo("KeePassGO", line) u.lastHeaderBoundsLog = line } From 44da1e6599f6e2da483113814f959e1f77f2d8ca Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 15:36:23 -0700 Subject: [PATCH 45/53] Confirm debug logging is enabled --- internal/appui/app.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/appui/app.go b/internal/appui/app.go index 04c73ed..ca59572 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -692,6 +692,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.loadUIPreferences() u.loadSettings() + if u.debugLogHeaderBounds { + platform.LogInfo("KeePassGO", "keepassgo header-bounds logging enabled") + } u.loadSettingsFormFromPreferences() u.loadSettingsDraft() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() From ac3478889c250658a1dd5de8ea7471ff3e2be540 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 15:48:57 -0700 Subject: [PATCH 46/53] Make header action cluster own menus --- internal/appui/header.go | 104 ++++++++++++++++++----------- internal/appui/header_sync_menu.go | 7 ++ 2 files changed, 72 insertions(+), 39 deletions(-) diff --git a/internal/appui/header.go b/internal/appui/header.go index d73df2c..841cd7d 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -39,68 +39,87 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { if u.shouldShowLifecycleSetup() || u.isVaultLocked() || u.shouldShowDesktopWorkingHeader() { return layout.Dimensions{} } - spacing := gtx.Dp(unit.Dp(8)) - metrics := headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))} - if !u.usesCompactViewport() { - metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4)) - } - actionCluster := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.SyncDims, metrics.SyncPrimaryDims, metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx) - return metrics.SyncDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.lockVault, "Lock") - metrics.LockDims = btn.Layout(gtx) - return metrics.LockDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.MainDims = u.mainMenuButtonGroup(gtx) - return metrics.MainDims - }), - ) - } - - rowOps := op.Record(gtx.Ops) - metrics.RowDims = actionCluster(gtx) - rowCall := rowOps.Stop() - metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) - + cluster := u.newHeaderActionCluster(gtx) surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} rowDims := layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - rowCall.Add(gtx.Ops) - return metrics.RowDims + cluster.RowCall.Add(gtx.Ops) + return cluster.Metrics.RowDims }) if u.usesCompactViewport() { - u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), metrics.Bounds())) + u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds())) } if u.usesCompactViewport() { if u.syncMenuOpen { u.phoneSyncMenuVisible = true - u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point() + u.phoneSyncMenuAnchor = cluster.Metrics.SyncAnchor().Point() } if u.mainMenuOpen { u.phoneMainMenuVisible = true - u.phoneMainMenuAnchor = metrics.MainAnchor().Point() + u.phoneMainMenuAnchor = cluster.Metrics.MainAnchor().Point() } return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} } - if u.syncMenuOpen { - surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu) + if cluster.ShowSyncMenu() { + surface.Draw(gtx, cluster.Metrics.SyncAnchor(), cluster.SyncMenu) } - if u.mainMenuOpen { - surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) + if cluster.ShowMainMenu() { + surface.Draw(gtx, cluster.Metrics.MainAnchor(), cluster.MainMenu) } return rowDims } +type headerActionCluster struct { + Metrics headerlayout.ActionMetrics + RowCall op.CallOp + SyncMenu layout.Widget + MainMenu layout.Widget +} + +func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil } + +func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil } + +func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster { + cluster := headerActionCluster{ + SyncMenu: u.syncMenuWidget(), + MainMenu: u.mainMenuWidget(), + } + spacing := gtx.Dp(unit.Dp(8)) + cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))} + if !u.usesCompactViewport() { + cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4)) + } + rowOps := op.Record(gtx.Ops) + cluster.Metrics.RowDims = cluster.layoutRow(gtx, u) + cluster.RowCall = rowOps.Stop() + cluster.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-cluster.Metrics.RowDims.Size.X) + return cluster +} + +func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx) + return c.Metrics.SyncDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + c.Metrics.LockDims = btn.Layout(gtx) + return c.Metrics.LockDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + c.Metrics.MainDims = u.mainMenuButtonGroup(gtx) + return c.Metrics.MainDims + }), + ) +} + type headerButtonBounds struct { SyncPrimary image.Rectangle SyncToggle image.Rectangle @@ -212,6 +231,13 @@ func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP return img.Layout(gtx) } +func (u *ui) mainMenuWidget() layout.Widget { + if !u.mainMenuOpen { + return nil + } + return u.mainMenu +} + func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/header_sync_menu.go b/internal/appui/header_sync_menu.go index b3053f9..e289188 100644 --- a/internal/appui/header_sync_menu.go +++ b/internal/appui/header_sync_menu.go @@ -58,6 +58,13 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { return btn.Layout(gtx) } +func (u *ui) syncMenuWidget() layout.Widget { + if !u.syncMenuOpen { + return nil + } + return u.syncMenu +} + func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { model := u.buildSyncMenuModel() profiles := u.availableRemoteProfiles() From 550d9f362ce4d678bcab7cc49bfcf32712695df6 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 16:08:08 -0700 Subject: [PATCH 47/53] Add ship-it skill and menu placement logs --- .codex/skills/keepassgo-ship-it/SKILL.md | 109 +++++++++++++++++++++++ internal/appui/frame.go | 2 + internal/appui/header.go | 26 +++++- internal/appui/header/layout/dropdown.go | 19 +++- internal/appui/settings.go | 27 ++++++ 5 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 .codex/skills/keepassgo-ship-it/SKILL.md diff --git a/.codex/skills/keepassgo-ship-it/SKILL.md b/.codex/skills/keepassgo-ship-it/SKILL.md new file mode 100644 index 0000000..81f2768 --- /dev/null +++ b/.codex/skills/keepassgo-ship-it/SKILL.md @@ -0,0 +1,109 @@ +--- +name: keepassgo-ship-it +description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened. +--- + +# KeePassGO Ship It + +Use this skill only in the KeePassGO repository. This is not a global shorthand. + +Use it together with: +- `android-emulator-debug` for emulator and `adb` mechanics +- `keepass-credentials` for Nextcloud credentials +- `public-repo-sanitization` before the commit/push step + +## Meaning Of `ship it` + +When the user says `ship it`, do all of this unless they narrow the scope: + +1. Commit the relevant KeePassGO source changes first. +2. Build and install the Arch package from that committed source. +3. Build the Android APK from that same committed source. +4. Zip the APK. +5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository. +6. Install the rebuilt APK in the emulator. +7. Launch the rebuilt app in the emulator. +8. Open a controlled demo vault in the emulator. + +Do not stop after the commit or after the package build. `ship it` means finish the full loop. + +## Required Sequence + +### 1. Commit First + +- Make sure the worktree state intended for shipping is committed before building. +- If the repo is dirty in unrelated ways, commit only the relevant changes. +- Before the commit or push, run the public-repo sanitization checks. + +### 2. Build And Install The Arch Package + +From the repo root: + +```sh +make archlinux-pkgbuild +cd packaging/archlinux/keepassgo-git +makepkg -si --noconfirm +``` + +The installed package version must correspond to the committed source, not a dirty worktree. + +### 3. Build The APK + +Use the repo's known-good local JDK unless the environment already proves otherwise: + +```sh +JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk +``` + +If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout. + +### 4. Zip The APK + +- Create the ZIP under the globally required temporary secret-safe directory. +- Use a name that includes the commit, for example: + `keepassgo--apk.zip` + +### 5. Upload To Nextcloud + +- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them. +- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow. +- Use the globally required temporary secret-safe directory for any temporary curl config or secret material. +- Ensure that directory exists with mode `700`. +- Create secret temp files with mode `600`. +- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`. + +### 6. Emulator Install And Launch + +- Reuse the existing emulator session if one is already running. +- Install with replacement: + +```sh +adb install -r build/keepassgo.apk +``` + +- Launch KeePassGO and confirm it is focused. +- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure. + +### 7. Open A Controlled Demo Vault + +- Do not rely on the user's real vault for this step. +- Use a controlled/sanitized demo vault that you can unlock yourself. +- Open it in the emulator before closing out `ship it`. +- Capture a screenshot if needed to verify the app really rendered and opened the vault. + +## Closeout Requirements + +When reporting back after `ship it`, include: +- the commit that was shipped +- the installed Arch package version +- the APK path +- the uploaded ZIP URL +- confirmation that the emulator app was launched +- confirmation that the controlled demo vault was opened + +## Constraints + +- Keep this workflow specific to KeePassGO. +- Preserve emulator state; do not kill or reset it unless the user explicitly asks. +- Do not use `rm -rf`. +- Do not use `rm -f`. diff --git a/internal/appui/frame.go b/internal/appui/frame.go index 7662dc7..2851cf4 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -679,12 +679,14 @@ func (u *ui) handleHeaderActionClicks(gtx layout.Context) { if u.syncMenuOpen { u.mainMenuOpen = false } + u.maybeLogHeaderMenuToggle("sync", u.syncMenuOpen) } for u.toggleMainMenu.Clicked(gtx) { u.mainMenuOpen = !u.mainMenuOpen if u.mainMenuOpen { u.syncMenuOpen = false } + u.maybeLogHeaderMenuToggle("main", u.mainMenuOpen) } for u.openAdvancedSync.Clicked(gtx) { u.openAdvancedSyncDialog() diff --git a/internal/appui/header.go b/internal/appui/header.go index 841cd7d..7d8c230 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -54,19 +54,29 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { if u.syncMenuOpen { u.phoneSyncMenuVisible = true u.phoneSyncMenuAnchor = cluster.Metrics.SyncAnchor().Point() + u.maybeLogHeaderMenuToggle("sync-visible", true) } if u.mainMenuOpen { u.phoneMainMenuVisible = true u.phoneMainMenuAnchor = cluster.Metrics.MainAnchor().Point() + u.maybeLogHeaderMenuToggle("main-visible", true) } return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} } if cluster.ShowSyncMenu() { - surface.Draw(gtx, cluster.Metrics.SyncAnchor(), cluster.SyncMenu) + placement, menuCall := surface.Place(gtx, cluster.Metrics.SyncAnchor(), cluster.SyncMenu) + u.maybeLogHeaderMenuPlacement("sync", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() } if cluster.ShowMainMenu() { - surface.Draw(gtx, cluster.Metrics.MainAnchor(), cluster.MainMenu) + placement, menuCall := surface.Place(gtx, cluster.Metrics.MainAnchor(), cluster.MainMenu) + u.maybeLogHeaderMenuPlacement("main", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() } return rowDims @@ -171,10 +181,18 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } if u.syncMenuVisibleOnPhone() { - surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + placement, menuCall := surface.Place(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + u.maybeLogHeaderMenuPlacement("sync-phone", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() } if u.mainMenuVisibleOnPhone() { - surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + placement, menuCall := surface.Place(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + u.maybeLogHeaderMenuPlacement("main-phone", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() } return layout.Dimensions{Size: gtx.Constraints.Max} } diff --git a/internal/appui/header/layout/dropdown.go b/internal/appui/header/layout/dropdown.go index 8e19512..ffd06a0 100644 --- a/internal/appui/header/layout/dropdown.go +++ b/internal/appui/header/layout/dropdown.go @@ -38,6 +38,12 @@ type DropdownSurface struct { TopInset int } +type DropdownPlacement struct { + Anchor DropdownAnchor + Origin image.Point + Size image.Point +} + func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context { menuGTX := gtx menuGTX.Constraints.Min = image.Point{} @@ -51,13 +57,22 @@ func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Poin return image.Pt(x, y) } -func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions { +func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) { menuGTX := s.MenuConstraints(gtx) menuOps := op.Record(gtx.Ops) menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) menuCall := menuOps.Stop() menuOrigin := s.Origin(anchor, menuDims.Size.X) - stack := op.Offset(menuOrigin).Push(gtx.Ops) + return DropdownPlacement{ + Anchor: anchor, + Origin: menuOrigin, + Size: menuDims.Size, + }, menuCall +} + +func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions { + placement, menuCall := s.Place(gtx, anchor, menu) + stack := op.Offset(placement.Origin).Push(gtx.Ops) menuCall.Add(gtx.Ops) stack.Pop() return layout.Dimensions{Size: gtx.Constraints.Max} diff --git a/internal/appui/settings.go b/internal/appui/settings.go index 078110b..ae531ab 100644 --- a/internal/appui/settings.go +++ b/internal/appui/settings.go @@ -17,6 +17,7 @@ import ( "gioui.org/widget" "gioui.org/widget/material" editormodel "git.julianfamily.org/keepassgo/internal/appui/editor" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" "git.julianfamily.org/keepassgo/internal/appui/platform" settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings" "git.julianfamily.org/keepassgo/internal/vault" @@ -309,6 +310,32 @@ func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) { u.lastHeaderBoundsLog = line } +func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) { + if !u.debugLogHeaderBounds { + return + } + platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open)) +} + +func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) { + if !u.debugLogHeaderBounds { + return + } + platform.LogInfo("KeePassGO", fmt.Sprintf( + "keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d", + menu, + placement.Anchor.TriggerRightX, + placement.Anchor.TriggerBottomY, + placement.Origin.X, + placement.Origin.Y, + placement.Size.X, + placement.Size.Y, + surface.ContainerWidth, + surface.LeftInset, + surface.TopInset, + )) +} + func (u *ui) showStatusMessage(message string) { u.state.StatusMessage = message if u.accessibilityPrefs.ReducedMotion { From 54f13d352c1d6533c26179a1c9a81ff8aa8492c8 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 18:55:02 -0700 Subject: [PATCH 48/53] Fix compact header overlay ordering --- internal/appui/app.go | 5 +++ internal/appui/frame.go | 7 +++- internal/appui/header.go | 69 +++++++++++++++++++++++++--------------- 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/internal/appui/app.go b/internal/appui/app.go index ca59572..4af599d 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -15,6 +15,7 @@ import ( "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" + "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" @@ -490,6 +491,10 @@ type ui struct { apiTokenSecret string phoneSyncMenuAnchor image.Point phoneMainMenuAnchor image.Point + phoneSyncMenuOrigin image.Point + phoneMainMenuOrigin image.Point + phoneSyncMenuCall op.CallOp + phoneMainMenuCall op.CallOp phoneSyncMenuVisible bool phoneMainMenuVisible bool selectedAuditIndex int diff --git a/internal/appui/frame.go b/internal/appui/frame.go index 2851cf4..e311f2f 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -3,6 +3,7 @@ package appui import ( "errors" "fmt" + "image" "path/filepath" "slices" "strings" @@ -590,6 +591,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) u.phoneSyncMenuVisible = false u.phoneMainMenuVisible = false + u.phoneSyncMenuOrigin = image.Point{} + u.phoneMainMenuOrigin = image.Point{} + u.phoneSyncMenuCall = op.CallOp{} + u.phoneMainMenuCall = op.CallOp{} u.syncHostedAPI() u.filter() u.processShortcuts(gtx) @@ -615,7 +620,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { layout.Stacked(u.securityDialogOverlay), layout.Stacked(u.remotePrefsDialogOverlay), layout.Stacked(u.approvalDialogOverlay), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { + layout.Expanded(func(gtx layout.Context) layout.Dimensions { return u.phoneHeaderMenus(gtx) }), layout.Stacked(u.statusToast), diff --git a/internal/appui/header.go b/internal/appui/header.go index 7d8c230..ebd7062 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -12,6 +12,7 @@ import ( "gioui.org/widget/material" headerview "git.julianfamily.org/keepassgo/internal/appui/header" headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" + "git.julianfamily.org/keepassgo/internal/appui/platform" ) func (u *ui) header(gtx layout.Context) layout.Dimensions { @@ -41,25 +42,34 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { } cluster := u.newHeaderActionCluster(gtx) surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} - - rowDims := layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - cluster.RowCall.Add(gtx.Ops) - return cluster.Metrics.RowDims - }) + rowDims := cluster.layout(gtx, u) if u.usesCompactViewport() { u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds())) } if u.usesCompactViewport() { + compactSurface := headerlayout.DropdownSurface{ + ContainerWidth: gtx.Constraints.Max.X, + LeftInset: u.frameInsetPx, + TopInset: u.frameInsetPx, + } if u.syncMenuOpen { u.phoneSyncMenuVisible = true u.phoneSyncMenuAnchor = cluster.Metrics.SyncAnchor().Point() u.maybeLogHeaderMenuToggle("sync-visible", true) + placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.SyncAnchor(), u.syncMenu) + u.phoneSyncMenuOrigin = placement.Origin + u.phoneSyncMenuCall = menuCall + u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement) } if u.mainMenuOpen { u.phoneMainMenuVisible = true u.phoneMainMenuAnchor = cluster.Metrics.MainAnchor().Point() u.maybeLogHeaderMenuToggle("main-visible", true) + placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.MainAnchor(), u.mainMenu) + u.phoneMainMenuOrigin = placement.Origin + u.phoneMainMenuCall = menuCall + u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement) } return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} } @@ -84,7 +94,6 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { type headerActionCluster struct { Metrics headerlayout.ActionMetrics - RowCall op.CallOp SyncMenu layout.Widget MainMenu layout.Widget } @@ -103,13 +112,20 @@ func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster { if !u.usesCompactViewport() { cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4)) } - rowOps := op.Record(gtx.Ops) - cluster.Metrics.RowDims = cluster.layoutRow(gtx, u) - cluster.RowCall = rowOps.Stop() - cluster.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-cluster.Metrics.RowDims.Size.X) return cluster } +func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions { + rowOps := op.Record(gtx.Ops) + c.Metrics.RowDims = c.layoutRow(gtx, u) + rowCall := rowOps.Stop() + c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X) + return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + rowCall.Add(gtx.Ops) + return c.Metrics.RowDims + }) +} + func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -169,29 +185,32 @@ func (u *ui) topRightActionOrder() []string { } func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { + if u.debugLogHeaderBounds { + platform.LogInfo("KeePassGO", fmt.Sprintf( + "keepassgo phone-header-menus compact=%t syncVisible=%t syncOpen=%t mainVisible=%t mainOpen=%t syncCall=%t mainCall=%t max=%dx%d", + u.usesCompactViewport(), + u.phoneSyncMenuVisible, + u.syncMenuOpen, + u.phoneMainMenuVisible, + u.mainMenuOpen, + u.phoneSyncMenuCall != (op.CallOp{}), + u.phoneMainMenuCall != (op.CallOp{}), + gtx.Constraints.Max.X, + gtx.Constraints.Max.Y, + )) + } if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) { return layout.Dimensions{} } - gtx.Constraints.Min = gtx.Constraints.Max - contentInsetPx := gtx.Dp(unit.Dp(16)) - surface := headerlayout.DropdownSurface{ - ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), - LeftInset: contentInsetPx, - TopInset: contentInsetPx, - } if u.syncMenuVisibleOnPhone() { - placement, menuCall := surface.Place(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) - u.maybeLogHeaderMenuPlacement("sync-phone", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) + stack := op.Offset(u.phoneSyncMenuOrigin).Push(gtx.Ops) + u.phoneSyncMenuCall.Add(gtx.Ops) stack.Pop() } if u.mainMenuVisibleOnPhone() { - placement, menuCall := surface.Place(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) - u.maybeLogHeaderMenuPlacement("main-phone", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) + stack := op.Offset(u.phoneMainMenuOrigin).Push(gtx.Ops) + u.phoneMainMenuCall.Add(gtx.Ops) stack.Pop() } return layout.Dimensions{Size: gtx.Constraints.Max} From 56a0711860d5c5d12ce16f6022644d53365a6727 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 19:23:13 -0700 Subject: [PATCH 49/53] Right-align compact header menus --- internal/appui/app.go | 4 ++-- internal/appui/frame.go | 2 ++ internal/appui/header.go | 24 ++++++++++++++++++------ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/internal/appui/app.go b/internal/appui/app.go index 4af599d..2e3496d 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -489,10 +489,10 @@ type ui struct { deleteGroupPath []string apiPolicyGroupScope bool apiTokenSecret string - phoneSyncMenuAnchor image.Point - phoneMainMenuAnchor image.Point phoneSyncMenuOrigin image.Point phoneMainMenuOrigin image.Point + phoneSyncMenuSize image.Point + phoneMainMenuSize image.Point phoneSyncMenuCall op.CallOp phoneMainMenuCall op.CallOp phoneSyncMenuVisible bool diff --git a/internal/appui/frame.go b/internal/appui/frame.go index e311f2f..e1f2512 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -593,6 +593,8 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.phoneMainMenuVisible = false u.phoneSyncMenuOrigin = image.Point{} u.phoneMainMenuOrigin = image.Point{} + u.phoneSyncMenuSize = image.Point{} + u.phoneMainMenuSize = image.Point{} u.phoneSyncMenuCall = op.CallOp{} u.phoneMainMenuCall = op.CallOp{} u.syncHostedAPI() diff --git a/internal/appui/header.go b/internal/appui/header.go index ebd7062..0e5f805 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -55,19 +55,19 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { } if u.syncMenuOpen { u.phoneSyncMenuVisible = true - u.phoneSyncMenuAnchor = cluster.Metrics.SyncAnchor().Point() u.maybeLogHeaderMenuToggle("sync-visible", true) placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.SyncAnchor(), u.syncMenu) u.phoneSyncMenuOrigin = placement.Origin + u.phoneSyncMenuSize = placement.Size u.phoneSyncMenuCall = menuCall u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement) } if u.mainMenuOpen { u.phoneMainMenuVisible = true - u.phoneMainMenuAnchor = cluster.Metrics.MainAnchor().Point() u.maybeLogHeaderMenuToggle("main-visible", true) placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.MainAnchor(), u.mainMenu) u.phoneMainMenuOrigin = placement.Origin + u.phoneMainMenuSize = placement.Size u.phoneMainMenuCall = menuCall u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement) } @@ -204,13 +204,25 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } if u.syncMenuVisibleOnPhone() { - stack := op.Offset(u.phoneSyncMenuOrigin).Push(gtx.Ops) - u.phoneSyncMenuCall.Add(gtx.Ops) + stack := op.Offset(image.Pt(u.frameInsetPx, u.phoneSyncMenuOrigin.Y)).Push(gtx.Ops) + menuGTX := gtx + menuGTX.Constraints.Min = image.Point{} + menuGTX.Constraints.Max.X = max(0, gtx.Constraints.Max.X-(u.frameInsetPx*2)) + layout.E.Layout(menuGTX, func(gtx layout.Context) layout.Dimensions { + u.phoneSyncMenuCall.Add(gtx.Ops) + return layout.Dimensions{Size: u.phoneSyncMenuSize} + }) stack.Pop() } if u.mainMenuVisibleOnPhone() { - stack := op.Offset(u.phoneMainMenuOrigin).Push(gtx.Ops) - u.phoneMainMenuCall.Add(gtx.Ops) + stack := op.Offset(image.Pt(u.frameInsetPx, u.phoneMainMenuOrigin.Y)).Push(gtx.Ops) + menuGTX := gtx + menuGTX.Constraints.Min = image.Point{} + menuGTX.Constraints.Max.X = max(0, gtx.Constraints.Max.X-(u.frameInsetPx*2)) + layout.E.Layout(menuGTX, func(gtx layout.Context) layout.Dimensions { + u.phoneMainMenuCall.Add(gtx.Ops) + return layout.Dimensions{Size: u.phoneMainMenuSize} + }) stack.Pop() } return layout.Dimensions{Size: gtx.Constraints.Max} From c4f110e0ad1d3633cbbe723fd278124ff524f00e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 21:48:05 -0700 Subject: [PATCH 50/53] Refine compact header menus --- internal/appui/frame.go | 4 +-- internal/appui/header.go | 53 ++++++++++------------------ internal/appui/header/layout/menu.go | 11 ++++-- internal/appui/header/menu.go | 20 ++++++++--- internal/appui/header_sync_menu.go | 52 +++++++++++++++++++++------ 5 files changed, 86 insertions(+), 54 deletions(-) diff --git a/internal/appui/frame.go b/internal/appui/frame.go index e1f2512..a731660 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -622,9 +622,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { layout.Stacked(u.securityDialogOverlay), layout.Stacked(u.remotePrefsDialogOverlay), layout.Stacked(u.approvalDialogOverlay), - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - return u.phoneHeaderMenus(gtx) - }), + layout.Expanded(u.phoneHeaderMenus), layout.Stacked(u.statusToast), ) } diff --git a/internal/appui/header.go b/internal/appui/header.go index 0e5f805..0e7bd25 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -12,7 +12,6 @@ import ( "gioui.org/widget/material" headerview "git.julianfamily.org/keepassgo/internal/appui/header" headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" - "git.julianfamily.org/keepassgo/internal/appui/platform" ) func (u *ui) header(gtx layout.Context) layout.Dimensions { @@ -185,47 +184,33 @@ func (u *ui) topRightActionOrder() []string { } func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { - if u.debugLogHeaderBounds { - platform.LogInfo("KeePassGO", fmt.Sprintf( - "keepassgo phone-header-menus compact=%t syncVisible=%t syncOpen=%t mainVisible=%t mainOpen=%t syncCall=%t mainCall=%t max=%dx%d", - u.usesCompactViewport(), - u.phoneSyncMenuVisible, - u.syncMenuOpen, - u.phoneMainMenuVisible, - u.mainMenuOpen, - u.phoneSyncMenuCall != (op.CallOp{}), - u.phoneMainMenuCall != (op.CallOp{}), - gtx.Constraints.Max.X, - gtx.Constraints.Max.Y, - )) - } if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) { return layout.Dimensions{} } if u.syncMenuVisibleOnPhone() { - stack := op.Offset(image.Pt(u.frameInsetPx, u.phoneSyncMenuOrigin.Y)).Push(gtx.Ops) - menuGTX := gtx - menuGTX.Constraints.Min = image.Point{} - menuGTX.Constraints.Max.X = max(0, gtx.Constraints.Max.X-(u.frameInsetPx*2)) - layout.E.Layout(menuGTX, func(gtx layout.Context) layout.Dimensions { - u.phoneSyncMenuCall.Add(gtx.Ops) - return layout.Dimensions{Size: u.phoneSyncMenuSize} + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) + defer stack.Pop() + fullWidthGTX := gtx + fullWidthGTX.Constraints.Min = image.Point{} + fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X + dims := layout.E.Layout(fullWidthGTX, u.syncMenu) + return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))} }) - stack.Pop() } if u.mainMenuVisibleOnPhone() { - stack := op.Offset(image.Pt(u.frameInsetPx, u.phoneMainMenuOrigin.Y)).Push(gtx.Ops) - menuGTX := gtx - menuGTX.Constraints.Min = image.Point{} - menuGTX.Constraints.Max.X = max(0, gtx.Constraints.Max.X-(u.frameInsetPx*2)) - layout.E.Layout(menuGTX, func(gtx layout.Context) layout.Dimensions { - u.phoneMainMenuCall.Add(gtx.Ops) - return layout.Dimensions{Size: u.phoneMainMenuSize} + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) + defer stack.Pop() + fullWidthGTX := gtx + fullWidthGTX.Constraints.Min = image.Point{} + fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X + dims := layout.E.Layout(fullWidthGTX, u.mainMenu) + return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))} }) - stack.Pop() } - return layout.Dimensions{Size: gtx.Constraints.Max} + return layout.Dimensions{} } func (u *ui) syncMenuVisibleOnPhone() bool { @@ -306,7 +291,7 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") }, } - return headerview.MainMenu(gtx, u.theme, rows, compactCard) + return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil) } func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { @@ -318,7 +303,7 @@ func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { } func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { - return headerlayout.IntrinsicCompactCard(gtx, w, compactCard) + return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil) } func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { diff --git a/internal/appui/header/layout/menu.go b/internal/appui/header/layout/menu.go index baaee43..2cae01b 100644 --- a/internal/appui/header/layout/menu.go +++ b/internal/appui/header/layout/menu.go @@ -8,13 +8,16 @@ import ( "gioui.org/unit" ) -func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions) layout.Dimensions { +func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions { measureGTX := gtx measureGTX.Constraints.Min = image.Point{} measureGTX.Constraints.Max.X = gtx.Constraints.Max.X macro := op.Record(gtx.Ops) contentDims := w(measureGTX) _ = macro.Stop() + if logger != nil { + logger("intrinsic-measure", measureGTX.Constraints, contentDims) + } width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) maxWidth := gtx.Constraints.Max.X if maxWidth > 0 && width > maxWidth { @@ -24,7 +27,11 @@ func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout. gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width } - return card(gtx, w) + dims := card(gtx, w) + if logger != nil { + logger("intrinsic-card", gtx.Constraints, dims) + } + return dims } func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int { diff --git a/internal/appui/header/menu.go b/internal/appui/header/menu.go index 2716dbb..14f9f8f 100644 --- a/internal/appui/header/menu.go +++ b/internal/appui/header/menu.go @@ -2,6 +2,7 @@ package header import ( "image/color" + "image" "gioui.org/layout" "gioui.org/unit" @@ -10,9 +11,12 @@ import ( headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" ) -func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions) layout.Dimensions { +func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions { rowWidth := headerlayout.MenuActionWidth(gtx, rows) - return headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + if logger != nil { + logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)}) + } + dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { children := make([]layout.FlexChild, 0, (len(rows)*2)-1) for i, row := range rows { if i > 0 { @@ -23,8 +27,16 @@ func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, c return headerlayout.RightAlignedAction(gtx, rowWidth, current) })) } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) - }, card) + dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + if logger != nil { + logger("rows", gtx.Constraints, dims) + } + return dims + }, card, logger) + if logger != nil { + logger("card", gtx.Constraints, dims) + } + return dims } func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions { diff --git a/internal/appui/header_sync_menu.go b/internal/appui/header_sync_menu.go index e289188..22b7ed1 100644 --- a/internal/appui/header_sync_menu.go +++ b/internal/appui/header_sync_menu.go @@ -1,11 +1,13 @@ package appui import ( + "image" "image/color" "runtime" "strings" "gioui.org/layout" + "gioui.org/op" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" @@ -77,9 +79,44 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { } actionRows := u.syncMenuActionRows(model) actionWidth := menuActionWidth(gtx, actionRows) - return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...) - }) + menu := func(gtx layout.Context) layout.Dimensions { + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...) + }) + } + reserveWidth := u.syncMenuTrailingReserveWidth(gtx) + if reserveWidth <= 0 { + return menu(gtx) + } + return layout.Flex{}.Layout(gtx, + layout.Rigid(menu), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Dimensions{Size: image.Pt(reserveWidth, 0)} + }), + ) +} + +func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int { + spacing := gtx.Dp(unit.Dp(8)) + if u.usesCompactViewport() { + spacing = gtx.Dp(unit.Dp(8)) + } + + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + + lockOps := op.Record(gtx.Ops) + lockDims := func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + return btn.Layout(gtx) + }(measureGTX) + _ = lockOps.Stop() + + menuOps := op.Record(gtx.Ops) + menuDims := u.mainMenuButtonGroup(measureGTX) + _ = menuOps.Stop() + + return spacing + lockDims.Size.X + spacing + menuDims.Size.X } func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget { @@ -126,14 +163,7 @@ func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProf } func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild { - 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), - } + rows := []layout.FlexChild{} if model.ShowShare { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, From fe3c07e3dde6e350d9629e6aa0f886e6de97a0de Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 22:12:50 -0700 Subject: [PATCH 51/53] Unify action menus and collapse empty detail pane --- internal/appui/app.go | 45 +++-------- internal/appui/frame.go | 14 ++++ internal/appui/header.go | 123 +++++++++++++++++------------ internal/appui/header_sync_menu.go | 5 -- 4 files changed, 100 insertions(+), 87 deletions(-) diff --git a/internal/appui/app.go b/internal/appui/app.go index 2e3496d..fa9d237 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -1850,8 +1850,17 @@ func (u *ui) navigationHeaderLabel() string { func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions { for click.Clicked(gtx) { - _ = u.state.ToggleVisibleIndex(idx) + if !u.shouldShowDetailPane() { + if idx >= 0 && idx < len(u.visible) { + u.state.SelectedEntryID = u.visible[idx].ID + } + } else { + _ = u.state.ToggleVisibleIndex(idx) + } u.loadSelectedEntryIntoEditor() + if u.invalidate != nil { + u.invalidate() + } } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { selected := item.ID == u.state.SelectedEntryID @@ -2014,21 +2023,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { return panel(gtx, func(gtx layout.Context) layout.Dimensions { if u.shouldShowDesktopWorkingHeader() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle, Spacing: layout.SpaceStart}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Dimensions{} - }), - layout.Rigid(u.syncButtonGroup), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.lockVault, "Lock") - return btn.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(u.mainMenuButtonGroup), - ) - }), + layout.Rigid(u.headerActions), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.detailPanelContent(gtx) @@ -2049,7 +2044,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { panel := u.staticDetailPanel() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...) case detaillayout.ModeEmpty: - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.emptyDetailChildren()...) + return layout.Dimensions{} } item, ok := u.selectedEntry() if mode == detaillayout.ModeEditor { @@ -2089,22 +2084,6 @@ func (u *ui) staticDetailPanel() []layout.FlexChild { } } -func (u *ui) emptyDetailChildren() []layout.FlexChild { - return []layout.FlexChild{ - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(18), "Entry details") - 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(16), u.detailPlaceholderMessage()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - } -} - func (u *ui) detailEditorContent(gtx layout.Context, hasSelected bool) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/frame.go b/internal/appui/frame.go index a731660..1df45a2 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -20,6 +20,7 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" + detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/session" ) @@ -1310,6 +1311,8 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { return u.lifecycleScreen(gtx) case u.shouldUseLockedSinglePane(): return u.detailPanel(gtx) + case !u.shouldShowDetailPane(): + return u.listPanel(gtx) case u.usesCompactViewport(): return u.compactPrimaryContent(gtx) default: @@ -1317,6 +1320,17 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { } } +func (u *ui) shouldShowDetailPane() bool { + _, hasSelectedEntry := u.selectedEntry() + mode := detaillayout.Resolve( + u.isVaultLocked(), + u.state.Section == appstate.SectionAPITokens || u.state.Section == appstate.SectionAPIAudit || u.state.Section == appstate.SectionAbout, + hasSelectedEntry, + u.editingEntry, + ) + return mode != detaillayout.ModeEmpty +} + func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { u.phoneSpan = gtx.Constraints.Max.Y listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) diff --git a/internal/appui/header.go b/internal/appui/header.go index 0e7bd25..97469c2 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -36,57 +36,21 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { } func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { - if u.shouldShowLifecycleSetup() || u.isVaultLocked() || u.shouldShowDesktopWorkingHeader() { + if u.shouldShowLifecycleSetup() || u.isVaultLocked() { return layout.Dimensions{} } cluster := u.newHeaderActionCluster(gtx) - surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} rowDims := cluster.layout(gtx, u) if u.usesCompactViewport() { u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds())) } if u.usesCompactViewport() { - compactSurface := headerlayout.DropdownSurface{ - ContainerWidth: gtx.Constraints.Max.X, - LeftInset: u.frameInsetPx, - TopInset: u.frameInsetPx, - } - if u.syncMenuOpen { - u.phoneSyncMenuVisible = true - u.maybeLogHeaderMenuToggle("sync-visible", true) - placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.SyncAnchor(), u.syncMenu) - u.phoneSyncMenuOrigin = placement.Origin - u.phoneSyncMenuSize = placement.Size - u.phoneSyncMenuCall = menuCall - u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement) - } - if u.mainMenuOpen { - u.phoneMainMenuVisible = true - u.maybeLogHeaderMenuToggle("main-visible", true) - placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.MainAnchor(), u.mainMenu) - u.phoneMainMenuOrigin = placement.Origin - u.phoneMainMenuSize = placement.Size - u.phoneMainMenuCall = menuCall - u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement) - } + cluster.prepareCompactMenus(gtx, u) return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} } - if cluster.ShowSyncMenu() { - placement, menuCall := surface.Place(gtx, cluster.Metrics.SyncAnchor(), cluster.SyncMenu) - u.maybeLogHeaderMenuPlacement("sync", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - } - if cluster.ShowMainMenu() { - placement, menuCall := surface.Place(gtx, cluster.Metrics.MainAnchor(), cluster.MainMenu) - u.maybeLogHeaderMenuPlacement("main", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - } + cluster.drawOverlayMenus(gtx, u, headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}) return rowDims } @@ -125,6 +89,17 @@ func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimension }) } +func (c headerActionCluster) activeMenu() layout.Widget { + switch { + case c.ShowSyncMenu(): + return c.SyncMenu + case c.ShowMainMenu(): + return c.MainMenu + default: + return nil + } +} + func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -145,6 +120,61 @@ func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimens ) } +func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) { + compactSurface := headerlayout.DropdownSurface{ + ContainerWidth: gtx.Constraints.Max.X, + LeftInset: u.frameInsetPx, + TopInset: u.frameInsetPx, + } + if c.ShowSyncMenu() { + u.phoneSyncMenuVisible = true + u.maybeLogHeaderMenuToggle("sync-visible", true) + placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu) + u.phoneSyncMenuOrigin = placement.Origin + u.phoneSyncMenuSize = placement.Size + u.phoneSyncMenuCall = menuCall + u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement) + } + if c.ShowMainMenu() { + u.phoneMainMenuVisible = true + u.maybeLogHeaderMenuToggle("main-visible", true) + placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu) + u.phoneMainMenuOrigin = placement.Origin + u.phoneMainMenuSize = placement.Size + u.phoneMainMenuCall = menuCall + u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement) + } +} + +func (c headerActionCluster) drawOverlayMenus(gtx layout.Context, u *ui, surface headerlayout.DropdownSurface) { + if c.ShowSyncMenu() { + placement, menuCall := surface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu) + u.maybeLogHeaderMenuPlacement("sync", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + } + if c.ShowMainMenu() { + placement, menuCall := surface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu) + u.maybeLogHeaderMenuPlacement("main", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + } +} + +func (c headerActionCluster) layoutCompactMenuRow(gtx layout.Context) layout.Dimensions { + menu := c.activeMenu() + if menu == nil { + return layout.Dimensions{} + } + fullWidthGTX := gtx + fullWidthGTX.Constraints.Min = image.Point{} + fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X + dims := layout.E.Layout(fullWidthGTX, menu) + return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)} +} + type headerButtonBounds struct { SyncPrimary image.Rectangle SyncToggle image.Rectangle @@ -188,26 +218,21 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } + cluster := u.newHeaderActionCluster(gtx) if u.syncMenuVisibleOnPhone() { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) defer stack.Pop() - fullWidthGTX := gtx - fullWidthGTX.Constraints.Min = image.Point{} - fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X - dims := layout.E.Layout(fullWidthGTX, u.syncMenu) - return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))} + dims := cluster.layoutCompactMenuRow(gtx) + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))} }) } if u.mainMenuVisibleOnPhone() { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) defer stack.Pop() - fullWidthGTX := gtx - fullWidthGTX.Constraints.Min = image.Point{} - fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X - dims := layout.E.Layout(fullWidthGTX, u.mainMenu) - return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))} + dims := cluster.layoutCompactMenuRow(gtx) + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))} }) } return layout.Dimensions{} diff --git a/internal/appui/header_sync_menu.go b/internal/appui/header_sync_menu.go index 22b7ed1..5193a30 100644 --- a/internal/appui/header_sync_menu.go +++ b/internal/appui/header_sync_menu.go @@ -16,11 +16,6 @@ import ( "git.julianfamily.org/keepassgo/internal/vault" ) -func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { - group, _, _ := u.syncButtonGroupWithMetrics(gtx) - return group -} - func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) { spacing := unit.Dp(4) if u.usesCompactViewport() { From 2deca549f5a1b01aeb86e88e983130a9693154e9 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 23:05:30 -0700 Subject: [PATCH 52/53] Fix desktop header menu layout --- internal/appui/app.go | 1 + internal/appui/frame.go | 18 ++++++++++++++++++ internal/appui/header.go | 41 ++++++++++++++++++---------------------- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/internal/appui/app.go b/internal/appui/app.go index fa9d237..0e7f1cf 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -2024,6 +2024,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { if u.shouldShowDesktopWorkingHeader() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.headerActions), + layout.Rigid(u.desktopHeaderMenus), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.detailPanelContent(gtx) diff --git a/internal/appui/frame.go b/internal/appui/frame.go index 1df45a2..23f71ae 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -1312,6 +1312,9 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { case u.shouldUseLockedSinglePane(): return u.detailPanel(gtx) case !u.shouldShowDetailPane(): + if !u.usesCompactViewport() { + return u.desktopListOnlyContent(gtx) + } return u.listPanel(gtx) case u.usesCompactViewport(): return u.compactPrimaryContent(gtx) @@ -1368,6 +1371,21 @@ func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) desktopListOnlyContent(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return card(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.headerActions), + layout.Rigid(u.desktopHeaderMenus), + ) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Flexed(1, u.listPanel), + ) +} + func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { if !u.syncDialogOpen { return layout.Dimensions{} diff --git a/internal/appui/header.go b/internal/appui/header.go index 97469c2..31939f2 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -49,9 +49,6 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { cluster.prepareCompactMenus(gtx, u) return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} } - - cluster.drawOverlayMenus(gtx, u, headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}) - return rowDims } @@ -146,24 +143,7 @@ func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) { } } -func (c headerActionCluster) drawOverlayMenus(gtx layout.Context, u *ui, surface headerlayout.DropdownSurface) { - if c.ShowSyncMenu() { - placement, menuCall := surface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu) - u.maybeLogHeaderMenuPlacement("sync", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - } - if c.ShowMainMenu() { - placement, menuCall := surface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu) - u.maybeLogHeaderMenuPlacement("main", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - } -} - -func (c headerActionCluster) layoutCompactMenuRow(gtx layout.Context) layout.Dimensions { +func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions { menu := c.activeMenu() if menu == nil { return layout.Dimensions{} @@ -223,7 +203,7 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) defer stack.Pop() - dims := cluster.layoutCompactMenuRow(gtx) + dims := cluster.layoutMenuRow(gtx) return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))} }) } @@ -231,13 +211,28 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) defer stack.Pop() - dims := cluster.layoutCompactMenuRow(gtx) + dims := cluster.layoutMenuRow(gtx) return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))} }) } return layout.Dimensions{} } +func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions { + if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) { + return layout.Dimensions{} + } + cluster := u.newHeaderActionCluster(gtx) + dims := cluster.layoutMenuRow(gtx) + if dims.Size.Y == 0 { + return dims + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }), + ) +} + func (u *ui) syncMenuVisibleOnPhone() bool { return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen } From b7d6dbdc9735cb889dfc043ce7ca5baeda6375b2 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 23:09:47 -0700 Subject: [PATCH 53/53] Keep desktop detail pane visible --- internal/appui/frame.go | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/internal/appui/frame.go b/internal/appui/frame.go index 23f71ae..b3137cf 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -1312,10 +1312,10 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { case u.shouldUseLockedSinglePane(): return u.detailPanel(gtx) case !u.shouldShowDetailPane(): - if !u.usesCompactViewport() { - return u.desktopListOnlyContent(gtx) + if u.usesCompactViewport() { + return u.listPanel(gtx) } - return u.listPanel(gtx) + return u.widePrimaryContent(gtx) case u.usesCompactViewport(): return u.compactPrimaryContent(gtx) default: @@ -1371,21 +1371,6 @@ func (u *ui) widePrimaryContent(gtx layout.Context) layout.Dimensions { ) } -func (u *ui) desktopListOnlyContent(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return card(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(u.headerActions), - layout.Rigid(u.desktopHeaderMenus), - ) - }) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Flexed(1, u.listPanel), - ) -} - func (u *ui) syncDialogOverlay(gtx layout.Context) layout.Dimensions { if !u.syncDialogOpen { return layout.Dimensions{}