From 386f9d1a2fa2b2e1bdcfcc03d82fd3954908c554 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Sun, 4 Oct 2026 13:29:42 -0400 Subject: [PATCH 01/27] site: check in printit.verae.ai (welcome dialog, mobile layout, Learn more FAQ) --- site/printit.verae.ai/DOWNLOADS-MANIFEST.txt | 61 ++++ site/printit.verae.ai/README.md | 9 + .../img/how-it-works-thumb.jpg | Bin 0 -> 27458 bytes site/printit.verae.ai/img/print-dialog.jpg | Bin 0 -> 67800 bytes site/printit.verae.ai/img/receipt-thumb.jpg | Bin 0 -> 8887 bytes site/printit.verae.ai/img/receipt.jpg | Bin 0 -> 88065 bytes site/printit.verae.ai/img/source-document.jpg | Bin 0 -> 101432 bytes .../printit.verae.ai/img/validation-thumb.jpg | Bin 0 -> 11684 bytes site/printit.verae.ai/img/validation.jpg | Bin 0 -> 87105 bytes site/printit.verae.ai/index.html | 288 ++++++++++++++++++ site/printit.verae.ai/robots.txt | 3 + .../samples/verae-receipt-sample.pdf | Bin 0 -> 41589 bytes site/printit.verae.ai/sitemap.xml | 4 + 13 files changed, 365 insertions(+) create mode 100644 site/printit.verae.ai/DOWNLOADS-MANIFEST.txt create mode 100644 site/printit.verae.ai/README.md create mode 100644 site/printit.verae.ai/img/how-it-works-thumb.jpg create mode 100644 site/printit.verae.ai/img/print-dialog.jpg create mode 100644 site/printit.verae.ai/img/receipt-thumb.jpg create mode 100644 site/printit.verae.ai/img/receipt.jpg create mode 100644 site/printit.verae.ai/img/source-document.jpg create mode 100644 site/printit.verae.ai/img/validation-thumb.jpg create mode 100644 site/printit.verae.ai/img/validation.jpg create mode 100644 site/printit.verae.ai/index.html create mode 100644 site/printit.verae.ai/robots.txt create mode 100644 site/printit.verae.ai/samples/verae-receipt-sample.pdf create mode 100644 site/printit.verae.ai/sitemap.xml diff --git a/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt b/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt new file mode 100644 index 0000000..59e237c --- /dev/null +++ b/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt @@ -0,0 +1,61 @@ +total 164968 +drwxr-xr-x 2 marchon marchon 4096 Oct 3 15:36 . +drwxr-xr-x 5 marchon marchon 4096 Oct 4 08:19 .. +-rw-r--r-- 1 marchon marchon 2981 Oct 3 15:36 SHA256SUMS +-rw-r--r-- 1 marchon marchon 2483600 Oct 3 00:08 verae-print_0.1.2_amd64.deb +-rw-r--r-- 1 marchon marchon 2114536 Oct 3 00:08 verae-print_0.1.2_arm64.deb +-rw-r--r-- 1 marchon marchon 2483600 Oct 3 00:08 verae-print_amd64.deb +-rw-r--r-- 1 marchon marchon 2114536 Oct 3 00:08 verae-print_arm64.deb +-rw-r--r-- 1 marchon marchon 18606858 Oct 3 00:00 VeraePrintStamp-0.1.2.pkg +-rw-r--r-- 1 marchon marchon 19787377 Oct 3 14:45 VeraePrintStamp-0.3.1.pkg +-rw-r--r-- 1 marchon marchon 19816111 Oct 3 15:36 VeraePrintStamp-0.3.2.pkg +-rw-r--r-- 1 marchon marchon 3149530 Oct 3 00:04 VeraePrintStamp-linux-amd64-0.1.2.tar.gz +-rw-r--r-- 1 marchon marchon 3456802 Oct 3 14:45 VeraePrintStamp-linux-amd64-0.3.1.tar.gz +-rw-r--r-- 1 marchon marchon 3462076 Oct 3 15:36 VeraePrintStamp-linux-amd64-0.3.2.tar.gz +-rw-r--r-- 1 marchon marchon 3462076 Oct 3 15:36 VeraePrintStamp-linux-amd64.tar.gz +-rw-r--r-- 1 marchon marchon 2832626 Oct 3 00:04 VeraePrintStamp-linux-arm64-0.1.2.tar.gz +-rw-r--r-- 1 marchon marchon 3106858 Oct 3 14:45 VeraePrintStamp-linux-arm64-0.3.1.tar.gz +-rw-r--r-- 1 marchon marchon 3113159 Oct 3 15:36 VeraePrintStamp-linux-arm64-0.3.2.tar.gz +-rw-r--r-- 1 marchon marchon 3113159 Oct 3 15:36 VeraePrintStamp-linux-arm64.tar.gz +-rw-r--r-- 1 marchon marchon 19816111 Oct 3 15:36 VeraePrintStamp-macos.pkg +-rw-r--r-- 1 marchon marchon 2612860 Oct 3 00:08 VeraePrintStamp-windows-amd64-0.1.2-setup.exe +-rw-r--r-- 1 marchon marchon 3219570 Oct 3 00:04 VeraePrintStamp-windows-amd64-0.1.2.zip +-rw-r--r-- 1 marchon marchon 6092955 Oct 3 14:45 VeraePrintStamp-windows-amd64-0.3.1-setup.exe +-rw-r--r-- 1 marchon marchon 6105599 Oct 3 15:36 VeraePrintStamp-windows-amd64-0.3.2-setup.exe +-rw-r--r-- 1 marchon marchon 6105599 Oct 3 15:36 VeraePrintStamp-windows-amd64-setup.exe +-rw-r--r-- 1 marchon marchon 3219570 Oct 3 00:04 VeraePrintStamp-windows-amd64.zip +-rw-r--r-- 1 marchon marchon 2217567 Oct 3 00:08 VeraePrintStamp-windows-arm64-0.1.2-setup.exe +-rw-r--r-- 1 marchon marchon 2865633 Oct 3 00:04 VeraePrintStamp-windows-arm64-0.1.2.zip +-rw-r--r-- 1 marchon marchon 2217567 Oct 3 00:08 VeraePrintStamp-windows-arm64-setup.exe +-rw-r--r-- 1 marchon marchon 2865633 Oct 3 00:04 VeraePrintStamp-windows-arm64.zip +-rw-r--r-- 1 marchon marchon 9198592 Oct 3 14:45 verae-print-windows-amd64-0.3.1.exe +-rw-r--r-- 1 marchon marchon 9216512 Oct 3 15:36 verae-print-windows-amd64-0.3.2.exe +# Verae Print Stamp — printit.verae.ai (latest: 0.3.2 for Windows amd64, macOS, Linux; Windows arm64, .deb and .zip are 0.1.2) +56cb72d8d65c5d2e1292b0a05f235ded0a267e66530229a53980887fc5de1b72 verae-print-windows-amd64-0.3.1.exe +aa2e7f1843ccbd4d466c66e92a5460f0e3d499e6af0dcba2f93cbd49bc2012e5 verae-print-windows-amd64-0.3.2.exe +079482b38ae3b49b268863209e309bb35072a3ec136f076f0ec5062d67a7c26e verae-print_0.1.2_amd64.deb +f4e77bb3a300563882a100ab667e6e6fdff4a82a4254c04cb4c658f677fe2ca5 verae-print_0.1.2_arm64.deb +079482b38ae3b49b268863209e309bb35072a3ec136f076f0ec5062d67a7c26e verae-print_amd64.deb +f4e77bb3a300563882a100ab667e6e6fdff4a82a4254c04cb4c658f677fe2ca5 verae-print_arm64.deb +403642bb889d6057b60ef70e1d5be13cbf01ec77792fac8d4bf4268e0823b8d9 VeraePrintStamp-0.1.2.pkg +34e158f39bce31a43c50801bd9e18bb66a81c54e2e3d0db60847fdc299de3ed8 VeraePrintStamp-0.3.1.pkg +47ef01906ed6408b2a29592a909548ace9eb0d20aa7ce7fd3f92a55ab55d8500 VeraePrintStamp-0.3.2.pkg +525e5f2dcd5b5ed20004eb899c5bf855e946586ab193877947e8e12f1902386b VeraePrintStamp-linux-amd64-0.1.2.tar.gz +20bcf8627586e823f44ef75fd7280a8a6c8dbc693dc441b52558ac8cbf067499 VeraePrintStamp-linux-amd64-0.3.1.tar.gz +5ec34c27c46c08463a09fc127e9c53814158037d345f18f5a4abe8eb5312727e VeraePrintStamp-linux-amd64-0.3.2.tar.gz +5ec34c27c46c08463a09fc127e9c53814158037d345f18f5a4abe8eb5312727e VeraePrintStamp-linux-amd64.tar.gz +90074fb0cc7dbb573deae8f33076806b05bffcea7cdcf69e7d79007ec3e2fd92 VeraePrintStamp-linux-arm64-0.1.2.tar.gz +61686df3c891015fc6d1e2659262005cd60370d3f92f4972bcc0b08c0c7d50bc VeraePrintStamp-linux-arm64-0.3.1.tar.gz +29ee11b01024048ce6ebeca9d5da10c8e0d9c606c8fa465bdb8cceb1dabae6d9 VeraePrintStamp-linux-arm64-0.3.2.tar.gz +29ee11b01024048ce6ebeca9d5da10c8e0d9c606c8fa465bdb8cceb1dabae6d9 VeraePrintStamp-linux-arm64.tar.gz +47ef01906ed6408b2a29592a909548ace9eb0d20aa7ce7fd3f92a55ab55d8500 VeraePrintStamp-macos.pkg +930280c07cb505332be994ff1b5e58da8105c40643ec6d310543ea9179794e66 VeraePrintStamp-windows-amd64-0.1.2-setup.exe +fe81e79b03402d41072050d863fa1da6a41337dd449a1bd23ebfc623edbf60e2 VeraePrintStamp-windows-amd64-0.1.2.zip +0fc5e3c052267f9295393fdf9e68a4a101bf03fc647322a99d2daef45fc30887 VeraePrintStamp-windows-amd64-0.3.1-setup.exe +e561af8f8c614f43237c5b97db5f5be2834009fb669c764955a06ce69b8ac987 VeraePrintStamp-windows-amd64-0.3.2-setup.exe +e561af8f8c614f43237c5b97db5f5be2834009fb669c764955a06ce69b8ac987 VeraePrintStamp-windows-amd64-setup.exe +fe81e79b03402d41072050d863fa1da6a41337dd449a1bd23ebfc623edbf60e2 VeraePrintStamp-windows-amd64.zip +764ba39f7285375db5771f45452216c424d2d2a6713e48bf16fdbaa18286a9d7 VeraePrintStamp-windows-arm64-0.1.2-setup.exe +3d4309f01f11396b0686ec1b55af7dbf8223ad6534da89864e013a024651d386 VeraePrintStamp-windows-arm64-0.1.2.zip +764ba39f7285375db5771f45452216c424d2d2a6713e48bf16fdbaa18286a9d7 VeraePrintStamp-windows-arm64-setup.exe +3d4309f01f11396b0686ec1b55af7dbf8223ad6534da89864e013a024651d386 VeraePrintStamp-windows-arm64.zip diff --git a/site/printit.verae.ai/README.md b/site/printit.verae.ai/README.md new file mode 100644 index 0000000..d2d63d5 --- /dev/null +++ b/site/printit.verae.ai/README.md @@ -0,0 +1,9 @@ +# printit.verae.ai + +Static site served by Caddy on 70.88.205.138 from `/var/www/printit.verae.ai`. + +- `index.html` — download page, "How it works" welcome dialog, "Learn more" FAQ. +- `img/`, `samples/` — screenshots and the sample receipt PDF used by the page. +- `downloads/` is not in git (162 MB of release builds); see `DOWNLOADS-MANIFEST.txt` for the files and checksums that were live. + +Deploy: `rsync -a --exclude downloads site/printit.verae.ai/ marchon@70.88.205.138:/var/www/printit.verae.ai/` (back up `index.html` first). diff --git a/site/printit.verae.ai/img/how-it-works-thumb.jpg b/site/printit.verae.ai/img/how-it-works-thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d78f3fa8bdab491da3fa71448f1b340b159fa1a7 GIT binary patch literal 27458 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 Crwr!+ literal 0 HcmV?d00001 diff --git a/site/printit.verae.ai/img/print-dialog.jpg b/site/printit.verae.ai/img/print-dialog.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d8e042d1a189b776520cebc007e2247a74a23d66 GIT binary patch literal 67800 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 R!U^H}A7JX=A4VL%{x_#c6B_^k literal 0 HcmV?d00001 diff --git a/site/printit.verae.ai/img/receipt.jpg b/site/printit.verae.ai/img/receipt.jpg new file mode 100644 index 0000000000000000000000000000000000000000..383f3c2000dd79e9e739b8102aa1f4a7492788ba GIT binary patch literal 88065 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 literal 0 HcmV?d00001 diff --git a/site/printit.verae.ai/img/source-document.jpg b/site/printit.verae.ai/img/source-document.jpg new file mode 100644 index 0000000000000000000000000000000000000000..279a2d7758b55470fb5f1e0bd8c59e9dba402d8d GIT binary patch literal 101432 zcmeFZbyOQp^e-433L!{~1uf9DMG6#`U@cNwXyJ=Xi%ZbrZY>_PXwd?tNPyrL9D=*M zySsPueQ)2MeP{RW`Mq=A+5KaGlXK>eOlIcJoqIoX@0~mOJN0)7K=w{fQ4WBG1pr`S zK7hZo09gPY4lXV(4j!h$!^6WTAR#2c6c8~n5eYenf`S}GPEJWhPfhul?g=?L4GRq& z0|OHi6D9Riwx^72^o&f5{|EJ0;1OVB5n@^u$N*Tl*jPB&csSTN1UT3L(7(UN#UrP{ zr)1%kCV2exli?FS2UdR>)fg(a*BVAYMhL%VQM1Pe49lwh!>?}a7>H~+I>wAdgPG#L zn+3xk%t)9PUUJNsSlGBY1b7%KaQ>wN3!5B=0yF**?k7Xarw%b$EyIufk;}(?GOtxz z|IPqNa4=oSamWFZfcu1y5W*0@|E&I#2mdJt|EUK5A3{O+lU^RRLiR#F!RJPnjtky& z1a-lq*L{*7S36}1Vhdw_>*+vBm_3OYGs%}Sr+!VWo=#Z@X>7bqcvl%dra)ELdGwhG z>fbhdi-^U3%KY?s?Rz-A{^yh~ov;e=K-qNXY9_g;T1mro+#H=f14r_>#BzBS+))Q^ zUIHsQKPk3~Yo;2HDEq4C{|5_xsl=gM;tRu4)_51H>0>qh2S(Sdz1*WEnm6YE0$6Y> z-blSI>E1XN?yY!X!bhf#j7}_96=BHcPg$h8AMh8w;|joj#fysNEt9)IMS@8!r&v6s z-n_wm^KYedkTqyn=Mi0>+C7d|=O#JSist!dD#8o)i5gFLG6fqxj*QHngv67OO72Q zCA@1z2>y5kh`+z;`AriE%U@4f1#`54>EFX#wn6tKJms?84mhJG(mi)dDe)qospvQ~ zo^Nuj@R7^IE|UAaYmLNJc-|mH#huqYxh6H8+N(YC@W%O{mPqvdN2p zVC05n8xrW;w{>0!%sU6}h*vSDYW10MmB}aOyARcMhwurrn|!0jXH<5jkN_}Be6%I? z<$6*uwOeDTv0qP#)?blV!U_`%cBQ|l-dZCK)Gv{}k1(>fwhR6O>tR%T`P4136Sz< zkk_D|UVeP3jT(uDnF|7bzl}tI{-&<3*DLL{a(&{!9TGE&`h#_d^{Z_5k-9xxMJJ%HeGW<2OSre5#Uh&Z1Zlm-DWLO zjy#i2=M2@U$Tp@>=Qg7s0QUg*q)F}53vR47gCP0LUij|k@id{WBelAlX-c_q!I|-b z<#Lw{*69*}MZLlP@QnXa@2jy|(#&1*L} zK)V-Qy2;jNFKTb5v5Yv+ja%DTtk?SuS+WKs5OO*ylx0SCYoa)-f9#{SNz^t-mfa@3) zR6}`yFX;TBdDd8pdZp6bGE&8E`nziRX}d$~bZEp8lO}-&8UCH!+`BE?#4acFht(VH zAIGN}ok1Rg#f<8G3uAlw_d>vqK zDw7G$L3Z1dj0QreZM+1=;<)q3bc8v>OyL-3^hmT~EzQOoGhj^JakoFZMU>URwKDkk z&6G2V)@_oT<$R%8OHiMK_S_i&cGuVM#?lYuI+Av6OsIMwD6lrUc{Iv9by1CL;=E*6 z|D~>c=^^9n0pUn`iXK(!U<|H=%Hkz<8yyRHL`Dqvx(d8GiX8%uPB{%3Fp86{X6=ak z1!i+I9f$LV<;E5sKjK7?yXHEQ%NbJ9&O5W#*a}5nHtmSaOs4nx5+gU+x;sLV&*%^^ zZ9v;pSbOL5xkFoJpaaeop5>SAt-Uj>wsqv=+;U^aEv{xZQtJ{=Lbn5|z;gfw}w06>lV&F6pO;muUh0=Kb6I+N?q& z5u93@>b@$TdDz&Y)$F*0WHf3_Ut~P#LW&jnp1iNi18@&CZ!@~r+ zQ;!zgt>-3!RVuHpzc=`5jTLMJi4-MoK*%;knr)i2rcdzGc5^KY#{>^dZi4gHJ11S_ zsWre@sM9GVeXBB{K5}*g61a5BMPWcUi_?m5e>Uv5Zlp4YdSMh{eSK z-2wx#))6(e?SA~?0`0px0uh>W&QE-UQRI>7FS&Z0`g=~hDLJX%iW$R?>%)7caYKoN z(0?WyCngNyyV@oM>QKmuCR-g)mQT^((Eeo_znKvEPV1%68(@Hhz%NDzUvie-LD?tp zoJq5Nt!muL48c{zNeM}cyf`R7HI2qLXnxKvFU{)avY^g?{3vGSk2G=9Ov=^l@7}c# z#k{W%G0W$?3(yjkk3u`uw4_Nr)dffMhH@{T-aoer~Hj|r3XU*9;soRsD2Q`S)$EWTDCja2v@9{9OLBUBnpC+@rod+*^`D5tvu z%$BFWaDJkoEq{Mh9XLy1g8U1RK3=PjUYs@9dRD#y4Dm2X{Itd+hoVojvPJt_=ALSm z9Zg6{<3t@8a-R)Dmk^yy1!DKwkoTV>%WdzXQMmEH59B!w?-<4XWK&ia*MymB=tOQ_ zvnO1A_G>W#tN#8>xT7}772S`MSD>TYb&QLgQ-BKMG(DOO3EHlJAmO=DU!p=ylqn<* z*nC5gBYpv9kZyJizJ{=#648b>L?Ye&vb^m&!VXlE%jyc|rzAJkWl zc>064)a++cw8|40cPv4SoX^{0E?m0-e}d0Hzzt1mUcI8(xqRxnnjC7Rw=qY)!h4gR z2E(jGfB^cDN&YBC@o3HTkludHUv)xHRBN1n3e%WV^i77)Os#~FZC2(GFd9qkHbd)1 z+G4l$#_tY4I=?R+zn7VKyo{`P_!1;f?wEer4WT?2X?Fu`@p!~KgNR4;m2l^u-&I-( zd+~@Q3Z8gkVrvq$UQEZ$mUa!W0A{Ecg~l_xuCGpq3wos(#qa-RwPNO+0(3rRLQ^Y_&+WAFW}iDs^dM9!n#}Jq`1zCSo;<%45<87UIso zg)#j^8}XfUv>Csg#-r}wb+S#4c*Rqb5C9@mu%{E(Ut+M$g=L<)C*8DihFcD~$P4Md z3QHHmx24dGtoaht0M69^Z2soTb&dmH2`_%|oST66N1;KE8$`*9?s)2ecCV3@X0|4h z6Q=hUU=u6vU&2z*c_)t&lKTs&6e{JN$p8F&&!%hI0=3*Ab!Q+1Q_-J$RLUn-r3CKW@z%lw7zRoG0q;@nOztIL-DrR9ZWe8vA`$N%QM z{a;4>&xZehvjk_!x}Z;zO!S#&aXv_GWj!X=B~@G4k?t?xBrz&S>=;!5dMM{hj!No&UvRxbQ~oMH%6474M9bp40@2 zkahB~0!(5c6RF8WDhsv+$3yI%=Y3!9b32=2ZF%%=bzUqj5N}NX#gT6or<9ct5%-%l z1W$i!#n+3ePJXIj;U;5~E&Lk_jgkVNSI*DGGNCMUCHTCx3qf6c#CSsz;|Hw|?2$0{ zKyZj_OtnSzq7P18NrWd;Z(zy+L|)!Q(zJJW;{Ma;6}t~-@f#~w&d;d&r!pRc=3o2* zvgFx46gnMBR_uAc%xfMf?lWqnaQ>`!4%Hxu5U^WE^cuWOt2Oabt%;pyqc-ky7=3wcN;`Ih);WBx4Z)jCLX7!lB@Ez+xaO-`n%p>QdR+MU5!d z(Zjtc;h7Ql=T0RqVuYiPQif^pr}Ve|!#I)tVhesQ-=tgw|5t+Ae*hnG-**ZI89#wN ziO&P}2(CD(wY+xclOz8{sIDi})`g_$G<3LEA43|_ERYX5{}z{Ls<25a?VG?_0^Iv= z$<+ONRIHX+s!`_VRmoyZ9@{qQtfTRVsO+3kDEsSF zpNlIZ$N97h-hlf3elfY8g*n~MQ30pTiH;6nfg|g=^*kW>7 zAUe~UCeo_g#R2H07skgGWz&f($UG+tCBxz{pk2rs9pgo{uadzgR@Q3sMDSlgwgl9} zJpac1x-rw6JFjZj9jeyWqsUNfnpb%iY+(&5ooG1B{q^XX?6NKXd8&Ht;d-MZLqr_q zDKOdp!m_*glj5006i%KnR{KF$;n^9?@jiDHQxIT8ulBS)`|V^eU>mf zLgS#j+rkv)tIkR|&;l#d0&9h=lS$%eEd243CDrcHuh9j-1gC~d&*274}74v*Jo zHbiz0OZ2%U|8W)&f6`}4t36-)BmP)DqHAhqWg=0`)&bzgn63zER(}tXS$}!X{VQ01^5aX%%XT@ z{ne>DczC|`Ds<2nl%c*znci+{-X$w@Mp@4!Qeqz7vUD_2^A}*)h3&HxWy)2(RVTb= z)3Y8M%Zk}R_kTA~uZ`tsRRqdZ#BAJ|w@tOMA%? z&+cRsHU@G+a7PGADK($79#GF*6l!@+(UdkTd<^1Nvtz-H#_OCn~PjS@j3&SD6&tM7KTWd(R1_=Bc+aX`ovr zTR*G33pw39gn7O{4s!N_#=~CgZrx^ z-UW-mS1n#O?~U^}6wZjgWO5+6%LL8~1M~O0wok2w67i#WA5K~)Vz!*Ta%P5Kw^(Lp z+lX#Vr5<0Yyh;$h(^sE-DKJzyk%{knCDkPad01?^(20!P3G-HritTd{O+`*4wZ}JN za&Ln)jaL{wbps<2J4S>LrNlUqn#77P&Na1kLFAiAYzXqodTmygIvnznYPO zx5+f2^^kn*lIMBG#F1Ai-b&FVTh|ohbLvPMU z)@kg5(`04x-u0hMY3jDlcXe&ev!*fGcDoxA_$^{DS8drD5UN?+iT8BGba{20LOi)( zteVWd^rpcLXnyPku)`NuGLhp^-6fbJRg;$f2M{QE)>m@FXUd@5`|bC(mHoAnH5l3; zFEk{YV#Eu7*S97E@S;glUiW%t@#wbcSoc=4e|P&@uMFL$2QLYFwVk(GYQJuTp7{Mr zP+n{!f>KaQ|I8+oMKfBCdY#0@WQuqXWQcL)l7MZ&r*YSZUyAdSJ za1g{$NVgjzE1U38ekTX@(j4}P@zmnh`mv-8x&@G8UB((4zkRp65i-|<)=xG*_(o2TdT)`IUNa;dMP?RTl!!rS955x zjp$_rQ(e)W`nGt9+UAW$zYagwQ2ShL|DU26C%KPX8BF+oXx}7;*vEQN@|$J%!UGwV zDoipt6q8Y4ej$CftFA0#w|;QMm*iTI3IHIe5^XXw%s1~;!Ih^s04d3%JwMrwYRgPb z5C;A{ml9(%v#Ty?s;-+@tBXZ(=e2(6w=@+AQr_a^?dPqoXg&d@T@$nrC$ICmbGjHW z$8)BGNO#W;XDJ2CLj$C&x`sJYwX3MbE4Bj9VcqAdTV4`7MfR;JBP%A(B40-nlsNhX z8m+~uiNR<)_)qZ3Jbdd#<7kiA(&V;NA-Q0+aK>N2Cnl|^p9MlJ(nHWT+XrExa$0cRiO5L$Gl{QUz9XwmT)5v|XSLdhM8+^>ywvuhL+KdYRsdQyE5Vo0$M7dt#=XlPy16(%`e3~g5$ND;qg*<6q4uUP) z9{(N<7vm#15w#>;Kr@NqjCmBpAZ_Vc&YnOwv^M8XP5F2-e|xZe3nCU`CBeT(Z4G3& z!Gjct68||BiP-+n2^0x!edVW@(jxFL8`kDJLz_gN_T0Z)J#y^%W+DV;PPHoohHwYVxw|cW} z_GFn)oHw&34fF*|`zV?2+KY?6QrY*FOM}bP4gLd=h5gipC<_ewbhS(*GN|7W`EfFK zLFUJ}_F__J=OvA9SM($kc%gFLbL$Z;iRN3wV-bZfVe^kZj{BM!vog$nv13-!@!Itm z0j~UqE<~^PkHw`R27;9g>*0`KGIeCW- zeX$pE;Qw9R*W&(@`L@`tXm~}DBr57n27!s)7#>8#sdzpv-HWDfH=lL8=8uZ4%8zU# z$0y+f-$(eWN0gKTy>UW-YhJ(yncL8v6qRh^GHNSmd!<<&T8%W87RGx+!Q19}qeKLV ztL7?xms`6xk)C_{xqL9yHtOdyQo7NgmgC)JO`~mvm5e8HG;Coc-pSnHPYD9Cm+vDJ z7;$>F^fuBwEI?2ya~C%;wdoH}xd!hae$qE-tT0)p-&6=_y>@sudVCfr`L+ysU>mg}Wad|@r-_;g-tup(Q1^4I! zCokc`#XEkSuA>JWJw@=5cs|o!Drrv>ge&NSk;ie1dA)uKtliErNr&``yuWR6b}zZM zT9$tO8Fx{0kCZ8zT4aTHv}Z+Tak=b_lS{P6`_|xbdPWb`}6k&8qf2Q?sggWwIZWBWUh7nmK=O;1+ilw-mE( z)OVgv$q-p-|Nd4Ys3xcboHn=044+eAl=Adr^LYN%DWW=!QLa-)p`_*$=`*VB2n56# zc}zOd+dVyfi$Hs0mKd$ssF;OdB9 z_g8kMRb&f&NW!bzblIB^Q-GQ_pN*k}glV!$kOo=hS-}JDYF!oX5LatLEK0 z!6{RNxZgf42ld)br6@(lHo(7xkN>qL$~^YN@l5t zhU5dRAl{57YB$LiX%@a!|`|I_Z9)dZ{Szb zEL7lQ6$#qqLX+{d<67hZ4Wp3*BSZU@DAdV|6B#T3Cv}AYuv{w3FQv~Pdsz#@Mocsg zMA!NkUqsj#x^%O)c>#f-y?B=L#}l6=(8}GW>%=5BcBTzF!q}3o(aM7vg$6AcF1HK% zstN+pbe&`^x>fT8d$A7&jVW|zM+t`I>;+|S9fmq6Q_Vu;CW-tl0Tf=IQ@2gA8rc7crV`APJ#`Lko;s-YGwhSLR1N9}6 zMCCc#(80<>t6suV@$Xd(q1mu|fS|sXYDhK6G5ZcAG;sZ94l}(P5;8+1v^TEEO(wauXca#Mqc} zgpr67S<`7ka~m{YJ*(&E&s8IA4qPh-2H85SvMuXZL@wahn1J9bhw*QY0w?}p{rN8h z)~DM|jPepSFw=zifa%B}+PRQ#wZ{_z{wkY5cKfh!d5n=XNP@kH%*TuXTTjCE+ z-k=NJc2ef3`8CS&%>h)jtob?XmpO+bLy{-;HbXw$uhw_Bvr1I{Wt171Pp6Pgh=Jsj zM5o0!8~Y@xXe>ytlL8bv^ZuKlIbt^7VVr%VZJxaH0i$(GBV=SNEc3(2r3XFe98fICZPBXM0#=V~CPeM)hEN zu4R=Q{fHi|*>SAbGb68DZaG-H zg_)$5^7Jf;*M}SldTqIBM}O&3;^#L@X>jP~pwXM?I+;lG?!t{a`zR{J@ys&^Nn2}Z zP4PJwm?$?|+Im4i25PLzTMW{D{5yd58=pe!M3;7#kk8H3jQ{YJ>)Bs`(yF~}^{LT! zi(Nir8be2|q2W@Z)678Y|4`B}&mt8Zj2-^VCY!PzU;hX+IofOBjIzx)Zgr&SlR!WQk3^6il^8jn$B8tHn+crLl^~)14XlMx`tEy9+gT(f zB~jSVSNMtTe*rsdkRO4x?0(b@(L$#U7kE**ZDbYDs;Lt3+5GW-wGK3i4iKnX6PG(+c zG-kFNoeXP;Xf%|i1!;c^2Ziv-W#rCUf`@PalsP5f>rU8-RtzA``do72HgY7#BP=6v z5>lk-j!PJ+%&GoRm?6NiK08&tn|DKTyr9+P}v*xUcwAaGG52iXHci2$D+DKGUuL^97>PJZ&3*C zxR8SyQI(u}c@vy9&sXX<<{+JWvH0EY$3G&PxF?j4y4uqXaD>or{G1U$xA)qtYBG>- z?)UdTOd8q1NgmTwuo|t{AR+stbH-6_=!oO_UjT?=M6!RGcGB4-OCrhQ!|UCCRS{Mb zIFz#%VMs8vRsY6^G?WekBY1aqN8-uSDboohDy+>+qgi+Nf}fvpvponpXO`AydwBP8 z37DTu(do^8t!$TB+NzRmYL&udA5?VoFM!4K7;Z{OceaX58KUk*+suO6oo)EZR!K@C z6iXNpSpBp9$Gz8MeHY#}5%KS0M-t?8!p!A)JY_v&$}?btnCMN+{#iV4QVM66l8!{z2xJRTi$@=BRWc==Lk7CaGFtW0U zA+H0W%_)P6bY^j$(%m@<@f)tdQmw$~>uPm7rzxtMQ*n}(2G3NXVx7q}@2CPae7guL zct>ri8T;ak|M)L#^MophSd1NoGXfsPDDk7CY;0E6`Y5&3MD$lKV^%OZ|0~iIizQfF zicenUfaU5gdYd_}gw2h$XVmFX)xb}qvs+_9wc}?kRmY2XzshKBE}+DCrx8OZw94(!B}({k?L+fO1l6V`)XykEj>I7_9EmM z5h;RP3+3MtUqwhF?D{3<=rFh@Oda65s!VJ;>B=>N2uaT==RNFI58Z<|yV6}AJ1XBd z6eT_UEYVmHX?W@^E-P$|$$HcBkKhTGXg{G_XsVuiQr5eYZ>p`IsLeWS;Gz}&t;8$` zqn{&kXWmiYjkqGH8h=>5{Uz+IS$sbK$gOD%lFwmmSiXR_KkG?xu?A#X5u0ZGi8IE4 z_jFOSO?5ySEPTCs&u62>zW*2CKf`y~yqK_^1DpH;chi=fDo*8$kId!x`5F>wpkeAN z`2OK5f9#>mUqGP&CjZT7j`2lRfA)u{4iJ(eJ#*6ScJJ1NEQC~~c zPPA{x-9|AJo8~txZN1pqFIrIiE1ekQf`m);qi3%fG{HDu{!D|xS`++GP04WGt>LaT z0IDpNq)W;Bm+~F53Sz5Cb!_QCXL}WR&ctkewV0_Bu^Rq;qukCl7Gu4R2EgL?yVwpU z)@R3xVLn;eRe^cF_Rf2W`7JKbkV<8mS^c;Uvbzy0tBhP>Z0F({LG3;t8YM*iB%Gqz6N zoh>83J#H<-DPQ?fxpo3MgoE$Wbe$tr!~u9j#FG7i5kENpoE>Ajb_nCYSrdSGwX#6! z4#cG)7j}^sDrt7JmPV;mTpH!fakc6LUV5gEa=l8*&7!UWUS!U@J7fxPnOKyG&l?T8 zqS5_PH|$R4HvbY%T15~gyXEqz*XWrFZnR?WH_Tx|O_fHT{YyM*jrk({i2`e(23_gN zerspvfC{%Lj;u+n;$2+jDwFaffd_-qX!-sV@2|C+whj&_E6P>M!I_NsepnbPcSi$I z^@7rS>u(nlN7 z@-cfDlk9jOWC*MN#qZ>V{8$L2<54YUWC;*NGl|e&I^f9KI`0TaffTrXQGfwfFV@3l1tg91@ExnhA!$xV@$m(?|32KbCfK$sn6u-l%tv+rp&<eVxc$2CKRhh?Z{?2GX#f0fM#eu%GY4>-Vq9w}Ko;4n+bwd{y^|D#*Qmb$y<+#-R6VbnI7Hm)5C}&K5Wn4GF#eM_R4gHNQmIE% z*aiWl!`*HKU@pW<3NTh^iamY!S6szY;I~$g^PF2O(TywG6K9}$zoJ^1SWP3r5b|>5 z(rA?Dmt|Aq(|E+ z(}{{opHtC2G0c7U$hobZM{a6$lQsQ-*}`UkE$*5vww&2NgDNd7rlRESySCKk=^S$& z0^U-5^qAa?_!S!yG+h1&&E?7!(pAr5Q$DE4VODKjK?FmV+^{AZkof23#4H7fXQ+MY zU9~jLzstP>^UKJ+8}in<5Eh9fmR-Si(qW}QFNC*=@hHS@KNEt&{(bT?rZAE z)|R*9yU^ZWvCU4Z?`+<6)6##dw#NGw4Dgajhu+h5SY7CVjR=3^3`zLU{001TZ8a0( z)u0ppK(hpmTHVW;a7;?rEz!zRg~c;Fzxqg;-eV4a#%Fak_pP*_-oB=$h2+3G>i#dF zzDfk^k|{p?PhI4OUgydC;D*B%K2M!5F&vx}yk z5E)`+b)Z8a*HA%kmG5z^f-ghD7G&NzUy;+R_UX)1y8$gFeLvs{4v19JRnDiS`=Z$m zHa~3?u01zi^??o!%5x%T!Y``7S=$>2rUJMfQ{~5e5}15H{B{*_Wf*n4?X37vZtGF{ z&fPJrHhv$Lt}rn(p8*wG9kzM(kSq)4yjTu+X^+G47m!<30n)U<3lN{6n)`>Ap@#Eq zTvWU=*DI5~i90d@g!g!-Xn7r1XqbIeiG7i|Xy?V#wyc;lE;>}O_THh4h;@>`0~^2% zcrV`gyZ#IC$p&c->chM&d(n4yy}my6{B|Se%2>e~3p$9&rMR6`2Ae-hhY5zg>-qvU z7IDT=rNo;6Z=<{>Q;7nK>-m;_8_3?~(DR;dO)lCJa{Bc>59vc#c!xwngeE1@9DzfgN&6Q5OB9dL(RqPQ z?ltB#tCgh17uqx@;^i@xc*TqgC!~>4fw{a;nCOq>kV+9BRZ-OmnG0;hT&91O`Inf! zZ~H}ySJKmqCwb$QIz!PDNRpKo43C3^g6%z(Q%6f=Kz}Yil_nJ=dDs?y$VJv^zB>ev zDvOB%00c__>RQB5wr!AakKa7MS_5B2GAiX`t?Yvi(=>1QYy(Mia0 zVet;pnN*@QAf94qWN#b8$wu64W$$Z5j;S*lQB5TmdpS`1T~^kuNu6VoR40F<=RK_* zs^k?W_$8VNgh6be4;cp^(X=sTRQ#ySpqeOVCVi&8Cx$Ei1>+Gi{UL_06`qFI94*+1 zjp{b3I(dQ0^vZS8w7K+obh*cn)8lul6H?Rc<-S2VN4+s;AkU%^Fc_ z!{ISU!I3twP@sd|AKZ$q+pYV=n$<|YRWRLv?b-q6&4S~#G`*49bK^IWSh+JNQfJ_Y zb%IbEjj%p}TqH3hBhgGqoItVNW==kT=zQ{*PNhyZn7xp!8I3|#j)?P%9Eif??z@?> z4&GgCtvbPD`Yb(#I@;ZB@14A36;|Uj6*?OT2ms3{G8fRvBamQ`v}#cmLgg`;OzX92 z%yN;j)U*Sn8Q>Ye9|@bGpCsUi2avW9uSJ-|)QQkcBHNT7YK)BJESmU3)ghLh}faEW0%rhf%1|Q?bOKFU}?x zxjDdZNLQ95NK;9pkW9`8&sW2EG29dc$yHe31@%>0NDJi48buk7GzeYMr<0E`UH6-?CzUckv=rY0NmR=|YG zn@fy@Z&6E~uEcJqlW88iz9vo%s6Ef5Ns(^}6oNX=B}{gN;<}YOsS>v?EG5*>%2P{# zb`Y`&c*a)+oC5{oxe<91l>}`ty*1`B<7(!Rc&tjPqH&~#eLURLg)~)AkQDtFz_FV$ z$zRzll4F4pjKF^PUr1aRj-GDA_^jhDG=acA>le)T@^z=G>TnM_w_Wdw|7XrjUrfg=SfyMFA@zCeyFvh5y~3+kSh+zD(KEc_sTzKkS^ z(_|@^n+(|M#%dP!fCqpwjDp4u?NtR?sM2VE$o4sM;$j~_>!xvhqfu4DGnc3$nBEET z9DaBOztH7TTFvb*1K?2n=W&&`j)YS!dvMW0`n4G?b#?4u)$Za#v|E<2|Q$|0<7hf?!wM z&LlAKKHA$cP)BbCM{1hwikLy^$^LlQxZ(=Zz>J`SQ-#!cMy}1Dk&tzcGY6YruJsg5 z``9K@P$0r$RA)GoU<#M<U9|P4$(D}6FF`*z*(4xre9iA#d37jH=a9dhbF&jl+eR$ri$V#e zJ$XA#t#jdg{A3Iz%uJt}_9TbdB1MERX(o?dpPgVLNKri?YR-u~4JA#^w+0O#xMMM# z>g4x`Rq>oOGM$p_VhyM<19=b7$)zj)M|5+;*GBG&!@X^htV4&*821!&72D2`M=x5Lr zIN{z>?8Sfw&@@}6j@T#l*mh1NkK?bpQ zO-a0#AdioPp6nU@!}ce9QMoOhl}ZJS*J=CfVP9igiy0ROD#T zj4|dgK+6z7CF)YBS3JwVCu>A-7!e~bktZ+z#_b%}*YpSGUl1%nQVkvS4m|Rdbe;dg z11=+TrA~XCJSO+Z3$))iAuimxPvBtB82pBT$%yQK01%!g4#+`Anu)}8WoB5+M3v72 zKzYu4;Sm2)_;|BK^ww@KuzjLXR-EjE5zTt(%9Pfn-!>s$J3o^u0D>-6aw${`ZnA9` zSXrAmjbBErtphODM}I>#4Bv1t0DWe&QFJqFB0D?`-7 zN3V3P%mBsB_?Wj~rG?8H0fYsZEp7{N`Pnc*B+F!|tD7|@tIv~kX2~mn@SJ#eW)j)w zDVwtVix9)82$&WI69h6X0Q>;y$lQWSmcxILPeN)%Qdis@-32;|!Hi1*ps4@~u=?+G zH40Vy_74F7f0_%T)8xIwJ&V7gDOZSUzoZX z>y~Ggd4bsQ;s;m@{`83eoV776)Zh$&r?4FB z2G!139wR85h^3EQ(5}FgqRqZ-D|1_EW{5pvNe;Vlb+{*IM^z}^tltM> z+AFs3v3h;H`3r?pP3Iv0R8|zed-z#C-zA4@OPb4TdGNMPuco0siEhf3w}hF4O?xJx z?rW7R1O4RgOr!`$85zzR1v$Dc1ZQ%fYcd{zKKIguRDyY?1qE}vU%bC`39&AUs*w*R zVh#X^o_NMfunxq)Fwt%iEnK-~GP`uwI z<>?Oelb<(vE%WpsS&Q{-y$d#JkpH;Fm;5y)&306=Z=U2fkIw&qd7EZBPbWo?6j5)CGpM3T|17Q7DZ^=PC@Htr zH9BafFmr7dHX5$~*p|eQ9gf$GEgTEY-m3F_ionb38tU3W78^Jj=twvpI3Hf%?4d%J zm}@>T235I(eBVAx-Wm5&o*tM=FCgVjF3ccf0`BFhY8LdUo9&ksCaYfrvpzqdB1cOH zhPZ?}oT&VcO;9TN>B`gNTy{Szyvv;r>^b!OQZ+64dtuvHd;R;&>Y1CxG!$hs!dqf( zx({;c)lrGdJrOaA-VvcWMci`}v|68Q9cyhWY@dC+;f5C1hoitJ7y0ByLYfKt)TreK zXqDARG!|*u@=k$fo7K8#Sl{?jc@INW=0W?#EUPE89~$7?M-{l{amyWQ zb<5H9vig9^tow{#Fg}HOpK767B}rk= zRPudq;09Vt1L)9{MKiZF4Ndfvvt#S0-}s$ksb1C%HggpcJgZOp4DG#D-j(ij+X?Eb zz7_j*SV5rt-i%uZ+dlR`of_w~+HOgCDD{!#r~B1y+Ru$=hV~!!(+=Ojxm2`O2gi2) z9B9S~NE{CJ@ywvruwU$lDjJ*8u^>*j6l|-@x9WnQoi2BMoj7k2)v}u{l=(4fJILTM zRNAP`CJNKR?`D6NDw6B`bQ&qCpdP@~8RJg=H5^`K0X}Yt~GJN%(b2_L!Qs;XKq>I%AGGEyu!XPKvc@kB_ z4L^e6lMt;VJKcup&g<(ur26!57D&9|t}T%dWsqjOj*pJBdUBb*FbmH-bBA7{pG#ZV z{*Q$Zq^4|^Bv*!rjK!igd;-A*c;Wyxgk@eH*7e9JoLer3D-R`zv%2K88Bg}i;h&>n zbl^VU*z&l0qGe6rk1ztw^%84~Y-8~$(8H;LBV9IV=s2XycD$6!YM6rcCe%}QLO~yN ztAD5tx~$uQk2PQ}@@e(ZXn>t5*a>FO_qxra_x)FM2hcB>Z==;QtnE`IF{<2&TN9@^ zW$y{Xw^|%f!fl`QB_$Vhwye?#w+Uk_*7tw@GKL{6zpDbxNyn(o!x2kqQz+6y$ppi7 z@gOOiP3)m>TSneI%-v7@*Q|zA>Vq9=*bnY=Od=yHZK_G1hIneCKX^z*Q|K()(M%!Y zE{_6z>GqjK$Heb-G6OYfTQvL5x7^xtvGe0(U@h-7s==e%Wv^H0hQ&2y`7q&dw)B}} z|CT7aLY|*Dac3PRq{qHTCzu~J6E8k&I2KIMFRxx{(yOz~7+)C&bW4a3UGmE?#A%H} za?d}159ly8Jnv*ehPMTE+@_b$Ez_%`I2VvZQ9?&kdPedo`e0i8)Lmf|^J|pNKcBw9 zwmH?9P23}KhhxPJN32fOS1obOd_L(V(>&X4oJoy-i>VK%rakEvi5?feLSk4R_MjjC zKW6Gq`D9=CGC1q2P>7AUC;)8&x}RvBqN5e5@JKs_V(8SU;Sy^n(fL`)8lJ0JxX4z+++vs=&=r2*`8_;53E5{zd!!K82E3TKd$IU7`Hz3EG{`%F(CRN>O^nKb z-KLSHp(EEM*YI72VLOeAAJOq*=BL4fG>f2%?2j=b9j3OZ;SgLi9xjwi|K3FIbf$5O zQN=5bjH&Dt69QZJNU9-}PtZ9$iY3#yin2oDbxZc>Io1saVN;BDAy*a>D#Yl{hfPhd zju3EXKmPt^NWyjtXjCX1jiW{by3yf<|CobTAvk3i+IGgMBi7$UaY-o_`~^h3Ah~!7 zqzd;jZ4(}E+@3?~Pfm`mPT^Uf3a#O+Lu~WZv}RJy6#rQ1qzud9lctO$GUdme0#^fR@D{&K><|T8h6zL7qn(d5iUg8$NxU|9L`s~+XDsOUV<>ipb zCn?ZmC%uXT#=46pXR^{LT}H14TEr-cg5h7lpN^*SOCVCJoh*-;z_sA{?vQaGX8;>z zg2Lohle~u&ec?V?O^(OOFg1&*0**sWvp!l~9c6Z{ON!;?o$XlXU7_RWOOFNIrGAH>H=*(t# zWOW%7LaWju>b}(4{;SSPE2ZWOKG}VDOT~6BH`G@peE{VHe-J}nF?#;N4Ul?=$bF}x z7SO$#@|f-x9!nQ3A>c06wq4z_K=nThHtwG1cOW7DlMN~^81*W-0uswfK^G(>TqtmZ zOp0AxOxwGf#@d~hr(hMhN(iZsT=)G`2&HFe1GDcO*GS_*^ov7>`4%bLXE#fS zo`%xsu_6{$R>kMM@k2r5gaRZ0CjGR{+Wb+bQcUx>tS7V1aNDR4YkI%>s8uahI92)h zL)k>>fRU~9*0ZbF1OI&u|Eckge40A!bh@{(Sb9ZSp(~WASloO*&weRQWKZ6(X}N4V zvWQOAlH#`h{4QZAg7`^D(p+hY%7}E6pQ7QmRFgf7IV@jnY2m%x=&f#lXZEu(~==>$l&z* z#eIDEPu}HMr&~r&_$e6&BRUH+&2SFf-k>3OU#F5~Wz9BU`lFf!FvDv)u zM_*@zHB3y5ooC;8CWyE-3gbA%JyL=6Xj`JP&kc>HuZ5^&n?>Iv!)-|Gt-|WAJ~UZ{`nCZG^nD^q#P%pUHz`-6i5N{k$xMJc$@%7y@X{;u!aLkW7VUKM=hkaE2a_fxYwIkwo z7t*T-UF{nO)muoJ7P#B1aam4RI6QFLIw#p1!D>@2#B@(mA)5-Y=3&pa zw?2l6!_uOXM^ii4Lm9!0Ynv)U=CsuL8>@NW7VOL@4s>B;WZ7H!DlgG!575uj8RvAd& z`Z=jQMeBAVO8s$2D zoDSldvZ|>n&|=4c^l^{{_dGJlEHj+naR^GmdX%RxkA+Z?H4}}*3BG)r2(e=*v zbU(aPZz0&{#>A(l=jS;C?O)1f8`bn6GA2{~4z~u~(R4X3NBPV(8Kv`j-C9n97q>pF zT2cA8{ulXS)+XdgZ#5xG8uGEbwm1XBn zOZZ&tOzZNm>vnkOnVi1F!i)raR_qn+xA2pr zB2mJ+B)4zme*q2o%VT6kHrJkB#!k*z-mhGZ$X~3phFFC`fAv$H_4P@;jXYNHlk$EJ zYx}%fa`8DM#jM2Z;!E$AZS^(Tp~qBhIW2Lfx|C4M=Ush6L%nCOlW_}L`_oxWCZq!c z(Scb5IJsMqO?qpXR>il%GGFxZ48QwfnRQy;@#*(HD~FfU`{r%d`d-`|hz@P<WCW_gyJ-VPoQ5hbdVA1HMzn_E(BV75a`&-48KuL7RF;k2 zgw9mB$> zgtoO+jZa$^cERzr^;W$$%9n@Y&~V>E`@ewfJFaRlJVG#wxZOww5DP$FXw}S9Il@oK zi``pkxPSM|x1n*yHayc@o5``yhik|bQ%{^s)tY({yMwFA38J{cH=J4}#zCTGQDOFjp%jSeJV!fLcYJ-YqgDN5t#hT2O?{#}ixCuG|m* z7>}AqVPyF%4L)-dzbdylHl-;UlE#eizYL#uaZjHL9YKU70ON}3Pa~zmhwryzH;_D2 zygGhe9P8$JO8tH@qY#mXF4*Pi8eLD<=v8Y-*qz813Bx>h4sW}uK*@1fg+U6O?X`gX znUS7n1m>GqyvtcGyTPdoY+_!g{F9kt=Uy^#mnu?Z7*!!@RW7x-`IJG|m1rqrGyo|z zcpWyvtWy|L%M2QH+|Vrx9h%?06TP;lsuV~_7H1VYKPi}DKwZ%NX}jkcFsxj}nSRbk zt((ayl$TPwjTGOI5n?9+UkX+b@N&Lg27H+NA3qu|-R9f@8k zIEaKL13U!}Qk%cL4%rwO4lSL)43QAE%6M~Nz84w2OM6IzHaRL06n?AK?a*VeZ#k&6 zg-HroByWudY_#((1PA6%lS=+F!*V-Nov@`nJMW5(3>|Lz^A}*4iV+%SsYk(z&cBm~ zclmu@5*fVjrSF{{O`GpQ?R*daTJ)YKO^$3Y1Wp@%zA?q2MjPM3SziFod}n)7sU~) zq8QQlA0rXH=EHG)oVwzC^bcAvlFr_`P&BRM6qJSoKe)M=J||XnQCGDnH;4ni-LHHb z(@4d|C$cC`yFO3~Rb88;LwlVqNyIjqvR8+1l+PGJ;ikO2q^BQVtXEz5&`j-=#EU?d zd4fvhfOO->^s{2BCp2rAQH9r^9|VMmg8H)Gn$|l`&!nrRfAO)pFtRz*rGWH2vx)n> zFrX41zDCSWNW`AVR4xS&Kg%L+xD5@T{fW9`>a^Y2G)K<6LBR0HtD{nMX+|tJG1O9-4;i(%b8@lMQ>4T~$0N+WI{utz%E@GH`$&Gqf=V$9L{iJxV+Rp@>s zksHxWxWLg4>+nkyh-()S!K|7jt~}>ZJZ>XT?mQpbv3$=UUJ5l7iooE6^O z7jyktD>XP9KIE-{#{b?9M=i?xQH$=}rv;6K5J=omTo!ows4pBNA3xYm{MVKuPwk3t z4GnY3ZPSt=B$`dnKB|%c4}z4(e)(>AHa}6j$WqK1i|aBk4c-%2t&}homLo7|002a@ zBHyNnvD~Hq_I6JgG0CwRR7ffx#YM0oh&(q5eI4eesHj}&+7{Qx{48Bs4SRtFRg_j$ zqPATc*4gMX&2GGNZ3}{c`~^fy!rAR8i&|%&hh%uQW=;)zbA?OdQ!0Y_!F^SMRt=Nl zj*=0^Ru0meCoK3@n!PhxU1(~pf0tPJ<8p7(1|smDqDYmUhn%;g z@Mmkf2%*Qruk_0HJP4TWM51u-@_qk#_-wgPuF&T;F^hsN39VZKmptbPD?1*nhEbNI zWf?aertRX7h%h6#nPh`qKhD72oCCTdWw=&8xzb$OBk($%3Zv1bW`)@Z3E?W-XuF&H z-5k40c+d8G%olF$H`XSq)5gq3w?rIwi2pQ25vRxEj^h~KzW-Rd#0;XkRqVQz?RI`H z#w+#`s%TxUT}l|o>QI+tMp}Dt>P7~yJIWCfDB#aGT*~ZQlh$OdpM|?96JYv|+jDJg z?IHklkfW?t_?`sxVo&#m^OwCYYz~_6?$+v8|Mt1sJrNY->$qI6bKv44HcKs7H=+^M zKVs4O^%h}X**BKSg>ez}u~dZ4?;pV`m=kfjHsx_%lrGF=7twljtaVUVs2dm z?sk`n3bUl;^-E#M^9<(q^hC7g38_}yTlGrJ2S;&3EkfQ4pX6ui%cf9PDYA5ydWkTa zM-suFmXW}mN$`OKl9A5iC6!N3VSGO_Uk%^(#`4%}Q+fwl87r)|oS_<%=j&UA)*c5^ zUQX%j#p$uD4SdZ|d3KXuqM&5{2W%!EXlPCr5xBA|vm$$8P49KxF-^zxYgpsE>U4f$ zDq+#7&a)J-X&PX&Hmx;Qr0z|k=aHxOGi`Hzdgc?+AIwtUt85ZgGo5zT22D3yry-tY zrDd#TC>6{QAiWncmPfgP`0*7(&`9eZTV#*P@_{U?z2O9ZgJhXIyUhgvI|^su)oz%l zSjly%ZfN+Kw$lU8^TG4%?$!(z?KZ-`%bj4n_m(DkPZr&;PK!mQgM|1*gw_r&sA8ok zbXIotjE~-#^FTzmmGDD|#81irHwH{(*1DUTJzuAQme<<4Q%GzV0$o>2ItCHOu=9^_ zfDZ3Bd}n0|3_iLjslYexOLYae@5bKGRsMQ-i^LNr6ReQ^B)jaCMoZvdwzl;|fELq> zyrnpYql)_B?MY;K&6C*UN)IL)RC?(s0Ze58SZhK zbVLyUU(V3zBPkk>{QZ)TqfW}vp;goMyBAEELFqr7)~B#(C$P&;?@MK#7rOI=`_(@c z3DJ<2$o^kIs^Op-FI{NA;PlQrf@el)ex%7~rM;LQ~GUvO@bHX)G zbGH{eb~!4qx(wmfG@tksBu;*{X>J3JJtjO9A?6-OQvo|}eQ&@1v%sx))8HBP%?U<* zo=67Y_KnX&15C?}=xqQVHpMj;3-R7IcY06i+U1Gnw1jsL(lez}C#gz(@{m0(pGgvE z9x6BP{fRgBR-OpnXpmxgI)D?vqvduL{Tmzno+gW%4)&|ncU}qUQKx&0d>`gAe#K}s zoT3RWwgWto8{n;aU}fdQ^mo~ll>`yN1%1W9(jAk&9#fjv^0s3mI_MEsUv`N{Vh+_u z{R>8phJYr5rn!>9b!V(+k|!AmOKkG<>b|nzh`T&eu*DwVkX?hJ%;>OWg*X@|ac1}6 z`r>>NJ(Ypyh2~7j3D;WYcF_1c#iRd<>|-wDcuu^coZPNayB^9c9!&aU3bG60_^z33tw@)b3Sn2x*b_U^h` zlfcS<{yW_mS$BqUM%gWHlKwAODD4`;Zh4r;jJqE;-ajN>dRnrb0@pt9?^tPyYw$=9 zm?*cE{*}U^)?#RNaJknz8)|$IxX7-r&ZD)5rV4$3pm+4P=YNt(itRSikV#h=2wx%0 zI{vYOzKA;>ZFI7}Tr2+HWUa@!{J&5kXMZ5dWF#W{aB~H~jn-f6VGD75&@|WPx?foL zDmVzaacA)cwU3WKqO&ndgM)X(0?u`XWNFT}gbG)UGCCk0mSn}Hy*(MjW_J~w>|EY6 z1++ok{x4D3i4H3=Y&xN6fUtF#x24yu$z*W_ECZBA6S0+-ljh8|0)!SSz8s7(kvs>% z2iffnjnbR{0)=1Y7|}rC02(Mvt>jPI?BgLhBm#BScsqOTaPaBubr0kpy@La8L{2sj z+{Nfq?>IvOq@*}r!w=@`&K%F&4_&+d2Zycrmxg_qtkFugosQExk_V+2|AYaN7+Iuk zCukD(eCZeZLbVHW!8XdZ`}=#v>_HR{>VIL_LA|i)xX&)0-a(2yFO%nqj`)8D6K2k% zYW95-4R_Jehz*)P&kKuhi%Xwd6^1JJ^)F9)(M3cZR==BVWQPeKyIT{w)^$Hz9JVkM z2$*HO`tbH^-|%Sv9P-Nw^@2UULAM)F%4NTrnK2?Z7hA5*wB46vYg+t;Nqg>;l>GS}i`>chW z|JtOU5a;80fw%+|wx94um^l*okxx}fXWiXXZ|m}g-P|f>)nn8dCe9zy645VZjkH%< z+PfB0(q)`VrFK#$&0gQHktRQC`~jgX-m)y(wJEtH2wh2up?>`H6DRT1%1I=X2FED9 zP_Nfk>=y|NDzk}a`5NB5JC?+oYZfPqpbhFnaXUpy&=2+&aEP~+yzw(cdrM)oI1#Tc{^(C6?e$l~icgkUk?t2ZA_hi;0 z^rSazJY%-n`wE%kJsCUbiyEnp%IEFMx7-=9tdmsExH|J}kBM{+Qmq}qd?T?>=cdai zDXeiQ*Rq`2U@)^sGf_oZBkb=<{|y%FYG!o-hY6xeh3||KNH?J3SRM3YHJYtFNspC$ za`e zq1P$J3wuqSg-fe5fy#V~es6&u9XD7b667^XG$l_}nT(0d%OXm+;SJ)lU3$kZd!_4@ zGYvq6U(Tih)WGxyv1K>W<|Jn4G(V6H0&{k1?6d$vo02Z+`KEhtolukRjsguRbdQ*M za{H`DMc&syI>gVe#+AP&^K{s4LSM`ERaedK3ZJfTbQvnGuOCzuow(S(JF`^|=}dhe zZjyU!JqVA-k08^Or9?f2Fd{n80BE#~To{_+oUvg34z+ayoKGDnmgAsl- z9`J3y?6%=CQG39-=@U}0zuJ^&xYKC6Ey_vef%{~m&_{8FO`)=~E=3Q2rmSiJ!3N6%2gNT04} zSJl*rg^Xp?eVne6W2>S^xUfgEF2Iy$3;p*l!OK5+GG2L0-5yoVHc6_n3sEnU#zgw0 zb;<&*ujW*9slnS|yJs2HdM`faUu^+v+m>%1hM_~T+ayahazG#}`^`SRMh*zp=IX6DZ!!Dx5?0bS=NX&n$THHfg${=n6?pCx)_V$=b zjG0FC)yb)e`~oWx5kX+OcZ0IS$sJXdGw(A$(6=>vOL^6*9xu4*S<)T<1!ybpCfmZR z&xAnxPLFX6zfmZ`iwO7G-zqbz1#c$4^!O|cC)&4#`jWT)8}acd`=VO0Nfj7-KKLil zwnS^Aav?foY-nhXDRMSLar7^sQ&Lv*j5^mcD>*rPF>ou(e}a-wikH_I>mh`A`BK}9 zSBn0Gmz?%mIC1ZOk$U$h`RtLx<~QSS)F8wxTc#Kq%$cYQq*+pP^#eMb`o@18ad~>r zZ7T6x!iGIm*P#A{HL+%BP>n3GfX;Qk&~u-`f|77n>vU0;TP)*wCywgZT6ff8%{OBd zF`wBQ&DvBMw*%TF;5v0(~y*$-Kedk)Qg)4|>F?D`*^Q+r!SN9Z%y= zx!@ndRC2HGmQlVoo1Z%nx9Yy22l3fVmZHx@wWI2jq!&+1xTWH3sMZVd%al1#o_SoL zpUf=#Pd0-B)LUCB#!l!vdQ9`m1Ad`+fF>79tjzpiwS~a#h*6|#Eex$$EVY)**FN_^ zb}Q;TMTK3yiis8W`O+KC%-tuwLnx8~o;^G(JsQWV{xtHnme%x7@Fcqk>unFYvyzJZuM&xM2l9IX(Q8%=NTMHiPmz z5sIU!({5{%V}+=|HVbj_%n|fi?uYa*TdkF1&P%qGq2=z^QCNMHT;AYK9U8%{8Fg!- z%)IGUT7Ie4WP`8rrx#3RUlMGmZ7dzNZH~s_3*G}49KBM13iYa>i0TK$wzMPE(Vw2V zrC|RS;tC81Xv8q~o}_E)%JR=^vme)me&{AL?pDnb{%xl>m*9Sn*cogWm*$$MBVG0H z5UYu)@4bVXu@52`Grf=AWQu7!iz-BHC3@LcYZaN)p^FSe;wJ5^E6N8+kpe6rbi>h; zYE_p1lon@uKjH_(`&q&80IDKp7Q73pZMh{Y5{)b$GWk9GAW!*rwoK2$V`93RWglar zQMD~x{~lX}h)bf`!yTUgV3zKqm7!Kj0mFNpP^#BAUyCG7VuR>!l8u})))VO~)V4fl z*IuAW2p;j_q!;7uNDT=#tHun5v;79XMSHa3% z3R-U`jqvBHq5DZ@5b>#|U$It@xTuJ@!_Tbg^6>-CrQr`@pTBy=Z~hZiE{>w0VkPhl zW)t4FXCXE+YJYWG0%jf3eCK?VJ5lr%W(-ACE+(HITAp&EzV)<92zUUZKl2(^LpGsG z>;!a$Z4Q0ir00ZgOZR^P7?zO0y6!+UaN*w`J1z^FwwtgX33!)(gKO(G5)_i_<(<%W zYli~6i$Dfwz3A?KHGP`tlUmhRuV;jyU|!)UkFFb~zknh1 zbF4-YM65#ys=kyo^v9@Qu>yzU`gj>Qiz+!_8X>aAVvr*dq1#3eJ9j$}^Z)+`K8}_0 zC-%bAh~FfDqNSxk`s2Wmp}rt0!tD-Pav#jR7~$VyH%Nv-$O643ZX}>AN_`)S-rXJ= zlDSuX$qw1Q)X}1j@18$)AAD2uB|l1B+*LNDBMf>KppZEAO8OoPr$Co}|LSs9ZwJDs zql_4+*g%xcDo}S|bPX^c6?emdE%YZ}H9jJh!p-g$#xQV*WFHtmUuyGv_T!|C2xyJv z-60#0uW1P30}8Pn4K1v5Tq0VdmpEzuo$A7`=Sl;>Hhwb&QcAPT^Po_P+VaIMz2z)? z5qlOtSy0E>c3LpEIBhL@3Ir5ilS@BmZbILl9yv8Rw7pNX;hO4g_HT(&XJnK6Q-^7a z{_9MK$E6Xkt@m^+C{Ns~)hO1pT5<#Yl(23IZ*$XVgw_D(rytzvD2G2MU~pDkQEh%> zcFh=xuqi}DnU_+2Y5s7=t~qXGKeC(_}Cg;?J*vFJ4asZdl9ej0rsao*UgCMO|&9mtccbUP(W$Jr;@VDefDR zGpgO2cSaoM^vnwHIwk)FOUuqycG?*@aMV^`nK0H2_9_lIP8}c<?NKuIoV8 z`N%7L>St5rggsLi0y1StqWfQgssFK*#;$uQkNNSQqVyBB5C!t2z8#!DxL~8#DY>kS*Yglp_j*$a(*ued2MBmkJA2>n4Q>hjdkV6e zGp#0<^ohwVT8;~Twl7d&uKU4ET;Bq^@J6!5gYJ60d_Q{);@n;K-84Bo8Xj62uFd() z_Obus_K5A_{o2>MpMar&YrQ;4SNEqC90ZHiO3 zxgb5Ra4Xjz!jVH`4;q(#c4fWjvMA1$qcE3WH~b>`- zwAf$3;?~V+rL5vplh{uIEiZk*jyqH%sB3$Bi8uR;V{>XEiWqPJ$-aJS@NxL`@t}<|EX=8T$e*kTfwL4SeWHsfRfFa)wshQ z`T?Nn_o+L$UjNg+AKW|jwW9Zm(~Z)CRGoJ-ov>y5Mw^AvpbMK3L$o>Xyi2ckY@s`v za+`<742dUQ5ow!X^Hk}R0ZPspgYwKx8Zme;7Qs&78sChJEE(RQ&mxvG$Db-zE9n`HiR zXi1k&M97685{I|o9lK0j5P==S2C52&Ex)dx4x7{E5XADH1QA-BU+QH6Xy~78xo7v+`1htcZT=+<4^=n?&3g&nu_AS(|XD6ucS zR4wovIQi*Cl?Dt%(PR54mY^xk|%z-#oMNIj=GCsXFdH7%m_PYpAQ#`DS&?LH! z^@%Q@@7^=q@AK2V4HN#TfKF$irfdlY3bQuDp^;`V*$I}+t-)Zu;?qPslhs5XMSAYf z`TREymiG$M&CBJMnT+k@(v7L>zv}3Lnvcbjr*}N>JB{wOL7PlDm8| zc%;|hwObR^Q`ZV&+QO8d#H{rMA`c=O73%za{sM-9>pqR2z|`|G%2CDG3S!5qo}2^2 zE=fyr!YvbRm)qo7-yXR#TW2e0l3#vZg6M4gMED{q{r~RjrLm-C!H>;Dfu@0N8+~SMf1V&(&(O%$TWwb zFRqW7PuonTLDRnYlpk~i8+{HE3W7Ea7%z8q}(9AXY#>}BsZwDt5-pOQ8zLuzQn zo5=Lau0r#<&r5)R0YrWN7v74ywkgh9rB)@5PUBOs(i~xvDQv0cYK>Zte=d1GL7KD( zcV`_=WLK1(!*!Jba{(M8wS?6$AgjUo97?^WiZpTZPWbvNCv&I$(-Wbk2N3@&IR zjXekXAA0q-5?qCU0d5-?4>#V6{^{bAGrU}#{G^e=45kLKiXS<`67E>Ust@9iLq~U@ z$-jWY$IQpE^|xv}Npc)@A<+?(808ugiVWyG%~(8JSiMuf8)KqXz>&diDWXXS6)B?RVCu8dOz2Q~^- zq8acLl1ud7)#Ybm!6?UAtJ|Hm}zv<1FDr(FJmaN!1O{NB2<* z@tgcu)?A>O$ill|vZxer*&C23Tc?HcxKD@ypx6J{M6ssZD5o7J76_IYo6*p~BLLWb z_}WoB&Dc^KslO`DixDu~N)QvqWZ*8X_g_FG0!_m;M$eV4U4MzFQdpZ5niL|6idnfU zr`oHndfsZ97o^k_6=m_GO_LV9nuBpH%MkBC&{Im?U3=nOr`i*d2&5pUA9RwCQhGy3 z=oFj{GR&1Xva>iHdv`vQ^i|#rGa^CEhO1;&%bHXmJ$jiQU6n|}du!+5Tyw<)Kc+rA z>G8Xfb~j{q4bVv8p3ebmgOuynnQjW@ED6vd77<9_Gt6^anu59u9@vdJ=c@a6j-d~C z(9fbgg3WJ(bB)TsNQg#}u4^ug;&={fQnbfRGY)>x4H)^NbLA^t0H!ybuQWfFmpfe3GQ+g!LygseN$oaG!t5-_-=jA z-V>VPATU<2{;1_Es_oY1_#u9c@S+bU1Gs>OxeGDOu@#=A`PwQ0lNL33R`=9VVTy!( zU#ut6jT%kh^%9}xADhj+)UKT(%>boSiEB{*ap2y|X^R^kSdD5Oi%sYmHj%BFGMBYf z-jgj7X;#)k4ZUcCgc|i~gK{WegJ0|G14lxsTo2&mlk1_{%$P)aBwqD1)%g7tXR5Uo zqKn*(!rrS@wk9xeW_y5%f>ZD?v>6XYU1sQ#y({^_yGbn{-aF)ylV14_nM?Ls{O6qO zEa5GQS3rwFG5A3A&q9Fs^DUD|ojfZi!$|?K>;)DNWf?rDKl6)R2j}pQ6cg8_vw|qh zzD(b@yAQu#OlBMuCdgvh#{L_#END7m-1>eX2NePV=gt0C`9lxTvC{k_m;35t>GQAq z%TzhW5WOnpaRx`_$2|c%XbcS9__m$IWnYWHW)4XMZBUbEAx1!c;(yDLJ(iKm%DMxm zznerPv{+@wqRo32dI9FXBui>{>4Lew#w41*`EVFH-kyD7!prpAG{cHsyxlWT_t73q zgn(0qUn}X~R$Uf6&2#uha^OzyCE^am<%WxbBFPw%NkWH2=}5(Omn3-fRyjD342gWN+}3><`Ct8Npx zwy3rs8m6c2#ANrxon#X2cti}i-QlBzeDJTHkFF9{>h~w$zRg%c|Kq%sF@2V{`E&z- zg88MUY$^mLoUy_YwFjU#q=&dhkgn%$pa5LtvyU~r;a1}inQ?$8fO5#GN2>&8=>Yvu zIVssrUf^HlERo4zUMk?q(PK*K7^#I*u^ka0B*DOh6DF|xvHbMB_^lzmtuJm8`j*ha zDKfahf#p$I>E(3!m!2e!x3rgOv+5un%zQ2@D{d(i04LM9)$9=7uXU3WOU=A_eF@gP z;O!}+VPyCqr_}`RY5Hsu-T1W(!e;kGBvC-b9i-SnpDKGk_1lrKpJoDR6Vl1PA{sJ) zRp--)z%lw>tCs%Y9+7?dDwjLh5nU0sn?>`Gz znfzAj5wq^8@(uDSrG&p9e5Y|~g7N*-mr~piCyG!?E%-ZwOhN# zp8O`X1IEOu1cO{PV~;Hrr?!`t&TO~B0^QC!y6$Ee>gSUbv+ zNT&FQ-BYUR^VrYfe&q?<$iScAz3HAR8iS%XUIw>lNkzMazW|_aEA1G|MYY7Au4kM> z@B2C5GGI8Q*j>~}*mhn&>fpX#eyIq^cn9ayc7%520mpZ2vgHe5+^47Ds$s) z)fyTYNiOWO$-?p6Kaw}*O4O9*wIkYov2I~L)<)0aZzS!!OBwtR;T1x%)p=R`!unc-iQZLbqebw%Fq%H=B6KHF!oeCArgr3PuHZBY5 z#&vEFPk4(vecLNjMO*;!m$hdz`jj^QOqT3yrTnVsBF9%`7yXgt&PZ(JSTweyx0SIgJ0au{i5+5THc+tz1LxqV1Lc_x#v| zy7Qd=g@9Ku$Ou8KU~HdTLw-4XG8Q%oyk2y{<%-otOrCN^(CM{$Qra9{ozM^pB1?uX z;23f6ZMu&{$t)FiG>aHPZdYeN3++LZv#ZbXi+Lq=?YN>7@hry0lokdL(}cs-_DPUt zo(2P15`H?H_2d_XoLa}!FBSMdJ4S}iuDSfyGpP*8C25IeAcF>|2(I_WtqgTjy-pNwSQZ{%m+iU-uBTi!Xr9|y83yHJh z0vS1_VLG^ghN&eRG!*iU*_@jdmHEw)-8a1)#xZiL8jv1mZ~zce6t0(H@XnM*KSsyX zmg;N@+g*W#O6Vs_sMIzbh?X9R%dfP{*xF=G%v_926j_Pu_l)}{E}2afm$)jFF`b@PLkTP^5?9^_T~?el(Z zIn%m%g2Gv!2g}}+B|rZV@mp)<@Wbx)%<@EEH|O^e^;RD;4kEpOgZQKa@zb+}SBP02 zpGlV#-1Oi_<&Y z_v89YR1R5dol_H##oA=Ek3>}9Ax-$+@6*;?XrftxXF3w(95HRnHKl}O3-`Yb4si9Y z+AP@_%?m8Z&DP3KvapZ{+h0+J9IowYY}x z4hg_I2r91Rz(h+vxt#_gPiPEobbhLdN@|TyrJC||g&nA~gW(#NLcA|3RbUoGCYb72 zHGzi`4Ck8Kyu+XSCkD;8O2b>g_z4c3&ECBX$p^v7%+k}x0uuXvUN_WHO$bnlT5&^;>y}8Kbg;y9?_|JZ5pyy;jOa~`-j^e{8|{Rt%#u>V zH-fp>e=&{`hbDZ)OuB!v3tY@uZ!X_2c0QOfCXSo$< zv&M_93>84H*~%F~Q=F*?+P=NWDc(0e`mfno4CPLv@7~7b9hv zD_VJ}c6|nI7`OpoC}#{IMK7+zRtADYevvz?4}f-$DEkE9n6YOZwE= zOBu9Hs>G#Lmt=~gX7a)Ozr)UzHU%q5BYqW1oQbukq}6_RqEIm0%-EB``gg$S2XhAc z5Ekahfh|!yjub~BiZ`>c4ktIs?s^K0Ry`UVbI2|Y`)HiHIFGjfuZ>u0nlqNw5yFrN zD-yLau5>E~b+Lb)>aPkju-_OLa^>9U^kIvZu1q=`x|3E+p!~P?|GDtcHO`kf{v119 z*}KJuy8v&L!wCvl1F%mGU|^dYfmkPMM@>u^$)RBHOA&>uhP?MnjAyo?jL?|eTv#T}gsjA0!0(i0_7{F~ z^qi-5PYz){OOJc<3?=t=e^0IU6&? z9xzOUEt#7UptGE*%>L=T1EwlgeUt`H#J`66eP5NCi#aQ0Zct|UspLx7|IPvJ>!r@F zta}vO?5K>N?TMh>UXbNvakmDUnN4*PgygROo972%j-TRE=jUQZ9L60m=B+_$01@}4 zsC2sTSw<;f<)3Lo|U2Vu& zqTR-}7Ziq+t^c!LtFk&`nzR|->D5`9KVP$BZ&~v5z45L+tTg5OboxP=RagYweT`3W znA3@VSu5H%y2h9Mc$U9~d+wKeqmJDaY#Z^&9UqN|RIFey1y7ZaCiwg)Pb$}AbBz2Y z&#j=#d-DEK862_$mtI9l-^V%^>l>8pX z!8ef9S0z>kfr`SZu-nxh8<8%c{?k>y*Cynn+HE_tD>7b0yqH z74ACRlx*@5eZ)&$sOdfAbEX&as(E;zkd5ooZ{cP%$~B#*p<^}N(?6D=OR)1nw}-GV z5;+WMQF%G#=z0u)W%_CibDo36>E``qV#AN_TkM30Oo^zR8>gIVO_v(cw&|i)T(ph? z#&GuK@Ad45jUA|JDOI+`f){6GlRn147o<+?WR!1Wl$ z3K{hGlf@f3d+bX@ji5XpY9I8@3%D)8YjqCiLe9bOuHR10=*589E-*#S6!v>FTisFe z6MOweisPfzuVXk;5^#UQWIizv^^*3kJq4;F|6jB(>XyKtLn2uzOgZ zgOo9=j?q|bOyQFu^terqnMNwP6*dSEavpGz{_PzgVfaYG=ONvwTf6?$SG@ zTP$462=ILTW{e#GpL+$Uh-|D2(dpjC^ zS7F)dOGPS$YX%O0nouG+C9MAt`UJye!gg=o+?SR(rJtnoi2j+p#NxhN0m(;URlk?Z-)GM;J0g>=D(K~46@rc&%8Xi*8A-DU zytF_=IpoRu7@4o>Vl3e(cF{b*9!ae`Cp2fP-?Eq=X#P&F{H;$VPce^|PYQ9xHUk@< zdD|xv6W24H&+QiW2ENrc^Y+31l2Bgys;3qpsS4X4+mxWJeeVQx)C9*`n$5%zW2@`+ zBvJ`g#w)9;Szhy;MWKBMHu`I{rX^wh5&`EU zjC?v3@}^n<2=S`kF63vVQ*+AF-h=uzJ3Rw-zq1_}4=w`WMGUrm&nJV1Y2PwWA!F%1 zWuSg?cV08fASk*s7MWPf1$hqY<43-$h695hwjsDEd-UwQL1~1&C)0_+v?Jd;(_9)+`p)^cLJ?@XTx5KKfIXtd zq$5qy`V7;k+u5ag#s7C9qss0+zOQm`2inl+sC*rnF}7BOmLp{M%(pLHC>n5mj-IO@ z(&-??&eyps9{6tdeX(AXD#jr=C_A;IcTux$NerJXcfl_fj)O@1*@)}KcZy&x;UR8f zt_$TKfTtZwP0UK#+#(8!bQ)6_=ZulcrfF?!YhJ@e6I>n~Axki&_HWze=apEXW6v`(Ec z>Xi!>0tGk7j;@*%LSpuVl#EKXTaUY%AylK$pH>Z(w&@=QH5HOpu4R}9`{c1JljB#z zxML;j?Mm+NyXxVT@Use%XP}ptH2dyqPnoXjq2o2e1 zsg8Hmg={K%4$^qwd1KH2fw;!y(pyh_(wW$IjoccUX7I~54Rd53Z1%zVc9hoj0go*= z_U{biPq7^%-;8L-KNepWHI-ug++q8ZZXwkJz;-<)`?({4EPn^?`ZWK2+jBM#$KqM} zph>8PWReEcu*PJ_=-=?OUxVFo$l`l%gglL_gkz?k2ixI=aZ=JJw(HpK1Ppu_0S=5M zZzh+`+WPB>Flmp26ol7hD+yYK$flHj{4JJPC+J73YX2Yanr3D_hePp?e<&1 zrUtpxOx>mpY4+lm0acnfKaa`9tG9}SV_l8wMDZjCFH>q*%~!$3P2E1Md2zpv#8}JR zw|Cq7k8T>cQ*-DY$uNR+O8t-o-nceyT;`9_ov5Q zdjz8tOr$|d0OFG4rW;0GBN_<$|;eHz0e)1@7Nug>mj~ zXmi{w+v|VsEit8;s!f0K`pr&5hG!!UFYuH1ldqOE2aU`Zd_`4kr>f3mnI>)mj=l{2 z-x5uJ?8Q80OEToYDOTS*bn*134qv#_*eqZcAeHBAQ8&0d$6!p3B%PdjToJ;sxd9=H zCq6*}jCdSg;zO41{Bp;%pfGwXI?4gVjqo&Ql3cXxCG11+&yaHnYG$t{{VHjDt2#KI z&d&|HKCW)~eys?l{z6jh?N_f6WKU&h5j$D(5rCF4a-_yrNCkXNvbp}x)t$qbc1_@2XTd8D*0{-~M zYUrn67=Bj{{&m^RN~Zn|mHZ9w+dIzJH$a+}Y9V5_P|A#PTU75|D&%!414{L`@x@HP zzG-%S+K1Vzik5naMu|9XU zr}v*9t4XHXy^ode_`vIlvnCJJtbsOWrTVflzwsBku_7AYmP95)PQR0VWsh+zdzbE3 zeg}Zo?3zZ;#s4`N98{y-ro58b%WuHRNtQf3J6h|B+U!kh7io=~@_nHMD(|)FQi)A= zrj^{8Uh}&|us!L%E^n&&Dq*WDs8%ofBjS-$jRxBaCZS^5oA``xXlUxoVve`uy&<}W zXYf^lZBnl2g~wyrB&Ofd-)qP0m$Awtx*HTM(jFt}tB?WAiyc?`57j?aKRPl{7Zlq9 z>}Ed#{C{ni&Q`PevthlmM;~}jgNM3H$FO}JG==xD29}aF^hzA;>Z|Z|P(^Sfmzud^ zLB%`{?)|PO>Z;M~d{`orPl(#9L+NYua z083UU6&j#Em{U@Co5k;=n{6|?@gl?R^7EmD7hjCDF{7P>Cj>%8K|MzxW9n?Pzu5GW zcPF_dBy3)^>r%Vdjg>;aZ3M#_fAS`6O!#j{eO1*7)%BcozRATtIs}SrY2TT&NUvE{ z?$2*efK3wzwEdM47w6*n}?*x#c?&yG*dzEWsJ|Q>i17FuFj6{a*e!xhd{GI zZFQ7l4V1&%Ns$cu2<{@zy8M^AM_A z+=S;h58L<#HCcq4=S#3CIztH8ZUYl5#{?xJo4B984_dPK7av@vqV12TZI2DPF04QK z+C^^@hEyfB^^sWHlYG#2Nc}-&CQz6qfHMVz$+2S9pL!NxEFce7uf=-DzDYevQQQ8M z`984THjSF4wIm0mF|Bl^gW3EJ1JxwC7J^;8LCqWL7y5IuGd4mFp8=V`JuRdOliuN# zBGzh$Z!(RK{u%&J3*p>;rL;|;^ego=IvGGlOmm(Y!ABW3*JKC4i#gV1nylAd`hE`N5|$SXS$e=wc9?*X(m9juc;8si ze!aIhD3M==tf<{y|C_Fmlf>IwW1)9u1p1jAqt9mJeJ_CiN2+K(UK08NOGg^Hd`8vJ zp3n!I6_UZ@0{4>mW#DhD>5Ej@UA~z|H;EWmV06dE34NHj+QENrULpK4(+*ws`oFzU z)9oyT*U2RCb}luTLXe-lo*_-@`*;qn1tD7-AF6NDh!>4cHzAcA;u|f5c}XtJOf3RA z42{Z0faKG9_>{a2TK-#at7arpEq!(>qp5bo*#ivnWbVHt*xa~zZEwmjyXQ+3Y4W)k zamF>3LBuE@6O>X_y6eV2$2oUMtoGXsS+q%u^F*5w z5Y_(~`v&^z?6hzdTzNJd?M2vW@w;aEA-}|Q#Fz7HvRTdP6jcy`;Gu&pxSE76O608|krZv5ddMs%Z8X5_t(e_{R+dz zjo$yaf^P}{{(p)%MVHS>MC}%!&6X-xv~7@F_191`XyJ759f{$kVdy$w{jS89iEc0f zOkM=@H{(m3E3Qxnq93MsB86oR1RNzy{%!5dV*_l8&%Rh2enLr%M6{}74?i_Y?K^qU zmHH5`K`q&DF* zkR2g1BVp<0yo-PPs9k-iN2tOpl)NOX5>G86-k=P_qMt z!yOs6STVhq@OC!8SYTGkV=($q!=SS0wa_TOu!;nu0wxNQN3R3U(iU#r_dY2`gVK`2 z{%Xr4Ir_GW@K-_Q`Dx7&5~W?jw+{eMLm0^JJOfnD-tTtu{2!=XUgjetOiQd1KK$u3 zEk%OnSeiPU`t1vLFBt=GH9lTIvLjw4paEM-oSuvxsDa9yTi6~2WSs8*B*nk138j7| z9VI5{La~Dtb2J^wj+aDld0vK!fwxdc$F#-tvKgTo+@nb7biq4h2Y(>ZnkR_30wf=J z{oMY4ucp4uyx{)C&JQq#TIc@yAW@!g;Xty?C3^L$I=HJPjir~*yqRm--k)=<+%Rv_ zA+dV1vulL8lMkNo&P<{@e@{8=debto&D3EmY6-5Fq?Qy?`RX5tF1T8Z<{AQKXI(3i zEY+74=P7YveS=>{9nuQ-3>gbHulVnbvH8ylXj?~)7{gS%u@5gFu|ECQ`L@IDc356& zK4W~FULl>4XLW)M+sfqH_KA^%pHwtlvt=}8vzkeqqs^koyd;ufWHow+lA6*b&_`)# zOiR&4_ZN@d@?W#rkJ3wpWZ#??bPMLo2Gyz^I*88gy*r27w8m=VEo5@8w_bMcaPq;8 zfbBot@x`ylBRy8Au6>%ze;`5gtnQAuQ?C)muy?UP5|ILTCW^b_NnU5wA5D1G$_D@&&ox{a*7kRxA zFL-|=WwMBt0E-bfyKU|{{;CGhZwk$8b(#rRKQlymF};B0%rUL;u%sy-5&0BtrN%f6 zEEF2Vu;eRXs9W9+^+N@U2FENrU)GmIC-PLCO>)}_aK6CY8N%OjJ7GS0eo&2D{3yMZ zo3fN7V91XPq0g{CSW7q^_aT5b=Q=)I5SF@#sW-`aJmy2B`qKaB^p>Rcq<`1aSQvN5 zc-b+QTav$$`MLbJbUxO7g|$_}ll@~@>--<_hZ5ZfyITWB8GXKr%pPl&45gKF5*fTQuH32n-LARenec1exEDr77 zxzfYQw%0M!k3^JQEm+9|Mf4%&sn_p)2{%_^U`vTQW1&BvN2a3|nWyhbV!6aSunur_ zs0N@cY@hv|dF)m5Y~4QUZT_q~ouuG*M_A?Wo4pT}7G(CgKB-k@QyC|RCDFT2w zEEtWI`7iNrGUW^%N7Kl@$uZPX2{J8bvz>`0#lFE7ejJ*2b~Y-}lNI!HD<4iv8U;l45fx#oK@OYomo_ zPiM6OYQK0lI+l7$j*YALN+P9_uZMTy)rVG9E`=}tEB;#YFJdATH_FUEx<8tv{1%q$ z*dg;?V~=#e^zcGmq2pZU;H|m+7=j7gV_cEDovNk0SawYq4mp5%EZOHblXRjmgrCaq zc?D<}@ZVqUp^KEnt4~YM#;;`1`Y8?WT_>(%|4#U`Wv3XhSkQZ;Llc-U#RAnPH6p!A zD*ET?4Cc=kM+=|rRD4}h(2QhSojr?uzQ!Q^S#fMiy`OJmr<9iE>Hb`CB?xXY%G?rIR5EKK1pW!UxyEnN%<;y$~TiqSC@Z{Snf8AEb zQgn6;X0n!QLXWPYQJK!Jcb@Q;FmLHCkHz&& zW-p0tgY5&er==p#3++b)-0jL=R5wrY+EQC1tSN8F<7Pa=zaB1*&@+#~O>*V=W7Xts z7hONL?pM~wFhRJc?v$aB=75Y?9LWUoAyVsCI@10EX4TA-Nq;zSqs|+*aK>meb7xR@ zaQ_3j9U~tlT9!jT?EmE{@F}m39*DbC_5xQg;1F{}j8{jIpxoycIx24ws}!7clHj5? zGAD=YN$$;qq=&CjAJ7GxSZ=)0YBN`QIrF!{L6~3VpxWMZYpDE-_l=r)PcU7^C9Gjy zs7r1KEN_)`{uvbr(QY81d@qbU$YzrJr5I|p>0Q>14}hDbCMSt z&i88ox$J`#$axBuK3NQmf;jvG1tvdyUG-j~5G!2rIE~GImW;T`>4K(w^&nW9o${-` z$lZ;l2R*8I&n>+SK>y0+K3Z`eB>(4sT&w%Q6Wjv;v)w&IP^Qgz_1AZ|=>!pXai(xj`X~=+%kXH)*=_j#Iu0!S$7q`0~9=z$WpLZ<>HJYw{YaB4bb3I@~C zt!(BxedwR4wI@N2UPRGZSo|oHd@mnE1#gU??0 z;+Db$*ay0uzO#Q>@BubBUcyrLCb^a{b2??V6%%{GCg^lcc5~uisI?Ck#O3qIj{Gb) z(RPVY-7X#=vI}6pm!>~1pc|nYirR*_sP8G^fr;K^CLmWeaU*TlV@LN}E0T4#JCK7naj{x8aoWoj$Vp z%?^V;ZKX!Ka}y;VXR$;QGtzIjozlLDmmv&b zQk*j#$Yp_&vh1VrTgQ*0+g_vX)VOjjWFc~fT>n7kM%%WzGfi>zh$@4RXEORVTS-fw zJuGORGQ*6{O#JQxZ1huwF}k&TDKT;e$ZfiJ9y!=PxrPtMQ}dw(F0>0MPRSGDJ;M%E z^L_(EgHkso-0t?ItjyB%Df!*2H^Y?GV$V_K!$GY@7N~u@p;nfras5%rs6g6dKw+QB z9=)r%8PWBs_G7OMy*B0VxVE$J@tlKgjmcnWs4wf9?$5Echn3b}(J~`N3&UrZvPUlD z85#+wsJJtQMB&O2GL8UxEfc+(exksYP}RL@HbG`vhZaO4>aQEU?U{aIqivdEZM9@< zVGb(0{6^3Ho?_87_a8{(0=?4If5hTH@2Z*b_d}7MDbIjr(=qMfpb|41b=>8>MB~(N zC^k-W-SKXZtdo`HX?e$^n92$fO1n2T`HY8?5&o7quDt~@;%b%CriVT4hjYE6j6x+;_n_wq25Y3c!q|w9 zpa{`3KrcdA59i`6tK~U{lGpXorLX3y9@eh10uhQLMWyNH1T>?L)j zcAz`0(!B876t$hLqRs)I+2f9_+wK+gNLamTHeA!X`$THa-AXR^uWOT`)fu%*Xzh2+s7ih#B|b#fMkbZgj_PAFtn{B zwgwOdL8t{xotfDQf2|*EMK}ilHB_EImxfE%W-`bh8@ktd`2yCEkA_oo4f#iq9P2Zjll@~7N z(sy)+Plzi#FXBl~4D7U7y$^IntJIjuXSTgT5}E)8?lp??#&gv z)tT>%+!zi%hMVm`p^7v&h66VLUR&@D{cO{?&}{m5Y$sS;O}reVwkBxf1kNG?lRAt5 z=7DF~H|7Dg?B!3*IlDu{H;6KfJMzXY^@4d@Yy7iKrXzs9!~*bj|Bm?jx6GPa7^^#@ z2%{M#nQ@S}7oJ0117Pr-DxV9KEmCc>%exKxGoJq|KN5o*fws5P3`f zp0VpKd=RTFI+vgrBV;=inuSsq9LdC|z0hPeD>Y3`Q&4x}X++380?1uF6R0H)Y{PSb z9_Hm3d2j&|LM3~&XEck~!h9GM|68D9)!GvT3G$K)Qry412Q0e77#Z_JezPpBu<6ek z8*u&QbwU%_@2Fk@6xO!s!nkb8jhEqt-80U0x1^~X5MOiMkyw3uTa}QCHZoVzg*^hU zzBvRQAm6zg|Gtc>I_{+W_0z5oq1M7;#N@HQgq~eXFfOFrII{RE@i(cyzg%sDVpIs4 zDWNa|oZ;Q+!l!Z2;h(%+q8z5|8TG5$Y|v%zMu&PP(P<2OL>=JEGy#@G+_AqpV5I`) z0*bQi@pdsN_~iC`tZ`vnUf0)L+NP_Jx?7SzmGGBA0AUUm>hw4Q zq;lW;u$Jh>mFfX_Jg|H56HNEeS1krfZ2K)}o)o&JZ@h==f(MSaGeqk#DzE%nDRW=D zy4^!XcnHK@181aXBAa{4_N(vE57DqA-%`NNckUgVv@=p5u5YKwH^BJ;J>q2t`2HV?z`j)W?m}`CWZ}Kit52j>(Zk&6& zvy6;AQ=*Hmi7UZKf;y|&AnwkG^U8)tSRIoqhbWKzeHH7>y139vCGhCaq4^mY6=EC? zy>E^h_7?f_&0i!>68|98+Y{b2mNw4)-kC*_uxThD1lj8YQ6`H>=ZlZqABha}vd~SO zeHE-o`CbsJestS(z4&`8{+_hG8DEl(+1J)X9xo1vCulQrVrWebQ$zXlf4?i5>#I?a z+CH3_DM{N!i%3fP7ULw`#c#E=@@!Ci(uQD0HzR{GFxPFY!=lCtHsW*wmi;JxiYi7p zTDWuh2>ks1{N|qCc^+8_o)V^b{7{H`k&)85Uls!LoH>@aj;Mw@=DQ3pxMdAOG|vjf0@oS2b)u?k z_l%~4OPt%i3#5y49EA988#q_a-mQFJ#0&S8K8PnFxMrWWpSa5IOB9lg!yq2Z)IZ4k zeCU_Df|`h_vv=tF=9zJYzRa>{b>=*e!_W){__KJQi5rX^coYqwUq(geO}@C?@}|ga z<%mDUU;WrIU2xUOxMV_4z16#ql)`m%aG|7$UvC@OY`3phzqXyk;+gvPef&DBkFYPF zCN;h4GWk@6a!r04VH?Va?RpjvE8cGNX+s+1i%jWQ)W*DK*hr`FH2=AS0g@(~xO|8Z z7HNO0md*9fUtt{iWO^kbZw_RJrKKJ<3Pp&YyuWp@(AGWh_u{~#)E5l>t=|R!0^T4Y z5e!y&T^`fMKlPGFi%*sH|7d+04-f(Ig|T}MR!VY&wGUXF(GS=?X^jrk75FGtjet-Q z(Mm1`IM=h@D=mrpVK%hU>|IfgZHMw|4j%8bvj)10sm~tV9luG{8n_RL09F$pxh0sk zs@u%i;n~~5y~f}fLadQozDLyY)H};_7eaC7H8p%Rh?2NMxyrIn@qM!ISe|)07a2)s znz@KNk%%F6uh})(Ar1id3Agb~h7F*g*eriM}2Xm?Me@!cv3H@+%vc7_*iLVj`;y42FhL(*!+H)x|6F zN`$jR0pk*CX``(((xahgr!G@Evceyw%!77b;0<2FhNN5c*z357>VmEu5qC!ITbgHg zWGh^Cpd&u1k&-_e?;;u{yG~XGO+U5L@7p+o0R1HC_^}}zmyLx+GsUWMqa__4UC2uc&9yaRW>1bx$u&dxx+cj#*H-AcZWldtutz@EJauKX1OFealxj z(#EjT$t&?id+V1*r&6dAxGqYL#|GLOyg2gV!1-Np0V<~aUi*HfC{Ne`h7$>`QNnSG}R3Tk|3i|uYTy&Mv1vsdRnB&XYcU;*hPH{u{=Z81 z2FId$mgcti`)7KO+)f5@OXo=$TKa9uJU)+&D&boE*5Cp8{5n>j-Ac7z_>)(5YI@o4 zUJm^O$+uY=+&z!4bbvqZ@6f2*b)u0~X4M~F*uDi2#zGuraLogwUrG{~M-b~%Na_7# zEBgK6Zf6gDEx^pXdkc`73yxCOHhHkFyFM(7Q}h1(9#Gt$i7=*Y2eaFHf}3reN%qgk z&5GMI3(gDQG8Va>YbnQ2B^Qe%-6svN;D7QDxNNDNZ{cT@J^{lJv=(iTc#?+BhwTs( z3g0g|&!mETYPdHoFugGozHpW0hzASHlhH*h3#-lmMJVU`ExIa74pX_VM$dDlK5{1g z`3VhsdF63er5dGUGKBIRt~Mf|pTz&h)KWUnqSwVD@k^a4K~B(itU5RGDM<(0%RSY0j#1LM2WLpOsh{hZV$vuz+BbGj`q||gpYa34Qs`v3mVfWC%;zIYC99m zYWyEJK-f&o4LNocB~ZEjk{>%cd5Mcwna~qFs34azBb! z+1h?P^cD$lJe*|Ew0`L8&q{35;igIDT>0W5JwhZ6BK;hW3~UbLB!(c*@yXC=yAaJ* zw5^8R-IZD{w_h9(YX5dN={s`XXHWohdwKx-GdH+E+{Bi@SGu?w-*aC;TMrhm#cXoQ z0}%>~D;uScp9X9%h|U|*FbWdVZJ96rils05=gXlf8yRXAjdR^!Gl(gopw=>Dl=0C@ zgj6s&pR*%bCDrNLZd=_J^*d&u74#C&ocb*OMLO$lT>VZdxu>dyNEpFG)A4)}u@YYw7Ig-R1k%ML-(YM?|=ON*TIXE864g92z zEmvZRP)2SYCj=K<>4CS~R6J+hy!rDP6vSO+t)R!I!AfrxH*a z$#yYJvw|>YAc^ibu)=YafL@_Wr^~aB%#{v)a?d}68n-9eW7P?ppz6m!PfLsYRHm#0 zcMuT{k&09z=mphou-kr52Ky0y*9E>{%+Xx)g#rUjXESXK61;a<4v1S++iwnm45p@K zBK?kh{>J$-qsB|CU^~ENKGe+5$&NsvAZgR%1wAh=XJ&?Tb0^?oROcE@P6REzN2*8e zwDsYZXe)-HH2NniA({s>qPO}r04L8aS9g+FzdGP>rBM$$BuYBE%^s&xW?(8=NLj=i zjKXA(aGws6wzMclQdy;oBy2t9_me)6F6c_y1*tZ%Nx0818gkxnict{QOWoVj#qHA{ zNVSw_%Sd8WsM#8H%X;`DIejLu>aam&LuBoqiUtwY zF5}iX)S9dMWznxN&gGm;7UO~qJ$k#p6e`PwSb2 zL}@K%^oK2HN_*|^8L={Q8IIxcw~3ekcNuWLK51>zyH0SJga#A@gQj#(b}k({=@++i z=6;a+&gLZ@^CjMX@*cz;x(sohdVachFsO9%`Kttnx&#kWCtad-A=ROrf{Zv!z_4$$KPQ_>5ey z-^o(oKP<7A+>HhhY68O*ITqhC_(_=sA^n8!DgD9?YD(^#RxQGM{fb!j7 zfUW7BDQHb^OV5yVk?3|a0n-q~tM{KaE8uU5U#_kF+IP!iQWfuEAn-&r=T+ZstF7u* zNXo(=PXKGVhp?wJh&WhoX+(_V^||K1ScOkmZjP<_;018z?9{^?%io|#66x=vUA~kV zXZ^#rj}0hrE(H%XaU+)D&b#rmmfPH|Osn_9nfUDcW%ui^@8#ATeD>XnU=~b~9uQ5x zucf@~C^ZovXiF5ZLa7)_ScWjXp&zph?-4>|QGVLcQ|i{=21$pB;Ja6BHxyDlKa@j%wc&o zQ^tB_$K!{up%7fwxjZr){0~F{2Gj+Af)N|Vj8kZV;7lo_i2C~gc+J`M)?3L@HASi2 z_cj8?8n`!&<7a~J1y1s!aB;m`$>LE-sukiFxNxFtwF31?7A+s2-tkRD3H<@F%;+m! z%O0nQubg!%h}*kEnQF&02tZG5wo&(N{FmwXwN2>fAUz{m`Q}S7bLx=|O=F3_+gjd= z$S_K~*g^BYUDx<$27;bTt*Rh(<{{n249^4f>zcBn6OsK6ou$X-<`HV`Yr4bW`vD;c z*#Sb>1+rIK>kHRQzYVO=;h9abE7WTD1HYFIxbHSpq^B}e!n*5WRI0L{_|;?aM)m>& z|AbBp5k5}K?;XZa`QBpkTBXEX*Ut!W_1`H!Igq^GcKwS8m-}44h|&w8-xJ#oOCqSc z%xOmxCTT}+T-_PD!IzE7p0p0V(lg%BMzXxIO)2>}Kv^vq>s@hc_a$*9$?vuYyG&_^ zj94LElWWZtc-}`;?Q&iT%u44na@pnGgXsS8)Ux~TIH^!i{3s@P3*&ljb=CfPus$7D+hcGGu#MR7T_f$IMCtVx~W;cz{Ju9?7Mt)*q=K8G7JVAC}eBW<977i1=A;mE#%$ z1(pEbyGHId&0L_dSdZ%>2tfl*ku2ie26Q)U)DBbI}8{q~&b++wKtKx_u}bM@ui3Ix*WC9R>r73!YX zvpfc3;%5i#(~Vc#5a=8NDp!#4BHe-Nk&v6lR=S7TZzAnk+QW^20g0is-jc9xy*J;~ zR4h0x-W<@|Hb}f@45R0uqDA-2|lGHVT8$on!Rk92v~6T!&o zh{3qL2kSz8k?R`*`qc+3Z$Xg%tV|RXzeR@uSx=I2r{lPjd9O}uX02cQ`i@^S-&!#9 zUlch^G~ahPxtdZq(?*9_zl9B98z|YpWZu!xZ-4ZKH6Y|KC)4yjnu~@%Wm`~lw-Yxs z&sw^^IX$nX1{lSqU0-Ns$iViA!60KM&RS8{%|?Ne0x4{Gh&U&>3#Z2aU#3sKc=f^yZw2tI6v_b(?mdGg&P zB%O5wuXwd@HK(-fljph^HbL9jA2Uf9<*&9Tp7WJp`SUpL(KEL8{-N6X88v@Dsm*s8 z+X9!nJ#WrjjNY#7rtT1SW5O3(OeDJuXDdhGxiW3&nbd4Y_VwGNi-nV^MS+3)4;NSl zj#p8qc;HIvZcI2fh9h6rM-pjQ94N>h z%wE-v(j8h~I%!EAuw2RQ#^BFzI78Omr5GY1mP)GQlpzupTs4CLjY$@rf>bje2p z;1+#}GZrQzw@Z))ANEUrfZ=V8`3-1i&rinU#O*eY z#)3oeL+YNC=$J{L`tP1DqnWY*A==PH_#fzI6-}d}C%_XHbZ3SILH$m45gU-P`6m>1 zgxoNcD#++5F7A5H>P0CCanapVWd8>mIW<%X?~*7QVZ-$KPM4&J_c&;uIgH7*c*Yo= zEc#-QKDN|-{yEb?wk6>7`9xAXJ%+2V+6>58SAS}thB>UCn0){byJ*~%@7ECC_t+Xe zt#-!mBd_Ur4%@zF<)!@@jTtXxF~>8{f}!cxK;*;ewGnHJ5qi--T_1=wl010q|NXth zh5c>{0nOp)2-e@NQTyy>7_A`R=9nqoRol1it{k}SxW3%D4<9w5rOr6pls|m8EY_Dy zK~hD9fcorjBH@4^iuAyDcjL}+PeA;3V+{+nOXegNQHk#IIb1)}YLu_}2jYf*nsE%M zV+qL3Zjkv0V#vcR-b{uM6l834&z*}Qd-4fVyik)V^y<4mv63Y+6P%={vl(|yuT(}Q zPXB@QM~T$Bt}{*CE^~!4md#fwKgwcJ6GUt?A=QqThO_zqlLltpdz~#L`fjOKdpnTY zqZ;#Z7Q^a1+Hi8J(crOKc&r`pb>aHdQ@-bP9%~|gfoW_5qBGb|diNriw4HViW$WkE zw|6&jjekqDPiKGQ16Hmz17aVO`(fOlHKm+wVj*6*E7x*P>bS#gOf@z$R1lheGT02L zl@qKtc&pK?sl}w$1vt#=(7V|WK-2na)d%NqGb-#6FkP#7K(h~S3|Lt18fvQuUJZE3 zopy6YnV95fR(dbbopFt_6E}VR*=$p7zUlJIuOdwxX^R>$x*m1p_=ys9alRI$w8H$fM%sAY5=-c~cx(O}ib%*gT8 zoSK%Sytzow0s8~)Te0P*a}jNNns&ArntwrWH@dU>sD;nWTCG>IDpYKr>pjxGAtybr zruUlJ-SK7oBi?e86ZJI#{WSG1^19lX6mSW*&F1j!bZwWvlbSW<>f;KSU2=#+dVwJq zpjXsoN&0D_m)74u9!wtTE_Hgf!zMm=O#s_uC5g(S7w)ZgEB%W|V16Acn&`<5WFSuJ z^rj48?A8K!yjTG(!f@eYiP_dm*Ml&k_%bEtDGL{|(UA3=K$iY%p5`X%NA;l_Q{NOQ zcE@uVt#JHoCTGa*>-l5{E_(=1&w>QFo|g2OM5UFM9 zVT)jD9a)bq?jv~Dr6_h-F&f)jAG#mYsNZo2v9EJ6FW%}xLE@#WWiu$9^QlzA}8%%%$_iG)#AP}3}qI+>mNMA^ge zmhDT{5%od|@4cc}Jq-EZRhIf``!K0t5bQ-6mLpyQY*xk4nE&JgGuf{fi`}cF0C)h` zr?JUTvtwYk^iYOY`bO!fJmD`Rj+eC*1MRB~WW1481MC!Yv(Lp$SlmgOUb?Fh7qamV zG(hELBt%>-rOO7F?M%?(nDLk{mg6=RT{~X7A6OOX&p}kzi%`oG4I&8~Z#Jy?H6L0q$%m7xdQ4sGM-LGdUH(?Esw_qUq@ zse@NPbPULA2|bUDLPf<#p|uox4Y*9mShU9es1J~|HMDMX+R9OCo<=Kpd#Ngvl$&O@ zyzO}tfyTOgi4pYGjgD8798}F5y+wN1xODq>!cXV6jG8#(MnLeSY@*l!%z<+HakUn9 znGjI$+}ZxJ3M<@Z`b;S_vii{D5#gb~bTfl<{;~fkHOXcEPR`LgJ^kJe82?z(;!<@C zf=6GZJw=AW#ppB9x>`umfECMuIzfL$y$ptWC#yiH4H z%I$LJ<&Z^^fJ@;}(2bfng!L+9njer87aAJ=LYkfDLo~ZTYKwR0-22eR?Hkvot+N@n zr_N*^6NtZd&)E*yWIFC{Jvw)q$9MJ(O)IF8@uYsB-k6x5@gOd*f{{{le808WD2VGr zl)g~YRf8%>f36y3@qc2#V-e;4ZDu$5)3KBP6!nr{Qf1Z~i>#FT!wSw^onl_ukcWBm z>|VZeK}I0Lb0`^C^Y{fdt($EJ972?;$kGa zg~vR@&tFNra4( zPa0dT6yb_1J=O`OykjsI+*ma@XeyKTmt?Z_FKkA0tUSJ~t-8-^`>Azc6f0es5oq>P z$-Ff>D_tmg{X%Rp6RnhymT)2r&)II4^7J7Ik}hxJzV7!M?y*Fs?TcbV+2qgW%Yb?{ z?5kW|7W#Ym9QS2G_zvYAbqiBw>jIaUs|vrQS=sPVAw5#7H2sX*VOvkRy_ZHyx4`Vc zH|j?8;7~gN6!wWsRXzL>mQdc@Z4v0W-;FsXa7bFyL+oUAat>7kYxz9+v>#xrSG^mNzsd3 zarysfd+VsSx3_OI6bTe7&|*Owq!cR@cS`XVDyI~OVuc2G*A@>Fid(T#3PFQ=kw9^2 za1ZVloHO5Zo_Xe-nYG?`ezVpL3-~K*K{k8e?C;+9=eiQjjNw1*yR7)&8$NGl5=$KJ z;8j&|7i-sZ__cnCZ|1~@Eq$=pI=FdQwcWO>T3bDW!~G4)>m{G~-YTsl^436kf+5y;t}XT-XYX6YzxmUUzUiHKE%3ENnW4;c%XJ z-cMQe4&_Ka=*{Y7YitJazMC#V703_9da~Jh#;>oqk1AD&T`}WT?Rc80hmu=1J zS7tsCcGh_=K+7q5S=EMF+|wGd&Bi^oGPYmJD1w@NoR2V3HR2UzjL`A@e|Ep#jXRZG zm9wW&oED!u&<^C%eA3`exiEdH6L#-r{GHLPCU>i!7v?Sy5vg?ss9|~!RmGMVF))mP z9fKc^6maSJ@9dS)DNkh+@XN35yJ(}+Yt)ZB@m9P~PVNFfM5l2vtv{w#X4yuv8sRh! z4QDd=MgiQb4fY-*K@aAIxfTWnbw5yDw~oyaPy^#v=s2ejb02 zQ;1pI+`!)6OLS7k^5Q~1UQP=X{S7j8l(^l(t7GL>zh=7!OQ$(M2m@kR!(dedY3^RR z`TKz0?|(kE`7yr7I`K*Cfr^4o{jo9Mnba0{+-$O|CgC9HBs0w zz^l3JgFpLgEskkH=T_xr0+1Yk6)4lXyI;YdtPIiJESQiaX2b#jML(@T)-Q>`Uc5kM zp<6$9(;}kggD;obV8-xTe8mOC_=)a@6TJp~+P=?HfxgsX{okOvYB)K;tt)M9S*Zn& zYjcE?Y@r3y`IJ5ni!QfUd>)Dkk6#dC?cS2PKc##QORP@%H4PY8P{?+7HOm5*t+X7x zd=(R{n#khfff{>9?YY#6v5$%y2ed_ofVS6>GjU zr(H*H&t5mp;1&;Gru6ev)$Pe-R-Mu!^tH1&sMWZKf5ZVaa?G7ahlwey@5axmwI8Vq z@Qui+%&y%0{yDv!kw;$Hvr%3B2wTXx_%}%J4<`&i{nQ^F7GOq+$i+H4xBj$WKvlhACzM4$(`t?@M$?R$Vo%&sJDZ)+%CBr6WYLxv}29O#nZYdEl zw~_;()-IYozS=J4AH(i=;q}DHc>8>boY2Rn3E)iNv#bS@L5r6Lp62bB&9&tLwW~&d z*5dQZB1(ORaMi1q4kXEq%(F3a(zH-hbCz|9d&7GQo`8|_hy8~CSHYmaLAmcKN}*Ku z3?*=FWOA+b-$0Zr3{7-Ti>#S8J|KPbd=yiu+P3L&${e!P0%kAt4q1Y2 zT9q{t#-?+Bs%`}Th!$|5Q_){YOA_1GI%V?3ugJmr6g}0;Qg%IEhR&zN#-{ywo9}I3 zvXe&k0&jn6^s)$ibu-{0ymr?_i4YBC;w@4t?i4Yw zH1{fwy|&B^E_B9l4Jud27QTC-oIRE>)G8-^SV$S5Y`fXtG;vg4;ueCR9aF*d;V>Yg z>V83H0m!HJ-CT<@gX z5}~LrmnrPHP0SgsV?-^6=AfnzJ7mM5d^C=X;O7W%5$3egf2%CI7V6!+U(L_^(7zz- zo1hJ~%E*g2yj3J<&WSCSKgLuhi90}iu-?P#DyO9dDgF+TXK z1B_Q|{D$ik6Qh4{J|0#Q0ABygk)f!Uvp82`e4qX`f6O?w_=nX-a*s=9)}>a!!=8zI zI2Jkau8vtkqBSdDrvYpyKcc;FqPt?$;WJeuYIr?hlq|;UYX3$&4QRrBX*BlYa0#bd zV(M6<`T6|v=%^GXBjNB>)yIZqWy_;%_6Ea!(oML(Cl8^?9^@hIm5Ium0Mj%h=E!Mq ztfobNN-2$@TRv0fTw4yT$#zpYU+S^-iRYPiwmYYZIR7%ocg2K!b`js= zCjC*}inF$j1a-m7GDO+=JKj7E%^zCLk>P1We=MR39V;j|%~w9`^$N60ud>e!p0)nC zfDV^cx#DOIoDAIMSWd*`ti;9bRs>mO+E{=-NV&zIc6-HrdMj^uatdT^=cJD7M|N5m z=zC=X_Y3>#E-&yWvuWoG`t;DtY}L<*W|C}lySrI1EeiYGfnL@E!;>~pZ~0#pqDlOP z#d^_045u2uhVQdyNGWng(Mb7F;u~h|mtTPxN6xMFrZkF$sj&?Bghl4IZ#2t_`fZ%7 zN}^?gh|ye)d()6kH9|+_+ky#c+D#%)hdM4 zjgiHoFSI-@0dc zHq*BXCIWvI#s`b(MM_gu>S(n22L9tzx>X94pZbf>$_2iaA?qUs)A_NvQ~6RvVi3~> zNztZb5tzvkt!lj2gp=i-_y~Z1iL0Cj0&tFBNsl^IC z`PG8n9wJ`uix?g5iIJ7AMW9A5ptRe|v+HtRlqi*E;w%geO z^LC*|g+oV{u;LA|Eyp+F>X#!Cw@M~$@iw%?2iG^a>n&zT@D<- zCcKt}kOw{+aC{iC0UL9;f(%DMY``StGYa}K|*Co-4-@Fli!ITepJKmIt!c-=| zeSyb_iH{)h6(|=8S6DT^%hmRc2E{24964Ga_U;Kd-R}L93rr$0%OYM6*3rBVr!=2j zT&vHwy{GyEI_Fhv)m-QE$5vU!H)GJLNwO(T(@;#doq%dbQE|EXapLZDH33OcluzV%rU0$Mt;;|GX1OZ2P&ScXLk9EnPG|9!ppu;{$NE$A_T#;De(Rm*7fa;A-M|oC_|GeiJE$6*DlTihv*=NZ^CffpVeb3- zx9T`X0#!%Z&hK!^IsHx`h5QEtkPysbQNpQ-;fJmF0?nYi7+B@F8?zbLWPFUzLqWVf zm@AXQDVsAyZidwrh{BoQt1x@?qLMgoPewd_C{N`^edO?5aUH5rsuDeJ-`2aW^{dz1 z^8gTP=s1EHC%#0^rJeGsf0{jfAIz1k&R0fm4m#z?2JtmA&R?^|#uU6~R$&w|wRn)@ zK)hmlFoOFxW>~&eE)2CYm+8to>ro&?AAge{@hP%u{yj2PB%{L?=WJ0SBU#Gos2WV& zoQ5OIZ4Uqbq{sZL(9$dmh_lc*?764BD+|Lvwb23s)J8K~?PQa;DWl66-~yd4QoZC|Ij^q+wxLR^C5b)%$O< z66*{^y@2Fx_r59$elH-dWva@!N_SiA;b(QI`G@$WrmU6kD2MTcMM^hZ|G~R5&h)3M z1XLm|>Ku+R-C;3x?l%0rkXc(>ry4O31_c7)@J!KfiekS1b_K?4s&aBth8&>{+~Oo= z#=xeTCcq-_V2XB9W=+xE3A@OohZUr?-D+7(V}K%3#yj|)XR!uSuVlcONR_ilx~e{} zKXw^Xbup$dUwQW((R1u`vWqI>z`n8{K!Vrduu|jfzv`U!{j#8&dVO@TxqHy^qAW$Y zlw2oUmEnv?S9pts3dfUUrVf%}4^|^vDijOfXpz+b>?u&_exChP=m6mfLLP8uZ2nhT zGEUfY2Fgk9c))@Ni~J2@Z)4qQ95s{3wfu;GipOSX{9~F-D|6Jinq4SfI>&@**>zNGTG1EEZ!Q(%h zZ-X<{(#bucwy#wdR7CR>DBJL?jCo&@ryWj~@wq7R;;$gY!J@lR(_oox*GUJd|?GBeFpu*l$V2gd8r`7x@liB(lEYXZbtjnUu5xxnFUhw5mLtmbW48GpX*@dt3PVKTuxvky$>~KOFO>T zxifE>^E=7`GnI|)rKFthe z&i?aTf4LS_7M1)i7sJPPLz%R3C+{304u|-Rq>oPRbay}g)aEt=_x=)-NS2&c9A-@{ ziadMUk>FS&Tt#Qa-$i5lE}?qz!b6HbONwmo5btl$jJIC8nPRAo15pxTD-I#O?L7!>bD^Q9anzM%`Lw(RSXY`KPP!r?nn9$_g5d( z$ZXZLkN3v< zcQS(xS^6A}y=F{Yrw6Y-&Rwa^zVC04e+Y_HCNo>T0h(vr@#Z|W)_3miijbqQPc4I% zSZ6!Om>MRA+#znAW`n3%`+L&e(snH0bTIMe5Wh{@#mqE8<+I_ny7N=E;Je?ksfm&H zs9Iy0o}^VGbp@eV_Y5{RqFU9hHPFg7_WV?RCM$$9wGkeCY(MQUIo^@u7-=M$YwW`a zqEMU}VLV(lx{dA*qr5!2^7oH|Ax~y&UJ76PA`D%TE)vzh-csFu^MC^^cFNr3m>b@z zhccg4ef@}`5ogzB zMm)4{PZ{;d5?uIQZzT13Bvo(+sm+6HQ)LCW1}g>}a67eD7EK4r$RUQ4^1hy}q)ALKE-ziR;}4=6hM4aPiE*JdsvuNcPbSmwNsioBeyyjFGJoKIJlM#2 z`CaP13DK4ekZlIkcd)5%k_h0`tt>y*&0>S+sYA?|i{g;Wa}~+fADG^n%o}Qo7akXB zBo6U;TeQX2S<#<1rsJPBCOFAPYq1dZv6sE!;j#c#f4g(W!f!Kt$^j@NzMI2pP8-?m zYNkhIv_KVm_=?k4hr{xY;3=2wua!?+=dBWQ`qiZ`LnTvu98$vJ-7=C{l!0wp=%BKl z=7k=&w_AqAT70eUl1Fz+VuE%FMDRfzwe}o!hYO=h+7EsC_K!nXI{N0& zmhvOMh&R@@g*_Yfq}kluY_$xRr`Rbz%t`cj)uD7Xs_3iKrrN#oLlw_S2(}`*dkw?5 zx{bTvE>DDsamhB{F7nGUD)Jp}pYr(`5GiKr$zDdc%$XKMHYMf>%*8CaKsHpay;)h^ zkh4!ip_&UbVjH!0??1PXeuHdp&l%Vie=a^|Zam+|F{~&};G)D74gOGO&BnS1Gz4I@ zL!@P#u?BG=t3I!YW6iZ<1XFgY7fMiPq^{cW`_*SZ_}$)l@;#;9NldCbgk4L z``K8PyYXy0D~RASjyQd#*StgD3hL@{uU?7e&P`*Yo__Q(W{EtBUSCrNELvzjrq6Cm zsQ*h8U#hzm$?*2rM!9{M$gqL#IM!WvA+{ zYX>y?U7)bIGdx%5dv@K`gS!>z<)b_KHL61vYrX^PR|PKmV&K}*?x3>r+jq(s@9(o0 zjjqgJW5#t;ta9`{$sFZ+V_xBIUXC2k$apMnA2nN;X1!R@)?}L6+^*@YRJ-6+{|)*i znNLTwV~1sxTUSB{iF=DO()_j?+fAJJ+UVs&aV0e_Os(@#vSrOgK=|dCIH+(3f5m}- z4;BzjTD{tf3r|2}=!p)!i@vZzE+-v%09*mS!h4Dy~_f2A?msE`HbxE+nyG=2v zQgrj7?A#D?Ziy?b2rq976GY%Ot$aMfzXbH{l%4}C{i|&-iaowk!ptF0A75WA;JUys z=24!@9Fwhiz6W;_>dwkHG5>W1%ANXw-&3<;Dj39W^l&c;Pb<4ow2MBvrjM*34Z^?D zr`Sk{1W)uPj3>FAmS8zcT<*|CTU6^YX~orEuGWA91FjdSmu|)aa#ch7m-#0SFHC%1 zd-UJbcxZaMEK54gpSIRaq~B>&s4{&{0BTY@kS|=^*}3#w!2Lzxr#;z9B6UEI;AYGQQg^}&cY7u?I5Bf=ct`}8s@Zni`>2|> zk9K$_hR50R35HK6_eyjfF$NJ4+D`HDtuMU__#EeVp6ShFSMjFvc+7UVk57SQ8B1dA zD20`+5#Qb_$|UY9+j>9OO#2ei7yNCC>_ASUAGzlNS^ee=fw0U*c8q&5HS?{RDskc_ z+|urFMINziYYQAd=Bupg` zka)Ey-G5c}S*TKMr!h(&G$qY_h0s_dYfSh1v{v)}sk_dy14JOSq`|H`ry&UoJ6=iY3XGDR93d2)phf6BG`e^gCnX~Ns5EoM#$rkrB5Tv!jy|pEghIX&; zM*M+HlJkBj4EL~yl&Tq_O7mh40kZZ4JK1$iDBTu>$Vo-M5S_j-kFPnmKr@CmtA27P zdFVRiqEGp!N8a=jBqEc)5XK1@hnMZ* z^qLD)whma5OG962{o^uiV&RnBr()(1G9)3=%NmP^X_Gh4xsBj^t=Z8CVQp{U!47-Y zc~jwd?MJ7|eWFE`RU^ZrPs+Puh`?6+XK&Ih}uJ=1oNpBngzaoO?-3bj)K@#85Zi~2QT z1}o$!q*F+l;AJHJ6q6l5_!$PV22wxD4R1n2=#BRrmWL&!_x#t}bSp6F-qB)HH~zJ)EbuE z>CEP!1key}UXMKA)Z@)F&o^a{<%d|yHOS2j>!#WO0UOOKQz?mb_PEaH77}&V^22pb zN3sggy##&R_r>E+CS3|)vm6%By9cEX*1bhC&liAEAks67d^N&n8zQoUEKbxgLOd3;NbtVN0 zu-H|ee~O!$<6jyIUq$<1Zu=e>ysQvSNhc2<@RGJ1eXcu^#Xj>vzP`n+V#PGpf;P>W zE7l6lPMlmnEsLa9W%18waxiWfQA|91e!&1;G0$e;jNvCh0c7SG^yjok-%Tw>DpkV3 zptWEziuPMYP>GJ67^!G+iml9~PZ~3ma=%rYQEmKEUU!gOe$Za>ZcPSx;=yE!l!|IM zadZsfAo&#jq=UsJh(s>@X-~I0BrhK6!$j-?G>gl1(PJv$|GI8PzggqqjQL;i{z>Xc z(sP(P+QRuAC+EZE6tCI-yR-ovHrDtSJK1~c zk9u+~V*l@T$P7JgH$8__GNT&US&>l@2e8inxNvM)9Tm?saOtweqMc&&uzI8*;vq)f zj$($%SAu*ae#8BqB`~ryz{rv>vUWPJG;ar3{O={kC(nb6PK0_aS40api{sGd(xROSh^O3rbf8 zt4M&^_Y%ShpGW#Ag0CKIwn-M0f`S27maeeQjLO%fZ1RY-0lT>8|6PfyG!B`xBPgYMf#a&^%#_DtgIW8g$V8z3C%XqQHZ-P_MtKvF4Cvf$$W7y5tFS-48 zNwno<(9A2S#*&kwNl+U=NpngS4{lenY=~rOn4HZAKv?Jh+GkYrQw9Tz>O*Z)} z-TZ;7_AxDyLT$12+W^Of#iN5&e@E5(alLQG6Z0*;GzwVNs6-sN-@n~44)`%dfVxo{ z_Yv^jOBu?Du2Lj>$0}wumtdJBhEwAI_`Cz@#okrA@;69(&dWp9LjFb9^Gl)03gb`m zBCM88ER&MXE(+F1O5sySON*C0w6zI3!=po_9>>O&B0?8X0*?I3RLlb0PUNs9{9zKnZdLNbc8{`fuXy_6b*ALf%Rf(StUg?B19dEFaX z6!-O`{PEUV!UtUX>EmLtzD{M-R`bxX4@rD1L4fJfIj7-}jN|}MhJF8ss?AvxLzAp; zrn?-M)}T?n=s=d*$Ov?@XvfERcuhjGa5$v=JzrSvW%7xx&_N9&r9}Pq059ME*OM|R-*_#L3RMCDi{))&i{fL=4L7QW8FfGyfGRRuSua$ee!0zcFoq?uSecDX1 z!+cms_S{F>Oo<~#*58*F(bS~V#2B2P_(uILw7>;b_PRDBUF}aX@I6($=lqRv0Y5k~VvsmO-l;CU*2>Drj!*?3;B+ zBWXCRT7XU4riXBbxI}Ek}Q7xgJ_@)$GeXFDU z1EL|7dGg<#_}^`7EYcEkE7^9tH=dg@hbDtY%8AY|nQd+K8ss593@V*;CB7ZmmP0Y` zh2Zi1%fr$cARG7gBhVz~dRG_Ke;DdepfMq2TdNi}_NzSxF^l?uIJMr^b5Trsw7Mr6 za|O0`xER)V{HV;5k%V;%`h;>M>Ng;mo2@T4Zfoc2 zLgKzIlh9j`b^s!mo|+#DDr0BA?}e?INn8m}{FrId@|?+a&f?d+Z0Rv2f3Mi~&Vrx!MNQ77>c*mYh|}ECXdS z2S`2Rro6v3QA8DqdKppB)k*2>r!KdCRK@4UEWP#XN=S@hwmTIHy9kvwR&Wn(le>iI z9v&GawenWyA;raG$dc>@Q|5a_hX1=;2 zohgVL4Bvb|)Bi(2UmA+e@# zZ@S}FS@Fs*)w-Q?dZ)~C1T{dzu_q?J-KT_-1o@qa;}lcxdkY)HS3wf<0&70jJn8j1 zj%F=Dw~auDSf#GMhHBb3nn}V`nXQQ2IjN6aKZo?$O^oH0xitU7hL=GYFc9ZjTF<>6 zY3db9)-rE*pbdrfur&^Lqhy%RjS#a|lwXldOJhU>gT~Bt*Sj6$)OO zuf3`+ZLuoIwyQizbEuvrG{cvI+vn2v()$8nr@O1$VmTuI0G81{Z{bji}J7pXD^ z;T8HwPaTM^iX_c=H#zCRPctS`3xqdE;!D}YO?0mJdoay7fFypFUkiCCvaR}IFcBUn zXUt^Iz2$fS7>~g4n_s71IP(ITuZxyZ{P6Ba+1z$z?oA?%s^Q!VswNf;@xIY@-T$tJ z9{gt3Rnvdhg5TB%rrhKAKdZS#s|88EV}0rK&)-##zXir$r7vM_kzwj$xn}Y4o)=Y4 z=T04L=$rKMH)?~M=bbRsqs+J^d5hZ zzP$qzgP@}m1<;GbqUowO5$v{ZpmAj2R?L@$a%w}1DE^Q6tu=`mJZ=3~f@x8E8D;h$ zPo$r-`@g67n1M{~F49ZW*}&pYx<1N9<-&7tU9+uokmCqnH6F_-x-y=5J^~Q>u@ey)-ryIVJzNZGx<&M?_eSyK~0hv@iN(g=sqSsm_e;NM=Y5+qikQsBKWAq^ypOh zVA9Xn&JRY3G~*6ry^q99yQ-bA?NAf5{kXA&OB&J?i>34T^e&}|Nf}SU@)FA^VJO$N zL#+5>9NvM(mtkgkb3zkA(R$6DRcFLP937a}ncUo<;+r$rl+Fif3#ElPkERB zKfF%a@H}?R+3mfWNhhY)q}vz)zRqlL#|9m|>{%gq}*k2SugXN}wR&qrWBk*r6e#v3Vu$}?p&*5fb|+0!JK%}I~s_+1lY;<`?m zKhCAr8~hFWKAJlz`pLkzk4%PS*pc78S4IIcA7}FVy|;O>_1dwQA?eTQ96)KEXSSF5uv%6*OSmA!k#U&AmK-Ul=5`AcJuEA}e0?Ju7ZXY=^(4&0 zlpj7Srbjk-c~)2(8sbHT?g9Uz$chZ|S#H&SUzGk^Nt@G!B&fz_RoLb^T|)(}PbgclQXd|<%`4?@EjeSH|L_Q3LFl7u&AIFw@L^J~AK8dQZm@7e)L%-)*P(^Qh<$?z9{wRv%t5 zs-2i?H!>?7$$nUyt-Yg98?2p!+6{~|EW<-RMqDV7~;=>(z}ZY%ka2E z!jGbJ6=jENHz3&#V_C6~(%d3fZvBa^)-QGQwK@8lFKZR_+^k8x&OnzdOyLj;3I~KN zZF1hN(8zi2%+lqIF8IL&r z_!INXw2~w9GAZgFY(zBHa9wEj8+MeGv@T&cw|>9PAtLc0fbhwC@07LD4gq6B+j>c( zd9ox;ZHo_sKJ|5!`E($K6Py&~@T2;Si%=!N&?hZ1;_;KCZZO5|hZFnoZT1IvYF@)Y zqnW*G*q=^oR;khU1Jz~a!QzeR!oN<5qz0d|684Q=j~{iKg%TGyl3g10*}{AgbDmP1 z;J3;u9oH{AESSf})MI|X${8=^>zJ#iFw82;?5*-R6@p$yhT&|w|RZW`KFTf6(V}qY#le!1F!F0qsb=PJccza+f3bIvIDn; zbRu9%rPgxA(bunB0`6BxloV>Ntahw%C;WWe(9J)r3i%vkYo^4&gqt9$Z^?_zHUS%N z_-c1rSp0g8UUm^)dwM_Ho6oSila?(gW_go~`W}?2CBEvEX2t)9pfxJe5w z^nOLfWD+u*`y&Z#h$hMczA~6WjN@P>puW4(dKp)9t=gp>`la4*RZ7zK>+mXjfqS2U z%nNhRm|3l>ge#Tb6qiv6*Rr8ZSsD}#o0_uEcHSyq`$Ze=L?8KfIy=>@37sTrF?^Zf7wn|#VNR_AQ`95TU#MQU58#{i#D54`H{{}@o z7&2Md@V*hvs7NVLPma=Mr)r^NVQ&Wx`Z57A^sn{5L6FM{)<5!uIa~foy%w9=+CM(M zO+=r@{6t5a8XA|re;-Bmt<(iEBj|R;k-dK>@u;# z>k=eP_=cJW>q`4e%7eb|Hh1(kc?{k|y_0pE=o{V4VJT;l8Iz-=AwiCSP;m%x&*b$R z8()nMZB`v&Lw_C8=0}V>nF4J|c5-qnWM<+L@(F$-e!k3nDca$>*I}*_Fqvt(L!DMcPL`{K_rvJ z($tSXGyAAd`6dx{3$h!?J`3!*O}kxi?sC@j>Rh~~U<#wDC1*8xr4vSVSoqypR&uOa zZPwk4Ow9Y*a|^TjC8(qdnt`cklTNA^30@>sJZv=rzx@B^pRQpU`9b>0P^_eXOBfqw z>F6H%!z5nUP>J&mnF!^@@@B( zQ1%^{cbD3b$SNOF{wDC7zVvSSH}w3%@9SDfR#xh|V}fklQIw0ffzKcK$84^f3nx3j z`QRv#JQXR?=!tWQZ_m%{6*4Cc%O+pRS?^s4(_6W$?EVOj-;6q8x8&}(|5LXfzFW~h zrdNaC8RkVXf@!AslXK67q`Z)FLXt9S&d7v0G>YLfB5xVpj5Fcz?Tk%&Pdsg?U`=jq z1GU(ng!$8*6ma|MZp9RLSiFo$sgm(1!ob{UoQ1oJsZm#1Oo~@hs{86R)i7nB_Qv(%8G9^c(Yw!f!pV6qZU;lOOq92 zrL{2w;%wXU;!of^b|Dhh%$Z@-%L_H3Hz(poZU>0<_p8o&GFgI!dHtGCnIi}PRPfO` zA<3tt%9uE=WV(7JenOM8ymS2xOIUUC){A)uWQAusjUH8( z!n;=K&##0EUrXD5Jnx%To4<%J!}lY(=d>TZKzTW~v_|>E+fy7*jCQV(_E2=n+isMa z@*eevqk+Fc;a)7h%*e2g*3f#gfHu`@fvwac+6H)}KvitwK7~cokgP0xdC_DO>v?)y zF#ej5GUv&h%h(MxY_su=X`vNM`lp(4=N@v5DuabWdDSJy=9f;oOJ+(r$$k^C)VzF9 z%6`}{v{t(7F}xS@&p7l0<6z$?y;_CdT?zPdNpjC6XH-^`8~rmg;8EvO2&=X+>tn<(6u&7|>5#NBv2qngF%XJRGn;6%+*YNItX_)DlLuPuP z4;>WedKUxDq%TxQJg+>{>Ah0bA#;p*nQMQt2YD zE?Xx~5og4I&-#-0uA^s>sfg2GMzYD1uJjw8&wuTDXWlvUobm!C>^y)hFDSV={B`|& zqcpc88uHk+d17Yi6Sf4=>!ZLwX69&LyZuIV+3ueiC%fAG@fQZ#xIg|j%f;Er- zs(+gO2@Bk7uh;z=#@sk?1TL#DBpPKets5y}T;(3F2rMpbO>z4&q%fPkR>8;vO&V=; z%6;jcsId4J!`g~((hu1dzaB{fO-;r9i{1WkS+r}SbwQcz#1jvKK~AKP?y{zz8vlIL zxp8Fxp?4HOO`M|0DnNAU4p=qB*I#EwU=fcP*Z^1evbA}HeF~(i%;nr@p!%^`d--%Z zNyLJqiey09&;F43Q|^w^;-Qb1-@A2i;!su57&}8(cRme+FI*3MeVm96z9zFMu7|{+ zTxm-MLz!*+S%39A1!&W`K0oZ0zIC?Khdtb-fL5I#D6TJAS^VBRK4;ST8`NPEPQ5P2 zZBZe)V?}}1sVwU;K+2S+ENs z`mws5QsMna{D%Lv-V4?cCLOxD#VViq`>*bCEW>=&BY0r8V%3sYryCKapiK3lm-l<_ zh@4U7`vWeMlsWvT_(wljFMmdMwFWaZ`AOC6&QxCRhTi?N@N`}|eAX3n$5%A}Z;&Mo zQvS`BNu&{L>c+H=g+B$;=UU`TV~s;ugktyOt|9XE-^O4iUnO_9C9sil$04)wq2&3j zZ%RY$5rd7C*=lsN&nemKiQl5|Cn^Kg-|U~^4s6&^4o5E7N@o3`u;~5|$C;Wg4>>#g z>`5wSnZc)N0-!N^4qC~se*Dcv;?pDM0y@IJQ&qh)7kGis52&pXOyjZrjVM6y*c}y< z+Y58pinf2-HxoW}wH8*?Fj%U_nlY&Tz`wy>GQKy)3wEewDiDvZJq0bhao19-*TlV$P@zbJ;8+D8`Vv0TFUSBjzML}zk(!&?I6!&GsL>&L5D znoXVl)fRRuDQZK_ozn+VUIKw`#pHH$oj7dTS_niNuWRt5Bdx}s;og##f3rb)N$U6O3YN^^1b!iA!qih^k!$91vuM&cpX~jV?!_#rK%g<)6}`Cb$T?7JD$XWS_y@z+qR%~b-l0i@s{JLv=`d4mm#b9slG=YKM%D<9_C2r z1RsLEm~n6k?IE8J;s|q6R@`XJRM?qkEd3~$P;+KhBKNBQbxeMz7Cwh>skMvcQcj10 zgxxghkp9Q2aqr9lw4&dipK?L*@X~c*HEiZUMpcxytm<~9M4V{zS7tnwSayvUU+XaX z5fdNamAxPj3`Qbuf(m%#kPoi!_hR|6gGDO)@bC$pQuh9y_%jk35#N{?5-7mt%oKVu zs*M=dX)Ln0a`Kuj)n;|z`7~gA-$d{cy6G%KKtqX`Tn2^4D|#lLQctT;=h>MQV^>CTL->(@EHc*|?eFSK zvt-%3>V5rgP(L626sq4axso2yV1|`UU$KhLR zxgVHJC##u$ct}7WcU0!|4(mg%&efdc1qcsPTF22f|F1UJD*`Q`@s$Wqt17 zJ!u!ropeBNvi&VK4va0;o&UB~^sPJU>1wgAz5HW-(XU2@nxr#FgA0_i235O#LtmnP z2v6q4%^viN;o9s zInMZkZ9R#lrUe|rd)$ysUqd8TA`s&5JU%_jz{%0+e8`STQ)TCga~8Pt6z`7x^E37- zhHS%DGdQOp+r2^QpN@fLH6-E3GE|Pgi35&GFHBg zoXMNEDDl1h)teKKP=+d{;3R14SlNPQpREl~_6&?OWMqb|E?drLm25zFvSv8QD`v+u zwZdiOvbh~tD8_xY9E!8QL@FAOIg{H9)m<(;)?+$sj`!`$k_a3nNxA3NCe#2_q}Oy0 znU%*6o`^|Li8ts~-HYJrR%e4(sQJI^LYgj&Az2Uc_gu>|!5?{HbQWuDPbOo=QY88T zLL#{h1$+-?s8<=_I4Kj_1Nv5;`|!?x6cv8>|K@f4+M1_abVZxGjlr$V)2GNjt@l;l zwjci|Jb%IXHQaZe4M5-eYB1?e|ar@+?sufa>@!{<{UnIDG_s>=YZSb;-#13x ztccLDPbtihA@^tNFOG2c^eya9ZsqVezA{_FTpw5hJ(DESEJ`RVBTca{SCS1b07yka`W z8K}!cMaK}PY!T&;5=Z2~HK?iJF_t-Dk6B52?A&){L!S4AuEZ62_1kS1pEtE_p3AE7 zRb^OY?)rTvS;HN8?fR}knojTic)7T7=U39{mMIg+L^8;>Py)d4;iHi>cP6y-BzybyTYP5gZ9_|8+&gV)K(Oz z?LujR7KauOQlPZO9fB7xR%nY$aVSoLTY=&Z#oeKJa0w2<9fG?%f#A@abANnu=G^&g z=9@cnXYS9Pz4Bx4*~zTE*84usFW0<(Xr2GiWR9I4f*Ag0#2ru> znIYul>~@dVMR2)Ko5g7<(rRu3MLIKmh7}iJ5j>9dGOk?LK!?e5LuGPMZ9(Q#Lm^B#1n9;#!3G&F##Xv!kk-~% zj6$VKn|s(y?ZJ`OW^|nK71DR;x7uXyv+&ZP2Gs*bh_8#V-JAx-@{EzF9zYRha>|lu zn@E7(Sc~2|w7kX=X`1qb$!5dV0ynyP`6+lmFc{^^Y960$7&~~#-us9TVo}AD)^FRV zKu;7@Orh{ezYY@kL4XneYc+deHw!z+X_-BtAI5eey}?8wWIJj~%EWGA`X9M)^mogf zooJ_hHOLczV%vdqVzu^|(Ao3Jjd|_OaC*~f;13-=EVg<%z+Dubhb7i$nbDu%UA3*0 z*wVy7(~|j2#q)<=i$qQs!$8GOqxg@$m6cy;{s8Dve2DQ~^RSlgbzY607O?=AiqJBL z?eT9pl2i=X)Ig>0q_@GOE9?hHE%$WA7J___mU(z{sc+6Yj}0Tuo!4JB9s3&Ru$mW; zW^UQx;~hhJoYZ(q6i2NK^O)@r%j^XGZOkmJoQBBX%~R}M??wEsWF^)QO<*($**IlX zG64lg{&KEf>$TBtyo-XeCJ&9U&ZUsleD!OEI~SKp0za6(>dTplOCO55QSB7*_?VTT z`(}OeIi^ zAaH1_2+q-Gw(#Kcz3>Wuk&1*Be@oLdip$%pZ@CDKW_%)BtRIL72pF6io)EBXmXm&$ zhy{v3xyhizZgi11AbGvJS5V!%sXSYo8pmhL6gChAtPgp&?o^!iGwnCfBtf@1{Mjp4 z;~NW&EAfJIcujPjj?32R5cStaCr?^)xjt01feqx9b>hDe9yz91F{ZM$eoNhvIQube z&E~5gs{j?ta9*tM^vOHXR;!B5y?S>{3v2Bb<3=bTRcJ@6Pc3s5-6d}`=z|uTQycPqUDL5L$k>7YiaScRn91tQ*iV5Wd#)6C-&Q_k>{*!|35{La4>)rilO})bg zE`5$O%U{J7uuAlv92eMV84vWRC5df#pt}ayx9A=X(FYg_V4I?JKJ6K$gnX?z-bjtQ zr^@+=e!%6EYd(OFjHFILJ&f0C%W#^otxh}3wwhOg$+FERc^l}qJxD4PaP79eIZYEj z^KPahtJfzG_sV+YXOn00#U&ic?!YK?JnLuIDZKLVniTv!)8E`Ip(S~`ER8*nQ$#WOk#7&?kCJbdq{dY2UEj$Jjm;a|%(bmjvM*4JRD15VgtAww z44vrnY9N|y)TUtF3;Rh3g)9z_cIkVbX4*eN=C*NlIlE7RLfMI&uy$m z9G(V$Zq7Jn*y_^Icc~AkH5rSngLXVUmEv?UKmSI-(4WUh0NI6+EJjH%XPa9JUwN`U z6I?a&+!|W%LKld^uA{$fE8kKs`iM*Sf_~mXKoP%I2G6=pBsVII6|kLss`~`itdre~ zkd>pIxwVE@Pe~sIdf0P648%r)=D_y08?;(vF4_{~$>!LY0BTH2p} zZmVU!-}nhP z4I5pSJ>{8Knc*8C7u0$RX$5oLvfqRdnx0?b2-OP{w#2(Pn8d3l##XNtFMpqcD$k03 z_|vgn`Lzq?2|#VMfGd-e`n$#vdb5PHN0|(8pEi-e2xok>Y46UozK2?S8~e1Kl+Fsr zDM^>hmsj_dWn*iZS6nISpv>DAJWEKIQl^;S?zKCOs8`@%^U%7gn)5qx&fSIX?{%Ip zn+ zWACOxdOM9&iOe)PMMWx?`8h;~I9Um=w(Fb@X@TO`Zq9gw3k4=x6T%X+Z-3T}W<*v^ z8snX9thb|io=!M=h*BgTl#S_c3=?ha&mF!>q0LNuNgtD{{9{qMH zda1VuwaX4Nc>Pybm~m76EfTa68_v5kra9AE^VK&)WVYXOOy~`SHp|(HhuW$L2qmXl zGi+P1=AEL_ZWN~*BQ##cq+$L;oAMA_RsF2X`b(K{G_p(If$&H8|VjR z`_uVU8tHndFCNvskkY#~$!EfqLPzbBz2+X2DWbn?hNVMsL{V=QF)A`o;?)$QZNbG+ ziD&QCPE(3=ayjG_zu;Y_Xg?dcyq|v4Q!PCy~gzZm%}iziTs-MSy~y z0)xpCf)toyiqhm%~N^!e1Mb)*B$DV~K)@6l?hgUt4(z_ky=> z-zl5tBeR4T^!k)v6FZp-Bk|A(wod!PaJmu6eGa>^#Vczs(o*tbn00BqZqJ|)Br`-4 zvhB)Gdy_AZZSi#4kc0eAl|g8$Q)2=y6`FG|hO-*EhglgI+$y-9%;pIr83k_^W;JiT z!A4g$;45&e*r~1c@p^SKs>E z-oltancPJ-T0J>E&Q63=o1<@-m{dvn)S`(gz>Bu+xdn$d^O<^$H}U`}3|#vN(dt^k zOgRrw=!BR&!W&%b+)Tz~$R+s?jZeh}OfiqJi|ic%+qgPU^mOLp=f|foMxI+ZV`}48pTlqb9I+qU{%ryQmd+t04L^lWg+SOz*b6wa&3{Nm>&mq3aWIJ${|6hAd30 z;!5j9b!{sV-8IHZF~jIozaFZt(ku~Ln?gW zdaWGrOuPtZWv9PuIMy*8PNf7tAZZ6i_ zQy>Cs(ej|C z#>Y1wvZ&aj^gCNQHB8T~k>yHd8m8x0**}&_PoKizl@$%@EXwjGNIV_l>NVrLSien$#YFbxRRttk3ACr>I93vy-k0K?y zp@PH90C%yc!HoDlf{fB>qPpq712KJ_vUxcXQ5}&L^Ye(8phw9$Jo}8mc;*S7h14=+ z+NbmcliC~AnGYmwM6Ws68TzdipH|l~rYzYFc~scp$Tf>$01WB3#JuGF_z8(ZA|!<{ zhkd2kcp|_Gy$5)FRJIF+;xL8IevpT6{2i~ju5H&O6}swcm#$Vi7{?k+VY?#}IkSbBI^m7sWn{j|;s#AB?p=e$SM9HJT`~Vl$8D zn$;FwcQu(H_NhhJ7yS!+|&Da-_aLPcw*62+T1dv5Tzo zbi%5Ot zGMHw3D;NCIHMC7b6?+6m*Y!_s<-Cuo$+=}SEsOMAPZbm@(YoH{i(T!(a zlJ1mG0yQuxi>`@FW+ijZr2Qf9OTcULSIICJMt8?JGLa;dE+u~Kv>oTGxTLuZOYBU; zH?QHU&(_MuavdEeb91=*ZgO)2#(hNZBog#Rpk3xwf^qZXMm68fX>` zsVd587Rr2k2Q6OiJw0hq7B_ZXtN2Qa#bP&WoaRX6G|(GgqJ5!A$D3w!8q=(C)tH!s zd5d~-d3jYDxWF3TDdNe*8@a#QX?D4y9eqlB834V#S&~d6^xdPWa*;tlFBgN_?!?XFk!9l6Eq8VJtCp%`I}{c3B2w4wi|tgG3&r4Q zneD3hHKxR6ictYte&8p{j;TBQIC znUhL2San;NS!e_dr+Wbf4Id(1t)o8WwT!Zbf91WP+w3kGL@iqYwToXd=2fC8na2a>q z?Q40A?>F|4V+pGKKi*X!|+iD9OQI`&beUvIz}*vnB8< z9o{Kc$sS)S9Ou}l1 zUq`$#7}d+1?9dIKKzuU<#_{$Ue;)nfy<5OMX*)5 zdCZkebq3|i{sE21!s?1@bO`)P;fnqI8aX*aqX=NgvvECuP7;$-@hCWANfU^9-F&m~ z5=*s9s|&7U(9clfI`xVjuxR24SkJFY+_N8T-FxwNS9-}+BfZ>WvsZIFyKP7G zw;;bDt}*{FzV#Du+G^Y>5~tS1dO&zB$}Nl47aHltZjTU6j6jOm91eKs=mh#DcgK(p z2l(N6>g*6zK6kQudeu1&<>656>>Apr5hO;`8eA)U@G6ueX~&zVixJpem0+`7Nf=!m zYY@Dxo|p?$^zf^6H$Cs4lUDX@^j{{Y)X(-=*~Z+=Z!W~WdZ$!Y*9XjL;`>>VX*B3a z9TlC~-VK?`+1j@U*yS0r0L%&Z(gr0A_&cw~&7`_7@tPAjx%5)&+NaLh^F-JaHkdzO zS5GQuV~Odhbix;XKHm#S>Sx;2)+|Mi3^Wy@>X(stBDX?k?bn|+`#A8JTFfu~5r>z$ z^V?31r@mcF0f$dwhw#yFr)}*cQY$9zVq$As6W0v$AQ%|mC{Dw1;A=gD;04QWNHA?8 zXv-yaqP$I$|C5(ZByHpLX0sH1rqFw(N&bexMky!D{2)bw8v7k)>+WA5-y&+R42*1) zK@05=uR$LP_(`EFqHHCSD{@^~5B*#`b7&KhBK)UtzpXO|pujG;Vs#XbPe-u0J#4wP z4z-MC(yp40e5D#8g=_B>6%!;h`aH{Nb1O>~__Xro5kVxxh6()g;n@5Gf2iiK>ydEmdo6+-sC6Is=L;Tx#Hoci&dOKAEl* zA$$Id|7C>&LwbL&iv&e)KAKzI>KE}1AkAl-WB0WhTBp{D;gLfWflJ^a@R#X%ArD>u zw>8?1meyq~?}m26u8J@FEZ{a;(`LJRbq(#h*XP|RbvUaN|WyP0!#=(@yjIS z`C(E~HEQ|sZ{5t>qHb4YLMna;55--;0Z_HrS2fE%5$X*U{jPD{y>y(;U?tAQ;CW(!_0}IZB-=@??;r8Lhm!#~Ic}nB0m~26n?7~?tibQUVUz4AS<#~(XisY^ zVqTWnBfk#<>^VH&#O2(1r+%la<7wHDPo8)G1rwK2rN&?I4p(hn4xbK}3LL!SN|8Jv zV5O;e68{)&n8(Ap0HNX+nE*&Q)g9t1nWQXi%%!89_uj>?+EJb5n2J%E;)}hSv7b)a z$y=*oJO;Tp>nf!>p<(SiSSMrMNM1|2p)`#x6zPc{h1pIwO(L(-^)M*L5`(S}FkE~V zX`O`{J~Z~cD1MptM4C@)cz*qDXqhaofoXThrJ5APX=;kQO_5Sv7fICAJ&sO=P2(b$ z^*sd&Fj`iQ7@|PU009`d`0l1OS{v)8xw!5fIXTeusiFQW%l~CYPc`X$U)5(O`ooj7`y-NWPq&mJWshSg~$f#n_O4 zFvWb%MLjp!MgjFFJ4(=%Vng0~PKD$7-mAdm{Y8F{w8FxoH`tsv?uC#WbT&`{YHEkV z7j0%Ux+9ScM{P(=m4#8H%B$g{|Cs-+CTf{8jmBB@nHX)tLh-D#4t#rW5X8;aB?o4E z+BG~c$TXmAWTB?iw2T(geW%omxA*>{NrO;T1DN{OxsB!m` z{KfUzUpKqZp^2XLjPx4aw?`^~X~xi~7>HCo*=Nd$h9peu;Ao?ECNmhg_o&@o=)D(y z8lT16gBqbTpe%U=AsM02GxAwsX}9@H`WF{SR#*3`hew0Mx=9W!@PDgK!b0`d6StoVSkO_~5rw|H+o)Sqp4{{US^9ahYm;1`| zn%T#fB)M#5y>!{%+~%3_9|2SQC7}r5z5VjHs^u5_az#&?#WsdTteV_kzni%y<6;iS z|I~nHXMPe&#~kzD^|*P@T7F1PpNO5ocpRrYlC5LpcszQARb9zolrusX0CmUkJgb8s z|9n9*5@h=H5$k=M^)1&P{_}Z=NP5|R0RO}i&;7joSf>Ay+SdQ^mHsBCv3x-_3!|wx zGw&yQ>T14n8sEaH!?gy42E4eL8Ia!oCXK&b<~8q=#{0Kxlp~7N{S|Eka+fO%_u-tPC{pC;*hwYN$PB8yC@;&A=m^INioP@VeA~b-jk-(>G~-rhKBG zxHfhrlifUWcbk})?Y}QCBz@4<<73T73#mX^d(n{_SHT2NuPWLLC?ab-1yDqAw-K#M zR=kwqDDJ0CKTJ(te52oZ_NKgq{GY4V_GH4kCVIORbBlCU(!V`sA-c7BVYn)fFJ@?cF})8ts5y%ZsR9_>Iij zJ&$q%LyPHJp4STRF34h99_c!J>YWi2P@hAGmxb;$1hDw2$jfooJ77nC!C8~O!{3A~ zAgVo0BBY^wxrN`b_NzklLt>KX@76=4nZJBtTx;FYC-N(+ND=)ap^CHP;|e-|arze_ zD4^I8?WgbTn;?SlBjMfIi6Qg;bZY~Op^RdEF7&Nb_Y42^ zoo^$o#ThNXDB2kLLBz=^9duGttk62R-S}e@SKG-X1Cjn-$OiiChbx+vdmXGcX z;5x}NbJYt7Houd<;{Swgx7ZJ`m-y>Lwc)F_jR5t^(SEJ_%&I#!j0wN((>9@HjXaU3 z?pRT=max9sAyoYX2qk0eL~8cfTq_}ERq>dW=d(!c8C3G0y~HU~G%%jKF51JYBJ{ce ziv9L0Kgfjmsz31N%&gaLb!iW8$hxitF3aQV_Rd?&Eopj>}pZONbR|$8nROdT)oUkYz&}vGk+9O~~6LXY&w^ z8VqK)I3Yt8r@uhPbAQahn$OSx3Rhv(w0hCYzs!@am zkledQkJu{k=Q3wzot>%-eKGc7@RPbwPK=1e#=3@iQ-r(7r5B;&J} zGqg*P0S&ZKZS7sarnkD-4J5z+z|JYB>@=+dk2e z7~Uk>Y_v%c8w8dmn|#ydqq7lI{YV#}x}9HRmkx??rmpINM;`fa_BZ8Q8>G7a4UIZ9 zD5{I>64dM)uwV;`XMP<)ZG>-4KmB6N4WZ8v>L5nBs;VAP_%=MM1#b^0Hx)~fCPkRx zk9ZBerK3sR35MSjw%B2~^0{W|IaJyDWkP}4XkKg<#ZP&wR$fIGp-sKz)+6P6J<*Ju z>Z*VyEZ)XiluY)debxx z&HitL+D*W;5IbbxOHowO8&((ddeMn77V_I4iSJiz`1&MNQ^XSMehZ?K(>M6};gOdd zy|^b|*(MVpcHIM{_DqSWZnu?n8X_wGcYvLEBacjbY;Jk`8;?@vi2%Ixb=(XQd+z!l zS9-TOroZZiwDb0tSgk0<0PUt^U_E=`bB4cFRmj)w*p5|t`wvWBc{y>+0!XZ3vv=fA zmDJKkY1E;fP_%JDkMNB`Uo!D8{IZ)Y)GihT2H0giJFT4#aw8R}iJYf!@Bxij{5sZ8 z8Ks+{848-{Jy#1y^Vm$`I-_+P8UmXdZUH=hUhw1TSW#|~?oCm8WlEi&?N5z&!GCoF z1#&HMqnN*uVl2T$&=3j{wjCt5XHpQ3D3ET2G+qW?Lf`gH{H)!eIPQ`xH_psCi6L>b z+b|i;5J6}e0e4W9;}4FhOf6H4#nJc;InAX5R^6@E_a^i`$Tt4p-_0(Bp2<_2@Ag{? zH#+C!uKJ2$Tt2%UXl*>Q@w6Yz_VOIq;SwcN>7$|>Bvwd;b&8BeC+5Zt+qz~%5{RiT znymf|h3l({VQpym{^hJcuTfTAUp?ReN6;r`yhaG((|1HkSbH+!Sz8J(uq$hB$bH#r zPNP>3;*MweQXOUSa@zzixeOwpz+$kEJ|)#}X|3O175#P7Fytm}dKN+c7ozHqN|9-p zdbl-JH#(X*3a5#mKEfbav{L=<*utN0t0DaSqMr&Mm<~Cv8uE%wok-Ne8ARapoZWO! z*_B3TNp<=0xs-5PeJ9$$yhEj}3?cY!dfiu5haV*hJWN1t3_A^LIBarrcMO7`8J;yV zD^!Ya!c}+RHPV`~Wacqhnc)a7m)$cP5)Kq{qp9$0@YOxo zd;0+wNeoeqv6T_0*YXI#DB#A+`HxM$8>?St_x)l+P@r3jyh4=oF18W&S?k_Zk{P~T zv7>{(;Ldv^_DeYIf@+?@UG@svmS#0)nAP?Ud1X&O0Wb?nyb`OeGIDg?%&cakug~9P zlsiE1@(uNMM+X8MzRm3|HRx#m*pI_;2 z&fWUsaaw9pG;xiv>k8qSpMU>(PD?%tf2mO5f|!uzWKmD@au+turh&ELs=2E>iR7AX zc5DFg*Suc3HFl2>%UgQufPOy}Ai*$pGIvjSg5BhTRq2t&Jd31$NW&uHHSbetyIBKY(AJ)}huxWK$tBah*<|57x5N~T21LcZ| zJwg4sZr+H8&PQbaj6&a~(ov|RRqWybg9M&!c?P^n&h6oqoY7i@fDOMF~W)NkcTZp9XIXYqB z$S7hsXC*)R+qX$iEW6YExjv1;QQYv<-eG?wOEnk2FFeHDhXYBA9cW zZC1+jqH*o?WimoaYhFk+8rS7|3(a}5nU*bSz^4RX1K562uQNC$!|*auKo)L$WU@kz z!dF%pNMQAiYrjPMCI9@+M{}m1ZU9xq5R0mg>z~Pb56qMwj*1Sp@rBmttj@QkT~X<> z3iRNv=t&HMKcrAe)@N?A=)OL=O`-Rs+!b^j4^O%Y;jq8PPYqAeJS>||GWok-&Cmj3 z`6hyK8TR|krOBNgL)z0L8q$0jqlrLJ!2PQD68;gIQyHz^|Dq-dEOU^6&)G8{F8>xp7hj*BN09i|}E zYRE9fgqy2DzY!7ySh&Pc7f9ObAU!BG4w||3VVBXMFh>5dUQPEXWnMB5Z z>H<@aQ@#`r9BvNMJ6?RIi-c4VA!&d*j*=Qug_*+lkYlhDE*|OLjaY^{~l#E!C3JoORHp+s>@7F-T)kn-(CC#uRnF+wJ%C zD$a6}uT5*Jat_P8ZEmx@t*&6iC|+Ey?l$>$^T})!LdduIX+6=hePWVCR^<5&X5>(Qz?xrwE zLFyxUI=!b9Ir-es0-SGnYYl*GnMOODId44J4tn**nDwlyUuQ+EEtN)8^{(9Kf&(5p77;5r+sruw7ecz$UJ-a}t zI59m8iJJn1%6-T)=u@O#n8k8|4fo)Fry3@0IwPV(V6S52K>;c{|Im7pfu5y5HAGdH zY`wlmP*;7cW)1pu!oNVh<0OX0REg#eM58&tuTMbrjF$&C$h(DTo5m5EL*z%^ zVI$?Dl#q_@foidLP37Va*yDP6B_+sS_Pnq_)>#jOtTZBHoumkEMG?&`fp`3}Jslh5 zT^N+-J$u2#nv(miTlEMHP5~>!sK-|c1*yX`Jr48SyhlBf!c(b|G%#4!H>#ki!o{fO zg)07`#oj4wm;gbwYsDWc$7K1BipwyQ&9X`J_&=<;&oLTkDKA$OMm>UHhS4DxPce(6 z=ea}LueoJJ9m(z+P6()iEQMn9hr+w-(Sc~Q=Ayna`#&#_H4lU`Eb>nqN=!0eqEkHU z!`9V9-%3i6&0Tb}M8UqxFZ(PCK(ou97_lXGTDfmf4W(?I4$4RLY`6XMj62H)gHo>> zBhT}&=b_o8e7!Q?5Wulk2f@w@G zbSyBx#UPE*9S*q~^eZ>h&wK8)EB5iZuaa)WoO~1(5`iF+@=#v43U(=4O?JmzauOd?30|V+*%%ANL zvovi-jDzZ`c2LuK@P5lGAwrN9`=Gcv>SN`;LSSaE5QWk0mnRW(Yu$cdRhbs_D5k~L z-+<`XN2K;thFsB62@6SiwjnnBoYEs8FL95H`m9TJeMMS;?5BeSZ3BXm2(DUgQBS`; z(HZ7-UW#UMG!LKr*JkDErBmmh2I0nFnM7>@LU>&Vp-`mnt3V&xkon9QA8t%%s=~U7 z#2`~0g1}9d-i-RL4YYYz2O^4fm|?N~`cPSKrob;X%#K$Ua-4U9vB(?oZ264l1* z-U;76A}^fMp`2o+@={$4wOiQ95H{(M1O?*+2cC;jD`PK{gy*qVbZDuTP|^_m%H8qs zsMlrI*lux+_<{yduKC&8BU)*lIpWR+lEi||9kE;dir?UiDo8gsAKY{^wQP$pe#snq ziK6W0c_s_XZ1?@xZ47>fZaM3BF{y=LH|8ZeCZ_WDpw--Ya*Jhi(E&(AZkqy7R~Gdf zUGhQ~M!*TZ{IU;3Wh{!$&*A+RS)VZL-PZj*CEJxCf_#xyVo~gQ2kZvvl2Bw2+-hBbD|73z%| zB5LTO`Wc4})^r)P)-PLX>Kl~V&b*eZNs!@fgxVoR*lgZr-ULHDL)brlLjSP`KrZj|Sbi6T(~M1pZ656(hEx(#hQ$L>>J z_x>tLBEw@dv2Y?2^Wo7&B4tMWBl(q{$O;UtYoXSm4EinfDSqmg49Z{6T!D#C3RfWC z(uv3_^~8LK@AMFF<{c^qFj(;l8%W=(=oROGsP1L%iuWd2iL4s_)+U0fHHC#B2JHPw zIizP8aa#>G1tl}Ed%MJGiBfHOxY3Y_-+Ve+rvKfm7r^#L^~!_!^Wt|#69&jUHrrw< zhCb{;{0G6d4G+IZc~fh+wH&B*sepe|r;kMca?HBTz!~OavGNeb3NN11>l2`6^jG^- z9M|I3wT|OTP4h2TQznq zPP7%PzI*Djrq=OXMx*=$Wghbv4B%fafQVRH_bm`)Iak$0!n9)%+Bq5)(40zeOkM8? z)(>L2*t&rV;c^2m&5hDn-){mh{@7#;p73onZ@!HDb@&S_pz(GB%_dG`;W(mBuSk^w zaT_$*l5h+p?PW3S-YNtopGc(z3oab*ISQj4jsvAW&YJFYvvkdeY?$Jr_qd_p3vRS~ zP&r(#OtqdqfVHh9jR4g5@Afp~O~8*{(odd$)}0k1sh%}~gCxx!YlJEJ`a9m<=@-WB zo=Usj;ypq9_ix|r*eE@;sgS4G=pr@pYWuJt{IX0d%&lFyXtByw*b{HM$a68M_4dBPJ-KGO zn+3yY)mtX#Aek{4gUVy!GothdD42#(bYH?24&eOqyWsm{OxC* z2%;gSu=vBp3Gzh8+nJn%M;G5XFpS^*k_jc$1RCa^0mMIT1OMd4vDA+YEHbJEQMlE$ z;95&jt><0UdOxbn&WiBUe9&ff`->YVkV1Y1|H0;nbO4@t5qxx_>#+JNATy)8y5ply>R+Of^JxeYrZNrEz>yw|Yet2uGU@cGImYFHu0Eu@> z5YF-6{(L2Ew>5fX^q{(AKVG)yPq23ro#gF1xp!si>v;aqBG|OL!=S^ACSkRzx@~1BTULuWh5=@kLlp=}cr}&AkXwFJ#J8Q%7g=>$7m_+oGIJ_kQr6 z1xp5H=+v$gel5py^P4kO!nON{_N!{K*Z8=9-*(e#&hr;OxQegGZyzEDNe*FWh;=BWMXr{9lot{Fi+n9BKzCYJ^IE^1K<`an0QYit4fb zx2hhlQzvd^D53|}%nXWXv=6V9h3%ryzmWen;Q!g`d+&M<=FlnmxvaI5`X@+d%(YCF zL8kj6SME%8SxNf88$ka*a6+dr?KPYN(~_8ZHVniB}Rcy`&BJ*&ZVK+ zW^+q{jYbov@E>WCe!RU?gDS#;8G zRKo>kH_Puoj>q>a>+987(^zL(lffTUpW#~dTlHkqqPCvC{Kxq|NJ*HFOlJk# ziKBJu_=&W?*R$q9y1>jhO9$7T@-KQB3He~>{<)OS{2=R93)rI>;!d~rR$3Is07IY`{87R75fG&JbnvPJC3Tv8f10Xu78oZGSDkz;P z2jxQ4;RhOnG&FMscgfZSclwOBF`k&g4a;8pG#LjZaOa%{4Vxg!nbMtJoBH>?=HaHq z?ze@RqzsPqIZ@Apn%dGe_4Y?IyTIE#s#uYgpPspPk?#3mo$-T3F(24+2}UvmCPfY9 z{-LS7x%)~m<#MharVbbSF?X-N99h3&9vZxV;w%3Q+a;a02XL%%lQ%j4@{XW5%P%jp zj_vTK>F>Alu(%rQf!BFZMy@01?;f!tON?yH0hY^;bPwk<8KO>ltGz~2MIiHpw)Bc6 zM#sr?*?VY-&b2IG4*RgUVZh;TC){RFb0IC4vxpv$xX4Ryn>Zgi9N$Qeaf{`?(uqXs z)}n6A<$G~f@F>ld;`)jO?W9)P(Se)CK|gFYjF}r4D+jWBXTLDFNbafRc>#44)?hE| zKzgSK_sTcBk|hSQ%!_;vN$$@=gX-|hvU8JV;HyjrbW+!QyVfl>8x~f1bLn`@QG>xo z)xu>;eL_8Aa_*qf0fcIt_v|tj-c9L5^3j-#ZdRd(_}k696ApS_;#p6{wAa<4o}A)C z&LpaIvrz`l{l~V=MP!bT?q{ndR+r1ojjf&tzLQ>qmh_u!FPSP(WL^@nEV44cMaH>D z@;3O*R8!(*meOR?OQzRoUn+$S&j7#z!4U+;gpm6<{kr>uspg#FM5#H9>W;uFqXo%?|AOPDN7W zWmZPqTGt{b67%vO+PeCOOS_-6fuh2b-0c)hWs23uiBb&r>X5Qnk=V;4G8beyD#tGA zl*AJpCYYbZBFq1o+5~jOixre1tSbig_8DS6&^iHeu9N}LwmDB49HU!WZ@&Emyh${7 zifk$CQPpWPp?5#x@C=RWcfdod(WQ=hjJ{UezOfKKt2PvZ3CyO%?+j37R6GOkRz&VqjsEcmYCa7+mM%pJ1QD zC1uvGE9ycma3E0G_9sFX$g1*jjT0hv37JnQ^W0v{*oIjQFvB}&yLR00!-mbi` zNZAXskBjj>o0L3*@gHgM(6SBNx=i(P*JNO9J>^F<0D}AlqjQ$qm`9)T4o?8WNIeTX zA>S{hU0;irSM1VV%L&ANX~zQJQRHS6oDB|8QtK#EW8-5iSx4-%&H6c!m}dMAchwA@gbMUq_^t#Kky zbZAQ1BSEO>i-2Bpvphvn^CdFM{pDQ9m>(g# z(I?W%El0$HqXQ?F6gSQVUmJ{$g^FrUCKV=gR0XCbv}HlWM?Qw+_vhMc`F$>-Xx1~omSJu#&SjCIW8_Uv|~uSpM7i1>cwRT zp6<_b%}d3aYqWj*84D2*qbTD^XUz0cq!uW8=8cccC#%&_i97IeRj+ybq2A4AlU*$G zV5*j1{+%WM23Vf1Zp48uM)Fns%%f(itKx&w=!QKoc|R;3o>RQAW+SDga!XB(|5U`6 zde58nbqyHykvEIzTx-#7*u|KtYQ%Q>F#H{VNPcR!%P50a`ih}~!*kw@Hya#bT0EkD z*0c#lI&GlK59P`v!bNznsy;X_V=MKHZ&`JPo zW?(rp2nfdvDWCf-(#-#;OLeT!(}?DlC6_Ge+U?Y z=R2#t{s#A>ns33mI@b1wW~~AO5P{hd#?a;#BQ9g4u#Rgrf|T|*Kr!JUDy`j>^W(#e z-pBz}IfV%hUB-FsUYv`Ri&u72?vA@xgSzVZiC9$WH2(+V!u^JA!CoCQ(Rlvuw`f8~ z;wqs);ftqmEEz9VuS9N9(X5EY(aD1kRn9)@oMTz6AxexJDk;UI%exZMG}&|qmc$}WY&P~f?JxceRXS?vL+ev3Eg!7@lwC$Q` z$o8>KifP3zf- zEuAzP(K&a)^qGUQ>?#K?`<-ky6|7u=Rg00s)B$z<)gx{ol}nwStY#btJ7`K@EpZG< zrndHWa4Jmo+ViGl9oIc7e#~|)nywYdoF)F0&|<);jbGcYSyDDRSdUCs(bxvgyz_s@ z?E)b*dC{32405=r;tj~p&G!;I(6n%JWoesLrzq738H*TFyR~+Z-#Bi+`gOBh{%lfq z0qj){*V^6cfJK16F*Vwo8Wk0iMxo!|wZ>jaLS@u_HMywi3nNjn+! z_zg@<7XOHf*d(Qj&?=#$7blh`ja5mGu?w;JeEC_1G-PF}+WaFp*KEeenzlA&jXMPS z!HFU@u_Cn-^>>ZG_z86;zM0@nSmTz1T`hF@j@{FzM{3C)!v78qLq%9aJMJfJ037(POx&)=>AE=6YS@`O+ z>_1^MbUvqbrlp?-AD9EeOi7RD!`>HGEu4dnAexC>qcm-b`ukAK#w>Kq?KhuydvF0Z zUm;OuO&@{j*VdvDSUX0+`Y=xWIT{IojXRZgnR7k&OC^p{x8cRJFJNP?`T-t{4d$_9 z`tGr69yC8PhVz4QEP>h1S*5CoL20zwF(ib_#> z2}Pud2q;Pmolp(Em(Z2okzO^@q=pUxf|O7~uc7xEdau99Gv}-|^J><4X3bjX&HMpL zcJ5^Dz3=_`Uf0Ky-Ph%`4ltY0z741JF54R2#00~DQBASol@`=JT`l3-9V}tXaqO=o zueM|1b$f64LW{7>r5(#FRnw}Ofsr3ne5Qy#*?b93zPNtiif?zY+?@LnByPGQyfOc^ z`bqpf&a{`0&7xMLtynTI?a!ZzzNy9{I&54UK4nbwIUQw94JwtYFU+Y)zX1^Js@E}Z z;~Cu1cQoc<$iVBw&x3Zay(zxXDUpbx+AroNH|CgxQSFyGb!BT|qTkSs5M~ zLC*yStP}1r%5$Yyc}z;R?Mhl*p6iKwp&xaV!g-W|#RF;#j6gv@4QOk)-d!PhS zRa6e@N73MRr0SGv^&@&HSgmAo^1RA`BAKr9Xk8TuuXmcD7D0G^_;teHb0Vsy98t9# z*`?k1=<1?cm>T?4O*w8m%J(?hhO^E4ZVQQ+C_;1{{f&O~(;7m) zwO5PmrL3Equ5ctMPq+hDe%O_L)p%#;4rS=dJw^@C#N*Btww$eGEM(P;3cd{Y7LcI4 zfS>N&Mcr9aL`T_Vlkz_Or6tXBeDc|KS3l`(tX^rardf6bgU}0a9gY>XwArq{!||Z$ zKbq-9m0BcilFfb(xwq8m1iUqW^1^!cJH`7X4krl9x>#~5fBc%+M!3>fRHwQ3{k_Dz z6c9;{Gp!hc;Eweuqftioa zHggEH!FDACf2arVZQcAPvbW-~s*<63VbJM9=gk$3Iy`#@%daHLVBC?4t8X(7B%OTE zFJ+)5A-qs?rESxyQB@D5`AcRjC~EtHVmu^JEjVqSOZILxy$r^6M*F!b&NweKSY>iu zyr~BAM+=i1G;=UX(%BXqJU%>Ow7}i)Mt?J^P9ey&fMQpkWqF3MLClLm<2mD7{8 zQPBJcV0R#QLZ6mObN8MedBUlZ?XXN046b%odvBTD*n1DkZ9kG)AXv>=4Q&cVhI?`+ zJSDV`T8$x;zZ5S8o{giw0EOiL3#tm)f-S^d-<-goBDOQ7Q#Ht1hSJljRCWjFd(&zy zEFK?=KH>B#VQV&BmdiKd9MkMe!pBtmd!si!@uAOebcckzVhS9v(+oVhDk<7;E}s-f z!eNL=9`8_RPz7{J^8DIXA$_q-1&dxOnhhT76^Bq`t9`A*DlSY|{_Y>-%n?!YT2U|~ zJRyB!P3j4q7C$;*{KU8lIi~OPA1Gg>D85>fbR=p_$dPlA>I%^-!Ksddk%%Tkr|XsX z{b=!_dqw+1^j0Lc{VV~ixeXAm7G(@Vy4`|XS&!SB%Z=eXLWC_%Q9?ZJ7BWpHR^>lP zn8v6$kQx68B10zOW%wh#fWevDi0jE~?j0042EK%F2qk3n*sFn2z*F2n?GM+@U$wZ7 zTiB4OPPjfTvvAT8HRsp~agYSk?A$Uf3nH@dhhCg4&ARf;GCS&bM4`TjTUD~!lJ28& z=RhUvhRa;yhb1?1tq|FY?8Ri20Xjp5RL6aDKHxya8mSE(d1P!aU1RlZ*W}*EACev% zAg~@nAZ>X=w^hw3*6)Kx@9K-`%1&!NgWM9hM|lm zpz$JFyWwfEKzT}tOek|>8dNfdr2kmNfNHenGPu~SShMIuRH(vd{%0?OH@wq1m|u~^ zfQ>$)7Kv^XH;XM*J3QXol%=!Vp4m;NUjIZ1`rqxBCP6|60RUqEJ41RK!TNqWCMC)& zt*$DNB=QHuDvzSW$}z3w2r0Y(>+qg?{#Qt;SDsyltFMq*8hz&lXC_KQtF^sWQL)k< zPT9XQL@|-AncwN^ruH1`St0Nq5hRWCc{<%|o{=&@apDO$96W&>-l5Wxet0~3sLH)# z?p>){=r~M9u{!^2yyh9f@`8VB&F#qgCKh^|d~VHNA>Msixa3vSkcpjgS*S3eVqusT zxYL=X>P%Q&F^n%$P7U3bOtKUmQTP8;q_{$V>&BH#U-R}zi9QHN#rBHb_w~`pjdr|8 z43%OKXP4WdR+g=vcgo^jv|MYMdt6uM#eVB|fwaBfIK2pmGTqrMX{12c( zchCKIoX4fQ^VZRy+WfD%+f>0$1AJ_D*(njWTUO4gdNnp%o*0J)*7jgJoKO`(&LFVN z)nzMkG^OAG7f{~Zu`LxkwW}tB+T+BRy&p5IP*~YqS&ct;y}6DQvV|RtexYk{y@;I<=wuSn>v>Lys4Jn=`sd)gr#%;Y_e0S<--3|S?=2jGd`j@|Ehch+|AA1 zpU!`#TgYjjVfKi3z#XjJKrV!P#|-ANWuLf=w-SpL2LY--Tr1UVd6NIx{NS7V9+pck zAf|(GoBB%vvXaVO-kcvR@pwKoT0i~xx*tsTwvwa%o8amEGd2-YA^4-2vn3P!nCB!bCu;{+sphw;Fx~-SoYi)J z8sr<3uXN)xQ6RD8Jo@M$>Djmy%Hh5DkCd)S%Zrc3K~I9zLV58Fo59}q(pJ|}-6mi} zee4Ap1Ed1W#J=9le`c_VpC~WKmtubbWp2y!&|ZRR=^RsQr<$Z+vqOpo1Si*9EQn+x z>h=uP(v+=EneX%s&ax~0e6Cj*{oA))q(sGbr<&cM(A*Od&1iMg?VJQ*8eEJ8AB9#jY`XZLa^Ke-6oW0IBY(a7U91 zpdtYem<|N>*4tFnx+i6;dR#cUXYIT?QZxD_%et7nL54bQ3kGEy837KsowgsE2QJW!NDb(s@Vy!64I~HKo5&H z_w%V7*Jix}o@EKs>Q`%K0vb)@&rJ8K^R#NX#6ofPImA^5(*C~(y>qad=kSqlnbD|d zQLFjyyoJan^7)ClafAL=PY2eS8Nz6EAaQjr9ll8|E3-Z$U2Gfcaj2nNVxEL7pToT< zV~EONf~rL@K^K%MiS|BcQ)yZjAJ1HfON6)aE;^L!hl>bD*-lfYS2v(=|^%@ zB0`E>d0d%&Da(^trPXG1l2>1h*d{l{Ydf2SYfPr=P}(|!7@nx!7*yg;H~55oLB?Aq zyC&AqZp}_z^Wgh&?}!}EdLuKWGcR8F25?X+z4Dmo&)FEV|L!uAL7CNBL?i-sc)v!kq zH&hirvO?2kv$5^#_q9=;_VhSOsvf8H8G>)Ri_E+HkR%>Zsd#($uPk~C0#mOEJt#?x zU>K@;`l0Wm`Smx{nq_=P{6!iQhX+Bw30&5CMCl;cT%LA2Q^@Qhg5PZS7RlrIy4T{> z1RBV(j_2McxxD9hk0&)yKcY(QIFUXKg;*HM9%u?DaWQ z#U+@zy=Drb;r0kKjZ|#C^0TKFkvjen6Y`i#E4(Bf-=?w@JStUaZkI9rAk2(Ql>{+0 zOv(tJEb*J!%+x+a@^>?pS;IP~+|kiNAz(@?BAW}H)s14g_1!SOmyu!J z{69W)BrxlT5>d;(aAeu*4;>FKE(lDKW-?)TqY=KQA+Zd$WE#IWJi>^d*J}ql( zw)anZyw*l?lvP|FUWhvg(8zE7m>`D3IO&wxyA&oE93dF1lg>XCtD&-C^1!oEyr!us#&ae(zdsBF{L}$(W}$ z#vL!AV>{$M3vHJtLHFgBGRqQCBLbxYPBldkG9f3unnCZRAR>&C&P&nVyQ+oe@vrvv zHf}eul=I0xV;9s~PErp;>$RV0|8{B=e+J~h1ivEGCo)dLfo$aaE-yXCdyaP_+ZjAb zrrfx|2OE_s0@am%c%B?c_ET zdW)rQ&zrrQgmMzP@w*0(zJR|XVYm;cW{ae)$iZ8W5p$FOsTS@hM;?9JI=_@|MWwyM3S&C;iip>kBKkK>Q zT33uGtPw@w^&S};$eV^Grgae9ETN)8XXD!JqKLthtNzFd8$QC!0UnGOn|;i~cm~VB z_io+O;*cG1GNQsMak?!FXklJT_l0I$z;-Jg2&}TyBP; zC(V}P_4ce1a`>mD1kh4i#T)`qIzTxGs;wFq**wOiL*EJIyVaHbbqRC}E zpM&Zb1nF>U(DRbAaa(S}%wM$nkU6rg)wTZq8brwOsu2Q{XP)sIWLyx= zg~`+Mpp}j~Ea0%+$rruQ@QwhXAh{Ug*tYfqNthK=P%xen)FyAJr;bG)2K2`9RxG_N{HRjyk$~gwn~h0QK1O@c1y^SaR_it$rM|+3)=a`g=Ni(9O~QhX zVRv)eqr}_2w!NeaxWUwpO3P|H^$ByQvNpS&a;}fN3KO}H zHmcO`fe={?O@(A)5NGfJLb`A4MgSK-ycM}xbR!#U=9985*+Rr$PTV~O%NVMqUU~zF z>n72*BO#p{(z1J!VDhzNNRic^?tzet@G=a&U#T96R%cD}qBZwU2dZR0RAv)(HlAx@ zT<-FTiLD#BQaRwqGFa(%eJa>Ec&`%Kz}+<|Kt~>bk1QR_=eRV9CTO`qgcPtTZ&6n8 zUn}*( z76DH>Eb15Du{lF^xkD+PU-?G3m%D-DcLhOi7OHlMo+~gFMtqBKaQr>HTeKW(pN?T) z$9kT7t>dG2USj1N12Scos0sRPaGjOs$v*OUtT<{ewnIPu8SJ;Pn2{h~mB6c|76XiE zi`!Ybq0#Qy<|A8GQtEmty$KfX15b_PID=5eqlkj@Nxrw=)>Y}A^NylIL-dRHD*v!EvZ zocNXrf7=ZK?QZZ@)2-aHT=GjsqnZ_LO(G*@ics5}=e5;^9PpXFT_|4r^Q%N%W~`=g z#YPYHMBwe38nr6biGk57VJeH22@@v7?2wnyS*TQshEH)_arn10$CW#urdh|{*Ww+i zp<3G|z&@z!;%MElOE_`ErBAw|*KE(6zY<%?>9>Iyr%FQDF10YJ?;e#?Wkc>yctt@l zFut+iHkhBty~m)ADR!7|hP|b%i=bLyvCVyPcL#{x% zpIAw93PiELvhvH{1>ge-0QJI4%g`IPm=W%L`(xm7*G`C-$G%$s?bY-2_+InpCzN#D zK5|Z(l9|BMj1j27MqLB_>sg1nDJ&_=<+cLc!BhIDNQL-29G2Ym%0N)fLd!<3mV7bk z-PfF|jSym+2QG#Lr?P{1?@i{Hy0)OsP6I&cu5gMdHH7^5Cg)<}$AqN>H(7#@@27&( z9GigZwO?41mBV`pVG|R}`n4Nx3&$`gU#j^5D=pEtv=cx#D?-NUhDcHs=wFYRk{N{f zG`rv1Sr?0sg?nrFdZigVl59IH;$Su7!j}Pyk~l}o_47e8MS=S%kv`=7RTnCz z)E)DF&p65L<9%VbUn)N(}^6YF{oru0S zSQh-5J5rpMZW_{X=-F?!)EBK}6S6ruAJb4YFqSC?(ltf-z76E*6cEFa2DK*^yQM!} z(M6t!-zp`=m1MGP9J%b?u^dFy={wIx5B^lO04I-Ex1mQiq@>$$aW1F#c}7zU_Kz9j zW2vuHONY2?gxq%Qy9RG*@9-f9n7^aP{v>5(3BWJzAL!s-N{rITqtRSRnbb?CH~!6s1uZw(x({hJ)W)NA+%0vL^QVtMHL&c8he0&h^>utwQ zEEs@CrXIX;eeXgmJ#WWmJ zH7N1(We{th$eQ^cHSOAUW0LR9?1FUdgbT}YyWQ`Eo-DOB_tB%NWYX%G7FeYzj|rvY ze(YNAEb#S66;*BZ8gh$#|9Wb+^9sw%XyzNXG<%tO$3?m~qkGgxMf(L2Jx*PB*p=eV zJ1PIQnyS&sv)Wku9h;6Fs*}kfZoLon4~OeWOQ*V3?eagR^7aY6YM?vA;W9;AmVYQS z60}@elNc-Xai4%Sq+!gmN5h@fsf5}rlLW2_k4k7yXW?5Wp5r0X5N1gp1DVk#SrI>a zI!jim&l5yar%IX)Medb7Z`CCIOy*hs0lZhDG<;1NXL=%W6lz)dNaHak&D;M{mV)bi z+W&T25-$jCP-bH64Aft80Wfc8x#os}hHq3x`K^Bc20_0MiWqM!ej}kIqIPFZm>wit zDA?PIYy^F@0FRob72u;Z0WK{j1>NV>yATTO9gSCR*Kql7AjZUB6TcLkGDC~CQv(iE z9^x^-?AN6GuQ7FVgfGTY)Pmc`{(j2B9T>0r6mXUy)wHDfnS%qkB7R;{b}#;EKK1NJ zZi&5^v0ywNUn=2H|AB59c4Dtu*NI4`K|ZGNaQD~WCGl+?xf@E6ulb<1d7LE4`+&pW zFX~IJY17+bXtku~LRjvh3U`}MXN-pO{+)d7hF)>Xh~L`m(sF-5(W|L!JIy+gk{S9f zB;XR%`QXr?R?4iLc!*g*o|o1#yd_>#FhM=8rtXeRAb~6+bKYimK2C1*aiPU(TTO3V z{Y>5Ac);BG#l&>a;b@co&XQI z_FM)PHY5OJ$~b{ZP7iO>FkyTJjOfF}0E|7|&Xm1W?JIv-zGK5OS4HzC1-GXHmEqZC zBSjBE?|p=G14>G#nay*S=LZvKg|@;MuN&MyJHp(V{?X{W)U ze*kazrx@2Uu?UY^gj}}~TJvvnRQ>bjPQz-?UN)-LhqL*3xMjKjrYdka;v4KTyU2-; zcBvBNkxDz>>ZB9;U^09f`_Pb(@*lttcDLaABMvY#*P4@oy@p+Gq5M?s=PzpAus^e@ z)2N4wtckVg-gIt51hYKx#bvC^7ybTz?E+BT-?4}SjBABxCTY_1C8g?Mnz7>Oo8)^I<3fPPeEhe1X&mx33Lw|cN z9;vy>8lChuH@S}lBR`o3k64l#7??EUPIiCF3^lz$4D#v9Jl_l~Yt7>n;VQqsYVnLeTWXi9JcwoFDat93X+V9 zyZRjuGXeNh93&zA4AgmbPMLKbMXX(Bat5yfzJRgBc8T?@`woums@F7qf@jM5=UR{P zS@I_q3-_6X>)F8CGDL{O=DH5=e%o?AB9N>M_$-ezN!i!Hff35;)eZ@8!P z40G_9B}902AeNaZyVy4W@5Us2a4K}dMm^9A6IJs^T*~D0o*Ky_Puoa}cOLL@w@vGj=z^^Xnl?-Y+#A zR%Sr>^r=yL@nS>v-Qd42$LEMIbgNyKHsR#eWRvF>zDp@}m`?haZ^_(lWv5wx`pCHk zZbsI)MRC_zu5)d6JAm`YsNzNR3w_12o|Ny}Q&4&PJ0#*!vDy}1X}K!dkk&=6%1jSu z3v-R`QfT>weiyJd>Clt2gv2ahc~-S`g&_%F1+G9o*nt-_rE`1y3Ap(gV;cqXinJfySg9{3wb*7~4HJ*ex1b~iiULnQ(E(7ckwwxts--e-+vgz!4- ztN&Qmp^Q3Dyrh3yaqP|z*STCcUQfH3Q)%dqhGZPg*kn#-0nwL{d}~5XBWT65;Er{+ z^UaJchVmq2`%Jqgk^(8j;m#IsDV7vQ*7gQ35BGDRcA>8?hGm@*S0n8>HUK%try5Li z5AvXm8Xb4*+EMouGj#mm)jKHUHErH`1W%GylMnj0~3!ns^3=oQ5V#^n{5uhgQGC=dcldiXmrIJC{PQB&o8S&va&F+L==} zZNBG^#JP%m80XG_=hJ@fqoGM^)(&9(QXe#gSu)gj6J80C{XPML#1@8ky{|obB;aPG z#;s~i5#6Q?DkK<5Sz4=aU_YY(JrMof4YOX6XIoK}CA*3w*tO53%iM^HdLZN`St@~{ zlLPO&(~rcoD1XHmZAlDwXsOs#L$dfP+G{uyvsE&n-5idLge?afzZD98Ci*K`ydi~f z2K>xMkKQL8=YPaw@ymiUTF&jKdN@g#A)(HVGn4F+dq*?!8gC$67pQWm!wN7kTnSG? zXE*BV`yJrWLCN}lM*zV}{KJ(JzpJVFe*gjWw`2+j;QZaS{mBv8CB2m5T>Wp3Zx+TU zUp7AY>qFo$7n9@ua3jcEseoG-gAPf`+;TVnb!8LnQAKX+%&5+y;$B<3Irk5M3~s81 zd7#p|w=lemZ!WBo0$lwn)4Z)r`rGALrwdEIe77ta6>ay#3X|dSr;8jOX6-=5La}M( zaIRJqzQWTpmNxgiF_pS!r@Tcu>HXH;1g|4Zyj;{z**zfVKAA9Pm+B_1aKbKnsGuj% zO_|BEjx|;&cI_%=>dom=4YpW;y#1wIY_u}N-B6q&1NZW1r27-dCc7642+U0XM2c&y z?A%#|Z|+G)>VTR?{@a4e#}{y#kb41==R4>yN+KKu$O|a>2-9Tw?YH{P6&(~WlO{`Z z5&cdT*>+V|1-hen%!IZdyPHs?8}gm%O7~kJCgdJbW%XeF3M#{|z~c^KbKEY~4^xRE z+Jr7`wBQ=IUbXr*(H=Xl@UagN+mU6&JxTY~FQREOJmlxhVZoT;ohuQ>8a=MH7oCI{06AdyVR zXYXreIM(Ss2mchNoA24ywHdt|q3J$8lY!;k?MORX*d$ShHs*PQW6GEg8gKhVm8 z7xsOZ*=j1)rB`z`h_Haf8H7B*9y(T3a)k{Tr)fzv7GE9{>dsgl)zDK=crv_jIn&VW zb%%efcjC`3@vIP8mc?DCnhKsgG4_s*usm4F!+BLI`8musUqImriEl&?v>=N3z@(fRL3hMaH>hjL`0s3L&yA`P6ev9krbnw5{Bl}atSiijPLv%=*7@x#~Hw)^B%Ew_J z+`T5&3pHpDLayw1^c{WeEw4;Cw|2J^E8j*Sr5~_W4O@?h~5co29g7* zk^4M;rfFjHvYg*PGn1 z&@TIGvuQb5(6ukZT6gp|gGdi0$l)@*Lh5Ke&x-D5@4vS1p{h8MmP%Ur8V5}i+pUO9 z=%9a!g+#ovK0=k+V#k9|n#vwERMJ|HWek~0j4vd0cxwp0?|$UP=f``)E>F{|$E@^K zt7&+Aeq5$($!ewiLTQ!VHI?OegZ8iLK1#&kA@2+RXqE%26-M`*s^!JmUKhS){y4Ks zyF56JQw=m?*L|~1=wP$7^zXU@>#pOaSgjtB%@_JKPaJ1P7KG!xMD8+O+6eTyO$_}K z;z#E>t4hvl}7!p9!18Mq>PG*Ei5zISL8+|_1;BQr?j3L zMQBj1v-2wwIaMw%X^IINU%k=XthWN=2xk>~>j8K~omdsM?09tT_Oh1*OU|&V#M#~W z5HLhxs@%-BTkU5JJV``(a=i-u$I?!8+iM=o+%sGsqj;?K3Lzt0A#8!nDzRjRG1=%m z=zv^x@;@a`^ zba)3=;=srm%2LW}e(Rvkh>*#pta-tC8lnr)-w|#Akuwl6w_rgUK^|+^?oR*Fl=Kzkn?Gcj+x?0h++%Q+2Vg}AiCplW^VC}a` zu{+D{K0&e^nE;ab0-fm0?WYwkT-7|$(Lf5mKz9M1cCaA()ZqTYsWOutT$w5DmW3K0 zQEkl#ue+A1o!CA$a4=n}*6L8KBawL4Zdunae`l@X$@%28{*LD$E1Oz%r;^hc*KiMF zjQ=;k2r|QzN&Jw*gI(T}C@Ryd(1&;Qd>0w`?BKw_?Bi(o%8RVu$Oj56)B3Y`K2S>W z?@^p>uNb0uXeo0pt3I4Ggcx&E6;qH*98kHT?k~OmHp(dPve@Ot+vS`>VeWH`;OEKU zkL2au+`2#yi2Ae})T0M&+l&Bhl4vQA(0ZxF&jv%Cw1s)xWSyrLpDs-}GfI<;m4Lux z2`7f&LaS?;-y(|2tVu~^oR=kkQUC@il@3i`AjTn-uQKUketPBLw0PtUq7a87BK12_ zx#%(cw#$zrmQIXML8@0+2m2*4Laz6RGt`qJnvY%WdLz?|7IQeuo7B^D75Coz;9YHa ztzl#mLYpH?{? z+vJH-@3OXL-7XFd;veX!H3hDCQl@OJqajhigf5)47GopdOE)mj;^;C*mk2)WSYHvH zEJ;DTMl_^leS)n=q0CqW%BM^O#xDfWxFeR&8uub8e3$|_avBz$za8F4&&-P977p;gKD>KI?q8`x=mAjol zu5u$_hNlX9`i}{^3rgmH06o84{NA1@coZmoquUm_FE*}aqtU4<+9E7?kCI)D-K;kG z$!{ensRT#87s{0a0i1qWZCad!bf?Ox5almrYQBnVmf<$8n^U0}^_VfxbKjng#(_k= z5^+C8bW}=u&c)u$7bTDCEK!Dl{5=Ae6xo5_>SJN>RyTHHd2G*MLe*LdN5IZk)IlQ7 z`SHHKj!{#*>(y(@x}sUxv2zR)-aScyZiDuD#Y;c-#M|sl2Rk=BPY~YHK`GZ;e?~mS z_nVYrd(D@+(mov|zN5pegesK0m z;mZp@NW1YYGbP(gJ0I_#TlHN=zI>qKFF}zEPT{!93Jxq z3=;Ur-(4a#DFfS)ralxg=!gQ?WF~w+<4D}0rlBDgl_gs@4^aT-MU}!ic<=U+=mD+> zzyDd_`4{6D)b{=BNQJvRTIDf*P*mwjP>H`!?f2V=ssE(34kwrG`FO0>+;*sD{J4i% z{ujF=;WQWk3)YV<>8EV&gv5c<*eA7D3tU;|+IB>dSDzbcS3YOcgvmgI_dhZ+HccH(Jv zI8+G02=GZB{f|r&%rv-`RCi}sNS68j5z)(*e(DY;{8b0`3V7Q)#d5^s*!S>9l1Mi& zIAek$P`draoBs=V{C|UEL;sIpZG_&KD9*IOnLVM9=wqKoRYHxpq0WSZwObeIu>S*M!_S@o literal 0 HcmV?d00001 diff --git a/site/printit.verae.ai/img/validation-thumb.jpg b/site/printit.verae.ai/img/validation-thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a5c22364de55180236c79a917e18e3f5cac0dad1 GIT binary patch literal 11684 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 z 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 Hd*(j?ojD*7 literal 0 HcmV?d00001 diff --git a/site/printit.verae.ai/index.html b/site/printit.verae.ai/index.html new file mode 100644 index 0000000..517dcd2 --- /dev/null +++ b/site/printit.verae.ai/index.html @@ -0,0 +1,288 @@ + + + + + + Print it — Verae Timestamping + + + + + + + + + + + diff --git a/site/printit.verae.ai/robots.txt b/site/printit.verae.ai/robots.txt new file mode 100644 index 0000000..5dc2896 --- /dev/null +++ b/site/printit.verae.ai/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://printit.verae.ai/sitemap.xml diff --git a/site/printit.verae.ai/samples/verae-receipt-sample.pdf b/site/printit.verae.ai/samples/verae-receipt-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fc7d73cf474b332a413377fe1bfb0efeaac68506 GIT binary patch literal 41589 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 literal 0 HcmV?d00001 diff --git a/site/printit.verae.ai/sitemap.xml b/site/printit.verae.ai/sitemap.xml new file mode 100644 index 0000000..4715f40 --- /dev/null +++ b/site/printit.verae.ai/sitemap.xml @@ -0,0 +1,4 @@ + + + https://printit.verae.ai/ + From 7a938892d151aaf0899fc8b8c5a86f9777aa1e57 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Sun, 4 Oct 2026 13:45:56 -0400 Subject: [PATCH 02/27] =?UTF-8?q?linux=20CUPS=20backend:=20install=20root?= =?UTF-8?q?=200700=20(CUPS=20ran=20it=20as=20lp=20=E2=86=92=20every=20job?= =?UTF-8?q?=20failed=20status=201),=20find=20/usr/bin/verae-print,=20chown?= =?UTF-8?q?=20spool=20to=20job=20user,=20clear=20ERROR:=20for=20missing=20?= =?UTF-8?q?signing=20key,=20debug=20trace=20via=20/etc/verae-print/debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- platform/linux/cups/backend-verae.sh | 104 +++++++++++++++++++-------- platform/linux/deb/build-deb.sh | 2 +- platform/linux/install.sh | 2 +- 3 files changed, 76 insertions(+), 32 deletions(-) diff --git a/platform/linux/cups/backend-verae.sh b/platform/linux/cups/backend-verae.sh index 9fa9dcd..59d9c14 100755 --- a/platform/linux/cups/backend-verae.sh +++ b/platform/linux/cups/backend-verae.sh @@ -2,13 +2,27 @@ # CUPS backend: verae — virtual printer "Verae Timestamping" (Linux) # Discovery (no args): advertise device URI verae:/ # Job: spool PDF and run verae-print as the job user (digests only). -set -euo pipefail +# +# MUST be installed mode 0700 root:root. CUPS runs 0755 backends as user "lp", +# which can't write into /home/ or switch to the job user, so every job +# fails with status 1 and sits in the queue being retried. +# +# Debug: touch /etc/verae-print/debug → full bash trace in the backend log. +# Log: /var/log/cups/verae-backend.log (plus ERROR:/INFO: lines in CUPS error_log +# and the job's status in `lpstat -l -o`). +set -uo pipefail -LOG_ROOT="${CUPS_SERVERROOT:-/var/log/cups}" -BACKEND_LOG="${LOG_ROOT}/verae-backend.log" +BACKEND_LOG="/var/log/cups/verae-backend.log" log() { - echo "$(date -u +%Y-%m-%dT%H:%MZ) $*" >>"$BACKEND_LOG" 2>/dev/null || true + echo "$(date '+%Y-%m-%dT%H:%M:%S%z') [job ${JOB_ID:-?}] $*" >>"$BACKEND_LOG" 2>/dev/null || true +} +# Messages prefixed ERROR:/INFO: on stderr show up in CUPS error_log and job status. +fail() { + local code="$1"; shift + log "FAIL: $*" + echo "ERROR: Verae: $*" >&2 + exit "$code" } if [[ $# -eq 0 ]]; then @@ -21,56 +35,86 @@ JOB_USER="${2:-}" JOB_TITLE="${3:-document}" JOB_FILE="${6:-}" -VERAE_PRINT="${VERAE_PRINT_BIN:-/usr/local/bin/verae-print}" +if [[ -f /etc/verae-print/debug ]]; then + exec 2> >(tee -a "$BACKEND_LOG" >&2) + set -x +fi + +log "start uid=$(id -u) user=$JOB_USER title=$JOB_TITLE file=${JOB_FILE:-stdin} argv=$*" + +if [[ "$(id -u)" != "0" ]]; then + fail 4 "backend is running as $(id -un), not root. Fix: sudo chown root:root $0 && sudo chmod 700 $0 && sudo systemctl restart cups" +fi + +# Find the CLI: env override, then the .deb path, then the tarball path. +VERAE_PRINT="${VERAE_PRINT_BIN:-}" +if [[ -z "$VERAE_PRINT" ]]; then + for c in /usr/bin/verae-print /usr/local/bin/verae-print; do + [[ -x "$c" ]] && { VERAE_PRINT="$c"; break; } + done +fi +[[ -x "${VERAE_PRINT:-}" ]] || fail 4 "verae-print CLI not found (looked in /usr/bin and /usr/local/bin)" CHANNEL="${VERAE_PRINT_CHANNEL:-zapier}" -if [[ -n "$JOB_USER" && -d "/home/$JOB_USER" ]]; then - SPOOL_BASE="/home/$JOB_USER/.local/share/verae/print-spool" - OUT_DIR="/home/$JOB_USER/Documents/Verae Timestamps" +USER_HOME="" +if [[ -n "$JOB_USER" && "$JOB_USER" != "root" ]] && id "$JOB_USER" >/dev/null 2>&1; then + USER_HOME="$(getent passwd "$JOB_USER" | cut -d: -f6)" +fi +if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then + SPOOL_BASE="$USER_HOME/.local/share/verae/print-spool" + OUT_DIR="$USER_HOME/Documents/Verae Timestamps" + USER_GROUP="$(id -gn "$JOB_USER")" else SPOOL_BASE="/var/spool/verae/${JOB_USER:-unknown}" OUT_DIR="/tmp/VeraeTimestamps" + USER_GROUP="" fi -mkdir -p "$SPOOL_BASE" -chmod 700 "$SPOOL_BASE" 2>/dev/null || true +# Signing key must exist for the job user before we bother spooling. +if [[ -n "$USER_HOME" && -z "${XVERAE_TOKEN:-}" && ! -s "$USER_HOME/.verae/xverae-token" ]]; then + # Exit 1 = CUPS retries the job later (ErrorPolicy retry-job), so it prints + # by itself once the user runs setup. + fail 1 "no signing key for $JOB_USER — run 'verae-print setup' in a terminal as $JOB_USER (job will retry automatically)" +fi SAFE_TITLE=$(echo "$JOB_TITLE" | tr -cd 'A-Za-z0-9._ -' | head -c 80) SAFE_TITLE=${SAFE_TITLE:-document} WORK="$SPOOL_BASE/job-${JOB_ID}-$$" -mkdir -p "$WORK" +mkdir -p "$WORK" "$OUT_DIR" || fail 1 "cannot create $WORK or $OUT_DIR" INPUT="$WORK/input.dat" if [[ -n "$JOB_FILE" && -f "$JOB_FILE" ]]; then - cp "$JOB_FILE" "$INPUT" + cp "$JOB_FILE" "$INPUT" || fail 1 "cannot copy $JOB_FILE" else - cat >"$INPUT" + cat >"$INPUT" || fail 1 "cannot read job from stdin" fi - PDF="$WORK/${SAFE_TITLE}.pdf" -if [[ "$(head -c 4 "$INPUT")" == "%PDF" ]]; then - cp "$INPUT" "$PDF" -else - # Best-effort: keep bytes; stamp accepts PDF/HTML - cp "$INPUT" "$PDF" -fi +mv "$INPUT" "$PDF" +log "spooled $(stat -c %s "$PDF") bytes, magic=$(head -c 4 "$PDF" | tr -cd '[:print:]') → $PDF" -log "job=$JOB_ID user=$JOB_USER title=$JOB_TITLE pdf=$PDF channel=$CHANNEL" +# Files were created by root inside the user's home: hand them to the user, +# otherwise verae-print (run as the user) can't read the PDF or write outputs. +if [[ -n "$USER_GROUP" ]]; then + chown -R "$JOB_USER:$USER_GROUP" "$USER_HOME/.local/share/verae" "$OUT_DIR" + chmod 700 "$SPOOL_BASE" +fi run_as_user() { - if [[ -n "$JOB_USER" && "$JOB_USER" != "root" ]] && command -v runuser >/dev/null 2>&1; then - runuser -u "$JOB_USER" -- "$@" - elif [[ -n "$JOB_USER" && "$JOB_USER" != "root" ]] && command -v su >/dev/null 2>&1; then - su - "$JOB_USER" -c "$(printf '%q ' "$@")" + if [[ -n "$USER_GROUP" ]]; then + runuser -u "$JOB_USER" -- env HOME="$USER_HOME" "$@" else "$@" fi } -mkdir -p "$OUT_DIR" 2>/dev/null || true -if run_as_user "$VERAE_PRINT" stamp -channel "$CHANNEL" -out-dir "$OUT_DIR" -skip-setup "$PDF"; then - log "job $JOB_ID ok → $OUT_DIR" +echo "INFO: Verae: registering digest" >&2 +OUT=$(run_as_user "$VERAE_PRINT" stamp -channel "$CHANNEL" -out-dir "$OUT_DIR" -skip-setup "$PDF" 2>&1) +RC=$? +log "verae-print rc=$RC output: $OUT" +if [[ $RC -eq 0 ]]; then + rm -rf "$WORK" + log "ok → $OUT_DIR" + echo "INFO: Verae: timestamped → $OUT_DIR" >&2 exit 0 fi -log "job $JOB_ID stamp failed" -exit 1 +fail 1 "verae-print stamp failed (rc=$RC): $(echo "$OUT" | tail -n 2 | tr '\n' ' ')" diff --git a/platform/linux/deb/build-deb.sh b/platform/linux/deb/build-deb.sh index 38ce40d..72a5aa7 100755 --- a/platform/linux/deb/build-deb.sh +++ b/platform/linux/deb/build-deb.sh @@ -41,7 +41,7 @@ build_arch() { install -m 755 "$bin" "$stage/usr/bin/verae-print" if [[ -f "$ROOT/platform/linux/cups/backend-verae.sh" ]]; then - install -m 755 "$ROOT/platform/linux/cups/backend-verae.sh" "$stage/usr/lib/cups/backend/verae" + install -m 700 "$ROOT/platform/linux/cups/backend-verae.sh" "$stage/usr/lib/cups/backend/verae" fi cat >"$stage/usr/share/doc/$PKG_NAME/copyright" </dev/null 2>&1; then fi if [[ -f "$BACKEND_SRC" ]]; then echo "→ CUPS backend $BACKEND_DST" - install -m 755 "$BACKEND_SRC" "$BACKEND_DST" + install -o root -g root -m 700 "$BACKEND_SRC" "$BACKEND_DST" # Point backend at installed binary if grep -q 'VERAE_PRINT_BIN:-/usr/local/bin/verae-print' "$BACKEND_DST" 2>/dev/null; then : From 095e1e42ba714cb20cef68106f14f2d25827a516 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Sun, 4 Oct 2026 13:51:56 -0400 Subject: [PATCH 03/27] deb: upgrades keep the Verae_Timestamping queue and its jobs (postinst creates only if missing, prerm removes only on remove); default VERSION 0.3.2 --- platform/linux/deb/build-deb.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/platform/linux/deb/build-deb.sh b/platform/linux/deb/build-deb.sh index 72a5aa7..4c28adb 100755 --- a/platform/linux/deb/build-deb.sh +++ b/platform/linux/deb/build-deb.sh @@ -11,7 +11,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -VERSION="${VERSION:-0.1.2}" +VERSION="${VERSION:-0.3.2}" OUT="${OUT:-$ROOT/build/deb}" ARCHS="${1:-all}" PKG_NAME="verae-print" @@ -113,11 +113,14 @@ EOF #!/bin/sh set -e if command -v lpadmin >/dev/null 2>&1 && [ -x /usr/lib/cups/backend/verae ]; then - lpadmin -x Verae_Timestamping 2>/dev/null || true - lpadmin -p Verae_Timestamping -E -v verae:/ -m raw \ - -D "Verae Timestamping" -L "Digests-only Verae stamp" 2>/dev/null || \ - lpadmin -p Verae_Timestamping -E -v verae:/ -m everywhere \ - -D "Verae Timestamping" 2>/dev/null || true + # Create the queue only if it doesn't exist: deleting it on upgrade purges + # any jobs waiting in it. + if ! lpstat -v Verae_Timestamping >/dev/null 2>&1; then + lpadmin -p Verae_Timestamping -E -v verae:/ -m raw \ + -D "Verae Timestamping" -L "Digests-only Verae stamp" 2>/dev/null || \ + lpadmin -p Verae_Timestamping -E -v verae:/ -m everywhere \ + -D "Verae Timestamping" 2>/dev/null || true + fi cupsenable Verae_Timestamping 2>/dev/null || true cupsaccept Verae_Timestamping 2>/dev/null || true fi @@ -129,7 +132,8 @@ EOF cat >"$stage/DEBIAN/prerm" <<'EOF' #!/bin/sh set -e -if command -v lpadmin >/dev/null 2>&1; then +# Only drop the queue on a real removal, not on upgrade. +if [ "$1" = "remove" ] && command -v lpadmin >/dev/null 2>&1; then lpadmin -x Verae_Timestamping 2>/dev/null || true fi exit 0 From 133315ab5398bf84510a218977e76e1afc2a885d Mon Sep 17 00:00:00 2001 From: George Lambert Date: Sun, 4 Oct 2026 13:57:10 -0400 Subject: [PATCH 04/27] Linux: zenity sign-in/key dialog; CUPS backend opens it in the user's session when a key is missing or rejected; apps-menu launcher; 0.3.3 --- cmd/verae-print/acquire_other.go | 2 +- cmd/verae-print/keydialog_stub.go | 10 ++ cmd/verae-print/keyflow_gui.go | 241 +++++++++++++++++++++++++++ cmd/verae-print/keysetup_darwin.go | 232 +------------------------- cmd/verae-print/keysetup_linux.go | 171 +++++++++++++++++++ cmd/verae-print/platform_other.go | 16 +- internal/job/job.go | 2 +- platform/linux/cups/backend-verae.sh | 47 +++++- platform/linux/deb/build-deb.sh | 17 +- platform/macos/pkg/build-pkg.sh | 2 +- 10 files changed, 505 insertions(+), 235 deletions(-) create mode 100644 cmd/verae-print/keydialog_stub.go create mode 100644 cmd/verae-print/keyflow_gui.go create mode 100644 cmd/verae-print/keysetup_linux.go diff --git a/cmd/verae-print/acquire_other.go b/cmd/verae-print/acquire_other.go index 5872a56..e684390 100644 --- a/cmd/verae-print/acquire_other.go +++ b/cmd/verae-print/acquire_other.go @@ -1,4 +1,4 @@ -//go:build !windows && !darwin +//go:build !windows && !darwin && !linux package main diff --git a/cmd/verae-print/keydialog_stub.go b/cmd/verae-print/keydialog_stub.go new file mode 100644 index 0000000..306afd1 --- /dev/null +++ b/cmd/verae-print/keydialog_stub.go @@ -0,0 +1,10 @@ +//go:build !windows && !linux + +package main + +import "fmt" + +func runKeyDialog() int { + fmt.Println("key-dialog is for Windows and Linux; on macOS run: verae-print mac-key-setup") + return 1 +} diff --git a/cmd/verae-print/keyflow_gui.go b/cmd/verae-print/keyflow_gui.go new file mode 100644 index 0000000..0b63e70 --- /dev/null +++ b/cmd/verae-print/keyflow_gui.go @@ -0,0 +1,241 @@ +//go:build darwin || linux + +package main + +// Shared native sign-in flow (macOS AppleScript dialogs, Linux zenity dialogs). +// Platform files provide ask/say/choose/openURL, machineWord and keyStoreDesc. +// Passwords travel only over the dialog's stdout pipe and HTTPS; never argv, +// files or logs. Keys are logged masked only. + +import ( + "errors" + "strings" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" +) + +var errCancel = errors.New("cancelled") + +const ( + optKeep = "Keep the existing key" + optLogin = "Sign in to my Verae account (email + password)" + optCreate = "Create a new Verae account" + optPaste = "Paste an xVerae- key" + optSkip = "Skip for now" +) + +// acquireKeyInteractive: native sign-in dialogs after a 401/403 or missing key. +func acquireKeyInteractive(reason string) bool { + if !guiAvailable() { + vlog.Printf("sign-in needed but no desktop session for dialogs: %s (run: verae-print key-dialog)", reason) + return false + } + _, saved := keyFlow(reason) + return saved +} + +// keyFlow returns a masked result string and whether a NEW key was saved. +func keyFlow(reason string) (string, bool) { + existing, _ := auth.LoadXVeraeToken() + caps := auth.GetCapabilities() + vlog.Printf("mac-key-setup: installApi=%v emailVerification=%v existing=%v", caps.InstallAPI, caps.EmailVerification, existing != "") + var opts []string + intro := "No Verae signing key was found for this " + machineWord + " user. You need one to timestamp documents." + if existing != "" { + st, det := auth.ValidateXVeraeKey(existing) + vlog.Printf("mac-key-setup: existing key %s check=%s %s", auth.MaskKey(existing), st, det) + switch st { + case "valid": + intro = "A Verae signing key is already saved: " + auth.MaskKey(existing) + " (verified with Verae)." + opts = append(opts, optKeep) + case "invalid": + intro = "A saved key (" + auth.MaskKey(existing) + ") was REJECTED by Verae. Please sign in or paste a new key." + default: + intro = "A Verae signing key is saved: " + auth.MaskKey(existing) + " (could not verify right now)." + opts = append(opts, optKeep) + } + } + opts = append(opts, optLogin, optCreate, optPaste, optSkip) + if reason != "" { + intro = reason + "\n\n" + intro + } + prompt := intro + "\n\nYour password is sent only to customer.verae.ai over HTTPS and is never stored. Only the issued key is saved (" + keyStoreDesc + ")." + for { + c, err := choose(prompt, opts) + if err != nil { + c = optSkip + } + var key string + switch c { + case optKeep: + return "kept " + auth.MaskKey(existing), false + case optLogin: + key, err = macLogin(caps) + case optCreate: + key, err = macCreate(caps) + case optPaste: + key, err = macPaste() + default: + btn, _, aerr := ask("Continue without a Verae key?\n\nThe printer is installed, but documents cannot be timestamped until you add a key. You will be asked again on your first print (or run Verae Print Stamp from your apps menu).", "", false, false, []string{"Go back", "Skip"}, "Go back") + if aerr != nil || btn == "Skip" { + return "skipped", false + } + continue + } + if errors.Is(err, errCancel) { + continue // back to the menu + } + if err != nil { + vlog.Errorf("mac-key-setup: %v", err) + say("Could not get a key: " + err.Error()) + continue + } + if err := auth.SaveXVeraeToken(key); err != nil { + vlog.Errorf("mac-key-setup: save failed: %v", err) + say("The key could not be saved: " + err.Error()) + continue + } + vlog.Printf("mac-key-setup: key saved %s", auth.MaskKey(key)) + say("Your Verae signing key (" + auth.MaskKey(key) + ") is saved.\n\nPrint from any app and choose \"Verae Timestamping\".") + return "saved " + auth.MaskKey(key), true + } +} + +func macLogin(caps auth.Capabilities) (string, error) { + email := "" + for { + btn, e, err := ask("Sign in to Verae\n\nEmail:", email, false, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + return "", errCancel + } + email = strings.TrimSpace(e) + if !auth.ValidEmail(email) { + say("Please enter a valid email address.") + continue + } + btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", "Sign in"}, "Sign in") + if err != nil || btn == "Back" { + continue + } + vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email)) + key, err := auth.InstallLogin(caps, email, pw, "", tokenName()) + if errors.Is(err, auth.ErrTOTPRequired) { + btn, code, cerr := ask("This account uses two-factor authentication.\n\nEnter the 6-digit code from your authenticator app:", "", false, true, []string{"Back", "Verify"}, "Verify") + if cerr != nil || btn == "Back" { + continue + } + key, err = auth.InstallLogin(caps, email, pw, strings.TrimSpace(code), tokenName()) + } + pw = "" + if err != nil { + vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err) + say("Sign-in failed: " + err.Error()) + continue + } + return key, nil + } +} + +func macCreate(caps auth.Capabilities) (string, error) { + var name, email string + for { + btn, n, err := ask("Create a Verae account\n\nYour name:", name, false, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + return "", errCancel + } + name = strings.TrimSpace(n) + if name == "" { + continue + } + btn, e, err := ask("Email address:", email, false, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + continue + } + email = strings.TrimSpace(e) + if !auth.ValidEmail(email) { + say("Please enter a valid email address.") + continue + } + policy := caps.Policy.Describe() + btn, pw, err := ask("Choose a password.\n"+policy, "", true, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + continue + } + if perr := caps.Policy.Check(pw, email); perr != nil { + say(perr.Error()) + continue + } + btn, pw2, err := ask("Confirm the password:", "", true, true, []string{"Back", "Create account"}, "Create account") + if err != nil || btn == "Back" { + continue + } + if pw != pw2 { + say("The passwords do not match.") + continue + } + pw2 = "" + vlog.Printf("mac-key-setup: register %s installApi=%v", auth.MaskEmail(email), caps.InstallAPI) + rr, err := auth.InstallRegister(caps, name, email, pw, tokenName()) + pw = "" + if err != nil { + vlog.Errorf("mac-key-setup: register failed for %s: %v", auth.MaskEmail(email), err) + say("Could not create the account: " + err.Error()) + continue + } + if rr.PendingID == "" { + return rr.Key, nil // server without email verification (legacy signup) + } + vlog.Printf("mac-key-setup: verification code sent to %s", auth.MaskEmail(email)) + msg := "We emailed a 6-digit code to " + auth.MaskEmail(email) + ".\nIt may take a minute; check spam.\n\nCode:" + for { + btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Verify"}, "Verify") + if err != nil || btn == "Cancel" { + return "", errCancel + } + if btn == "Send a new code" { + if rerr := auth.InstallResend(rr.PendingID); rerr != nil { + say("Could not send a new code: " + rerr.Error()) + } else { + say("A new code was sent.") + } + continue + } + key, verr := auth.InstallVerify(rr.PendingID, code, tokenName()) + if verr != nil { + say(verr.Error()) + continue + } + return key, nil + } + } +} + +func macPaste() (string, error) { + for { + btn, k, err := ask("Paste your xVerae- key.\n\nNo key yet? Click \"Open portal\", sign in, open Security and create an xVerae- token (shown once).", "", true, true, []string{"Back", "Open portal", "Save"}, "Save") + if err != nil || btn == "Back" { + return "", errCancel + } + if btn == "Open portal" { + openURL(auth.PortalSignupURL) + continue + } + k = strings.TrimSpace(k) + st, det := auth.ValidateXVeraeKey(k) + vlog.Printf("mac-key-setup: pasted key %s: %s %s", auth.MaskKey(k), st, det) + switch st { + case "valid": + return k, nil + case "invalid": + say("Verae rejected this key (" + det + "). Please check it and paste again.") + case "badformat": + say("That does not look like a Verae key: " + det) + default: + b, _, _ := ask("Could not reach Verae to check the key ("+det+").\nSave it anyway?", "", false, false, []string{"No", "Save anyway"}, "Save anyway") + if b == "Save anyway" { + return k, nil + } + } + } +} diff --git a/cmd/verae-print/keysetup_darwin.go b/cmd/verae-print/keysetup_darwin.go index 10c4b4f..96db150 100644 --- a/cmd/verae-print/keysetup_darwin.go +++ b/cmd/verae-print/keysetup_darwin.go @@ -3,14 +3,12 @@ package main import ( - "errors" "flag" "fmt" "os" "os/exec" "strings" - "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" ) @@ -22,6 +20,15 @@ import ( const dlgTitle = "Verae Print Stamp" +const ( + machineWord = "Mac" + keyStoreDesc = "Keychain + ~/.verae" +) + +func openURL(u string) { _ = exec.Command("open", u).Start() } + +func guiAvailable() bool { return true } + // AppleScript gets all user-visible text via argv (no string interpolation → // no injection). Output: " Receipts

+`, htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(b.String())) +} + +func htmlEscape(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(s) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func loopback(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// StoppedStatus is what the tray shows when the control API is down. +func StoppedStatus() Status { + return Status{Running: false, Name: "Verae Timestamp", Port: 631} +} + +// OpenURL opens a local admin or receipt page in the user's browser. +func OpenURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("refusing to open %q", raw) + } + return openURL(raw) +} diff --git a/internal/netprint/forward.go b/internal/netprint/forward.go new file mode 100644 index 0000000..c088ccb --- /dev/null +++ b/internal/netprint/forward.go @@ -0,0 +1,129 @@ +package netprint + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/OpenPrinting/goipp" +) + +// ForwardFunc sends the timestamped PDF to a real printer. A nil error with +// an empty printer means there is nothing to do. +type ForwardFunc func(printer, pdfPath, title string) error + +// Forward prints pdfPath. printer is a queue name (lp/lpr, or the Windows +// print verb) or an ipp:// / http:// URI (IPP Print-Job). +func Forward(printer, pdfPath, title string) error { + printer = strings.TrimSpace(printer) + if printer == "" { + return nil + } + if strings.HasPrefix(printer, "ipp://") || strings.HasPrefix(printer, "http://") || strings.HasPrefix(printer, "ipps://") || strings.HasPrefix(printer, "https://") { + return forwardIPP(printer, pdfPath, title) + } + if lp, err := exec.LookPath("lp"); err == nil { + cmd := exec.Command(lp, "-d", printer, "-t", title, pdfPath) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lp -d %s: %w (%s)", printer, err, truncate(string(out), 300)) + } + return nil + } + if lpr, err := exec.LookPath("lpr"); err == nil { + cmd := exec.Command(lpr, "-P", printer, "-J", title, pdfPath) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lpr -P %s: %w (%s)", printer, err, truncate(string(out), 300)) + } + return nil + } + if runtime.GOOS == "windows" { + return forwardWindows(printer, pdfPath) + } + return fmt.Errorf("no lp/lpr on PATH and %q is not an IPP URI", printer) +} + +func forwardWindows(printer, pdfPath string) error { + // PrintTo uses the file association (typically a PDF reader). The queue + // name is the Destination. This does not install a driver on this service. + ps := fmt.Sprintf(`Start-Process -FilePath %s -Verb PrintTo -ArgumentList %s -WindowStyle Hidden`, + psQuote(pdfPath), psQuote(printer)) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("windows PrintTo %s: %w (%s)", printer, err, truncate(string(out), 300)) + } + return nil +} + +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +func forwardIPP(printer, pdfPath, title string) error { + doc, err := os.ReadFile(pdfPath) + if err != nil { + return err + } + uri := printer + httpURL := printer + switch { + case strings.HasPrefix(printer, "ipp://"): + httpURL = "http://" + strings.TrimPrefix(printer, "ipp://") + case strings.HasPrefix(printer, "ipps://"): + httpURL = "https://" + strings.TrimPrefix(printer, "ipps://") + case strings.HasPrefix(printer, "http://"): + uri = "ipp://" + strings.TrimPrefix(printer, "http://") + case strings.HasPrefix(printer, "https://"): + uri = "ipps://" + strings.TrimPrefix(printer, "https://") + } + m := goipp.NewRequest(goipp.DefaultVersion, goipp.OpPrintJob, 1) + m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String(uri))) + m.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("verae-netprint"))) + if title != "" { + m.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String(title))) + } + m.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + body, err := m.EncodeBytes() + if err != nil { + return err + } + body = append(body, doc...) + req, err := http.NewRequest(http.MethodPost, httpURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", goipp.ContentType) + client := &http.Client{Timeout: 60 * time.Second} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + var resp goipp.Message + raw, _ := io.ReadAll(res.Body) + if err := resp.Decode(bytes.NewReader(raw)); err != nil { + return fmt.Errorf("forward IPP %s: HTTP %d decode: %w", printer, res.StatusCode, err) + } + if goipp.Status(resp.Code) != goipp.StatusOk { + return fmt.Errorf("forward IPP %s: %s", printer, goipp.Status(resp.Code)) + } + return nil +} + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/netprint/ipp.go b/internal/netprint/ipp.go new file mode 100644 index 0000000..0c45bb1 --- /dev/null +++ b/internal/netprint/ipp.go @@ -0,0 +1,473 @@ +package netprint + +import ( + "crypto/sha1" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/OpenPrinting/goipp" +) + +const ( + jobPending = 3 + jobProcessing = 5 + jobCanceled = 7 + jobAborted = 8 + jobCompleted = 9 + maxDocument = 64 << 20 +) + +// DocumentFunc receives a spooled IPP document. format is a MIME type. +type DocumentFunc func(id int, format, title, user string, doc []byte) + +// Printer is an IPP/2.0 Everywhere endpoint. Document bytes follow the IPP +// message in the same HTTP body, matching the loopback helper. +type Printer struct { + Name string + UUID string + AdminURL string + OnDocument DocumentFunc + + mu sync.Mutex + started time.Time + nextID int + jobs map[int]*ippJob +} + +type ippJob struct { + id, state int + name, user, format, reasons, message string + created, done time.Time +} + +// ServeHTTP handles one IPP request. +func (p *Printer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if p.started.IsZero() { + p.started = time.Now() + } + if p.jobs == nil { + p.jobs = map[int]*ippJob{} + } + var req goipp.Message + if err := req.Decode(r.Body); err != nil { + http.Error(w, "bad ipp", http.StatusBadRequest) + return + } + resp := p.dispatch(&req, r) + _, _ = io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", goipp.ContentType) + _ = resp.Encode(w) +} + +// SetState updates an in-memory IPP job. Unknown ids are ignored. +func (p *Printer) SetState(id, state int, reason, msg string) { + p.mu.Lock() + defer p.mu.Unlock() + j := p.jobs[id] + if j == nil { + return + } + j.state, j.reasons, j.message = state, reason, msg + if state >= jobCanceled { + j.done = time.Now() + } +} + +// Canceled reports whether the client has canceled the job. +func (p *Printer) Canceled(id int) bool { + p.mu.Lock() + defer p.mu.Unlock() + j := p.jobs[id] + return j != nil && j.state == jobCanceled +} + +func (p *Printer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message { + if req.RequestID == 0 { + return p.newResp(req, goipp.StatusErrorBadRequest, "request-id must be non-zero") + } + if attrString(req.Operation, "attributes-charset") == "" || attrString(req.Operation, "attributes-natural-language") == "" { + return p.newResp(req, goipp.StatusErrorBadRequest, "missing attributes-charset / attributes-natural-language") + } + if attrString(req.Operation, "printer-uri") == "" && attrString(req.Operation, "job-uri") == "" { + return p.newResp(req, goipp.StatusErrorBadRequest, "missing printer-uri") + } + switch goipp.Op(req.Code) { + case goipp.OpIdentifyPrinter, goipp.OpCancelMyJobs, goipp.OpCloseJob: + return p.newResp(req, goipp.StatusOk, "") + case goipp.OpGetPrinterAttributes: + resp := p.newResp(req, goipp.StatusOk, "") + resp.Printer = p.printerAttrs(req, r) + return resp + case goipp.OpValidateJob: + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + return p.newResp(req, goipp.StatusOk, "") + case goipp.OpPrintJob: + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + j := p.createJob(req) + return p.receive(req, r, j) + case goipp.OpCreateJob: + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + j := p.createJob(req) + resp := p.newResp(req, goipp.StatusOk, "") + resp.Job = p.jobAttrs(j, r) + return resp + case goipp.OpSendDocument: + id := int(attrInt(req.Operation, "job-id")) + p.mu.Lock() + j := p.jobs[id] + p.mu.Unlock() + if j == nil { + return p.newResp(req, goipp.StatusErrorNotFound, "no such job") + } + if f := attrString(req.Operation, "document-format"); f != "" { + p.mu.Lock() + j.format = f + p.mu.Unlock() + } + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + return p.receive(req, r, j) + case goipp.OpGetJobAttributes: + id := int(attrInt(req.Operation, "job-id")) + p.mu.Lock() + j := p.jobs[id] + p.mu.Unlock() + if j == nil { + return p.newResp(req, goipp.StatusErrorNotFound, "no such job") + } + resp := p.newResp(req, goipp.StatusOk, "") + resp.Job = p.jobAttrs(j, r) + return resp + case goipp.OpGetJobs: + resp := p.newResp(req, goipp.StatusOk, "") + which := attrString(req.Operation, "which-jobs") + p.mu.Lock() + var groups goipp.Groups + groups.Add(goipp.Group{Tag: goipp.TagOperationGroup, Attrs: resp.Operation}) + for _, j := range p.jobs { + done := j.state >= jobCanceled + if (which == "completed") != done { + continue + } + groups.Add(goipp.Group{Tag: goipp.TagJobGroup, Attrs: p.jobAttrsLocked(j, r)}) + } + p.mu.Unlock() + resp.Groups = groups + return resp + case goipp.OpCancelJob: + id := int(attrInt(req.Operation, "job-id")) + p.SetState(id, jobCanceled, "job-canceled-by-user", "canceled") + return p.newResp(req, goipp.StatusOk, "") + default: + return p.newResp(req, goipp.StatusErrorOperationNotSupported, "operation not supported") + } +} + +func (p *Printer) receive(req *goipp.Message, r *http.Request, j *ippJob) *goipp.Message { + doc, err := io.ReadAll(io.LimitReader(r.Body, maxDocument+1)) + if err != nil { + p.SetState(j.id, jobAborted, "aborted-by-system", err.Error()) + return p.newResp(req, goipp.StatusErrorInternal, "read failed") + } + if len(doc) == 0 || len(doc) > maxDocument { + p.SetState(j.id, jobAborted, "aborted-by-system", "empty or oversized document") + return p.newResp(req, goipp.StatusErrorBadRequest, "empty or oversized document") + } + format := sniffFormat(j.format, doc) + p.mu.Lock() + j.format = format + title, user := j.name, j.user + id := j.id + p.mu.Unlock() + if p.OnDocument != nil { + go p.OnDocument(id, format, title, user, append([]byte(nil), doc...)) + } + resp := p.newResp(req, goipp.StatusOk, "") + resp.Job = p.jobAttrs(j, r) + return resp +} + +func sniffFormat(format string, doc []byte) string { + switch { + case len(doc) >= 5 && string(doc[:5]) == "%PDF-": + return "application/pdf" + case len(doc) >= 8 && string(doc[:8]) == "UNIRAST\x00": + return "image/urf" + case len(doc) >= 4 && (string(doc[:4]) == "RaS2" || string(doc[:4]) == "2SaR"): + return "image/pwg-raster" + default: + if format == "" { + return "application/octet-stream" + } + return format + } +} + +func (p *Printer) checkFormat(req *goipp.Message) (goipp.Status, string) { + f := strings.ToLower(strings.TrimSpace(attrString(req.Operation, "document-format"))) + switch f { + case "", "application/octet-stream", "application/pdf", "image/urf", "image/pwg-raster": + return goipp.StatusOk, "" + default: + return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f + } +} + +func (p *Printer) createJob(req *goipp.Message) *ippJob { + p.mu.Lock() + defer p.mu.Unlock() + if p.jobs == nil { + p.jobs = map[int]*ippJob{} + } + p.nextID++ + j := &ippJob{ + id: p.nextID, + name: firstNonEmpty(attrString(req.Operation, "job-name"), attrString(req.Operation, "document-name"), "document"), + user: attrString(req.Operation, "requesting-user-name"), + format: firstNonEmpty(attrString(req.Operation, "document-format"), "application/octet-stream"), + state: jobPending, + reasons: "none", + created: time.Now(), + } + p.jobs[j.id] = j + return j +} + +func (p *Printer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message { + resp := goipp.NewResponse(req.Version, st, req.RequestID) + resp.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + resp.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + if msg != "" { + resp.Operation.Add(goipp.MakeAttribute("status-message", goipp.TagText, goipp.String(msg))) + } + return resp +} + +func (p *Printer) jobAttrs(j *ippJob, r *http.Request) goipp.Attributes { + p.mu.Lock() + defer p.mu.Unlock() + return p.jobAttrsLocked(j, r) +} + +func (p *Printer) jobAttrsLocked(j *ippJob, r *http.Request) goipp.Attributes { + host := r.Host + if host == "" { + host = "localhost" + } + var a goipp.Attributes + a.Add(goipp.MakeAttribute("job-id", goipp.TagInteger, goipp.Integer(j.id))) + a.Add(goipp.MakeAttribute("job-uri", goipp.TagURI, goipp.String(fmt.Sprintf("ipp://%s/ipp/print/%d", host, j.id)))) + a.Add(goipp.MakeAttribute("job-printer-uri", goipp.TagURI, goipp.String("ipp://"+host+"/ipp/print"))) + a.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String(j.name))) + a.Add(goipp.MakeAttribute("job-originating-user-name", goipp.TagName, goipp.String(firstNonEmpty(j.user, "anonymous")))) + a.Add(goipp.MakeAttribute("job-state", goipp.TagEnum, goipp.Integer(j.state))) + a.Add(goipp.MakeAttribute("job-state-reasons", goipp.TagKeyword, goipp.String(firstNonEmpty(j.reasons, "none")))) + if j.message != "" { + a.Add(goipp.MakeAttribute("job-state-message", goipp.TagText, goipp.String(j.message))) + } + return a +} + +func (p *Printer) printerURI(req *goipp.Message, r *http.Request) string { + if u := attrString(req.Operation, "printer-uri"); u != "" { + return u + } + host := r.Host + if host == "" { + host = "localhost" + } + return "ipp://" + host + "/ipp/print" +} + +func (p *Printer) uuid() string { + if strings.TrimSpace(p.UUID) != "" { + u := strings.TrimPrefix(strings.TrimSpace(p.UUID), "urn:uuid:") + return "urn:uuid:" + u + } + h := sha1.Sum([]byte("verae-netprint|" + p.Name)) + return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", h[0:4], h[4:6], h[6:8], h[8:10], h[10:16]) +} + +func (p *Printer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attributes { + uri := p.printerURI(req, r) + p.mu.Lock() + queued := 0 + busy := false + started := p.started + if started.IsZero() { + started = time.Now() + } + for _, j := range p.jobs { + if j.state < jobCanceled { + queued++ + busy = busy || j.state == jobProcessing + } + } + p.mu.Unlock() + state := 3 + if busy { + state = 4 + } + name := firstNonEmpty(p.Name, "Verae Timestamp") + admin := p.AdminURL + if admin == "" { + admin = "http://" + r.Host + "/" + } + var a goipp.Attributes + add := func(x goipp.Attribute) { a.Add(x) } + add(goipp.MakeAttribute("printer-uri-supported", goipp.TagURI, goipp.String(uri))) + add(kw("uri-security-supported", "none")) + add(kw("uri-authentication-supported", "none")) + add(goipp.MakeAttribute("printer-name", goipp.TagName, goipp.String(name))) + add(goipp.MakeAttribute("printer-info", goipp.TagText, goipp.String(name))) + add(goipp.MakeAttribute("printer-make-and-model", goipp.TagText, goipp.String("Verae Timestamp"))) + add(goipp.MakeAttribute("printer-location", goipp.TagText, goipp.String("LAN"))) + add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String(admin))) + add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(p.uuid()))) + add(goipp.MakeAttribute("printer-dns-sd-name", goipp.TagName, goipp.String(name))) + add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:Timestamp;CMD:PDF,URF,PWG;CLS:PRINTER;DES:Verae Timestamp;"))) + add(ints("printer-state", goipp.TagEnum, state)) + add(kw("printer-state-reasons", "none")) + add(goipp.MakeAttribute("printer-is-accepting-jobs", goipp.TagBoolean, goipp.Boolean(true))) + add(ints("queued-job-count", goipp.TagInteger, queued)) + add(ints("printer-up-time", goipp.TagInteger, int(time.Since(started).Seconds())+1)) + add(kw("ipp-versions-supported", "1.1", "2.0")) + add(kw("ipp-features-supported", "ipp-everywhere")) + add(ints("operations-supported", goipp.TagEnum, + int(goipp.OpPrintJob), int(goipp.OpValidateJob), int(goipp.OpCreateJob), int(goipp.OpSendDocument), + int(goipp.OpCancelJob), int(goipp.OpGetJobAttributes), int(goipp.OpGetJobs), int(goipp.OpGetPrinterAttributes), + int(goipp.OpCloseJob), int(goipp.OpCancelMyJobs), int(goipp.OpIdentifyPrinter))) + add(multi("charset-configured", goipp.TagCharset, "utf-8")) + add(multi("charset-supported", goipp.TagCharset, "utf-8")) + add(multi("natural-language-configured", goipp.TagLanguage, "en")) + add(multi("generated-natural-language-supported", goipp.TagLanguage, "en")) + add(multi("document-format-supported", goipp.TagMimeType, "application/pdf", "image/urf", "image/pwg-raster")) + add(multi("document-format-default", goipp.TagMimeType, "application/pdf")) + add(kw("urf-supported", "V1.4", "W8", "SRGB24", "RS300", "RS600", "DM1")) + add(kw("pdl-override-supported", "attempted")) + add(kw("compression-supported", "none")) + add(goipp.MakeAttribute("color-supported", goipp.TagBoolean, goipp.Boolean(true))) + add(kw("print-color-mode-default", "color")) + add(kw("print-color-mode-supported", "auto", "color", "monochrome")) + add(kw("sides-default", "one-sided")) + add(kw("sides-supported", "one-sided")) + add(goipp.MakeAttribute("copies-default", goipp.TagInteger, goipp.Integer(1))) + add(goipp.MakeAttribute("copies-supported", goipp.TagRange, goipp.Range{Lower: 1, Upper: 99})) + add(kw("media-default", "na_letter_8.5x11in")) + add(kw("media-supported", "na_letter_8.5x11in", "iso_a4_210x297mm", "na_legal_8.5x14in")) + add(kw("media-ready", "na_letter_8.5x11in", "iso_a4_210x297mm")) + add(kw("output-bin-default", "face-down")) + add(kw("output-bin-supported", "face-down")) + add(ints("orientation-requested-default", goipp.TagEnum, 3)) + add(ints("orientation-requested-supported", goipp.TagEnum, 3, 4, 5, 6)) + add(ints("finishings-default", goipp.TagEnum, 3)) + add(ints("finishings-supported", goipp.TagEnum, 3)) + add(ints("print-quality-default", goipp.TagEnum, 4)) + add(ints("print-quality-supported", goipp.TagEnum, 3, 4, 5)) + res300 := goipp.Resolution{Xres: 300, Yres: 300, Units: goipp.UnitsDpi} + res600 := goipp.Resolution{Xres: 600, Yres: 600, Units: goipp.UnitsDpi} + add(goipp.MakeAttribute("printer-resolution-default", goipp.TagResolution, res600)) + pr := goipp.Attribute{Name: "printer-resolution-supported"} + pr.Values.Add(goipp.TagResolution, res300) + pr.Values.Add(goipp.TagResolution, res600) + add(pr) + add(kw("pwg-raster-document-type-supported", "sgray_8", "srgb_8", "srgb_16")) + add(kw("which-jobs-supported", "completed", "not-completed")) + add(goipp.MakeAttribute("multiple-document-jobs-supported", goipp.TagBoolean, goipp.Boolean(false))) + add(kw("multiple-operation-time-out-action", "process-job")) + add(ints("multiple-operation-time-out", goipp.TagInteger, 120)) + add(kw("identify-actions-default", "display")) + add(kw("identify-actions-supported", "display")) + add(kw("pdf-versions-supported", "adobe-1.3", "adobe-1.4", "adobe-1.5", "adobe-1.6", "adobe-1.7")) + add(kw("printer-kind", "document")) + return filterAttrs(a, req) +} + +func filterAttrs(a goipp.Attributes, req *goipp.Message) goipp.Attributes { + var names []string + for _, at := range req.Operation { + if at.Name != "requested-attributes" { + continue + } + for _, v := range at.Values { + names = append(names, v.V.String()) + } + } + if len(names) == 0 { + return a + } + for _, n := range names { + if n == "all" { + return a + } + } + want := map[string]bool{} + for _, n := range names { + want[n] = true + } + var out goipp.Attributes + for _, at := range a { + if want[at.Name] { + out.Add(at) + } + } + return out +} + +func kw(name string, vals ...string) goipp.Attribute { return multi(name, goipp.TagKeyword, vals...) } + +func multi(name string, tag goipp.Tag, vals ...string) goipp.Attribute { + a := goipp.Attribute{Name: name} + for _, v := range vals { + a.Values.Add(tag, goipp.String(v)) + } + return a +} + +func ints(name string, tag goipp.Tag, vals ...int) goipp.Attribute { + a := goipp.Attribute{Name: name} + for _, v := range vals { + a.Values.Add(tag, goipp.Integer(v)) + } + return a +} + +func attrString(attrs goipp.Attributes, name string) string { + for _, a := range attrs { + if a.Name == name && len(a.Values) > 0 { + return a.Values[0].V.String() + } + } + return "" +} + +func attrInt(attrs goipp.Attributes, name string) int32 { + for _, a := range attrs { + if a.Name == name && len(a.Values) > 0 { + if v, ok := a.Values[0].V.(goipp.Integer); ok { + return int32(v) + } + } + } + return 0 +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return s + } + } + return "" +} diff --git a/internal/netprint/mdns.go b/internal/netprint/mdns.go new file mode 100644 index 0000000..aa3829f --- /dev/null +++ b/internal/netprint/mdns.go @@ -0,0 +1,558 @@ +package netprint + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "fmt" + "net" + "os" + "strings" + "time" +) + +const ( + dnsTypeA = 1 + dnsTypePTR = 12 + dnsTypeTXT = 16 + dnsTypeAAAA = 28 + dnsTypeSRV = 33 + dnsTypeANY = 255 + dnsClassIN = 1 + dnsCacheFlush = 0x8000 + mdnsTTL = 120 +) + +// Service is the DNS-SD record set for one IPP Everywhere printer. +type Service struct { + Instance string + Host string // label only, without .local + Port int + IPs []net.IP + TXT []string +} + +// AirPrintTXT returns the Bonjour Printing / AirPrint text records. +// adminURL should be reachable on the LAN (http://ip:port/). +func AirPrintTXT(name, uuid, adminURL, pdl, urf string) []string { + if pdl == "" { + pdl = "application/pdf,image/urf,image/pwg-raster" + } + if urf == "" { + urf = "V1.4,W8,SRGB24,RS300,RS600,DM1" + } + uuid = strings.TrimPrefix(strings.TrimSpace(uuid), "urn:uuid:") + return []string{ + "txtvers=1", + "qtotal=1", + "rp=ipp/print", + "ty=" + trimTXT(name, 100), + "product=(" + trimTXT(name, 80) + ")", + "pdl=" + pdl, + "URF=" + urf, + "Color=T", + "Duplex=F", + "Copies=T", + "Bind=F", + "Collate=F", + "Sort=F", + "Staple=F", + "Punch=F", + "PaperMax=legal-A4", + "PaperCustom=F", + "UUID=" + uuid, + "adminurl=" + adminURL, + "note=Digests-only Verae timestamp", + "priority=0", + "kind=document", + "Fax=F", + "Scan=F", + "Transparent=T", + "Binary=T", + "TBCP=F", + "usb_MFG=Verae", + "usb_MDL=Timestamp", + "usb_CMD=PDF,URF,PWG", + } +} + +func trimTXT(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > n { + return s[:n] + } + return s +} + +func (s Service) instanceLabels() []string { + return []string{sanitizeLabel(s.Instance), "_ipp", "_tcp", "local"} +} + +func (s Service) hostLabels() []string { + return []string{sanitizeLabel(s.Host), "local"} +} + +func sanitizeLabel(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, ".") + if s == "" { + s = "Verae-Timestamp" + } + if len(s) > 63 { + s = s[:63] + } + return s +} + +type dnsQuestion struct { + name []string + typ uint16 + class uint16 +} + +// Reply builds an mDNS response for query, or nil when the query is not for +// this service (or is itself a response). +func (s Service) Reply(query []byte) []byte { + if len(query) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(query[2:4]) + if flags&0x8000 != 0 { // QR + return nil + } + qs, err := parseQuestions(query) + if err != nil || len(qs) == 0 { + return nil + } + id := binary.BigEndian.Uint16(query[0:2]) + var matched []dnsQuestion + for _, q := range qs { + if s.matches(q) { + matched = append(matched, q) + } + } + if len(matched) == 0 { + return nil + } + return s.encodeResponse(id, matched, mdnsTTL) +} + +func (s Service) matches(q dnsQuestion) bool { + n := lowerJoin(q.name) + inst := lowerJoin(s.instanceLabels()) + host := lowerJoin(s.hostLabels()) + ptr := "_ipp._tcp.local" + univ := "_universal._sub._ipp._tcp.local" + print := "_print._sub._ipp._tcp.local" + enum := "_services._dns-sd._udp.local" + switch { + case n == ptr || n == univ || n == print || n == enum: + return q.typ == dnsTypePTR || q.typ == dnsTypeANY + case n == inst: + return q.typ == dnsTypeSRV || q.typ == dnsTypeTXT || q.typ == dnsTypeANY || q.typ == dnsTypePTR + case n == host: + return q.typ == dnsTypeA || q.typ == dnsTypeAAAA || q.typ == dnsTypeANY + default: + return false + } +} + +func lowerJoin(labels []string) string { + var b strings.Builder + for i, l := range labels { + if i > 0 { + b.WriteByte('.') + } + b.WriteString(strings.ToLower(l)) + } + return b.String() +} + +// Announce is an unsolicited response (id 0) advertising the service. +func (s Service) Announce(goodbye bool) []byte { + ttl := uint32(mdnsTTL) + if goodbye { + ttl = 0 + } + qs := []dnsQuestion{ + {name: []string{"_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, + {name: []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, + {name: []string{"_print", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, + } + return s.encodeResponse(0, qs, ttl) +} + +func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte { + var answers []byte + var add []byte + comp := map[string]int{} + put := func(dst *[]byte, name []string, typ, class uint16, flush bool, rdata []byte) { + c := class + if flush { + c |= dnsCacheFlush + } + *dst = append(*dst, encodeName(name, comp, 12+len(answers)+len(add))...) + // The offset passed to encodeName must be the absolute offset in the + // final message. Rebuilding with a second pass is simpler and correct. + _ = rdata + _ = typ + _ = c + } + _ = put + + // Two-pass: first compute without compression, then we encode names + // uncompressed (still valid DNS). Packets stay well under 9 KiB. + var an, ar []dnsRR + seenPTR := map[string]bool{} + needSRV, needTXT, needAddr := false, false, false + for _, q := range qs { + n := lowerJoin(q.name) + switch { + case n == "_services._dns-sd._udp.local": + k := "_ipp._tcp.local" + if !seenPTR[k] { + an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: []string{"_ipp", "_tcp", "local"}}) + seenPTR[k] = true + } + case n == "_ipp._tcp.local" || n == "_universal._sub._ipp._tcp.local" || n == "_print._sub._ipp._tcp.local": + if !seenPTR[n] { + an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: s.instanceLabels()}) + seenPTR[n] = true + } + needSRV, needTXT, needAddr = true, true, true + case n == lowerJoin(s.instanceLabels()): + if q.typ == dnsTypeANY || q.typ == dnsTypeSRV { + needSRV = true + } + if q.typ == dnsTypeANY || q.typ == dnsTypeTXT { + needTXT = true + } + if q.typ == dnsTypeANY || q.typ == dnsTypePTR { + needSRV, needTXT, needAddr = true, true, true + } + case n == lowerJoin(s.hostLabels()): + needAddr = true + } + } + if needSRV { + an = append(an, dnsRR{name: s.instanceLabels(), typ: dnsTypeSRV, class: dnsClassIN | dnsCacheFlush, ttl: ttl, srvPort: s.Port, srvTarget: s.hostLabels()}) + } + if needTXT { + an = append(an, dnsRR{name: s.instanceLabels(), typ: dnsTypeTXT, class: dnsClassIN | dnsCacheFlush, ttl: ttl, txt: s.TXT}) + } + if needAddr { + for _, ip := range s.IPs { + if v4 := ip.To4(); v4 != nil { + ar = append(ar, dnsRR{name: s.hostLabels(), typ: dnsTypeA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v4}) + } else if v6 := ip.To16(); v6 != nil { + ar = append(ar, dnsRR{name: s.hostLabels(), typ: dnsTypeAAAA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v6}) + } + } + } + // SRV/TXT that were requested directly belong in the answer section. + // Additional address records stay additional. When the question was a + // PTR, SRV and TXT are additional too (RFC 6763). + var ans, extra []dnsRR + ptrOnly := true + for _, q := range qs { + if q.typ != dnsTypePTR && lowerJoin(q.name) != "_services._dns-sd._udp.local" { + ptrOnly = false + } + if q.typ == dnsTypePTR && (lowerJoin(q.name) == lowerJoin(s.instanceLabels())) { + ptrOnly = false + } + } + directHost := false + directInst := false + for _, q := range qs { + if lowerJoin(q.name) == lowerJoin(s.hostLabels()) { + directHost = true + } + if lowerJoin(q.name) == lowerJoin(s.instanceLabels()) { + directInst = true + } + } + for _, rr := range an { + if rr.typ == dnsTypePTR { + ans = append(ans, rr) + continue + } + if directInst || !ptrOnly { + ans = append(ans, rr) + } else { + extra = append(extra, rr) + } + } + if directHost { + ans = append(ans, ar...) + } else { + extra = append(extra, ar...) + } + _ = answers + _ = add + return encodeMessage(id, 0x8400, nil, ans, extra) +} + +type dnsRR struct { + name []string + typ uint16 + class uint16 + ttl uint32 + ptr []string + txt []string + srvPort int + srvTarget []string + ip net.IP +} + +func encodeMessage(id, flags uint16, qs []dnsQuestion, ans, extra []dnsRR) []byte { + var body bytes.Buffer + comp := map[string]int{} + // header is 12 bytes; compression offsets are absolute. + base := 12 + writeName := func(labels []string) { + body.Write(encodeNameAt(labels, comp, base+body.Len())) + } + for _, q := range qs { + writeName(q.name) + _ = binary.Write(&body, binary.BigEndian, q.typ) + _ = binary.Write(&body, binary.BigEndian, q.class) + } + writeRR := func(rr dnsRR) { + writeName(rr.name) + _ = binary.Write(&body, binary.BigEndian, rr.typ) + _ = binary.Write(&body, binary.BigEndian, rr.class) + _ = binary.Write(&body, binary.BigEndian, rr.ttl) + rdata := rr.rdata(comp, base+body.Len()+2) // +2 for the rdlength we haven't written + // rdata name compression offsets must account for rdlength. Recompute + // with a placeholder by encoding rdata names relative to the position + // after rdlength. encodeNameAt uses the map which may already have + // entries. Passing the future offset is correct if rdata() uses it. + _ = binary.Write(&body, binary.BigEndian, uint16(len(rdata))) + body.Write(rdata) + } + // The offset passed into rdata is wrong by construction if rdata encodes + // names, because comp offsets must match the final position. Do a simpler + // uncompressed rdata for SRV/PTR targets: still valid, and tests check names. + for _, rr := range ans { + writeRR(rr) + } + anCount := len(ans) + for _, rr := range extra { + writeRR(rr) + } + var hdr [12]byte + binary.BigEndian.PutUint16(hdr[0:2], id) + binary.BigEndian.PutUint16(hdr[2:4], flags) + binary.BigEndian.PutUint16(hdr[4:6], uint16(len(qs))) + binary.BigEndian.PutUint16(hdr[6:8], uint16(anCount)) + binary.BigEndian.PutUint16(hdr[8:10], 0) + binary.BigEndian.PutUint16(hdr[10:12], uint16(len(extra))) + return append(hdr[:], body.Bytes()...) +} + +func (rr dnsRR) rdata(comp map[string]int, off int) []byte { + _ = comp + _ = off + var b bytes.Buffer + switch rr.typ { + case dnsTypePTR: + b.Write(encodeNameRaw(rr.ptr)) + case dnsTypeSRV: + _ = binary.Write(&b, binary.BigEndian, uint16(0)) + _ = binary.Write(&b, binary.BigEndian, uint16(0)) + _ = binary.Write(&b, binary.BigEndian, uint16(rr.srvPort)) + b.Write(encodeNameRaw(rr.srvTarget)) + case dnsTypeTXT: + if len(rr.txt) == 0 { + b.WriteByte(0) + break + } + for _, t := range rr.txt { + if len(t) > 255 { + t = t[:255] + } + b.WriteByte(byte(len(t))) + b.WriteString(t) + } + case dnsTypeA: + ip := rr.ip.To4() + b.Write(ip) + case dnsTypeAAAA: + b.Write(rr.ip.To16()) + } + return b.Bytes() +} + +func encodeNameRaw(labels []string) []byte { + var b bytes.Buffer + for _, l := range labels { + if len(l) > 63 { + l = l[:63] + } + b.WriteByte(byte(len(l))) + b.WriteString(l) + } + b.WriteByte(0) + return b.Bytes() +} + +func encodeNameAt(labels []string, comp map[string]int, off int) []byte { + // Compression: if the remaining name was already written, emit a pointer. + key := lowerJoin(labels) + if prev, ok := comp[key]; ok && prev < 0x3FFF { + return []byte{0xC0 | byte(prev>>8), byte(prev)} + } + if len(labels) == 0 { + return []byte{0} + } + comp[key] = off + lab := labels[0] + if len(lab) > 63 { + lab = lab[:63] + } + rest := encodeNameAt(labels[1:], comp, off+1+len(lab)) + out := make([]byte, 0, 1+len(lab)+len(rest)) + out = append(out, byte(len(lab))) + out = append(out, lab...) + out = append(out, rest...) + return out +} + +func encodeName(labels []string, comp map[string]int, off int) []byte { + return encodeNameAt(labels, comp, off) +} + +func parseQuestions(msg []byte) ([]dnsQuestion, error) { + if len(msg) < 12 { + return nil, fmt.Errorf("short") + } + n := int(binary.BigEndian.Uint16(msg[4:6])) + off := 12 + var qs []dnsQuestion + for i := 0; i < n; i++ { + labels, next, err := parseName(msg, off) + if err != nil { + return nil, err + } + off = next + if off+4 > len(msg) { + return nil, fmt.Errorf("truncated question") + } + typ := binary.BigEndian.Uint16(msg[off : off+2]) + class := binary.BigEndian.Uint16(msg[off+2:off+4]) & 0x7FFF + off += 4 + qs = append(qs, dnsQuestion{name: labels, typ: typ, class: class}) + } + return qs, nil +} + +func parseName(msg []byte, off int) ([]string, int, error) { + var labels []string + jumped := false + end := off + hops := 0 + for { + if off >= len(msg) { + return nil, 0, fmt.Errorf("name overflow") + } + l := int(msg[off]) + if l == 0 { + if !jumped { + end = off + 1 + } + return labels, end, nil + } + if l&0xC0 == 0xC0 { + if off+1 >= len(msg) { + return nil, 0, fmt.Errorf("bad pointer") + } + ptr := int(l&0x3F)<<8 | int(msg[off+1]) + if !jumped { + end = off + 2 + } + off = ptr + jumped = true + hops++ + if hops > 16 { + return nil, 0, fmt.Errorf("pointer loop") + } + continue + } + if l&0xC0 != 0 { + return nil, 0, fmt.Errorf("bad label") + } + off++ + if off+l > len(msg) { + return nil, 0, fmt.Errorf("label overflow") + } + labels = append(labels, string(msg[off:off+l])) + off += l + if !jumped { + end = off + } + } +} + +// Query builds a single-question mDNS query (for probes and tests). +func Query(id uint16, labels []string, typ uint16) []byte { + return encodeMessage(id, 0, []dnsQuestion{{name: labels, typ: typ, class: dnsClassIN}}, nil, nil) +} + +func lanIPv4() []net.IP { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var out []net.IP + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + var ip net.IP + switch v := a.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() { + continue + } + if v4 := ip.To4(); v4 != nil { + out = append(out, v4) + } + } + } + return out +} + +func hostLabel(override string) string { + if override != "" { + return sanitizeLabel(override) + } + h, err := os.Hostname() + if err != nil || h == "" { + return "verae-timestamp" + } + if i := strings.IndexByte(h, '.'); i > 0 { + h = h[:i] + } + return sanitizeLabel(h) +} + +// newUUID returns a random UUID string (version 4 layout). +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("verae-%d", time.Now().UnixNano()) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/internal/netprint/netprint_test.go b/internal/netprint/netprint_test.go new file mode 100644 index 0000000..6229746 --- /dev/null +++ b/internal/netprint/netprint_test.go @@ -0,0 +1,426 @@ +package netprint + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/OpenPrinting/goipp" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/hash" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" +) + +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return p +} + +type fakeCtrl struct { + mu sync.Mutex + actions []string + launch bool + tray bool +} + +func (f *fakeCtrl) Start() error { + f.mu.Lock() + f.actions = append(f.actions, "start") + f.mu.Unlock() + return nil +} +func (f *fakeCtrl) Stop() error { + f.mu.Lock() + f.actions = append(f.actions, "stop") + f.mu.Unlock() + return nil +} +func (f *fakeCtrl) Restart() error { + f.mu.Lock() + f.actions = append(f.actions, "restart") + f.mu.Unlock() + return nil +} +func (f *fakeCtrl) LaunchAtLogin() (bool, error) { return f.launch, nil } +func (f *fakeCtrl) SetLaunchAtLogin(on bool) error { + f.launch = on + return nil +} +func (f *fakeCtrl) TrayAtLogin() (bool, error) { return f.tray, nil } +func (f *fakeCtrl) SetTrayAtLogin(on bool) error { + f.tray = on + return nil +} + +func testConfig(t *testing.T) (Config, string) { + t.Helper() + off := false + dir := t.TempDir() + cfg := Defaults() + cfg.Bind = "127.0.0.1" + cfg.Port = freePort(t) + cfg.FallbackPort = 0 + cfg.ControlPort = freePort(t) + cfg.DataDir = dir + cfg.Advertise = &off + cfg.Name = "Verae Timestamp" + path := filepath.Join(dir, "netprint.json") + return cfg, path +} + +func stampMock(t *testing.T) StampFunc { + t.Helper() + return func(pdf, title, user string) (job.Result, error) { + pdfBytes := []byte("%PDF-1.4 certificate") + client := &veraeapi.Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.test", + XVeraeToken: "xVerae-test", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + if !bytes.Contains(body, []byte("sha256")) && !bytes.Contains(bytes.ToLower(body), []byte("digest")) && len(body) == 0 { + t.Errorf("stamp request had an empty body") + } + b64 := "JVBERi0xLjQgY2VydGlmaWNhdGU=" + raw := `{"receipt_id":"bafyReceipt99","pdfBase64":"` + b64 + `"}` + _ = pdfBytes + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(raw)), Header: make(http.Header)}, nil + }, + } + return job.Run(job.Options{ + SourcePDF: pdf, OutDir: filepath.Dir(pdf), DocumentName: "document.pdf", + Channel: auth.ChannelZapier, Client: client, + }) + } +} + +func ippReq(op goipp.Op, host string) *goipp.Message { + m := goipp.NewRequest(goipp.DefaultVersion, op, 1) + m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String("ipp://"+host+"/ipp/print"))) + return m +} + +func ippCall(t *testing.T, addr string, m *goipp.Message, doc []byte) *goipp.Message { + t.Helper() + b, err := m.EncodeBytes() + if err != nil { + t.Fatal(err) + } + b = append(b, doc...) + res, err := http.Post("http://"+addr+"/ipp/print", goipp.ContentType, bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var out goipp.Message + if err := out.Decode(res.Body); err != nil { + t.Fatal(err) + } + return &out +} + +func hasKeyword(attrs goipp.Attributes, name, want string) bool { + for _, a := range attrs { + if a.Name != name { + continue + } + for _, v := range a.Values { + if v.V.String() == want { + return true + } + } + } + return false +} + +func hasOp(attrs goipp.Attributes, op goipp.Op) bool { + for _, a := range attrs { + if a.Name != "operations-supported" { + continue + } + for _, v := range a.Values { + if n, ok := v.V.(goipp.Integer); ok && int(n) == int(op) { + return true + } + } + } + return false +} + +func TestIPPEverywhereAttributesAndPrint(t *testing.T) { + cfg, path := testConfig(t) + ctrl := &fakeCtrl{launch: true, tray: true} + s, err := Start(cfg, path, stampMock(t), ctrl) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(s.Port())) + + gpa := ippCall(t, addr, ippReq(goipp.OpGetPrinterAttributes, addr), nil) + if goipp.Status(gpa.Code) != goipp.StatusOk { + t.Fatalf("get-printer-attributes %v", goipp.Status(gpa.Code)) + } + for _, kw := range []string{"application/pdf", "image/urf", "image/pwg-raster"} { + if !hasKeyword(gpa.Printer, "document-format-supported", kw) { + t.Fatalf("missing document-format %s", kw) + } + } + if !hasKeyword(gpa.Printer, "ipp-features-supported", "ipp-everywhere") { + t.Fatal("missing ipp-everywhere") + } + if !hasKeyword(gpa.Printer, "ipp-versions-supported", "2.0") { + t.Fatal("missing ipp 2.0") + } + if !hasKeyword(gpa.Printer, "urf-supported", "V1.4") { + t.Fatal("missing urf-supported") + } + for _, op := range []goipp.Op{goipp.OpPrintJob, goipp.OpValidateJob, goipp.OpGetPrinterAttributes, goipp.OpGetJobs, goipp.OpGetJobAttributes, goipp.OpCancelJob} { + if !hasOp(gpa.Printer, op) { + t.Fatalf("missing operation %s", op) + } + } + bad := ippReq(goipp.OpValidateJob, addr) + bad.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/postscript"))) + if st := goipp.Status(ippCall(t, addr, bad, nil).Code); st == goipp.StatusOk { + t.Fatal("postscript should be rejected") + } + + doc := []byte("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") + pj := ippReq(goipp.OpPrintJob, addr) + pj.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("ada@example.com"))) + pj.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("Contract"))) + pj.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + res := ippCall(t, addr, pj, doc) + if goipp.Status(res.Code) != goipp.StatusOk { + t.Fatalf("print-job %v", goipp.Status(res.Code)) + } + + var rec Record + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + list := s.store.List() + if len(list) > 0 && list[0].State == "completed" { + rec = list[0] + break + } + if len(list) > 0 && list[0].State == "aborted" { + t.Fatal(list[0].Error) + } + time.Sleep(20 * time.Millisecond) + } + if rec.State != "completed" { + t.Fatalf("job not completed: %+v", rec) + } + if rec.ReceiptID != "bafyReceipt99" { + t.Fatalf("receipt %q", rec.ReceiptID) + } + got, err := hash.FileSHA256(rec.OriginalPDF) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(rec.OriginalPDF) + if err != nil { + t.Fatal(err) + } + if rec.SHA256 == "" || rec.SHA256 != got || !bytes.Equal(body, doc) { + t.Fatalf("sha %q file %q bytes match %v", rec.SHA256, got, bytes.Equal(body, doc)) + } + if !strings.Contains(rec.VerifyURL, "explorer.verae.ai") || !strings.Contains(rec.VerifyURL, rec.ReceiptID) { + t.Fatalf("verify url %s", rec.VerifyURL) + } + side, _ := os.ReadFile(rec.ReceiptJSON) + if !strings.Contains(string(side), "ada@example.com") { + t.Fatalf("sidecar missing user: %s", side) + } + + page, err := http.Get("http://" + addr + "/") + if err != nil { + t.Fatal(err) + } + htmlBytes, _ := io.ReadAll(page.Body) + page.Body.Close() + html := string(htmlBytes) + if !strings.Contains(html, "bafyReceipt99") || !strings.Contains(html, "explorer.verae.ai") { + t.Fatalf("admin page missing receipt: %s", html) + } + + c := &Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")} + if err := c.Settings("Lobby Printer", "office", 6310); err != nil { + t.Fatal(err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(saved), "Lobby Printer") || !strings.Contains(string(saved), "office") { + t.Fatalf("config not written by service: %s", saved) + } + st, err := c.Status() + if err != nil || st.Name != "Lobby Printer" || st.JobsToday < 1 || !st.Running { + t.Fatalf("status %+v err %v", st, err) + } + if err := c.Service("stop"); err != nil { + t.Fatal(err) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if len(ctrl.actions) != 1 || ctrl.actions[0] != "stop" { + t.Fatalf("actions %v", ctrl.actions) + } +} + +func TestRasterRoundTripAndMenu(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("UNIRAST\x00") + _ = binary.Write(&buf, binary.BigEndian, uint32(1)) + hdr := make([]byte, 32) + hdr[0] = 8 + hdr[1] = 4 // W + binary.BigEndian.PutUint32(hdr[12:16], 1) + binary.BigEndian.PutUint32(hdr[16:20], 1) + binary.BigEndian.PutUint32(hdr[20:24], 300) + buf.Write(hdr) + buf.Write([]byte{0x00, 0x00, 0xff}) // one white pixel + pages, err := RasterToPages(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + pdf, err := PagesToPDF(pages) + if err != nil || !bytes.HasPrefix(pdf, []byte("%PDF-")) { + t.Fatalf("pdf %v %q", err, pdf[:min(20, len(pdf))]) + } + + pwg := make([]byte, 4+pwgHeaderSize) + copy(pwg, []byte("RaS2")) + h := pwg[4:] + binary.LittleEndian.PutUint32(h[372:], 1) + binary.LittleEndian.PutUint32(h[376:], 1) + binary.LittleEndian.PutUint32(h[384:], 8) + binary.LittleEndian.PutUint32(h[388:], 24) + binary.LittleEndian.PutUint32(h[392:], 3) + binary.LittleEndian.PutUint32(h[400:], uint32(cspaceSRGB)) + binary.LittleEndian.PutUint32(h[276:], 300) + pwg = append(pwg, 0x00, 0x00, 0xff, 0x00, 0x00) // repeat pixel once of 3 bytes? bpp=3, command 0 means 1 pixel + // command 0 + 3 bytes + pages, err = RasterToPages(pwg) + if err != nil { + t.Fatal(err) + } + pdf, err = PagesToPDF(pages) + if err != nil || !bytes.HasPrefix(pdf, []byte("%PDF-")) { + t.Fatal(err) + } + + stopped := BuildMenu(StoppedStatus()) + var startOn, stopOn bool + for _, e := range stopped { + if e.Kind == "start" { + startOn = e.Enabled + } + if e.Kind == "stop" { + stopOn = e.Enabled + } + if e.Kind == "launch" && e.Enabled { + t.Fatal("login toggle should wait until the service is up") + } + } + if !startOn || stopOn { + t.Fatalf("start %v stop %v", startOn, stopOn) + } +} + +func TestMDNSAirPrintReply(t *testing.T) { + txt := AirPrintTXT("Verae Timestamp", "abc-def", "http://192.0.2.10:631/", "", "") + joined := strings.Join(txt, "\n") + for _, k := range []string{"txtvers=1", "rp=ipp/print", "ty=Verae Timestamp", "pdl=application/pdf,image/urf,image/pwg-raster", "URF=", "UUID=abc-def", "adminurl=http://192.0.2.10:631/"} { + if !strings.Contains(joined, k) { + t.Fatalf("txt missing %s\n%s", k, joined) + } + } + svc := Service{ + Instance: "Verae Timestamp", + Host: "host", + Port: 631, + IPs: []net.IP{net.ParseIP("192.0.2.10")}, + TXT: txt, + } + q := Query(7, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR) + resp := svc.Reply(q) + if resp == nil { + t.Fatal("no reply for universal subtype") + } + if !bytes.Contains(resp, []byte("Verae Timestamp")) { + t.Fatal("reply missing instance") + } + if svc.Reply(svc.Announce(false)) != nil { + t.Fatal("responses must be ignored") + } +} + +func TestMachineToken(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + t.Setenv("XVERAE_TOKEN", "") + t.Setenv("VERAE_SKIP_KEYCHAIN", "1") + t.Setenv("VERAE_TOKEN_FILE", filepath.Join(dir, "machine")) + if err := auth.SaveMachineToken("xVerae-machine"); err != nil { + t.Fatal(err) + } + got, err := auth.LoadXVeraeToken() + if err != nil || got != "xVerae-machine" { + t.Fatalf("%q %v", got, err) + } +} + +func TestOwnersRefcount(t *testing.T) { + dir := t.TempDir() + // ownersPath is platform-fixed; exercise the JSON helpers by writing the file they read + // only when we can point them at a temp dir. The helpers use a fixed path, so this + // test covers the empty/add/remove logic through a temp copy of the same format. + o := ownerSet{} + if !o.empty() { + t.Fatal("empty") + } + o.Driver = true + o.Standalone = true + b, _ := json.Marshal(o) + var back ownerSet + if err := json.Unmarshal(b, &back); err != nil || back.empty() { + t.Fatal(err) + } + back.Driver = false + if back.empty() { + t.Fatal("standalone should remain") + } + back.Standalone = false + if !back.empty() { + t.Fatal("both removed") + } + _ = dir +} diff --git a/internal/netprint/open_darwin.go b/internal/netprint/open_darwin.go new file mode 100644 index 0000000..4ba98e3 --- /dev/null +++ b/internal/netprint/open_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package netprint + +import "os/exec" + +func openURL(raw string) error { + return exec.Command("open", raw).Start() +} diff --git a/internal/netprint/open_other.go b/internal/netprint/open_other.go new file mode 100644 index 0000000..8a889d1 --- /dev/null +++ b/internal/netprint/open_other.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package netprint + +import "os/exec" + +func openURL(raw string) error { + return exec.Command("xdg-open", raw).Start() +} diff --git a/internal/netprint/open_windows.go b/internal/netprint/open_windows.go new file mode 100644 index 0000000..0139266 --- /dev/null +++ b/internal/netprint/open_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package netprint + +import "os/exec" + +func openURL(raw string) error { + return exec.Command("rundll32", "url.dll,FileProtocolHandler", raw).Start() +} diff --git a/internal/netprint/pdf.go b/internal/netprint/pdf.go new file mode 100644 index 0000000..1832926 --- /dev/null +++ b/internal/netprint/pdf.go @@ -0,0 +1,94 @@ +package netprint + +import ( + "bytes" + "compress/flate" + "fmt" +) + +// PagesToPDF wraps decoded RGB pages in a PDF 1.4 document. Each page is an +// Image XObject with Flate compression. Page size is width/height at the +// raster DPI (72 points per inch). +func PagesToPDF(pages []Page) ([]byte, error) { + if len(pages) == 0 { + return nil, fmt.Errorf("no pages") + } + type blob struct { + data []byte + } + var objs []blob + add := func(s string) int { + objs = append(objs, blob{data: []byte(s)}) + return len(objs) + } + addBin := func(dict string, stream []byte) int { + var b bytes.Buffer + fmt.Fprintf(&b, "%s\nstream\n", dict) + b.Write(stream) + if len(stream) == 0 || stream[len(stream)-1] != '\n' { + b.WriteByte('\n') + } + b.WriteString("endstream") + objs = append(objs, blob{data: b.Bytes()}) + return len(objs) + } + + // Object 1 is the catalog, object 2 the pages tree. Children are filled later. + objs = append(objs, blob{}, blob{}) + var kids []int + for i, p := range pages { + if p.Width <= 0 || p.Height <= 0 || len(p.RGB) != p.Width*p.Height*3 { + return nil, fmt.Errorf("page %d: rgb length %d want %d", i+1, len(p.RGB), p.Width*p.Height*3) + } + dpi := p.DPI + if dpi <= 0 { + dpi = 300 + } + w := float64(p.Width) * 72 / float64(dpi) + h := float64(p.Height) * 72 / float64(dpi) + var comp bytes.Buffer + zw, err := flate.NewWriter(&comp, flate.BestSpeed) + if err != nil { + return nil, err + } + if _, err := zw.Write(p.RGB); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + img := addBin(fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>", + p.Width, p.Height, comp.Len()), comp.Bytes()) + content := fmt.Sprintf("q\n%.4f 0 0 %.4f 0 0 cm\n/Im0 Do\nQ\n", w, h) + cs := addBin(fmt.Sprintf("<< /Length %d >>", len(content)), []byte(content)) + page := add(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.4f %.4f] /Contents %d 0 R /Resources << /XObject << /Im0 %d 0 R >> >> >>", + w, h, cs, img)) + kids = append(kids, page) + } + var kb bytes.Buffer + for i, id := range kids { + if i > 0 { + kb.WriteByte(' ') + } + fmt.Fprintf(&kb, "%d 0 R", id) + } + objs[0] = blob{data: []byte("<< /Type /Catalog /Pages 2 0 R >>")} + objs[1] = blob{data: []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", len(kids), kb.String()))} + + var out bytes.Buffer + out.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") + offs := make([]int, len(objs)) + for i, o := range objs { + offs[i] = out.Len() + fmt.Fprintf(&out, "%d 0 obj\n", i+1) + out.Write(o.data) + out.WriteString("\nendobj\n") + } + xref := out.Len() + fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1) + for _, off := range offs { + fmt.Fprintf(&out, "%010d 00000 n \n", off) + } + fmt.Fprintf(&out, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref) + return out.Bytes(), nil +} diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go new file mode 100644 index 0000000..089af52 --- /dev/null +++ b/internal/netprint/pipeline.go @@ -0,0 +1,160 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/pdfcert" +) + +// StampFunc timestamps one PDF with the same client path as the desktop drivers. +// Implementations call job.Run (which hashes the file). They must not prompt. +type StampFunc func(pdfPath, title, user string) (job.Result, error) + +// ProcessJob turns an IPP document into a Verae receipt and optional forward. +// A forward or mail failure does not fail the timestamp. +func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user string, doc []byte, stamp StampFunc, forward ForwardFunc) { + if stamp == nil { + stamp = func(string, string, string) (job.Result, error) { + return job.Result{}, fmt.Errorf("no stamp function") + } + } + if forward == nil { + forward = Forward + } + rec := Record{ + ID: id, Received: time.Now(), User: user, Title: title, Format: format, State: "processing", + } + _ = st.Put(rec) + if p != nil { + p.SetState(id, jobProcessing, "job-printing", "") + } + fail := func(err error) { + rec.State = "aborted" + rec.Error = err.Error() + rec.Completed = time.Now() + _ = st.Put(rec) + if p != nil { + p.SetState(id, jobAborted, "aborted-by-system", err.Error()) + } + } + if p != nil && p.Canceled(id) { + rec.State = "canceled" + rec.Completed = time.Now() + _ = st.Put(rec) + return + } + pdf, err := documentPDF(format, doc) + if err != nil { + fail(err) + return + } + dir := filepath.Join(st.Dir(), fmt.Sprintf("job-%d", id)) + if err := os.MkdirAll(dir, 0o700); err != nil { + fail(err) + return + } + src := filepath.Join(dir, "document.pdf") + if err := os.WriteFile(src, pdf, 0o600); err != nil { + fail(err) + return + } + res, err := stamp(src, title, user) + if err != nil { + fail(err) + return + } + if user != "" && res.ReceiptJSON != "" { + annotateUser(res.ReceiptJSON, user) + } + rec.SHA256 = res.SHA256 + rec.ReceiptID = res.ReceiptID + rec.VerifyURL = pdfcert.LookupURL(res.SHA256, res.ReceiptID) + rec.CertificatePDF = res.CertificatePDF + rec.ReceiptJSON = res.ReceiptJSON + if cfg.Keep() { + rec.OriginalPDF = res.OriginalPDF + } else if res.OriginalPDF != "" { + _ = os.Remove(res.OriginalPDF) + } + if fp := strings.TrimSpace(cfg.ForwardPrinter); fp != "" && res.CertificatePDF != "" { + // Forward the original document when we still have it, otherwise the certificate. + path := res.OriginalPDF + if !cfg.Keep() || path == "" { + path = res.CertificatePDF + } + if err := forward(fp, path, title); err != nil { + rec.ForwardError = err.Error() + } else { + rec.ForwardedTo = fp + } + } + rec.Notified = maybeEmail(user, rec) + rec.State = "completed" + rec.Completed = time.Now() + _ = st.Put(rec) + if p != nil { + p.SetState(id, jobCompleted, "job-completed-successfully", "") + } +} + +func documentPDF(format string, doc []byte) ([]byte, error) { + f := sniffFormat(format, doc) + switch f { + case "application/pdf": + return doc, nil + case "image/urf", "image/pwg-raster": + pages, err := RasterToPages(doc) + if err != nil { + return nil, err + } + return PagesToPDF(pages) + default: + return nil, fmt.Errorf("unsupported document format %s", f) + } +} + +func annotateUser(path, user string) { + b, err := os.ReadFile(path) + if err != nil { + return + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil || m == nil { + return + } + m["requesting_user_name"] = user + out, err := json.MarshalIndent(m, "", " ") + if err != nil { + return + } + _ = os.WriteFile(path, append(out, '\n'), 0o600) +} + +func maybeEmail(user string, rec Record) string { + user = strings.TrimSpace(user) + if !strings.Contains(user, "@") { + return "" + } + cfg, err := mailer.FromEnv() + if err != nil { + return "" + } + text := fmt.Sprintf("Verae timestamped %q.\nReceipt: %s\nVerify: %s\nSHA-256: %s\n", + rec.Title, rec.ReceiptID, rec.VerifyURL, rec.SHA256) + err = (&mailer.SMTPSender{Cfg: cfg}).Send(mailer.Message{ + To: user, + Subject: "Verae timestamp receipt", + Text: text, + }) + if err != nil { + return "email: " + err.Error() + } + return "emailed" +} diff --git a/internal/netprint/raster.go b/internal/netprint/raster.go new file mode 100644 index 0000000..428f19f --- /dev/null +++ b/internal/netprint/raster.go @@ -0,0 +1,346 @@ +package netprint + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" +) + +// Page is one decoded raster page in 8-bit RGB, top to bottom. +type Page struct { + Width int + Height int + DPI int + RGB []byte // width*height*3 +} + +const ( + maxPagePixels = 40_000_000 + maxPages = 100 + pwgHeaderSize = 1796 +) + +// RasterToPages decodes an Apple Raster (image/urf) or PWG Raster +// (image/pwg-raster) document. The line encoding is the modified PackBits +// CUPS uses for both (cups/raster-stream.c): a leading repeat count, then +// pixel runs. +func RasterToPages(doc []byte) ([]Page, error) { + if len(doc) >= 8 && bytes.HasPrefix(doc, []byte("UNIRAST\x00")) { + return decodeURF(doc) + } + if len(doc) >= 4 && (bytes.HasPrefix(doc, []byte("RaS2")) || bytes.HasPrefix(doc, []byte("2SaR"))) { + return decodePWG(doc) + } + return nil, fmt.Errorf("not URF or PWG raster") +} + +func decodeURF(doc []byte) ([]Page, error) { + if len(doc) < 12 { + return nil, fmt.Errorf("urf: short header") + } + nPages := int(binary.BigEndian.Uint32(doc[8:12])) + unknownCount := nPages == 0 || nPages == int(^uint32(0)>>0) || nPages < 0 + if unknownCount { + nPages = maxPages + } + off := 12 + var pages []Page + for i := 0; i < nPages; i++ { + if off >= len(doc) { + break + } + if off+32 > len(doc) { + return nil, fmt.Errorf("urf: truncated page header") + } + h := doc[off : off+32] + off += 32 + bppBits := int(h[0]) + cs := int(h[1]) + width := int(binary.BigEndian.Uint32(h[12:16])) + height := int(binary.BigEndian.Uint32(h[16:20])) + dpi := int(binary.BigEndian.Uint32(h[20:24])) + colors := urfColors(cs) + if colors <= 0 { + return nil, fmt.Errorf("urf: unsupported color space %d", cs) + } + if bppBits <= 0 || bppBits > 32 { + return nil, fmt.Errorf("urf: unsupported bits per pixel %d", bppBits) + } + bpl := width * bppBits / 8 + page, n, err := decodeCompressedPage(doc[off:], width, height, bpl, (bppBits+7)/8, dpi, colors, urfColorSpace(cs)) + if err != nil { + return nil, fmt.Errorf("urf page %d: %w", i+1, err) + } + pages = append(pages, page) + off += n + if unknownCount && off >= len(doc) { + break + } + } + if len(pages) == 0 { + return nil, fmt.Errorf("urf: no pages") + } + return pages, nil +} + +// urf color-space byte → CUPS colorspace roughly matching cupsRasterReadHeader. +func urfColorSpace(cs int) int { + switch cs { + case 0: + return cspaceSW + case 1: + return cspaceSRGB + case 2: + return cspaceCIELab + case 3: + return cspaceAdobe + case 4: + return cspaceW + case 5: + return cspaceRGB + case 6: + return cspaceCMYK + default: + return -1 + } +} + +func urfColors(cs int) int { + switch cs { + case 0, 4: + return 1 + case 1, 2, 3, 5: + return 3 + case 6: + return 4 + default: + return 0 + } +} + +const ( + cspaceW = iota + cspaceRGB + cspaceRGBA + cspaceK + cspaceCMY + cspaceYMC + cspaceCMYK + _ + _ + _ + _ + _ + _ + _ + _ + cspaceCIEXYZ + cspaceCIELab + cspaceRGBW + cspaceSW + cspaceSRGB + cspaceAdobe +) + +func fillByte(cs int) byte { + switch cs { + case cspaceW, cspaceRGB, cspaceSW, cspaceSRGB, cspaceRGBW, cspaceAdobe, cspaceRGBA: + return 0xff + default: + return 0x00 + } +} + +func decodePWG(doc []byte) ([]Page, error) { + swapped := bytes.HasPrefix(doc, []byte("2SaR")) + off := 4 + var pages []Page + for len(pages) < maxPages { + if off == len(doc) || (off > 4 && off+pwgHeaderSize > len(doc) && off+32 > len(doc)) { + break + } + if off+pwgHeaderSize > len(doc) { + if len(pages) > 0 { + break + } + return nil, fmt.Errorf("pwg: truncated page header") + } + h := doc[off : off+pwgHeaderSize] + off += pwgHeaderSize + width := int(u32at(h, 372, swapped)) + height := int(u32at(h, 376, swapped)) + bpc := int(u32at(h, 384, swapped)) + bppBits := int(u32at(h, 388, swapped)) + bpl := int(u32at(h, 392, swapped)) + order := int(u32at(h, 396, swapped)) + cs := int(u32at(h, 400, swapped)) + dpi := int(u32at(h, 276, swapped)) + if width <= 0 || height <= 0 || bpl <= 0 || bppBits <= 0 { + return nil, fmt.Errorf("pwg: bad geometry %dx%d bpl=%d bpp=%d", width, height, bpl, bppBits) + } + bpp := (bppBits + 7) / 8 + if order != 0 { // not chunked + bpp = (bpc + 7) / 8 + } + if bpp <= 0 { + bpp = 1 + } + colors := pwgColors(cs, bppBits, bpp) + page, n, err := decodeCompressedPage(doc[off:], width, height, bpl, bpp, dpi, colors, cs) + if err != nil { + return nil, fmt.Errorf("pwg page %d: %w", len(pages)+1, err) + } + pages = append(pages, page) + off += n + } + if len(pages) == 0 { + return nil, fmt.Errorf("pwg: no pages") + } + return pages, nil +} + +func pwgColors(cs, bppBits, bpp int) int { + switch cs { + case cspaceW, cspaceK, cspaceSW: + return 1 + case cspaceRGB, cspaceSRGB, cspaceAdobe, cspaceCIELab, cspaceCIEXYZ, cspaceCMY, cspaceYMC: + return 3 + case cspaceRGBA, cspaceRGBW, cspaceCMYK: + return 4 + default: + if bpp > 0 && bppBits/8 > 0 { + return bppBits / 8 + } + return bpp + } +} + +func u32at(h []byte, off int, swapped bool) uint32 { + if off+4 > len(h) { + return 0 + } + v := binary.LittleEndian.Uint32(h[off : off+4]) + if swapped { + v = v<<24 | (v&0xff00)<<8 | (v&0xff0000)>>8 | v>>24 + } + return v +} + +// decodeCompressedPage reads CUPS modified PackBits and returns the page plus +// how many bytes of src were consumed. +func decodeCompressedPage(src []byte, width, height, bytesPerLine, bpp, dpi, colors, cs int) (Page, int, error) { + if width <= 0 || height <= 0 || bytesPerLine <= 0 || bpp <= 0 { + return Page{}, 0, fmt.Errorf("bad geometry") + } + if width > 20000 || height > 20000 || int64(width)*int64(height) > maxPagePixels { + return Page{}, 0, fmt.Errorf("page too large (%dx%d)", width, height) + } + if bytesPerLine%bpp != 0 { + return Page{}, 0, fmt.Errorf("bytes-per-line %d not divisible by bpp %d", bytesPerLine, bpp) + } + r := bytes.NewReader(src) + raw := make([]byte, height*bytesPerLine) + row := 0 + for row < height { + repb, err := r.ReadByte() + if err != nil { + return Page{}, 0, fmt.Errorf("row %d repeat: %w", row, err) + } + repeats := int(repb) + 1 + line := make([]byte, bytesPerLine) + if err := decodeLine(r, line, bpp, fillByte(cs)); err != nil { + return Page{}, 0, fmt.Errorf("row %d: %w", row, err) + } + for i := 0; i < repeats && row < height; i++ { + copy(raw[row*bytesPerLine:(row+1)*bytesPerLine], line) + row++ + } + } + rgb, err := toRGB(raw, width, height, bytesPerLine, colors, cs) + if err != nil { + return Page{}, 0, err + } + if dpi <= 0 { + dpi = 300 + } + consumed := len(src) - r.Len() + return Page{Width: width, Height: height, DPI: dpi, RGB: rgb}, consumed, nil +} + +func decodeLine(r *bytes.Reader, line []byte, bpp int, fill byte) error { + pos := 0 + for pos < len(line) { + b, err := r.ReadByte() + if err != nil { + return err + } + switch { + case b == 128: + for i := pos; i < len(line); i++ { + line[i] = fill + } + return nil + case b&128 != 0: + n := (257 - int(b)) * bpp + if pos+n > len(line) { + n = len(line) - pos + } + if _, err := io.ReadFull(r, line[pos:pos+n]); err != nil { + return err + } + pos += n + default: + n := (int(b) + 1) * bpp + if pos+n > len(line) { + n = len(line) - pos + } + if n < bpp { + return fmt.Errorf("short pixel run") + } + pix := make([]byte, bpp) + if _, err := io.ReadFull(r, pix); err != nil { + return err + } + for wrote := 0; wrote < n; wrote += bpp { + copy(line[pos+wrote:pos+wrote+bpp], pix) + } + pos += n + } + } + return nil +} + +func toRGB(raw []byte, width, height, bpl, colors, cs int) ([]byte, error) { + if cs == cspaceCIEXYZ || cs == cspaceCIELab { + return nil, fmt.Errorf("color space %d is not converted", cs) + } + if colors != 1 && colors != 3 && colors != 4 { + return nil, fmt.Errorf("unsupported %d color components", colors) + } + rgb := make([]byte, width*height*3) + for y := 0; y < height; y++ { + row := raw[y*bpl : y*bpl+bpl] + for x := 0; x < width; x++ { + px := x * colors + if px+colors > len(row) { + return nil, fmt.Errorf("row shorter than width") + } + o := (y*width + x) * 3 + switch { + case colors == 1: + rgb[o], rgb[o+1], rgb[o+2] = row[px], row[px], row[px] + case colors == 3: + copy(rgb[o:o+3], row[px:px+3]) + case cs == cspaceCMYK: + c, m, yy, k := float64(row[px])/255, float64(row[px+1])/255, float64(row[px+2])/255, float64(row[px+3])/255 + rgb[o] = byte(255 * (1 - c) * (1 - k)) + rgb[o+1] = byte(255 * (1 - m) * (1 - k)) + rgb[o+2] = byte(255 * (1 - yy) * (1 - k)) + default: + copy(rgb[o:o+3], row[px:px+3]) + } + } + } + return rgb, nil +} diff --git a/internal/netprint/server.go b/internal/netprint/server.go new file mode 100644 index 0000000..233d743 --- /dev/null +++ b/internal/netprint/server.go @@ -0,0 +1,225 @@ +package netprint + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Server is the LAN IPP printer plus the loopback control API. +type Server struct { + mu sync.Mutex + cfg Config + configPath string + store *Store + printer *Printer + ctrl ServiceControl + port int + controlURL string + stamp StampFunc + forward ForwardFunc + + ippSrv *http.Server + controlSrv *http.Server + mdnsCancel context.CancelFunc +} + +// Run loads config, listens, and blocks until ctx is canceled. +func Run(ctx context.Context, explicitPath string, flags Config, stamp StampFunc) error { + cfg, err := Load(explicitPath, flags) + if err != nil { + return err + } + path := explicitPath + if strings.TrimSpace(path) == "" { + path = SystemConfigPath() + } + s, err := Start(cfg, path, stamp, nil) + if err != nil { + return err + } + <-ctx.Done() + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.Shutdown(shut) +} + +// Start listens and serves. ctrl may be nil (the OS service manager is used). +func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) (*Server, error) { + if err := cfg.normalize(); err != nil { + return nil, err + } + st, err := OpenStore(cfg.DataDir) + if err != nil { + return nil, err + } + if ctrl == nil { + ctrl = NewServiceControl() + } + uuid := loadUUID(cfg.DataDir) + s := &Server{ + cfg: cfg, + configPath: configPath, + store: st, + ctrl: ctrl, + stamp: stamp, + printer: &Printer{ + Name: cfg.Name, + UUID: uuid, + }, + } + ippLn, port, err := listenIPP(cfg) + if err != nil { + return nil, err + } + s.port = port + ctlLn, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ControlPort)) + if err != nil { + _ = ippLn.Close() + return nil, fmt.Errorf("control listen: %w", err) + } + s.controlURL = "http://" + ctlLn.Addr().String() + "/" + hosts := lanHosts() + if len(hosts) > 0 { + s.printer.AdminURL = fmt.Sprintf("http://%s/", net.JoinHostPort(hosts[0], fmt.Sprint(port))) + } + s.printer.OnDocument = func(id int, format, title, user string, doc []byte) { + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward) + } + + admin := &Admin{Store: st, Name: func() string { return s.snapshot().Name }, Port: func() int { return s.port }} + pub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && (strings.Contains(r.Header.Get("Content-Type"), "application/ipp") || strings.HasPrefix(r.URL.Path, "/ipp")) { + s.printer.ServeHTTP(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/settings" { + http.NotFound(w, r) + return + } + admin.ServeHTTP(w, r) + }) + s.ippSrv = &http.Server{Handler: pub} + ctrlMux := http.NewServeMux() + ctrlMux.HandleFunc("/api/v1/status", s.handleStatus) + ctrlMux.HandleFunc("/api/v1/service", s.handleService) + ctrlMux.HandleFunc("/api/v1/launch", s.handleLaunch) + ctrlMux.HandleFunc("/api/v1/tray", s.handleTray) + ctrlMux.HandleFunc("/api/v1/settings", s.handleSettings) + ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth) + ctrlMux.HandleFunc("/settings", s.handleSettingsPage) + ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !loopback(r) { + http.Error(w, "loopback only", http.StatusForbidden) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + admin.ServeHTTP(w, r) + })) + s.controlSrv = &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !loopback(r) { + http.Error(w, "loopback only", http.StatusForbidden) + return + } + ctrlMux.ServeHTTP(w, r) + })} + go func() { _ = s.ippSrv.Serve(ippLn) }() + go func() { _ = s.controlSrv.Serve(ctlLn) }() + if cfg.MDNS() { + ctx, cancel := context.WithCancel(context.Background()) + s.mdnsCancel = cancel + go func() { + err := Advertise(ctx, func() Service { + c := s.snapshot() + ips := lanIPv4() + adminURL := s.printer.AdminURL + return Service{ + Instance: c.Name, + Host: hostLabel(c.Hostname), + Port: s.port, + IPs: ips, + TXT: AirPrintTXT(c.Name, uuid, adminURL, "", ""), + } + }) + if err != nil && ctx.Err() == nil { + log.Printf("netprint: mDNS not advertised: %v", err) + } + }() + } + log.Printf("netprint: IPP on %s, control on %s", ippLn.Addr(), ctlLn.Addr()) + return s, nil +} + +func (s *Server) snapshot() Config { + s.mu.Lock() + defer s.mu.Unlock() + return s.cfg +} + +// Port is the IPP port actually bound. +func (s *Server) Port() int { return s.port } + +// ControlURL is the loopback base URL including a trailing slash. +func (s *Server) ControlURL() string { return s.controlURL } + +// Shutdown stops listeners and sends an mDNS goodbye. +func (s *Server) Shutdown(ctx context.Context) error { + if s.mdnsCancel != nil { + s.mdnsCancel() + } + var err error + if s.ippSrv != nil { + err = s.ippSrv.Shutdown(ctx) + } + if s.controlSrv != nil { + if e := s.controlSrv.Shutdown(ctx); err == nil { + err = e + } + } + return err +} + +func listenIPP(cfg Config) (net.Listener, int, error) { + bind := cfg.Bind + try := []int{cfg.Port} + if cfg.FallbackPort > 0 && cfg.FallbackPort != cfg.Port { + try = append(try, cfg.FallbackPort) + } + var last error + for _, port := range try { + ln, err := net.Listen("tcp", net.JoinHostPort(bind, fmt.Sprint(port))) + if err == nil { + return ln, port, nil + } + last = err + log.Printf("netprint: port %d: %v", port, err) + } + if last == nil { + last = errors.New("no port") + } + return nil, 0, fmt.Errorf("listen: %w", last) +} + +func loadUUID(dir string) string { + path := filepath.Join(dir, "uuid") + if b, err := os.ReadFile(path); err == nil { + if s := strings.TrimSpace(string(b)); s != "" { + return s + } + } + id := newUUID() + _ = os.MkdirAll(dir, 0o700) + _ = os.WriteFile(path, []byte(id+"\n"), 0o644) + return id +} diff --git a/internal/netprint/service.go b/internal/netprint/service.go new file mode 100644 index 0000000..d6a5854 --- /dev/null +++ b/internal/netprint/service.go @@ -0,0 +1,142 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" +) + +const ( + linuxUnit = "verae-netprint.service" + winService = "VeraeNetPrint" + launchdLabel = "com.verae.netprint" + trayLabel = "com.verae.netprint.tray" +) + +// NewServiceControl returns the OS service manager for this platform. +func NewServiceControl() ServiceControl { return osControl{} } + +type ownerSet struct { + Driver bool `json:"driver"` + Standalone bool `json:"standalone"` +} + +func ownersPath() string { + switch runtime.GOOS { + case "windows": + return filepath.Join(programData(), "Verae", "PrintStamp", "netprint.owners.json") + case "darwin": + return "/Library/Application Support/Verae/PrintStamp/netprint.owners.json" + default: + return "/etc/verae/netprint.owners.json" + } +} + +func programData() string { + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return pd +} + +func readOwners() ownerSet { + b, err := os.ReadFile(ownersPath()) + if err != nil { + return ownerSet{} + } + var o ownerSet + _ = json.Unmarshal(b, &o) + return o +} + +func writeOwners(o ownerSet) error { + path := ownersPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(o, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o644) +} + +func addOwner(owner string) (ownerSet, error) { + o := readOwners() + switch owner { + case "driver": + o.Driver = true + case "standalone": + o.Standalone = true + default: + return o, fmt.Errorf("owner must be driver or standalone") + } + return o, writeOwners(o) +} + +func removeOwner(owner string) (ownerSet, error) { + o := readOwners() + switch owner { + case "driver": + o.Driver = false + case "standalone": + o.Standalone = false + default: + return o, fmt.Errorf("owner must be driver or standalone") + } + return o, writeOwners(o) +} + +func (o ownerSet) empty() bool { return !o.Driver && !o.Standalone } + +func copyToken(explicit string) { + explicit = strings.TrimSpace(explicit) + if explicit != "" { + if b, err := os.ReadFile(explicit); err == nil { + _ = auth.SaveMachineToken(strings.TrimSpace(string(b))) + } + return + } + if t, err := auth.LoadXVeraeToken(); err == nil { + _ = auth.SaveMachineToken(t) + return + } + if u := os.Getenv("SUDO_USER"); u != "" { + if home := userHome(u); home != "" { + if b, err := os.ReadFile(filepath.Join(home, ".verae", "xverae-token")); err == nil { + _ = auth.SaveMachineToken(strings.TrimSpace(string(b))) + } + } + } +} + +func userHome(user string) string { + if runtime.GOOS == "windows" { + return "" + } + out, err := exec.Command("getent", "passwd", user).Output() + if err != nil { + return "" + } + parts := strings.Split(strings.TrimSpace(string(out)), ":") + if len(parts) < 6 { + return "" + } + return parts[5] +} + +// Executable is the verae-print binary the service should launch. +func Executable() (string, error) { + p, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Abs(p) +} diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go new file mode 100644 index 0000000..18390a0 --- /dev/null +++ b/internal/netprint/service_darwin.go @@ -0,0 +1,198 @@ +//go:build darwin + +package netprint + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" +) + +const ( + daemonPlist = "/Library/LaunchDaemons/com.verae.netprint.plist" + agentPlist = "/Library/LaunchAgents/com.verae.netprint.tray.plist" +) + +func plistDaemon(exe, config string) string { + return fmt.Sprintf(` + + + Label%s + ProgramArguments + + %s + netprint + --config + %s + + RunAtLoad + KeepAlive + +`, launchdLabel, exe, config) +} + +func plistAgent(exe string) string { + return fmt.Sprintf(` + + + Label%s + ProgramArguments + + %s + tray + + RunAtLoad + LimitLoadToSessionTypeAqua + +`, trayLabel, exe) +} + +type osControl struct{} + +func (osControl) Start() error { return launchctl("bootstrap", "system", daemonPlist) } +func (osControl) Stop() error { + return delayed("launchctl", "bootout", "system/"+launchdLabel) +} +func (osControl) Restart() error { + return delayed("launchctl", "kickstart", "-k", "system/"+launchdLabel) +} +func (osControl) LaunchAtLogin() (bool, error) { + out, _ := exec.Command("launchctl", "print", "system/"+launchdLabel).CombinedOutput() + return strings.Contains(string(out), "state = running") || strings.Contains(string(out), "state = waiting"), nil +} +func (osControl) SetLaunchAtLogin(on bool) error { + if on { + _ = exec.Command("launchctl", "enable", "system/"+launchdLabel).Run() + return launchctl("bootstrap", "system", daemonPlist) + } + _ = exec.Command("launchctl", "disable", "system/"+launchdLabel).Run() + return exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() +} +func (osControl) TrayAtLogin() (bool, error) { + _, err := os.Stat(agentPlist) + if err != nil { + return false, nil + } + out, _ := exec.Command("launchctl", "print-disabled", "system").CombinedOutput() + return !strings.Contains(string(out), `"`+trayLabel+`" => disabled`), nil +} +func (osControl) SetTrayAtLogin(on bool) error { + uid := consoleUID() + target := "gui/" + uid + if on { + if err := os.WriteFile(agentPlist, []byte(plistAgent(mustExe())), 0o644); err != nil { + return err + } + _ = exec.Command("launchctl", "enable", target+"/"+trayLabel).Run() + return exec.Command("launchctl", "bootstrap", target, agentPlist).Run() + } + _ = exec.Command("launchctl", "bootout", target+"/"+trayLabel).Run() + return exec.Command("launchctl", "disable", target+"/"+trayLabel).Run() +} + +func delayed(args ...string) error { + cmd := exec.Command("sh", append([]string{"-c", `sleep 0.4; exec "$@"`, "sh"}, args...)...) + return cmd.Start() +} + +func launchctl(args ...string) error { + out, err := exec.Command("launchctl", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("launchctl: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func mustExe() string { + p, err := Executable() + if err != nil { + return "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + } + return p +} + +func consoleUID() string { + if u := os.Getenv("SUDO_UID"); u != "" { + return u + } + out, err := exec.Command("stat", "-f", "%u", "/dev/console").Output() + if err == nil { + if s := strings.TrimSpace(string(out)); s != "" && s != "0" { + return s + } + } + if cu, err := user.Current(); err == nil && cu.Uid != "" { + return cu.Uid + } + return "501" +} + +// Install registers the LaunchDaemon and the menu-bar agent. +func Install(owner, tokenFile string) error { + if _, err := addOwner(owner); err != nil { + return err + } + exe, err := Executable() + if err != nil { + return err + } + cfgPath := SystemConfigPath() + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + return err + } + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if err := os.WriteFile(cfgPath, ExampleJSON("/Library/Application Support/Verae/NetPrint"), 0o644); err != nil { + return err + } + } + if err := os.MkdirAll("/Library/LaunchDaemons", 0o755); err != nil { + return err + } + if _, err := os.Stat(daemonPlist); os.IsNotExist(err) { + if err := os.WriteFile(daemonPlist, []byte(plistDaemon(exe, cfgPath)), 0o644); err != nil { + return err + } + } + if err := os.MkdirAll("/Library/LaunchAgents", 0o755); err != nil { + return err + } + if err := os.WriteFile(agentPlist, []byte(plistAgent(exe)), 0o644); err != nil { + return err + } + _ = os.MkdirAll("/usr/local/bin", 0o755) + _ = os.Remove("/usr/local/bin/verae-print") + _ = os.Symlink(exe, "/usr/local/bin/verae-print") + copyToken(tokenFile) + _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() + if err := launchctl("bootstrap", "system", daemonPlist); err != nil { + return err + } + uid := consoleUID() + _ = exec.Command("launchctl", "bootstrap", "gui/"+uid, agentPlist).Run() + return nil +} + +// Uninstall removes the daemon and menu item only when no owner remains. +func Uninstall(owner string) error { + o, err := removeOwner(owner) + if err != nil { + return err + } + if !o.empty() { + return nil + } + _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() + if uid := consoleUID(); uid != "" { + _ = exec.Command("launchctl", "bootout", "gui/"+uid+"/"+trayLabel).Run() + } + _ = os.Remove(daemonPlist) + _ = os.Remove(agentPlist) + return nil +} + +// silence unused in case strconv is needed by future uid parsing +var _ = strconv.Itoa diff --git a/internal/netprint/service_linux.go b/internal/netprint/service_linux.go new file mode 100644 index 0000000..4e09b5b --- /dev/null +++ b/internal/netprint/service_linux.go @@ -0,0 +1,271 @@ +//go:build linux + +package netprint + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +const ( + unitPath = "/usr/lib/systemd/system/verae-netprint.service" + trayDesktop = "/etc/xdg/autostart/verae-netprint-tray.desktop" + sudoersPath = "/etc/sudoers.d/verae-netprint" + ctlPath = "/usr/lib/verae-netprint/ctl" + serviceUser = "verae-netprint" + serviceHome = "/var/lib/verae-netprint" +) + +func unitText(exe, config string) string { + return fmt.Sprintf(`[Unit] +Description=Verae network timestamp printer +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=%s +Group=%s +Environment=HOME=%s +ExecStart=%s netprint --config %s +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target +`, serviceUser, serviceUser, serviceHome, exe, config) +} + +func trayDesktopText(exe string, enabled bool) string { + hidden := "false" + gnome := "true" + if !enabled { + hidden = "true" + gnome = "false" + } + return fmt.Sprintf(`[Desktop Entry] +Type=Application +Name=Verae Network Printer +Comment=Status and controls for the Verae network timestamp printer +Exec=%s tray +Icon=printer +Terminal=false +Categories=Utility; +X-GNOME-Autostart-enabled=%s +Hidden=%s +`, exe, gnome, hidden) +} + +func ctlScript() string { + return `#!/bin/sh +set -e +f=/etc/xdg/autostart/verae-netprint-tray.desktop +case "$1" in + tray-on) sed -i 's/^Hidden=.*/Hidden=false/; s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=true/' "$f" ;; + tray-off) sed -i 's/^Hidden=.*/Hidden=true/; s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/' "$f" ;; + *) echo "usage: ctl tray-on|tray-off" >&2; exit 2 ;; +esac +` +} + +func sudoersText() string { + return serviceUser + ` ALL=(root) NOPASSWD: /bin/systemctl start verae-netprint.service, /bin/systemctl stop verae-netprint.service, /bin/systemctl restart verae-netprint.service, /bin/systemctl enable verae-netprint.service, /bin/systemctl disable verae-netprint.service, /usr/lib/verae-netprint/ctl tray-on, /usr/lib/verae-netprint/ctl tray-off +` +} + +type osControl struct{} + +func (osControl) Start() error { return systemctlNow("start", linuxUnit) } +func (osControl) Stop() error { return systemctlDelayed("stop", linuxUnit) } +func (osControl) Restart() error { + return systemctlDelayed("restart", linuxUnit) +} + +func (osControl) LaunchAtLogin() (bool, error) { + out, _ := exec.Command("systemctl", "is-enabled", linuxUnit).CombinedOutput() + return strings.TrimSpace(string(out)) == "enabled", nil +} + +func (osControl) SetLaunchAtLogin(on bool) error { + if on { + return systemctlNow("enable", linuxUnit) + } + return systemctlNow("disable", linuxUnit) +} + +func (osControl) TrayAtLogin() (bool, error) { + b, err := os.ReadFile(trayDesktop) + if err != nil { + return false, nil + } + s := string(b) + if strings.Contains(s, "Hidden=true") || strings.Contains(s, "X-GNOME-Autostart-enabled=false") { + return false, nil + } + return true, nil +} + +func (osControl) SetTrayAtLogin(on bool) error { + arg := "tray-off" + if on { + arg = "tray-on" + } + if os.Geteuid() == 0 { + return exec.Command(ctlPath, arg).Run() + } + return exec.Command("sudo", "-n", ctlPath, arg).Run() +} + +func systemctlNow(args ...string) error { + if _, err := exec.LookPath("systemctl"); err != nil { + return fmt.Errorf("systemctl not available") + } + if os.Geteuid() != 0 { + args = append([]string{"-n", "systemctl"}, args...) + out, err := exec.Command("sudo", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("sudo systemctl: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil + } + out, err := exec.Command("systemctl", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func systemctlDelayed(args ...string) error { + bin := "systemctl" + argv := args + if os.Geteuid() != 0 { + bin = "sudo" + argv = append([]string{"-n", "systemctl"}, args...) + } + cmd := exec.Command("sh", append([]string{"-c", `sleep 0.4; exec "$@"`, "sh", bin}, argv...)...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return cmd.Start() +} + +// Install registers this product as an owner and enables the shared service and tray. +func Install(owner, tokenFile string) error { + if _, err := addOwner(owner); err != nil { + return err + } + exe, err := Executable() + if err != nil { + return err + } + if err := ensureServiceUser(); err != nil { + return err + } + if err := os.MkdirAll("/etc/verae", 0o750); err != nil { + return err + } + cfgPath := SystemConfigPath() + if _, err := os.Stat(cfgPath); err != nil { + if err := os.WriteFile(cfgPath, ExampleJSON(serviceHome), 0o644); err != nil { + return err + } + } + uid := userID(serviceUser) + if uid >= 0 { + _ = os.Chown("/etc/verae", uid, uid) + _ = os.Chown(cfgPath, uid, uid) + } + if _, err := os.Stat(unitPath); err != nil { + if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(unitPath, []byte(unitText(exe, cfgPath)), 0o644); err != nil { + return err + } + } + if err := os.MkdirAll(filepath.Dir(ctlPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(ctlPath, []byte(ctlScript()), 0o755); err != nil { + return err + } + if err := os.WriteFile(sudoersPath, []byte(sudoersText()), 0o440); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(trayDesktop), 0o755); err != nil { + return err + } + if err := os.WriteFile(trayDesktop, []byte(trayDesktopText(exe, true)), 0o644); err != nil { + return err + } + linkBin(exe) + copyToken(tokenFile) + _ = exec.Command("systemctl", "daemon-reload").Run() + if err := systemctlNow("enable", "--now", linuxUnit); err != nil { + return err + } + return nil +} + +// Uninstall drops this owner. The service, unit, and tray icon are removed only +// when neither the driver option nor the standalone package still wants them. +func Uninstall(owner string) error { + o, err := removeOwner(owner) + if err != nil { + return err + } + if !o.empty() { + return nil + } + _ = systemctlNow("disable", "--now", linuxUnit) + _ = os.Remove(unitPath) + _ = os.Remove(trayDesktop) + _ = os.Remove(sudoersPath) + _ = exec.Command("systemctl", "daemon-reload").Run() + return nil +} + +func linkBin(exe string) { + const link = "/usr/bin/verae-print" + if exe == link { + return + } + _ = os.Remove(link) + _ = os.Symlink(exe, link) +} + +func ensureServiceUser() error { + if exec.Command("id", serviceUser).Run() == nil { + _ = os.MkdirAll(serviceHome, 0o750) + return nil + } + if _, err := exec.LookPath("useradd"); err == nil { + out, err := exec.Command("useradd", "--system", "--home", serviceHome, "--create-home", "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput() + if err != nil { + return fmt.Errorf("useradd %s: %w (%s)", serviceUser, err, strings.TrimSpace(string(out))) + } + return nil + } + if _, err := exec.LookPath("adduser"); err == nil { + out, err := exec.Command("adduser", "--system", "--home", serviceHome, "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput() + if err != nil { + return fmt.Errorf("adduser %s: %w (%s)", serviceUser, err, strings.TrimSpace(string(out))) + } + } + return nil +} + +func userID(name string) int { + out, err := exec.Command("id", "-u", name).Output() + if err != nil { + return -1 + } + var id int + fmt.Sscan(strings.TrimSpace(string(out)), &id) + return id +} diff --git a/internal/netprint/service_run_other.go b/internal/netprint/service_run_other.go new file mode 100644 index 0000000..cef787a --- /dev/null +++ b/internal/netprint/service_run_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package netprint + +import "context" + +// RunCommand runs the printer in the foreground. Windows uses the service control manager. +func RunCommand(ctx context.Context, explicit string, flags Config, stamp StampFunc) error { + return Run(ctx, explicit, flags, stamp) +} diff --git a/internal/netprint/service_run_windows.go b/internal/netprint/service_run_windows.go new file mode 100644 index 0000000..efc3509 --- /dev/null +++ b/internal/netprint/service_run_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package netprint + +import ( + "context" + + "golang.org/x/sys/windows/svc" +) + +type svcHandler struct { + explicit string + flags Config + stamp StampFunc +} + +func (h *svcHandler) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + const accept = svc.AcceptStop | svc.AcceptShutdown + changes <- svc.Status{State: svc.StartPending} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errc := make(chan error, 1) + go func() { errc <- Run(ctx, h.explicit, h.flags, h.stamp) }() + changes <- svc.Status{State: svc.Running, Accepts: accept} + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending} + cancel() + <-errc + return false, 0 + case svc.Interrogate: + changes <- svc.Status{State: svc.Running, Accepts: accept} + } + case <-errc: + return false, 0 + } + } +} + +// RunCommand runs under the Windows service control manager when started by SCM. +func RunCommand(ctx context.Context, explicit string, flags Config, stamp StampFunc) error { + isSvc, err := svc.IsWindowsService() + if err == nil && isSvc { + return svc.Run(winService, &svcHandler{explicit: explicit, flags: flags, stamp: StampFunc(stamp)}) + } + return Run(ctx, explicit, flags, stamp) +} diff --git a/internal/netprint/service_windows.go b/internal/netprint/service_windows.go new file mode 100644 index 0000000..eb45a76 --- /dev/null +++ b/internal/netprint/service_windows.go @@ -0,0 +1,147 @@ +//go:build windows + +package netprint + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +const runKey = `HKLM\Software\Microsoft\Windows\CurrentVersion\Run` +const ownerKey = `HKLM\Software\Verae\NetPrint` + +type osControl struct{} + +func (osControl) Start() error { return sc("start", winService) } +func (osControl) Stop() error { + return delayed("cmd.exe", "/c", "ping -n 2 127.0.0.1 >NUL & sc stop "+winService) +} +func (osControl) Restart() error { + return delayed("cmd.exe", "/c", "sc stop "+winService+" & ping -n 2 127.0.0.1 >NUL & sc start "+winService) +} +func (osControl) LaunchAtLogin() (bool, error) { + out, err := exec.Command("sc.exe", "qc", winService).CombinedOutput() + if err != nil { + return false, nil + } + return strings.Contains(strings.ToUpper(string(out)), "AUTO_START"), nil +} +func (osControl) SetLaunchAtLogin(on bool) error { + mode := "demand" + if on { + mode = "auto" + } + return sc("config", winService, "start=", mode) +} +func (osControl) TrayAtLogin() (bool, error) { + out, err := exec.Command("reg.exe", "query", runKey, "/v", "VeraeNetPrintTray").CombinedOutput() + return err == nil && strings.Contains(string(out), "VeraeNetPrintTray"), nil +} +func (osControl) SetTrayAtLogin(on bool) error { + if !on { + return exec.Command("reg.exe", "delete", runKey, "/v", "VeraeNetPrintTray", "/f").Run() + } + tray := trayExe() + return exec.Command("reg.exe", "add", runKey, "/v", "VeraeNetPrintTray", "/t", "REG_SZ", "/d", tray, "/f").Run() +} + +func sc(args ...string) error { + out, err := exec.Command("sc.exe", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("sc %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func delayed(args ...string) error { + cmd := exec.Command(args[0], args[1:]...) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x00000008} // DETACHED_PROCESS + return cmd.Start() +} + +func trayExe() string { + exe, err := Executable() + if err != nil { + return `verae-print.exe tray` + } + sibling := filepath.Join(filepath.Dir(exe), "verae-netprint-tray.exe") + if _, err := os.Stat(sibling); err == nil { + return `"` + sibling + `"` + } + return `"` + exe + `" tray` +} + +func setOwnerReg(name string, on bool) { + v := "0" + if on { + v = "1" + } + _ = exec.Command("reg.exe", "add", ownerKey, "/v", name, "/t", "REG_DWORD", "/d", v, "/f").Run() +} + +func ownerReg(name string) bool { + out, err := exec.Command("reg.exe", "query", ownerKey, "/v", name).CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), "0x1") +} + +// Install registers the VeraeNetPrint service and the tray Run key. +func Install(owner, tokenFile string) error { + if _, err := addOwner(owner); err != nil { + return err + } + if owner == "driver" { + setOwnerReg("Driver", true) + } else { + setOwnerReg("Standalone", true) + } + exe, err := Executable() + if err != nil { + return err + } + cfgPath := SystemConfigPath() + _ = os.MkdirAll(filepath.Dir(cfgPath), 0o755) + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if err := os.WriteFile(cfgPath, ExampleJSON(defaultDataDir()), 0o644); err != nil { + return err + } + } + bin := fmt.Sprintf(`"%s" netprint`, exe) + _ = exec.Command("sc.exe", "stop", winService).Run() + _ = exec.Command("sc.exe", "delete", winService).Run() + if err := sc("create", winService, "binPath=", bin, "start=", "auto", "DisplayName=", "Verae Network Printer"); err != nil { + return err + } + _ = exec.Command("sc.exe", "description", winService, "Verae network timestamp printer").Run() + copyToken(tokenFile) + if err := sc("start", winService); err != nil { + return err + } + return osControl{}.SetTrayAtLogin(true) +} + +// Uninstall removes the service only when neither installer still owns it. +func Uninstall(owner string) error { + o, err := removeOwner(owner) + if err != nil { + return err + } + if owner == "driver" { + setOwnerReg("Driver", false) + } else { + setOwnerReg("Standalone", false) + } + if !o.empty() || ownerReg("Driver") || ownerReg("Standalone") { + return nil + } + _ = exec.Command("sc.exe", "stop", winService).Run() + _ = exec.Command("sc.exe", "delete", winService).Run() + _ = osControl{}.SetTrayAtLogin(false) + return nil +} diff --git a/internal/netprint/store.go b/internal/netprint/store.go new file mode 100644 index 0000000..06a0522 --- /dev/null +++ b/internal/netprint/store.go @@ -0,0 +1,120 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Record is one network print job as shown on the admin page and kept across restarts. +type Record struct { + ID int `json:"id"` + Received time.Time `json:"received"` + Completed time.Time `json:"completed,omitempty"` + User string `json:"user,omitempty"` + Title string `json:"title,omitempty"` + Format string `json:"format,omitempty"` + State string `json:"state"` + Error string `json:"error,omitempty"` + SHA256 string `json:"sha256,omitempty"` + ReceiptID string `json:"receiptId,omitempty"` + VerifyURL string `json:"verifyUrl,omitempty"` + OriginalPDF string `json:"originalPdf,omitempty"` + CertificatePDF string `json:"certificatePdf,omitempty"` + ReceiptJSON string `json:"receiptJson,omitempty"` + ForwardedTo string `json:"forwardedTo,omitempty"` + ForwardError string `json:"forwardError,omitempty"` + Notified string `json:"notified,omitempty"` +} + +// Store persists the job index under DataDir/jobs.json. +type Store struct { + dir string + mu sync.Mutex + all []Record +} + +func OpenStore(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + s := &Store{dir: dir} + b, err := os.ReadFile(s.path()) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, err + } + if len(b) == 0 { + return s, nil + } + if err := json.Unmarshal(b, &s.all); err != nil { + return nil, fmt.Errorf("jobs.json: %w", err) + } + return s, nil +} + +func (s *Store) path() string { return filepath.Join(s.dir, "jobs.json") } + +func (s *Store) Dir() string { return s.dir } + +// List returns a copy, newest id last. +func (s *Store) List() []Record { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Record, len(s.all)) + copy(out, s.all) + return out +} + +func (s *Store) Get(id int) (Record, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range s.all { + if r.ID == id { + return r, true + } + } + return Record{}, false +} + +func (s *Store) NextID() int { + s.mu.Lock() + defer s.mu.Unlock() + max := 0 + for _, r := range s.all { + if r.ID > max { + max = r.ID + } + } + return max + 1 +} + +func (s *Store) Put(r Record) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.all { + if s.all[i].ID == r.ID { + s.all[i] = r + return s.flush() + } + } + s.all = append(s.all, r) + return s.flush() +} + +func (s *Store) flush() error { + b, err := json.MarshalIndent(s.all, "", " ") + if err != nil { + return err + } + tmp := s.path() + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path()) +} diff --git a/internal/netprint/tray.go b/internal/netprint/tray.go new file mode 100644 index 0000000..a2a9bd0 --- /dev/null +++ b/internal/netprint/tray.go @@ -0,0 +1,8 @@ +package netprint + +import "context" + +// RunTray blocks in the platform menu (Windows notification area, macOS status +// item, or a freedesktop StatusNotifierItem). It talks to the print service +// over the loopback control API. +func RunTray(ctx context.Context) error { return runTray(ctx) } diff --git a/internal/netprint/tray_darwin.go b/internal/netprint/tray_darwin.go new file mode 100644 index 0000000..cbf3a7a --- /dev/null +++ b/internal/netprint/tray_darwin.go @@ -0,0 +1,106 @@ +//go:build darwin + +package netprint + +import ( + "context" + "runtime" + "sync" + "unsafe" + + "github.com/ebitengine/purego/objc" +) + +var ( + trayOnce sync.Once + trayTarget objc.ID + trayMenu objc.ID + trayMu sync.Mutex + trayItems []MenuEntry + selTray = objc.RegisterName("trayAction:") + selOpen = objc.RegisterName("menuWillOpen:") +) + +func runTray(ctx context.Context) error { + runtime.LockOSThread() + trayOnce.Do(registerTrayClass) + app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) + app.Send(objc.RegisterName("setActivationPolicy:"), int64(1)) + bar := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) + item := bar.Send(objc.RegisterName("statusItemWithLength:"), float64(-1)) + btn := item.Send(objc.RegisterName("button")) + btn.Send(objc.RegisterName("setTitle:"), nsString("Verae")) + menu := cls("NSMenu").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + trayMenu = menu + trayTarget = cls("VeraeNetPrintTray").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + menu.Send(objc.RegisterName("setDelegate:"), trayTarget) + item.Send(objc.RegisterName("setMenu:"), menu) + rebuildDarwinMenu(menu) + app.Send(objc.RegisterName("run")) + return nil +} + +func registerTrayClass() { + _, _ = objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{ + {Cmd: selOpen, Fn: menuWillOpen}, + {Cmd: selTray, Fn: trayAction}, + }) +} + +func menuWillOpen(self, _cmd, menu objc.ID) { + rebuildDarwinMenu(menu) +} + +func trayAction(self, _cmd, sender objc.ID) { + tag := int(objc.Send[int64](sender, objc.RegisterName("tag"))) + trayMu.Lock() + items := append([]MenuEntry(nil), trayItems...) + trayMu.Unlock() + st := Probe() + for _, e := range items { + if e.ID == tag { + _ = Dispatch(st, e) + return + } + } +} + +func rebuildDarwinMenu(menu objc.ID) { + st := Probe() + items := BuildMenu(st) + trayMu.Lock() + trayItems = items + trayMu.Unlock() + menu.Send(objc.RegisterName("removeAllItems")) + for _, e := range items { + if e.Separator { + sep := cls("NSMenuItem").Send(objc.RegisterName("separatorItem")) + menu.Send(objc.RegisterName("addItem:"), sep) + continue + } + item := cls("NSMenuItem").Send(objc.RegisterName("alloc")).Send( + objc.RegisterName("initWithTitle:action:keyEquivalent:"), + nsString(e.Label), selTray, nsString(""), + ) + item.Send(objc.RegisterName("setTag:"), int64(e.ID)) + item.Send(objc.RegisterName("setTarget:"), trayTarget) + en := byte(0) + if e.Enabled { + en = 1 + } + item.Send(objc.RegisterName("setEnabled:"), en) + if e.Checked { + item.Send(objc.RegisterName("setState:"), int64(1)) + } + menu.Send(objc.RegisterName("addItem:"), item) + } +} + +func cls(name string) objc.ID { return objc.ID(objc.GetClass(name)) } + +func nsString(s string) objc.ID { + b := append([]byte(s), 0) + id := cls("NSString").Send(objc.RegisterName("stringWithUTF8String:"), unsafe.Pointer(&b[0])) + runtime.KeepAlive(b) + return id +} diff --git a/internal/netprint/tray_linux.go b/internal/netprint/tray_linux.go new file mode 100644 index 0000000..3ce66e8 --- /dev/null +++ b/internal/netprint/tray_linux.go @@ -0,0 +1,182 @@ +//go:build linux + +package netprint + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/prop" +) + +type dbusItem struct { + ID int32 + Props map[string]dbus.Variant + Children []dbus.Variant +} + +type trayMenu struct { + mu sync.Mutex + rev uint32 + conn *dbus.Conn + entries []MenuEntry + status Status +} + +func (m *trayMenu) refresh() { + st := Probe() + entries := BuildMenu(st) + m.mu.Lock() + m.status = st + m.entries = entries + m.rev++ + rev := m.rev + conn := m.conn + m.mu.Unlock() + if conn != nil { + _ = conn.Emit(dbus.ObjectPath("/Menu"), "com.canonical.dbusmenu.LayoutUpdated", rev, int32(0)) + } +} + +func (m *trayMenu) layout() dbusItem { + root := dbusItem{ID: 0, Props: map[string]dbus.Variant{"children-display": dbus.MakeVariant("submenu")}, Children: []dbus.Variant{}} + for _, e := range m.entries { + if e.Separator { + root.Children = append(root.Children, dbus.MakeVariant(dbusItem{ + ID: 0, Props: map[string]dbus.Variant{"type": dbus.MakeVariant("separator")}, Children: []dbus.Variant{}, + })) + continue + } + props := map[string]dbus.Variant{ + "label": dbus.MakeVariant(e.Label), + "enabled": dbus.MakeVariant(e.Enabled), + } + if e.Checked { + props["toggle-type"] = dbus.MakeVariant("checkmark") + props["toggle-state"] = dbus.MakeVariant(int32(1)) + } + root.Children = append(root.Children, dbus.MakeVariant(dbusItem{ + ID: int32(e.ID), Props: props, Children: []dbus.Variant{}, + })) + } + return root +} + +func (m *trayMenu) GetLayout(parentID int32, recursionDepth int32, propertyNames []string) (uint32, dbusItem, *dbus.Error) { + m.mu.Lock() + defer m.mu.Unlock() + if parentID != 0 { + return m.rev, dbusItem{ID: parentID, Props: map[string]dbus.Variant{}, Children: []dbus.Variant{}}, nil + } + return m.rev, m.layout(), nil +} + +func (m *trayMenu) Event(id int32, eventID string, data dbus.Variant, timestamp uint32) *dbus.Error { + if eventID != "clicked" { + return nil + } + m.mu.Lock() + st := m.status + var entry MenuEntry + for _, e := range m.entries { + if e.ID == int(id) { + entry = e + break + } + } + m.mu.Unlock() + if entry.Kind == "" { + return nil + } + go func() { + if err := Dispatch(st, entry); err != nil && IsQuit(err) { + os.Exit(0) + } + }() + return nil +} + +func (m *trayMenu) AboutToShow(id int32) (bool, *dbus.Error) { + m.refresh() + return true, nil +} + +func (m *trayMenu) GetGroupProperties(ids []int32, props []string) ([]struct { + ID int32 + Props map[string]dbus.Variant +}, *dbus.Error) { + return nil, nil +} + +type sni struct{} + +func (sni) Activate(x, y int32) *dbus.Error { return nil } +func (sni) ContextMenu(x, y int32) *dbus.Error { return nil } +func (sni) SecondaryActivate(x, y int32) *dbus.Error { return nil } +func (sni) Scroll(delta int32, orientation string) *dbus.Error { + return nil +} + +func runTray(ctx context.Context) error { + conn, err := dbus.ConnectSessionBus() + if err != nil { + log.Printf("verae-netprint tray: no session bus (%v)", err) + return nil + } + defer conn.Close() + menu := &trayMenu{conn: conn} + menu.refresh() + if err := conn.Export(menu, "/Menu", "com.canonical.dbusmenu"); err != nil { + return err + } + if _, err := prop.Export(conn, "/Menu", prop.Map{ + "com.canonical.dbusmenu": { + "Version": {Value: int32(3), Emit: prop.EmitConst}, + "TextDirection": {Value: "ltr", Emit: prop.EmitConst}, + "Status": {Value: "normal", Emit: prop.EmitTrue}, + "IconThemePath": {Value: []string{}, Emit: prop.EmitConst}, + }, + }); err != nil { + return err + } + if err := conn.Export(sni{}, "/StatusNotifierItem", "org.kde.StatusNotifierItem"); err != nil { + return err + } + if _, err := prop.Export(conn, "/StatusNotifierItem", prop.Map{ + "org.kde.StatusNotifierItem": { + "Category": {Value: "ApplicationStatus", Emit: prop.EmitConst}, + "Id": {Value: "verae-netprint", Emit: prop.EmitConst}, + "Title": {Value: "Verae Timestamp", Emit: prop.EmitTrue}, + "Status": {Value: "Active", Emit: prop.EmitTrue}, + "IconName": {Value: "printer", Emit: prop.EmitConst}, + "ItemIsMenu": {Value: true, Emit: prop.EmitConst}, + "Menu": {Value: dbus.ObjectPath("/Menu"), Emit: prop.EmitConst}, + }, + }); err != nil { + return err + } + busName := fmt.Sprintf("org.kde.StatusNotifierItem-%d-1", os.Getpid()) + if _, err := conn.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil { + return err + } + obj := conn.Object("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher") + if err := obj.Call("org.kde.StatusNotifierWatcher.RegisterStatusNotifierItem", 0, busName).Err; err != nil { + if err2 := obj.Call("org.kde.StatusNotifierWatcher.RegisterStatusNotifierItem", 0, "/StatusNotifierItem").Err; err2 != nil { + log.Printf("verae-netprint tray: status notifier watcher: %v", err) + return nil + } + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + select { + case <-ctx.Done(): + case <-sig: + } + return nil +} diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go new file mode 100644 index 0000000..9b347f0 --- /dev/null +++ b/internal/netprint/tray_menu.go @@ -0,0 +1,149 @@ +package netprint + +import ( + "fmt" + "strings" +) + +const ( + idStatus = 1 + idStart = 3 + idStop = 4 + idRestart = 5 + idLaunch = 7 + idTray = 8 + idReceipts = 10 + idSettings = 11 + idQuit = 100 + idRecent = 20 +) + +// MenuEntry is one row in the tray or menu bar. The UI layers only display it. +type MenuEntry struct { + ID int + Label string + Enabled bool + Checked bool + Separator bool + URL string + Kind string +} + +// Probe reads status from the loopback control API. A failure means the service is stopped. +func Probe() Status { + st, err := NewClient(8630).Status() + if err != nil { + return StoppedStatus() + } + return st +} + +// BuildMenu is the control surface: status, service, login items, receipts, and settings. +func BuildMenu(st Status) []MenuEntry { + state := "Stopped" + if st.Running { + state = "Running" + } + where := st.Name + if where == "" { + where = "Verae Timestamp" + } + if len(st.Addresses) > 0 && st.Port > 0 { + where = fmt.Sprintf("%s · %s:%d", where, st.Addresses[0], st.Port) + } + var out []MenuEntry + out = append(out, + MenuEntry{ID: idStatus, Label: fmt.Sprintf("%s — %s · %d jobs today", state, where, st.JobsToday), Kind: "status"}, + MenuEntry{Separator: true}, + MenuEntry{ID: idStart, Label: "Start", Enabled: !st.Running, Kind: "start"}, + MenuEntry{ID: idStop, Label: "Stop", Enabled: st.Running, Kind: "stop"}, + MenuEntry{ID: idRestart, Label: "Restart", Enabled: st.Running, Kind: "restart"}, + MenuEntry{Separator: true}, + MenuEntry{ID: idLaunch, Label: "Launch printer at login", Enabled: st.Running, Checked: st.LaunchAtLogin, Kind: "launch"}, + MenuEntry{ID: idTray, Label: "Launch this menu at login", Enabled: st.Running, Checked: st.TrayAtLogin, Kind: "tray"}, + MenuEntry{Separator: true}, + MenuEntry{ID: idReceipts, Label: "Open receipts", Enabled: st.Running && st.AdminURL != "", URL: firstURL(st), Kind: "open"}, + ) + for i, rec := range st.Recent { + if i >= 6 { + break + } + label := firstNonEmpty(rec.Title, fmt.Sprintf("Job %d", rec.ID)) + if rec.ReceiptID == "" { + continue + } + out = append(out, MenuEntry{ + ID: idRecent + i, Label: "Receipt · " + label, Enabled: true, + URL: receiptURL(st, rec), Kind: "open", + }) + } + settings := strings.TrimRight(st.ControlURL, "/") + "/settings" + if st.ControlURL == "" { + settings = "http://127.0.0.1:8630/settings" + } + out = append(out, + MenuEntry{ID: idSettings, Label: "Settings…", Enabled: st.Running, URL: settings, Kind: "open"}, + MenuEntry{Separator: true}, + MenuEntry{ID: idQuit, Label: "Quit menu", Enabled: true, Kind: "quit"}, + ) + return out +} + +func firstURL(st Status) string { + if u := strings.TrimRight(st.ControlURL, "/"); u != "" { + return u + "/" + } + if st.AdminURL != "" { + return st.AdminURL + } + return "http://127.0.0.1:8630/" +} + +func receiptURL(st Status, rec Record) string { + base := strings.TrimRight(st.ControlURL, "/") + if base == "" { + base = "http://127.0.0.1:8630" + } + return fmt.Sprintf("%s/jobs/%d/receipt.pdf", base, rec.ID) +} + +// Dispatch performs one menu action. Settings and receipts open in the browser. +// Start uses the OS service manager when the control API is down. Other changes +// go through that API so this process never edits config files. +func Dispatch(st Status, e MenuEntry) error { + c := NewClient(8630) + if st.ControlURL != "" { + c.BaseURL = strings.TrimRight(st.ControlURL, "/") + } + switch e.Kind { + case "start": + if st.Running { + return c.Service("start") + } + return NewServiceControl().Start() + case "stop": + return c.Service("stop") + case "restart": + return c.Service("restart") + case "launch": + return c.SetLaunchAtLogin(!st.LaunchAtLogin) + case "tray": + return c.SetTrayAtLogin(!st.TrayAtLogin) + case "open": + return OpenURL(e.URL) + case "quit": + return errQuit + default: + return nil + } +} + +type quitError struct{} + +func (quitError) Error() string { return "quit" } + +var errQuit quitError + +func IsQuit(err error) bool { + return err == errQuit +} diff --git a/internal/netprint/tray_windows.go b/internal/netprint/tray_windows.go new file mode 100644 index 0000000..f946576 --- /dev/null +++ b/internal/netprint/tray_windows.go @@ -0,0 +1,184 @@ +//go:build windows + +package netprint + +import ( + "context" + "syscall" + "unsafe" +) + +const ( + wmUser = 0x0400 + wmCommand = 0x0111 + wmDestroy = 0x0002 + wmRButtonUp = 0x0205 + wmLButtonUp = 0x0202 + nimAdd = 0 + nimDelete = 2 + nifMessage = 1 + nifIcon = 2 + nifTip = 4 + mfString = 0 + mfSeparator = 0x800 + mfGrayed = 1 + mfChecked = 8 + idiApp = 32512 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + shell32 = syscall.NewLazyDLL("shell32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + pRegisterClassExW = user32.NewProc("RegisterClassExW") + pCreateWindowExW = user32.NewProc("CreateWindowExW") + pDefWindowProcW = user32.NewProc("DefWindowProcW") + pGetMessageW = user32.NewProc("GetMessageW") + pTranslateMessage = user32.NewProc("TranslateMessage") + pDispatchMessageW = user32.NewProc("DispatchMessageW") + pPostQuitMessage = user32.NewProc("PostQuitMessage") + pLoadIconW = user32.NewProc("LoadIconW") + pCreatePopupMenu = user32.NewProc("CreatePopupMenu") + pAppendMenuW = user32.NewProc("AppendMenuW") + pTrackPopupMenu = user32.NewProc("TrackPopupMenu") + pDestroyMenu = user32.NewProc("DestroyMenu") + pSetForegroundWindow = user32.NewProc("SetForegroundWindow") + pGetCursorPos = user32.NewProc("GetCursorPos") + pShellNotify = shell32.NewProc("Shell_NotifyIconW") + pGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + wndProcCB = syscall.NewCallback(trayWndProc) + trayHWND uintptr + trayIconData notifyIcon + trayShown []MenuEntry +) + +type wndClassEx struct { + size uint32 + style uint32 + wndProc uintptr + clsExtra int32 + wndExtra int32 + instance uintptr + icon uintptr + cursor uintptr + background uintptr + menuName *uint16 + className *uint16 + iconSm uintptr +} + +type point struct{ x, y int32 } + +type winMsg struct { + hwnd uintptr + message uint32 + wParam uintptr + lParam uintptr + time uint32 + pt point +} + +type notifyIcon struct { + cbSize uint32 + hWnd uintptr + uID uint32 + uFlags uint32 + uCallbackMessage uint32 + hIcon uintptr + szTip [128]uint16 +} + +func runTray(ctx context.Context) error { + inst, _, _ := pGetModuleHandleW.Call(0) + class, _ := syscall.UTF16PtrFromString("VeraeNetPrintTray") + wc := wndClassEx{wndProc: wndProcCB, instance: inst, className: class} + wc.size = uint32(unsafe.Sizeof(wc)) + if r, _, err := pRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))); r == 0 { + return err + } + title, _ := syscall.UTF16PtrFromString("Verae Network Printer") + hwnd, _, err := pCreateWindowExW.Call(0, uintptr(unsafe.Pointer(class)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, inst, 0) + if hwnd == 0 { + return err + } + trayHWND = hwnd + icon, _, _ := pLoadIconW.Call(0, idiApp) + trayIconData = notifyIcon{ + hWnd: hwnd, uID: 1, + uFlags: nifMessage | nifIcon | nifTip, uCallbackMessage: wmUser + 1, hIcon: icon, + } + trayIconData.cbSize = uint32(unsafe.Sizeof(trayIconData)) + tip, _ := syscall.UTF16FromString("Verae Timestamp") + copy(trayIconData.szTip[:], tip) + pShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&trayIconData))) + defer pShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&trayIconData))) + var m winMsg + for { + r, _, _ := pGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if int32(r) <= 0 { + break + } + pTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) + pDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) + } + return nil +} + +func trayWndProc(hwnd, msg, wParam, lParam uintptr) uintptr { + switch msg { + case wmUser + 1: + if lParam == wmRButtonUp || lParam == wmLButtonUp { + showTrayMenu(hwnd) + return 0 + } + case wmCommand: + id := int(wParam & 0xffff) + go func() { + st := Probe() + for _, e := range trayShown { + if e.ID == id { + if err := Dispatch(st, e); IsQuit(err) { + pPostQuitMessage.Call(0) + } + return + } + } + }() + return 0 + case wmDestroy: + pPostQuitMessage.Call(0) + return 0 + } + r, _, _ := pDefWindowProcW.Call(hwnd, msg, wParam, lParam) + return r +} + +func showTrayMenu(hwnd uintptr) { + st := Probe() + items := BuildMenu(st) + trayShown = items + menu, _, _ := pCreatePopupMenu.Call() + if menu == 0 { + return + } + defer pDestroyMenu.Call(menu) + for _, e := range items { + if e.Separator { + pAppendMenuW.Call(menu, mfSeparator, 0, 0) + continue + } + flags := uintptr(mfString) + if !e.Enabled { + flags |= mfGrayed + } + if e.Checked { + flags |= mfChecked + } + label, _ := syscall.UTF16PtrFromString(e.Label) + pAppendMenuW.Call(menu, flags, uintptr(e.ID), uintptr(unsafe.Pointer(label))) + } + var pt point + pGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) + pSetForegroundWindow.Call(hwnd) + pTrackPopupMenu.Call(menu, 0x0000, uintptr(pt.x), uintptr(pt.y), 0, hwnd, 0) +} diff --git a/platform/linux/deb/build-deb.sh b/platform/linux/deb/build-deb.sh index 93ff435..cb66a39 100755 --- a/platform/linux/deb/build-deb.sh +++ b/platform/linux/deb/build-deb.sh @@ -34,13 +34,15 @@ build_arch() { local stage="$OUT/stage-${arch}" rm -rf "$stage" mkdir -p "$stage/DEBIAN" \ - "$stage/usr/bin" \ + "$stage/usr/lib/verae-print" \ "$stage/usr/lib/cups/backend" \ "$stage/usr/share/doc/$PKG_NAME" \ "$stage/usr/share/man/man1" \ "$stage/usr/share/applications" - install -m 755 "$bin" "$stage/usr/bin/verae-print" + # The command is /usr/bin/verae-print, a symlink created in postinst, so the + # standalone verae-netprint package can share that path without dpkg conflicts. + install -m 755 "$bin" "$stage/usr/lib/verae-print/verae-print" if [[ -f "$ROOT/platform/linux/cups/backend-verae.sh" ]]; then install -m 700 "$ROOT/platform/linux/cups/backend-verae.sh" "$stage/usr/lib/cups/backend/verae" fi @@ -112,19 +114,46 @@ Section: utils Priority: optional Architecture: $arch Maintainer: Verae -Depends: libc6 +Depends: libc6, debconf Recommends: cups-client, zenity, libnotify-bin, xdg-utils +Suggests: verae-netprint Installed-Size: $size Homepage: https://printit.verae.ai/ Description: Digests-only Verae timestamping from Print / CLI Verae Print Stamp hashes a document locally and registers only the SHA-256 digest with Verae. Includes an optional CUPS backend for a "Verae Timestamping" print destination. + . + The same package can enable the Verae network timestamp printer + (IPP Everywhere on the LAN). That choice is off unless you accept + the debconf prompt, or you install the verae-netprint package. + Both use /usr/bin/verae-print, /etc/verae/netprint.json, and + systemd unit verae-netprint.service. EOF + cat >"$stage/DEBIAN/templates" <<'EOF' +Template: verae-print/enable-netprint +Type: boolean +Default: false +Description: Enable the Verae network timestamp printer? + The network printer appears on your LAN so phones and other computers can + print to it. It stays off unless you choose this. You can also install the + separate verae-netprint package later. Either way there is one service + (verae-netprint) and one menu-bar / tray control. +EOF + cat >"$stage/DEBIAN/config" <<'EOF' +#!/bin/sh +set -e +. /usr/share/debconf/confmodule +db_input medium verae-print/enable-netprint || true +db_go || true +EOF + chmod 755 "$stage/DEBIAN/config" + cat >"$stage/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e +ln -sfn /usr/lib/verae-print/verae-print /usr/bin/verae-print if command -v lpadmin >/dev/null 2>&1 && [ -x /usr/lib/cups/backend/verae ]; then # Create the queue only if it doesn't exist: deleting it on upgrade purges # any jobs waiting in it. @@ -137,6 +166,15 @@ if command -v lpadmin >/dev/null 2>&1 && [ -x /usr/lib/cups/backend/verae ]; the cupsenable Verae_Timestamping 2>/dev/null || true cupsaccept Verae_Timestamping 2>/dev/null || true fi +enable=false +if [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_get verae-print/enable-netprint || true + enable=${RET:-false} +fi +if [ "$enable" = "true" ]; then + /usr/lib/verae-print/verae-print netprint install --owner driver || true +fi echo "verae-print installed. Next: verae-print setup" exit 0 EOF @@ -146,13 +184,32 @@ EOF #!/bin/sh set -e # Only drop the queue on a real removal, not on upgrade. -if [ "$1" = "remove" ] && command -v lpadmin >/dev/null 2>&1; then - lpadmin -x Verae_Timestamping 2>/dev/null || true +if [ "$1" = "remove" ]; then + if [ -x /usr/lib/verae-print/verae-print ]; then + /usr/lib/verae-print/verae-print netprint uninstall --owner driver || true + fi + if command -v lpadmin >/dev/null 2>&1; then + lpadmin -x Verae_Timestamping 2>/dev/null || true + fi fi exit 0 EOF chmod 755 "$stage/DEBIAN/prerm" + cat >"$stage/DEBIAN/postrm" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then + if [ -x /usr/lib/verae-netprint/verae-print ]; then + ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print + else + rm -f /usr/bin/verae-print + fi +fi +exit 0 +EOF + chmod 755 "$stage/DEBIAN/postrm" + local deb="$OUT/${PKG_NAME}_${VERSION}_${arch}.deb" if command -v fakeroot >/dev/null 2>&1; then fakeroot dpkg-deb --build "$stage" "$deb" @@ -162,7 +219,7 @@ EOF # Stable name cp -f "$deb" "$OUT/${PKG_NAME}_${arch}.deb" echo "built $deb" - dpkg-deb -I "$deb" | head -20 + dpkg-deb -I "$deb" | head -20 || true } resolve_bin() { diff --git a/platform/linux/deb/build-netprint-deb.sh b/platform/linux/deb/build-netprint-deb.sh new file mode 100755 index 0000000..0773f27 --- /dev/null +++ b/platform/linux/deb/build-netprint-deb.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Build the standalone verae-netprint Debian package. +# It shares /usr/bin/verae-print (a symlink), /etc/verae/netprint.json, and +# systemd unit verae-netprint.service with the driver package's opt-in. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +VERSION="${VERSION:-0.3.4}" +OUT="${OUT:-$ROOT/build/netprint}" +ARCH="${1:-amd64}" +BIN="${BIN:-}" + +if [[ -z "$BIN" ]]; then + case "$ARCH" in + amd64) BIN="${BIN_AMD64:-$ROOT/build/netprint/linux-amd64/verae-print}" ;; + arm64) BIN="${BIN_ARM64:-$ROOT/build/netprint/linux-arm64/verae-print}" ;; + esac +fi +if [[ ! -f "$BIN" ]]; then + echo "missing binary: $BIN" >&2 + exit 1 +fi +if ! command -v dpkg-deb >/dev/null 2>&1; then + echo "dpkg-deb required" >&2 + exit 1 +fi + +stage="$OUT/stage-netprint-${ARCH}" +rm -rf "$stage" +mkdir -p "$stage/DEBIAN" \ + "$stage/usr/lib/verae-netprint" \ + "$stage/usr/share/doc/verae-netprint" + +install -m 755 "$BIN" "$stage/usr/lib/verae-netprint/verae-print" +cat >"$stage/usr/share/doc/verae-netprint/copyright" <"$stage/DEBIAN/control" < +Depends: libc6, adduser +Recommends: cups-client +Installed-Size: $size +Homepage: https://printit.verae.ai/ +Description: Verae network timestamp printer (IPP Everywhere) + A LAN printer that timestamps each job with Verae and keeps the receipt + on this computer. Installs systemd unit verae-netprint, a tray icon at + login, and the loopback control API on 127.0.0.1:8630. + . + Shares /usr/bin/verae-print and the service name with the optional + component of the verae-print driver package, so installing both upgrades + in place instead of running two servers. +EOF + +cat >"$stage/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "configure" ]; then + ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print + /usr/lib/verae-netprint/verae-print netprint install --owner standalone +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/postinst" + +cat >"$stage/DEBIAN/prerm" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "remove" ] && [ -x /usr/lib/verae-netprint/verae-print ]; then + /usr/lib/verae-netprint/verae-print netprint uninstall --owner standalone || true +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/prerm" + +cat >"$stage/DEBIAN/postrm" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then + if [ -x /usr/lib/verae-print/verae-print ]; then + ln -sfn /usr/lib/verae-print/verae-print /usr/bin/verae-print + else + rm -f /usr/bin/verae-print + fi +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/postrm" + +mkdir -p "$OUT" +deb="$OUT/verae-netprint_${VERSION}_${ARCH}.deb" +if command -v fakeroot >/dev/null 2>&1; then + fakeroot dpkg-deb --build "$stage" "$deb" +else + dpkg-deb --build "$stage" "$deb" +fi +cp -f "$deb" "$OUT/verae-netprint_${ARCH}.deb" +echo "built $deb" +dpkg-deb -I "$deb" | head -20 || true diff --git a/platform/macos/netprint/com.verae.netprint.plist b/platform/macos/netprint/com.verae.netprint.plist new file mode 100644 index 0000000..c3ef224 --- /dev/null +++ b/platform/macos/netprint/com.verae.netprint.plist @@ -0,0 +1,14 @@ + + + + Labelcom.verae.netprint + ProgramArguments + + /Library/Application Support/Verae/PrintStamp/bin/verae-print + netprint + --config + /Library/Application Support/Verae/PrintStamp/netprint.json + + RunAtLoad + KeepAlive + diff --git a/platform/macos/netprint/com.verae.netprint.tray.plist b/platform/macos/netprint/com.verae.netprint.tray.plist new file mode 100644 index 0000000..fcf84d7 --- /dev/null +++ b/platform/macos/netprint/com.verae.netprint.tray.plist @@ -0,0 +1,12 @@ + + + + Labelcom.verae.netprint.tray + ProgramArguments + + /Library/Application Support/Verae/PrintStamp/bin/verae-print + tray + + RunAtLoad + LimitLoadToSessionTypeAqua + diff --git a/platform/macos/netprint/scripts/postinstall b/platform/macos/netprint/scripts/postinstall new file mode 100755 index 0000000..3d54813 --- /dev/null +++ b/platform/macos/netprint/scripts/postinstall @@ -0,0 +1,11 @@ +#!/bin/bash +# Enable the shared Verae network printer. Safe to run from the driver package's +# optional component (owner driver) or the standalone package (owner standalone). +set -euo pipefail +OWNER="${VERAE_NETPRINT_OWNER:-standalone}" +BIN="/Library/Application Support/Verae/PrintStamp/bin/verae-print" +if [[ ! -x "$BIN" ]]; then + echo "verae-print is not installed at $BIN" >&2 + exit 1 +fi +exec "$BIN" netprint install --owner "$OWNER" diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh new file mode 100755 index 0000000..70d3902 --- /dev/null +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Standalone Verae Network Printer .pkg. +# On a Mac with pkgbuild this produces an installer. Signing happens only when +# signing.env is present (same file as the driver package). Without pkgbuild +# (Linux CI) the caller still ships the darwin binaries and this script. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +VERSION="${VERSION:-0.3.4}" +OUT="${OUT:-$ROOT/build/netprint/macos}" +BIN="${BIN:-$ROOT/build/netprint/macos/verae-print}" +mkdir -p "$OUT" + +if [[ ! -x "$BIN" && ! -f "$BIN" ]]; then + echo "missing $BIN" >&2 + exit 1 +fi + +STAGE="$OUT/payload" +SCRIPTS="$OUT/scripts" +rm -rf "$STAGE" "$SCRIPTS" +mkdir -p "$STAGE/Library/Application Support/Verae/PrintStamp/bin" "$SCRIPTS" +cp -f "$BIN" "$STAGE/Library/Application Support/Verae/PrintStamp/bin/verae-print" +chmod 755 "$STAGE/Library/Application Support/Verae/PrintStamp/bin/verae-print" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/PrintStamp/" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/PrintStamp/" +cat >"$SCRIPTS/postinstall" <<'EOF' +#!/bin/bash +export VERAE_NETPRINT_OWNER=standalone +exec "/Library/Application Support/Verae/PrintStamp/bin/verae-print" netprint install --owner standalone +EOF +chmod 755 "$SCRIPTS/postinstall" + +if ! command -v pkgbuild >/dev/null 2>&1; then + echo "pkgbuild not available — payload and scripts are in $OUT" + tar -C "$OUT" -czf "$OUT/VeraeNetPrint-macos-payload.tar.gz" payload scripts + exit 0 +fi + +pkgbuild \ + --root "$STAGE" \ + --scripts "$SCRIPTS" \ + --identifier "ai.verae.netprint.standalone" \ + --version "$VERSION" \ + --install-location "/" \ + "$OUT/VeraeNetPrint-macos.pkg" + +SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}" +if [[ -f "$SIGNING_ENV" ]]; then + # shellcheck source=/dev/null + source "$SIGNING_ENV" + ID_PKG="${APPLE_INSTALLER_SIGN_ID:-Developer ID Installer: FlightLookup Inc. (G8CKY7VMZC)}" + productbuild --package "$OUT/VeraeNetPrint-macos.pkg" --sign "$ID_PKG" --timestamp \ + "$OUT/VeraeNetPrint-macos-signed.pkg" + mv -f "$OUT/VeraeNetPrint-macos-signed.pkg" "$OUT/VeraeNetPrint-macos.pkg" +fi +echo "built $OUT/VeraeNetPrint-macos.pkg" diff --git a/platform/macos/pkg/build-pkg.sh b/platform/macos/pkg/build-pkg.sh index a5f3d46..119ff34 100755 --- a/platform/macos/pkg/build-pkg.sh +++ b/platform/macos/pkg/build-pkg.sh @@ -126,7 +126,19 @@ echo "→ Preparing pkg scripts" cp -f "$PKG_DIR/scripts/postinstall" "$SCRIPTS/postinstall" chmod 755 "$SCRIPTS/postinstall" +# Optional network-printer component. Off unless the user selects it in +# Customize. It calls the same install command as the standalone package. +NET_SCRIPTS="$BUILD_ROOT/netprint-scripts" +mkdir -p "$NET_SCRIPTS" +cat >"$NET_SCRIPTS/postinstall" <<'EOF' +#!/bin/bash +export VERAE_NETPRINT_OWNER=driver +exec "/Library/Application Support/Verae/PrintStamp/bin/verae-print" netprint install --owner driver +EOF +chmod 755 "$NET_SCRIPTS/postinstall" + COMPONENT_PKG="$DIST/VeraePrintStamp-component.pkg" +NET_PKG="$DIST/VeraeNetPrint-component.pkg" PRODUCT_PKG="$DIST/VeraePrintStamp-${VERSION}.pkg" echo "→ pkgbuild component" @@ -138,9 +150,22 @@ pkgbuild \ --install-location "/" \ "$COMPONENT_PKG" +echo "→ pkgbuild optional network printer" +pkgbuild \ + --nopayload \ + --scripts "$NET_SCRIPTS" \ + --identifier "ai.verae.netprint.pkg" \ + --version "$VERSION" \ + --install-location "/" \ + "$NET_PKG" + +cp -f "$PKG_DIR/distribution.xml" "$DIST/distribution.xml" +# distribution.xml carries version 0; productbuild reads the component pkgs. + echo "→ productbuild + Developer ID Installer sign" productbuild \ - --package "$COMPONENT_PKG" \ + --distribution "$DIST/distribution.xml" \ + --package-path "$DIST" \ --sign "$ID_PKG" \ --timestamp \ "$PRODUCT_PKG" diff --git a/platform/macos/pkg/distribution.xml b/platform/macos/pkg/distribution.xml new file mode 100644 index 0000000..2f450a1 --- /dev/null +++ b/platform/macos/pkg/distribution.xml @@ -0,0 +1,18 @@ + + + Verae Print Stamp + + + + + + + + + + + + + VeraePrintStamp-component.pkg + VeraeNetPrint-component.pkg + diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi new file mode 100644 index 0000000..9e3b6d4 --- /dev/null +++ b/platform/windows/installer-netprint.nsi @@ -0,0 +1,98 @@ +; Verae Network Printer — standalone Windows installer (NSIS). +; Installs the same binaries, service name (VeraeNetPrint), and config as the +; optional checkbox in the driver installer. Installing one when the other is +; present upgrades in place. +!ifndef VERSION + !define VERSION "0.3.4" +!endif +!ifndef ARCH + !define ARCH "amd64" +!endif +!ifndef SRCDIR + !define SRCDIR "." +!endif +!ifndef OUTDIR + !define OUTDIR "." +!endif + +!define PRODUCT_NAME "Verae Network Printer" +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraeNetPrint" + +Name "${PRODUCT_NAME} ${VERSION}" +OutFile "${OUTDIR}\VeraeNetPrint-windows-${ARCH}-${VERSION}-setup.exe" +InstallDir "$PROGRAMFILES64\Verae\PrintStamp" +RequestExecutionLevel admin +Unicode true +SetCompressor /SOLID lzma +ShowInstDetails show + +!include "MUI2.nsh" +!include "x64.nsh" +!include "LogicLib.nsh" + +!define MUI_ABORTWARNING +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_LICENSE "${SRCDIR}\LICENSE.txt" +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +Function .onInit + ${IfNot} ${RunningX64} + MessageBox MB_ICONSTOP "This build targets 64-bit Windows." + Abort + ${EndIf} + SetRegView 64 + SetShellVarContext all +FunctionEnd + +Section "Verae Network Printer" SecMain + SectionIn RO + SetOutPath "$INSTDIR\bin" + File "${SRCDIR}\verae-print.exe" + File "${SRCDIR}\verae-netprint-tray.exe" + SetOutPath "$INSTDIR" + File "${SRCDIR}\LICENSE.txt" + WriteUninstaller "$INSTDIR\Uninstall-NetPrint.exe" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall-NetPrint.exe"' + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "Verae" + CreateDirectory "$SMPROGRAMS\Verae" + CreateShortCut "$SMPROGRAMS\Verae\Verae Network Printer.lnk" "$INSTDIR\bin\verae-netprint-tray.exe" + nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint install --owner standalone' + Pop $0 + ${If} $0 != 0 + MessageBox MB_ICONEXCLAMATION "The service did not start (exit $0). You can start it from the tray icon after signing in." + ${EndIf} +SectionEnd + +Section "Uninstall" + nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner standalone' + Pop $0 + ClearErrors + ReadRegDWORD $R1 HKLM "Software\Verae\NetPrint" "Driver" + ${If} ${Errors} + StrCpy $R1 0 + ${EndIf} + ClearErrors + ${If} $R1 != 1 + nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' + Pop $0 + Delete "$INSTDIR\bin\verae-print.exe" + Delete "$INSTDIR\bin\verae-netprint-tray.exe" + RMDir "$INSTDIR\bin" + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\Uninstall-NetPrint.exe" + RMDir "$INSTDIR" + Delete "$SMPROGRAMS\Verae\Verae Network Printer.lnk" + RMDir "$SMPROGRAMS\Verae" + ${Else} + Delete "$INSTDIR\Uninstall-NetPrint.exe" + Delete "$SMPROGRAMS\Verae\Verae Network Printer.lnk" + ${EndIf} + DeleteRegKey HKLM "${UNINST_KEY}" +SectionEnd diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index d4196b9..eba8ec4 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -77,6 +77,8 @@ Var hOpenPdf Var OpenMode ; folder|pdf|none Var hPromptChk Var PromptMode ; true|false (promptForFilename) +Var hNetChk +Var NetPrint ; 1 when the optional LAN printer is selected Var TargetProfile ; results Var FinishTitle @@ -158,6 +160,7 @@ Function .onInit File "${SRCDIR}\scripts\common.ps1" File "${SRCDIR}\scripts\user-token.ps1" StrCpy $Exe "$PLUGINSDIR\verae-print.exe" + StrCpy $NetPrint "0" StrCpy $Checked 0 StrCpy $TokenReady 0 StrCpy $KeyChoice "skip" @@ -540,8 +543,10 @@ Function OptionsPageCreate Pop $hOpenFolder ${NSD_CreateRadioButton} 14u 66u 90% 12u "Open the certificate PDF in my PDF viewer" Pop $hOpenPdf - ${NSD_CreateLabel} 0 88u 100% 30u "Without the Save As dialog, files are saved in Documents\Verae Timestamps. You can change these later in %USERPROFILE%\.verae\config.json (promptForFilename: true/false; openOnComplete: folder, pdf or none)." + ${NSD_CreateLabel} 0 88u 100% 24u "Without the Save As dialog, files are saved in Documents\Verae Timestamps. You can change these later in %USERPROFILE%\.verae\config.json (promptForFilename: true/false; openOnComplete: folder, pdf or none)." Pop $0 + ${NSD_CreateCheckbox} 0 116u 100% 28u "Also install the &network timestamp printer (off unless checked)$\r$\nSame service and tray icon as the standalone Verae Network Printer setup. Other computers on your LAN can print to it." + Pop $hNetChk ${If} $PromptMode != "false" ${NSD_Check} $hPromptChk ${EndIf} @@ -588,6 +593,12 @@ Function OptionsPageLeave ${Else} StrCpy $OpenMode "folder" ${EndIf} + ${NSD_GetState} $hNetChk $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetPrint "1" + ${Else} + StrCpy $NetPrint "0" + ${EndIf} FunctionEnd ; ======================= Install ======================= @@ -701,6 +712,22 @@ Section "Verae Print Stamp + printer" SecMain ${LOG} "ERROR printer setup step(s) failed - details in $8" ${EndIf} + ${If} $NetPrint == "1" + SetOutPath "$INSTDIR\bin" + File "${SRCDIR}\verae-netprint-tray.exe" + DetailPrint "Installing the Verae network printer (service + tray)" + nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver' + Pop $0 + ${If} $0 == 0 + !insertmacro STEP_RESULT "Network printer" "OK - Verae Timestamp on the LAN, tray at login" + ${Else} + ${LOG} "ERROR network printer install exit $0" + !insertmacro STEP_RESULT "Network printer" "FAIL (exit $0)" + ${EndIf} + ${Else} + !insertmacro STEP_RESULT "Network printer" "SKIPPED" + ${EndIf} + ${If} $5 == "OK" StrCpy $FinishTitle "Verae TimeStamp PDF is installed" StrCpy $FinishText "$FinishText$\r$\nPrint from any app and choose 'Verae TimeStamp PDF'. Results go to Documents\Verae Timestamps." @@ -715,20 +742,37 @@ SectionEnd Section "Uninstall" nsExec::Exec '"$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "$INSTDIR\scripts\uninstall-printer.ps1"' Pop $0 + nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner driver' + Pop $0 nsExec::Exec 'taskkill /F /IM verae-printhelper.exe' Pop $0 - Delete "$INSTDIR\bin\verae-print.exe" - Delete "$INSTDIR\bin\verae-printhelper.exe" - RMDir "$INSTDIR\bin" - Delete "$INSTDIR\scripts\*.ps1" - RMDir "$INSTDIR\scripts" - Delete "$INSTDIR\README-WINDOWS.txt" - Delete "$INSTDIR\LICENSE.txt" - Delete "$INSTDIR\Uninstall.exe" - RMDir "$INSTDIR" - Delete "$SMPROGRAMS\Verae\*.lnk" - RMDir "$SMPROGRAMS\Verae" + ; The standalone network-printer setup shares these binaries. Leave them + ; when that product is still installed. + ClearErrors + ReadRegDWORD $R1 HKLM "Software\Verae\NetPrint" "Standalone" + ${If} ${Errors} + StrCpy $R1 0 + ${EndIf} + ClearErrors + ${If} $R1 != 1 + nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' + Pop $0 + Delete "$INSTDIR\bin\verae-print.exe" + Delete "$INSTDIR\bin\verae-printhelper.exe" + Delete "$INSTDIR\bin\verae-netprint-tray.exe" + RMDir "$INSTDIR\bin" + Delete "$INSTDIR\scripts\*.ps1" + RMDir "$INSTDIR\scripts" + Delete "$INSTDIR\README-WINDOWS.txt" + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\Uninstall.exe" + RMDir "$INSTDIR" + Delete "$SMPROGRAMS\Verae\*.lnk" + RMDir "$SMPROGRAMS\Verae" + DeleteRegKey HKLM "Software\Verae\PrintStamp" + ${Else} + Delete "$INSTDIR\Uninstall.exe" + ${EndIf} DeleteRegKey HKLM "${UNINST_KEY}" - DeleteRegKey HKLM "Software\Verae\PrintStamp" ; logs and the user's .verae\xverae-token are kept on purpose SectionEnd diff --git a/scripts/build-netprint-installers.sh b/scripts/build-netprint-installers.sh new file mode 100755 index 0000000..c842974 --- /dev/null +++ b/scripts/build-netprint-installers.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Build the network-printer installers that this environment can produce: +# Debian packages (driver + standalone), Windows NSIS setups, and macOS +# binaries plus a .pkg when pkgbuild exists. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +VERSION="${VERSION:-0.3.4}" +OUT="${OUT:-$ROOT/build/netprint}" +mkdir -p "$OUT" + +echo "→ test" +go test ./internal/netprint/ ./internal/auth/ ./cmd/verae-print/ + +build_unix() { + local goos="$1" goarch="$2" name="$3" + local dir="$OUT/$name" + mkdir -p "$dir" + echo "→ $goos/$goarch verae-print" + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "-s -w" -o "$dir/verae-print" ./cmd/verae-print +} + +build_unix linux amd64 linux-amd64 +build_unix linux arm64 linux-arm64 +build_unix darwin arm64 darwin-arm64 +build_unix darwin amd64 darwin-amd64 + +echo "→ windows binaries" +mkdir -p "$OUT/windows-amd64" "$OUT/windows-arm64" +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o "$OUT/windows-amd64/verae-print.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-amd64/verae-printhelper.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-amd64/verae-netprint-tray.exe" ./cmd/verae-netprint-tray +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o "$OUT/windows-arm64/verae-print.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-arm64/verae-printhelper.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-arm64/verae-netprint-tray.exe" ./cmd/verae-netprint-tray + +echo "→ debian packages" +chmod +x platform/linux/deb/build-deb.sh platform/linux/deb/build-netprint-deb.sh +NET="$OUT" +VERSION="$VERSION" OUT="$ROOT/build/deb" BIN_AMD64="$NET/linux-amd64/verae-print" BIN_ARM64="$NET/linux-arm64/verae-print" \ + ./platform/linux/deb/build-deb.sh all +cp -f "$ROOT/build/deb"/verae-print_*"$VERSION"*.deb "$NET"/ +cp -f "$ROOT/build/deb"/verae-print_amd64.deb "$ROOT/build/deb"/verae-print_arm64.deb "$NET"/ +VERSION="$VERSION" OUT="$NET" BIN="$NET/linux-amd64/verae-print" ./platform/linux/deb/build-netprint-deb.sh amd64 +VERSION="$VERSION" OUT="$NET" BIN="$NET/linux-arm64/verae-print" ./platform/linux/deb/build-netprint-deb.sh arm64 + +echo "→ windows installers" +if ! command -v makensis >/dev/null 2>&1; then + echo "makensis missing" >&2 + exit 1 +fi +build_win() { + local arch="$1" + local stage="$OUT/win-stage-$arch" + rm -rf "$stage" + mkdir -p "$stage/scripts" + cp "$OUT/windows-$arch/verae-print.exe" "$OUT/windows-$arch/verae-printhelper.exe" "$OUT/windows-$arch/verae-netprint-tray.exe" "$stage/" + cp platform/windows/scripts/*.ps1 "$stage/scripts/" + cp platform/windows/LICENSE.txt platform/windows/README-WINDOWS.txt "$stage/" + ( + cd "$OUT" + makensis -V2 -DVERSION="$VERSION" -DARCH="$arch" -DSRCDIR="$stage" -DOUTDIR="$OUT" \ + "$ROOT/platform/windows/installer-windows.nsi" + makensis -V2 -DVERSION="$VERSION" -DARCH="$arch" -DSRCDIR="$stage" -DOUTDIR="$OUT" \ + "$ROOT/platform/windows/installer-netprint.nsi" + ) +} +build_win amd64 +build_win arm64 + +echo "→ macos payload" +mkdir -p "$OUT/macos" +# Universal binary when lipo exists; otherwise ship both slices. +if command -v lipo >/dev/null 2>&1; then + lipo -create -output "$OUT/macos/verae-print" "$OUT/darwin-arm64/verae-print" "$OUT/darwin-amd64/verae-print" +else + cp -f "$OUT/darwin-arm64/verae-print" "$OUT/macos/verae-print-arm64" + cp -f "$OUT/darwin-amd64/verae-print" "$OUT/macos/verae-print-amd64" + cp -f "$OUT/darwin-arm64/verae-print" "$OUT/macos/verae-print" +fi +chmod +x platform/macos/pkg/build-netprint-pkg.sh +BIN="$OUT/macos/verae-print" OUT="$OUT/macos" VERSION="$VERSION" ./platform/macos/pkg/build-netprint-pkg.sh +cp -f platform/macos/pkg/distribution.xml "$OUT/macos/distribution.xml" +cp -f platform/macos/netprint/scripts/postinstall "$OUT/macos/postinstall-netprint.sh" + +( + cd "$OUT" + sha256sum *.deb *.exe "$OUT/macos"/* 2>/dev/null | tee SHA256SUMS || true +) +echo +echo "Artifacts in $OUT" +find "$OUT" -maxdepth 2 -type f \( -name '*.deb' -o -name '*.exe' -o -name '*.pkg' -o -name '*.tar.gz' -o -name 'verae-print*' \) -printf '%s %p\n' | sort diff --git a/site/printit.verae.ai/index.html b/site/printit.verae.ai/index.html index 8c4e14f..4ba4b1d 100644 --- a/site/printit.verae.ai/index.html +++ b/site/printit.verae.ai/index.html @@ -190,6 +190,20 @@ +
+
+

Network printer

+

Optional LAN queue “Verae Timestamp” (IPP Everywhere) plus a tray or menu-bar control. Standalone installers, or the off-by-default option inside the driver installers. One service either way. Version 0.3.4.

+
+ +
+

Linux (tarball)

@@ -212,7 +226,8 @@
  1. macOS: open the .pkg → first launch saves your signing key → Print → Destination → Verae Timestamping.
  2. Windows: run the setup .exe → Finish with “Run setup” → paste your xVerae- key → verae-print stamp file.pdf.
  3. -
  4. Debian/Ubuntu: sudo apt install ./verae-print_amd64.deb, then print to “Verae Timestamping” and sign in when the window appears (or open “Verae Signing Key” from your apps menu).
  5. +
  6. Debian/Ubuntu: sudo apt install ./verae-print_amd64.deb, then print to “Verae Timestamping” and sign in when the window appears (or open “Verae Signing Key” from your apps menu). Leave the network-printer question at “no” unless you want the LAN queue.
  7. +
  8. Network printer: install verae-netprint, or accept that prompt / the Windows checkbox / the macOS Customize choice. Other devices print to “Verae Timestamp”. Receipts are on this computer; the menu-bar or tray icon opens them.
  9. Outputs: Documents/Verae Timestamps/.
From 2f7f7aa87301c33fc7bb63d38f929218c4717c96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 19:51:03 +0000 Subject: [PATCH 10/27] Keep the network printer off the driver helper port and install folder. Use 8632 when 631 is taken, install standalone Windows and macOS builds beside the driver instead of over it, open the LAN firewall ports, and give silent NSIS dialogs a default plus an install log. Co-authored-by: George Lambert --- README.md | 2 +- docs/network-printer.md | 23 ++- internal/netprint/config.go | 22 ++- internal/netprint/server.go | 26 ++- internal/netprint/service_darwin.go | 82 ++++++++- internal/netprint/service_linux.go | 92 ++++++++-- internal/netprint/service_windows.go | 117 +++++++++++-- internal/netprint/version.go | 161 ++++++++++++++++++ internal/netprint/version_test.go | 66 +++++++ platform/linux/deb/build-deb.sh | 26 ++- platform/linux/deb/build-netprint-deb.sh | 33 +++- .../macos/netprint/com.verae.netprint.plist | 2 +- .../netprint/com.verae.netprint.tray.plist | 2 +- platform/macos/pkg/build-netprint-pkg.sh | 12 +- platform/windows/installer-netprint.nsi | 111 +++++++++--- platform/windows/installer-windows.nsi | 95 +++++++---- site/printit.verae.ai/index.html | 2 +- 17 files changed, 748 insertions(+), 126 deletions(-) create mode 100644 internal/netprint/version.go create mode 100644 internal/netprint/version_test.go diff --git a/README.md b/README.md index ad9faf3..115f227 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ verae-print setup verae-print stamp document.pdf ``` -**Network printer (optional, separate from the driver):** an IPP Everywhere queue named **Verae Timestamp** for other devices on the LAN. Install the standalone package, or leave the driver installer’s option off (Windows checkbox, macOS Customize, Debian debconf). Both use one service and one tray / menu-bar control. See [docs/network-printer.md](docs/network-printer.md). +**Network printer (optional, separate from the driver):** an IPP Everywhere queue named **Verae Timestamp** for other devices on the LAN. Install the standalone package, or leave the driver installer’s option off (Windows checkbox, macOS Customize, Debian debconf). Both use one service and one tray / menu-bar control. The standalone Windows folder is `C:\Program Files\Verae\NetPrint` (the driver stays in `PrintStamp`). The LAN fallback port is **8632**, so it does not collide with the driver helper on `127.0.0.1:8631`. See [docs/network-printer.md](docs/network-printer.md). | OS | Standalone | With the driver (off unless you opt in) | |----|------------|------------------------------------------| diff --git a/docs/network-printer.md b/docs/network-printer.md index 84593e2..ddd3c12 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -2,7 +2,7 @@ Optional IPP Everywhere printer. Phones and other computers on the LAN print to **Verae Timestamp**. This machine hashes the document, registers the SHA-256 with Verae the same way the desktop drivers do, and keeps the receipt. Document bytes are not uploaded. -The printer is off until you install the standalone package or turn on the driver installer option. Both paths install one binary command, one config file, one service, and one tray / menu-bar control. +The printer is off until you install the standalone package or turn on the driver installer option. Both paths install one service and one tray / menu-bar control. They do not share an install folder: the standalone package never overwrites the driver binary. If both are installed, the service runs whichever binary is the same version or newer, and removing one leaves the other in place. | | Windows | macOS | Debian / Ubuntu | |---|---|---|---| @@ -10,17 +10,19 @@ The printer is off until you install the standalone package or turn on the drive | Driver installer option | Unchecked checkbox on the options page | Customize → “Network timestamp printer” (unselected) | debconf “Enable the Verae network timestamp printer?” (default no), or `apt install verae-netprint` | | Service | `VeraeNetPrint` | LaunchDaemon `com.verae.netprint` | `verae-netprint.service` | | Tray / menu bar | `verae-netprint-tray.exe` (notification area) | `verae-print tray` (menu bar) | `verae-print tray` (StatusNotifier / AppIndicator) | -| Command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/bin/verae-print` | +| Standalone command | `%ProgramFiles%\Verae\NetPrint\bin\verae-print.exe` | `/Library/Application Support/Verae/NetPrint/bin/verae-print` | `/usr/lib/verae-netprint/verae-print` | +| Driver command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/lib/verae-print/verae-print` | +| CLI on `PATH` | the driver binary when the driver is installed | `/usr/local/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | `/usr/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | | Config | `%ProgramData%\Verae\PrintStamp\netprint.json` | `/Library/Application Support/Verae/PrintStamp/netprint.json` | `/etc/verae/netprint.json` | | Receipts | `%ProgramData%\Verae\NetPrint` | `/Library/Application Support/Verae/NetPrint` | `/var/lib/verae-netprint` | -Installing the standalone package when the driver option is already installed (or the other way around) keeps the same service name and config. Removing one product leaves the service in place while the other is still installed. +The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network printer does not use that port. ## Try it 1. Install using either path above. The tray or menu-bar icon starts at login only after the print server is installed. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus `_universal` and `_print`) and AirPrint text records. The IPP port is 631, or 8631 when 631 is already taken by CUPS. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus `_universal` and `_print`) and AirPrint text records. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. 4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://:/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, and how many jobs arrived today. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). @@ -29,12 +31,21 @@ Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward ## Firewall -Allow TCP **631** (and **8631** if CUPS already owns 631) from your LAN. Allow UDP **5353** so other devices can discover the printer. The control API listens only on `127.0.0.1:8630`. +The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. They are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback. + +On Debian and Ubuntu, `postinst` opens the same three ports with `ufw` when ufw is installed and already active. The rules are removed when the last package is removed. + +On macOS, the Application Firewall is per application (System Settings → Network → Firewall). Install runs `/usr/libexec/ApplicationFirewall/socketfilterfw --add` and `--unblockapp` on the service binary when that tool is present. If other devices still cannot connect, allow `verae-print` in that pane. + +The control API listens only on `127.0.0.1:8630`. + +A silent Windows install (`/S`) does not wait on a dialog. If the service does not start, the message is written to `%ProgramData%\Verae\logs\netprint-installer.log`. ## Troubleshooting - The icon says stopped. Start it from the menu. On Linux, `systemctl status verae-netprint`. On Windows, `sc query VeraeNetPrint`. On macOS, `sudo launchctl print system/com.verae.netprint`. -- Other devices do not see it. Confirm the service is running, mDNS is not blocked, and the host has a LAN address. CUPS on the same machine is fine: this printer falls back to port 8631 and still advertises that port. +- Other devices do not see it. Confirm the service is running, mDNS (UDP 5353) is not blocked, and the host has a LAN address. CUPS on the same machine is fine: this printer falls back to port 8632 and still advertises that port. It does not fall back to 8631. +- Windows setup seemed to hang with `/S`. Every installer dialog has a silent default. Read `%ProgramData%\Verae\logs\netprint-installer.log`. - Jobs fail with an authentication error. Open Settings and save a current `xVerae-` key. The service reads the machine token (`/etc/verae/xverae-token`, the macOS Application Support file, or `%ProgramData%\Verae\PrintStamp\xverae-token`) and the usual per-user token. - Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) are converted to PDF on this machine before they are hashed. PDF is accepted as-is. diff --git a/internal/netprint/config.go b/internal/netprint/config.go index b81de46..35a52b5 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -12,6 +12,17 @@ import ( "strings" ) +const ( + // DefaultFallbackPort is the LAN IPP port when 631 is already taken. + // It is not 8631: that port belongs to the desktop driver's loopback helper. + DefaultFallbackPort = 8632 + // DriverHelperPort is reserved for the desktop driver's loopback IPP helper + // (127.0.0.1:8631). The network printer never binds it. + DriverHelperPort = 8631 + // DefaultControlPort is the loopback control API. + DefaultControlPort = 8630 +) + // Config is the network printer. The Verae signing key is not stored here; // the process loads it the same way the drivers do (internal/auth). type Config struct { @@ -20,6 +31,7 @@ type Config struct { // Port is the IPP/HTTP port to try first. 631 is the IPP default. Port int `json:"port,omitempty"` // FallbackPort is used when Port is already taken (CUPS on macOS and Debian). + // The default is 8632. 8631 is reserved for the driver helper and is never bound. FallbackPort int `json:"fallbackPort,omitempty"` // Bind is the listen address without a port. Empty listens on all interfaces. Bind string `json:"bind,omitempty"` @@ -50,8 +62,8 @@ func Defaults() Config { return Config{ Name: "Verae Timestamp", Port: 631, - FallbackPort: 8631, - ControlPort: 8630, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, DataDir: defaultDataDir(), KeepPDF: &keep, Advertise: &adv, @@ -191,7 +203,7 @@ func (c *Config) normalize() error { return fmt.Errorf("port must be 1-65535") } if c.ControlPort <= 0 { - c.ControlPort = 8630 + c.ControlPort = DefaultControlPort } if c.ControlPort > 65535 { return fmt.Errorf("controlPort must be 1-65535") @@ -220,8 +232,8 @@ func ExampleJSON(dataDir string) []byte { c := Config{ Name: "Verae Timestamp", Port: 631, - FallbackPort: 8631, - ControlPort: 8630, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, DataDir: dataDir, KeepPDF: &keep, ForwardPrinter: "", diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 233d743..98fa1de 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -190,12 +190,30 @@ func (s *Server) Shutdown(ctx context.Context) error { return err } +// ippListenPorts is the IPP port list for cfg. Port 8631 is the desktop +// driver's loopback helper and is never used, even when a saved config asks for it. +func ippListenPorts(cfg Config) []int { + primary := cfg.Port + fallback := cfg.FallbackPort + if primary == DriverHelperPort { + primary = DefaultFallbackPort + } + if fallback == DriverHelperPort { + fallback = DefaultFallbackPort + } + if primary <= 0 { + primary = 631 + } + out := []int{primary} + if fallback > 0 && fallback != primary { + out = append(out, fallback) + } + return out +} + func listenIPP(cfg Config) (net.Listener, int, error) { bind := cfg.Bind - try := []int{cfg.Port} - if cfg.FallbackPort > 0 && cfg.FallbackPort != cfg.Port { - try = append(try, cfg.FallbackPort) - } + try := ippListenPorts(cfg) var last error for _, port := range try { ln, err := net.Listen("tcp", net.JoinHostPort(bind, fmt.Sprint(port))) diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index 18390a0..6d89ab4 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -4,6 +4,7 @@ package netprint import ( "fmt" + "log" "os" "os/exec" "os/user" @@ -110,7 +111,10 @@ func launchctl(args ...string) error { func mustExe() string { p, err := Executable() if err != nil { - return "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + if _, statErr := os.Stat("/Library/Application Support/Verae/PrintStamp/bin/verae-print"); statErr == nil { + return "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + } + return "/Library/Application Support/Verae/NetPrint/bin/verae-print" } return p } @@ -149,24 +153,22 @@ func Install(owner, tokenFile string) error { return err } } + chosen := selectServiceBinary(exe, currentServiceExe()) if err := os.MkdirAll("/Library/LaunchDaemons", 0o755); err != nil { return err } - if _, err := os.Stat(daemonPlist); os.IsNotExist(err) { - if err := os.WriteFile(daemonPlist, []byte(plistDaemon(exe, cfgPath)), 0o644); err != nil { - return err - } + if err := os.WriteFile(daemonPlist, []byte(plistDaemon(chosen, cfgPath)), 0o644); err != nil { + return err } if err := os.MkdirAll("/Library/LaunchAgents", 0o755); err != nil { return err } - if err := os.WriteFile(agentPlist, []byte(plistAgent(exe)), 0o644); err != nil { + if err := os.WriteFile(agentPlist, []byte(plistAgent(chosen)), 0o644); err != nil { return err } - _ = os.MkdirAll("/usr/local/bin", 0o755) - _ = os.Remove("/usr/local/bin/verae-print") - _ = os.Symlink(exe, "/usr/local/bin/verae-print") + linkCLI(exe) copyToken(tokenFile) + allowAppFirewall(chosen) _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() if err := launchctl("bootstrap", "system", daemonPlist); err != nil { return err @@ -183,6 +185,13 @@ func Uninstall(owner string) error { return err } if !o.empty() { + if other := peerBinary(owner); other != "" { + cfgPath := SystemConfigPath() + _ = os.WriteFile(daemonPlist, []byte(plistDaemon(other, cfgPath)), 0o644) + _ = os.WriteFile(agentPlist, []byte(plistAgent(other)), 0o644) + _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() + _ = launchctl("bootstrap", "system", daemonPlist) + } return nil } _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() @@ -194,5 +203,60 @@ func Uninstall(owner string) error { return nil } +func currentServiceExe() string { + b, err := os.ReadFile(daemonPlist) + if err != nil { + return "" + } + return parseDarwinProgram(string(b)) +} + +func peerBinary(owner string) string { + p := "/Library/Application Support/Verae/NetPrint/bin/verae-print" + if owner == "standalone" { + p = "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// linkCLI keeps /usr/local/bin/verae-print on the driver binary when that +// install is present. A real file at the link path is left alone. +func linkCLI(exe string) { + const link = "/usr/local/bin/verae-print" + const driver = "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + target := exe + if _, err := os.Stat(driver); err == nil { + target = driver + } + _ = os.MkdirAll("/usr/local/bin", 0o755) + st, err := os.Lstat(link) + if err == nil && st.Mode()&os.ModeSymlink == 0 { + return + } + if err == nil { + _ = os.Remove(link) + } + _ = os.Symlink(target, link) +} + +// allowAppFirewall asks the macOS application firewall to accept the service +// binary. System Settings → Network → Firewall still applies if this tool +// is unavailable or the user has blocked the app. +func allowAppFirewall(exe string) { + const fw = "/usr/libexec/ApplicationFirewall/socketfilterfw" + if _, err := os.Stat(fw); err != nil { + return + } + if out, err := exec.Command(fw, "--add", exe).CombinedOutput(); err != nil { + log.Printf("netprint: socketfilterfw --add: %v (%s)", err, strings.TrimSpace(string(out))) + } + if out, err := exec.Command(fw, "--unblockapp", exe).CombinedOutput(); err != nil { + log.Printf("netprint: socketfilterfw --unblockapp: %v (%s)", err, strings.TrimSpace(string(out))) + } +} + // silence unused in case strconv is needed by future uid parsing var _ = strconv.Itoa diff --git a/internal/netprint/service_linux.go b/internal/netprint/service_linux.go index 4e09b5b..ccea0d7 100644 --- a/internal/netprint/service_linux.go +++ b/internal/netprint/service_linux.go @@ -4,6 +4,7 @@ package netprint import ( "fmt" + "log" "os" "os/exec" "path/filepath" @@ -180,13 +181,12 @@ func Install(owner, tokenFile string) error { _ = os.Chown("/etc/verae", uid, uid) _ = os.Chown(cfgPath, uid, uid) } - if _, err := os.Stat(unitPath); err != nil { - if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { - return err - } - if err := os.WriteFile(unitPath, []byte(unitText(exe, cfgPath)), 0o644); err != nil { - return err - } + chosen := selectServiceBinary(exe, currentServiceExe()) + if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(unitPath, []byte(unitText(chosen, cfgPath)), 0o644); err != nil { + return err } if err := os.MkdirAll(filepath.Dir(ctlPath), 0o755); err != nil { return err @@ -200,11 +200,12 @@ func Install(owner, tokenFile string) error { if err := os.MkdirAll(filepath.Dir(trayDesktop), 0o755); err != nil { return err } - if err := os.WriteFile(trayDesktop, []byte(trayDesktopText(exe, true)), 0o644); err != nil { + if err := os.WriteFile(trayDesktop, []byte(trayDesktopText(chosen, true)), 0o644); err != nil { return err } linkBin(exe) copyToken(tokenFile) + ensureFirewall() _ = exec.Command("systemctl", "daemon-reload").Run() if err := systemctlNow("enable", "--now", linuxUnit); err != nil { return err @@ -220,8 +221,16 @@ func Uninstall(owner string) error { return err } if !o.empty() { + if other := peerBinary(owner); other != "" { + cfgPath := SystemConfigPath() + _ = os.WriteFile(unitPath, []byte(unitText(other, cfgPath)), 0o644) + _ = os.WriteFile(trayDesktop, []byte(trayDesktopText(other, true)), 0o644) + _ = exec.Command("systemctl", "daemon-reload").Run() + _ = systemctlNow("restart", linuxUnit) + } return nil } + removeFirewall() _ = systemctlNow("disable", "--now", linuxUnit) _ = os.Remove(unitPath) _ = os.Remove(trayDesktop) @@ -230,13 +239,74 @@ func Uninstall(owner string) error { return nil } +func currentServiceExe() string { + b, err := os.ReadFile(unitPath) + if err != nil { + return "" + } + return parseExecStart(string(b)) +} + +func peerBinary(owner string) string { + p := "/usr/lib/verae-netprint/verae-print" + if owner == "standalone" { + p = "/usr/lib/verae-print/verae-print" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// linkBin points /usr/bin/verae-print at this binary only when the driver +// package is not installed. An installed driver keeps the CLI. A real +// (non-symlink) file is left alone. func linkBin(exe string) { const link = "/usr/bin/verae-print" - if exe == link { + const driver = "/usr/lib/verae-print/verae-print" + target := exe + if _, err := os.Stat(driver); err == nil { + target = driver + } + if target == link { return } - _ = os.Remove(link) - _ = os.Symlink(exe, link) + st, err := os.Lstat(link) + if err == nil && st.Mode()&os.ModeSymlink == 0 { + return + } + if err == nil { + _ = os.Remove(link) + } + _ = os.Symlink(target, link) +} + +func ensureFirewall() { + if _, err := exec.LookPath("ufw"); err != nil { + return + } + out, err := exec.Command("ufw", "status").CombinedOutput() + if err != nil || !strings.Contains(string(out), "Status: active") { + return + } + status := string(out) + for _, rule := range []string{"631/tcp", "8632/tcp", "5353/udp"} { + if strings.Contains(status, rule) { + continue + } + if err := exec.Command("ufw", "--force", "allow", rule, "comment", "Verae NetPrint").Run(); err != nil { + log.Printf("netprint: ufw allow %s: %v", rule, err) + } + } +} + +func removeFirewall() { + if _, err := exec.LookPath("ufw"); err != nil { + return + } + for _, rule := range []string{"631/tcp", "8632/tcp", "5353/udp"} { + _ = exec.Command("ufw", "--force", "delete", "allow", rule).Run() + } } func ensureServiceUser() error { diff --git a/internal/netprint/service_windows.go b/internal/netprint/service_windows.go index eb45a76..22d22b8 100644 --- a/internal/netprint/service_windows.go +++ b/internal/netprint/service_windows.go @@ -4,6 +4,7 @@ package netprint import ( "fmt" + "log" "os" "os/exec" "path/filepath" @@ -42,11 +43,8 @@ func (osControl) TrayAtLogin() (bool, error) { return err == nil && strings.Contains(string(out), "VeraeNetPrintTray"), nil } func (osControl) SetTrayAtLogin(on bool) error { - if !on { - return exec.Command("reg.exe", "delete", runKey, "/v", "VeraeNetPrintTray", "/f").Run() - } - tray := trayExe() - return exec.Command("reg.exe", "add", runKey, "/v", "VeraeNetPrintTray", "/t", "REG_SZ", "/d", tray, "/f").Run() + exe, _ := Executable() + return setTrayFor(exe, on) } func sc(args ...string) error { @@ -63,16 +61,85 @@ func delayed(args ...string) error { return cmd.Start() } -func trayExe() string { - exe, err := Executable() - if err != nil { +func trayNextTo(serviceExe string) string { + serviceExe = strings.Trim(serviceExe, `"`) + if serviceExe == "" { return `verae-print.exe tray` } - sibling := filepath.Join(filepath.Dir(exe), "verae-netprint-tray.exe") + sibling := filepath.Join(filepath.Dir(serviceExe), "verae-netprint-tray.exe") if _, err := os.Stat(sibling); err == nil { return `"` + sibling + `"` } - return `"` + exe + `" tray` + return `"` + serviceExe + `" tray` +} + +func setTrayFor(serviceExe string, on bool) error { + if !on { + return exec.Command("reg.exe", "delete", runKey, "/v", "VeraeNetPrintTray", "/f").Run() + } + return exec.Command("reg.exe", "add", runKey, "/v", "VeraeNetPrintTray", "/t", "REG_SZ", "/d", trayNextTo(serviceExe), "/f").Run() +} + +func serviceBinPath(exe string) string { + return fmt.Sprintf(`"%s" netprint`, exe) +} + +func currentServiceExe() string { + out, err := exec.Command("sc.exe", "qc", winService).CombinedOutput() + if err != nil { + return "" + } + return parseWindowsBinPath(string(out)) +} + +func peerBinary(owner string) string { + rel := filepath.Join("Verae", "NetPrint", "bin", "verae-print.exe") + if owner == "standalone" { + rel = filepath.Join("Verae", "PrintStamp", "bin", "verae-print.exe") + } + var roots []string + if p := os.Getenv("ProgramW6432"); p != "" { + roots = append(roots, p) + } + if p := os.Getenv("ProgramFiles"); p != "" { + roots = append(roots, p) + } + roots = append(roots, `C:\Program Files`) + seen := map[string]bool{} + for _, root := range roots { + if seen[root] { + continue + } + seen[root] = true + p := filepath.Join(root, rel) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func ensureFirewall() { + removeFirewall() + rules := []struct{ name, proto, port string }{ + {"Verae NetPrint IPP", "TCP", "631"}, + {"Verae NetPrint IPP Fallback", "TCP", "8632"}, + {"Verae NetPrint mDNS", "UDP", "5353"}, + } + for _, r := range rules { + out, err := exec.Command("netsh", "advfirewall", "firewall", "add", "rule", + "name="+r.name, "dir=in", "action=allow", "protocol="+r.proto, + "localport="+r.port, "profile=private,domain").CombinedOutput() + if err != nil { + log.Printf("netprint: firewall %s: %v (%s)", r.name, err, strings.TrimSpace(string(out))) + } + } +} + +func removeFirewall() { + for _, name := range []string{"Verae NetPrint IPP", "Verae NetPrint IPP Fallback", "Verae NetPrint mDNS"} { + _ = exec.Command("netsh", "advfirewall", "firewall", "delete", "rule", "name="+name).Run() + } } func setOwnerReg(name string, on bool) { @@ -112,18 +179,27 @@ func Install(owner, tokenFile string) error { return err } } - bin := fmt.Sprintf(`"%s" netprint`, exe) - _ = exec.Command("sc.exe", "stop", winService).Run() - _ = exec.Command("sc.exe", "delete", winService).Run() - if err := sc("create", winService, "binPath=", bin, "start=", "auto", "DisplayName=", "Verae Network Printer"); err != nil { - return err + current := currentServiceExe() + chosen := selectServiceBinary(exe, current) + bin := serviceBinPath(chosen) + if current == "" { + if err := sc("create", winService, "binPath=", bin, "start=", "auto", "DisplayName=", "Verae Network Printer"); err != nil { + return err + } + } else if chosen != current { + _ = exec.Command("sc.exe", "stop", winService).Run() + if err := sc("config", winService, "binPath=", bin); err != nil { + return err + } } _ = exec.Command("sc.exe", "description", winService, "Verae network timestamp printer").Run() + _ = exec.Command("sc.exe", "config", winService, "start=", "auto").Run() copyToken(tokenFile) + ensureFirewall() if err := sc("start", winService); err != nil { return err } - return osControl{}.SetTrayAtLogin(true) + return setTrayFor(chosen, true) } // Uninstall removes the service only when neither installer still owns it. @@ -138,10 +214,17 @@ func Uninstall(owner string) error { setOwnerReg("Standalone", false) } if !o.empty() || ownerReg("Driver") || ownerReg("Standalone") { + if other := peerBinary(owner); other != "" { + _ = exec.Command("sc.exe", "stop", winService).Run() + _ = sc("config", winService, "binPath=", serviceBinPath(other)) + _ = sc("start", winService) + _ = setTrayFor(other, true) + } return nil } _ = exec.Command("sc.exe", "stop", winService).Run() _ = exec.Command("sc.exe", "delete", winService).Run() - _ = osControl{}.SetTrayAtLogin(false) + _ = setTrayFor("", false) + removeFirewall() return nil } diff --git a/internal/netprint/version.go b/internal/netprint/version.go new file mode 100644 index 0000000..0a2e1b1 --- /dev/null +++ b/internal/netprint/version.go @@ -0,0 +1,161 @@ +package netprint + +import ( + "os/exec" + "strconv" + "strings" +) + +// compareVersions compares dotted numeric versions. +// It returns -1 when a is older than b, 0 when they are equal, and 1 when a is newer. +func compareVersions(a, b string) int { + ap := splitVer(a) + bp := splitVer(b) + n := len(ap) + if len(bp) > n { + n = len(bp) + } + for i := 0; i < n; i++ { + av, bv := 0, 0 + if i < len(ap) { + av = ap[i] + } + if i < len(bp) { + bv = bp[i] + } + if av < bv { + return -1 + } + if av > bv { + return 1 + } + } + return 0 +} + +func splitVer(s string) []int { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + if i := strings.IndexAny(s, " \t\r\n"); i >= 0 { + s = s[:i] + } + if s == "" { + return nil + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + digits := strings.TrimLeftFunc(p, func(r rune) bool { return r < '0' || r > '9' }) + end := 0 + for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' { + end++ + } + if end == 0 { + break + } + n, _ = strconv.Atoi(digits[:end]) + } + out = append(out, n) + } + return out +} + +// versionOf runs `exe version` and returns the first line. +func versionOf(exe string) string { + exe = strings.TrimSpace(exe) + if exe == "" { + return "" + } + out, err := exec.Command(exe, "version").Output() + if err != nil { + return "" + } + line := strings.TrimSpace(string(out)) + if i := strings.IndexAny(line, "\r\n"); i >= 0 { + line = strings.TrimSpace(line[:i]) + } + return line +} + +// selectServiceBinary chooses which installed binary the shared service should run. +// ours is used when current is missing or ours is the same version or newer. +// A newer current binary is kept so an older installer cannot downgrade the service +// or replace the other product's files. +func selectServiceBinary(ours, current string) string { + return selectServiceBinaryVersions(ours, current, versionOf(ours), versionOf(current)) +} + +func selectServiceBinaryVersions(ours, current, oursVer, curVer string) string { + ours = strings.TrimSpace(ours) + current = strings.TrimSpace(current) + if current == "" { + return ours + } + if ours == "" { + return current + } + if compareVersions(oursVer, curVer) < 0 { + return current + } + return ours +} + +func parseWindowsBinPath(qc string) string { + for _, line := range strings.Split(qc, "\n") { + if !strings.Contains(strings.ToUpper(line), "BINARY_PATH_NAME") { + continue + } + i := strings.Index(line, ":") + if i < 0 { + continue + } + return firstExecutable(strings.TrimSpace(line[i+1:])) + } + return "" +} + +func parseExecStart(unit string) string { + for _, line := range strings.Split(unit, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "ExecStart=") { + continue + } + return firstExecutable(strings.TrimSpace(strings.TrimPrefix(line, "ExecStart="))) + } + return "" +} + +func parseDarwinProgram(plist string) string { + i := strings.Index(plist, "ProgramArguments") + if i < 0 { + return "" + } + rest := plist[i:] + s := strings.Index(rest, "") + e := strings.Index(rest, "") + if s < 0 || e < 0 || e <= s+len("") { + return "" + } + return strings.TrimSpace(rest[s+len("") : e]) +} + +func firstExecutable(rest string) string { + rest = strings.TrimSpace(rest) + if rest == "" { + return "" + } + if strings.HasPrefix(rest, `"`) { + rest = rest[1:] + if j := strings.Index(rest, `"`); j >= 0 { + return rest[:j] + } + return rest + } + fields := strings.Fields(rest) + if len(fields) == 0 { + return "" + } + return fields[0] +} diff --git a/internal/netprint/version_test.go b/internal/netprint/version_test.go new file mode 100644 index 0000000..ecf497f --- /dev/null +++ b/internal/netprint/version_test.go @@ -0,0 +1,66 @@ +package netprint + +import ( + "strings" + "testing" +) + +func TestFallbackPortAvoidsDriverHelper(t *testing.T) { + d := Defaults() + if d.FallbackPort != DefaultFallbackPort || d.FallbackPort == DriverHelperPort { + t.Fatalf("fallback port %d, want %d and not the driver helper %d", d.FallbackPort, DefaultFallbackPort, DriverHelperPort) + } + if d.Port == DriverHelperPort { + t.Fatal("primary port collides with the driver helper") + } + sample := string(ExampleJSON(d.DataDir)) + if !strings.Contains(sample, "8632") || strings.Contains(sample, "8631") { + t.Fatalf("example config should advertise 8632 and not 8631:\n%s", sample) + } + ports := ippListenPorts(Config{Port: 631, FallbackPort: DriverHelperPort}) + for _, p := range ports { + if p == DriverHelperPort { + t.Fatalf("listen ports include driver helper: %v", ports) + } + } + if len(ports) != 2 || ports[0] != 631 || ports[1] != DefaultFallbackPort { + t.Fatalf("631 with a saved 8631 fallback should try 631 then %d, got %v", DefaultFallbackPort, ports) + } + only := ippListenPorts(Config{Port: DriverHelperPort, FallbackPort: DriverHelperPort}) + if len(only) != 1 || only[0] != DefaultFallbackPort { + t.Fatalf("both ports set to the helper should bind %d, got %v", DefaultFallbackPort, only) + } +} + +func TestSelectServiceBinary(t *testing.T) { + if compareVersions("0.3.4", "0.3.3") <= 0 || compareVersions("0.3.4", "0.3.4") != 0 || compareVersions("0.3.4", "0.4") >= 0 { + t.Fatal("compareVersions") + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, `/old/verae-print.exe`, "0.3.4", "0.3.3"); got != `/new/verae-print.exe` { + t.Fatal(got) + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, `/old/verae-print.exe`, "0.3.3", "0.3.4"); got != `/old/verae-print.exe` { + t.Fatal(got) + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, `/old/verae-print.exe`, "0.3.4", "0.3.4"); got != `/new/verae-print.exe` { + t.Fatal(got) + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, "", "0.3.4", ""); got != `/new/verae-print.exe` { + t.Fatal(got) + } +} + +func TestParseServiceExecutable(t *testing.T) { + qc := "BINARY_PATH_NAME : \"C:\\Program Files\\Verae\\NetPrint\\bin\\verae-print.exe\" netprint\r\n" + if got := parseWindowsBinPath(qc); got != `C:\Program Files\Verae\NetPrint\bin\verae-print.exe` { + t.Fatal(got) + } + unit := "ExecStart=/usr/lib/verae-netprint/verae-print netprint --config /etc/verae/netprint.json\n" + if got := parseExecStart(unit); got != "/usr/lib/verae-netprint/verae-print" { + t.Fatal(got) + } + plist := "ProgramArguments/Library/Application Support/Verae/NetPrint/bin/verae-printnetprint" + if got := parseDarwinProgram(plist); got != "/Library/Application Support/Verae/NetPrint/bin/verae-print" { + t.Fatal(got) + } +} diff --git a/platform/linux/deb/build-deb.sh b/platform/linux/deb/build-deb.sh index cb66a39..bb2224c 100755 --- a/platform/linux/deb/build-deb.sh +++ b/platform/linux/deb/build-deb.sh @@ -40,8 +40,8 @@ build_arch() { "$stage/usr/share/man/man1" \ "$stage/usr/share/applications" - # The command is /usr/bin/verae-print, a symlink created in postinst, so the - # standalone verae-netprint package can share that path without dpkg conflicts. + # The command is /usr/bin/verae-print, a symlink created in postinst. + # The standalone package uses /usr/lib/verae-netprint and does not replace this file. install -m 755 "$bin" "$stage/usr/lib/verae-print/verae-print" if [[ -f "$ROOT/platform/linux/cups/backend-verae.sh" ]]; then install -m 700 "$ROOT/platform/linux/cups/backend-verae.sh" "$stage/usr/lib/cups/backend/verae" @@ -127,8 +127,10 @@ Description: Digests-only Verae timestamping from Print / CLI The same package can enable the Verae network timestamp printer (IPP Everywhere on the LAN). That choice is off unless you accept the debconf prompt, or you install the verae-netprint package. - Both use /usr/bin/verae-print, /etc/verae/netprint.json, and - systemd unit verae-netprint.service. + Both use /etc/verae/netprint.json and systemd unit verae-netprint.service. + The driver binary stays in /usr/lib/verae-print. The standalone package + uses /usr/lib/verae-netprint and does not replace this binary. + /usr/bin/verae-print points at the driver while this package is installed. EOF cat >"$stage/DEBIAN/templates" <<'EOF' @@ -173,6 +175,11 @@ if [ -e /usr/share/debconf/confmodule ]; then enable=${RET:-false} fi if [ "$enable" = "true" ]; then + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw status | grep -q "631/tcp" || ufw allow 631/tcp comment "Verae NetPrint" || true + ufw status | grep -q "8632/tcp" || ufw allow 8632/tcp comment "Verae NetPrint" || true + ufw status | grep -q "5353/udp" || ufw allow 5353/udp comment "Verae NetPrint" || true + fi /usr/lib/verae-print/verae-print netprint install --owner driver || true fi echo "verae-print installed. Next: verae-print setup" @@ -201,10 +208,17 @@ EOF set -e if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then if [ -x /usr/lib/verae-netprint/verae-print ]; then - ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print - else + if [ -L /usr/bin/verae-print ] || [ ! -e /usr/bin/verae-print ]; then + ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print + fi + elif [ -L /usr/bin/verae-print ]; then rm -f /usr/bin/verae-print fi + if [ ! -x /usr/lib/verae-netprint/verae-print ] && command -v ufw >/dev/null 2>&1; then + ufw --force delete allow 631/tcp >/dev/null 2>&1 || true + ufw --force delete allow 8632/tcp >/dev/null 2>&1 || true + ufw --force delete allow 5353/udp >/dev/null 2>&1 || true + fi fi exit 0 EOF diff --git a/platform/linux/deb/build-netprint-deb.sh b/platform/linux/deb/build-netprint-deb.sh index 0773f27..f62fd7f 100755 --- a/platform/linux/deb/build-netprint-deb.sh +++ b/platform/linux/deb/build-netprint-deb.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Build the standalone verae-netprint Debian package. -# It shares /usr/bin/verae-print (a symlink), /etc/verae/netprint.json, and -# systemd unit verae-netprint.service with the driver package's opt-in. +# The binary lives in /usr/lib/verae-netprint and does not replace +# /usr/lib/verae-print. /usr/bin/verae-print stays on the driver when that +# package is installed. Both share /etc/verae/netprint.json and +# systemd unit verae-netprint.service. set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" @@ -59,16 +61,28 @@ Description: Verae network timestamp printer (IPP Everywhere) on this computer. Installs systemd unit verae-netprint, a tray icon at login, and the loopback control API on 127.0.0.1:8630. . - Shares /usr/bin/verae-print and the service name with the optional - component of the verae-print driver package, so installing both upgrades - in place instead of running two servers. + Uses the same service name as the optional component of the verae-print + driver package, so installing both keeps one server. The driver keeps + /usr/bin/verae-print when it is installed. This package does not replace + the driver binary. EOF cat >"$stage/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e if [ "$1" = "configure" ]; then - ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print + # Leave the CLI on the driver when that package is installed. Do not + # replace a real (non-symlink) /usr/bin/verae-print. + if [ ! -x /usr/lib/verae-print/verae-print ]; then + if [ -L /usr/bin/verae-print ] || [ ! -e /usr/bin/verae-print ]; then + ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print + fi + fi + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw status | grep -q "631/tcp" || ufw allow 631/tcp comment "Verae NetPrint" || true + ufw status | grep -q "8632/tcp" || ufw allow 8632/tcp comment "Verae NetPrint" || true + ufw status | grep -q "5353/udp" || ufw allow 5353/udp comment "Verae NetPrint" || true + fi /usr/lib/verae-netprint/verae-print netprint install --owner standalone fi exit 0 @@ -91,9 +105,14 @@ set -e if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then if [ -x /usr/lib/verae-print/verae-print ]; then ln -sfn /usr/lib/verae-print/verae-print /usr/bin/verae-print - else + elif [ -L /usr/bin/verae-print ]; then rm -f /usr/bin/verae-print fi + if [ ! -x /usr/lib/verae-print/verae-print ] && command -v ufw >/dev/null 2>&1; then + ufw --force delete allow 631/tcp >/dev/null 2>&1 || true + ufw --force delete allow 8632/tcp >/dev/null 2>&1 || true + ufw --force delete allow 5353/udp >/dev/null 2>&1 || true + fi fi exit 0 EOF diff --git a/platform/macos/netprint/com.verae.netprint.plist b/platform/macos/netprint/com.verae.netprint.plist index c3ef224..89def63 100644 --- a/platform/macos/netprint/com.verae.netprint.plist +++ b/platform/macos/netprint/com.verae.netprint.plist @@ -4,7 +4,7 @@ Labelcom.verae.netprint ProgramArguments - /Library/Application Support/Verae/PrintStamp/bin/verae-print + /Library/Application Support/Verae/NetPrint/bin/verae-print netprint --config /Library/Application Support/Verae/PrintStamp/netprint.json diff --git a/platform/macos/netprint/com.verae.netprint.tray.plist b/platform/macos/netprint/com.verae.netprint.tray.plist index fcf84d7..e4c4ccd 100644 --- a/platform/macos/netprint/com.verae.netprint.tray.plist +++ b/platform/macos/netprint/com.verae.netprint.tray.plist @@ -4,7 +4,7 @@ Labelcom.verae.netprint.tray ProgramArguments - /Library/Application Support/Verae/PrintStamp/bin/verae-print + /Library/Application Support/Verae/NetPrint/bin/verae-print tray RunAtLoad diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 70d3902..1c42280 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -19,15 +19,15 @@ fi STAGE="$OUT/payload" SCRIPTS="$OUT/scripts" rm -rf "$STAGE" "$SCRIPTS" -mkdir -p "$STAGE/Library/Application Support/Verae/PrintStamp/bin" "$SCRIPTS" -cp -f "$BIN" "$STAGE/Library/Application Support/Verae/PrintStamp/bin/verae-print" -chmod 755 "$STAGE/Library/Application Support/Verae/PrintStamp/bin/verae-print" -cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/PrintStamp/" -cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/PrintStamp/" +mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" "$SCRIPTS" +cp -f "$BIN" "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" +chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" cat >"$SCRIPTS/postinstall" <<'EOF' #!/bin/bash export VERAE_NETPRINT_OWNER=standalone -exec "/Library/Application Support/Verae/PrintStamp/bin/verae-print" netprint install --owner standalone +exec "/Library/Application Support/Verae/NetPrint/bin/verae-print" netprint install --owner standalone EOF chmod 755 "$SCRIPTS/postinstall" diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi index 9e3b6d4..5992e02 100644 --- a/platform/windows/installer-netprint.nsi +++ b/platform/windows/installer-netprint.nsi @@ -1,7 +1,9 @@ ; Verae Network Printer — standalone Windows installer (NSIS). -; Installs the same binaries, service name (VeraeNetPrint), and config as the -; optional checkbox in the driver installer. Installing one when the other is -; present upgrades in place. +; Installs into its own folder (C:\Program Files\Verae\NetPrint) so it does not +; overwrite the driver's C:\Program Files\Verae\PrintStamp\bin\verae-print.exe. +; The service name (VeraeNetPrint) and %ProgramData% config are shared. Installing +; one product when the other is present points the service at the same or newer +; binary and leaves the other product's files in place. !ifndef VERSION !define VERSION "0.3.4" !endif @@ -20,7 +22,7 @@ Name "${PRODUCT_NAME} ${VERSION}" OutFile "${OUTDIR}\VeraeNetPrint-windows-${ARCH}-${VERSION}-setup.exe" -InstallDir "$PROGRAMFILES64\Verae\PrintStamp" +InstallDir "$PROGRAMFILES64\Verae\NetPrint" RequestExecutionLevel admin Unicode true SetCompressor /SOLID lzma @@ -40,15 +42,69 @@ ShowInstDetails show !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_LANGUAGE "English" +Var LogFile +Var LogDir + Function .onInit ${IfNot} ${RunningX64} - MessageBox MB_ICONSTOP "This build targets 64-bit Windows." + MessageBox MB_ICONSTOP "This build targets 64-bit Windows." /SD IDOK Abort ${EndIf} SetRegView 64 SetShellVarContext all + Call SetupLogPath FunctionEnd +Function un.onInit + SetRegView 64 + SetShellVarContext all + Call un.SetupLogPath +FunctionEnd + +Function SetupLogPath + ReadEnvStr $LogDir "ProgramData" + ${If} $LogDir == "" + StrCpy $LogDir "C:\ProgramData" + ${EndIf} + StrCpy $LogDir "$LogDir\Verae\logs" + StrCpy $LogFile "$LogDir\netprint-installer.log" +FunctionEnd + +Function un.SetupLogPath + ReadEnvStr $LogDir "ProgramData" + ${If} $LogDir == "" + StrCpy $LogDir "C:\ProgramData" + ${EndIf} + StrCpy $LogDir "$LogDir\Verae\logs" + StrCpy $LogFile "$LogDir\netprint-installer.log" +FunctionEnd + +; Inbound TCP 631 (IPP), TCP 8632 (fallback; 8631 is the driver helper), UDP 5353 (mDNS). +; Private and domain profiles only. A macro so the uninstaller can run the same commands. +!macro NETPRINT_FIREWALL_ADD + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=private,domain' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=private,domain' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=private,domain' + Pop $0 +!macroend + +!macro NETPRINT_FIREWALL_REMOVE + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 +!macroend + Section "Verae Network Printer" SecMain SectionIn RO SetOutPath "$INSTDIR\bin" @@ -63,16 +119,35 @@ Section "Verae Network Printer" SecMain WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "Verae" CreateDirectory "$SMPROGRAMS\Verae" CreateShortCut "$SMPROGRAMS\Verae\Verae Network Printer.lnk" "$INSTDIR\bin\verae-netprint-tray.exe" - nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint install --owner standalone' + !insertmacro NETPRINT_FIREWALL_ADD + CreateDirectory "$LogDir" + nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint install --owner standalone' Pop $0 + Pop $1 + ClearErrors + FileOpen $2 "$LogFile" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "netprint install exit $0$\r$\n$1$\r$\n" + FileClose $2 + ${EndIf} ${If} $0 != 0 - MessageBox MB_ICONEXCLAMATION "The service did not start (exit $0). You can start it from the tray icon after signing in." + MessageBox MB_ICONEXCLAMATION "The service did not start (exit $0).$\r$\nDetails were written to $LogFile$\r$\nYou can start it from the tray icon after signing in." /SD IDOK ${EndIf} SectionEnd Section "Uninstall" - nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner standalone' + nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner standalone' Pop $0 + Pop $1 + CreateDirectory "$LogDir" + ClearErrors + FileOpen $2 "$LogFile" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "netprint uninstall exit $0$\r$\n$1$\r$\n" + FileClose $2 + ${EndIf} ClearErrors ReadRegDWORD $R1 HKLM "Software\Verae\NetPrint" "Driver" ${If} ${Errors} @@ -82,17 +157,15 @@ Section "Uninstall" ${If} $R1 != 1 nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' Pop $0 - Delete "$INSTDIR\bin\verae-print.exe" - Delete "$INSTDIR\bin\verae-netprint-tray.exe" - RMDir "$INSTDIR\bin" - Delete "$INSTDIR\LICENSE.txt" - Delete "$INSTDIR\Uninstall-NetPrint.exe" - RMDir "$INSTDIR" - Delete "$SMPROGRAMS\Verae\Verae Network Printer.lnk" - RMDir "$SMPROGRAMS\Verae" - ${Else} - Delete "$INSTDIR\Uninstall-NetPrint.exe" - Delete "$SMPROGRAMS\Verae\Verae Network Printer.lnk" + !insertmacro NETPRINT_FIREWALL_REMOVE ${EndIf} + Delete "$INSTDIR\bin\verae-print.exe" + Delete "$INSTDIR\bin\verae-netprint-tray.exe" + RMDir "$INSTDIR\bin" + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\Uninstall-NetPrint.exe" + RMDir "$INSTDIR" + Delete "$SMPROGRAMS\Verae\Verae Network Printer.lnk" + RMDir "$SMPROGRAMS\Verae" DeleteRegKey HKLM "${UNINST_KEY}" SectionEnd diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index eba8ec4..319f26f 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -143,7 +143,7 @@ FunctionEnd Function .onInit ${IfNot} ${RunningX64} - MessageBox MB_ICONSTOP "This build targets 64-bit Windows." + MessageBox MB_ICONSTOP "This build targets 64-bit Windows." /SD IDOK Abort ${EndIf} SetRegView 64 @@ -252,7 +252,7 @@ Function KeyPageLeave StrCpy $KeyChoice "paste" ${Else} StrCpy $KeyChoice "skip" - MessageBox MB_YESNO|MB_ICONEXCLAMATION "Continue without a Verae key?$\r$\n$\r$\nThe printer will be installed, but documents cannot be timestamped until you add a key. On your first print you will be asked for one (or use Start > Verae > Setup signing key)." IDYES +2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION "Continue without a Verae key?$\r$\n$\r$\nThe printer will be installed, but documents cannot be timestamped until you add a key. On your first print you will be asked for one (or use Start > Verae > Setup signing key)." /SD IDYES IDYES +2 Abort ${EndIf} StrCpy $TokenReady 0 @@ -301,7 +301,7 @@ Function LoginPageLeave ${NSD_GetText} $hTotp $3 ${If} $1 == "" ${OrIf} $2 == "" - MessageBox MB_ICONEXCLAMATION "Please enter your email and password." + MessageBox MB_ICONEXCLAMATION "Please enter your email and password." /SD IDOK Abort ${EndIf} !insertmacro REQ_OPEN "$PLUGINSDIR\req.txt" @@ -331,18 +331,18 @@ Function HandleAccountResult ${EndIf} ${If} $4 == "ok" StrCpy $TokenReady 1 - MessageBox MB_ICONINFORMATION "A new Verae key was created for this PC.$\r$\nIt will be saved for $TargetUser." + MessageBox MB_ICONINFORMATION "A new Verae key was created for this PC.$\r$\nIt will be saved for $TargetUser." /SD IDOK ${ElseIf} $4 == "code_sent" ; handled by caller ${ElseIf} $4 == "totp_required" - MessageBox MB_ICONINFORMATION "$5" + MessageBox MB_ICONINFORMATION "$5" /SD IDOK ${NSD_SetFocus} $hTotp Abort ${Else} ${If} $4 == "" StrCpy $5 "No response from the helper (exit $0)." ${EndIf} - MessageBox MB_ICONEXCLAMATION "Verae said: $5" + MessageBox MB_ICONEXCLAMATION "Verae said: $5" /SD IDOK Abort ${EndIf} FunctionEnd @@ -393,7 +393,7 @@ Function CreatePageLeave ${NSD_GetText} $hPass $3 ${NSD_GetText} $hConfirm $6 ${If} $3 != $6 - MessageBox MB_ICONEXCLAMATION "The passwords do not match." + MessageBox MB_ICONEXCLAMATION "The passwords do not match." /SD IDOK Abort ${EndIf} !insertmacro REQ_OPEN "$PLUGINSDIR\req.txt" @@ -449,7 +449,7 @@ Function ResendCode Pop $0 ReadINIStr $5 "$PLUGINSDIR\acct.ini" account detail Delete "$PLUGINSDIR\acct.ini" - MessageBox MB_ICONINFORMATION "$5" + MessageBox MB_ICONINFORMATION "$5" /SD IDOK FunctionEnd Function VerifyPageLeave @@ -490,7 +490,7 @@ FunctionEnd Function PastePageLeave ${NSD_GetText} $hKey $1 ${If} $1 == "" - MessageBox MB_ICONEXCLAMATION "Please paste your key (or go Back and choose Skip)." + MessageBox MB_ICONEXCLAMATION "Please paste your key (or go Back and choose Skip)." /SD IDOK Abort ${EndIf} FileOpen $9 "$PLUGINSDIR\newtoken.txt" w @@ -513,14 +513,14 @@ Function PastePageLeave StrCpy $TokenReady 1 ${ElseIf} $4 == "invalid" Delete "$PLUGINSDIR\newtoken.txt" - MessageBox MB_ICONEXCLAMATION "Verae rejected this key ($5). Please check it and paste again." + MessageBox MB_ICONEXCLAMATION "Verae rejected this key ($5). Please check it and paste again." /SD IDOK Abort ${ElseIf} $4 == "badformat" Delete "$PLUGINSDIR\newtoken.txt" - MessageBox MB_ICONEXCLAMATION "That does not look like a Verae key: $5" + MessageBox MB_ICONEXCLAMATION "That does not look like a Verae key: $5" /SD IDOK Abort ${Else} - MessageBox MB_YESNO|MB_ICONQUESTION "Could not reach Verae to check the key ($5).$\r$\nSave it anyway?" IDYES +3 + MessageBox MB_YESNO|MB_ICONQUESTION "Could not reach Verae to check the key ($5).$\r$\nSave it anyway?" /SD IDYES IDYES +3 Delete "$PLUGINSDIR\newtoken.txt" Abort StrCpy $TokenReady 1 @@ -716,13 +716,35 @@ Section "Verae Print Stamp + printer" SecMain SetOutPath "$INSTDIR\bin" File "${SRCDIR}\verae-netprint-tray.exe" DetailPrint "Installing the Verae network printer (service + tray)" - nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver' + ; TCP 631, TCP 8632 (not the helper's 8631), UDP 5353. Private and domain only. + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=private,domain' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=private,domain' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=private,domain' + Pop $0 + CreateDirectory "$LogDir" + nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver' + Pop $0 + Pop $1 + ClearErrors + FileOpen $2 "$LogDir\netprint-installer.log" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "driver netprint install exit $0$\r$\n$1$\r$\n" + FileClose $2 + ${EndIf} ${If} $0 == 0 !insertmacro STEP_RESULT "Network printer" "OK - Verae Timestamp on the LAN, tray at login" ${Else} - ${LOG} "ERROR network printer install exit $0" - !insertmacro STEP_RESULT "Network printer" "FAIL (exit $0)" + ${LOG} "ERROR network printer install exit $0. See $LogDir\netprint-installer.log" + !insertmacro STEP_RESULT "Network printer" "FAIL (exit $0) - see netprint-installer.log" ${EndIf} ${Else} !insertmacro STEP_RESULT "Network printer" "SKIPPED" @@ -746,8 +768,8 @@ Section "Uninstall" Pop $0 nsExec::Exec 'taskkill /F /IM verae-printhelper.exe' Pop $0 - ; The standalone network-printer setup shares these binaries. Leave them - ; when that product is still installed. + ; Standalone NetPrint lives in its own folder. Always remove this driver's files. + ; Drop the shared firewall rules only when the standalone product is gone. ClearErrors ReadRegDWORD $R1 HKLM "Software\Verae\NetPrint" "Standalone" ${If} ${Errors} @@ -757,22 +779,31 @@ Section "Uninstall" ${If} $R1 != 1 nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' Pop $0 - Delete "$INSTDIR\bin\verae-print.exe" - Delete "$INSTDIR\bin\verae-printhelper.exe" - Delete "$INSTDIR\bin\verae-netprint-tray.exe" - RMDir "$INSTDIR\bin" - Delete "$INSTDIR\scripts\*.ps1" - RMDir "$INSTDIR\scripts" - Delete "$INSTDIR\README-WINDOWS.txt" - Delete "$INSTDIR\LICENSE.txt" - Delete "$INSTDIR\Uninstall.exe" - RMDir "$INSTDIR" - Delete "$SMPROGRAMS\Verae\*.lnk" - RMDir "$SMPROGRAMS\Verae" - DeleteRegKey HKLM "Software\Verae\PrintStamp" - ${Else} - Delete "$INSTDIR\Uninstall.exe" + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 ${EndIf} + Delete "$INSTDIR\bin\verae-print.exe" + Delete "$INSTDIR\bin\verae-printhelper.exe" + Delete "$INSTDIR\bin\verae-netprint-tray.exe" + RMDir "$INSTDIR\bin" + Delete "$INSTDIR\scripts\*.ps1" + RMDir "$INSTDIR\scripts" + Delete "$INSTDIR\README-WINDOWS.txt" + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\Uninstall.exe" + RMDir "$INSTDIR" + Delete "$SMPROGRAMS\Verae\Verae - Setup signing key.lnk" + Delete "$SMPROGRAMS\Verae\Verae - Start print helper.lnk" + Delete "$SMPROGRAMS\Verae\Verae - Logs folder.lnk" + Delete "$SMPROGRAMS\Verae\Verae - Collect diagnostics.lnk" + Delete "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp.lnk" + Delete "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp (debug).lnk" + RMDir "$SMPROGRAMS\Verae" + DeleteRegKey HKLM "Software\Verae\PrintStamp" DeleteRegKey HKLM "${UNINST_KEY}" ; logs and the user's .verae\xverae-token are kept on purpose SectionEnd diff --git a/site/printit.verae.ai/index.html b/site/printit.verae.ai/index.html index 4ba4b1d..9a3568f 100644 --- a/site/printit.verae.ai/index.html +++ b/site/printit.verae.ai/index.html @@ -193,7 +193,7 @@

Network printer

-

Optional LAN queue “Verae Timestamp” (IPP Everywhere) plus a tray or menu-bar control. Standalone installers, or the off-by-default option inside the driver installers. One service either way. Version 0.3.4.

+

Optional LAN queue “Verae Timestamp” (IPP Everywhere) plus a tray or menu-bar control. Standalone installers, or the off-by-default option inside the driver installers. One service either way. The Windows standalone setup uses C:\Program Files\Verae\NetPrint and does not replace the driver. If port 631 is taken, the printer uses 8632 (the driver helper keeps 8631). Version 0.3.4.

Debian amd64 From 141abf5623ee4ba3ae1ab452d8a5e90d2e5aa5aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 21:48:31 +0000 Subject: [PATCH 11/27] Advertise the Windows printer through DNS-SD and log the service. VeraeNetPrint had no UDP sockets because Dnscache owns port 5353 and the bind error never reached a log or the status API. Register with DnsServiceRegister first, fall back to a shared UDP socket, write %ProgramData%\Verae\logs\netprint.log, and surface mDNS errors on /api/v1/status. The installer also starts the tray for the logged-in user, and Public networks plus a local queue stay off unless opted in. Co-authored-by: George Lambert --- README.md | 2 +- cmd/verae-print/main.go | 5 +- cmd/verae-print/netprint_cmd.go | 34 +- docs/network-printer.md | 18 +- internal/netprint/advertise.go | 56 +++- internal/netprint/advertise_unix.go | 4 + internal/netprint/advertise_windows.go | 72 +++- internal/netprint/config.go | 12 + internal/netprint/control.go | 109 +++++- internal/netprint/dnsservice.go | 115 +++++++ internal/netprint/dnsservice_windows.go | 228 +++++++++++++ internal/netprint/firewall.go | 10 + internal/netprint/firewall_other.go | 7 + internal/netprint/launch_tray_other.go | 11 + internal/netprint/launch_tray_windows.go | 194 +++++++++++ internal/netprint/mdns_windows_test.go | 409 +++++++++++++++++++++++ internal/netprint/netprint_test.go | 3 + internal/netprint/network_other.go | 7 + internal/netprint/network_windows.go | 39 +++ internal/netprint/pipeline.go | 4 + internal/netprint/queue_windows.go | 56 ++++ internal/netprint/server.go | 22 +- internal/netprint/service.go | 24 ++ internal/netprint/service_darwin.go | 3 +- internal/netprint/service_linux.go | 3 +- internal/netprint/service_log.go | 39 +++ internal/netprint/service_log_other.go | 21 ++ internal/netprint/service_log_windows.go | 16 + internal/netprint/service_windows.go | 41 ++- internal/netprint/tray_menu.go | 51 ++- platform/windows/installer-netprint.nsi | 86 ++++- platform/windows/installer-windows.nsi | 87 ++++- site/printit.verae.ai/index.html | 2 +- 33 files changed, 1730 insertions(+), 60 deletions(-) create mode 100644 internal/netprint/dnsservice.go create mode 100644 internal/netprint/dnsservice_windows.go create mode 100644 internal/netprint/firewall.go create mode 100644 internal/netprint/firewall_other.go create mode 100644 internal/netprint/launch_tray_other.go create mode 100644 internal/netprint/launch_tray_windows.go create mode 100644 internal/netprint/mdns_windows_test.go create mode 100644 internal/netprint/network_other.go create mode 100644 internal/netprint/network_windows.go create mode 100644 internal/netprint/queue_windows.go create mode 100644 internal/netprint/service_log.go create mode 100644 internal/netprint/service_log_other.go create mode 100644 internal/netprint/service_log_windows.go diff --git a/README.md b/README.md index 115f227..8b82508 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ verae-print setup verae-print stamp document.pdf ``` -**Network printer (optional, separate from the driver):** an IPP Everywhere queue named **Verae Timestamp** for other devices on the LAN. Install the standalone package, or leave the driver installer’s option off (Windows checkbox, macOS Customize, Debian debconf). Both use one service and one tray / menu-bar control. The standalone Windows folder is `C:\Program Files\Verae\NetPrint` (the driver stays in `PrintStamp`). The LAN fallback port is **8632**, so it does not collide with the driver helper on `127.0.0.1:8631`. See [docs/network-printer.md](docs/network-printer.md). +**Network printer (optional, separate from the driver):** an IPP Everywhere queue named **Verae Timestamp** for other devices on the LAN. Install the standalone package, or leave the driver installer’s option off (Windows checkbox, macOS Customize, Debian debconf). Both use one service and one tray / menu-bar control. On Windows the service advertises through DNS-SD (`DnsServiceRegister`) because the DNS Client owns UDP 5353, writes `%ProgramData%\Verae\logs\netprint.log`, and the setup starts the tray for the logged-in user. Public networks and a local Windows queue stay off unless you opt in. The standalone Windows folder is `C:\Program Files\Verae\NetPrint` (the driver stays in `PrintStamp`). The LAN fallback port is **8632**, so it does not collide with the driver helper on `127.0.0.1:8631`. See [docs/network-printer.md](docs/network-printer.md). | OS | Standalone | With the driver (off unless you opt in) | |----|------------|------------------------------------------| diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index 50a937b..3e5b04c 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -84,7 +84,10 @@ Usage: verae-print serve [--addr 127.0.0.1:8631] [--no-ipp] [--no-watch] [--debug] # Windows print helper: IPP loopback printer + fallback watcher verae-print netprint [--config FILE] [--name NAME] [--port N] [--data-dir DIR] [--forward TARGET] [--no-mdns] - # LAN IPP Everywhere printer (also: netprint install|uninstall --owner driver|standalone) + # LAN IPP Everywhere printer + verae-print netprint install --owner driver|standalone [--allow-public true|false] [--local-queue] + verae-print netprint uninstall --owner driver|standalone + verae-print netprint launch-tray # Windows: start the tray as the logged-in user verae-print tray # menu-bar / system-tray control for the network printer verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH] diff --git a/cmd/verae-print/netprint_cmd.go b/cmd/verae-print/netprint_cmd.go index 72012e5..2796737 100644 --- a/cmd/verae-print/netprint_cmd.go +++ b/cmd/verae-print/netprint_cmd.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" @@ -16,7 +17,7 @@ import ( ) func runNetprint(args []string) int { - if len(args) > 0 && (args[0] == "install" || args[0] == "uninstall") { + if len(args) > 0 && (args[0] == "install" || args[0] == "uninstall" || args[0] == "launch-tray") { return runNetprintInstall(args) } fs := flag.NewFlagSet("netprint", flag.ContinueOnError) @@ -48,14 +49,24 @@ func runNetprintInstall(args []string) int { fs := flag.NewFlagSet(args[0], flag.ContinueOnError) owner := fs.String("owner", "standalone", "driver or standalone") token := fs.String("token-file", "", "optional xVerae- token file to copy for the service") + allowPublic := fs.String("allow-public", "", "true or false; empty leaves the saved choice") + localQueue := fs.Bool("local-queue", false, "add a local Windows IPP queue named Verae Timestamp") fs.SetOutput(os.Stderr) if err := fs.Parse(args[1:]); err != nil { return 2 } var err error - if args[0] == "install" { - err = netprint.Install(*owner, *token) - } else { + switch args[0] { + case "launch-tray": + err = netprint.LaunchTrayNow() + case "install": + pub, perr := parseAllowPublic(*allowPublic) + if perr != nil { + fmt.Fprintln(os.Stderr, perr) + return 2 + } + err = netprint.Install(*owner, *token, netprint.InstallOptions{AllowPublic: pub, LocalQueue: *localQueue}) + default: err = netprint.Uninstall(*owner) } if err != nil { @@ -66,6 +77,21 @@ func runNetprintInstall(args []string) int { return 0 } +func parseAllowPublic(s string) (*bool, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "": + return nil, nil + case "true", "1", "yes": + v := true + return &v, nil + case "false", "0", "no": + v := false + return &v, nil + default: + return nil, fmt.Errorf("allow-public must be true or false") + } +} + func runTrayCmd() int { if err := netprint.RunTray(context.Background()); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/docs/network-printer.md b/docs/network-printer.md index ddd3c12..0212321 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -20,18 +20,20 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin ## Try it -1. Install using either path above. The tray or menu-bar icon starts at login only after the print server is installed. +1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus `_universal` and `_print`) and AirPrint text records. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus `_universal` and `_print`) and AirPrint text records. On Windows 10 and later the service registers with `DnsServiceRegister`, so the DNS Client (which already owns UDP 5353) sends the advertisements. If that call fails, the service tries a shared UDP 5353 socket and records the error. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. 4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://:/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`. -The icon shows running or stopped, the printer name, a LAN address and port, and how many jobs arrived today. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). +The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, and whether discovery is advertising. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward does not fail the timestamp. ## Firewall -The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. They are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback. +The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. A checkbox, off by default, also allows Public networks. A silent install can pass `/PUBLIC=1`. The tray has the same switch. The rules are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback. + +An optional checkbox (silent `/QUEUE=1`) adds a local Windows printer named **Verae Timestamp** aimed at `http://127.0.0.1:/ipp/print`. It is removed when the last NetPrint product is uninstalled. The driver printer **Verae TimeStamp PDF** is a different queue. On Debian and Ubuntu, `postinst` opens the same three ports with `ufw` when ufw is installed and already active. The rules are removed when the last package is removed. @@ -39,12 +41,12 @@ On macOS, the Application Firewall is per application (System Settings → Netwo The control API listens only on `127.0.0.1:8630`. -A silent Windows install (`/S`) does not wait on a dialog. If the service does not start, the message is written to `%ProgramData%\Verae\logs\netprint-installer.log`. +A silent Windows install (`/S`) does not wait on a dialog. If the service does not start, the message is written to `%ProgramData%\Verae\logs\netprint-installer.log`. The service itself appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log`. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. ## Troubleshooting - The icon says stopped. Start it from the menu. On Linux, `systemctl status verae-netprint`. On Windows, `sc query VeraeNetPrint`. On macOS, `sudo launchctl print system/com.verae.netprint`. -- Other devices do not see it. Confirm the service is running, mDNS (UDP 5353) is not blocked, and the host has a LAN address. CUPS on the same machine is fine: this printer falls back to port 8632 and still advertises that port. It does not fall back to 8631. +- Other devices do not see it. Open `http://127.0.0.1:8630/api/v1/status` and read `mdnsMode` / `mdnsError`, and `%ProgramData%\Verae\logs\netprint.log` on Windows. `mdnsMode` `dns-sd` means Windows DNS-SD accepted the printer. Confirm the host has a LAN address and the network is Private, or turn on Allow Public networks. CUPS on the same machine is fine: this printer falls back to port 8632 and still advertises that port. It does not fall back to 8631. - Windows setup seemed to hang with `/S`. Every installer dialog has a silent default. Read `%ProgramData%\Verae\logs\netprint-installer.log`. - Jobs fail with an authentication error. Open Settings and save a current `xVerae-` key. The service reads the machine token (`/etc/verae/xverae-token`, the macOS Application Support file, or `%ProgramData%\Verae\PrintStamp\xverae-token`) and the usual per-user token. - Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) are converted to PDF on this machine before they are hashed. PDF is accepted as-is. @@ -53,7 +55,9 @@ Commands: ```bash verae-print netprint # foreground, for debugging -verae-print netprint install --owner driver # or standalone +verae-print netprint install --owner driver --allow-public=false +verae-print netprint install --owner standalone --allow-public=true --local-queue +verae-print netprint launch-tray # Windows: tray as the logged-in user verae-print netprint uninstall --owner driver verae-print tray ``` diff --git a/internal/netprint/advertise.go b/internal/netprint/advertise.go index 324f03b..3904279 100644 --- a/internal/netprint/advertise.go +++ b/internal/netprint/advertise.go @@ -3,27 +3,71 @@ package netprint import ( "context" "errors" + "log" "net" "time" ) var mdnsAddr = &net.UDPAddr{IP: net.ParseIP("224.0.0.251"), Port: 5353} -// Advertise answers DNS-SD queries and refreshes the announcement until ctx ends. +// listenMDNSFn is the UDP 5353 bind. Tests replace it. +var listenMDNSFn = listenMDNS + +// mdnsReport is what the advertiser tells the server about discovery. +type mdnsReport struct { + Advertising bool + Mode string + Detail string + Err string + LastSent time.Time +} + +type mdnsSink func(mdnsReport) + +// startAdvertiser is the discovery loop. Tests replace it to prove the Windows path runs. +var startAdvertiser = func(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertisePlatform(ctx, current, sink) +} + +// advertiseUDP answers DNS-SD queries and repeats the announcement until ctx ends. // A bind failure is returned so the caller can keep IPP running without mDNS. -func Advertise(ctx context.Context, current func() Service) error { - conn, err := listenMDNS() +func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) error { + conn, err := listenMDNSFn() if err != nil { + msg := "UDP 5353 bind failed: " + err.Error() + log.Printf("netprint: mDNS %s", msg) + if sink != nil { + sink(mdnsReport{Mode: "udp", Err: err.Error(), Detail: msg}) + } return err } defer conn.Close() + log.Printf("netprint: mDNS shared UDP 5353 socket is open") go func() { <-ctx.Done() _, _ = conn.WriteToUDP(current().Announce(true), mdnsAddr) _ = conn.Close() }() + sent := 0 send := func() { - _, _ = conn.WriteToUDP(current().Announce(false), mdnsAddr) + n, werr := conn.WriteToUDP(current().Announce(false), mdnsAddr) + if werr != nil { + log.Printf("netprint: mDNS announcement failed: %v", werr) + if sink != nil { + sink(mdnsReport{Mode: "udp", Err: werr.Error(), Detail: "announcement send failed"}) + } + return + } + sent++ + log.Printf("netprint: mDNS announcement sent (%d bytes) to 224.0.0.251:5353", n) + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "udp", + Detail: "shared UDP 5353, announcement sent to 224.0.0.251", + LastSent: time.Now(), + }) + } } send() buf := make([]byte, 9000) @@ -54,6 +98,8 @@ func Advertise(ctx context.Context, current func() Service) error { if addr != nil { _, _ = conn.WriteToUDP(resp, addr) } - _, _ = conn.WriteToUDP(resp, mdnsAddr) + if _, werr := conn.WriteToUDP(resp, mdnsAddr); werr == nil { + log.Printf("netprint: mDNS query reply sent (%d bytes)", len(resp)) + } } } diff --git a/internal/netprint/advertise_unix.go b/internal/netprint/advertise_unix.go index 2bc94d6..8ec4a9e 100644 --- a/internal/netprint/advertise_unix.go +++ b/internal/netprint/advertise_unix.go @@ -36,3 +36,7 @@ func listenMDNS() (*net.UDPConn, error) { _ = conn.SetReadBuffer(1 << 20) return conn, nil } + +func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseUDP(ctx, current, sink) +} diff --git a/internal/netprint/advertise_windows.go b/internal/netprint/advertise_windows.go index 20482d3..64a7769 100644 --- a/internal/netprint/advertise_windows.go +++ b/internal/netprint/advertise_windows.go @@ -3,16 +3,84 @@ package netprint import ( + "context" "fmt" + "log" "net" + "syscall" + + "golang.org/x/sys/windows" ) -const soReusePort = 0 +const ipAddMembership = 12 func listenMDNS() (*net.UDPConn, error) { - conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 5353}) + lc := net.ListenConfig{ + Control: func(network, address string, c syscall.RawConn) error { + var sockErr error + err := c.Control(func(fd uintptr) { + // Dnscache already binds UDP 5353. SO_REUSEADDR lets this + // process share that socket when the DNS Client allows it. + sockErr = syscall.SetsockoptInt(syscall.Handle(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + }) + if err != nil { + return err + } + return sockErr + }, + } + pc, err := lc.ListenPacket(context.Background(), "udp4", "0.0.0.0:5353") if err != nil { return nil, fmt.Errorf("mDNS bind: %w", err) } + conn := pc.(*net.UDPConn) + joinIPv4Multicast(conn) + _ = conn.SetReadBuffer(1 << 20) return conn, nil } + +func joinIPv4Multicast(conn *net.UDPConn) { + raw, err := conn.SyscallConn() + if err != nil { + log.Printf("netprint: mDNS multicast socket: %v", err) + return + } + group := net.ParseIP("224.0.0.251").To4() + ifaces, _ := net.Interfaces() + joined := 0 + _ = raw.Control(func(fd uintptr) { + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 { + continue + } + addrs, _ := ifi.Addrs() + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok { + continue + } + ip := ipnet.IP.To4() + if ip == nil || ip.IsLoopback() { + continue + } + var mreq [8]byte + copy(mreq[0:4], group) + copy(mreq[4:8], ip) + err := windows.Setsockopt(windows.Handle(fd), windows.IPPROTO_IP, ipAddMembership, &mreq[0], int32(len(mreq))) + if err != nil { + log.Printf("netprint: mDNS join 224.0.0.251 on %s: %v", ip, err) + continue + } + joined++ + log.Printf("netprint: mDNS joined 224.0.0.251 on %s (%s)", ifi.Name, ip) + } + } + }) + if joined == 0 { + log.Printf("netprint: mDNS joined no IPv4 interfaces for 224.0.0.251") + } +} + +func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseForOS("windows", ctx, current, windowsDNSService{}, sink) +} diff --git a/internal/netprint/config.go b/internal/netprint/config.go index 35a52b5..5d8c717 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -47,8 +47,14 @@ type Config struct { Hostname string `json:"hostname,omitempty"` // ControlPort is the loopback-only control and settings API (default 8630). ControlPort int `json:"controlPort,omitempty"` + // AllowPublic opens the Windows firewall on Public networks as well as + // Private and Domain. Nil and false keep Public blocked. + AllowPublic *bool `json:"allowPublic,omitempty"` } +// PublicNetworks reports whether Public firewall profiles should be allowed. +func (c Config) PublicNetworks() bool { return c.AllowPublic != nil && *c.AllowPublic } + // Keep reports whether original PDFs are retained. func (c Config) Keep() bool { return c.KeepPDF == nil || *c.KeepPDF } @@ -193,6 +199,10 @@ func mergeConfig(dst *Config, src Config) { if src.ControlPort > 0 { dst.ControlPort = src.ControlPort } + if src.AllowPublic != nil { + v := *src.AllowPublic + dst.AllowPublic = &v + } } func (c *Config) normalize() error { @@ -229,6 +239,7 @@ func (c *Config) normalize() error { func ExampleJSON(dataDir string) []byte { keep := true adv := true + public := false c := Config{ Name: "Verae Timestamp", Port: 631, @@ -238,6 +249,7 @@ func ExampleJSON(dataDir string) []byte { KeepPDF: &keep, ForwardPrinter: "", Advertise: &adv, + AllowPublic: &public, } b, _ := json.MarshalIndent(c, "", " ") return append(b, '\n') diff --git a/internal/netprint/control.go b/internal/netprint/control.go index caf69f8..1cd99cf 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net" "net/http" "net/url" @@ -28,6 +29,15 @@ type Status struct { TrayAtLogin bool `json:"trayAtLogin"` KeyPresent bool `json:"keyPresent"` ControlURL string `json:"controlUrl"` + // MDNSAdvertising is true only when an announcement was accepted or sent. + MDNSAdvertising bool `json:"mdnsAdvertising"` + MDNSMode string `json:"mdnsMode"` + MDNSDetail string `json:"mdnsDetail"` + MDNSError string `json:"mdnsError"` + MDNSLastSent string `json:"mdnsLastSent,omitempty"` + AllowPublic bool `json:"allowPublic"` + NetworkPublic bool `json:"networkPublic"` + SupportsPublic bool `json:"supportsPublicFirewall"` } // ServiceControl is how the service asks the OS to start, stop, or launch at login. @@ -90,6 +100,11 @@ func (c *Client) SetTrayAtLogin(enabled bool) error { return c.post("/api/v1/tray", map[string]bool{"enabled": enabled}, nil) } +// SetAllowPublic asks the service to open or close the Public firewall profile. +func (c *Client) SetAllowPublic(on bool) error { + return c.post("/api/v1/firewall", map[string]bool{"allowPublic": on}, nil) +} + // Settings asks the service to store printer name, forward target, and port. func (c *Client) Settings(name, forward string, port int) error { body := map[string]any{"name": name, "forwardPrinter": forward} @@ -162,19 +177,34 @@ func (s *Server) status() Status { } launch, _ := s.ctrl.LaunchAtLogin() tray, _ := s.ctrl.TrayAtLogin() + s.mu.Lock() + mdns := s.mdns + s.mu.Unlock() + last := "" + if !mdns.LastSent.IsZero() { + last = mdns.LastSent.UTC().Format(time.RFC3339) + } return Status{ - Running: true, - Name: cfg.Name, - Port: s.port, - Addresses: addrs, - AdminURL: admin, - JobsToday: today, - Recent: recent, - ForwardPrinter: cfg.ForwardPrinter, - LaunchAtLogin: launch, - TrayAtLogin: tray, - KeyPresent: auth.HasXVeraeToken(), - ControlURL: s.controlURL, + Running: true, + Name: cfg.Name, + Port: s.port, + Addresses: addrs, + AdminURL: admin, + JobsToday: today, + Recent: recent, + ForwardPrinter: cfg.ForwardPrinter, + LaunchAtLogin: launch, + TrayAtLogin: tray, + KeyPresent: auth.HasXVeraeToken(), + ControlURL: s.controlURL, + MDNSAdvertising: mdns.Advertising, + MDNSMode: mdns.Mode, + MDNSDetail: mdns.Detail, + MDNSError: mdns.Err, + MDNSLastSent: last, + AllowPublic: cfg.PublicNetworks(), + NetworkPublic: cachedNetworkPublic(), + SupportsPublic: supportsPublicFirewall(), } } @@ -241,6 +271,32 @@ func (s *Server) handleTray(w http.ResponseWriter, r *http.Request) { s.handleToggle(w, r, s.ctrl.SetTrayAtLogin) } +func (s *Server) handleFirewall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + AllowPublic bool `json:"allowPublic"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.mu.Lock() + s.cfg.AllowPublic = &body.AllowPublic + cfg := s.cfg + path := s.configPath + s.mu.Unlock() + if err := Save(path, cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + applyFirewall(body.AllowPublic) + log.Printf("netprint: firewall public networks=%v", body.AllowPublic) + writeJSON(w, map[string]bool{"allowPublic": body.AllowPublic}) +} + func (s *Server) handleToggle(w http.ResponseWriter, r *http.Request, set func(bool) error) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -269,6 +325,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` ForwardPrinter string `json:"forwardPrinter"` Port int `json:"port"` + AllowPublic *bool `json:"allowPublic"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -283,6 +340,10 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { if body.Port > 0 && body.Port < 65536 { s.cfg.Port = body.Port } + if body.AllowPublic != nil { + v := *body.AllowPublic + s.cfg.AllowPublic = &v + } cfg := s.cfg path := s.configPath s.mu.Unlock() @@ -290,6 +351,10 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if body.AllowPublic != nil { + applyFirewall(*body.AllowPublic) + log.Printf("netprint: firewall public networks=%v", *body.AllowPublic) + } writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"}) } @@ -334,9 +399,12 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { if port > 0 && port < 65536 { s.cfg.Port = port } + pub := r.FormValue("allowPublic") == "1" + s.cfg.AllowPublic = &pub cfg := s.cfg path := s.configPath s.mu.Unlock() + applyFirewall(pub) if err := Save(path, cfg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -367,13 +435,21 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { +

Receipts

-`, htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port) +`, htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port, checkedAttr(cfg.PublicNetworks())) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(b.String())) } +func checkedAttr(on bool) string { + if on { + return "checked" + } + return "" +} + func htmlEscape(s string) string { return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(s) } @@ -396,7 +472,12 @@ func loopback(r *http.Request) bool { // StoppedStatus is what the tray shows when the control API is down. func StoppedStatus() Status { - return Status{Running: false, Name: "Verae Timestamp", Port: 631} + return Status{ + Running: false, + Name: "Verae Timestamp", + Port: 631, + SupportsPublic: supportsPublicFirewall(), + } } // OpenURL opens a local admin or receipt page in the user's browser. diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go new file mode 100644 index 0000000..50acd31 --- /dev/null +++ b/internal/netprint/dnsservice.go @@ -0,0 +1,115 @@ +package netprint + +import ( + "context" + "fmt" + "log" + "net" + "time" +) + +// dnsSDInstance is one DNS-SD registration (primary service or a subtype). +type dnsSDInstance struct { + InstanceName string + Host string + Port int + IPv4 []net.IP + TXT []string +} + +// dnsSDClient registers a service with the operating system's DNS-SD API. +// On Windows 10 and later this is DnsServiceRegister in dnsapi.dll. +type dnsSDClient interface { + Register(dnsSDInstance) error + Deregister(dnsSDInstance) error +} + +func serviceToDNS(s Service) []dnsSDInstance { + host := sanitizeLabel(s.Host) + ".local" + base := sanitizeLabel(s.Instance) + names := []string{ + base + "._ipp._tcp.local", + base + "._universal._sub._ipp._tcp.local", + base + "._print._sub._ipp._tcp.local", + } + out := make([]dnsSDInstance, 0, len(names)) + for _, name := range names { + out = append(out, dnsSDInstance{ + InstanceName: name, + Host: host, + Port: s.Port, + IPv4: append([]net.IP(nil), s.IPs...), + TXT: append([]string(nil), s.TXT...), + }) + } + return out +} + +// advertiseWithDNS publishes the printer through DnsServiceRegister (or a test double) +// and blocks until ctx is canceled. The primary _ipp._tcp name must succeed. +// Subtype failures are logged and do not hide the primary advertisement. +func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + if api == nil { + return fmt.Errorf("DnsServiceRegister client is nil") + } + insts := serviceToDNS(current()) + if len(insts) == 0 { + return fmt.Errorf("DnsServiceRegister: empty service") + } + var registered []dnsSDInstance + for i, inst := range insts { + if err := api.Register(inst); err != nil { + if i == 0 { + return fmt.Errorf("DnsServiceRegister %s: %w", inst.InstanceName, err) + } + log.Printf("netprint: mDNS subtype %s not registered: %v", inst.InstanceName, err) + continue + } + registered = append(registered, inst) + log.Printf("netprint: mDNS advertised via DnsServiceRegister %s port %d", inst.InstanceName, inst.Port) + } + if len(registered) == 0 { + return fmt.Errorf("DnsServiceRegister registered nothing") + } + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: fmt.Sprintf("DnsServiceRegister %s port %d", registered[0].InstanceName, registered[0].Port), + LastSent: time.Now(), + }) + } + <-ctx.Done() + for _, inst := range registered { + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: mDNS DnsServiceDeRegister %s: %v", inst.InstanceName, err) + } + } + log.Printf("netprint: mDNS DnsServiceRegister removed") + return nil +} + +// advertiseForOS is the discovery strategy for goos. +// Windows tries DnsServiceRegister first so the advertisement is sent by Dnscache, +// which already owns UDP 5353. A shared UDP socket is the fallback. +func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + if goos == "windows" { + err := advertiseWithDNS(ctx, current, api, sink) + if err == nil || ctx.Err() != nil { + return nil + } + log.Printf("netprint: DnsServiceRegister failed: %v; trying a shared UDP 5353 socket", err) + if sink != nil { + sink(mdnsReport{ + Mode: "dns-sd", + Err: err.Error(), + Detail: "DnsServiceRegister failed; trying shared UDP 5353", + }) + } + if udpErr := advertiseUDP(ctx, current, sink); udpErr != nil { + return fmt.Errorf("DnsServiceRegister: %v; shared UDP 5353: %w", err, udpErr) + } + return nil + } + return advertiseUDP(ctx, current, sink) +} diff --git a/internal/netprint/dnsservice_windows.go b/internal/netprint/dnsservice_windows.go new file mode 100644 index 0000000..d7f2de0 --- /dev/null +++ b/internal/netprint/dnsservice_windows.go @@ -0,0 +1,228 @@ +//go:build windows + +package netprint + +import ( + "encoding/binary" + "fmt" + "net" + "runtime" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + dnsQueryRequestVersion1 = 1 + dnsRequestPending = 9506 +) + +// windowsDNSService calls dnsapi!DnsServiceRegister. Windows 10 and later +// send the mDNS packets from the DNS Client service, which owns UDP 5353. +type windowsDNSService struct{} + +func (windowsDNSService) Register(inst dnsSDInstance) error { + return dnsServiceCall(inst, false) +} + +func (windowsDNSService) Deregister(inst dnsSDInstance) error { + return dnsServiceCall(inst, true) +} + +var ( + dnsAPI = windows.NewLazySystemDLL("dnsapi.dll") + procConstruct = dnsAPI.NewProc("DnsServiceConstructInstance") + procFree = dnsAPI.NewProc("DnsServiceFreeInstance") + procRegister = dnsAPI.NewProc("DnsServiceRegister") + procDereg = dnsAPI.NewProc("DnsServiceDeRegister") + + dnsMu sync.Mutex + dnsWait chan uint32 + dnsCallback uintptr +) + +func init() { + dnsCallback = syscall.NewCallback(dnsComplete) +} + +func dnsComplete(status, _, _ uintptr) uintptr { + dnsMu.Lock() + ch := dnsWait + dnsMu.Unlock() + if ch != nil { + select { + case ch <- uint32(status): + default: + } + } + return 0 +} + +type dnsServiceCancel struct { + reserved uintptr +} + +type dnsServiceRegisterRequest struct { + Version uint32 + InterfaceIndex uint32 + Instance uintptr + RegisterCompletionCallback uintptr + QueryContext uintptr + Cancel uintptr + UnicastEnabled uint32 +} + +func dnsServiceCall(inst dnsSDInstance, deregister bool) error { + if err := procConstruct.Find(); err != nil { + return fmt.Errorf("DnsServiceConstructInstance: %w", err) + } + name, err := windows.UTF16PtrFromString(inst.InstanceName) + if err != nil { + return err + } + host, err := windows.UTF16PtrFromString(inst.Host) + if err != nil { + return err + } + keys, values, err := txtUTF16(inst.TXT) + if err != nil { + return err + } + ip4 := ip4ZeroTerminated(inst.IPv4) + var ip4Ptr uintptr + if len(ip4) > 0 { + ip4Ptr = uintptr(unsafe.Pointer(&ip4[0])) + } + var keysPtr, valuesPtr uintptr + if len(keys) > 0 { + keysPtr = uintptr(unsafe.Pointer(&keys[0])) + valuesPtr = uintptr(unsafe.Pointer(&values[0])) + } + r, _, callErr := procConstruct.Call( + uintptr(unsafe.Pointer(name)), + uintptr(unsafe.Pointer(host)), + ip4Ptr, + 0, + uintptr(inst.Port), + 0, + 0, + uintptr(len(keys)), + keysPtr, + valuesPtr, + ) + runtime.KeepAlive(name) + runtime.KeepAlive(host) + runtime.KeepAlive(keys) + runtime.KeepAlive(values) + runtime.KeepAlive(ip4) + if r == 0 { + return fmt.Errorf("DnsServiceConstructInstance failed (%v)", callErr) + } + defer procFree.Call(r) + + cancel := dnsServiceCancel{} + req := dnsServiceRegisterRequest{ + Version: dnsQueryRequestVersion1, + Instance: r, + RegisterCompletionCallback: dnsCallback, + Cancel: uintptr(unsafe.Pointer(&cancel)), + } + proc := procRegister + what := "DnsServiceRegister" + if deregister { + proc = procDereg + what = "DnsServiceDeRegister" + } + if err := proc.Find(); err != nil { + return fmt.Errorf("%s: %w", what, err) + } + code, err := callDNS(func() uintptr { + ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&req)), uintptr(unsafe.Pointer(&cancel))) + return ret + }) + if err != nil { + return fmt.Errorf("%s %s: %w", what, inst.InstanceName, err) + } + runtime.KeepAlive(&req) + runtime.KeepAlive(&cancel) + if code != 0 { + return fmt.Errorf("%s %s: win32 %d", what, inst.InstanceName, code) + } + return nil +} + +func callDNS(call func() uintptr) (uint32, error) { + ch := make(chan uint32, 1) + dnsMu.Lock() + dnsWait = ch + dnsMu.Unlock() + defer func() { + dnsMu.Lock() + if dnsWait == ch { + dnsWait = nil + } + dnsMu.Unlock() + }() + ret := uint32(call()) + if ret == 0 { + return 0, nil + } + if ret != dnsRequestPending { + return ret, nil + } + select { + case st := <-ch: + return st, nil + case <-time.After(15 * time.Second): + return 0, fmt.Errorf("timed out waiting for the DNS-SD callback") + } +} + +func txtUTF16(txt []string) ([]*uint16, []*uint16, error) { + if len(txt) == 0 { + return nil, nil, nil + } + keys := make([]*uint16, 0, len(txt)) + values := make([]*uint16, 0, len(txt)) + for _, item := range txt { + k, v := item, "" + for i := 0; i < len(item); i++ { + if item[i] == '=' { + k, v = item[:i], item[i+1:] + break + } + } + if k == "" { + continue + } + kp, err := windows.UTF16PtrFromString(k) + if err != nil { + return nil, nil, err + } + vp, err := windows.UTF16PtrFromString(v) + if err != nil { + return nil, nil, err + } + keys = append(keys, kp) + values = append(values, vp) + } + return keys, values, nil +} + +func ip4ZeroTerminated(ips []net.IP) []uint32 { + var out []uint32 + for _, ip := range ips { + v4 := ip.To4() + if v4 == nil { + continue + } + out = append(out, binary.BigEndian.Uint32(v4)) + } + if len(out) == 0 { + return nil + } + return append(out, 0) +} diff --git a/internal/netprint/firewall.go b/internal/netprint/firewall.go new file mode 100644 index 0000000..b1039e4 --- /dev/null +++ b/internal/netprint/firewall.go @@ -0,0 +1,10 @@ +package netprint + +// firewallProfile is the netsh profile list. Public stays off unless the +// installer or the tray asked for it. Private and Domain stay allowed. +func firewallProfile(public bool) string { + if public { + return "private,domain,public" + } + return "private,domain" +} diff --git a/internal/netprint/firewall_other.go b/internal/netprint/firewall_other.go new file mode 100644 index 0000000..7c81d5d --- /dev/null +++ b/internal/netprint/firewall_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package netprint + +// applyFirewall is a no-op off Windows. ufw has no public/private split; +// the Debian package opens the ports when ufw is already active. +func applyFirewall(bool) {} diff --git a/internal/netprint/launch_tray_other.go b/internal/netprint/launch_tray_other.go new file mode 100644 index 0000000..ca718df --- /dev/null +++ b/internal/netprint/launch_tray_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package netprint + +import "fmt" + +// LaunchTrayNow starts the tray for the logged-in user. Windows is the only +// platform where a silent elevated install leaves the icon unstarted. +func LaunchTrayNow() error { + return fmt.Errorf("launch-tray is implemented on Windows") +} diff --git a/internal/netprint/launch_tray_windows.go b/internal/netprint/launch_tray_windows.go new file mode 100644 index 0000000..dfda59e --- /dev/null +++ b/internal/netprint/launch_tray_windows.go @@ -0,0 +1,194 @@ +//go:build windows + +package netprint + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const trayTaskName = "VeraeNetPrintTrayLaunch" + +// LaunchTrayNow starts verae-netprint-tray.exe as the logged-in user, not as +// the elevated installer. A second call is a no-op when the tray is already running. +func LaunchTrayNow() error { + if trayAlreadyRunning() { + fmt.Println("tray started") + return nil + } + cmd, err := trayCommandLine() + if err != nil { + return err + } + if err := launchAsInteractiveUser(cmd); err != nil { + log.Printf("netprint: tray token launch: %v", err) + user, uerr := activeConsoleUser() + if uerr != nil { + return fmt.Errorf("%v; %w", err, uerr) + } + if err2 := launchTrayTask(cmd, user); err2 != nil { + return fmt.Errorf("%v; %w", err, err2) + } + } + fmt.Println("tray started") + return nil +} + +func trayCommandLine() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + exe, err = filepath.Abs(exe) + if err != nil { + return "", err + } + sibling := filepath.Join(filepath.Dir(exe), "verae-netprint-tray.exe") + if st, err := os.Stat(sibling); err == nil && !st.IsDir() { + return `"` + sibling + `"`, nil + } + return `"` + exe + `" tray`, nil +} + +func trayAlreadyRunning() bool { + out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq verae-netprint-tray.exe", "/FO", "CSV", "/NH").Output() + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(out)), "verae-netprint-tray.exe") +} + +func activeConsoleUser() (string, error) { + out, err := exec.Command("query", "user").CombinedOutput() + if err != nil || len(strings.TrimSpace(string(out))) == 0 { + if alt, err2 := exec.Command("quser").CombinedOutput(); err2 == nil { + out = alt + } + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(strings.ToUpper(line), "USERNAME") { + continue + } + fields := strings.Fields(line) + user := "" + active := false + for i, f := range fields { + if i == 0 { + user = strings.TrimLeft(f, ">") + } + if strings.EqualFold(f, "Active") { + active = true + } + } + if !active || user == "" { + continue + } + upper := strings.ToUpper(user) + if upper == "SYSTEM" || strings.Contains(upper, "NT AUTHORITY") { + continue + } + return user, nil + } + return "", fmt.Errorf("no interactive user") +} + +func launchAsInteractiveUser(cmdline string) error { + hwnd := windows.GetShellWindow() + if hwnd == 0 { + return fmt.Errorf("no shell window") + } + var pid uint32 + if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil || pid == 0 { + return fmt.Errorf("shell process: %w", err) + } + proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return err + } + defer windows.CloseHandle(proc) + var token windows.Token + if err := windows.OpenProcessToken(proc, windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY, &token); err != nil { + return err + } + defer token.Close() + var primary windows.Token + var access uint32 = windows.TOKEN_ASSIGN_PRIMARY | windows.TOKEN_DUPLICATE | windows.TOKEN_QUERY | windows.TOKEN_ADJUST_DEFAULT | windows.TOKEN_ADJUST_SESSIONID + if err := windows.DuplicateTokenEx(token, access, nil, windows.SecurityImpersonation, windows.TokenPrimary, &primary); err != nil { + return err + } + defer primary.Close() + buf, err := windows.UTF16FromString(cmdline) + if err != nil { + return err + } + // CreateProcessWithTokenW may write into the command line buffer. + cmd := make([]uint16, len(buf)+8) + copy(cmd, buf) + var si windows.StartupInfo + si.Cb = uint32(unsafe.Sizeof(si)) + var pi windows.ProcessInformation + if err := createProcessWithToken(primary, &cmd[0], &si, &pi); err != nil { + return err + } + _ = windows.CloseHandle(pi.Process) + _ = windows.CloseHandle(pi.Thread) + return nil +} + +var ( + advapi32 = windows.NewLazySystemDLL("advapi32.dll") + procCreateProcessWithTokenW = advapi32.NewProc("CreateProcessWithTokenW") +) + +func createProcessWithToken(token windows.Token, cmdline *uint16, si *windows.StartupInfo, pi *windows.ProcessInformation) error { + // LOGON_WITH_PROFILE (1) fails from an elevated installer. 0 uses the token as-is. + r, _, callErr := procCreateProcessWithTokenW.Call( + uintptr(token), + 0, + 0, + uintptr(unsafe.Pointer(cmdline)), + uintptr(windows.CREATE_UNICODE_ENVIRONMENT), + 0, + 0, + uintptr(unsafe.Pointer(si)), + uintptr(unsafe.Pointer(pi)), + ) + if r == 0 { + if callErr != nil && callErr != windows.ERROR_SUCCESS { + return fmt.Errorf("CreateProcessWithTokenW: %w", callErr) + } + return fmt.Errorf("CreateProcessWithTokenW failed") + } + return nil +} + +func launchTrayTask(cmdline, user string) error { + _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() + out, err := exec.Command("schtasks", "/Create", + "/TN", trayTaskName, + "/TR", cmdline, + "/SC", "ONCE", + "/ST", "00:00", + "/RL", "LIMITED", + "/IT", + "/RU", user, + "/F", + ).CombinedOutput() + if err != nil { + return fmt.Errorf("schtasks create: %w (%s)", err, strings.TrimSpace(string(out))) + } + out, err = exec.Command("schtasks", "/Run", "/TN", trayTaskName).CombinedOutput() + _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() + if err != nil { + return fmt.Errorf("schtasks run: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/netprint/mdns_windows_test.go b/internal/netprint/mdns_windows_test.go new file mode 100644 index 0000000..23f0f51 --- /dev/null +++ b/internal/netprint/mdns_windows_test.go @@ -0,0 +1,409 @@ +package netprint + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" +) + +// fakeDNS is a DnsServiceRegister stand-in. CI runs on Linux, so the Windows +// strategy is invoked through advertiseForOS("windows", ..., fakeDNS). +type fakeDNS struct { + mu sync.Mutex + registered []dnsSDInstance + deregistered []dnsSDInstance + err error + failSubtypes bool +} + +func (f *fakeDNS) Register(inst dnsSDInstance) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failSubtypes && len(f.registered) > 0 { + return f.err + } + if f.err != nil && !f.failSubtypes { + return f.err + } + f.registered = append(f.registered, inst) + return nil +} + +func (f *fakeDNS) Deregister(inst dnsSDInstance) error { + f.mu.Lock() + defer f.mu.Unlock() + f.deregistered = append(f.deregistered, inst) + return nil +} + +func (f *fakeDNS) names() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, len(f.registered)) + for i, inst := range f.registered { + out[i] = inst.InstanceName + } + return out +} + +func (f *fakeDNS) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.registered) +} + +func sampleService() Service { + return Service{ + Instance: "Verae Timestamp", + Host: "host", + Port: 631, + IPs: []net.IP{net.ParseIP("192.168.1.184")}, + TXT: []string{"ty=Verae Timestamp", "rp=ipp/print"}, + } +} + +func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { + listened := false + prev := listenMDNSFn + listenMDNSFn = func() (*net.UDPConn, error) { + listened = true + return nil, errors.New("UDP 5353 must not be bound when DnsServiceRegister succeeds") + } + t.Cleanup(func() { listenMDNSFn = prev }) + + api := &fakeDNS{} + var mu sync.Mutex + var got mdnsReport + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, func(r mdnsReport) { + mu.Lock() + got = r + mu.Unlock() + }) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && api.count() < 3 { + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("advertiseForOS did not return after cancel") + } + if listened { + t.Fatal("shared UDP 5353 was opened even though DnsServiceRegister succeeded") + } + mu.Lock() + report := got + mu.Unlock() + if report.Mode != "dns-sd" || !report.Advertising { + t.Fatalf("status report %+v", report) + } + if !strings.Contains(report.Detail, "DnsServiceRegister") || !strings.Contains(report.Detail, "631") { + t.Fatalf("detail %q", report.Detail) + } + want := []string{ + "Verae Timestamp._ipp._tcp.local", + "Verae Timestamp._universal._sub._ipp._tcp.local", + "Verae Timestamp._print._sub._ipp._tcp.local", + } + gotNames := api.names() + if strings.Join(gotNames, "|") != strings.Join(want, "|") { + t.Fatalf("registered %v", gotNames) + } + api.mu.Lock() + defer api.mu.Unlock() + if len(api.registered) != 3 || api.registered[0].Port != 631 || api.registered[0].Host != "host.local" { + t.Fatalf("instance %+v", api.registered) + } + if len(api.deregistered) != 3 { + t.Fatalf("deregistered %d", len(api.deregistered)) + } +} + +func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) { + prev := listenMDNSFn + listenMDNSFn = func() (*net.UDPConn, error) { + return nil, errors.New("Only one usage of each socket address (protocol/network address/port) is normally permitted") + } + t.Cleanup(func() { listenMDNSFn = prev }) + api := &fakeDNS{err: errors.New("dnsapi unavailable")} + err := advertiseForOS("windows", context.Background(), func() Service { return sampleService() }, api, nil) + if err == nil { + t.Fatal("expected a combined error") + } + msg := err.Error() + if !strings.Contains(msg, "DnsServiceRegister") || !strings.Contains(msg, "5353") { + t.Fatalf("error %q", msg) + } + if api.count() != 0 { + t.Fatal("primary registration should not be recorded after failure") + } +} + +func TestWindowsSubtypeFailureStillAdvertises(t *testing.T) { + api := &fakeDNS{err: errors.New("subtype rejected"), failSubtypes: true} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && api.count() < 1 { + time.Sleep(10 * time.Millisecond) + } + if api.count() != 1 { + t.Fatalf("primary count %d", api.count()) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestWindowsAdvertiseEntrypoint(t *testing.T) { + b, err := os.ReadFile("advertise_windows.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + if !strings.Contains(src, `advertiseForOS("windows"`) || !strings.Contains(src, "windowsDNSService{}") { + t.Fatal("Windows advertisePlatform must call DnsServiceRegister via advertiseForOS") + } +} + +func TestStatusShowsWindowsDNSDSuccess(t *testing.T) { + api := &fakeDNS{} + restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseForOS("windows", ctx, current, api, sink) + }) + defer restore() + prevListen := listenMDNSFn + listenMDNSFn = func() (*net.UDPConn, error) { + t.Errorf("UDP listen called") + return nil, errors.New("should not bind") + } + defer func() { listenMDNSFn = prevListen }() + + cfg, path := testConfig(t) + on := true + cfg.Advertise = &on + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + st := waitMDNS(t, s, "dns-sd") + if !st.MDNSAdvertising || st.MDNSError != "" { + t.Fatalf("%+v", st) + } + if api.count() < 1 || !strings.Contains(api.names()[0], "._ipp._tcp.local") { + t.Fatalf("Windows path was not invoked: %v", api.names()) + } + body := getURL(t, s.ControlURL()+"api/v1/status") + for _, needle := range []string{`"mdnsAdvertising": true`, `"mdnsMode": "dns-sd"`} { + if !strings.Contains(body, needle) { + t.Fatalf("status missing %s\n%s", needle, body) + } + } +} + +func TestStatusShowsMDNSFailure(t *testing.T) { + restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseForOS("windows", ctx, current, &fakeDNS{err: errors.New("dnsapi unavailable")}, sink) + }) + defer restore() + prevListen := listenMDNSFn + listenMDNSFn = func() (*net.UDPConn, error) { + return nil, errors.New("Only one usage of each socket address") + } + defer func() { listenMDNSFn = prevListen }() + + cfg, path := testConfig(t) + on := true + cfg.Advertise = &on + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + st := waitMDNS(t, s, "error") + if st.MDNSAdvertising { + t.Fatal("advertising should be false") + } + if !strings.Contains(st.MDNSError, "DnsServiceRegister") || !strings.Contains(st.MDNSError, "5353") { + t.Fatalf("mdnsError %q", st.MDNSError) + } + body := getURL(t, s.ControlURL()+"api/v1/status") + if !strings.Contains(body, `"mdnsMode": "error"`) || !strings.Contains(body, "DnsServiceRegister") { + t.Fatalf("status API hid the mDNS error\n%s", body) + } +} + +func TestServiceLogWritesStartupAndJobs(t *testing.T) { + dir := t.TempDir() + t.Setenv("VERAE_LOG_DIR", dir) + prevW := log.Writer() + prevFlags := log.Flags() + t.Cleanup(func() { + log.SetOutput(prevW) + log.SetFlags(prevFlags) + }) + setupServiceLog() + log.Printf("netprint: starting") + st, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + ProcessJob(st, Config{}, nil, 7, "application/pdf", "Hello", "ada", []byte("%PDF-1.4"), func(string, string, string) (job.Result, error) { + return job.Result{}, fmt.Errorf("boom") + }, nil) + b, err := os.ReadFile(filepath.Join(dir, "netprint.log")) + if err != nil { + t.Fatal(err) + } + text := string(b) + for _, needle := range []string{"netprint: log file", "netprint: starting", "job 7 started", "job 7 failed"} { + if !strings.Contains(text, needle) { + t.Fatalf("log missing %q\n%s", needle, text) + } + } +} + +func TestTrayDiscoveryAndPublicWarning(t *testing.T) { + st := Status{ + Running: true, Name: "Verae Timestamp", Port: 631, + MDNSMode: "error", MDNSError: "DnsServiceRegister: dnsapi unavailable; shared UDP 5353: bind failed", + NetworkPublic: true, SupportsPublic: true, + } + var sawDiscovery, sawWarn, sawPublic bool + for _, e := range BuildMenu(st) { + if strings.HasPrefix(e.Label, "Discovery — not advertising:") { + sawDiscovery = true + if !strings.Contains(e.Label, "DnsServiceRegister") { + t.Fatal(e.Label) + } + } + if strings.Contains(e.Label, "This network is Public") { + sawWarn = true + } + if e.Kind == "public" { + sawPublic = true + if !e.Enabled || e.Checked { + t.Fatalf("public entry %+v", e) + } + } + } + if !sawDiscovery || !sawWarn || !sawPublic { + t.Fatalf("discovery %v warn %v public %v", sawDiscovery, sawWarn, sawPublic) + } + if firewallProfile(false) != "private,domain" || firewallProfile(true) != "private,domain,public" { + t.Fatalf("profiles %q %q", firewallProfile(false), firewallProfile(true)) + } +} + +func TestAllowPublicRoundTrip(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + res, err := httpPost(t, s.ControlURL()+"api/v1/firewall", `{"allowPublic":true}`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(res, `"allowPublic": true`) { + t.Fatal(res) + } + body := getURL(t, s.ControlURL()+"api/v1/status") + if !strings.Contains(body, `"allowPublic": true`) { + t.Fatal(body) + } +} + +func swapAdvertiser(t *testing.T, fn func(context.Context, func() Service, mdnsSink) error) func() { + t.Helper() + prev := startAdvertiser + startAdvertiser = fn + return func() { startAdvertiser = prev } +} + +func waitMDNS(t *testing.T, s *Server, mode string) Status { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + var st Status + for time.Now().Before(deadline) { + st = s.status() + if st.MDNSMode == mode { + return st + } + time.Sleep(15 * time.Millisecond) + } + t.Fatalf("mdns mode %q, want %q (%+v)", st.MDNSMode, mode, st) + return st +} + +func getURL(t *testing.T, raw string) string { + t.Helper() + res, err := http.Get(raw) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("GET %s: %d %s", raw, res.StatusCode, b) + } + return string(b) +} + +func httpPost(t *testing.T, raw, body string) (string, error) { + t.Helper() + res, err := http.Post(raw, "application/json", strings.NewReader(body)) + if err != nil { + return "", err + } + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + if res.StatusCode >= 300 { + return "", fmt.Errorf("POST %s: %d %s", raw, res.StatusCode, b) + } + return string(b), nil +} diff --git a/internal/netprint/netprint_test.go b/internal/netprint/netprint_test.go index 6229746..145172a 100644 --- a/internal/netprint/netprint_test.go +++ b/internal/netprint/netprint_test.go @@ -348,6 +348,9 @@ func TestRasterRoundTripAndMenu(t *testing.T) { if e.Kind == "launch" && e.Enabled { t.Fatal("login toggle should wait until the service is up") } + if e.Kind == "public" && e.Enabled { + t.Fatal("public toggle should wait until the service is up") + } } if !startOn || stopOn { t.Fatalf("start %v stop %v", startOn, stopOn) diff --git a/internal/netprint/network_other.go b/internal/netprint/network_other.go new file mode 100644 index 0000000..b43a3cf --- /dev/null +++ b/internal/netprint/network_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package netprint + +func supportsPublicFirewall() bool { return false } + +func cachedNetworkPublic() bool { return false } diff --git a/internal/netprint/network_windows.go b/internal/netprint/network_windows.go new file mode 100644 index 0000000..26b5401 --- /dev/null +++ b/internal/netprint/network_windows.go @@ -0,0 +1,39 @@ +//go:build windows + +package netprint + +import ( + "os/exec" + "strings" + "sync" + "time" +) + +func supportsPublicFirewall() bool { return true } + +func networkIsPublic() bool { + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", + `(Get-NetConnectionProfile | ForEach-Object { $_.NetworkCategory }) -join ','`).Output() + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(out)), "public") +} + +var ( + netPublicMu sync.Mutex + netPublicAt time.Time + netPublicVal bool +) + +// cachedNetworkPublic avoids a PowerShell call on every tray poll. +func cachedNetworkPublic() bool { + netPublicMu.Lock() + defer netPublicMu.Unlock() + if !netPublicAt.IsZero() && time.Since(netPublicAt) < 20*time.Second { + return netPublicVal + } + netPublicVal = networkIsPublic() + netPublicAt = time.Now() + return netPublicVal +} diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go index 089af52..ed0a020 100644 --- a/internal/netprint/pipeline.go +++ b/internal/netprint/pipeline.go @@ -3,6 +3,7 @@ package netprint import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "strings" @@ -35,7 +36,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s if p != nil { p.SetState(id, jobProcessing, "job-printing", "") } + log.Printf("netprint: job %d started user=%q title=%q format=%s bytes=%d", id, user, title, format, len(doc)) fail := func(err error) { + log.Printf("netprint: job %d failed: %v", id, err) rec.State = "aborted" rec.Error = err.Error() rec.Completed = time.Now() @@ -99,6 +102,7 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s rec.State = "completed" rec.Completed = time.Now() _ = st.Put(rec) + log.Printf("netprint: job %d completed receipt=%s sha256=%s", id, rec.ReceiptID, rec.SHA256) if p != nil { p.SetState(id, jobCompleted, "job-completed-successfully", "") } diff --git a/internal/netprint/queue_windows.go b/internal/netprint/queue_windows.go new file mode 100644 index 0000000..78cde94 --- /dev/null +++ b/internal/netprint/queue_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package netprint + +import ( + "fmt" + "log" + "os/exec" + "strings" + "time" +) + +func runPowerShell(script string) error { + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + msg := strings.TrimSpace(string(out)) + if err != nil { + if msg != "" { + return fmt.Errorf("%w (%s)", err, msg) + } + return err + } + if msg != "" { + log.Printf("netprint: %s", msg) + } + return nil +} + +// waitBoundPort polls the control API until the service reports the IPP port +// it actually bound (631, or 8632 when 631 was taken). +func waitBoundPort(controlPort int) int { + c := NewClient(controlPort) + for i := 0; i < 40; i++ { + st, err := c.Status() + if err == nil && st.Port > 0 { + log.Printf("netprint: local queue will use port %d", st.Port) + return st.Port + } + time.Sleep(250 * time.Millisecond) + } + log.Printf("netprint: status port unavailable; local queue uses 631") + return 631 +} + +// addLocalQueue adds the Windows printer "Verae Timestamp" pointed at this +// machine's IPP listener. An existing queue with that name is left as-is. +func addLocalQueue(port int) error { + if port <= 0 { + port = 631 + } + script := fmt.Sprintf(`$name = 'Verae Timestamp'; if (Get-Printer -Name $name -ErrorAction SilentlyContinue) { 'local queue exists' } else { Add-Printer -IppURL 'http://127.0.0.1:%d/ipp/print' -Name $name; 'local queue added' }`, port) + return runPowerShell(script) +} + +func removeLocalQueue() error { + return runPowerShell(`Remove-Printer -Name 'Verae Timestamp' -ErrorAction SilentlyContinue; 'local queue removed'`) +} diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 98fa1de..dcc9ec4 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -30,10 +30,13 @@ type Server struct { ippSrv *http.Server controlSrv *http.Server mdnsCancel context.CancelFunc + mdns mdnsReport } // Run loads config, listens, and blocks until ctx is canceled. func Run(ctx context.Context, explicitPath string, flags Config, stamp StampFunc) error { + setupServiceLog() + log.Printf("netprint: starting") cfg, err := Load(explicitPath, flags) if err != nil { return err @@ -113,6 +116,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) ctrlMux.HandleFunc("/api/v1/service", s.handleService) ctrlMux.HandleFunc("/api/v1/launch", s.handleLaunch) ctrlMux.HandleFunc("/api/v1/tray", s.handleTray) + ctrlMux.HandleFunc("/api/v1/firewall", s.handleFirewall) ctrlMux.HandleFunc("/api/v1/settings", s.handleSettings) ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth) ctrlMux.HandleFunc("/settings", s.handleSettingsPage) @@ -139,8 +143,9 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) if cfg.MDNS() { ctx, cancel := context.WithCancel(context.Background()) s.mdnsCancel = cancel + s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) go func() { - err := Advertise(ctx, func() Service { + err := startAdvertiser(ctx, func() Service { c := s.snapshot() ips := lanIPv4() adminURL := s.printer.AdminURL @@ -151,11 +156,15 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) IPs: ips, TXT: AirPrintTXT(c.Name, uuid, adminURL, "", ""), } - }) + }, s.setMDNS) if err != nil && ctx.Err() == nil { log.Printf("netprint: mDNS not advertised: %v", err) + s.setMDNS(mdnsReport{Mode: "error", Err: err.Error(), Detail: err.Error()}) } }() + } else { + log.Printf("netprint: mDNS disabled") + s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) } log.Printf("netprint: IPP on %s, control on %s", ippLn.Addr(), ctlLn.Addr()) return s, nil @@ -167,6 +176,15 @@ func (s *Server) snapshot() Config { return s.cfg } +func (s *Server) setMDNS(r mdnsReport) { + s.mu.Lock() + s.mdns = r + s.mu.Unlock() + if r.Err != "" { + log.Printf("netprint: mDNS status: %s (%s)", r.Detail, r.Err) + } +} + // Port is the IPP port actually bound. func (s *Server) Port() int { return s.port } diff --git a/internal/netprint/service.go b/internal/netprint/service.go index d6a5854..eb58803 100644 --- a/internal/netprint/service.go +++ b/internal/netprint/service.go @@ -3,6 +3,7 @@ package netprint import ( "encoding/json" "fmt" + "log" "os" "os/exec" "path/filepath" @@ -132,6 +133,29 @@ func userHome(user string) string { return parts[5] } +// InstallOptions carries installer choices that are not the owner or token. +// AllowPublic nil leaves the saved firewall choice unchanged. +type InstallOptions struct { + AllowPublic *bool + LocalQueue bool +} + +func noteInstallOptions(cfgPath string, opts InstallOptions) { + if opts.AllowPublic == nil { + return + } + cfg, err := Load(cfgPath, Config{}) + if err != nil { + log.Printf("netprint: install options: %v", err) + return + } + v := *opts.AllowPublic + cfg.AllowPublic = &v + if err := Save(cfgPath, cfg); err != nil { + log.Printf("netprint: save allowPublic: %v", err) + } +} + // Executable is the verae-print binary the service should launch. func Executable() (string, error) { p, err := os.Executable() diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index 6d89ab4..4a688ec 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -136,7 +136,7 @@ func consoleUID() string { } // Install registers the LaunchDaemon and the menu-bar agent. -func Install(owner, tokenFile string) error { +func Install(owner, tokenFile string, opts InstallOptions) error { if _, err := addOwner(owner); err != nil { return err } @@ -153,6 +153,7 @@ func Install(owner, tokenFile string) error { return err } } + noteInstallOptions(cfgPath, opts) chosen := selectServiceBinary(exe, currentServiceExe()) if err := os.MkdirAll("/Library/LaunchDaemons", 0o755); err != nil { return err diff --git a/internal/netprint/service_linux.go b/internal/netprint/service_linux.go index ccea0d7..7ee7155 100644 --- a/internal/netprint/service_linux.go +++ b/internal/netprint/service_linux.go @@ -156,7 +156,7 @@ func systemctlDelayed(args ...string) error { } // Install registers this product as an owner and enables the shared service and tray. -func Install(owner, tokenFile string) error { +func Install(owner, tokenFile string, opts InstallOptions) error { if _, err := addOwner(owner); err != nil { return err } @@ -176,6 +176,7 @@ func Install(owner, tokenFile string) error { return err } } + noteInstallOptions(cfgPath, opts) uid := userID(serviceUser) if uid >= 0 { _ = os.Chown("/etc/verae", uid, uid) diff --git a/internal/netprint/service_log.go b/internal/netprint/service_log.go new file mode 100644 index 0000000..0bd9530 --- /dev/null +++ b/internal/netprint/service_log.go @@ -0,0 +1,39 @@ +package netprint + +import ( + "io" + "log" + "os" + "path/filepath" +) + +// setupServiceLog appends the service log under %ProgramData%\Verae\logs on Windows. +// VERAE_LOG_DIR overrides the directory (used by tests). +func setupServiceLog() { + dir := serviceLogDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Printf("netprint: log directory: %v", err) + return + } + _ = osChmodUsers(dir) + f, err := os.OpenFile(filepath.Join(dir, "netprint.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.Printf("netprint: log file: %v", err) + return + } + log.SetFlags(log.Ldate | log.Ltime) + log.SetOutput(io.MultiWriter(os.Stderr, f)) + log.Printf("netprint: log file %s", f.Name()) +} + +func serviceLogDir() string { + if d := os.Getenv("VERAE_LOG_DIR"); d != "" { + return d + } + return defaultServiceLogDir() +} + +func osChmodUsers(dir string) error { + // Users (S-1-5-32-545) can read the service log. Ignore failure when not elevated. + return execGrantUsers(dir) +} diff --git a/internal/netprint/service_log_other.go b/internal/netprint/service_log_other.go new file mode 100644 index 0000000..be035ce --- /dev/null +++ b/internal/netprint/service_log_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package netprint + +import ( + "os" + "path/filepath" +) + +func defaultServiceLogDir() string { + if os.Geteuid() == 0 { + return "/var/log/verae" + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), "verae-logs") + } + return filepath.Join(home, ".verae", "logs") +} + +func execGrantUsers(string) error { return nil } diff --git a/internal/netprint/service_log_windows.go b/internal/netprint/service_log_windows.go new file mode 100644 index 0000000..e893c5a --- /dev/null +++ b/internal/netprint/service_log_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package netprint + +import ( + "os/exec" + "path/filepath" +) + +func defaultServiceLogDir() string { + return filepath.Join(programData(), "Verae", "logs") +} + +func execGrantUsers(dir string) error { + return exec.Command("icacls", dir, "/grant", "*S-1-5-32-545:(OI)(CI)R", "/C").Run() +} diff --git a/internal/netprint/service_windows.go b/internal/netprint/service_windows.go index 22d22b8..24210d9 100644 --- a/internal/netprint/service_windows.go +++ b/internal/netprint/service_windows.go @@ -119,8 +119,9 @@ func peerBinary(owner string) string { return "" } -func ensureFirewall() { +func ensureFirewall(public bool) { removeFirewall() + profile := firewallProfile(public) rules := []struct{ name, proto, port string }{ {"Verae NetPrint IPP", "TCP", "631"}, {"Verae NetPrint IPP Fallback", "TCP", "8632"}, @@ -129,13 +130,17 @@ func ensureFirewall() { for _, r := range rules { out, err := exec.Command("netsh", "advfirewall", "firewall", "add", "rule", "name="+r.name, "dir=in", "action=allow", "protocol="+r.proto, - "localport="+r.port, "profile=private,domain").CombinedOutput() + "localport="+r.port, "profile="+profile).CombinedOutput() if err != nil { - log.Printf("netprint: firewall %s: %v (%s)", r.name, err, strings.TrimSpace(string(out))) + log.Printf("netprint: firewall %s profile %s: %v (%s)", r.name, profile, err, strings.TrimSpace(string(out))) + } else { + log.Printf("netprint: firewall %s profile %s", r.name, profile) } } } +func applyFirewall(public bool) { ensureFirewall(public) } + func removeFirewall() { for _, name := range []string{"Verae NetPrint IPP", "Verae NetPrint IPP Fallback", "Verae NetPrint mDNS"} { _ = exec.Command("netsh", "advfirewall", "firewall", "delete", "rule", "name="+name).Run() @@ -159,7 +164,7 @@ func ownerReg(name string) bool { } // Install registers the VeraeNetPrint service and the tray Run key. -func Install(owner, tokenFile string) error { +func Install(owner, tokenFile string, opts InstallOptions) error { if _, err := addOwner(owner); err != nil { return err } @@ -195,11 +200,28 @@ func Install(owner, tokenFile string) error { _ = exec.Command("sc.exe", "description", winService, "Verae network timestamp printer").Run() _ = exec.Command("sc.exe", "config", winService, "start=", "auto").Run() copyToken(tokenFile) - ensureFirewall() - if err := sc("start", winService); err != nil { - return err + noteInstallOptions(cfgPath, opts) + public := false + if opts.AllowPublic != nil { + public = *opts.AllowPublic + } else if c, err := Load(cfgPath, Config{}); err == nil { + public = c.PublicNetworks() } - return setTrayFor(chosen, true) + ensureFirewall(public) + startErr := sc("start", winService) + if opts.LocalQueue && startErr == nil { + port := waitBoundPort(DefaultControlPort) + if err := addLocalQueue(port); err != nil { + log.Printf("netprint: local printer queue: %v", err) + } + } + if err := setTrayFor(chosen, true); err != nil { + log.Printf("netprint: tray autostart: %v", err) + } + if err := LaunchTrayNow(); err != nil { + log.Printf("netprint: tray launch: %v", err) + } + return startErr } // Uninstall removes the service only when neither installer still owns it. @@ -226,5 +248,8 @@ func Uninstall(owner string) error { _ = exec.Command("sc.exe", "delete", winService).Run() _ = setTrayFor("", false) removeFirewall() + if err := removeLocalQueue(); err != nil { + log.Printf("netprint: remove local queue: %v", err) + } return nil } diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go index 9b347f0..8b45a0a 100644 --- a/internal/netprint/tray_menu.go +++ b/internal/netprint/tray_menu.go @@ -12,6 +12,7 @@ const ( idRestart = 5 idLaunch = 7 idTray = 8 + idPublic = 9 idReceipts = 10 idSettings = 11 idQuit = 100 @@ -54,6 +55,12 @@ func BuildMenu(st Status) []MenuEntry { var out []MenuEntry out = append(out, MenuEntry{ID: idStatus, Label: fmt.Sprintf("%s — %s · %d jobs today", state, where, st.JobsToday), Kind: "status"}, + MenuEntry{Label: discoveryLine(st), Kind: "status"}, + ) + if st.NetworkPublic && !st.AllowPublic { + out = append(out, MenuEntry{Label: "This network is Public. Other devices cannot reach the printer until you allow Public networks.", Kind: "status"}) + } + out = append(out, MenuEntry{Separator: true}, MenuEntry{ID: idStart, Label: "Start", Enabled: !st.Running, Kind: "start"}, MenuEntry{ID: idStop, Label: "Stop", Enabled: st.Running, Kind: "stop"}, @@ -61,7 +68,11 @@ func BuildMenu(st Status) []MenuEntry { MenuEntry{Separator: true}, MenuEntry{ID: idLaunch, Label: "Launch printer at login", Enabled: st.Running, Checked: st.LaunchAtLogin, Kind: "launch"}, MenuEntry{ID: idTray, Label: "Launch this menu at login", Enabled: st.Running, Checked: st.TrayAtLogin, Kind: "tray"}, - MenuEntry{Separator: true}, + ) + if st.SupportsPublic { + out = append(out, MenuEntry{ID: idPublic, Label: "Allow Public networks", Enabled: st.Running, Checked: st.AllowPublic, Kind: "public"}) + } + out = append(out, MenuEntry{Separator: true}, MenuEntry{ID: idReceipts, Label: "Open receipts", Enabled: st.Running && st.AdminURL != "", URL: firstURL(st), Kind: "open"}, ) for i, rec := range st.Recent { @@ -89,6 +100,42 @@ func BuildMenu(st Status) []MenuEntry { return out } +func discoveryLine(st Status) string { + if !st.Running { + return "Discovery — service stopped" + } + switch st.MDNSMode { + case "dns-sd": + if st.MDNSAdvertising { + return "Discovery — advertised (Windows DNS-SD)" + } + case "udp": + if st.MDNSAdvertising { + return "Discovery — advertised (UDP 5353)" + } + case "off": + return "Discovery — off" + case "starting": + return "Discovery — starting" + case "error": + return "Discovery — not advertising: " + clipMenu(st.MDNSError, 80) + } + if st.MDNSError != "" { + return "Discovery — " + clipMenu(st.MDNSError, 80) + } + if st.MDNSDetail != "" { + return "Discovery — " + clipMenu(st.MDNSDetail, 80) + } + return "Discovery — waiting" +} + +func clipMenu(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} + func firstURL(st Status) string { if u := strings.TrimRight(st.ControlURL, "/"); u != "" { return u + "/" @@ -129,6 +176,8 @@ func Dispatch(st Status, e MenuEntry) error { return c.SetLaunchAtLogin(!st.LaunchAtLogin) case "tray": return c.SetTrayAtLogin(!st.TrayAtLogin) + case "public": + return c.SetAllowPublic(!st.AllowPublic) case "open": return OpenURL(e.URL) case "quit": diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi index 5992e02..4bd61f7 100644 --- a/platform/windows/installer-netprint.nsi +++ b/platform/windows/installer-netprint.nsi @@ -29,13 +29,19 @@ SetCompressor /SOLID lzma ShowInstDetails show !include "MUI2.nsh" +!include "nsDialogs.nsh" !include "x64.nsh" !include "LogicLib.nsh" +!include "FileFunc.nsh" +!include "WinMessages.nsh" +!insertmacro GetParameters +!insertmacro GetOptions !define MUI_ABORTWARNING !insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_LICENSE "${SRCDIR}\LICENSE.txt" !insertmacro MUI_PAGE_DIRECTORY +Page custom NetOptionsCreate NetOptionsLeave !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH !insertmacro MUI_UNPAGE_CONFIRM @@ -44,6 +50,11 @@ ShowInstDetails show Var LogFile Var LogDir +Var AllowPublic +Var LocalQueue +Var hPublic +Var hQueue +Var FwProfile Function .onInit ${IfNot} ${RunningX64} @@ -53,6 +64,23 @@ Function .onInit SetRegView 64 SetShellVarContext all Call SetupLogPath + StrCpy $AllowPublic "0" + StrCpy $LocalQueue "0" + ${GetParameters} $R0 + ClearErrors + ${GetOptions} $R0 "/PUBLIC=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $AllowPublic "1" + ${EndIf} + ${EndIf} + ClearErrors + ${GetOptions} $R0 "/QUEUE=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $LocalQueue "1" + ${EndIf} + ${EndIf} FunctionEnd Function un.onInit @@ -80,22 +108,55 @@ Function un.SetupLogPath FunctionEnd ; Inbound TCP 631 (IPP), TCP 8632 (fallback; 8631 is the driver helper), UDP 5353 (mDNS). -; Private and domain profiles only. A macro so the uninstaller can run the same commands. +; $FwProfile is private,domain unless the installer opted into Public. +; A macro so the uninstaller can run the same commands. !macro NETPRINT_FIREWALL_ADD nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=private,domain' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=private,domain' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=private,domain' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=$FwProfile' Pop $0 !macroend +Function NetOptionsCreate + !insertmacro MUI_HEADER_TEXT "Network options" "Both stay off unless you turn them on." + nsDialogs::Create 1018 + Pop $0 + ${NSD_CreateCheckbox} 0 0 100% 28u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." + Pop $hPublic + ${NSD_CreateCheckbox} 0 36u 100% 28u "Add a local Windows printer named &Verae Timestamp (off by default).$\r$\nPrints to this computer through http://127.0.0.1 on the port the service binds." + Pop $hQueue + ${If} $AllowPublic == "1" + ${NSD_Check} $hPublic + ${EndIf} + ${If} $LocalQueue == "1" + ${NSD_Check} $hQueue + ${EndIf} + nsDialogs::Show +FunctionEnd + +Function NetOptionsLeave + ${NSD_GetState} $hPublic $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $AllowPublic "1" + ${Else} + StrCpy $AllowPublic "0" + ${EndIf} + ${NSD_GetState} $hQueue $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $LocalQueue "1" + ${Else} + StrCpy $LocalQueue "0" + ${EndIf} +FunctionEnd + !macro NETPRINT_FIREWALL_REMOVE nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' Pop $0 @@ -119,16 +180,29 @@ Section "Verae Network Printer" SecMain WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "Verae" CreateDirectory "$SMPROGRAMS\Verae" CreateShortCut "$SMPROGRAMS\Verae\Verae Network Printer.lnk" "$INSTDIR\bin\verae-netprint-tray.exe" + StrCpy $FwProfile "private,domain" + StrCpy $R8 "false" + ${If} $AllowPublic == "1" + StrCpy $FwProfile "private,domain,public" + StrCpy $R8 "true" + ${EndIf} !insertmacro NETPRINT_FIREWALL_ADD CreateDirectory "$LogDir" - nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint install --owner standalone' + StrCpy $R9 '"$INSTDIR\bin\verae-print.exe" netprint install --owner standalone --allow-public=$R8' + ${If} $LocalQueue == "1" + StrCpy $R9 '$R9 --local-queue' + ${EndIf} + nsExec::ExecToStack $R9 Pop $0 Pop $1 + nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint launch-tray' + Pop $3 + Pop $4 ClearErrors FileOpen $2 "$LogFile" a ${IfNot} ${Errors} FileSeek $2 0 END - FileWrite $2 "netprint install exit $0$\r$\n$1$\r$\n" + FileWrite $2 "netprint install allow-public=$R8 local-queue=$LocalQueue exit $0$\r$\n$1$\r$\nlaunch-tray exit $3$\r$\n$4$\r$\n" FileClose $2 ${EndIf} ${If} $0 != 0 diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index 319f26f..d6e6281 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -40,6 +40,8 @@ BrandingText "Verae TimeStamp PDF ${VERSION}" !include "LogicLib.nsh" !include "FileFunc.nsh" !include "WinMessages.nsh" +!insertmacro GetParameters +!insertmacro GetOptions Var LogDir Var LogFile @@ -79,6 +81,11 @@ Var hPromptChk Var PromptMode ; true|false (promptForFilename) Var hNetChk Var NetPrint ; 1 when the optional LAN printer is selected +Var hNetPublic +Var hNetQueue +Var NetPublic ; 1 to also allow Public networks (off by default) +Var NetQueue ; 1 to add the local "Verae Timestamp" queue (off by default) +Var FwProfile Var TargetProfile ; results Var FinishTitle @@ -100,6 +107,7 @@ Page custom CreatePageCreate CreatePageLeave Page custom VerifyPageCreate VerifyPageLeave Page custom PastePageCreate PastePageLeave Page custom OptionsPageCreate OptionsPageLeave +Page custom NetExtraCreate NetExtraLeave !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH !insertmacro MUI_UNPAGE_CONFIRM @@ -161,6 +169,23 @@ Function .onInit File "${SRCDIR}\scripts\user-token.ps1" StrCpy $Exe "$PLUGINSDIR\verae-print.exe" StrCpy $NetPrint "0" + StrCpy $NetPublic "0" + StrCpy $NetQueue "0" + ${GetParameters} $R0 + ClearErrors + ${GetOptions} $R0 "/PUBLIC=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $NetPublic "1" + ${EndIf} + ${EndIf} + ClearErrors + ${GetOptions} $R0 "/QUEUE=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $NetQueue "1" + ${EndIf} + ${EndIf} StrCpy $Checked 0 StrCpy $TokenReady 0 StrCpy $KeyChoice "skip" @@ -601,6 +626,42 @@ Function OptionsPageLeave ${EndIf} FunctionEnd +; Shown only when the network printer option is checked. Both boxes stay off by default. +Function NetExtraCreate + ${If} $NetPrint != "1" + Abort + ${EndIf} + !insertmacro MUI_HEADER_TEXT "Network printer" "Optional. Both stay off unless you turn them on." + nsDialogs::Create 1018 + Pop $Dlg + ${NSD_CreateCheckbox} 0 0 100% 28u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." + Pop $hNetPublic + ${NSD_CreateCheckbox} 0 36u 100% 28u "Add a local Windows printer named &Verae Timestamp (off by default)." + Pop $hNetQueue + ${If} $NetPublic == "1" + ${NSD_Check} $hNetPublic + ${EndIf} + ${If} $NetQueue == "1" + ${NSD_Check} $hNetQueue + ${EndIf} + nsDialogs::Show +FunctionEnd + +Function NetExtraLeave + ${NSD_GetState} $hNetPublic $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetPublic "1" + ${Else} + StrCpy $NetPublic "0" + ${EndIf} + ${NSD_GetState} $hNetQueue $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetQueue "1" + ${Else} + StrCpy $NetQueue "0" + ${EndIf} +FunctionEnd + ; ======================= Install ======================= !macro STEP_RESULT label value StrCpy $FinishText "$FinishText${label}: ${value}$\r$\n" @@ -716,28 +777,42 @@ Section "Verae Print Stamp + printer" SecMain SetOutPath "$INSTDIR\bin" File "${SRCDIR}\verae-netprint-tray.exe" DetailPrint "Installing the Verae network printer (service + tray)" - ; TCP 631, TCP 8632 (not the helper's 8631), UDP 5353. Private and domain only. + ; TCP 631, TCP 8632 (not the helper's 8631), UDP 5353. + ; Private and domain unless the Public checkbox was turned on. + StrCpy $FwProfile "private,domain" + StrCpy $R8 "false" + ${If} $NetPublic == "1" + StrCpy $FwProfile "private,domain,public" + StrCpy $R8 "true" + ${EndIf} nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=private,domain' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=private,domain' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=private,domain' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=$FwProfile' Pop $0 CreateDirectory "$LogDir" - nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver' + StrCpy $R9 '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver --allow-public=$R8' + ${If} $NetQueue == "1" + StrCpy $R9 '$R9 --local-queue' + ${EndIf} + nsExec::ExecToStack $R9 Pop $0 Pop $1 + nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint launch-tray' + Pop $3 + Pop $4 ClearErrors FileOpen $2 "$LogDir\netprint-installer.log" a ${IfNot} ${Errors} FileSeek $2 0 END - FileWrite $2 "driver netprint install exit $0$\r$\n$1$\r$\n" + FileWrite $2 "driver netprint install allow-public=$R8 local-queue=$NetQueue exit $0$\r$\n$1$\r$\nlaunch-tray exit $3$\r$\n$4$\r$\n" FileClose $2 ${EndIf} ${If} $0 == 0 diff --git a/site/printit.verae.ai/index.html b/site/printit.verae.ai/index.html index 9a3568f..ab5c60d 100644 --- a/site/printit.verae.ai/index.html +++ b/site/printit.verae.ai/index.html @@ -193,7 +193,7 @@

Network printer

-

Optional LAN queue “Verae Timestamp” (IPP Everywhere) plus a tray or menu-bar control. Standalone installers, or the off-by-default option inside the driver installers. One service either way. The Windows standalone setup uses C:\Program Files\Verae\NetPrint and does not replace the driver. If port 631 is taken, the printer uses 8632 (the driver helper keeps 8631). Version 0.3.4.

+

Optional LAN queue “Verae Timestamp” (IPP Everywhere) plus a tray or menu-bar control. Standalone installers, or the off-by-default option inside the driver installers. One service either way. On Windows, discovery goes through DNS-SD so it still works when the DNS Client owns UDP 5353, and the setup starts the tray for the signed-in user. Public networks and a local Windows queue stay off unless you opt in. The Windows standalone setup uses C:\Program Files\Verae\NetPrint and does not replace the driver. If port 631 is taken, the printer uses 8632 (the driver helper keeps 8631). Version 0.3.4.

Debian amd64 From 0808cdd781edd453e05c4817a5102ff1c8e3774d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 22:00:24 +0000 Subject: [PATCH 12/27] Stop the network printer before replacing its binary on upgrade. A silent Windows setup copied verae-print.exe while VeraeNetPrint and the tray still had the files open, skipped the locked copies, and left the old binary to reject the new flags. launch-tray on that binary then started a second server. Stop and wait, then copy, and abort if a file is still locked. macOS bootouts the LaunchDaemon before the payload is copied, and Debian stops the unit from preinst. launch-tray only starts the tray. Co-authored-by: George Lambert --- cmd/verae-print/installer_cmds_test.go | 14 ++++ cmd/verae-print/netprint_cmd.go | 13 +++- docs/network-printer.md | 4 +- internal/netprint/launch_tray_windows.go | 3 + platform/linux/deb/build-deb.sh | 20 ++++++ platform/linux/deb/build-netprint-deb.sh | 21 ++++++ platform/macos/netprint/bootout.sh | 15 ++++ platform/macos/pkg/build-netprint-pkg.sh | 13 +++- platform/macos/pkg/build-pkg.sh | 17 ++++- platform/macos/pkg/scripts/postinstall | 11 +++ platform/windows/installer-netprint.nsi | 23 ++++-- platform/windows/installer-windows.nsi | 50 ++++++++++--- platform/windows/netprint-upgrade.nsh | 91 ++++++++++++++++++++++++ 13 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 platform/macos/netprint/bootout.sh create mode 100644 platform/windows/netprint-upgrade.nsh diff --git a/cmd/verae-print/installer_cmds_test.go b/cmd/verae-print/installer_cmds_test.go index d776c2a..fb1fb44 100644 --- a/cmd/verae-print/installer_cmds_test.go +++ b/cmd/verae-print/installer_cmds_test.go @@ -3,10 +3,24 @@ package main import ( "os" "path/filepath" + "runtime" "testing" "unicode/utf16" ) +func TestNetprintUnknownCommandDoesNotStartServer(t *testing.T) { + if code := runNetprint([]string{"launch-server"}); code != 2 { + t.Fatalf("unknown command exit %d, want 2", code) + } + if runtime.GOOS == "windows" { + return + } + // Linux has no tray launcher. The command must fail before any listen. + if code := runNetprint([]string{"launch-tray"}); code != 1 { + t.Fatalf("launch-tray exit %d, want 1", code) + } +} + func TestReadTextFileUTF16(t *testing.T) { s := "Email=a@b.co\r\nPassword=p=\"w\\x\r\n" u := utf16.Encode([]rune(s)) diff --git a/cmd/verae-print/netprint_cmd.go b/cmd/verae-print/netprint_cmd.go index 2796737..00a5586 100644 --- a/cmd/verae-print/netprint_cmd.go +++ b/cmd/verae-print/netprint_cmd.go @@ -17,8 +17,17 @@ import ( ) func runNetprint(args []string) int { - if len(args) > 0 && (args[0] == "install" || args[0] == "uninstall" || args[0] == "launch-tray") { - return runNetprintInstall(args) + if len(args) > 0 { + switch args[0] { + case "install", "uninstall", "launch-tray": + return runNetprintInstall(args) + } + // An unknown word must not fall through into the server. That is how + // `netprint launch-tray` on an older binary started a second listener. + if !strings.HasPrefix(args[0], "-") { + fmt.Fprintf(os.Stderr, "unknown netprint command %q (not starting the server)\n", args[0]) + return 2 + } } fs := flag.NewFlagSet("netprint", flag.ContinueOnError) config := fs.String("config", "", "config file (default: system netprint.json)") diff --git a/docs/network-printer.md b/docs/network-printer.md index 0212321..09d071f 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -41,7 +41,9 @@ On macOS, the Application Firewall is per application (System Settings → Netwo The control API listens only on `127.0.0.1:8630`. -A silent Windows install (`/S`) does not wait on a dialog. If the service does not start, the message is written to `%ProgramData%\Verae\logs\netprint-installer.log`. The service itself appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log`. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. +A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint launch-tray` only starts the tray; an unknown `netprint` subcommand does not start a second server. The service itself appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log`. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. + +macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary. ## Troubleshooting diff --git a/internal/netprint/launch_tray_windows.go b/internal/netprint/launch_tray_windows.go index dfda59e..637996e 100644 --- a/internal/netprint/launch_tray_windows.go +++ b/internal/netprint/launch_tray_windows.go @@ -52,8 +52,11 @@ func trayCommandLine() (string, error) { } sibling := filepath.Join(filepath.Dir(exe), "verae-netprint-tray.exe") if st, err := os.Stat(sibling); err == nil && !st.IsDir() { + // The tray binary's main is RunTray. It has no path that listens. return `"` + sibling + `"`, nil } + // `verae-print.exe` with no arguments prints usage. The tray subcommand + // is required so this never starts `netprint` / the IPP server. return `"` + exe + `" tray`, nil } diff --git a/platform/linux/deb/build-deb.sh b/platform/linux/deb/build-deb.sh index bb2224c..2f488cc 100755 --- a/platform/linux/deb/build-deb.sh +++ b/platform/linux/deb/build-deb.sh @@ -152,6 +152,26 @@ db_go || true EOF chmod 755 "$stage/DEBIAN/config" + cat >"$stage/DEBIAN/preinst" <<'EOF' +#!/bin/sh +set -e +# Stop the network printer before unpack when it is running this package's +# binary. A service pointed at /usr/lib/verae-netprint is left alone. +if [ "$1" = "upgrade" ] || [ "$1" = "install" ]; then + unit=/usr/lib/systemd/system/verae-netprint.service + if [ -f "$unit" ] && grep -q '/usr/lib/verae-print/verae-print' "$unit"; then + if command -v systemctl >/dev/null 2>&1; then + systemctl stop verae-netprint.service >/dev/null 2>&1 || true + fi + fi + if [ -x /usr/lib/verae-print/verae-print ]; then + pkill -f '/usr/lib/verae-print/verae-print tray' >/dev/null 2>&1 || true + fi +fi +exit 0 +EOF + chmod 755 "$stage/DEBIAN/preinst" + cat >"$stage/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e diff --git a/platform/linux/deb/build-netprint-deb.sh b/platform/linux/deb/build-netprint-deb.sh index f62fd7f..223e043 100755 --- a/platform/linux/deb/build-netprint-deb.sh +++ b/platform/linux/deb/build-netprint-deb.sh @@ -67,6 +67,27 @@ Description: Verae network timestamp printer (IPP Everywhere) the driver binary. EOF +cat >"$stage/DEBIAN/preinst" <<'EOF' +#!/bin/sh +set -e +# dpkg unpacks the new binary after preinst. Stop the service first when it +# is running this package's binary, or the upgrade hits "Text file busy". +if [ "$1" = "upgrade" ] || [ "$1" = "install" ]; then + unit=/usr/lib/systemd/system/verae-netprint.service + if [ -f "$unit" ] && grep -q '/usr/lib/verae-netprint/verae-print' "$unit"; then + if command -v systemctl >/dev/null 2>&1; then + systemctl stop verae-netprint.service >/dev/null 2>&1 || true + fi + fi + if [ -x /usr/lib/verae-netprint/verae-print ]; then + # The tray is the same binary, started from the autostart desktop file. + pkill -f '/usr/lib/verae-netprint/verae-print tray' >/dev/null 2>&1 || true + fi +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/preinst" + cat >"$stage/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e diff --git a/platform/macos/netprint/bootout.sh b/platform/macos/netprint/bootout.sh new file mode 100644 index 0000000..b3de0ce --- /dev/null +++ b/platform/macos/netprint/bootout.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Unload the network printer and its menu-bar agent so an upgrade can +# replace the binary. launchd KeepAlive would otherwise hold the file open. +bootout_netprint() { + launchctl bootout system/com.verae.netprint 2>/dev/null || true + local user uid + user=$(stat -f%Su /dev/console 2>/dev/null || true) + if [[ -n "$user" && "$user" != "root" && "$user" != "loginwindow" ]]; then + uid=$(id -u "$user" 2>/dev/null || true) + if [[ -n "$uid" ]]; then + launchctl bootout "gui/${uid}/com.verae.netprint.tray" 2>/dev/null || true + fi + fi + sleep 1 +} diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 1c42280..44e79c1 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -24,12 +24,23 @@ cp -f "$BIN" "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" +# preinstall runs before the payload replaces verae-print. postinstall +# bootstraps the service again. +cp -f "$ROOT/platform/macos/netprint/bootout.sh" "$SCRIPTS/bootout.sh" +cat >"$SCRIPTS/preinstall" <<'EOF' +#!/bin/bash +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +source "$HERE/bootout.sh" +bootout_netprint +exit 0 +EOF cat >"$SCRIPTS/postinstall" <<'EOF' #!/bin/bash export VERAE_NETPRINT_OWNER=standalone exec "/Library/Application Support/Verae/NetPrint/bin/verae-print" netprint install --owner standalone EOF -chmod 755 "$SCRIPTS/postinstall" +chmod 755 "$SCRIPTS/preinstall" "$SCRIPTS/postinstall" "$SCRIPTS/bootout.sh" if ! command -v pkgbuild >/dev/null 2>&1; then echo "pkgbuild not available — payload and scripts are in $OUT" diff --git a/platform/macos/pkg/build-pkg.sh b/platform/macos/pkg/build-pkg.sh index 119ff34..587b3f9 100755 --- a/platform/macos/pkg/build-pkg.sh +++ b/platform/macos/pkg/build-pkg.sh @@ -124,7 +124,22 @@ codesign --force --deep --sign "$ID_APP" --options runtime --timestamp \ echo "→ Preparing pkg scripts" cp -f "$PKG_DIR/scripts/postinstall" "$SCRIPTS/postinstall" -chmod 755 "$SCRIPTS/postinstall" +cp -f "$ROOT/platform/macos/netprint/bootout.sh" "$SCRIPTS/bootout.sh" +cat >"$SCRIPTS/preinstall" <<'EOF' +#!/bin/bash +# Only unload the printer when it is running the binary this package is +# about to replace. A standalone install under NetPrint is left running. +plist="/Library/LaunchDaemons/com.verae.netprint.plist" +if [[ -f "$plist" ]] && grep -q "PrintStamp/bin/verae-print" "$plist"; then + HERE="$(cd "$(dirname "$0")" && pwd)" + # shellcheck source=/dev/null + source "$HERE/bootout.sh" + bootout_netprint + touch /var/tmp/verae-netprint-stopped-for-upgrade +fi +exit 0 +EOF +chmod 755 "$SCRIPTS/preinstall" "$SCRIPTS/postinstall" "$SCRIPTS/bootout.sh" # Optional network-printer component. Off unless the user selects it in # Customize. It calls the same install command as the standalone package. diff --git a/platform/macos/pkg/scripts/postinstall b/platform/macos/pkg/scripts/postinstall index e1a1f7a..33caa45 100755 --- a/platform/macos/pkg/scripts/postinstall +++ b/platform/macos/pkg/scripts/postinstall @@ -78,5 +78,16 @@ if [[ -n "${CONSOLE_USER:-}" && "$CONSOLE_USER" != "root" && -n "${HOME_DIR:-}" fi fi +# The preinstall script unloaded the network printer so this package could +# replace its binary. Bring that job back. The optional NetPrint component +# bootstraps again when it is selected. +if [[ -f /var/tmp/verae-netprint-stopped-for-upgrade ]]; then + rm -f /var/tmp/verae-netprint-stopped-for-upgrade + if [[ -f /Library/LaunchDaemons/com.verae.netprint.plist ]]; then + launchctl bootstrap system /Library/LaunchDaemons/com.verae.netprint.plist \ + || echo "netprint bootstrap exited $? (component install may load it)" + fi +fi + echo "$(date -u +%Y-%m-%dT%H:%MZ) postinstall done" exit 0 diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi index 4bd61f7..18e073d 100644 --- a/platform/windows/installer-netprint.nsi +++ b/platform/windows/installer-netprint.nsi @@ -34,6 +34,7 @@ ShowInstDetails show !include "LogicLib.nsh" !include "FileFunc.nsh" !include "WinMessages.nsh" +!include "netprint-upgrade.nsh" !insertmacro GetParameters !insertmacro GetOptions @@ -63,6 +64,7 @@ Function .onInit ${EndIf} SetRegView 64 SetShellVarContext all + AllowSkipFiles off Call SetupLogPath StrCpy $AllowPublic "0" StrCpy $LocalQueue "0" @@ -86,6 +88,7 @@ FunctionEnd Function un.onInit SetRegView 64 SetShellVarContext all + AllowSkipFiles off Call un.SetupLogPath FunctionEnd @@ -168,11 +171,17 @@ FunctionEnd Section "Verae Network Printer" SecMain SectionIn RO + ; The service and tray keep the installed exes locked. Copying first + ; skips those files under /S and leaves the old binary in place. + !insertmacro NETPRINT_STOP_SERVICE + !insertmacro NETPRINT_KILL_TRAY + SetOverwrite on + AllowSkipFiles off SetOutPath "$INSTDIR\bin" - File "${SRCDIR}\verae-print.exe" - File "${SRCDIR}\verae-netprint-tray.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-print.exe" "verae-print.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-netprint-tray.exe" "verae-netprint-tray.exe" SetOutPath "$INSTDIR" - File "${SRCDIR}\LICENSE.txt" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\LICENSE.txt" "LICENSE.txt" WriteUninstaller "$INSTDIR\Uninstall-NetPrint.exe" WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}" WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall-NetPrint.exe"' @@ -211,6 +220,8 @@ Section "Verae Network Printer" SecMain SectionEnd Section "Uninstall" + !insertmacro NETPRINT_STOP_SERVICE + !insertmacro NETPRINT_KILL_TRAY nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner standalone' Pop $0 Pop $1 @@ -229,12 +240,10 @@ Section "Uninstall" ${EndIf} ClearErrors ${If} $R1 != 1 - nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' - Pop $0 !insertmacro NETPRINT_FIREWALL_REMOVE ${EndIf} - Delete "$INSTDIR\bin\verae-print.exe" - Delete "$INSTDIR\bin\verae-netprint-tray.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-print.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-netprint-tray.exe" RMDir "$INSTDIR\bin" Delete "$INSTDIR\LICENSE.txt" Delete "$INSTDIR\Uninstall-NetPrint.exe" diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index d6e6281..d21f9f3 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -40,6 +40,9 @@ BrandingText "Verae TimeStamp PDF ${VERSION}" !include "LogicLib.nsh" !include "FileFunc.nsh" !include "WinMessages.nsh" +!include "netprint-upgrade.nsh" +!include "StrFunc.nsh" +${Using:StrFunc} StrStr !insertmacro GetParameters !insertmacro GetOptions @@ -85,6 +88,7 @@ Var hNetPublic Var hNetQueue Var NetPublic ; 1 to also allow Public networks (off by default) Var NetQueue ; 1 to add the local "Verae Timestamp" queue (off by default) +Var NetStopped ; 1 when this upgrade stopped VeraeNetPrint to unlock verae-print.exe Var FwProfile Var TargetProfile ; results @@ -156,6 +160,7 @@ Function .onInit ${EndIf} SetRegView 64 SetShellVarContext all + AllowSkipFiles off ReadEnvStr $LogDir "ProgramData" StrCpy $LogDir "$LogDir\Verae\logs" CreateDirectory $LogDir @@ -171,6 +176,7 @@ Function .onInit StrCpy $NetPrint "0" StrCpy $NetPublic "0" StrCpy $NetQueue "0" + StrCpy $NetStopped "0" ${GetParameters} $R0 ClearErrors ${GetOptions} $R0 "/PUBLIC=" $R1 @@ -196,6 +202,13 @@ FunctionEnd Function un.onInit SetRegView 64 SetShellVarContext all + AllowSkipFiles off + ReadEnvStr $LogDir "ProgramData" + ${If} $LogDir == "" + StrCpy $LogDir "C:\ProgramData" + ${EndIf} + StrCpy $LogDir "$LogDir\Verae\logs" + StrCpy $LogFile "$LogDir\netprint-installer.log" FunctionEnd ; ======================= Page: Verae key status / choice ======================= @@ -675,6 +688,9 @@ Section "Verae Print Stamp + printer" SecMain Pop $0 nsExec::Exec 'taskkill /F /IM verae-printhelper.exe' Pop $0 + ; VeraeNetPrint may be running this folder's verae-print.exe. Stop it + ; before the File copy, or a locked exe is skipped and the old binary stays. + !insertmacro NETPRINT_STOP_IF_OURS ; replace a previous DEBUG test build (0.3.0-debug) cleanly DeleteRegKey HKLM "${OLD_DEBUG_KEY}" Delete "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp (debug).lnk" @@ -682,9 +698,11 @@ Section "Verae Print Stamp + printer" SecMain StrCpy $R9 "OK" ClearErrors + SetOverwrite on + AllowSkipFiles off SetOutPath "$INSTDIR\bin" - File "${SRCDIR}\verae-print.exe" - File "${SRCDIR}\verae-printhelper.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-print.exe" "verae-print.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-printhelper.exe" "verae-printhelper.exe" SetOutPath "$INSTDIR\scripts" File "${SRCDIR}\scripts\common.ps1" File "${SRCDIR}\scripts\user-token.ps1" @@ -700,6 +718,16 @@ Section "Verae Print Stamp + printer" SecMain ${EndIf} ${If} $R9 != "OK" ${LOG} "ERROR could not write all program files to $INSTDIR" + nsExec::Exec 'sc start VeraeNetPrint' + Pop $0 + MessageBox MB_ICONSTOP "Could not write all program files to $INSTDIR.$\r$\nDetails: $LogFile" /SD IDOK + Abort + ${EndIf} + ${If} $NetStopped == "1" + ${AndIf} $NetPrint != "1" + nsExec::Exec 'sc start VeraeNetPrint' + Pop $0 + !insertmacro NETPRINT_LOG "restarted VeraeNetPrint after replacing verae-print.exe (exit $0)" ${EndIf} WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}" WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall.exe"' @@ -774,8 +802,14 @@ Section "Verae Print Stamp + printer" SecMain ${EndIf} ${If} $NetPrint == "1" + !insertmacro NETPRINT_KILL_TRAY + ${If} $NetStopped != "1" + !insertmacro NETPRINT_STOP_IF_OURS + ${EndIf} + SetOverwrite on + AllowSkipFiles off SetOutPath "$INSTDIR\bin" - File "${SRCDIR}\verae-netprint-tray.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-netprint-tray.exe" "verae-netprint-tray.exe" DetailPrint "Installing the Verae network printer (service + tray)" ; TCP 631, TCP 8632 (not the helper's 8631), UDP 5353. ; Private and domain unless the Public checkbox was turned on. @@ -837,6 +871,8 @@ Section "Verae Print Stamp + printer" SecMain SectionEnd Section "Uninstall" + !insertmacro NETPRINT_STOP_SERVICE + !insertmacro NETPRINT_KILL_TRAY nsExec::Exec '"$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "$INSTDIR\scripts\uninstall-printer.ps1"' Pop $0 nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner driver' @@ -852,8 +888,6 @@ Section "Uninstall" ${EndIf} ClearErrors ${If} $R1 != 1 - nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' - Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' @@ -861,9 +895,9 @@ Section "Uninstall" nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' Pop $0 ${EndIf} - Delete "$INSTDIR\bin\verae-print.exe" - Delete "$INSTDIR\bin\verae-printhelper.exe" - Delete "$INSTDIR\bin\verae-netprint-tray.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-print.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-printhelper.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-netprint-tray.exe" RMDir "$INSTDIR\bin" Delete "$INSTDIR\scripts\*.ps1" RMDir "$INSTDIR\scripts" diff --git a/platform/windows/netprint-upgrade.nsh b/platform/windows/netprint-upgrade.nsh new file mode 100644 index 0000000..8deec27 --- /dev/null +++ b/platform/windows/netprint-upgrade.nsh @@ -0,0 +1,91 @@ +; Stop VeraeNetPrint and the tray before replacing their binaries, and refuse +; to finish an install that left the old exe in place. A silent /S install +; otherwise skips a locked file and exits 0. + +!macro NETPRINT_LOG line + CreateDirectory "$LogDir" + ClearErrors + FileOpen $2 "$LogFile" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "${line}$\r$\n" + FileClose $2 + ${EndIf} +!macroend + +; Poll until sc reports STOPPED, or the service does not exist (1060). +; Clobbers $R6 and $0. +!macro NETPRINT_STOP_SERVICE + DetailPrint "Stopping VeraeNetPrint" + nsExec::Exec 'sc stop VeraeNetPrint' + Pop $0 + StrCpy $R6 0 + ${Do} + IntOp $R6 $R6 + 1 + nsExec::Exec 'cmd /c "sc query VeraeNetPrint > %TEMP%\verae-netprint-sc.txt 2>&1 & findstr /C:STOPPED /C:1060 %TEMP%\verae-netprint-sc.txt >NUL"' + Pop $0 + ${If} $0 == 0 + ${Break} + ${EndIf} + ${If} $R6 >= 40 + !insertmacro NETPRINT_LOG "WARN VeraeNetPrint was still running 20s after sc stop" + ${Break} + ${EndIf} + Sleep 500 + ${Loop} + nsExec::Exec 'taskkill /F /FI "SERVICES eq VeraeNetPrint"' + Pop $0 + Sleep 400 + !insertmacro NETPRINT_LOG "stopped VeraeNetPrint before replacing files" +!macroend + +!macro NETPRINT_KILL_TRAY + DetailPrint "Closing the Verae network printer tray" + nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' + Pop $0 + Sleep 400 + !insertmacro NETPRINT_LOG "closed verae-netprint-tray.exe before replacing files" +!macroend + +; Stop only when this install folder is the service binary, so a driver +; upgrade does not take down a standalone service in the other folder. +; Clobbers $R5, $0, and $1. Sets $NetStopped to 1 when it stops the service. +!macro NETPRINT_STOP_IF_OURS + nsExec::ExecToStack 'sc qc VeraeNetPrint' + Pop $0 + Pop $1 + ${StrStr} $R5 $1 $INSTDIR + ${If} $R5 != "" + StrCpy $NetStopped "1" + !insertmacro NETPRINT_STOP_SERVICE + ${EndIf} +!macroend + +; SetOverwrite on and AllowSkipFiles off must already be set. A failed +; extract used to be skipped; abort instead, and start the service again +; so a failed upgrade does not leave the printer stopped. +!macro NETPRINT_COPY_CHECKED src label + ClearErrors + File "${src}" + ${If} ${Errors} + !insertmacro NETPRINT_LOG "ERROR could not replace ${label} in $INSTDIR. The file is still in use, so setup did not upgrade." + nsExec::Exec 'sc start VeraeNetPrint' + Pop $0 + MessageBox MB_ICONSTOP "Could not replace ${label}.$\r$\nThe service or tray still has it open, so nothing was upgraded.$\r$\nDetails: $LogFile" /SD IDOK + Abort + ${EndIf} +!macroend + +!macro NETPRINT_DELETE_CHECKED path + !define NETPRINT_DEL_ID ${__COUNTER__} + IfFileExists "${path}" 0 netprint_del_skip_${NETPRINT_DEL_ID} + ClearErrors + Delete "${path}" + ${If} ${Errors} + !insertmacro NETPRINT_LOG "ERROR could not delete ${path}. It is still in use." + MessageBox MB_ICONSTOP "Could not delete ${path}.$\r$\nClose the Verae network printer and run uninstall again.$\r$\nDetails: $LogFile" /SD IDOK + Abort + ${EndIf} + netprint_del_skip_${NETPRINT_DEL_ID}: + !undef NETPRINT_DEL_ID +!macroend From 1d8dbac81a66bc5aef941b152dcc165f58c4adfa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 22:16:54 +0000 Subject: [PATCH 13/27] Advertise AirPrint subtypes and keep job folders, formats, and the service log consistent. Windows DnsServiceRegister now registers the Bonjour service-type form and the RFC subtype name so a Mac browse of _ipp._tcp,_universal can see the printer. document-format-supported matches the TXT pdl list, including JPEG. Job ids persist in next-job and job-N directories. The service log is written and flushed even when stderr fails. The tray is started by Task Scheduler or Explorer so setup is not its parent. Co-authored-by: George Lambert --- docs/network-printer.md | 6 +- internal/netprint/dnsservice.go | 29 ++++- internal/netprint/ipp.go | 70 ++++++++-- internal/netprint/launch_tray_windows.go | 155 +++++++++-------------- internal/netprint/mdns.go | 4 +- internal/netprint/mdns_windows_test.go | 11 +- internal/netprint/netprint_test.go | 139 +++++++++++++++++++- internal/netprint/pipeline.go | 47 ++++++- internal/netprint/server.go | 5 +- internal/netprint/service_log.go | 29 ++++- internal/netprint/store.go | 81 +++++++++++- internal/netprint/tray_launch.go | 18 +++ 12 files changed, 459 insertions(+), 135 deletions(-) create mode 100644 internal/netprint/tray_launch.go diff --git a/docs/network-printer.md b/docs/network-printer.md index 09d071f..55da3de 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus `_universal` and `_print`) and AirPrint text records. On Windows 10 and later the service registers with `DnsServiceRegister`, so the DNS Client (which already owns UDP 5353) sends the advertisements. If that call fails, the service tries a shared UDP 5353 socket and records the error. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later the service registers the primary name with `DnsServiceRegister`, then registers each subtype again — once as `._ipp._tcp,_universal.local` (the service-type form Windows publishes) and once as `._universal._sub._ipp._tcp.local` — so `dns-sd -B _ipp._tcp,_universal` and iOS can see it. The DNS Client, which already owns UDP 5353, sends the advertisements. If the primary registration fails, the service tries a shared UDP 5353 socket and records the error. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. Get-Printer-Attributes `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://:/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, and whether discovery is advertising. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -41,7 +41,7 @@ On macOS, the Application Firewall is per application (System Settings → Netwo The control API listens only on `127.0.0.1:8630`. -A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint launch-tray` only starts the tray; an unknown `netprint` subcommand does not start a second server. The service itself appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log`. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. +A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint launch-tray` starts the tray through Task Scheduler, or through Explorer if that task cannot be created, and then exits. The tray is not a child of setup, so `Start-Process -Wait` returns. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary. @@ -51,7 +51,7 @@ macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the packag - Other devices do not see it. Open `http://127.0.0.1:8630/api/v1/status` and read `mdnsMode` / `mdnsError`, and `%ProgramData%\Verae\logs\netprint.log` on Windows. `mdnsMode` `dns-sd` means Windows DNS-SD accepted the printer. Confirm the host has a LAN address and the network is Private, or turn on Allow Public networks. CUPS on the same machine is fine: this printer falls back to port 8632 and still advertises that port. It does not fall back to 8631. - Windows setup seemed to hang with `/S`. Every installer dialog has a silent default. Read `%ProgramData%\Verae\logs\netprint-installer.log`. - Jobs fail with an authentication error. Open Settings and save a current `xVerae-` key. The service reads the machine token (`/etc/verae/xverae-token`, the macOS Application Support file, or `%ProgramData%\Verae\PrintStamp\xverae-token`) and the usual per-user token. -- Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) are converted to PDF on this machine before they are hashed. PDF is accepted as-is. +- Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) and `image/jpeg` are converted to PDF on this machine before they are hashed. PDF is accepted as-is. Commands: diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 50acd31..769f491 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net" + "strings" "time" ) @@ -27,8 +28,18 @@ type dnsSDClient interface { func serviceToDNS(s Service) []dnsSDInstance { host := sanitizeLabel(s.Host) + ".local" base := sanitizeLabel(s.Instance) + // The first name is the primary service. A Mac `dns-sd -B _ipp._tcp` already + // resolves it. AirPrint and `dns-sd -B _ipp._tcp,_universal` query the PTR + // `_universal._sub._ipp._tcp.local`, which has to point at that same instance. + // DnsServiceRegister has no subtype field. Windows accepts the subtype on the + // service type, Bonjour-style ("._ipp._tcp,_universal.local"), and + // some builds only honor the RFC 6763 instance name + // ("._universal._sub._ipp._tcp.local"). Register both. A failure of + // either spelling is logged and does not remove the primary advertisement. names := []string{ base + "._ipp._tcp.local", + base + "._ipp._tcp,_universal.local", + base + "._ipp._tcp,_print.local", base + "._universal._sub._ipp._tcp.local", base + "._print._sub._ipp._tcp.local", } @@ -72,12 +83,24 @@ func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClie return fmt.Errorf("DnsServiceRegister registered nothing") } if sink != nil { - sink(mdnsReport{ + var subtypes []string + for _, inst := range registered[1:] { + subtypes = append(subtypes, inst.InstanceName) + } + detail := fmt.Sprintf("DnsServiceRegister %s port %d", registered[0].InstanceName, registered[0].Port) + report := mdnsReport{ Advertising: true, Mode: "dns-sd", - Detail: fmt.Sprintf("DnsServiceRegister %s port %d", registered[0].InstanceName, registered[0].Port), + Detail: detail, LastSent: time.Now(), - }) + } + if len(subtypes) > 0 { + report.Detail = detail + "; subtypes " + strings.Join(subtypes, ", ") + } + if len(registered) < len(insts) { + report.Err = "AirPrint subtype registration incomplete" + } + sink(report) } <-ctx.Done() for _, inst := range registered { diff --git a/internal/netprint/ipp.go b/internal/netprint/ipp.go index 0c45bb1..9be9cf3 100644 --- a/internal/netprint/ipp.go +++ b/internal/netprint/ipp.go @@ -31,6 +31,9 @@ type Printer struct { UUID string AdminURL string OnDocument DocumentFunc + // AllocateID, when set, returns a job id that is never reused after a + // restart. Without it, ids are process-local and start again at 1. + AllocateID func() (int, error) mu sync.Mutex started time.Time @@ -38,6 +41,30 @@ type Printer struct { jobs map[int]*ippJob } +// acceptedFormats is every document MIME type this printer consumes. +// document-format-supported and the AirPrint TXT pdl record both use it. +var acceptedFormats = []string{ + "application/pdf", + "image/urf", + "image/pwg-raster", + "image/jpeg", +} + +func pdlValue() string { return strings.Join(acceptedFormats, ",") } + +func formatAccepted(f string) bool { + switch f { + case "", "application/octet-stream": + return true + } + for _, a := range acceptedFormats { + if f == a { + return true + } + } + return false +} + type ippJob struct { id, state int name, user, format, reasons, message string @@ -111,13 +138,19 @@ func (p *Printer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message { if st, msg := p.checkFormat(req); st != goipp.StatusOk { return p.newResp(req, st, msg) } - j := p.createJob(req) + j, err := p.createJob(req) + if err != nil { + return p.newResp(req, goipp.StatusErrorInternal, err.Error()) + } return p.receive(req, r, j) case goipp.OpCreateJob: if st, msg := p.checkFormat(req); st != goipp.StatusOk { return p.newResp(req, st, msg) } - j := p.createJob(req) + j, err := p.createJob(req) + if err != nil { + return p.newResp(req, goipp.StatusErrorInternal, err.Error()) + } resp := p.newResp(req, goipp.StatusOk, "") resp.Job = p.jobAttrs(j, r) return resp @@ -206,6 +239,8 @@ func sniffFormat(format string, doc []byte) string { return "image/urf" case len(doc) >= 4 && (string(doc[:4]) == "RaS2" || string(doc[:4]) == "2SaR"): return "image/pwg-raster" + case len(doc) >= 3 && doc[0] == 0xff && doc[1] == 0xd8 && doc[2] == 0xff: + return "image/jpeg" default: if format == "" { return "application/octet-stream" @@ -216,23 +251,24 @@ func sniffFormat(format string, doc []byte) string { func (p *Printer) checkFormat(req *goipp.Message) (goipp.Status, string) { f := strings.ToLower(strings.TrimSpace(attrString(req.Operation, "document-format"))) - switch f { - case "", "application/octet-stream", "application/pdf", "image/urf", "image/pwg-raster": + if formatAccepted(f) { return goipp.StatusOk, "" - default: - return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f } + return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f } -func (p *Printer) createJob(req *goipp.Message) *ippJob { +func (p *Printer) createJob(req *goipp.Message) (*ippJob, error) { + id, err := p.allocJobID() + if err != nil { + return nil, err + } p.mu.Lock() defer p.mu.Unlock() if p.jobs == nil { p.jobs = map[int]*ippJob{} } - p.nextID++ j := &ippJob{ - id: p.nextID, + id: id, name: firstNonEmpty(attrString(req.Operation, "job-name"), attrString(req.Operation, "document-name"), "document"), user: attrString(req.Operation, "requesting-user-name"), format: firstNonEmpty(attrString(req.Operation, "document-format"), "application/octet-stream"), @@ -241,7 +277,17 @@ func (p *Printer) createJob(req *goipp.Message) *ippJob { created: time.Now(), } p.jobs[j.id] = j - return j + return j, nil +} + +func (p *Printer) allocJobID() (int, error) { + if p.AllocateID != nil { + return p.AllocateID() + } + p.mu.Lock() + defer p.mu.Unlock() + p.nextID++ + return p.nextID, nil } func (p *Printer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message { @@ -336,7 +382,7 @@ func (p *Printer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attrib add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String(admin))) add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(p.uuid()))) add(goipp.MakeAttribute("printer-dns-sd-name", goipp.TagName, goipp.String(name))) - add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:Timestamp;CMD:PDF,URF,PWG;CLS:PRINTER;DES:Verae Timestamp;"))) + add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:Timestamp;CMD:PDF,URF,PWG,JPEG;CLS:PRINTER;DES:Verae Timestamp;"))) add(ints("printer-state", goipp.TagEnum, state)) add(kw("printer-state-reasons", "none")) add(goipp.MakeAttribute("printer-is-accepting-jobs", goipp.TagBoolean, goipp.Boolean(true))) @@ -352,7 +398,7 @@ func (p *Printer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attrib add(multi("charset-supported", goipp.TagCharset, "utf-8")) add(multi("natural-language-configured", goipp.TagLanguage, "en")) add(multi("generated-natural-language-supported", goipp.TagLanguage, "en")) - add(multi("document-format-supported", goipp.TagMimeType, "application/pdf", "image/urf", "image/pwg-raster")) + add(multi("document-format-supported", goipp.TagMimeType, acceptedFormats...)) add(multi("document-format-default", goipp.TagMimeType, "application/pdf")) add(kw("urf-supported", "V1.4", "W8", "SRGB24", "RS300", "RS600", "DM1")) add(kw("pdl-override-supported", "attempted")) diff --git a/internal/netprint/launch_tray_windows.go b/internal/netprint/launch_tray_windows.go index 637996e..a3d2811 100644 --- a/internal/netprint/launch_tray_windows.go +++ b/internal/netprint/launch_tray_windows.go @@ -9,55 +9,66 @@ import ( "os/exec" "path/filepath" "strings" - "unsafe" - - "golang.org/x/sys/windows" + "time" ) const trayTaskName = "VeraeNetPrintTrayLaunch" -// LaunchTrayNow starts verae-netprint-tray.exe as the logged-in user, not as -// the elevated installer. A second call is a no-op when the tray is already running. +// LaunchTrayNow starts verae-netprint-tray.exe as the logged-in user. +// The tray is not a child of this process. Task Scheduler (and, if that is +// unavailable, Explorer) starts it, so Start-Process -Wait on setup returns +// when setup exits instead of waiting for the tray. func LaunchTrayNow() error { if trayAlreadyRunning() { fmt.Println("tray started") return nil } - cmd, err := trayCommandLine() + spec, err := trayLaunchSpec() if err != nil { return err } - if err := launchAsInteractiveUser(cmd); err != nil { - log.Printf("netprint: tray token launch: %v", err) - user, uerr := activeConsoleUser() - if uerr != nil { - return fmt.Errorf("%v; %w", err, uerr) - } - if err2 := launchTrayTask(cmd, user); err2 != nil { - return fmt.Errorf("%v; %w", err, err2) - } + var launchErr error + if user, uerr := activeConsoleUser(); uerr != nil { + launchErr = uerr + } else if err := launchTrayTask(spec, user); err != nil { + launchErr = err + log.Printf("netprint: tray scheduled task: %v", err) + } else { + fmt.Println("tray started") + return nil } - fmt.Println("tray started") - return nil + // Explorer can only launch a program with no arguments. The dedicated tray + // binary needs none. verae-print.exe must be started as `tray` by the task. + if len(spec.args) == 0 && spec.exe != "" { + if err := launchViaExplorer(spec.exe); err != nil { + if launchErr != nil { + return fmt.Errorf("%v; %w", launchErr, err) + } + return err + } + fmt.Println("tray started") + return nil + } + if launchErr != nil { + return launchErr + } + return fmt.Errorf("tray was not started") } -func trayCommandLine() (string, error) { +func trayLaunchSpec() (trayLaunch, error) { exe, err := os.Executable() if err != nil { - return "", err + return trayLaunch{}, err } exe, err = filepath.Abs(exe) if err != nil { - return "", err + return trayLaunch{}, err } sibling := filepath.Join(filepath.Dir(exe), "verae-netprint-tray.exe") if st, err := os.Stat(sibling); err == nil && !st.IsDir() { - // The tray binary's main is RunTray. It has no path that listens. - return `"` + sibling + `"`, nil + return trayLaunch{exe: sibling}, nil } - // `verae-print.exe` with no arguments prints usage. The tray subcommand - // is required so this never starts `netprint` / the IPP server. - return `"` + exe + `" tray`, nil + return trayLaunch{exe: exe, args: []string{"tray"}}, nil } func trayAlreadyRunning() bool { @@ -103,81 +114,11 @@ func activeConsoleUser() (string, error) { return "", fmt.Errorf("no interactive user") } -func launchAsInteractiveUser(cmdline string) error { - hwnd := windows.GetShellWindow() - if hwnd == 0 { - return fmt.Errorf("no shell window") - } - var pid uint32 - if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil || pid == 0 { - return fmt.Errorf("shell process: %w", err) - } - proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) - if err != nil { - return err - } - defer windows.CloseHandle(proc) - var token windows.Token - if err := windows.OpenProcessToken(proc, windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY, &token); err != nil { - return err - } - defer token.Close() - var primary windows.Token - var access uint32 = windows.TOKEN_ASSIGN_PRIMARY | windows.TOKEN_DUPLICATE | windows.TOKEN_QUERY | windows.TOKEN_ADJUST_DEFAULT | windows.TOKEN_ADJUST_SESSIONID - if err := windows.DuplicateTokenEx(token, access, nil, windows.SecurityImpersonation, windows.TokenPrimary, &primary); err != nil { - return err - } - defer primary.Close() - buf, err := windows.UTF16FromString(cmdline) - if err != nil { - return err - } - // CreateProcessWithTokenW may write into the command line buffer. - cmd := make([]uint16, len(buf)+8) - copy(cmd, buf) - var si windows.StartupInfo - si.Cb = uint32(unsafe.Sizeof(si)) - var pi windows.ProcessInformation - if err := createProcessWithToken(primary, &cmd[0], &si, &pi); err != nil { - return err - } - _ = windows.CloseHandle(pi.Process) - _ = windows.CloseHandle(pi.Thread) - return nil -} - -var ( - advapi32 = windows.NewLazySystemDLL("advapi32.dll") - procCreateProcessWithTokenW = advapi32.NewProc("CreateProcessWithTokenW") -) - -func createProcessWithToken(token windows.Token, cmdline *uint16, si *windows.StartupInfo, pi *windows.ProcessInformation) error { - // LOGON_WITH_PROFILE (1) fails from an elevated installer. 0 uses the token as-is. - r, _, callErr := procCreateProcessWithTokenW.Call( - uintptr(token), - 0, - 0, - uintptr(unsafe.Pointer(cmdline)), - uintptr(windows.CREATE_UNICODE_ENVIRONMENT), - 0, - 0, - uintptr(unsafe.Pointer(si)), - uintptr(unsafe.Pointer(pi)), - ) - if r == 0 { - if callErr != nil && callErr != windows.ERROR_SUCCESS { - return fmt.Errorf("CreateProcessWithTokenW: %w", callErr) - } - return fmt.Errorf("CreateProcessWithTokenW failed") - } - return nil -} - -func launchTrayTask(cmdline, user string) error { +func launchTrayTask(spec trayLaunch, user string) error { _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() out, err := exec.Command("schtasks", "/Create", "/TN", trayTaskName, - "/TR", cmdline, + "/TR", spec.taskCommand(), "/SC", "ONCE", "/ST", "00:00", "/RL", "LIMITED", @@ -189,9 +130,27 @@ func launchTrayTask(cmdline, user string) error { return fmt.Errorf("schtasks create: %w (%s)", err, strings.TrimSpace(string(out))) } out, err = exec.Command("schtasks", "/Run", "/TN", trayTaskName).CombinedOutput() - _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() if err != nil { + _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() return fmt.Errorf("schtasks run: %w (%s)", err, strings.TrimSpace(string(out))) } + // /Run returns once Task Scheduler has created the process, which is not a + // child of setup. Give tasklist a moment to see it, then drop the one-shot + // task so it cannot also fire at midnight. + deadline := time.Now().Add(1500 * time.Millisecond) + for time.Now().Before(deadline) && !trayAlreadyRunning() { + time.Sleep(100 * time.Millisecond) + } + _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() + return nil +} + +func launchViaExplorer(exe string) error { + // explorer.exe starts the program and returns. The tray's parent is + // Explorer, not setup, so the installer job object does not wait on it. + cmd := exec.Command("explorer.exe", exe) + if err := cmd.Start(); err != nil { + return fmt.Errorf("explorer launch: %w", err) + } return nil } diff --git a/internal/netprint/mdns.go b/internal/netprint/mdns.go index aa3829f..88218dd 100644 --- a/internal/netprint/mdns.go +++ b/internal/netprint/mdns.go @@ -36,7 +36,7 @@ type Service struct { // adminURL should be reachable on the LAN (http://ip:port/). func AirPrintTXT(name, uuid, adminURL, pdl, urf string) []string { if pdl == "" { - pdl = "application/pdf,image/urf,image/pwg-raster" + pdl = pdlValue() } if urf == "" { urf = "V1.4,W8,SRGB24,RS300,RS600,DM1" @@ -72,7 +72,7 @@ func AirPrintTXT(name, uuid, adminURL, pdl, urf string) []string { "TBCP=F", "usb_MFG=Verae", "usb_MDL=Timestamp", - "usb_CMD=PDF,URF,PWG", + "usb_CMD=PDF,URF,PWG,JPEG", } } diff --git a/internal/netprint/mdns_windows_test.go b/internal/netprint/mdns_windows_test.go index 23f0f51..d02fe18 100644 --- a/internal/netprint/mdns_windows_test.go +++ b/internal/netprint/mdns_windows_test.go @@ -96,7 +96,7 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { }) }() deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && api.count() < 3 { + for time.Now().Before(deadline) && api.count() < 5 { time.Sleep(10 * time.Millisecond) } cancel() @@ -122,6 +122,8 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { } want := []string{ "Verae Timestamp._ipp._tcp.local", + "Verae Timestamp._ipp._tcp,_universal.local", + "Verae Timestamp._ipp._tcp,_print.local", "Verae Timestamp._universal._sub._ipp._tcp.local", "Verae Timestamp._print._sub._ipp._tcp.local", } @@ -131,12 +133,15 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { } api.mu.Lock() defer api.mu.Unlock() - if len(api.registered) != 3 || api.registered[0].Port != 631 || api.registered[0].Host != "host.local" { + if len(api.registered) != 5 || api.registered[0].Port != 631 || api.registered[0].Host != "host.local" { t.Fatalf("instance %+v", api.registered) } - if len(api.deregistered) != 3 { + if len(api.deregistered) != 5 { t.Fatalf("deregistered %d", len(api.deregistered)) } + if !strings.Contains(report.Detail, "_ipp._tcp,_universal") { + t.Fatalf("detail missing universal subtype %q", report.Detail) + } } func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) { diff --git a/internal/netprint/netprint_test.go b/internal/netprint/netprint_test.go index 145172a..4b1ea21 100644 --- a/internal/netprint/netprint_test.go +++ b/internal/netprint/netprint_test.go @@ -5,6 +5,9 @@ import ( "context" "encoding/binary" "encoding/json" + "errors" + "image" + "image/jpeg" "io" "net" "net/http" @@ -185,7 +188,21 @@ func TestIPPEverywhereAttributesAndPrint(t *testing.T) { if goipp.Status(gpa.Code) != goipp.StatusOk { t.Fatalf("get-printer-attributes %v", goipp.Status(gpa.Code)) } - for _, kw := range []string{"application/pdf", "image/urf", "image/pwg-raster"} { + var formats []string + attrs := 0 + for _, a := range gpa.Printer { + if a.Name != "document-format-supported" { + continue + } + attrs++ + for _, v := range a.Values { + formats = append(formats, v.V.String()) + } + } + if attrs != 1 || strings.Join(formats, ",") != pdlValue() { + t.Fatalf("document-format-supported attrs=%d values=%v", attrs, formats) + } + for _, kw := range acceptedFormats { if !hasKeyword(gpa.Printer, "document-format-supported", kw) { t.Fatalf("missing document-format %s", kw) } @@ -360,7 +377,7 @@ func TestRasterRoundTripAndMenu(t *testing.T) { func TestMDNSAirPrintReply(t *testing.T) { txt := AirPrintTXT("Verae Timestamp", "abc-def", "http://192.0.2.10:631/", "", "") joined := strings.Join(txt, "\n") - for _, k := range []string{"txtvers=1", "rp=ipp/print", "ty=Verae Timestamp", "pdl=application/pdf,image/urf,image/pwg-raster", "URF=", "UUID=abc-def", "adminurl=http://192.0.2.10:631/"} { + for _, k := range []string{"txtvers=1", "rp=ipp/print", "ty=Verae Timestamp", "pdl=" + pdlValue(), "URF=", "UUID=abc-def", "adminurl=http://192.0.2.10:631/", "usb_CMD=PDF,URF,PWG,JPEG"} { if !strings.Contains(joined, k) { t.Fatalf("txt missing %s\n%s", k, joined) } @@ -401,6 +418,124 @@ func TestMachineToken(t *testing.T) { } } +func TestJobIDsPersistAndDoNotReuseFolders(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "job-1"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "job-1", "document-3.pdf"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "jobs.json"), []byte("[{\"id\":3,\"state\":\"completed\"}]\n"), 0o600); err != nil { + t.Fatal(err) + } + st, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + if st.NextID() != 4 { + t.Fatalf("next %d", st.NextID()) + } + id, err := st.AllocateID() + if err != nil || id != 4 { + t.Fatalf("id %d err %v", id, err) + } + st2, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + id2, err := st2.AllocateID() + if err != nil || id2 != 5 { + t.Fatalf("restarted id %d err %v", id2, err) + } + req := goipp.NewRequest(goipp.DefaultVersion, goipp.OpCreateJob, 1) + req.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("third"))) + p := &Printer{AllocateID: st2.AllocateID} + j, err := p.createJob(req) + if err != nil || j == nil || j.id != 6 { + t.Fatalf("job %+v err %v", j, err) + } + old, err := os.ReadFile(filepath.Join(dir, "job-1", "document-3.pdf")) + if err != nil || string(old) != "old" { + t.Fatalf("job-1 was reused: %q %v", old, err) + } + entries, err := os.ReadDir(filepath.Join(dir, "job-1")) + if err != nil || len(entries) != 1 || entries[0].Name() != "document-3.pdf" { + t.Fatalf("job-1 entries %v %v", entries, err) + } +} + +func TestJPEGAcceptedAndConverted(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + if got := sniffFormat("application/octet-stream", buf.Bytes()); got != "image/jpeg" { + t.Fatalf("sniff %s", got) + } + pdf, err := documentPDF("image/jpeg", buf.Bytes()) + if err != nil || !bytes.HasPrefix(pdf, []byte("%PDF-")) { + t.Fatalf("pdf %v", err) + } + req := goipp.NewRequest(goipp.DefaultVersion, goipp.OpValidateJob, 1) + req.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("image/jpeg"))) + if st, msg := (&Printer{}).checkFormat(req); st != goipp.StatusOk { + t.Fatalf("%v %s", st, msg) + } + if joined := strings.Join(acceptedFormats, ","); !strings.Contains(pdlValue(), "image/jpeg") || pdlValue() != joined { + t.Fatalf("pdl %s", pdlValue()) + } +} + +type failWriter struct{} + +func (failWriter) Write([]byte) (int, error) { return 0, errors.New("stderr closed") } + +func TestServiceLogWriteSurvivesBrokenStderr(t *testing.T) { + dir := t.TempDir() + f, err := os.Create(filepath.Join(dir, "netprint.log")) + if err != nil { + t.Fatal(err) + } + w := &serviceLogWriter{file: f, extra: failWriter{}} + if _, err := w.Write([]byte("netprint: starting\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(dir, "netprint.log")) + if err != nil { + t.Fatal(err) + } + if string(b) != "netprint: starting\n" { + t.Fatalf("log %q", b) + } +} + +func TestTrayLaunchIsDetachedFromSetup(t *testing.T) { + b, err := os.ReadFile("launch_tray_windows.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + if strings.Contains(src, "CreateProcessWithTokenW") { + t.Fatal("tray launch must not be a child of setup") + } + if !strings.Contains(src, "schtasks") || !strings.Contains(src, "explorer.exe") { + t.Fatal("tray launch should use a scheduled task or Explorer") + } + spec := trayLaunch{exe: `C:\Program Files\Verae\NetPrint\bin\verae-netprint-tray.exe`} + if spec.taskCommand() != spec.exe { + t.Fatalf("task command %q", spec.taskCommand()) + } + withArgs := trayLaunch{exe: `C:\Program Files\Verae\PrintStamp\bin\verae-print.exe`, args: []string{"tray"}} + if withArgs.taskCommand() != `"C:\Program Files\Verae\PrintStamp\bin\verae-print.exe" tray` { + t.Fatalf("task command %q", withArgs.taskCommand()) + } +} + func TestOwnersRefcount(t *testing.T) { dir := t.TempDir() // ownersPath is platform-fixed; exercise the JSON helpers by writing the file they read diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go index ed0a020..49f62d4 100644 --- a/internal/netprint/pipeline.go +++ b/internal/netprint/pipeline.go @@ -1,8 +1,12 @@ package netprint import ( + "bytes" "encoding/json" "fmt" + "image" + "image/draw" + "image/jpeg" "log" "os" "path/filepath" @@ -32,7 +36,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s rec := Record{ ID: id, Received: time.Now(), User: user, Title: title, Format: format, State: "processing", } - _ = st.Put(rec) + if err := st.Put(rec); err != nil { + log.Printf("netprint: job %d index: %v", id, err) + } if p != nil { p.SetState(id, jobProcessing, "job-printing", "") } @@ -42,7 +48,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s rec.State = "aborted" rec.Error = err.Error() rec.Completed = time.Now() - _ = st.Put(rec) + if putErr := st.Put(rec); putErr != nil { + log.Printf("netprint: job %d index: %v", id, putErr) + } if p != nil { p.SetState(id, jobAborted, "aborted-by-system", err.Error()) } @@ -50,7 +58,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s if p != nil && p.Canceled(id) { rec.State = "canceled" rec.Completed = time.Now() - _ = st.Put(rec) + if putErr := st.Put(rec); putErr != nil { + log.Printf("netprint: job %d index: %v", id, putErr) + } return } pdf, err := documentPDF(format, doc) @@ -101,7 +111,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s rec.Notified = maybeEmail(user, rec) rec.State = "completed" rec.Completed = time.Now() - _ = st.Put(rec) + if err := st.Put(rec); err != nil { + log.Printf("netprint: job %d index: %v", id, err) + } log.Printf("netprint: job %d completed receipt=%s sha256=%s", id, rec.ReceiptID, rec.SHA256) if p != nil { p.SetState(id, jobCompleted, "job-completed-successfully", "") @@ -119,11 +131,38 @@ func documentPDF(format string, doc []byte) ([]byte, error) { return nil, err } return PagesToPDF(pages) + case "image/jpeg": + pages, err := jpegToPages(doc) + if err != nil { + return nil, err + } + return PagesToPDF(pages) default: return nil, fmt.Errorf("unsupported document format %s", f) } } +func jpegToPages(doc []byte) ([]Page, error) { + img, err := jpeg.Decode(bytes.NewReader(doc)) + if err != nil { + return nil, fmt.Errorf("jpeg: %w", err) + } + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w < 1 || h < 1 || w > 20000 || h > 20000 || w*h > maxPagePixels { + return nil, fmt.Errorf("jpeg: dimensions %dx%d", w, h) + } + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + rgb := make([]byte, w*h*3) + for i, j := 0, 0; i < len(dst.Pix); i, j = i+4, j+3 { + rgb[j] = dst.Pix[i] + rgb[j+1] = dst.Pix[i+1] + rgb[j+2] = dst.Pix[i+2] + } + return []Page{{Width: w, Height: h, DPI: 300, RGB: rgb}}, nil +} + func annotateUser(path, user string) { b, err := os.ReadFile(path) if err != nil { diff --git a/internal/netprint/server.go b/internal/netprint/server.go index dcc9ec4..c6a1386 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -75,8 +75,9 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) ctrl: ctrl, stamp: stamp, printer: &Printer{ - Name: cfg.Name, - UUID: uuid, + Name: cfg.Name, + UUID: uuid, + AllocateID: st.AllocateID, }, } ippLn, port, err := listenIPP(cfg) diff --git a/internal/netprint/service_log.go b/internal/netprint/service_log.go index 0bd9530..753b8a6 100644 --- a/internal/netprint/service_log.go +++ b/internal/netprint/service_log.go @@ -7,6 +7,33 @@ import ( "path/filepath" ) +// serviceLogWriter appends each line to the log file and flushes it. +// A Windows service has no console, so os.Stderr.Write fails. io.MultiWriter +// stops at that first error and never reaches the file, which left +// netprint.log at 0 bytes. The file is written first; stderr is best-effort. +type serviceLogWriter struct { + file *os.File + extra io.Writer +} + +func (w *serviceLogWriter) Write(p []byte) (int, error) { + if w == nil || w.file == nil { + return 0, os.ErrInvalid + } + n, err := w.file.Write(p) + if err != nil { + return n, err + } + if n != len(p) { + return n, io.ErrShortWrite + } + _ = w.file.Sync() + if w.extra != nil { + _, _ = w.extra.Write(p) + } + return n, nil +} + // setupServiceLog appends the service log under %ProgramData%\Verae\logs on Windows. // VERAE_LOG_DIR overrides the directory (used by tests). func setupServiceLog() { @@ -22,7 +49,7 @@ func setupServiceLog() { return } log.SetFlags(log.Ldate | log.Ltime) - log.SetOutput(io.MultiWriter(os.Stderr, f)) + log.SetOutput(&serviceLogWriter{file: f, extra: os.Stderr}) log.Printf("netprint: log file %s", f.Name()) } diff --git a/internal/netprint/store.go b/internal/netprint/store.go index 06a0522..d24ead7 100644 --- a/internal/netprint/store.go +++ b/internal/netprint/store.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "strings" "sync" "time" ) @@ -82,16 +84,85 @@ func (s *Store) Get(id int) (Record, bool) { return Record{}, false } +func (s *Store) seqPath() string { return filepath.Join(s.dir, "next-job") } + +// NextID is the next job id that AllocateID would return, without reserving it. func (s *Store) NextID() int { s.mu.Lock() defer s.mu.Unlock() - max := 0 - for _, r := range s.all { - if r.ID > max { - max = r.ID + return s.highWaterLocked() +} + +// AllocateID reserves a job id and its job-N directory. The counter and the +// directory both survive a process restart, so a later job cannot land in an +// earlier job-N folder. +func (s *Store) AllocateID() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + next := s.highWaterLocked() + for { + dir := filepath.Join(s.dir, fmt.Sprintf("job-%d", next)) + err := os.Mkdir(dir, 0o700) + if err == nil { + if werr := s.writeSeq(next + 1); werr != nil { + return 0, werr + } + return next, nil + } + if !os.IsExist(err) { + return 0, err + } + next++ + if next > 1_000_000_000 { + return 0, fmt.Errorf("job id space exhausted") } } - return max + 1 +} + +func (s *Store) highWaterLocked() int { + next := 1 + if n, err := s.readSeq(); err == nil && n > next { + next = n + } + for _, r := range s.all { + if r.ID+1 > next { + next = r.ID + 1 + } + } + entries, err := os.ReadDir(s.dir) + if err != nil { + return next + } + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, "job-") { + continue + } + n, err := strconv.Atoi(name[len("job-"):]) + if err != nil || n < 1 { + continue + } + if n+1 > next { + next = n + 1 + } + } + return next +} + +func (s *Store) readSeq() (int, error) { + b, err := os.ReadFile(s.seqPath()) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(b))) +} + +func (s *Store) writeSeq(n int) error { + tmp := s.seqPath() + ".tmp" + if err := os.WriteFile(tmp, []byte(strconv.Itoa(n)+"\n"), 0o600); err != nil { + return err + } + return os.Rename(tmp, s.seqPath()) } func (s *Store) Put(r Record) error { diff --git a/internal/netprint/tray_launch.go b/internal/netprint/tray_launch.go new file mode 100644 index 0000000..640f3ad --- /dev/null +++ b/internal/netprint/tray_launch.go @@ -0,0 +1,18 @@ +package netprint + +import "strings" + +// trayLaunch is the program to start. args is empty for verae-netprint-tray.exe, +// whose main is RunTray and which never listens. The verae-print.exe fallback +// must pass "tray" so it does not start the IPP server. +type trayLaunch struct { + exe string + args []string +} + +func (t trayLaunch) taskCommand() string { + if len(t.args) == 0 { + return t.exe + } + return `"` + t.exe + `" ` + strings.Join(t.args, " ") +} From e099cee78c69475f38581d1b202dd957b438de20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 00:15:02 +0000 Subject: [PATCH 14/27] Fix Windows AirPrint discovery, media, PWG pages, tray lifetime, and Print-Job reads. DnsServiceRegister publishes only the primary _ipp._tcp name. A shared UDP 5353 socket answers subtype PTRs that point at that name, plus SRV, TXT, and address records. PWG RaS2 headers are big-endian, IPP advertises letter and A4 media-col, Print-Job reads Content-Length after 100 Continue, and the tray starts once behind a mutex. Co-authored-by: George Lambert --- docs/network-printer.md | 4 +- internal/netprint/advertise.go | 171 +++++++- internal/netprint/advertise_unix.go | 2 +- internal/netprint/advertise_windows.go | 74 +++- internal/netprint/dnsservice.go | 227 +++++++---- internal/netprint/ipp.go | 91 ++++- internal/netprint/launch_tray_windows.go | 8 + internal/netprint/mdns.go | 73 +++- ...dns_windows_test.go => mdns_dnssd_test.go} | 66 +-- internal/netprint/mdns_packet_test.go | 384 ++++++++++++++++++ internal/netprint/netprint_test.go | 16 +- internal/netprint/printjob_test.go | 226 +++++++++++ internal/netprint/pwg_test.go | 88 ++++ internal/netprint/raster.go | 32 +- internal/netprint/server.go | 2 +- internal/netprint/tray.go | 12 + internal/netprint/tray_launch.go | 4 +- internal/netprint/tray_windows.go | 55 ++- platform/windows/installer-netprint.nsi | 5 +- platform/windows/installer-windows.nsi | 5 +- 20 files changed, 1367 insertions(+), 178 deletions(-) rename internal/netprint/{mdns_windows_test.go => mdns_dnssd_test.go} (87%) create mode 100644 internal/netprint/mdns_packet_test.go create mode 100644 internal/netprint/printjob_test.go create mode 100644 internal/netprint/pwg_test.go diff --git a/docs/network-printer.md b/docs/network-printer.md index 55da3de..6975217 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later the service registers the primary name with `DnsServiceRegister`, then registers each subtype again — once as `._ipp._tcp,_universal.local` (the service-type form Windows publishes) and once as `._universal._sub._ipp._tcp.local` — so `dns-sd -B _ipp._tcp,_universal` and iOS can see it. The DNS Client, which already owns UDP 5353, sends the advertisements. If the primary registration fails, the service tries a shared UDP 5353 socket and records the error. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. Get-Printer-Attributes `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) and answers `_ipp._tcp`, `_universal._sub._ipp._tcp`, and `_print._sub._ipp._tcp` itself. Every one of those PTRs points at `._ipp._tcp.local`, with SRV, TXT, and address records. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` both list the printer. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://:/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, and whether discovery is advertising. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -41,7 +41,7 @@ On macOS, the Application Firewall is per application (System Settings → Netwo The control API listens only on `127.0.0.1:8630`. -A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint launch-tray` starts the tray through Task Scheduler, or through Explorer if that task cannot be created, and then exits. The tray is not a child of setup, so `Start-Process -Wait` returns. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. +A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary. diff --git a/internal/netprint/advertise.go b/internal/netprint/advertise.go index 3904279..2c78d6e 100644 --- a/internal/netprint/advertise.go +++ b/internal/netprint/advertise.go @@ -2,16 +2,161 @@ package netprint import ( "context" + "encoding/binary" "errors" "log" "net" + "sync" "time" ) -var mdnsAddr = &net.UDPAddr{IP: net.ParseIP("224.0.0.251"), Port: 5353} +var ( + mdnsAddr4 = &net.UDPAddr{IP: net.ParseIP("224.0.0.251"), Port: 5353} + mdnsAddr6 = &net.UDPAddr{IP: net.ParseIP("ff02::fb"), Port: 5353} +) + +// mdnsConn is the shared UDP 5353 socket. *net.UDPConn implements it. +type mdnsConn interface { + ReadFromUDP(b []byte) (int, *net.UDPAddr, error) + WriteToUDP(b []byte, addr *net.UDPAddr) (int, error) + SetReadDeadline(t time.Time) error + Close() error +} // listenMDNSFn is the UDP 5353 bind. Tests replace it. -var listenMDNSFn = listenMDNS +var listenMDNSFn = func() (mdnsConn, error) { return listenMDNS() } + +// writeMDNS sends pkt to every mDNS group the socket can reach. +// One successful write is enough: an IPv4-only socket cannot join ff02::fb. +func writeMDNS(conn mdnsConn, pkt []byte) (int, error) { + n4, err4 := conn.WriteToUDP(pkt, mdnsAddr4) + n6, err6 := conn.WriteToUDP(pkt, mdnsAddr6) + if err4 == nil { + return n4, nil + } + if err6 == nil { + return n6, nil + } + return 0, err4 +} + +// ipv6GroupReq is the Windows IPV6_MREQ for ff02::fb on ifindex. +func ipv6GroupReq(ifindex int) []byte { + var mreq [20]byte + copy(mreq[:16], net.ParseIP("ff02::fb").To16()) + binary.LittleEndian.PutUint32(mreq[16:], uint32(ifindex)) + return mreq[:] +} + +// dualMDNS reads IPv4 and IPv6 mDNS sockets as one conn. +type dualMDNS struct { + v4, v6 *net.UDPConn + once sync.Once + pkts chan dualPkt + done chan struct{} +} + +type dualPkt struct { + n int + buf []byte + addr *net.UDPAddr + err error +} + +func (d *dualMDNS) start() { + d.once.Do(func() { + d.pkts = make(chan dualPkt) + d.done = make(chan struct{}) + if d.v4 != nil { + go d.read(d.v4) + } + if d.v6 != nil { + go d.read(d.v6) + } + }) +} + +func (d *dualMDNS) read(c *net.UDPConn) { + for { + buf := make([]byte, 9000) + n, addr, err := c.ReadFromUDP(buf) + pkt := dualPkt{n: n, addr: addr, err: err} + if err == nil { + pkt.buf = append([]byte(nil), buf[:n]...) + } + select { + case <-d.done: + return + case d.pkts <- pkt: + } + if err != nil && !isTimeout(err) { + return + } + } +} + +func isTimeout(err error) bool { + var ne net.Error + return errors.As(err, &ne) && ne.Timeout() +} + +func (d *dualMDNS) ReadFromUDP(b []byte) (int, *net.UDPAddr, error) { + d.start() + select { + case <-d.done: + return 0, nil, net.ErrClosed + case pkt := <-d.pkts: + if pkt.err != nil { + return 0, nil, pkt.err + } + return copy(b, pkt.buf), pkt.addr, nil + } +} + +func (d *dualMDNS) WriteToUDP(b []byte, addr *net.UDPAddr) (int, error) { + if addr != nil && addr.IP.To4() == nil { + if d.v6 == nil { + return 0, errors.New("no IPv6 mDNS socket") + } + return d.v6.WriteToUDP(b, addr) + } + if d.v4 == nil { + return 0, errors.New("no IPv4 mDNS socket") + } + return d.v4.WriteToUDP(b, addr) +} + +func (d *dualMDNS) SetReadDeadline(t time.Time) error { + var err error + if d.v4 != nil { + err = d.v4.SetReadDeadline(t) + } + if d.v6 != nil { + if e := d.v6.SetReadDeadline(t); err == nil { + err = e + } + } + return err +} + +func (d *dualMDNS) Close() error { + d.start() + select { + case <-d.done: + default: + close(d.done) + } + var err error + if d.v4 != nil { + err = d.v4.Close() + } + if d.v6 != nil { + if e := d.v6.Close(); err == nil { + err = e + } + } + return err +} // mdnsReport is what the advertiser tells the server about discovery. type mdnsReport struct { @@ -45,12 +190,12 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er log.Printf("netprint: mDNS shared UDP 5353 socket is open") go func() { <-ctx.Done() - _, _ = conn.WriteToUDP(current().Announce(true), mdnsAddr) + _, _ = writeMDNS(conn, current().Announce(true)) _ = conn.Close() }() sent := 0 send := func() { - n, werr := conn.WriteToUDP(current().Announce(false), mdnsAddr) + n, werr := writeMDNS(conn, current().Announce(false)) if werr != nil { log.Printf("netprint: mDNS announcement failed: %v", werr) if sink != nil { @@ -59,12 +204,12 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er return } sent++ - log.Printf("netprint: mDNS announcement sent (%d bytes) to 224.0.0.251:5353", n) + log.Printf("netprint: mDNS announcement sent (%d bytes) to 224.0.0.251 and ff02::fb port 5353", n) if sink != nil { sink(mdnsReport{ Advertising: true, Mode: "udp", - Detail: "shared UDP 5353, announcement sent to 224.0.0.251", + Detail: "shared UDP 5353, announcement sent to 224.0.0.251 and ff02::fb", LastSent: time.Now(), }) } @@ -91,14 +236,24 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er } continue } - resp := current().Reply(append([]byte(nil), buf[:n]...)) + query := append([]byte(nil), buf[:n]...) + resp := current().Reply(query) if resp == nil { continue } + // QU (the top bit of the class) asks for a unicast reply. Multicast + // questions are answered on the group so other browsers see them. + if queryUnicast(query) { + if addr != nil { + _, _ = conn.WriteToUDP(resp, addr) + log.Printf("netprint: mDNS QU reply sent (%d bytes) to %s", len(resp), addr) + } + continue + } if addr != nil { _, _ = conn.WriteToUDP(resp, addr) } - if _, werr := conn.WriteToUDP(resp, mdnsAddr); werr == nil { + if _, werr := writeMDNS(conn, resp); werr == nil { log.Printf("netprint: mDNS query reply sent (%d bytes)", len(resp)) } } diff --git a/internal/netprint/advertise_unix.go b/internal/netprint/advertise_unix.go index 8ec4a9e..5257b8d 100644 --- a/internal/netprint/advertise_unix.go +++ b/internal/netprint/advertise_unix.go @@ -9,7 +9,7 @@ import ( "syscall" ) -func listenMDNS() (*net.UDPConn, error) { +func listenMDNS() (mdnsConn, error) { lc := net.ListenConfig{ Control: func(network, address string, c syscall.RawConn) error { var sockErr error diff --git a/internal/netprint/advertise_windows.go b/internal/netprint/advertise_windows.go index 64a7769..9ad6f61 100644 --- a/internal/netprint/advertise_windows.go +++ b/internal/netprint/advertise_windows.go @@ -12,16 +12,43 @@ import ( "golang.org/x/sys/windows" ) -const ipAddMembership = 12 +const ( + ipAddMembership = 12 // IP_ADD_MEMBERSHIP + ipv6AddMembership = 12 // IPV6_ADD_MEMBERSHIP / IPV6_JOIN_GROUP + soExclusiveAddrUse = ^int32(syscall.SO_REUSEADDR) // SO_EXCLUSIVEADDRUSE (~SO_REUSEADDR) +) -func listenMDNS() (*net.UDPConn, error) { +func listenMDNS() (mdnsConn, error) { + v4, err := listenReuse("udp4", "0.0.0.0:5353") + if err != nil { + return nil, err + } + joinIPv4Multicast(v4) + _ = v4.SetReadBuffer(1 << 20) + v6, err6 := listenReuse("udp6", "[::]:5353") + if err6 != nil { + log.Printf("netprint: mDNS IPv6 bind: %v", err6) + return v4, nil + } + joinIPv6Multicast(v6) + _ = v6.SetReadBuffer(1 << 20) + return &dualMDNS{v4: v4, v6: v6}, nil +} + +func listenReuse(network, address string) (*net.UDPConn, error) { lc := net.ListenConfig{ Control: func(network, address string, c syscall.RawConn) error { var sockErr error err := c.Control(func(fd uintptr) { - // Dnscache already binds UDP 5353. SO_REUSEADDR lets this - // process share that socket when the DNS Client allows it. - sockErr = syscall.SetsockoptInt(syscall.Handle(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + // Dnscache already binds UDP 5353. SO_REUSEADDR shares that + // port. SO_EXCLUSIVEADDRUSE would make the bind fail, so it + // is turned off before bind. + h := syscall.Handle(fd) + sockErr = syscall.SetsockoptInt(h, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + if sockErr != nil { + return + } + _ = syscall.SetsockoptInt(h, syscall.SOL_SOCKET, int(soExclusiveAddrUse), 0) }) if err != nil { return err @@ -29,14 +56,11 @@ func listenMDNS() (*net.UDPConn, error) { return sockErr }, } - pc, err := lc.ListenPacket(context.Background(), "udp4", "0.0.0.0:5353") + pc, err := lc.ListenPacket(context.Background(), network, address) if err != nil { - return nil, fmt.Errorf("mDNS bind: %w", err) + return nil, fmt.Errorf("mDNS bind %s %s: %w", network, address, err) } - conn := pc.(*net.UDPConn) - joinIPv4Multicast(conn) - _ = conn.SetReadBuffer(1 << 20) - return conn, nil + return pc.(*net.UDPConn), nil } func joinIPv4Multicast(conn *net.UDPConn) { @@ -81,6 +105,34 @@ func joinIPv4Multicast(conn *net.UDPConn) { } } +func joinIPv6Multicast(conn *net.UDPConn) { + raw, err := conn.SyscallConn() + if err != nil { + log.Printf("netprint: mDNS IPv6 socket: %v", err) + return + } + ifaces, _ := net.Interfaces() + joined := 0 + _ = raw.Control(func(fd uintptr) { + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 || ifi.Flags&net.FlagLoopback != 0 { + continue + } + mreq := ipv6GroupReq(ifi.Index) + err := windows.Setsockopt(windows.Handle(fd), windows.IPPROTO_IPV6, ipv6AddMembership, &mreq[0], int32(len(mreq))) + if err != nil { + log.Printf("netprint: mDNS join ff02::fb on %s: %v", ifi.Name, err) + continue + } + joined++ + log.Printf("netprint: mDNS joined ff02::fb on %s", ifi.Name) + } + }) + if joined == 0 { + log.Printf("netprint: mDNS joined no interfaces for ff02::fb") + } +} + func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { return advertiseForOS("windows", ctx, current, windowsDNSService{}, sink) } diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 769f491..7fd75af 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -5,7 +5,7 @@ import ( "fmt" "log" "net" - "strings" + "sync" "time" ) @@ -28,37 +28,24 @@ type dnsSDClient interface { func serviceToDNS(s Service) []dnsSDInstance { host := sanitizeLabel(s.Host) + ".local" base := sanitizeLabel(s.Instance) - // The first name is the primary service. A Mac `dns-sd -B _ipp._tcp` already - // resolves it. AirPrint and `dns-sd -B _ipp._tcp,_universal` query the PTR - // `_universal._sub._ipp._tcp.local`, which has to point at that same instance. - // DnsServiceRegister has no subtype field. Windows accepts the subtype on the - // service type, Bonjour-style ("._ipp._tcp,_universal.local"), and - // some builds only honor the RFC 6763 instance name - // ("._universal._sub._ipp._tcp.local"). Register both. A failure of - // either spelling is logged and does not remove the primary advertisement. - names := []string{ - base + "._ipp._tcp.local", - base + "._ipp._tcp,_universal.local", - base + "._ipp._tcp,_print.local", - base + "._universal._sub._ipp._tcp.local", - base + "._print._sub._ipp._tcp.local", - } - out := make([]dnsSDInstance, 0, len(names)) - for _, name := range names { - out = append(out, dnsSDInstance{ - InstanceName: name, - Host: host, - Port: s.Port, - IPv4: append([]net.IP(nil), s.IPs...), - TXT: append([]string(nil), s.TXT...), - }) - } - return out + // DnsServiceRegister has no subtype field. A comma in the service type + // ("._ipp._tcp,_universal.local") is rejected (win32 87). Registering + // "._universal._sub._ipp._tcp.local" publishes a PTR whose target is + // that long name, which `dns-sd -B _ipp._tcp,_universal` does not accept, and + // those extra services also stopped plain `_ipp._tcp` browse from listing the + // printer. Only the primary instance is registered here. Subtype PTRs are + // sent by the shared UDP responder and point at this same name. + return []dnsSDInstance{{ + InstanceName: base + "._ipp._tcp.local", + Host: host, + Port: s.Port, + IPv4: append([]net.IP(nil), s.IPs...), + TXT: append([]string(nil), s.TXT...), + }} } -// advertiseWithDNS publishes the printer through DnsServiceRegister (or a test double) -// and blocks until ctx is canceled. The primary _ipp._tcp name must succeed. -// Subtype failures are logged and do not hide the primary advertisement. +// advertiseWithDNS publishes the primary _ipp._tcp name through DnsServiceRegister +// (or a test double) and blocks until ctx is canceled. func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { if api == nil { return fmt.Errorf("DnsServiceRegister client is nil") @@ -67,72 +54,154 @@ func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClie if len(insts) == 0 { return fmt.Errorf("DnsServiceRegister: empty service") } - var registered []dnsSDInstance - for i, inst := range insts { - if err := api.Register(inst); err != nil { - if i == 0 { - return fmt.Errorf("DnsServiceRegister %s: %w", inst.InstanceName, err) - } - log.Printf("netprint: mDNS subtype %s not registered: %v", inst.InstanceName, err) - continue - } - registered = append(registered, inst) - log.Printf("netprint: mDNS advertised via DnsServiceRegister %s port %d", inst.InstanceName, inst.Port) - } - if len(registered) == 0 { - return fmt.Errorf("DnsServiceRegister registered nothing") + inst := insts[0] + if err := api.Register(inst); err != nil { + return fmt.Errorf("DnsServiceRegister %s: %w", inst.InstanceName, err) } + log.Printf("netprint: mDNS advertised via DnsServiceRegister %s port %d", inst.InstanceName, inst.Port) if sink != nil { - var subtypes []string - for _, inst := range registered[1:] { - subtypes = append(subtypes, inst.InstanceName) - } - detail := fmt.Sprintf("DnsServiceRegister %s port %d", registered[0].InstanceName, registered[0].Port) - report := mdnsReport{ + sink(mdnsReport{ Advertising: true, Mode: "dns-sd", - Detail: detail, + Detail: fmt.Sprintf("DnsServiceRegister %s port %d", inst.InstanceName, inst.Port), LastSent: time.Now(), - } - if len(subtypes) > 0 { - report.Detail = detail + "; subtypes " + strings.Join(subtypes, ", ") - } - if len(registered) < len(insts) { - report.Err = "AirPrint subtype registration incomplete" - } - sink(report) + }) } <-ctx.Done() - for _, inst := range registered { - if err := api.Deregister(inst); err != nil { - log.Printf("netprint: mDNS DnsServiceDeRegister %s: %v", inst.InstanceName, err) - } + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: mDNS DnsServiceDeRegister %s: %v", inst.InstanceName, err) } log.Printf("netprint: mDNS DnsServiceRegister removed") return nil } // advertiseForOS is the discovery strategy for goos. -// Windows tries DnsServiceRegister first so the advertisement is sent by Dnscache, -// which already owns UDP 5353. A shared UDP socket is the fallback. +// Windows registers only the primary name with DnsServiceRegister (Dnscache +// already owns UDP 5353 and answers plain _ipp._tcp browse). Subtype PTRs +// cannot be expressed there, so a shared UDP 5353 socket always runs as well +// and answers _ipp._tcp plus _universal._sub and _print._sub, all pointing at +// the primary instance. The socket sets SO_REUSEADDR and joins the mDNS groups +// so it shares the port with the Windows stack instead of replacing it. func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { - if goos == "windows" { - err := advertiseWithDNS(ctx, current, api, sink) - if err == nil || ctx.Err() != nil { - return nil + if goos != "windows" { + return advertiseUDP(ctx, current, sink) + } + var mu sync.Mutex + st := struct { + dnsOK bool + udpOK bool + dnsDetail string + dnsErr string + udpErr string + }{} + publish := func() { + if sink == nil { + return } - log.Printf("netprint: DnsServiceRegister failed: %v; trying a shared UDP 5353 socket", err) - if sink != nil { + mu.Lock() + defer mu.Unlock() + switch { + case st.dnsOK && st.udpOK: sink(mdnsReport{ - Mode: "dns-sd", - Err: err.Error(), - Detail: "DnsServiceRegister failed; trying shared UDP 5353", + Advertising: true, + Mode: "dns-sd", + Detail: st.dnsDetail + "; shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub", + LastSent: time.Now(), + }) + case st.dnsOK: + report := mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: st.dnsDetail + "; AirPrint subtypes not advertised", + LastSent: time.Now(), + } + if st.udpErr != "" { + report.Err = "shared UDP 5353: " + st.udpErr + } + sink(report) + case st.udpOK: + sink(mdnsReport{ + Advertising: true, + Mode: "udp", + Detail: "shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub", + Err: st.dnsErr, + LastSent: time.Now(), + }) + case st.dnsErr != "" && st.udpErr != "": + sink(mdnsReport{ + Mode: "error", + Err: st.dnsErr + "; shared UDP 5353: " + st.udpErr, + Detail: "DnsServiceRegister and shared UDP 5353 failed", }) } - if udpErr := advertiseUDP(ctx, current, sink); udpErr != nil { - return fmt.Errorf("DnsServiceRegister: %v; shared UDP 5353: %w", err, udpErr) - } - return nil } - return advertiseUDP(ctx, current, sink) + dnsReady := make(chan struct{}) + dnsDone := make(chan struct{}) + var dnsOnce sync.Once + ready := func() { dnsOnce.Do(func() { close(dnsReady) }) } + go func() { + defer close(dnsDone) + err := advertiseWithDNS(ctx, current, api, func(r mdnsReport) { + mu.Lock() + st.dnsOK = r.Advertising + st.dnsDetail = r.Detail + if r.Err != "" { + st.dnsErr = r.Err + } + mu.Unlock() + ready() + publish() + }) + if err != nil && ctx.Err() == nil { + mu.Lock() + st.dnsOK = false + st.dnsErr = err.Error() + mu.Unlock() + log.Printf("netprint: %v", err) + publish() + } + ready() + }() + udpErr := advertiseUDP(ctx, current, func(r mdnsReport) { + mu.Lock() + if r.Advertising { + st.udpOK = true + st.udpErr = "" + } + if r.Err != "" && !r.Advertising { + st.udpOK = false + st.udpErr = r.Err + } + mu.Unlock() + publish() + }) + if udpErr != nil && ctx.Err() == nil { + mu.Lock() + st.udpOK = false + st.udpErr = udpErr.Error() + mu.Unlock() + publish() + select { + case <-dnsReady: + case <-ctx.Done(): + } + mu.Lock() + dnsOK := st.dnsOK + dnsErr := st.dnsErr + udpMsg := st.udpErr + mu.Unlock() + if dnsOK && ctx.Err() == nil { + <-ctx.Done() + } + <-dnsDone + if dnsOK || ctx.Err() != nil { + return nil + } + if dnsErr == "" { + dnsErr = "DnsServiceRegister failed" + } + return fmt.Errorf("%s; shared UDP 5353: %s", dnsErr, udpMsg) + } + <-dnsDone + return nil } diff --git a/internal/netprint/ipp.go b/internal/netprint/ipp.go index 9be9cf3..7f7c575 100644 --- a/internal/netprint/ipp.go +++ b/internal/netprint/ipp.go @@ -79,17 +79,38 @@ func (p *Printer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if p.jobs == nil { p.jobs = map[int]*ippJob{} } + // Flush 100 Continue before touching the body. Clients such as CUPS hold + // the Print-Job body until they see it, and give up after about a minute. + if r.ProtoAtLeast(1, 1) && r.Header.Get("Expect") != "" && strings.Contains(strings.ToLower(r.Header.Get("Expect")), "100-continue") { + w.WriteHeader(http.StatusContinue) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + body := &countingReader{r: r.Body} var req goipp.Message - if err := req.Decode(r.Body); err != nil { + if err := req.Decode(body); err != nil { http.Error(w, "bad ipp", http.StatusBadRequest) return } - resp := p.dispatch(&req, r) - _, _ = io.Copy(io.Discard, r.Body) + resp := p.dispatch(&req, r, body) w.Header().Set("Content-Type", goipp.ContentType) _ = resp.Encode(w) } +// countingReader counts bytes read from the HTTP body so the document can be +// read with Content-Length instead of waiting for the connection to close. +type countingReader struct { + r io.Reader + n int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += int64(n) + return n, err +} + // SetState updates an in-memory IPP job. Unknown ids are ignored. func (p *Printer) SetState(id, state int, reason, msg string) { p.mu.Lock() @@ -112,7 +133,7 @@ func (p *Printer) Canceled(id int) bool { return j != nil && j.state == jobCanceled } -func (p *Printer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message { +func (p *Printer) dispatch(req *goipp.Message, r *http.Request, body *countingReader) *goipp.Message { if req.RequestID == 0 { return p.newResp(req, goipp.StatusErrorBadRequest, "request-id must be non-zero") } @@ -142,7 +163,7 @@ func (p *Printer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message { if err != nil { return p.newResp(req, goipp.StatusErrorInternal, err.Error()) } - return p.receive(req, r, j) + return p.receive(req, r, body, j) case goipp.OpCreateJob: if st, msg := p.checkFormat(req); st != goipp.StatusOk { return p.newResp(req, st, msg) @@ -170,7 +191,7 @@ func (p *Printer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message { if st, msg := p.checkFormat(req); st != goipp.StatusOk { return p.newResp(req, st, msg) } - return p.receive(req, r, j) + return p.receive(req, r, body, j) case goipp.OpGetJobAttributes: id := int(attrInt(req.Operation, "job-id")) p.mu.Lock() @@ -207,8 +228,8 @@ func (p *Printer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message { } } -func (p *Printer) receive(req *goipp.Message, r *http.Request, j *ippJob) *goipp.Message { - doc, err := io.ReadAll(io.LimitReader(r.Body, maxDocument+1)) +func (p *Printer) receive(req *goipp.Message, r *http.Request, body *countingReader, j *ippJob) *goipp.Message { + doc, err := readDocument(r, body) if err != nil { p.SetState(j.id, jobAborted, "aborted-by-system", err.Error()) return p.newResp(req, goipp.StatusErrorInternal, "read failed") @@ -231,6 +252,28 @@ func (p *Printer) receive(req *goipp.Message, r *http.Request, j *ippJob) *goipp return resp } +// readDocument returns the bytes after the IPP message. +// When Content-Length is set, it reads exactly the remaining bytes and returns +// without waiting for the client to close the connection. +func readDocument(r *http.Request, body *countingReader) ([]byte, error) { + if r.ContentLength >= 0 { + remain := r.ContentLength - body.n + if remain < 0 { + return nil, fmt.Errorf("ipp message longer than Content-Length") + } + if remain == 0 { + return nil, nil + } + if remain > maxDocument+1 { + remain = maxDocument + 1 + } + buf := make([]byte, remain) + _, err := io.ReadFull(body, buf) + return buf, err + } + return io.ReadAll(io.LimitReader(body, maxDocument+1)) +} + func sniffFormat(format string, doc []byte) string { switch { case len(doc) >= 5 && string(doc[:5]) == "%PDF-": @@ -413,6 +456,22 @@ func (p *Printer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attrib add(kw("media-default", "na_letter_8.5x11in")) add(kw("media-supported", "na_letter_8.5x11in", "iso_a4_210x297mm", "na_legal_8.5x14in")) add(kw("media-ready", "na_letter_8.5x11in", "iso_a4_210x297mm")) + letter := mediaCol(21590, 27940, 0) // na_letter_8.5x11in, hundredths of a mm + a4 := mediaCol(21000, 29700, 0) // iso_a4_210x297mm + add(goipp.MakeAttribute("media-col-default", goipp.TagBeginCollection, letter)) + readyCol := goipp.Attribute{Name: "media-col-ready"} + readyCol.Values.Add(goipp.TagBeginCollection, letter) + readyCol.Values.Add(goipp.TagBeginCollection, a4) + add(readyCol) + add(kw("media-col-supported", "media-size", "media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin")) + sizes := goipp.Attribute{Name: "media-size-supported"} + sizes.Values.Add(goipp.TagBeginCollection, mediaSize(21590, 27940)) + sizes.Values.Add(goipp.TagBeginCollection, mediaSize(21000, 29700)) + add(sizes) + add(ints("media-bottom-margin-supported", goipp.TagInteger, 0)) + add(ints("media-left-margin-supported", goipp.TagInteger, 0)) + add(ints("media-right-margin-supported", goipp.TagInteger, 0)) + add(ints("media-top-margin-supported", goipp.TagInteger, 0)) add(kw("output-bin-default", "face-down")) add(kw("output-bin-supported", "face-down")) add(ints("orientation-requested-default", goipp.TagEnum, 3)) @@ -471,6 +530,22 @@ func filterAttrs(a goipp.Attributes, req *goipp.Message) goipp.Attributes { return out } +func mediaSize(w, h int) goipp.Collection { + c := goipp.Collection{} + c.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(w))) + c.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(h))) + return c +} + +func mediaCol(w, h, margin int) goipp.Collection { + c := goipp.Collection{} + c.Add(goipp.MakeAttribute("media-size", goipp.TagBeginCollection, mediaSize(w, h))) + for _, name := range []string{"media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"} { + c.Add(goipp.MakeAttribute(name, goipp.TagInteger, goipp.Integer(margin))) + } + return c +} + func kw(name string, vals ...string) goipp.Attribute { return multi(name, goipp.TagKeyword, vals...) } func multi(name string, tag goipp.Tag, vals ...string) goipp.Attribute { diff --git a/internal/netprint/launch_tray_windows.go b/internal/netprint/launch_tray_windows.go index a3d2811..8b05cc0 100644 --- a/internal/netprint/launch_tray_windows.go +++ b/internal/netprint/launch_tray_windows.go @@ -20,6 +20,7 @@ const trayTaskName = "VeraeNetPrintTrayLaunch" // when setup exits instead of waiting for the tray. func LaunchTrayNow() error { if trayAlreadyRunning() { + log.Printf("netprint: tray already running") fmt.Println("tray started") return nil } @@ -27,13 +28,20 @@ func LaunchTrayNow() error { if err != nil { return err } + // The dedicated tray exe is started through Explorer, as the interactive + // user when Task Scheduler is available. The scheduled task's process is + // explorer.exe, which forwards to the running shell and exits. The tray + // is not in that task's job, so the task's time limit and WM_CLOSE do + // not stop it a few minutes later. A second copy hits the tray mutex. var launchErr error if user, uerr := activeConsoleUser(); uerr != nil { launchErr = uerr + log.Printf("netprint: tray user: %v", uerr) } else if err := launchTrayTask(spec, user); err != nil { launchErr = err log.Printf("netprint: tray scheduled task: %v", err) } else { + log.Printf("netprint: tray launch requested via scheduled task") fmt.Println("tray started") return nil } diff --git a/internal/netprint/mdns.go b/internal/netprint/mdns.go index 88218dd..5114482 100644 --- a/internal/netprint/mdns.go +++ b/internal/netprint/mdns.go @@ -352,17 +352,15 @@ func encodeMessage(id, flags uint16, qs []dnsQuestion, ans, extra []dnsRR) []byt } func (rr dnsRR) rdata(comp map[string]int, off int) []byte { - _ = comp - _ = off var b bytes.Buffer switch rr.typ { case dnsTypePTR: - b.Write(encodeNameRaw(rr.ptr)) + b.Write(encodeNameAt(rr.ptr, comp, off)) case dnsTypeSRV: _ = binary.Write(&b, binary.BigEndian, uint16(0)) _ = binary.Write(&b, binary.BigEndian, uint16(0)) _ = binary.Write(&b, binary.BigEndian, uint16(rr.srvPort)) - b.Write(encodeNameRaw(rr.srvTarget)) + b.Write(encodeNameAt(rr.srvTarget, comp, off+6)) case dnsTypeTXT: if len(rr.txt) == 0 { b.WriteByte(0) @@ -499,6 +497,73 @@ func Query(id uint16, labels []string, typ uint16) []byte { return encodeMessage(id, 0, []dnsQuestion{{name: labels, typ: typ, class: dnsClassIN}}, nil, nil) } +// QueryUnicast is Query with the QU bit set (unicast response requested). +func QueryUnicast(id uint16, labels []string, typ uint16) []byte { + return encodeMessage(id, 0, []dnsQuestion{{name: labels, typ: typ, class: dnsClassIN | 0x8000}}, nil, nil) +} + +// queryUnicast reports whether any question asked for a unicast reply. +func queryUnicast(msg []byte) bool { + if len(msg) < 12 { + return false + } + n := int(binary.BigEndian.Uint16(msg[4:6])) + off := 12 + for i := 0; i < n; i++ { + _, next, err := parseName(msg, off) + if err != nil || next+4 > len(msg) { + return false + } + if binary.BigEndian.Uint16(msg[next+2:next+4])&0x8000 != 0 { + return true + } + off = next + 4 + } + return false +} + +func lanIPs() []net.IP { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var out []net.IP + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ip := addrIP(a) + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + if v4 := ip.To4(); v4 != nil { + out = append(out, v4) + continue + } + if v6 := ip.To16(); v6 != nil { + out = append(out, v6) + } + } + } + return out +} + +func addrIP(a net.Addr) net.IP { + switch v := a.(type) { + case *net.IPNet: + return v.IP + case *net.IPAddr: + return v.IP + default: + return nil + } +} + func lanIPv4() []net.IP { ifaces, err := net.Interfaces() if err != nil { diff --git a/internal/netprint/mdns_windows_test.go b/internal/netprint/mdns_dnssd_test.go similarity index 87% rename from internal/netprint/mdns_windows_test.go rename to internal/netprint/mdns_dnssd_test.go index d02fe18..c98f435 100644 --- a/internal/netprint/mdns_windows_test.go +++ b/internal/netprint/mdns_dnssd_test.go @@ -75,11 +75,10 @@ func sampleService() Service { } func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { - listened := false + pkt := &fakePkt{} prev := listenMDNSFn - listenMDNSFn = func() (*net.UDPConn, error) { - listened = true - return nil, errors.New("UDP 5353 must not be bound when DnsServiceRegister succeeds") + listenMDNSFn = func() (mdnsConn, error) { + return pkt, nil } t.Cleanup(func() { listenMDNSFn = prev }) @@ -96,9 +95,18 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { }) }() deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && api.count() < 5 { + for time.Now().Before(deadline) { + mu.Lock() + detail := got.Detail + mu.Unlock() + if api.count() >= 1 && pkt.count() >= 1 && strings.Contains(detail, "_universal._sub") { + break + } time.Sleep(10 * time.Millisecond) } + if api.count() != 1 || pkt.count() < 1 { + t.Fatalf("registered %d announcements %d", api.count(), pkt.count()) + } cancel() select { case err := <-done: @@ -108,45 +116,41 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("advertiseForOS did not return after cancel") } - if listened { - t.Fatal("shared UDP 5353 was opened even though DnsServiceRegister succeeded") - } mu.Lock() report := got mu.Unlock() - if report.Mode != "dns-sd" || !report.Advertising { + if report.Mode != "dns-sd" || !report.Advertising || report.Err != "" { t.Fatalf("status report %+v", report) } - if !strings.Contains(report.Detail, "DnsServiceRegister") || !strings.Contains(report.Detail, "631") { + if !strings.Contains(report.Detail, "DnsServiceRegister") || !strings.Contains(report.Detail, "631") || !strings.Contains(report.Detail, "_universal._sub") { t.Fatalf("detail %q", report.Detail) } - want := []string{ - "Verae Timestamp._ipp._tcp.local", - "Verae Timestamp._ipp._tcp,_universal.local", - "Verae Timestamp._ipp._tcp,_print.local", - "Verae Timestamp._universal._sub._ipp._tcp.local", - "Verae Timestamp._print._sub._ipp._tcp.local", - } gotNames := api.names() - if strings.Join(gotNames, "|") != strings.Join(want, "|") { + if len(gotNames) != 1 || gotNames[0] != "Verae Timestamp._ipp._tcp.local" { t.Fatalf("registered %v", gotNames) } + for _, name := range gotNames { + if strings.Contains(name, "_sub") || strings.Contains(name, ",") { + t.Fatalf("subtype service was registered: %s", name) + } + } api.mu.Lock() defer api.mu.Unlock() - if len(api.registered) != 5 || api.registered[0].Port != 631 || api.registered[0].Host != "host.local" { + if api.registered[0].Port != 631 || api.registered[0].Host != "host.local" { t.Fatalf("instance %+v", api.registered) } - if len(api.deregistered) != 5 { + if len(api.deregistered) != 1 { t.Fatalf("deregistered %d", len(api.deregistered)) } - if !strings.Contains(report.Detail, "_ipp._tcp,_universal") { - t.Fatalf("detail missing universal subtype %q", report.Detail) + ann := pkt.packet(0) + if err := assertPrimaryPTRs(ann, "Verae Timestamp"); err != nil { + t.Fatal(err) } } func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) { prev := listenMDNSFn - listenMDNSFn = func() (*net.UDPConn, error) { + listenMDNSFn = func() (mdnsConn, error) { return nil, errors.New("Only one usage of each socket address (protocol/network address/port) is normally permitted") } t.Cleanup(func() { listenMDNSFn = prev }) @@ -165,6 +169,13 @@ func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) { } func TestWindowsSubtypeFailureStillAdvertises(t *testing.T) { + // Subtype names are not registered. The shared socket carries them. + // A second DnsServiceRegister must not be attempted. + prev := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { + return &fakePkt{}, nil + } + t.Cleanup(func() { listenMDNSFn = prev }) api := &fakeDNS{err: errors.New("subtype rejected"), failSubtypes: true} ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) @@ -176,7 +187,7 @@ func TestWindowsSubtypeFailureStillAdvertises(t *testing.T) { time.Sleep(10 * time.Millisecond) } if api.count() != 1 { - t.Fatalf("primary count %d", api.count()) + t.Fatalf("primary count %d names %v", api.count(), api.names()) } cancel() if err := <-done; err != nil { @@ -202,9 +213,8 @@ func TestStatusShowsWindowsDNSDSuccess(t *testing.T) { }) defer restore() prevListen := listenMDNSFn - listenMDNSFn = func() (*net.UDPConn, error) { - t.Errorf("UDP listen called") - return nil, errors.New("should not bind") + listenMDNSFn = func() (mdnsConn, error) { + return &fakePkt{}, nil } defer func() { listenMDNSFn = prevListen }() @@ -241,7 +251,7 @@ func TestStatusShowsMDNSFailure(t *testing.T) { }) defer restore() prevListen := listenMDNSFn - listenMDNSFn = func() (*net.UDPConn, error) { + listenMDNSFn = func() (mdnsConn, error) { return nil, errors.New("Only one usage of each socket address") } defer func() { listenMDNSFn = prevListen }() diff --git a/internal/netprint/mdns_packet_test.go b/internal/netprint/mdns_packet_test.go new file mode 100644 index 0000000..67604be --- /dev/null +++ b/internal/netprint/mdns_packet_test.go @@ -0,0 +1,384 @@ +package netprint + +import ( + "encoding/binary" + "fmt" + "net" + "os" + "strings" + "sync" + "testing" + "time" +) + +type fakePkt struct { + mu sync.Mutex + writes [][]byte + once sync.Once + closed chan struct{} + closeMu sync.Once +} + +func (f *fakePkt) ensure() { + f.once.Do(func() { f.closed = make(chan struct{}) }) +} + +func (f *fakePkt) ReadFromUDP([]byte) (int, *net.UDPAddr, error) { + f.ensure() + <-f.closed + return 0, nil, net.ErrClosed +} + +func (f *fakePkt) WriteToUDP(b []byte, _ *net.UDPAddr) (int, error) { + f.ensure() + f.mu.Lock() + f.writes = append(f.writes, append([]byte(nil), b...)) + f.mu.Unlock() + return len(b), nil +} + +func (f *fakePkt) SetReadDeadline(time.Time) error { return nil } + +func (f *fakePkt) Close() error { + f.ensure() + f.closeMu.Do(func() { close(f.closed) }) + return nil +} + +func (f *fakePkt) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.writes) +} + +func (f *fakePkt) packet(i int) []byte { + f.mu.Lock() + defer f.mu.Unlock() + return f.writes[i] +} + +type rrView struct { + name string + typ uint16 + ttl uint32 + ptr string + port int + target string + txt []string + ip net.IP +} + +func parsePacket(msg []byte) (ans, extra []rrView, compressed bool, err error) { + if len(msg) < 12 { + return nil, nil, false, fmt.Errorf("short") + } + qd := int(binary.BigEndian.Uint16(msg[4:6])) + an := int(binary.BigEndian.Uint16(msg[6:8])) + ns := int(binary.BigEndian.Uint16(msg[8:10])) + ar := int(binary.BigEndian.Uint16(msg[10:12])) + off := 12 + for i := 0; i < qd; i++ { + _, off, err = skipQuestion(msg, off) + if err != nil { + return nil, nil, false, err + } + } + read := func(n int) ([]rrView, error) { + var out []rrView + for i := 0; i < n; i++ { + var rr rrView + var jumped bool + rr, off, jumped, err = parseRR(msg, off) + if err != nil { + return nil, err + } + if jumped { + compressed = true + } + out = append(out, rr) + } + return out, nil + } + if ans, err = read(an); err != nil { + return nil, nil, false, err + } + if _, err = read(ns); err != nil { + return nil, nil, false, err + } + if extra, err = read(ar); err != nil { + return nil, nil, false, err + } + return ans, extra, compressed, nil +} + +func skipQuestion(msg []byte, off int) (int, int, error) { + _, next, err := parseName(msg, off) + if err != nil { + return 0, 0, err + } + if next+4 > len(msg) { + return 0, 0, fmt.Errorf("truncated question") + } + return 0, next + 4, nil +} + +func parseRR(msg []byte, off int) (rrView, int, bool, error) { + labels, next, err := parseName(msg, off) + jumped := nameUsesPointer(msg, off) + if err != nil { + return rrView{}, 0, false, err + } + off = next + if off+10 > len(msg) { + return rrView{}, 0, false, fmt.Errorf("truncated rr") + } + rr := rrView{ + name: strings.ToLower(strings.Join(labels, ".")), + typ: binary.BigEndian.Uint16(msg[off : off+2]), + ttl: binary.BigEndian.Uint32(msg[off+4 : off+8]), + } + rdlen := int(binary.BigEndian.Uint16(msg[off+8 : off+10])) + off += 10 + if off+rdlen > len(msg) { + return rrView{}, 0, false, fmt.Errorf("truncated rdata") + } + rdata := msg[off : off+rdlen] + off += rdlen + switch rr.typ { + case dnsTypePTR: + pl, _, err := parseName(msg, off-rdlen) + if err != nil { + return rrView{}, 0, false, err + } + if nameUsesPointer(msg, off-rdlen) { + jumped = true + } + rr.ptr = strings.ToLower(strings.Join(pl, ".")) + case dnsTypeSRV: + if len(rdata) < 6 { + return rrView{}, 0, false, fmt.Errorf("short srv") + } + rr.port = int(binary.BigEndian.Uint16(rdata[4:6])) + tl, _, err := parseName(msg, off-rdlen+6) + if err != nil { + return rrView{}, 0, false, err + } + if nameUsesPointer(msg, off-rdlen+6) { + jumped = true + } + rr.target = strings.ToLower(strings.Join(tl, ".")) + case dnsTypeTXT: + for i := 0; i < len(rdata); { + n := int(rdata[i]) + i++ + if i+n > len(rdata) { + return rrView{}, 0, false, fmt.Errorf("bad txt") + } + rr.txt = append(rr.txt, string(rdata[i:i+n])) + i += n + } + case dnsTypeA: + if len(rdata) == 4 { + rr.ip = net.IP(rdata) + } + case dnsTypeAAAA: + if len(rdata) == 16 { + rr.ip = net.IP(rdata) + } + } + return rr, off, jumped, nil +} + +func nameUsesPointer(msg []byte, off int) bool { + for hops := 0; hops < 16 && off < len(msg); hops++ { + l := int(msg[off]) + if l == 0 { + return false + } + if l&0xC0 == 0xC0 { + return true + } + if l&0xC0 != 0 || off+1+l > len(msg) { + return false + } + off += 1 + l + } + return false +} + +func assertPrimaryPTRs(msg []byte, instance string) error { + ans, extra, compressed, err := parsePacket(msg) + if err != nil { + return err + } + want := strings.ToLower(instance) + "._ipp._tcp.local" + need := map[string]bool{ + "_ipp._tcp.local": false, + "_universal._sub._ipp._tcp.local": false, + "_print._sub._ipp._tcp.local": false, + } + var sawSRV, sawTXT, sawA bool + for _, rr := range append(ans, extra...) { + if rr.typ == dnsTypePTR { + if _, ok := need[rr.name]; ok { + if rr.ptr != want { + return fmt.Errorf("PTR %s target %q, want %q", rr.name, rr.ptr, want) + } + need[rr.name] = true + } + } + if rr.typ == dnsTypeSRV && rr.port == 631 && rr.target == "host.local" && rr.name == want { + sawSRV = true + } + if rr.typ == dnsTypeTXT && rr.name == want { + joined := strings.Join(rr.txt, "\n") + if strings.Contains(joined, "rp=ipp/print") { + sawTXT = true + } + } + if rr.typ == dnsTypeA && rr.ip.Equal(net.ParseIP("192.168.1.184")) && rr.name == "host.local" { + sawA = true + } + } + for name, ok := range need { + if !ok { + return fmt.Errorf("missing PTR %s", name) + } + } + if !sawSRV || !sawTXT || !sawA { + return fmt.Errorf("srv %v txt %v a %v", sawSRV, sawTXT, sawA) + } + if !compressed { + return fmt.Errorf("announcement did not compress a repeated name") + } + return nil +} + +func TestMDNSPacketLayout(t *testing.T) { + svc := sampleService() + pkt := svc.Announce(false) + if err := assertPrimaryPTRs(pkt, "Verae Timestamp"); err != nil { + t.Fatal(err) + } + ans, _, _, err := parsePacket(pkt) + if err != nil { + t.Fatal(err) + } + for _, rr := range ans { + if rr.typ == dnsTypePTR && rr.ttl != mdnsTTL { + t.Fatalf("PTR ttl %d", rr.ttl) + } + } + bye := svc.Announce(true) + ans, _, _, err = parsePacket(bye) + if err != nil { + t.Fatal(err) + } + var ptrs int + for _, rr := range ans { + if rr.typ == dnsTypePTR { + ptrs++ + if rr.ttl != 0 { + t.Fatalf("goodbye ttl %d", rr.ttl) + } + if rr.ptr != "verae timestamp._ipp._tcp.local" { + t.Fatalf("goodbye target %s", rr.ptr) + } + } + } + if ptrs != 3 { + t.Fatalf("goodbye ptrs %d", ptrs) + } + + q := QueryUnicast(9, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR) + if !queryUnicast(q) { + t.Fatal("QU bit not set") + } + if queryUnicast(Query(9, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) { + t.Fatal("plain query reported QU") + } + resp := svc.Reply(q) + if resp == nil { + t.Fatal("no QU reply") + } + ans, extra, _, err := parsePacket(resp) + if err != nil { + t.Fatal(err) + } + found := false + for _, rr := range append(ans, extra...) { + if rr.typ == dnsTypePTR && rr.name == "_universal._sub._ipp._tcp.local" { + found = rr.ptr == "verae timestamp._ipp._tcp.local" + } + } + if !found { + t.Fatal("QU subtype PTR does not point at the primary instance") + } + plain := svc.Reply(Query(3, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) + if plain == nil { + t.Fatal("plain browse reply missing") + } + if err := func() error { + a, e, _, err := parsePacket(plain) + if err != nil { + return err + } + for _, rr := range append(a, e...) { + if rr.typ == dnsTypePTR && rr.name == "_ipp._tcp.local" && rr.ptr == "verae timestamp._ipp._tcp.local" { + return nil + } + } + return fmt.Errorf("plain PTR target") + }(); err != nil { + t.Fatal(err) + } +} + +func TestIPv6MulticastRequest(t *testing.T) { + b := ipv6GroupReq(7) + if got := net.IP(b[:16]).String(); got != "ff02::fb" { + t.Fatalf("group %s", got) + } + if binary.LittleEndian.Uint32(b[16:]) != 7 { + t.Fatalf("ifindex %d", binary.LittleEndian.Uint32(b[16:])) + } +} + +func TestWindowsListenJoinsBothGroups(t *testing.T) { + src, err := readSource("advertise_windows.go") + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{"SO_REUSEADDR", "224.0.0.251", "ff02::fb", "SO_EXCLUSIVEADDRUSE", "ipv6GroupReq"} { + if !strings.Contains(src, needle) { + t.Fatalf("advertise_windows.go missing %s", needle) + } + } +} + +func TestTraySingleInstanceAndLog(t *testing.T) { + if trayLoopExit(0) != "WM_QUIT" || trayLoopExit(-1) != "GetMessage error" { + t.Fatal(trayLoopExit(0)) + } + src, err := readSource("tray_windows.go") + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{"CreateMutexW", `Local\VeraeNetPrintTray`, "setupServiceLog", "tray message loop ended", "WM_CLOSE", "tray already running"} { + if !strings.Contains(src, needle) { + t.Fatalf("tray_windows.go missing %s", needle) + } + } + launch, err := readSource("launch_tray_windows.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(launch, "explorer.exe") { + t.Fatal("tray launch must go through Explorer so Task Scheduler does not own the process") + } +} + +func readSource(name string) (string, error) { + raw, err := os.ReadFile(name) + return string(raw), err +} diff --git a/internal/netprint/netprint_test.go b/internal/netprint/netprint_test.go index 4b1ea21..063d440 100644 --- a/internal/netprint/netprint_test.go +++ b/internal/netprint/netprint_test.go @@ -335,13 +335,13 @@ func TestRasterRoundTripAndMenu(t *testing.T) { pwg := make([]byte, 4+pwgHeaderSize) copy(pwg, []byte("RaS2")) h := pwg[4:] - binary.LittleEndian.PutUint32(h[372:], 1) - binary.LittleEndian.PutUint32(h[376:], 1) - binary.LittleEndian.PutUint32(h[384:], 8) - binary.LittleEndian.PutUint32(h[388:], 24) - binary.LittleEndian.PutUint32(h[392:], 3) - binary.LittleEndian.PutUint32(h[400:], uint32(cspaceSRGB)) - binary.LittleEndian.PutUint32(h[276:], 300) + binary.BigEndian.PutUint32(h[372:], 1) + binary.BigEndian.PutUint32(h[376:], 1) + binary.BigEndian.PutUint32(h[384:], 8) + binary.BigEndian.PutUint32(h[388:], 24) + binary.BigEndian.PutUint32(h[392:], 3) + binary.BigEndian.PutUint32(h[400:], uint32(cspaceSRGB)) + binary.BigEndian.PutUint32(h[276:], 300) pwg = append(pwg, 0x00, 0x00, 0xff, 0x00, 0x00) // repeat pixel once of 3 bytes? bpp=3, command 0 means 1 pixel // command 0 + 3 bytes pages, err = RasterToPages(pwg) @@ -527,7 +527,7 @@ func TestTrayLaunchIsDetachedFromSetup(t *testing.T) { t.Fatal("tray launch should use a scheduled task or Explorer") } spec := trayLaunch{exe: `C:\Program Files\Verae\NetPrint\bin\verae-netprint-tray.exe`} - if spec.taskCommand() != spec.exe { + if spec.taskCommand() != `explorer.exe "`+spec.exe+`"` { t.Fatalf("task command %q", spec.taskCommand()) } withArgs := trayLaunch{exe: `C:\Program Files\Verae\PrintStamp\bin\verae-print.exe`, args: []string{"tray"}} diff --git a/internal/netprint/printjob_test.go b/internal/netprint/printjob_test.go new file mode 100644 index 0000000..9d85495 --- /dev/null +++ b/internal/netprint/printjob_test.go @@ -0,0 +1,226 @@ +package netprint + +import ( + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/OpenPrinting/goipp" +) + +func TestMediaColLetterAndA4(t *testing.T) { + p := &Printer{Name: "Verae Timestamp"} + req := ippReq(goipp.OpGetPrinterAttributes, "nimo.local:631") + r, _ := http.NewRequest(http.MethodPost, "http://nimo.local:631/ipp/print", nil) + attrs := p.printerAttrs(req, r) + def := attrByName(attrs, "media-col-default") + if def.Name == "" || len(def.Values) != 1 { + t.Fatal("missing media-col-default") + } + col, ok := def.Values[0].V.(goipp.Collection) + if !ok { + t.Fatalf("media-col-default type %T", def.Values[0].V) + } + if x, y := collectionSize(col); x != 21590 || y != 27940 { + t.Fatalf("letter size %d x %d", x, y) + } + for _, m := range []string{"media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"} { + if _, found := collectionInt(col, m); !found { + t.Fatalf("missing %s", m) + } + } + ready := attrByName(attrs, "media-col-ready") + if len(ready.Values) != 2 { + t.Fatalf("media-col-ready %d", len(ready.Values)) + } + a4, ok := ready.Values[1].V.(goipp.Collection) + if !ok { + t.Fatalf("a4 %T", ready.Values[1].V) + } + if x, y := collectionSize(a4); x != 21000 || y != 29700 { + t.Fatalf("a4 size %d x %d", x, y) + } + sizes := attrByName(attrs, "media-size-supported") + if len(sizes.Values) != 2 { + t.Fatalf("media-size-supported %d", len(sizes.Values)) + } + if !hasKeyword(attrs, "media-default", "na_letter_8.5x11in") || !hasKeyword(attrs, "media-ready", "iso_a4_210x297mm") || !hasKeyword(attrs, "media-supported", "na_letter_8.5x11in") { + t.Fatal("media keywords") + } + if !hasKeyword(attrs, "media-col-supported", "media-size") || !hasKeyword(attrs, "media-col-supported", "media-top-margin") { + t.Fatal("media-col-supported") + } + msg := goipp.NewResponse(goipp.DefaultVersion, goipp.StatusOk, 1) + msg.Printer = attrs + raw, err := msg.EncodeBytes() + if err != nil { + t.Fatal(err) + } + var back goipp.Message + if err := back.DecodeBytes(raw); err != nil { + t.Fatal(err) + } + again := attrByName(back.Printer, "media-col-default") + col, ok = again.Values[0].V.(goipp.Collection) + if !ok { + t.Fatalf("round trip %T", again.Values[0].V) + } + if x, y := collectionSize(col); x != 21590 || y != 27940 { + t.Fatalf("round trip size %d x %d", x, y) + } +} + +func attrByName(attrs goipp.Attributes, name string) goipp.Attribute { + for _, a := range attrs { + if a.Name == name { + return a + } + } + return goipp.Attribute{} +} + +func collectionSize(c goipp.Collection) (int, int) { + for _, a := range c { + if a.Name != "media-size" || len(a.Values) == 0 { + continue + } + inner, ok := a.Values[0].V.(goipp.Collection) + if !ok { + return 0, 0 + } + x, _ := collectionInt(inner, "x-dimension") + y, _ := collectionInt(inner, "y-dimension") + return x, y + } + return 0, 0 +} + +func collectionInt(c goipp.Collection, name string) (int, bool) { + for _, a := range c { + if a.Name == name && len(a.Values) > 0 { + if v, ok := a.Values[0].V.(goipp.Integer); ok { + return int(v), true + } + } + } + return 0, false +} + +func TestPrintJobReadsContentLengthWithoutEOF(t *testing.T) { + doc := []byte("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") + body := printJobBytes(t, "127.0.0.1", doc) + ln := servePrinter(t, doc) + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + deadline := time.Now().Add(2 * time.Second) + _ = conn.SetDeadline(deadline) + host := ln.Addr().String() + fmt.Fprintf(conn, "POST /ipp/print HTTP/1.1\r\nHost: %s\r\nContent-Type: application/ipp\r\nContent-Length: %d\r\nConnection: keep-alive\r\n\r\n", host, len(body)) + if _, err := conn.Write(body); err != nil { + t.Fatal(err) + } + // The write side stays open. A reader that waits for EOF blocks here. + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil && n == 0 { + t.Fatal(err) + } + if !strings.Contains(string(buf[:n]), "200") { + t.Fatalf("response %q", buf[:n]) + } + if time.Now().After(deadline) { + t.Fatal("print-job exceeded 2s") + } +} + +func TestPrintJobExpect100Continue(t *testing.T) { + doc := []byte("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") + body := printJobBytes(t, "127.0.0.1", doc) + ln := servePrinter(t, doc) + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + fmt.Fprintf(conn, "POST /ipp/print HTTP/1.1\r\nHost: %s\r\nContent-Type: application/ipp\r\nContent-Length: %d\r\nExpect: 100-continue\r\n\r\n", ln.Addr().String(), len(body)) + hdr := readHTTPHead(t, conn) + if !strings.Contains(hdr, "100") { + t.Fatalf("expected 100 Continue, got %q", hdr) + } + if _, err := conn.Write(body); err != nil { + t.Fatal(err) + } + rest := readHTTPHead(t, conn) + if !strings.Contains(rest, "200") { + t.Fatalf("response %q", rest) + } +} + +func servePrinter(t *testing.T, want []byte) net.Listener { + t.Helper() + started := make(chan []byte, 1) + p := &Printer{ + Name: "Verae Timestamp", + OnDocument: func(id int, format, title, user string, doc []byte) { + started <- append([]byte(nil), doc...) + }, + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: p} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + t.Cleanup(func() { + select { + case got := <-started: + if string(got) != string(want) { + t.Fatalf("document %q", got) + } + case <-time.After(2 * time.Second): + t.Fatal("job did not start") + } + }) + return ln +} + +func printJobBytes(t *testing.T, host string, doc []byte) []byte { + t.Helper() + m := ippReq(goipp.OpPrintJob, host+"/ipp/print") + m.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("ada"))) + m.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("note"))) + m.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + b, err := m.EncodeBytes() + if err != nil { + t.Fatal(err) + } + return append(b, doc...) +} + +func readHTTPHead(t *testing.T, r io.Reader) string { + t.Helper() + var buf []byte + tmp := make([]byte, 1) + for !strings.Contains(string(buf), "\r\n\r\n") { + n, err := r.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + t.Fatalf("read headers: %v %q", err, buf) + } + if len(buf) > 8192 { + t.Fatalf("headers too long %q", buf) + } + } + return string(buf) +} diff --git a/internal/netprint/pwg_test.go b/internal/netprint/pwg_test.go new file mode 100644 index 0000000..90e9fbd --- /dev/null +++ b/internal/netprint/pwg_test.go @@ -0,0 +1,88 @@ +package netprint + +import ( + "encoding/binary" + "strings" + "testing" +) + +func TestPWGHeaderBigEndian(t *testing.T) { + h := make([]byte, pwgHeaderSize) + put := binary.BigEndian.PutUint32 + put(h[276:], 600) + put(h[372:], 5100) + put(h[376:], 6600) + put(h[384:], 8) + put(h[388:], 24) + put(h[392:], 5100*3) + put(h[396:], 0) + put(h[400:], uint32(cspaceSRGB)) + // 5100 is 00 00 13 EC. A little-endian read is the field failure 0xEC130000. + if binary.LittleEndian.Uint32(h[372:376]) != 0xEC130000 { + t.Fatalf("width bytes %x", h[372:376]) + } + w, ht, dpi, bpc, bpp, bpl, order, cs := pwgFields(h, true) + if w != 5100 || ht != 6600 || dpi != 600 || bpc != 8 || bpp != 24 || bpl != 5100*3 || order != 0 || cs != cspaceSRGB { + t.Fatalf("fields %d %d dpi %d bpc %d bpp %d bpl %d order %d cs %d", w, ht, dpi, bpc, bpp, bpl, order, cs) + } + + small := make([]byte, pwgHeaderSize) + put(small[276:], 600) + put(small[372:], 1) + put(small[376:], 1) + put(small[384:], 8) + put(small[388:], 24) + put(small[392:], 3) + put(small[400:], uint32(cspaceSRGB)) + doc := append([]byte("RaS2"), small...) + doc = append(doc, 0x00, 0x00, 0xff, 0x00, 0x00) // one RGB pixel + pages, err := RasterToPages(doc) + if err != nil { + t.Fatal(err) + } + if pages[0].Width != 1 || pages[0].Height != 1 || pages[0].DPI != 600 { + t.Fatalf("page %+v", pages[0]) + } + + native := make([]byte, pwgHeaderSize) + le := binary.LittleEndian.PutUint32 + le(native[276:], 300) + le(native[372:], 2) + le(native[376:], 1) + le(native[384:], 8) + le(native[388:], 24) + le(native[392:], 6) + le(native[400:], uint32(cspaceSRGB)) + doc = append([]byte("2SaR"), native...) + doc = append(doc, 0x00, 0x01, 0x11, 0x22, 0x33) // one row, two pixels + pages, err = RasterToPages(doc) + if err != nil { + t.Fatal(err) + } + if pages[0].Width != 2 || pages[0].Height != 1 || pages[0].DPI != 300 { + t.Fatalf("native page %+v", pages[0]) + } +} + +func TestPWGTruncatedHeaderRejected(t *testing.T) { + doc := append([]byte("RaS2"), make([]byte, 100)...) + _, err := RasterToPages(doc) + if err == nil || !strings.Contains(err.Error(), "truncated") { + t.Fatalf("err %v", err) + } + h := make([]byte, pwgHeaderSize) + binary.BigEndian.PutUint32(h[372:], 5100) + binary.BigEndian.PutUint32(h[376:], 6600) + binary.BigEndian.PutUint32(h[384:], 8) + binary.BigEndian.PutUint32(h[388:], 24) + binary.BigEndian.PutUint32(h[392:], 5100*3) + binary.BigEndian.PutUint32(h[400:], uint32(cspaceSRGB)) + binary.BigEndian.PutUint32(h[276:], 600) + _, err = RasterToPages(append([]byte("RaS2"), h...)) + if err == nil { + t.Fatal("header without pixels decoded") + } + if strings.Contains(err.Error(), "3960668160") || strings.Contains(err.Error(), "3357081600") { + t.Fatal(err) + } +} diff --git a/internal/netprint/raster.go b/internal/netprint/raster.go index 428f19f..f9cac72 100644 --- a/internal/netprint/raster.go +++ b/internal/netprint/raster.go @@ -153,7 +153,11 @@ func fillByte(cs int) byte { } func decodePWG(doc []byte) ([]Page, error) { - swapped := bytes.HasPrefix(doc, []byte("2SaR")) + // PWG 5102.4 / CUPS: the sync word "RaS2" means the header integers are + // big-endian (network byte order). "2SaR" is the little-endian native + // form. cupsfilter writes "RaS2"; reading those fields little-endian + // turns 5100 (00 00 13 EC) into 0xEC130000. + big := bytes.HasPrefix(doc, []byte("RaS2")) off := 4 var pages []Page for len(pages) < maxPages { @@ -168,14 +172,7 @@ func decodePWG(doc []byte) ([]Page, error) { } h := doc[off : off+pwgHeaderSize] off += pwgHeaderSize - width := int(u32at(h, 372, swapped)) - height := int(u32at(h, 376, swapped)) - bpc := int(u32at(h, 384, swapped)) - bppBits := int(u32at(h, 388, swapped)) - bpl := int(u32at(h, 392, swapped)) - order := int(u32at(h, 396, swapped)) - cs := int(u32at(h, 400, swapped)) - dpi := int(u32at(h, 276, swapped)) + width, height, dpi, bpc, bppBits, bpl, order, cs := pwgFields(h, big) if width <= 0 || height <= 0 || bpl <= 0 || bppBits <= 0 { return nil, fmt.Errorf("pwg: bad geometry %dx%d bpl=%d bpp=%d", width, height, bpl, bppBits) } @@ -216,15 +213,22 @@ func pwgColors(cs, bppBits, bpp int) int { } } -func u32at(h []byte, off int, swapped bool) uint32 { +// pwgFields reads every header integer the decoder uses. big is true for a +// "RaS2" page (network byte order) and false for "2SaR". +func pwgFields(h []byte, big bool) (width, height, dpi, bpc, bppBits, bpl, order, cs int) { + return int(u32at(h, 372, big)), int(u32at(h, 376, big)), int(u32at(h, 276, big)), + int(u32at(h, 384, big)), int(u32at(h, 388, big)), int(u32at(h, 392, big)), + int(u32at(h, 396, big)), int(u32at(h, 400, big)) +} + +func u32at(h []byte, off int, big bool) uint32 { if off+4 > len(h) { return 0 } - v := binary.LittleEndian.Uint32(h[off : off+4]) - if swapped { - v = v<<24 | (v&0xff00)<<8 | (v&0xff0000)>>8 | v>>24 + if big { + return binary.BigEndian.Uint32(h[off : off+4]) } - return v + return binary.LittleEndian.Uint32(h[off : off+4]) } // decodeCompressedPage reads CUPS modified PackBits and returns the page plus diff --git a/internal/netprint/server.go b/internal/netprint/server.go index c6a1386..ef79f50 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -148,7 +148,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) go func() { err := startAdvertiser(ctx, func() Service { c := s.snapshot() - ips := lanIPv4() + ips := lanIPs() adminURL := s.printer.AdminURL return Service{ Instance: c.Name, diff --git a/internal/netprint/tray.go b/internal/netprint/tray.go index a2a9bd0..889f9aa 100644 --- a/internal/netprint/tray.go +++ b/internal/netprint/tray.go @@ -6,3 +6,15 @@ import "context" // item, or a freedesktop StatusNotifierItem). It talks to the print service // over the loopback control API. func RunTray(ctx context.Context) error { return runTray(ctx) } + +// trayLoopExit describes why the Windows message loop returned. +func trayLoopExit(code int32) string { + switch { + case code == 0: + return "WM_QUIT" + case code < 0: + return "GetMessage error" + default: + return "stopped" + } +} diff --git a/internal/netprint/tray_launch.go b/internal/netprint/tray_launch.go index 640f3ad..2a97c7b 100644 --- a/internal/netprint/tray_launch.go +++ b/internal/netprint/tray_launch.go @@ -12,7 +12,9 @@ type trayLaunch struct { func (t trayLaunch) taskCommand() string { if len(t.args) == 0 { - return t.exe + // explorer.exe exits after asking the running shell to start the + // tray, so Task Scheduler does not own the tray process. + return `explorer.exe "` + t.exe + `"` } return `"` + t.exe + `" ` + strings.Join(t.args, " ") } diff --git a/internal/netprint/tray_windows.go b/internal/netprint/tray_windows.go index f946576..723a1e4 100644 --- a/internal/netprint/tray_windows.go +++ b/internal/netprint/tray_windows.go @@ -4,6 +4,8 @@ package netprint import ( "context" + "fmt" + "log" "syscall" "unsafe" ) @@ -46,6 +48,7 @@ var ( pGetCursorPos = user32.NewProc("GetCursorPos") pShellNotify = shell32.NewProc("Shell_NotifyIconW") pGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + pCreateMutexW = kernel32.NewProc("CreateMutexW") wndProcCB = syscall.NewCallback(trayWndProc) trayHWND uintptr trayIconData notifyIcon @@ -89,17 +92,31 @@ type notifyIcon struct { } func runTray(ctx context.Context) error { + setupServiceLog() + release, ok, err := acquireTrayMutex() + if err != nil { + log.Printf("netprint: tray mutex: %v", err) + return err + } + if !ok { + log.Printf("netprint: tray already running; this copy is exiting") + return nil + } + defer release() + log.Printf("netprint: tray starting") inst, _, _ := pGetModuleHandleW.Call(0) class, _ := syscall.UTF16PtrFromString("VeraeNetPrintTray") wc := wndClassEx{wndProc: wndProcCB, instance: inst, className: class} wc.size = uint32(unsafe.Sizeof(wc)) if r, _, err := pRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))); r == 0 { - return err + log.Printf("netprint: tray window class: %v", err) + return fmt.Errorf("tray window class: %w", err) } title, _ := syscall.UTF16PtrFromString("Verae Network Printer") hwnd, _, err := pCreateWindowExW.Call(0, uintptr(unsafe.Pointer(class)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, inst, 0) if hwnd == 0 { - return err + log.Printf("netprint: tray window: %v", err) + return fmt.Errorf("tray window: %w", err) } trayHWND = hwnd icon, _, _ := pLoadIconW.Call(0, idiApp) @@ -110,20 +127,45 @@ func runTray(ctx context.Context) error { trayIconData.cbSize = uint32(unsafe.Sizeof(trayIconData)) tip, _ := syscall.UTF16FromString("Verae Timestamp") copy(trayIconData.szTip[:], tip) - pShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&trayIconData))) + if r, _, err := pShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&trayIconData))); r == 0 { + log.Printf("netprint: tray icon: %v", err) + } defer pShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&trayIconData))) + log.Printf("netprint: tray icon added") var m winMsg + var code int32 for { - r, _, _ := pGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) - if int32(r) <= 0 { + r, _, err := pGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + code = int32(r) + if code <= 0 { + if code < 0 { + log.Printf("netprint: tray GetMessage: %v", err) + } break } pTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) pDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) } + log.Printf("netprint: tray message loop ended (%s)", trayLoopExit(code)) return nil } +func acquireTrayMutex() (func(), bool, error) { + name, err := syscall.UTF16PtrFromString(`Local\VeraeNetPrintTray`) + if err != nil { + return nil, false, err + } + h, _, callErr := pCreateMutexW.Call(0, 0, uintptr(unsafe.Pointer(name))) + if h == 0 { + return nil, false, callErr + } + if errno, ok := callErr.(syscall.Errno); ok && errno == 183 { // ERROR_ALREADY_EXISTS + syscall.CloseHandle(syscall.Handle(h)) + return nil, false, nil + } + return func() { syscall.CloseHandle(syscall.Handle(h)) }, true, nil +} + func trayWndProc(hwnd, msg, wParam, lParam uintptr) uintptr { switch msg { case wmUser + 1: @@ -145,7 +187,10 @@ func trayWndProc(hwnd, msg, wParam, lParam uintptr) uintptr { } }() return 0 + case 0x0010: // WM_CLOSE + log.Printf("netprint: tray received WM_CLOSE") case wmDestroy: + log.Printf("netprint: tray window destroyed") pPostQuitMessage.Call(0) return 0 } diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi index 18e073d..fe80938 100644 --- a/platform/windows/installer-netprint.nsi +++ b/platform/windows/installer-netprint.nsi @@ -204,14 +204,11 @@ Section "Verae Network Printer" SecMain nsExec::ExecToStack $R9 Pop $0 Pop $1 - nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint launch-tray' - Pop $3 - Pop $4 ClearErrors FileOpen $2 "$LogFile" a ${IfNot} ${Errors} FileSeek $2 0 END - FileWrite $2 "netprint install allow-public=$R8 local-queue=$LocalQueue exit $0$\r$\n$1$\r$\nlaunch-tray exit $3$\r$\n$4$\r$\n" + FileWrite $2 "netprint install allow-public=$R8 local-queue=$LocalQueue exit $0$\r$\n$1$\r$\n" FileClose $2 ${EndIf} ${If} $0 != 0 diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index d21f9f3..3259866 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -839,14 +839,11 @@ Section "Verae Print Stamp + printer" SecMain nsExec::ExecToStack $R9 Pop $0 Pop $1 - nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint launch-tray' - Pop $3 - Pop $4 ClearErrors FileOpen $2 "$LogDir\netprint-installer.log" a ${IfNot} ${Errors} FileSeek $2 0 END - FileWrite $2 "driver netprint install allow-public=$R8 local-queue=$NetQueue exit $0$\r$\n$1$\r$\nlaunch-tray exit $3$\r$\n$4$\r$\n" + FileWrite $2 "driver netprint install allow-public=$R8 local-queue=$NetQueue exit $0$\r$\n$1$\r$\n" FileClose $2 ${EndIf} ${If} $0 == 0 From 17bb0670104a8333c8337f68f45baa644cb3d503 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 00:34:34 +0000 Subject: [PATCH 15/27] Keep one IPP advertisement and accept link-local IPv6. DnsServiceRegister stays the only owner of the _ipp._tcp instance. The shared UDP responder answers subtype PTRs only, so mDNS no longer renames a second copy to Name(1). The IPP port listens on IPv4 and on [::] with IPV6_V6ONLY, and the firewall rules allow fe80::/10, so a Mac connect to the host's link-local address completes. Co-authored-by: George Lambert --- docs/network-printer.md | 4 +- internal/netprint/dnsservice.go | 43 ++++-- internal/netprint/firewall.go | 6 + internal/netprint/listen.go | 179 +++++++++++++++++++++++ internal/netprint/listen_ipv6_unix.go | 13 ++ internal/netprint/listen_ipv6_windows.go | 13 ++ internal/netprint/listen_test.go | 120 +++++++++++++++ internal/netprint/mdns.go | 44 +++++- internal/netprint/mdns_dnssd_test.go | 37 ++++- internal/netprint/mdns_packet_test.go | 123 ++++++++++++++++ internal/netprint/server.go | 6 +- internal/netprint/service_windows.go | 2 +- platform/windows/installer-netprint.nsi | 7 +- platform/windows/installer-windows.nsi | 7 +- 14 files changed, 575 insertions(+), 29 deletions(-) create mode 100644 internal/netprint/listen.go create mode 100644 internal/netprint/listen_ipv6_unix.go create mode 100644 internal/netprint/listen_ipv6_windows.go create mode 100644 internal/netprint/listen_test.go diff --git a/docs/network-printer.md b/docs/network-printer.md index 6975217..6bcc039 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) and answers `_ipp._tcp`, `_universal._sub._ipp._tcp`, and `_print._sub._ipp._tcp` itself. Every one of those PTRs points at `._ipp._tcp.local`, with SRV, TXT, and address records. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` both list the printer. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://:/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, and whether discovery is advertising. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -31,7 +31,7 @@ Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward ## Firewall -The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. A checkbox, off by default, also allows Public networks. A silent install can pass `/PUBLIC=1`. The tray has the same switch. The rules are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback. +The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. Each rule sets `remoteip=any`, which includes IPv4, global IPv6, and link-local `fe80::/10`. A checkbox, off by default, also allows Public networks. A silent install can pass `/PUBLIC=1`. The tray has the same switch. The rules are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback. An optional checkbox (silent `/QUEUE=1`) adds a local Windows printer named **Verae Timestamp** aimed at `http://127.0.0.1:/ipp/print`. It is removed when the last NetPrint product is uninstalled. The driver printer **Verae TimeStamp PDF** is a different queue. diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 7fd75af..2ff0530 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -34,7 +34,8 @@ func serviceToDNS(s Service) []dnsSDInstance { // that long name, which `dns-sd -B _ipp._tcp,_universal` does not accept, and // those extra services also stopped plain `_ipp._tcp` browse from listing the // printer. Only the primary instance is registered here. Subtype PTRs are - // sent by the shared UDP responder and point at this same name. + // sent by the shared UDP responder, point at this same name, and do not + // include SRV, TXT, or address records. return []dnsSDInstance{{ InstanceName: base + "._ipp._tcp.local", Host: host, @@ -77,11 +78,16 @@ func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClie // advertiseForOS is the discovery strategy for goos. // Windows registers only the primary name with DnsServiceRegister (Dnscache -// already owns UDP 5353 and answers plain _ipp._tcp browse). Subtype PTRs -// cannot be expressed there, so a shared UDP 5353 socket always runs as well -// and answers _ipp._tcp plus _universal._sub and _print._sub, all pointing at -// the primary instance. The socket sets SO_REUSEADDR and joins the mDNS groups -// so it shares the port with the Windows stack instead of replacing it. +// already owns UDP 5353 and answers plain _ipp._tcp browse, including SRV, +// TXT, and the host addresses). Subtype PTRs cannot be expressed there, so a +// shared UDP 5353 socket runs as well, but only after registration is decided. +// While DnsServiceRegister holds the instance, that socket answers +// _universal._sub and _print._sub PTRs and never the base _ipp._tcp PTR, SRV, +// TXT, or A/AAAA. Publishing those unique records from both stacks makes mDNS +// rename one advertisement to "Name(1)". If DnsServiceRegister fails, the UDP +// socket advertises the full set so plain browse still works. The socket sets +// SO_REUSEADDR and joins the mDNS groups so it shares the port with the +// Windows stack instead of replacing it. func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { if goos != "windows" { return advertiseUDP(ctx, current, sink) @@ -105,7 +111,7 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap sink(mdnsReport{ Advertising: true, Mode: "dns-sd", - Detail: st.dnsDetail + "; shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub", + Detail: st.dnsDetail + "; shared UDP 5353 subtype PTR _universal._sub and _print._sub", LastSent: time.Now(), }) case st.dnsOK: @@ -162,7 +168,24 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap } ready() }() - udpErr := advertiseUDP(ctx, current, func(r mdnsReport) { + select { + case <-dnsReady: + case <-ctx.Done(): + <-dnsDone + return nil + } + mu.Lock() + dnsOK := st.dnsOK + mu.Unlock() + // Snapshot dnsOK. Registration does not succeed later in this call, and + // flipping SubtypesOnly on after a full announcement would already have + // probed a second instance. + udpCurrent := func() Service { + s := current() + s.SubtypesOnly = dnsOK + return s + } + udpErr := advertiseUDP(ctx, udpCurrent, func(r mdnsReport) { mu.Lock() if r.Advertising { st.udpOK = true @@ -181,10 +204,6 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap st.udpErr = udpErr.Error() mu.Unlock() publish() - select { - case <-dnsReady: - case <-ctx.Done(): - } mu.Lock() dnsOK := st.dnsOK dnsErr := st.dnsErr diff --git a/internal/netprint/firewall.go b/internal/netprint/firewall.go index b1039e4..1111db8 100644 --- a/internal/netprint/firewall.go +++ b/internal/netprint/firewall.go @@ -1,5 +1,11 @@ package netprint +// firewallRemoteIP is passed to netsh as remoteip=. The value any covers +// IPv4, global IPv6, and link-local fe80::/10. The Mac resolver tries the +// fe80 address of the SRV target before IPv4; a rule that omits that prefix +// never completes the TCP handshake. +const firewallRemoteIP = "any" + // firewallProfile is the netsh profile list. Public stays off unless the // installer or the tray asked for it. Private and Domain stay allowed. func firewallProfile(public bool) string { diff --git a/internal/netprint/listen.go b/internal/netprint/listen.go new file mode 100644 index 0000000..f630355 --- /dev/null +++ b/internal/netprint/listen.go @@ -0,0 +1,179 @@ +package netprint + +import ( + "context" + "fmt" + "log" + "net" + "strings" + "sync" + "syscall" +) + +// ippBindHosts splits cfg.Bind into an IPv4 host and an IPv6 host. +// An empty bind listens on 0.0.0.0 and on [::] as two sockets. A single +// dual-stack socket (IPV6_V6ONLY off) accepts global IPv6 and IPv4, and on +// Windows a connect to the host's fe80 address never completes. The IPv6 +// socket is bound with IPV6_V6ONLY so it can take [::] beside the IPv4 socket +// and still accept link-local destinations. +func ippBindHosts(bind string) (v4, v6 string) { + bind = strings.Trim(strings.TrimSpace(bind), "[]") + switch bind { + case "", "0.0.0.0", "::": + return "0.0.0.0", "::" + } + ip := net.ParseIP(bind) + if ip == nil { + return bind, "" + } + if ip.To4() != nil { + return ip.String(), "" + } + return "", ip.String() +} + +func listenPort(v4host, v6host string, port int) (net.Listener, error) { + var lns []net.Listener + if v4host != "" { + ln, err := net.Listen("tcp4", net.JoinHostPort(v4host, fmt.Sprint(port))) + if err != nil { + return nil, err + } + lns = append(lns, ln) + } + if v6host != "" { + ln, err := listenTCP6(net.JoinHostPort(v6host, fmt.Sprint(port))) + if err != nil { + if len(lns) == 0 { + return nil, err + } + // IPv4 is up. Keep the port that was requested instead of moving + // the printer, and say why link-local clients will time out. + log.Printf("netprint: IPv6 [%s]:%d (IPV6_V6ONLY): %v", v6host, port, err) + } else { + lns = append(lns, ln) + } + } + if len(lns) == 0 { + return nil, fmt.Errorf("no listen address") + } + if len(lns) == 1 { + return lns[0], nil + } + return newMultiListener(lns), nil +} + +func listenTCP6(address string) (net.Listener, error) { + var lc net.ListenConfig + lc.Control = func(network, address string, c syscall.RawConn) error { + var sockErr error + if err := c.Control(func(fd uintptr) { + sockErr = setIPv6Only(fd) + }); err != nil { + return err + } + return sockErr + } + return lc.Listen(context.Background(), "tcp6", address) +} + +func listenerAddrs(ln net.Listener) string { + m, ok := ln.(*multiListener) + if !ok { + return ln.Addr().String() + } + parts := make([]string, 0, len(m.lns)) + for _, l := range m.lns { + parts = append(parts, l.Addr().String()) + } + return strings.Join(parts, " and ") +} + +type acceptOne struct { + c net.Conn + err error +} + +// multiListener is several listeners served as one. http.Server.Shutdown +// closes it, which closes every socket. +type multiListener struct { + lns []net.Listener + ch chan acceptOne + done chan struct{} + start sync.Once + stop sync.Once +} + +func newMultiListener(lns []net.Listener) *multiListener { + return &multiListener{ + lns: lns, + ch: make(chan acceptOne), + done: make(chan struct{}), + } +} + +func (m *multiListener) Addr() net.Addr { return m.lns[0].Addr() } + +func (m *multiListener) Accept() (net.Conn, error) { + m.start.Do(func() { + for _, ln := range m.lns { + go m.loop(ln) + } + }) + select { + case <-m.done: + return nil, net.ErrClosed + case r := <-m.ch: + return r.c, r.err + } +} + +func (m *multiListener) loop(ln net.Listener) { + for { + c, err := ln.Accept() + select { + case <-m.done: + if c != nil { + _ = c.Close() + } + return + case m.ch <- acceptOne{c: c, err: err}: + } + if err != nil { + return + } + } +} + +func (m *multiListener) Close() error { + var err error + m.stop.Do(func() { + close(m.done) + for _, ln := range m.lns { + if e := ln.Close(); err == nil { + err = e + } + } + }) + return err +} + +func socketIPv6Only(ln net.Listener) (int, error) { + tcp, ok := ln.(*net.TCPListener) + if !ok { + return 0, fmt.Errorf("%T", ln) + } + raw, err := tcp.SyscallConn() + if err != nil { + return 0, err + } + var v int + var sockErr error + err = raw.Control(func(fd uintptr) { + v, sockErr = getIPv6Only(fd) + }) + if err != nil { + return 0, err + } + return v, sockErr +} diff --git a/internal/netprint/listen_ipv6_unix.go b/internal/netprint/listen_ipv6_unix.go new file mode 100644 index 0000000..10a6fe6 --- /dev/null +++ b/internal/netprint/listen_ipv6_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package netprint + +import "syscall" + +func setIPv6Only(fd uintptr) error { + return syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY, 1) +} + +func getIPv6Only(fd uintptr) (int, error) { + return syscall.GetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY) +} diff --git a/internal/netprint/listen_ipv6_windows.go b/internal/netprint/listen_ipv6_windows.go new file mode 100644 index 0000000..bd6f6d4 --- /dev/null +++ b/internal/netprint/listen_ipv6_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package netprint + +import "syscall" + +func setIPv6Only(fd uintptr) error { + return syscall.SetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY, 1) +} + +func getIPv6Only(fd uintptr) (int, error) { + return syscall.GetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY) +} diff --git a/internal/netprint/listen_test.go b/internal/netprint/listen_test.go new file mode 100644 index 0000000..fb332cd --- /dev/null +++ b/internal/netprint/listen_test.go @@ -0,0 +1,120 @@ +package netprint + +import ( + "net" + "os" + "strconv" + "strings" + "testing" + "time" +) + +func TestListenIPPAcceptsIPv4AndLinkLocalIPv6(t *testing.T) { + port := freePort(t) + ln, got, err := listenIPP(Config{Port: port}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + if got != port { + t.Fatalf("port %d", got) + } + ml, ok := ln.(*multiListener) + if !ok || len(ml.lns) != 2 { + t.Fatalf("listener %T (%s)", ln, listenerAddrs(ln)) + } + v6only, err := socketIPv6Only(ml.lns[1]) + if err != nil { + t.Fatal(err) + } + if v6only != 1 { + t.Fatalf("IPV6_V6ONLY %d", v6only) + } + dial := func(network, address string) { + t.Helper() + conn, err := net.DialTimeout(network, address, 2*time.Second) + if err != nil { + t.Fatalf("dial %s %s: %v", network, address, err) + } + _ = conn.Close() + } + dial("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + dial("tcp6", net.JoinHostPort("::1", strconv.Itoa(port))) + ip, zone := firstLinkLocal() + if ip == nil { + t.Log("no link-local address; [::] with IPV6_V6ONLY still accepts ::1") + return + } + dial("tcp6", net.JoinHostPort(ip.String()+"%"+zone, strconv.Itoa(port))) +} + +func TestListenIPPSpecificIPv4StaysIPv4(t *testing.T) { + port := freePort(t) + ln, _, err := listenIPP(Config{Bind: "127.0.0.1", Port: port}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + if _, ok := ln.(*net.TCPListener); !ok { + t.Fatalf("specific bind opened %T", ln) + } + conn, err := net.DialTimeout("tcp6", net.JoinHostPort("::1", strconv.Itoa(port)), 200*time.Millisecond) + if err == nil { + _ = conn.Close() + t.Fatal("127.0.0.1 bind accepted IPv6") + } +} + +func TestFirewallRuleAllowsLinkLocal(t *testing.T) { + if firewallRemoteIP != "any" { + t.Fatalf("remoteip %q", firewallRemoteIP) + } + src, err := os.ReadFile("firewall.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "fe80::/10") { + t.Fatal("firewall.go does not document link-local") + } + win, err := os.ReadFile("service_windows.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(win), "remoteip="+`firewallRemoteIP`) && !strings.Contains(string(win), `remoteip="+firewallRemoteIP`) { + t.Fatal("service firewall rule does not set remoteip") + } + for _, path := range []string{"../../platform/windows/installer-netprint.nsi", "../../platform/windows/installer-windows.nsi"} { + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(b) + if strings.Count(text, "remoteip=any") < 3 || !strings.Contains(text, "fe80::/10") { + t.Fatalf("%s missing link-local firewall coverage", path) + } + } +} + +func firstLinkLocal() (net.IP, string) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, "" + } + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ip := addrIP(a) + if ip == nil || ip.To4() != nil || !ip.IsLinkLocalUnicast() { + continue + } + return ip, ifi.Name + } + } + return nil, "" +} diff --git a/internal/netprint/mdns.go b/internal/netprint/mdns.go index 5114482..1f84974 100644 --- a/internal/netprint/mdns.go +++ b/internal/netprint/mdns.go @@ -30,6 +30,11 @@ type Service struct { Port int IPs []net.IP TXT []string + // SubtypesOnly answers _universal._sub and _print._sub PTRs and nothing + // else. Windows uses that mode while DnsServiceRegister owns the primary + // _ipp._tcp name: a second SRV or TXT for the same instance is a conflict, + // and mDNS renames one of them to "Name(1)". + SubtypesOnly bool } // AirPrintTXT returns the Bonjour Printing / AirPrint text records. @@ -145,6 +150,9 @@ func (s Service) matches(q dnsQuestion) bool { univ := "_universal._sub._ipp._tcp.local" print := "_print._sub._ipp._tcp.local" enum := "_services._dns-sd._udp.local" + if s.SubtypesOnly { + return (n == univ || n == print) && (q.typ == dnsTypePTR || q.typ == dnsTypeANY) + } switch { case n == ptr || n == univ || n == print || n == enum: return q.typ == dnsTypePTR || q.typ == dnsTypeANY @@ -175,10 +183,12 @@ func (s Service) Announce(goodbye bool) []byte { ttl = 0 } qs := []dnsQuestion{ - {name: []string{"_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, {name: []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, {name: []string{"_print", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, } + if !s.SubtypesOnly { + qs = append([]dnsQuestion{{name: []string{"_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}}, qs...) + } return s.encodeResponse(0, qs, ttl) } @@ -209,18 +219,31 @@ func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte n := lowerJoin(q.name) switch { case n == "_services._dns-sd._udp.local": + if s.SubtypesOnly { + break + } k := "_ipp._tcp.local" if !seenPTR[k] { an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: []string{"_ipp", "_tcp", "local"}}) seenPTR[k] = true } case n == "_ipp._tcp.local" || n == "_universal._sub._ipp._tcp.local" || n == "_print._sub._ipp._tcp.local": + if s.SubtypesOnly && n == "_ipp._tcp.local" { + break + } if !seenPTR[n] { an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: s.instanceLabels()}) seenPTR[n] = true } - needSRV, needTXT, needAddr = true, true, true + // Subtype PTRs are not a second claim on the instance. SRV, TXT, + // and address records stay with whoever owns the base name. + if !s.SubtypesOnly { + needSRV, needTXT, needAddr = true, true, true + } case n == lowerJoin(s.instanceLabels()): + if s.SubtypesOnly { + break + } if q.typ == dnsTypeANY || q.typ == dnsTypeSRV { needSRV = true } @@ -231,7 +254,9 @@ func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte needSRV, needTXT, needAddr = true, true, true } case n == lowerJoin(s.hostLabels()): - needAddr = true + if !s.SubtypesOnly { + needAddr = true + } } } if needSRV { @@ -538,7 +563,7 @@ func lanIPs() []net.IP { } for _, a := range addrs { ip := addrIP(a) - if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + if !keepLAN(ip) { continue } if v4 := ip.To4(); v4 != nil { @@ -553,6 +578,17 @@ func lanIPs() []net.IP { return out } +// keepLAN reports whether ip may be published in our own A/AAAA records. +// Link-local addresses (fe80::/10 and 169.254.0.0/16) are omitted. Windows +// still publishes fe80 for the computer name that the SRV target uses; the +// IPP listener accepts that address instead of leaving the connect unanswered. +func keepLAN(ip net.IP) bool { + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + return ip.To4() != nil || ip.To16() != nil +} + func addrIP(a net.Addr) net.IP { switch v := a.(type) { case *net.IPNet: diff --git a/internal/netprint/mdns_dnssd_test.go b/internal/netprint/mdns_dnssd_test.go index c98f435..00d5a30 100644 --- a/internal/netprint/mdns_dnssd_test.go +++ b/internal/netprint/mdns_dnssd_test.go @@ -125,6 +125,9 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { if !strings.Contains(report.Detail, "DnsServiceRegister") || !strings.Contains(report.Detail, "631") || !strings.Contains(report.Detail, "_universal._sub") { t.Fatalf("detail %q", report.Detail) } + if strings.Contains(report.Detail, "PTR _ipp._tcp") { + t.Fatalf("UDP responder still claims the base PTR: %q", report.Detail) + } gotNames := api.names() if len(gotNames) != 1 || gotNames[0] != "Verae Timestamp._ipp._tcp.local" { t.Fatalf("registered %v", gotNames) @@ -143,11 +146,43 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { t.Fatalf("deregistered %d", len(api.deregistered)) } ann := pkt.packet(0) - if err := assertPrimaryPTRs(ann, "Verae Timestamp"); err != nil { + if err := assertSubtypePTRsOnly(ann, "Verae Timestamp"); err != nil { t.Fatal(err) } } +func TestWindowsDNSFailureAdvertisesFullService(t *testing.T) { + pkt := &fakePkt{} + prev := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { return pkt, nil } + t.Cleanup(func() { listenMDNSFn = prev }) + api := &fakeDNS{err: errors.New("dnsapi unavailable")} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && pkt.count() < 1 { + time.Sleep(10 * time.Millisecond) + } + if pkt.count() < 1 { + t.Fatal("no announcement after DnsServiceRegister failed") + } + if err := assertPrimaryPTRs(pkt.packet(0), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("advertiseForOS did not return") + } +} + func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) { prev := listenMDNSFn listenMDNSFn = func() (mdnsConn, error) { diff --git a/internal/netprint/mdns_packet_test.go b/internal/netprint/mdns_packet_test.go index 67604be..e6524a1 100644 --- a/internal/netprint/mdns_packet_test.go +++ b/internal/netprint/mdns_packet_test.go @@ -254,6 +254,129 @@ func assertPrimaryPTRs(msg []byte, instance string) error { return nil } +func assertSubtypePTRsOnly(msg []byte, instance string) error { + ans, extra, _, err := parsePacket(msg) + if err != nil { + return err + } + want := strings.ToLower(instance) + "._ipp._tcp.local" + need := map[string]bool{ + "_universal._sub._ipp._tcp.local": false, + "_print._sub._ipp._tcp.local": false, + } + for _, rr := range append(ans, extra...) { + switch rr.typ { + case dnsTypePTR: + if rr.name == "_ipp._tcp.local" { + return fmt.Errorf("base _ipp._tcp PTR was advertised") + } + if _, ok := need[rr.name]; ok { + if rr.ptr != want { + return fmt.Errorf("PTR %s target %q, want %q", rr.name, rr.ptr, want) + } + need[rr.name] = true + } + case dnsTypeSRV, dnsTypeTXT, dnsTypeA, dnsTypeAAAA: + return fmt.Errorf("unique record type %d name %s", rr.typ, rr.name) + } + } + for name, ok := range need { + if !ok { + return fmt.Errorf("missing PTR %s", name) + } + } + return nil +} + +func TestSubtypeOnlyResponderSkipsPrimaryRecords(t *testing.T) { + svc := sampleService() + svc.SubtypesOnly = true + if err := assertSubtypePTRsOnly(svc.Announce(false), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + bye := svc.Announce(true) + if err := assertSubtypePTRsOnly(bye, "Verae Timestamp"); err != nil { + t.Fatal(err) + } + ans, _, _, err := parsePacket(bye) + if err != nil { + t.Fatal(err) + } + var ptrs int + for _, rr := range ans { + if rr.typ == dnsTypePTR { + ptrs++ + if rr.ttl != 0 { + t.Fatalf("goodbye ttl %d", rr.ttl) + } + } + } + if ptrs != 2 { + t.Fatalf("goodbye ptrs %d", ptrs) + } + if svc.Reply(Query(1, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) != nil { + t.Fatal("answered base _ipp._tcp PTR") + } + if svc.Reply(Query(1, svc.instanceLabels(), dnsTypeSRV)) != nil { + t.Fatal("answered SRV") + } + if svc.Reply(Query(1, svc.instanceLabels(), dnsTypeTXT)) != nil { + t.Fatal("answered TXT") + } + if svc.Reply(Query(1, svc.hostLabels(), dnsTypeA)) != nil { + t.Fatal("answered A") + } + if svc.Reply(Query(1, svc.hostLabels(), dnsTypeAAAA)) != nil { + t.Fatal("answered AAAA") + } + resp := svc.Reply(Query(1, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR)) + ans, extra, _, err := parsePacket(resp) + if err != nil { + t.Fatal(err) + } + var sawUniv bool + for _, rr := range append(ans, extra...) { + switch rr.typ { + case dnsTypePTR: + if rr.name == "_universal._sub._ipp._tcp.local" && rr.ptr == "verae timestamp._ipp._tcp.local" { + sawUniv = true + } + if rr.name == "_ipp._tcp.local" { + t.Fatal("subtype reply included the base PTR") + } + case dnsTypeSRV, dnsTypeTXT, dnsTypeA, dnsTypeAAAA: + t.Fatalf("subtype reply included type %d", rr.typ) + } + } + if !sawUniv { + t.Fatal("subtype reply missing the _universal PTR") + } + full := sampleService() + if full.Reply(Query(1, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) == nil { + t.Fatal("full responder dropped the base PTR") + } + if err := assertPrimaryPTRs(full.Announce(false), "Verae Timestamp"); err != nil { + t.Fatal(err) + } +} + +func TestLANIPsSkipLinkLocal(t *testing.T) { + if keepLAN(net.ParseIP("fe80::d5bb:1")) || keepLAN(net.ParseIP("169.254.1.1")) { + t.Fatal("link-local address was kept") + } + if keepLAN(net.ParseIP("127.0.0.1")) || keepLAN(net.ParseIP("::1")) || keepLAN(nil) { + t.Fatal("loopback or nil was kept") + } + if !keepLAN(net.ParseIP("192.168.1.184")) || !keepLAN(net.ParseIP("2001:db8::184")) { + t.Fatal("global address was dropped") + } + for _, ip := range lanIPs() { + if ip.IsLinkLocalUnicast() { + t.Fatalf("lanIPs returned %s", ip) + } + } +} + func TestMDNSPacketLayout(t *testing.T) { svc := sampleService() pkt := svc.Announce(false) diff --git a/internal/netprint/server.go b/internal/netprint/server.go index ef79f50..ee5dd65 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -167,7 +167,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) log.Printf("netprint: mDNS disabled") s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) } - log.Printf("netprint: IPP on %s, control on %s", ippLn.Addr(), ctlLn.Addr()) + log.Printf("netprint: IPP on %s, control on %s", listenerAddrs(ippLn), ctlLn.Addr()) return s, nil } @@ -231,11 +231,11 @@ func ippListenPorts(cfg Config) []int { } func listenIPP(cfg Config) (net.Listener, int, error) { - bind := cfg.Bind + v4host, v6host := ippBindHosts(cfg.Bind) try := ippListenPorts(cfg) var last error for _, port := range try { - ln, err := net.Listen("tcp", net.JoinHostPort(bind, fmt.Sprint(port))) + ln, err := listenPort(v4host, v6host, port) if err == nil { return ln, port, nil } diff --git a/internal/netprint/service_windows.go b/internal/netprint/service_windows.go index 24210d9..a2fdb63 100644 --- a/internal/netprint/service_windows.go +++ b/internal/netprint/service_windows.go @@ -130,7 +130,7 @@ func ensureFirewall(public bool) { for _, r := range rules { out, err := exec.Command("netsh", "advfirewall", "firewall", "add", "rule", "name="+r.name, "dir=in", "action=allow", "protocol="+r.proto, - "localport="+r.port, "profile="+profile).CombinedOutput() + "localport="+r.port, "remoteip="+firewallRemoteIP, "profile="+profile).CombinedOutput() if err != nil { log.Printf("netprint: firewall %s profile %s: %v (%s)", r.name, profile, err, strings.TrimSpace(string(out))) } else { diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi index fe80938..88299ec 100644 --- a/platform/windows/installer-netprint.nsi +++ b/platform/windows/installer-netprint.nsi @@ -112,19 +112,20 @@ FunctionEnd ; Inbound TCP 631 (IPP), TCP 8632 (fallback; 8631 is the driver helper), UDP 5353 (mDNS). ; $FwProfile is private,domain unless the installer opted into Public. +; remoteip=any includes IPv4, global IPv6, and link-local fe80::/10. ; A macro so the uninstaller can run the same commands. !macro NETPRINT_FIREWALL_ADD nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=$FwProfile' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 remoteip=any profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=$FwProfile' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 remoteip=any profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=$FwProfile' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 remoteip=any profile=$FwProfile' Pop $0 !macroend diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index 3259866..5ad9a71 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -813,6 +813,7 @@ Section "Verae Print Stamp + printer" SecMain DetailPrint "Installing the Verae network printer (service + tray)" ; TCP 631, TCP 8632 (not the helper's 8631), UDP 5353. ; Private and domain unless the Public checkbox was turned on. + ; remoteip=any includes IPv4, global IPv6, and link-local fe80::/10. StrCpy $FwProfile "private,domain" StrCpy $R8 "false" ${If} $NetPublic == "1" @@ -821,15 +822,15 @@ Section "Verae Print Stamp + printer" SecMain ${EndIf} nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 profile=$FwProfile' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 remoteip=any profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 profile=$FwProfile' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 remoteip=any profile=$FwProfile' Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' Pop $0 - nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 profile=$FwProfile' + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 remoteip=any profile=$FwProfile' Pop $0 CreateDirectory "$LogDir" StrCpy $R9 '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver --allow-public=$R8' From dcc987da6e3a1a3dcc961836766d3b7566632a5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 00:56:38 +0000 Subject: [PATCH 16/27] Mail stamped PDFs and serve a phone receipts page. After a job is stored as completed, email the certificate, the original, and the receipt summary. A mail failure is logged and retried, and it does not fail the print. The SMTP password stays in DPAPI or a root-only file. Phones on Private and Domain networks can open /receipts for the stamped PDF. Co-authored-by: George Lambert --- docs/network-printer.md | 18 +- internal/mailer/mailer.go | 89 ++++- internal/mailer/mailer_test.go | 29 ++ internal/netprint/admin.go | 6 +- internal/netprint/config.go | 104 +++++ internal/netprint/control.go | 239 ++++++++++-- internal/netprint/delivery_test.go | 547 +++++++++++++++++++++++++++ internal/netprint/mail_job.go | 263 +++++++++++++ internal/netprint/mdns_dnssd_test.go | 2 +- internal/netprint/pipeline.go | 32 +- internal/netprint/receipts.go | 189 +++++++++ internal/netprint/secret.go | 86 +++++ internal/netprint/secret_unix.go | 7 + internal/netprint/secret_windows.go | 47 +++ internal/netprint/server.go | 16 +- internal/netprint/store.go | 1 + internal/netprint/tray_menu.go | 29 +- 17 files changed, 1614 insertions(+), 90 deletions(-) create mode 100644 internal/netprint/delivery_test.go create mode 100644 internal/netprint/mail_job.go create mode 100644 internal/netprint/receipts.go create mode 100644 internal/netprint/secret.go create mode 100644 internal/netprint/secret_unix.go create mode 100644 internal/netprint/secret_windows.go diff --git a/docs/network-printer.md b/docs/network-printer.md index 6bcc039..a45e810 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -23,9 +23,19 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. 3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. -4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://:/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`. +4. Print a PDF. Open **Receipts** from the tray, or browse to `http://:/receipts` (the AirPrint admin URL). The page lists recent jobs, newest first, with the stamped PDF and the original. Each job also has a verify link on `https://explorer.verae.ai/time`. -The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, and whether discovery is advertising. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. +The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, whether discovery is advertising, and the receipts URL. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, turn **Email stamped PDFs** on or off, open recent receipts, and open settings (printer name, forward-to printer, signing key, mail, and the receipts PIN). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. + +## Email + +Settings and the tray can mail each completed job. The message subject is `Verae timestamp: `. The body contains the receipt ID, the SHA-256, and the `.verae.json` receipt summary. The certificate PDF and the original document are attached. The default recipient is `marchon@gmail.com`, and the default server is `smtp.gmail.com` port 587 with STARTTLS. A Gmail app password works: the username is the Gmail address and the password is the 16-character app password. Mail stays off until the toggle is on and a password is saved. A failure is written to the log and to `emailError` in the status JSON, then retried in the background. It does not fail the print. + +The SMTP password and the optional receipts PIN are not stored in `netprint.json`. On Windows they are sealed with DPAPI for the service account. On macOS and Linux they are in `netprint-secrets.bin` next to the config, mode `0600`. Neither value is written to the log. + +## Receipts on the phone + +`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON field is `receiptsUrl`. Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward does not fail the timestamp. @@ -39,9 +49,9 @@ On Debian and Ubuntu, `postinst` opens the same three ports with `ufw` when ufw On macOS, the Application Firewall is per application (System Settings → Network → Firewall). Install runs `/usr/libexec/ApplicationFirewall/socketfilterfw --add` and `--unblockapp` on the service binary when that tool is present. If other devices still cannot connect, allow `verae-print` in that pane. -The control API listens only on `127.0.0.1:8630`. +The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer. -A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`. +A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, and `receiptsPinSet`. It does not include the SMTP password or the receipts PIN. macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary. diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 69af9c6..e26d9a5 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -38,7 +38,10 @@ type Config struct { From string // AllowInsecureLocal permits plaintext SMTP to 127.0.0.1/localhost (tests only). AllowInsecureLocal bool - Timeout time.Duration + // ImplicitTLS dials TLS immediately (port 465, or an explicit TLS setting). + // Otherwise the client uses STARTTLS. + ImplicitTLS bool + Timeout time.Duration } // String never reveals the password. @@ -86,12 +89,20 @@ func envOr(k, d string) string { return d } +// Attachment is one file on a message. Name is the filename only. +type Attachment struct { + Name string + Type string + Data []byte +} + // Message is a rendered email. type Message struct { - To string - Subject string - Text string - HTML string + To string + Subject string + Text string + HTML string + Attachments []Attachment } // Sender is what the install API needs (mockable). @@ -144,7 +155,8 @@ func (s *SMTPSender) send(m Message) error { timeout = 20 * time.Second } var conn net.Conn - if c.Port == 465 { + implicitTLS := c.ImplicitTLS || c.Port == 465 + if implicitTLS { conn, err = tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, tlsCfg) } else { conn, err = net.DialTimeout("tcp", addr, timeout) @@ -162,7 +174,7 @@ func (s *SMTPSender) send(m Message) error { if err := cl.Hello("verae-mailer"); err != nil { return fmt.Errorf("ehlo: %v", err) } - if c.Port != 465 { + if !implicitTLS { if ok, _ := cl.Extension("STARTTLS"); ok { if err := cl.StartTLS(tlsCfg); err != nil { return fmt.Errorf("starttls: %v", err) @@ -195,9 +207,10 @@ func (s *SMTPSender) send(m Message) error { return cl.Quit() } -// Build renders a multipart/alternative RFC 5322 message. +// Build renders an RFC 5322 message. With no attachments it is +// multipart/alternative. Attachments use multipart/mixed around that part. func Build(from, to *mail.Address, m Message, now time.Time) ([]byte, error) { - bnd, err := randHex(16) + alt, err := randHex(16) if err != nil { return nil, err } @@ -206,6 +219,15 @@ func Build(from, to *mail.Address, m Message, now time.Time) ([]byte, error) { if i := strings.LastIndex(from.Address, "@"); i >= 0 { dom = from.Address[i+1:] } + outer := alt + outerType := "multipart/alternative" + if len(m.Attachments) > 0 { + outer, err = randHex(12) + if err != nil { + return nil, err + } + outerType = "multipart/mixed" + } var b bytes.Buffer h := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) } h("From", from.String()) @@ -215,19 +237,58 @@ func Build(from, to *mail.Address, m Message, now time.Time) ([]byte, error) { h("Message-ID", "<"+id+"@"+dom+">") h("MIME-Version", "1.0") h("Auto-Submitted", "auto-generated") - h("Content-Type", `multipart/alternative; boundary="`+bnd+`"`) + h("Content-Type", outerType+`; boundary="`+outer+`"`) b.WriteString("\r\n") + if len(m.Attachments) == 0 { + writeAlternative(&b, alt, m) + return b.Bytes(), nil + } + fmt.Fprintf(&b, "--%s\r\nContent-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", outer, alt) + writeAlternative(&b, alt, m) + for _, a := range m.Attachments { + name := strings.Map(func(r rune) rune { + if r < 32 || r == '"' || r == '\\' { + return '_' + } + return r + }, a.Name) + if name == "" { + name = "attachment.bin" + } + typ := a.Type + if typ == "" { + typ = "application/octet-stream" + } + fmt.Fprintf(&b, "--%s\r\n", outer) + fmt.Fprintf(&b, "Content-Type: %s; name=\"%s\"\r\n", typ, name) + fmt.Fprintf(&b, "Content-Disposition: attachment; filename=\"%s\"\r\n", name) + b.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") + enc := base64.StdEncoding.EncodeToString(a.Data) + for len(enc) > 76 { + b.WriteString(enc[:76]) + b.WriteString("\r\n") + enc = enc[76:] + } + if enc != "" { + b.WriteString(enc) + b.WriteString("\r\n") + } + } + fmt.Fprintf(&b, "--%s--\r\n", outer) + return b.Bytes(), nil +} + +func writeAlternative(b *bytes.Buffer, bnd string, m Message) { part := func(ct, body string) { - fmt.Fprintf(&b, "--%s\r\nContent-Type: %s; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", bnd, ct) - qp := quotedprintable.NewWriter(&b) + fmt.Fprintf(b, "--%s\r\nContent-Type: %s; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", bnd, ct) + qp := quotedprintable.NewWriter(b) _, _ = qp.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n"))) _ = qp.Close() b.WriteString("\r\n") } part("text/plain", m.Text) part("text/html", m.HTML) - fmt.Fprintf(&b, "--%s--\r\n", bnd) - return b.Bytes(), nil + fmt.Fprintf(b, "--%s--\r\n", bnd) } func mimeHeader(s string) string { diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go index b5ec417..78caaf0 100644 --- a/internal/mailer/mailer_test.go +++ b/internal/mailer/mailer_test.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/base64" "net" + "net/mail" "strings" "sync" "testing" @@ -117,6 +118,34 @@ func TestSendViaFakeSMTP(t *testing.T) { } } +func TestBuildAttachesFiles(t *testing.T) { + from, err := mail.ParseAddress("Verae Timestamp ") + if err != nil { + t.Fatal(err) + } + to, err := mail.ParseAddress("marchon@gmail.com") + if err != nil { + t.Fatal(err) + } + raw, err := Build(from, to, Message{ + Subject: "Verae timestamp: Note", + Text: "Receipt: Qmab\nSHA-256: abc\n", + HTML: "

Receipt: Qmab

", + Attachments: []Attachment{{ + Name: "certificate.pdf", Type: "application/pdf", Data: []byte("%PDF-1.4"), + }}, + }, time.Now()) + if err != nil { + t.Fatal(err) + } + s := string(raw) + for _, want := range []string{"multipart/mixed", "multipart/alternative", "filename=\"certificate.pdf\"", "Content-Transfer-Encoding: base64"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q\n%s", want, s) + } + } +} + func TestRefusesPlaintextWithoutOptIn(t *testing.T) { f := startFake(t) s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "u@x.com", Pass: "pw-should-not-leak", From: "u@x.com", Timeout: 5 * time.Second}} diff --git a/internal/netprint/admin.go b/internal/netprint/admin.go index 5d2a01e..97035db 100644 --- a/internal/netprint/admin.go +++ b/internal/netprint/admin.go @@ -29,6 +29,10 @@ func (a *Admin) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.index(w, r) return } + if path == "/receipts" { + a.receipts(w, r) + return + } parts := strings.Split(strings.TrimPrefix(path, "/"), "/") if len(parts) >= 2 && parts[0] == "jobs" { id, err := strconv.Atoi(parts[1]) @@ -71,7 +75,7 @@ func (a *Admin) index(w http.ResponseWriter, r *http.Request) { var b strings.Builder fmt.Fprintf(&b, "%s", html.EscapeString(name)) fmt.Fprintf(&b, "") - fmt.Fprintf(&b, "

%s

Jobs timestamped by this printer. Document bytes stay on this computer; Verae receives only the SHA-256.

", html.EscapeString(name)) + fmt.Fprintf(&b, "

%s

Jobs timestamped by this printer. Document bytes stay on this computer; Verae receives only the SHA-256.

Phone receipts

", html.EscapeString(name)) fmt.Fprintf(&b, "") list := a.Store.List() for i := len(list) - 1; i >= 0; i-- { diff --git a/internal/netprint/config.go b/internal/netprint/config.go index 5d8c717..b360824 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -50,8 +50,27 @@ type Config struct { // AllowPublic opens the Windows firewall on Public networks as well as // Private and Domain. Nil and false keep Public blocked. AllowPublic *bool `json:"allowPublic,omitempty"` + // EmailEnabled sends the stamped PDF after each completed job. Nil and + // false do not send. A mail failure never fails the print. + EmailEnabled *bool `json:"emailEnabled,omitempty"` + // EmailTo is a comma-separated recipient list. Empty uses the default. + EmailTo string `json:"emailTo,omitempty"` + // SMTPHost, SMTPPort, SMTPTLS, and SMTPUser are the mail server. + // SMTPTLS is "starttls" or "tls" (implicit TLS, typically port 465). + // The password is not in this file. + SMTPHost string `json:"smtpHost,omitempty"` + SMTPPort int `json:"smtpPort,omitempty"` + SMTPTLS string `json:"smtpTLS,omitempty"` + SMTPUser string `json:"smtpUser,omitempty"` } +const ( + // DefaultEmailTo is the recipient used when email is on and EmailTo is empty. + DefaultEmailTo = "marchon@gmail.com" + // DefaultSMTPHost is Gmail's submission server. An app password works with STARTTLS. + DefaultSMTPHost = "smtp.gmail.com" +) + // PublicNetworks reports whether Public firewall profiles should be allowed. func (c Config) PublicNetworks() bool { return c.AllowPublic != nil && *c.AllowPublic } @@ -61,10 +80,39 @@ func (c Config) Keep() bool { return c.KeepPDF == nil || *c.KeepPDF } // MDNS reports whether the printer should advertise. func (c Config) MDNS() bool { return c.Advertise == nil || *c.Advertise } +// EmailOn reports whether completed jobs should be mailed. +func (c Config) EmailOn() bool { return c.EmailEnabled != nil && *c.EmailEnabled } + +// MailRecipients splits EmailTo. An empty setting uses DefaultEmailTo. +func (c Config) MailRecipients() []string { + raw := strings.TrimSpace(c.EmailTo) + if raw == "" { + raw = DefaultEmailTo + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' ' + }) + var out []string + seen := map[string]bool{} + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || seen[strings.ToLower(p)] { + continue + } + seen[strings.ToLower(p)] = true + out = append(out, p) + } + if len(out) == 0 { + return []string{DefaultEmailTo} + } + return out +} + // Defaults fills an empty config with platform paths and port 631. func Defaults() Config { keep := true adv := true + emailOff := false return Config{ Name: "Verae Timestamp", Port: 631, @@ -73,6 +121,12 @@ func Defaults() Config { DataDir: defaultDataDir(), KeepPDF: &keep, Advertise: &adv, + EmailEnabled: &emailOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } } @@ -203,6 +257,25 @@ func mergeConfig(dst *Config, src Config) { v := *src.AllowPublic dst.AllowPublic = &v } + if src.EmailEnabled != nil { + v := *src.EmailEnabled + dst.EmailEnabled = &v + } + if strings.TrimSpace(src.EmailTo) != "" { + dst.EmailTo = strings.TrimSpace(src.EmailTo) + } + if strings.TrimSpace(src.SMTPHost) != "" { + dst.SMTPHost = strings.TrimSpace(src.SMTPHost) + } + if src.SMTPPort > 0 { + dst.SMTPPort = src.SMTPPort + } + if strings.TrimSpace(src.SMTPTLS) != "" { + dst.SMTPTLS = strings.ToLower(strings.TrimSpace(src.SMTPTLS)) + } + if strings.TrimSpace(src.SMTPUser) != "" { + dst.SMTPUser = strings.TrimSpace(src.SMTPUser) + } } func (c *Config) normalize() error { @@ -232,6 +305,30 @@ func (c *Config) normalize() error { t := true c.Advertise = &t } + if c.EmailEnabled == nil { + f := false + c.EmailEnabled = &f + } + if strings.TrimSpace(c.EmailTo) == "" { + c.EmailTo = DefaultEmailTo + } + if strings.TrimSpace(c.SMTPHost) == "" { + c.SMTPHost = DefaultSMTPHost + } + if c.SMTPPort <= 0 { + c.SMTPPort = 587 + } + switch strings.ToLower(strings.TrimSpace(c.SMTPTLS)) { + case "", "starttls": + c.SMTPTLS = "starttls" + case "tls", "ssl", "implicit": + c.SMTPTLS = "tls" + default: + return fmt.Errorf("smtpTLS must be starttls or tls") + } + if c.SMTPPort > 65535 { + return fmt.Errorf("smtpPort must be 1-65535") + } return nil } @@ -240,6 +337,7 @@ func ExampleJSON(dataDir string) []byte { keep := true adv := true public := false + emailOff := false c := Config{ Name: "Verae Timestamp", Port: 631, @@ -250,6 +348,12 @@ func ExampleJSON(dataDir string) []byte { ForwardPrinter: "", Advertise: &adv, AllowPublic: &public, + EmailEnabled: &emailOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } b, _ := json.MarshalIndent(c, "", " ") return append(b, '\n') diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 1cd99cf..17a0076 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -38,6 +38,14 @@ type Status struct { AllowPublic bool `json:"allowPublic"` NetworkPublic bool `json:"networkPublic"` SupportsPublic bool `json:"supportsPublicFirewall"` + ReceiptsURL string `json:"receiptsUrl"` + EmailEnabled bool `json:"emailEnabled"` + EmailTo string `json:"emailTo"` + EmailError string `json:"emailError"` + SMTPHost string `json:"smtpHost"` + SMTPUser string `json:"smtpUser"` + SMTPPasswordSet bool `json:"smtpPasswordSet"` + ReceiptsPINSet bool `json:"receiptsPinSet"` } // ServiceControl is how the service asks the OS to start, stop, or launch at login. @@ -114,6 +122,11 @@ func (c *Client) Settings(name, forward string, port int) error { return c.put("/api/v1/settings", body, nil) } +// SetEmailEnabled turns receipt email on or off without changing other settings. +func (c *Client) SetEmailEnabled(on bool) error { + return c.put("/api/v1/settings", map[string]bool{"emailEnabled": on}, nil) +} + // Auth asks the service to store an xVerae- signing key. func (c *Client) Auth(apiKey string) error { return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil) @@ -159,10 +172,7 @@ func (c *Client) do(method, path string, body, out any) error { func (s *Server) status() Status { cfg := s.snapshot() addrs := lanHosts() - admin := "" - if len(addrs) > 0 { - admin = fmt.Sprintf("http://%s/", net.JoinHostPort(addrs[0], fmt.Sprint(s.port))) - } + admin := s.receiptsURL() list := s.store.List() today := 0 now := time.Now() @@ -179,11 +189,18 @@ func (s *Server) status() Status { tray, _ := s.ctrl.TrayAtLogin() s.mu.Lock() mdns := s.mdns + emailErr := s.emailErr s.mu.Unlock() last := "" if !mdns.LastSent.IsZero() { last = mdns.LastSent.UTC().Format(time.RFC3339) } + sec, secErr := loadSecrets(secretsPath(s.configPath, cfg.DataDir)) + passwordSet := secErr == nil && strings.TrimSpace(sec.SMTPPassword) != "" + pinSet := secErr == nil && strings.TrimSpace(sec.ReceiptsPIN) != "" + if emailErr == "" && cfg.EmailOn() && !passwordSet { + emailErr = "SMTP password is not set" + } return Status{ Running: true, Name: cfg.Name, @@ -205,6 +222,14 @@ func (s *Server) status() Status { AllowPublic: cfg.PublicNetworks(), NetworkPublic: cachedNetworkPublic(), SupportsPublic: supportsPublicFirewall(), + ReceiptsURL: admin, + EmailEnabled: cfg.EmailOn(), + EmailTo: cfg.EmailTo, + EmailError: emailErr, + SMTPHost: cfg.SMTPHost, + SMTPUser: cfg.SMTPUser, + SMTPPasswordSet: passwordSet, + ReceiptsPINSet: pinSet, } } @@ -322,39 +347,86 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { return } var body struct { - Name string `json:"name"` - ForwardPrinter string `json:"forwardPrinter"` - Port int `json:"port"` - AllowPublic *bool `json:"allowPublic"` + Name *string `json:"name"` + ForwardPrinter *string `json:"forwardPrinter"` + Port *int `json:"port"` + AllowPublic *bool `json:"allowPublic"` + EmailEnabled *bool `json:"emailEnabled"` + EmailTo *string `json:"emailTo"` + SMTPHost *string `json:"smtpHost"` + SMTPPort *int `json:"smtpPort"` + SMTPTLS *string `json:"smtpTLS"` + SMTPUser *string `json:"smtpUser"` + SMTPPassword *string `json:"smtpPassword"` + ClearPassword *bool `json:"clearPassword"` + ReceiptsPIN *string `json:"receiptsPin"` + ClearPIN *bool `json:"clearPin"` } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { + http.Error(w, "bad settings", http.StatusBadRequest) return } s.mu.Lock() - if strings.TrimSpace(body.Name) != "" { - s.cfg.Name = strings.TrimSpace(body.Name) - s.printer.Name = s.cfg.Name + cfg := s.cfg + path := s.configPath + if body.Name != nil && strings.TrimSpace(*body.Name) != "" { + cfg.Name = strings.TrimSpace(*body.Name) } - s.cfg.ForwardPrinter = strings.TrimSpace(body.ForwardPrinter) - if body.Port > 0 && body.Port < 65536 { - s.cfg.Port = body.Port + if body.ForwardPrinter != nil { + cfg.ForwardPrinter = strings.TrimSpace(*body.ForwardPrinter) + } + if body.Port != nil && *body.Port > 0 && *body.Port < 65536 { + cfg.Port = *body.Port } if body.AllowPublic != nil { v := *body.AllowPublic - s.cfg.AllowPublic = &v + cfg.AllowPublic = &v + } + if body.EmailEnabled != nil { + v := *body.EmailEnabled + cfg.EmailEnabled = &v + } + if body.EmailTo != nil { + cfg.EmailTo = strings.TrimSpace(*body.EmailTo) + } + if body.SMTPHost != nil { + cfg.SMTPHost = strings.TrimSpace(*body.SMTPHost) + } + if body.SMTPPort != nil && *body.SMTPPort > 0 && *body.SMTPPort < 65536 { + cfg.SMTPPort = *body.SMTPPort + } + if body.SMTPTLS != nil { + cfg.SMTPTLS = strings.TrimSpace(*body.SMTPTLS) + } + if body.SMTPUser != nil { + cfg.SMTPUser = strings.TrimSpace(*body.SMTPUser) + } + if err := cfg.normalize(); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.cfg = cfg + if s.printer != nil { + s.printer.Name = cfg.Name } - cfg := s.cfg - path := s.configPath s.mu.Unlock() if err := Save(path, cfg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := applySecretFields(path, cfg.DataDir, secretFields{ + password: body.SMTPPassword, clearPassword: body.ClearPassword, + pin: body.ReceiptsPIN, clearPIN: body.ClearPIN, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } if body.AllowPublic != nil { applyFirewall(*body.AllowPublic) log.Printf("netprint: firewall public networks=%v", *body.AllowPublic) } + log.Printf("netprint: settings saved name=%q email=%v", cfg.Name, cfg.EmailOn()) writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"}) } @@ -382,33 +454,91 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]bool{"ok": true}) } +type secretFields struct { + password *string + clearPassword *bool + pin *string + clearPIN *bool +} + +func applySecretFields(configPath, dataDir string, f secretFields) error { + if f.password == nil && f.clearPassword == nil && f.pin == nil && f.clearPIN == nil { + return nil + } + path := secretsPath(configPath, dataDir) + sec, err := loadSecrets(path) + if err != nil { + return err + } + if f.clearPassword != nil && *f.clearPassword { + sec.SMTPPassword = "" + } else if f.password != nil && *f.password != "" { + sec.SMTPPassword = *f.password + } + if f.clearPIN != nil && *f.clearPIN { + sec.ReceiptsPIN = "" + } else if f.pin != nil && strings.TrimSpace(*f.pin) != "" { + sec.ReceiptsPIN = strings.TrimSpace(*f.pin) + } + return saveSecrets(path, sec) +} + func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, "bad form", http.StatusBadRequest) return } port := 0 fmt.Sscan(r.FormValue("port"), &port) + smtpPort := 0 + fmt.Sscan(r.FormValue("smtpPort"), &smtpPort) s.mu.Lock() - if n := strings.TrimSpace(r.FormValue("name")); n != "" { - s.cfg.Name = n - s.printer.Name = n - } - s.cfg.ForwardPrinter = strings.TrimSpace(r.FormValue("forwardPrinter")) - if port > 0 && port < 65536 { - s.cfg.Port = port - } - pub := r.FormValue("allowPublic") == "1" - s.cfg.AllowPublic = &pub cfg := s.cfg path := s.configPath + if n := strings.TrimSpace(r.FormValue("name")); n != "" { + cfg.Name = n + } + cfg.ForwardPrinter = strings.TrimSpace(r.FormValue("forwardPrinter")) + if port > 0 && port < 65536 { + cfg.Port = port + } + pub := r.FormValue("allowPublic") == "1" + cfg.AllowPublic = &pub + emailOn := r.FormValue("emailEnabled") == "1" + cfg.EmailEnabled = &emailOn + cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo")) + cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost")) + if smtpPort > 0 && smtpPort < 65536 { + cfg.SMTPPort = smtpPort + } + cfg.SMTPTLS = strings.TrimSpace(r.FormValue("smtpTLS")) + cfg.SMTPUser = strings.TrimSpace(r.FormValue("smtpUser")) + if err := cfg.normalize(); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.cfg = cfg + if s.printer != nil { + s.printer.Name = cfg.Name + } s.mu.Unlock() applyFirewall(pub) if err := Save(path, cfg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + pw := r.FormValue("smtpPassword") + pin := r.FormValue("receiptsPin") + clearPW := r.FormValue("clearPassword") == "1" + clearPIN := r.FormValue("clearPin") == "1" + if err := applySecretFields(path, cfg.DataDir, secretFields{ + password: &pw, clearPassword: &clearPW, pin: &pin, clearPIN: &clearPIN, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } if key := strings.TrimSpace(r.FormValue("apiKey")); key != "" { if err := auth.SaveXVeraeToken(key); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -419,30 +549,69 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { return } } + log.Printf("netprint: settings saved name=%q email=%v", cfg.Name, cfg.EmailOn()) http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) return } cfg := s.snapshot() saved := r.URL.Query().Get("saved") == "1" + sec, _ := loadSecrets(secretsPath(s.configPath, cfg.DataDir)) + pwNote := "Password is not set." + if strings.TrimSpace(sec.SMTPPassword) != "" { + pwNote = "A password is saved. Leave the field blank to keep it." + } + pinNote := "No access PIN. The page is open on Private and Domain networks." + if strings.TrimSpace(sec.ReceiptsPIN) != "" { + pinNote = "A PIN is saved. Leave the field blank to keep it." + } var b strings.Builder - b.WriteString("Verae network printer") - b.WriteString("") + b.WriteString("Verae network printer") + b.WriteString("") b.WriteString("

Verae network printer

") if saved { b.WriteString("

Saved. A port change applies after the service restarts.

") } + receipts := htmlEscape(s.receiptsURL()) + fmt.Fprintf(&b, `

Phone receipts: %s

`, receipts, receipts) fmt.Fprintf(&b, ` - + -

Receipts

-`, htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port, checkedAttr(cfg.PublicNetworks())) +

Email the stamped PDF

+

After each completed job, mail the certificate, the original document, and the receipt summary. A mail failure is logged and does not fail the print. For Gmail use smtp.gmail.com, port 587 with STARTTLS (or 465 with TLS). The username is the Gmail address and the password is a 16-character app password, not the account password. The password is stored with Windows DPAPI, or in a root-only file on macOS and Linux. It is not written to netprint.json or the log.

+ + + + + + + +

%s

+ +

Receipts page

+

The page is on the printer port, which stays limited to Private and Domain networks unless Public networks are allowed. An optional PIN is required on the LAN. This computer's tray does not need it.

+ +

%s

+ +

Receipts on this computer

+`, + htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port, checkedAttr(cfg.PublicNetworks()), + checkedAttr(cfg.EmailOn()), htmlEscape(cfg.EmailTo), htmlEscape(cfg.SMTPHost), cfg.SMTPPort, + selectedAttr(cfg.SMTPTLS != "tls"), selectedAttr(cfg.SMTPTLS == "tls"), htmlEscape(cfg.SMTPUser), + htmlEscape(pwNote), htmlEscape(pinNote)) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(b.String())) } +func selectedAttr(on bool) string { + if on { + return "selected" + } + return "" +} + func checkedAttr(on bool) string { if on { return "checked" diff --git a/internal/netprint/delivery_test.go b/internal/netprint/delivery_test.go new file mode 100644 index 0000000..d156903 --- /dev/null +++ b/internal/netprint/delivery_test.go @@ -0,0 +1,547 @@ +package netprint + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/cookiejar" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" +) + +func TestMailRecipientsAndDefaults(t *testing.T) { + d := Defaults() + if d.EmailOn() || d.EmailTo != DefaultEmailTo || d.SMTPHost != "smtp.gmail.com" || d.SMTPPort != 587 || d.SMTPTLS != "starttls" || d.SMTPUser != DefaultEmailTo { + t.Fatalf("%+v", d) + } + c := Config{EmailTo: "A@Example.com, a@example.com; b@x.com\nother@x.com"} + got := strings.Join(c.MailRecipients(), ",") + if got != "A@Example.com,b@x.com,other@x.com" { + t.Fatal(got) + } + if strings.Join((Config{}).MailRecipients(), ",") != DefaultEmailTo { + t.Fatal((Config{}).MailRecipients()) + } + sample := string(ExampleJSON(d.DataDir)) + if strings.Contains(sample, "smtpPassword") || strings.Contains(sample, "receiptsPin") { + t.Fatal(sample) + } +} + +func TestReceiptsNetworkAllowed(t *testing.T) { + if !receiptsNetworkAllowed(false, false) || !receiptsNetworkAllowed(true, true) || receiptsNetworkAllowed(true, false) { + t.Fatal("network gate") + } +} + +func TestWindowsSecretsUseDPAPI(t *testing.T) { + b, err := os.ReadFile("secret_windows.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + for _, needle := range []string{"CryptProtectData", "CryptUnprotectData", "CRYPTPROTECT_UI_FORBIDDEN"} { + if !strings.Contains(src, needle) { + t.Fatalf("missing %s", needle) + } + } + if strings.Contains(src, "CRYPTPROTECT_LOCAL_MACHINE") { + t.Fatal("DPAPI must stay user-scope so the service account can read it") + } +} + +func TestSecretsFileIsPrivate(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "netprint.json") + if err := Save(cfgPath, Defaults()); err != nil { + t.Fatal(err) + } + const secret = "app-secret-should-not-leak" + path := secretsPath(cfgPath, dir) + if err := saveSecrets(path, Secrets{SMTPPassword: secret, ReceiptsPIN: "2468"}); err != nil { + t.Fatal(err) + } + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Fatalf("mode %o", st.Mode().Perm()) + } + saved, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(saved), secret) || strings.Contains(string(saved), "2468") { + t.Fatalf("config holds a secret:\n%s", saved) + } + got, err := loadSecrets(path) + if err != nil || got.SMTPPassword != secret || got.ReceiptsPIN != "2468" { + t.Fatalf("%+v %v", got, err) + } + if err := saveSecrets(path, Secrets{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal(err) + } +} + +func TestReceiptMailAttachesAndRetries(t *testing.T) { + s := mailFixture(t) + rec := sampleReceipt(t, s, 11, "Scan\r\nBcc: x", "mobile", "Qmab1sA9") + if err := s.store.Put(rec); err != nil { + t.Fatal(err) + } + const secret = "app-secret-should-not-leak" + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: secret}); err != nil { + t.Fatal(err) + } + prevDelay := mailRetryDelays + mailRetryDelays = []time.Duration{0, 0} + t.Cleanup(func() { mailRetryDelays = prevDelay }) + var calls int + var got mailer.Message + prevSend := sendMail + sendMail = func(cfg mailer.Config, m mailer.Message) error { + calls++ + if cfg.Pass != secret || cfg.Host != "smtp.gmail.com" || cfg.Port != 587 || cfg.ImplicitTLS { + t.Errorf("smtp %+v", cfg) + } + if strings.ContainsAny(m.Subject, "\r\n") { + t.Errorf("subject %q", m.Subject) + } + got = m + if calls == 1 { + return errors.New("dial refused " + cfg.Pass) + } + return nil + } + t.Cleanup(func() { sendMail = prevSend }) + buf := captureLog(t) + s.deliverWithRetry(rec) + if calls != 2 { + t.Fatalf("calls %d", calls) + } + if got.Subject != "Verae timestamp: Scan Bcc: x" && got.Subject != "Verae timestamp: Scan Bcc: x" { + t.Fatal(got.Subject) + } + if !strings.Contains(got.Text, "Qmab1sA9") || !strings.Contains(got.Text, rec.SHA256) || !strings.Contains(got.Text, "scan.verae.json") { + t.Fatal(got.Text) + } + if len(got.Attachments) != 2 || got.Attachments[0].Name != "certificate.pdf" || got.Attachments[1].Name != "document.pdf" { + t.Fatalf("%+v", got.Attachments) + } + stored, ok := s.store.Get(11) + if !ok || stored.State != "completed" || stored.Notified != "emailed" || stored.EmailError != "" { + t.Fatalf("%+v", stored) + } + if strings.Contains(buf.String(), secret) || strings.Contains(s.status().EmailError, secret) { + t.Fatalf("password leaked\n%s", buf.String()) + } +} + +func TestEmailFailureStaysCompleted(t *testing.T) { + s := mailFixture(t) + s.cfg.EmailTo = "marchon@gmail.com, other@example.com" + const secret = "app-secret-should-not-leak" + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: secret}); err != nil { + t.Fatal(err) + } + prevDelay := mailRetryDelays + mailRetryDelays = []time.Duration{0, 0} + t.Cleanup(func() { mailRetryDelays = prevDelay }) + prevSend := sendMail + sendMail = func(cfg mailer.Config, m mailer.Message) error { + return errors.New("dial " + cfg.Pass) + } + t.Cleanup(func() { sendMail = prevSend }) + buf := captureLog(t) + ProcessJob(s.store, s.snapshot(), nil, 11, "application/pdf", "Contract", "mobile", []byte("%PDF-1.4\n"), func(pdf, title, user string) (job.Result, error) { + dir := filepath.Dir(pdf) + cert := filepath.Join(dir, "certificate.pdf") + js := filepath.Join(dir, "contract.verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil { + return job.Result{}, err + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"Qmab1sA9\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{SHA256: strings.Repeat("ab", 32), ReceiptID: "Qmab1sA9", CertificatePDF: cert, OriginalPDF: pdf, ReceiptJSON: js}, nil + }, nil, s.queueReceiptMail) + var got Record + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + g, ok := s.store.Get(11) + if ok && g.Notified == "email failed" { + got = g + break + } + time.Sleep(15 * time.Millisecond) + } + if got.State != "completed" || got.ReceiptID != "Qmab1sA9" || got.Notified != "email failed" { + t.Fatalf("%+v", got) + } + if strings.Contains(got.EmailError, secret) || strings.Contains(buf.String(), secret) { + t.Fatalf("leaked %q\n%s", got.EmailError, buf.String()) + } + if !strings.Contains(got.EmailError, "[redacted]") { + t.Fatal(got.EmailError) + } + if s.status().EmailError != got.EmailError { + t.Fatal(s.status().EmailError) + } +} + +func TestOversizedAttachmentIsNamedInBody(t *testing.T) { + s := mailFixture(t) + rec := sampleReceipt(t, s, 3, "Big", "mobile", "Qmab1sA9") + prev := maxAttachBytes + maxAttachBytes = 4 + t.Cleanup(func() { maxAttachBytes = prev }) + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: "pw"}); err != nil { + t.Fatal(err) + } + var got mailer.Message + prevSend := sendMail + sendMail = func(cfg mailer.Config, m mailer.Message) error { + got = m + return nil + } + t.Cleanup(func() { sendMail = prevSend }) + if err := sendReceiptMail(s.snapshot(), "pw", rec); err != nil { + t.Fatal(err) + } + if len(got.Attachments) != 0 || !strings.Contains(got.Text, "certificate.pdf was not attached") { + t.Fatalf("%q %+v", got.Text, got.Attachments) + } +} + +func TestMissingPasswordDoesNotSend(t *testing.T) { + s := mailFixture(t) + called := false + prevSend := sendMail + sendMail = func(mailer.Config, mailer.Message) error { + called = true + return nil + } + t.Cleanup(func() { sendMail = prevSend }) + err := sendReceiptMail(s.snapshot(), "", sampleReceipt(t, s, 1, "Note", "mobile", "Qmab1sA9")) + if err == nil || err.Error() != "SMTP password is not set" || called { + t.Fatal(err, called) + } + if s.status().EmailError != "SMTP password is not set" { + t.Fatal(s.status().EmailError) + } +} + +func TestReceiptsPagePINAndNetwork(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + older := time.Now().Add(-time.Hour) + newer := time.Now() + sampleReceiptAt(t, s, 1, "Older", "ada", "oldreceipt", older) + sampleReceiptAt(t, s, 2, "Newer", "mobile", "Qmab1sA9", newer) + page := getBody(t, ippURL(s, "/receipts")) + if !strings.Contains(page, `name="viewport"`) || !strings.Contains(page, "min-height:44px") { + t.Fatal(page) + } + if strings.Index(page, "Newer") > strings.Index(page, "Older") || strings.Index(page, "Newer") < 0 { + t.Fatal(page) + } + for _, needle := range []string{"Qmab1sA9", "Stamped PDF", "Original", "/jobs/2/receipt.pdf", "/jobs/2/document.pdf"} { + if !strings.Contains(page, needle) { + t.Fatalf("missing %s\n%s", needle, page) + } + } + pdf := getBody(t, ippURL(s, "/jobs/2/receipt.pdf")) + if !strings.HasPrefix(pdf, "%PDF-cert-") { + t.Fatal(pdf) + } + local := getBody(t, strings.TrimRight(s.ControlURL(), "/")+"/receipts") + if !strings.Contains(local, "Qmab1sA9") { + t.Fatal(local) + } + if !strings.HasSuffix(s.printer.AdminURL, "/receipts") { + t.Fatal(s.printer.AdminURL) + } + st, err := (&Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")}).Status() + if err != nil || !strings.HasSuffix(st.ReceiptsURL, "/receipts") { + t.Fatalf("%+v %v", st, err) + } + + if err := saveSecrets(s.secretsFile(), Secrets{ReceiptsPIN: "2468"}); err != nil { + t.Fatal(err) + } + locked := getBody(t, ippURL(s, "/receipts")) + if strings.Contains(locked, "Newer") || !strings.Contains(locked, "Access PIN") { + t.Fatal(locked) + } + hidden := getBody(t, ippURL(s, "/jobs/2/receipt.pdf")) + if strings.Contains(hidden, "%PDF") || strings.Contains(hidden, "Qmab1sA9") { + t.Fatal(hidden) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + bad, err := client.PostForm(ippURL(s, "/receipts/unlock"), map[string][]string{"pin": {"0000"}}) + if err != nil { + t.Fatal(err) + } + badBody, _ := io.ReadAll(bad.Body) + bad.Body.Close() + if strings.Contains(string(badBody), "Newer") || !strings.Contains(string(badBody), "not right") { + t.Fatal(string(badBody)) + } + good, err := client.PostForm(ippURL(s, "/receipts/unlock"), map[string][]string{"pin": {"2468"}}) + if err != nil { + t.Fatal(err) + } + goodBody, _ := io.ReadAll(good.Body) + good.Body.Close() + if !strings.Contains(string(goodBody), "Qmab1sA9") { + t.Fatal(string(goodBody)) + } + gotPDF, err := client.Get(ippURL(s, "/jobs/2/document.pdf")) + if err != nil { + t.Fatal(err) + } + pdfBody, _ := io.ReadAll(gotPDF.Body) + gotPDF.Body.Close() + if !bytes.HasPrefix(pdfBody, []byte("%PDF-orig-")) { + t.Fatalf("%q", pdfBody) + } + withToken := getBody(t, ippURL(s, "/receipts?access=2468")) + if !strings.Contains(withToken, "Qmab1sA9") { + t.Fatal(withToken) + } + without := getBody(t, ippURL(s, "/receipts?access=nope")) + if strings.Contains(without, "Qmab1sA9") { + t.Fatal(without) + } + stillLocal := getBody(t, strings.TrimRight(s.ControlURL(), "/")+"/receipts") + if !strings.Contains(stillLocal, "Qmab1sA9") { + t.Fatal(stillLocal) + } + + prevNet := currentNetworkPublic + currentNetworkPublic = func() bool { return true } + t.Cleanup(func() { currentNetworkPublic = prevNet }) + res, err := http.Get(ippURL(s, "/receipts?access=2468")) + if err != nil { + t.Fatal(err) + } + blocked, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusForbidden || strings.Contains(string(blocked), "Qmab1sA9") { + t.Fatalf("%d %s", res.StatusCode, blocked) + } + if err := (&Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")}).SetAllowPublic(true); err != nil { + t.Fatal(err) + } + opened := getBody(t, ippURL(s, "/receipts?access=2468")) + if !strings.Contains(opened, "Qmab1sA9") { + t.Fatal(opened) + } +} + +func TestSettingsEmailDoesNotWipePrinter(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + c := &Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")} + if err := c.Settings("Lobby Printer", "office", 0); err != nil { + t.Fatal(err) + } + if err := c.SetEmailEnabled(true); err != nil { + t.Fatal(err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(saved) + if !strings.Contains(text, "Lobby Printer") || !strings.Contains(text, "office") || !strings.Contains(text, "marchon@gmail.com") { + t.Fatal(text) + } + const secret = "app-secret-should-not-leak" + buf := captureLog(t) + putSettings(t, c.BaseURL, `{"smtpPassword":"`+secret+`","receiptsPin":"2468"}`) + if strings.Contains(buf.String(), secret) || strings.Contains(buf.String(), "2468") { + t.Fatal(buf.String()) + } + saved, _ = os.ReadFile(path) + if strings.Contains(string(saved), secret) || strings.Contains(string(saved), "2468") { + t.Fatal(string(saved)) + } + sec, err := loadSecrets(s.secretsFile()) + if err != nil || sec.SMTPPassword != secret || sec.ReceiptsPIN != "2468" { + t.Fatalf("%+v %v", sec, err) + } + raw := getBody(t, c.BaseURL+"/api/v1/status") + if strings.Contains(raw, secret) || strings.Contains(raw, "2468") || !strings.Contains(raw, `"smtpPasswordSet": true`) || !strings.Contains(raw, `"receiptsPinSet": true`) || !strings.Contains(raw, `"receiptsUrl"`) { + t.Fatal(raw) + } + page := getBody(t, c.BaseURL+"/settings") + if !strings.Contains(page, "marchon@gmail.com") || !strings.Contains(page, "smtp.gmail.com") || strings.Contains(page, secret) { + t.Fatal(page) + } + putSettings(t, c.BaseURL, `{"clearPassword":true,"clearPin":true}`) + st, err := c.Status() + if err != nil || st.SMTPPasswordSet || st.ReceiptsPINSet || st.Name != "Lobby Printer" || st.ForwardPrinter != "office" { + t.Fatalf("%+v %v", st, err) + } +} + +func TestTrayShowsReceiptsURLAndEmail(t *testing.T) { + st := Status{ + Running: true, Name: "Verae Timestamp", Port: 631, + ReceiptsURL: "http://192.0.2.10:631/receipts", + EmailEnabled: true, EmailTo: "marchon@gmail.com", + EmailError: "SMTP password is not set", + ControlURL: "http://127.0.0.1:8630/", + } + var sawURL, sawMail, sawErr bool + var open string + for _, e := range BuildMenu(st) { + if strings.Contains(e.Label, "http://192.0.2.10:631/receipts") { + sawURL = true + } + if e.Kind == "email" { + sawMail = e.Checked && e.Enabled + } + if strings.Contains(e.Label, "SMTP password is not set") { + sawErr = true + } + if e.ID == idReceipts { + open = e.URL + } + } + if !sawURL || !sawMail || !sawErr || open != "http://127.0.0.1:8630/receipts" { + t.Fatalf("url %v mail %v err %v open %s", sawURL, sawMail, sawErr, open) + } +} + +func mailFixture(t *testing.T) *Server { + t.Helper() + cfg, path := testConfig(t) + on := true + cfg.EmailEnabled = &on + cfg.EmailTo = DefaultEmailTo + st, err := OpenStore(cfg.DataDir) + if err != nil { + t.Fatal(err) + } + return &Server{cfg: cfg, configPath: path, store: st, ctrl: &fakeCtrl{}, port: cfg.Port} +} + +func sampleReceipt(t *testing.T, s *Server, id int, title, user, receipt string) Record { + t.Helper() + return sampleReceiptAt(t, s, id, title, user, receipt, time.Now()) +} + +func sampleReceiptAt(t *testing.T, s *Server, id int, title, user, receipt string, when time.Time) Record { + t.Helper() + dir := filepath.Join(s.store.Dir(), fmt.Sprintf("job-%d", id)) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + cert := filepath.Join(dir, "certificate.pdf") + orig := filepath.Join(dir, "document.pdf") + js := filepath.Join(dir, "scan.verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert-"+receipt), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(orig, []byte("%PDF-orig-"+receipt), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\""+receipt+"\"}\n"), 0o600); err != nil { + t.Fatal(err) + } + rec := Record{ + ID: id, Received: when, Completed: when, User: user, Title: title, State: "completed", + ReceiptID: receipt, SHA256: strings.Repeat("ab", 32), VerifyURL: "https://explorer.verae.ai/time", + CertificatePDF: cert, OriginalPDF: orig, ReceiptJSON: js, + } + if err := s.store.Put(rec); err != nil { + t.Fatal(err) + } + return rec +} + +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := log.Writer() + flags := log.Flags() + log.SetOutput(&buf) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(prev) + log.SetFlags(flags) + }) + return &buf +} + +func ippURL(s *Server, path string) string { + return fmt.Sprintf("http://127.0.0.1:%d%s", s.Port(), path) +} + +func getBody(t *testing.T, url string) string { + t.Helper() + res, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func putSettings(t *testing.T, base, jsonBody string) { + t.Helper() + req, err := http.NewRequest(http.MethodPut, base+"/api/v1/settings", strings.NewReader(jsonBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + b, _ := io.ReadAll(res.Body) + if res.StatusCode >= 300 { + t.Fatalf("settings %d %s", res.StatusCode, b) + } +} diff --git a/internal/netprint/mail_job.go b/internal/netprint/mail_job.go new file mode 100644 index 0000000..a2f6542 --- /dev/null +++ b/internal/netprint/mail_job.go @@ -0,0 +1,263 @@ +package netprint + +import ( + "errors" + "fmt" + "html" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" +) + +var ( + maxReceiptJSON = 64 << 10 + maxAttachBytes int64 = 20 << 20 + maxAttachTotal int64 = 32 << 20 +) + +// mailRetryDelays is the wait before each attempt. The first attempt is immediate. +var mailRetryDelays = []time.Duration{0, 30 * time.Second, 2 * time.Minute, 10 * time.Minute} + +// sendMail is the SMTP call. Tests replace it. +var sendMail = func(cfg mailer.Config, m mailer.Message) error { + return (&mailer.SMTPSender{Cfg: cfg}).Send(m) +} + +func (s *Server) queueReceiptMail(rec Record) { + if rec.State != "completed" || !s.snapshot().EmailOn() { + return + } + go s.deliverWithRetry(rec) +} + +func (s *Server) deliverWithRetry(rec Record) { + delays := append([]time.Duration(nil), mailRetryDelays...) + var last error + for i, d := range delays { + if d > 0 { + time.Sleep(d) + } + cfg := s.snapshot() + if !cfg.EmailOn() { + return + } + sec, err := loadSecrets(s.secretsFile()) + if err != nil { + last = errors.New("could not read mail settings") + log.Printf("netprint: job %d email attempt %d: %v", rec.ID, i+1, last) + continue + } + err = sendReceiptMail(cfg, sec.SMTPPassword, rec) + if err == nil { + log.Printf("netprint: job %d emailed receipt=%s", rec.ID, rec.ReceiptID) + s.noteMail(rec.ID, "emailed", "") + return + } + last = scrubSecret(err, sec.SMTPPassword) + log.Printf("netprint: job %d email attempt %d: %v", rec.ID, i+1, last) + } + msg := "email failed" + if last != nil { + msg = last.Error() + } + log.Printf("netprint: job %d email failed: %s", rec.ID, msg) + s.noteMail(rec.ID, "email failed", msg) +} + +func (s *Server) noteMail(id int, notified, emailErr string) { + rec, ok := s.store.Get(id) + if !ok { + return + } + rec.Notified = notified + rec.EmailError = emailErr + if err := s.store.Put(rec); err != nil { + log.Printf("netprint: job %d mail index: %v", id, err) + } + s.mu.Lock() + s.emailErr = emailErr + s.mu.Unlock() +} + +func (s *Server) seedEmailError() { + list := s.store.List() + for i := len(list) - 1; i >= 0; i-- { + if list[i].EmailError != "" { + s.emailErr = list[i].EmailError + return + } + if list[i].Notified != "" || list[i].State == "completed" || list[i].State == "aborted" { + return + } + } +} + +func scrubSecret(err error, secret string) error { + if err == nil { + return nil + } + msg := err.Error() + if secret != "" { + msg = strings.ReplaceAll(msg, secret, "[redacted]") + } + return errors.New(msg) +} + +func mailSubject(title string) string { + title = strings.Join(strings.Fields(title), " ") + if title == "" { + title = "document" + } + return "Verae timestamp: " + title +} + +func smtpConfig(cfg Config, pass string) (mailer.Config, error) { + user := strings.TrimSpace(cfg.SMTPUser) + if user == "" { + user = DefaultEmailTo + } + host := strings.TrimSpace(cfg.SMTPHost) + if host == "" { + host = DefaultSMTPHost + } + port := cfg.SMTPPort + if port <= 0 { + port = 587 + } + from := "Verae Timestamp <" + user + ">" + if !strings.Contains(user, "@") { + return mailer.Config{}, errors.New("SMTP user is not an email address") + } + return mailer.Config{ + Host: host, + Port: port, + User: user, + Pass: pass, + From: from, + ImplicitTLS: strings.EqualFold(cfg.SMTPTLS, "tls"), + Timeout: 30 * time.Second, + }, nil +} + +// sendReceiptMail sends the stamped PDF, the original, and the receipt summary. +// It never includes the SMTP password in an error string. +func sendReceiptMail(cfg Config, pass string, rec Record) error { + if strings.TrimSpace(pass) == "" { + return errors.New("SMTP password is not set") + } + mc, err := smtpConfig(cfg, pass) + if err != nil { + return err + } + recipients := cfg.MailRecipients() + if len(recipients) == 0 { + return errors.New("no recipients") + } + msg, err := receiptMessage(rec) + if err != nil { + return err + } + var errs []string + for _, to := range recipients { + msg.To = to + if err := sendMail(mc, msg); err != nil { + errs = append(errs, scrubSecret(err, pass).Error()) + } + } + if len(errs) > 0 { + return errors.New(strings.Join(errs, "; ")) + } + return nil +} + +func receiptMessage(rec Record) (mailer.Message, error) { + var notes []string + var attachments []mailer.Attachment + used := 0 + add := func(path, name string) { + if strings.TrimSpace(path) == "" { + return + } + fi, err := os.Stat(path) + if err != nil || fi.IsDir() { + notes = append(notes, name+" was not attached") + return + } + if fi.Size() > maxAttachBytes || int64(used)+fi.Size() > maxAttachTotal { + notes = append(notes, fmt.Sprintf("%s was not attached (%d bytes)", name, fi.Size())) + return + } + b, err := os.ReadFile(path) + if err != nil { + notes = append(notes, name+" was not attached") + return + } + used += len(b) + attachments = append(attachments, mailer.Attachment{Name: name, Type: "application/pdf", Data: b}) + } + add(rec.CertificatePDF, "certificate.pdf") + if rec.OriginalPDF != "" && !samePath(rec.OriginalPDF, rec.CertificatePDF) { + add(rec.OriginalPDF, "document.pdf") + } + summary := receiptSummary(rec.ReceiptJSON) + title := strings.TrimSpace(rec.Title) + if title == "" { + title = "document" + } + var text strings.Builder + fmt.Fprintf(&text, "Verae timestamp: %s\n\nReceipt: %s\nSHA-256: %s\n", title, rec.ReceiptID, rec.SHA256) + if rec.VerifyURL != "" { + fmt.Fprintf(&text, "Verify: %s\n", rec.VerifyURL) + } + if len(notes) > 0 { + text.WriteByte('\n') + for _, n := range notes { + text.WriteString(n) + text.WriteByte('\n') + } + } + text.WriteByte('\n') + text.WriteString(summary) + plain := text.String() + page := "

" + html.EscapeString(plain) + "

" + page = strings.ReplaceAll(page, "\n", "
\n") + return mailer.Message{ + Subject: mailSubject(rec.Title), + Text: plain, + HTML: page, + Attachments: attachments, + }, nil +} + +func receiptSummary(path string) string { + if strings.TrimSpace(path) == "" { + return "(receipt summary is not on disk)\n" + } + b, err := os.ReadFile(path) + name := filepath.Base(path) + if err != nil { + return "(receipt summary is not on disk)\n" + } + clipped := "" + if len(b) > maxReceiptJSON { + b = b[:maxReceiptJSON] + clipped = "\n[truncated]\n" + } + return "--- " + name + " ---\n" + string(b) + clipped +} + +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + aa, errA := filepath.Abs(a) + bb, errB := filepath.Abs(b) + if errA != nil || errB != nil { + return a == b + } + return aa == bb +} diff --git a/internal/netprint/mdns_dnssd_test.go b/internal/netprint/mdns_dnssd_test.go index 00d5a30..57ed8f8 100644 --- a/internal/netprint/mdns_dnssd_test.go +++ b/internal/netprint/mdns_dnssd_test.go @@ -333,7 +333,7 @@ func TestServiceLogWritesStartupAndJobs(t *testing.T) { } ProcessJob(st, Config{}, nil, 7, "application/pdf", "Hello", "ada", []byte("%PDF-1.4"), func(string, string, string) (job.Result, error) { return job.Result{}, fmt.Errorf("boom") - }, nil) + }, nil, nil) b, err := os.ReadFile(filepath.Join(dir, "netprint.log")) if err != nil { t.Fatal(err) diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go index 49f62d4..e4e5f21 100644 --- a/internal/netprint/pipeline.go +++ b/internal/netprint/pipeline.go @@ -14,7 +14,6 @@ import ( "time" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" - "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/pdfcert" ) @@ -23,8 +22,9 @@ import ( type StampFunc func(pdfPath, title, user string) (job.Result, error) // ProcessJob turns an IPP document into a Verae receipt and optional forward. -// A forward or mail failure does not fail the timestamp. -func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user string, doc []byte, stamp StampFunc, forward ForwardFunc) { +// A forward failure does not fail the timestamp. notify runs only after the +// job is stored as completed, and its work must not change that outcome. +func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user string, doc []byte, stamp StampFunc, forward ForwardFunc, notify func(Record)) { if stamp == nil { stamp = func(string, string, string) (job.Result, error) { return job.Result{}, fmt.Errorf("no stamp function") @@ -108,7 +108,6 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s rec.ForwardedTo = fp } } - rec.Notified = maybeEmail(user, rec) rec.State = "completed" rec.Completed = time.Now() if err := st.Put(rec); err != nil { @@ -118,6 +117,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s if p != nil { p.SetState(id, jobCompleted, "job-completed-successfully", "") } + if notify != nil { + notify(rec) + } } func documentPDF(format string, doc []byte) ([]byte, error) { @@ -179,25 +181,3 @@ func annotateUser(path, user string) { } _ = os.WriteFile(path, append(out, '\n'), 0o600) } - -func maybeEmail(user string, rec Record) string { - user = strings.TrimSpace(user) - if !strings.Contains(user, "@") { - return "" - } - cfg, err := mailer.FromEnv() - if err != nil { - return "" - } - text := fmt.Sprintf("Verae timestamped %q.\nReceipt: %s\nVerify: %s\nSHA-256: %s\n", - rec.Title, rec.ReceiptID, rec.VerifyURL, rec.SHA256) - err = (&mailer.SMTPSender{Cfg: cfg}).Send(mailer.Message{ - To: user, - Subject: "Verae timestamp receipt", - Text: text, - }) - if err != nil { - return "email: " + err.Error() - } - return "emailed" -} diff --git a/internal/netprint/receipts.go b/internal/netprint/receipts.go new file mode 100644 index 0000000..357e0fe --- /dev/null +++ b/internal/netprint/receipts.go @@ -0,0 +1,189 @@ +package netprint + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "html" + "log" + "net" + "net/http" + "strconv" + "strings" +) + +// currentNetworkPublic is the Windows network category, replaced in tests. +var currentNetworkPublic = cachedNetworkPublic + +// receiptsNetworkAllowed is the HTTP check in front of the LAN receipts pages. +// The firewall already limits the port to Private and Domain unless AllowPublic +// is on. On Windows this also refuses the pages when the active network is Public. +func receiptsNetworkAllowed(networkPublic, allowPublic bool) bool { + if networkPublic && !allowPublic { + return false + } + return true +} + +func (s *Server) receiptsURL() string { + host := "" + cfg := s.snapshot() + if ip := strings.TrimSpace(cfg.Bind); ip != "" && ip != "0.0.0.0" && ip != "::" && ip != "[::]" { + host = ip + } + if host == "" { + hosts := lanHosts() + if len(hosts) == 0 { + return "" + } + host = hosts[0] + } + return fmt.Sprintf("http://%s/receipts", net.JoinHostPort(host, strconv.Itoa(s.port))) +} + +func (s *Server) secretsFile() string { + s.mu.Lock() + defer s.mu.Unlock() + return secretsPath(s.configPath, s.cfg.DataDir) +} + +func (s *Server) receiptsPIN() (string, error) { + sec, err := loadSecrets(s.secretsFile()) + if err != nil { + return "", err + } + return strings.TrimSpace(sec.ReceiptsPIN), nil +} + +func (s *Server) allowReceipts(w http.ResponseWriter, r *http.Request) bool { + pin, err := s.receiptsPIN() + if err != nil { + log.Printf("netprint: receipts lock: %v", err) + http.Error(w, "receipts unavailable", http.StatusForbidden) + return false + } + if pin == "" { + return true + } + if r.URL.Path == "/receipts/unlock" && r.Method == http.MethodPost { + s.unlockReceipts(w, r, pin) + return false + } + if c, err := r.Cookie(receiptsCookie); err == nil && cookieMatches(c.Value, pin) { + return true + } + if access := r.URL.Query().Get("access"); access != "" && pinMatch(access, pin) { + setReceiptsCookie(w, pin) + return true + } + writePINForm(w, false) + return false +} + +func (s *Server) unlockReceipts(w http.ResponseWriter, r *http.Request, pin string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + if !pinMatch(r.FormValue("pin"), pin) { + writePINForm(w, true) + return + } + setReceiptsCookie(w, pin) + http.Redirect(w, r, "/receipts", http.StatusSeeOther) +} + +const receiptsCookie = "verae_receipts" + +func pinMAC(pin string) []byte { + m := hmac.New(sha256.New, []byte("verae-netprint-receipts")) + _, _ = m.Write([]byte(pin)) + return m.Sum(nil) +} + +func pinMatch(got, want string) bool { + return subtle.ConstantTimeCompare(pinMAC(got), pinMAC(want)) == 1 +} + +func cookieMatches(cookie, pin string) bool { + want := hex.EncodeToString(pinMAC(pin)) + if len(cookie) != len(want) { + return false + } + return subtle.ConstantTimeCompare([]byte(cookie), []byte(want)) == 1 +} + +func setReceiptsCookie(w http.ResponseWriter, pin string) { + http.SetCookie(w, &http.Cookie{ + Name: receiptsCookie, + Value: hex.EncodeToString(pinMAC(pin)), + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 30 * 24 * 3600, + }) +} + +func writePINForm(w http.ResponseWriter, bad bool) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + msg := "" + if bad { + msg = "

That PIN is not right.

" + } + _, _ = w.Write([]byte(`Receipts + +

Receipts

` + msg + ``)) +} + +func (a *Admin) receipts(w http.ResponseWriter, r *http.Request) { + name := "Verae Timestamp" + if a.Name != nil && a.Name() != "" { + name = a.Name() + } + var b strings.Builder + b.WriteString("") + b.WriteString("") + fmt.Fprintf(&b, "%s receipts", html.EscapeString(name)) + b.WriteString(``) + fmt.Fprintf(&b, "

%s

Recent timestamped jobs, newest first.

", html.EscapeString(name)) + list := a.Store.List() + shown := 0 + for i := len(list) - 1; i >= 0 && shown < 100; i-- { + rec := list[i] + shown++ + title := firstNonEmpty(rec.Title, fmt.Sprintf("Job %d", rec.ID)) + when := rec.Received.Local().Format("2006-01-02 15:04") + fmt.Fprintf(&b, "

%s

%s · %s · %s

", + html.EscapeString(title), html.EscapeString(when), html.EscapeString(rec.User), html.EscapeString(rec.State)) + if rec.ReceiptID != "" { + fmt.Fprintf(&b, "

Receipt %s

", html.EscapeString(rec.ReceiptID)) + } + if rec.SHA256 != "" { + fmt.Fprintf(&b, "

SHA-256 %s

", html.EscapeString(rec.SHA256)) + } + if rec.CertificatePDF != "" { + fmt.Fprintf(&b, `Stamped PDF`, rec.ID) + } + if rec.OriginalPDF != "" { + fmt.Fprintf(&b, `Original`, rec.ID) + } + b.WriteString("
") + } + if shown == 0 { + b.WriteString("

No jobs yet.

") + } + b.WriteString("") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(b.String())) +} diff --git a/internal/netprint/secret.go b/internal/netprint/secret.go new file mode 100644 index 0000000..bc4e744 --- /dev/null +++ b/internal/netprint/secret.go @@ -0,0 +1,86 @@ +package netprint + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +// Secrets holds values that must not be written to netprint.json or the log. +type Secrets struct { + SMTPPassword string `json:"smtpPassword,omitempty"` + ReceiptsPIN string `json:"receiptsPin,omitempty"` +} + +func (s Secrets) empty() bool { + return strings.TrimSpace(s.SMTPPassword) == "" && strings.TrimSpace(s.ReceiptsPIN) == "" +} + +// secretsPath is the protected file next to the config, or inside dataDir. +func secretsPath(configPath, dataDir string) string { + if strings.TrimSpace(configPath) != "" { + return filepath.Join(filepath.Dir(configPath), "netprint-secrets.bin") + } + return filepath.Join(dataDir, "netprint-secrets.bin") +} + +func loadSecrets(path string) (Secrets, error) { + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Secrets{}, nil + } + return Secrets{}, err + } + if len(b) == 0 { + return Secrets{}, nil + } + plain, err := unprotect(b) + if err != nil { + return Secrets{}, err + } + var s Secrets + if err := json.Unmarshal(plain, &s); err != nil { + return Secrets{}, err + } + return s, nil +} + +func saveSecrets(path string, s Secrets) error { + if strings.TrimSpace(path) == "" { + return errors.New("secrets path required") + } + if s.empty() { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + b, err := json.Marshal(s) + if err != nil { + return err + } + sealed, err := protect(b) + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, sealed, 0o600); err != nil { + return err + } + if err := os.Chmod(tmp, 0o600); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Chmod(path, 0o600) +} diff --git a/internal/netprint/secret_unix.go b/internal/netprint/secret_unix.go new file mode 100644 index 0000000..3ec5189 --- /dev/null +++ b/internal/netprint/secret_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package netprint + +func protect(plain []byte) ([]byte, error) { return plain, nil } + +func unprotect(b []byte) ([]byte, error) { return b, nil } diff --git a/internal/netprint/secret_windows.go b/internal/netprint/secret_windows.go new file mode 100644 index 0000000..21cb2a7 --- /dev/null +++ b/internal/netprint/secret_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package netprint + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +// protect seals b with DPAPI for the service account. The blob is useless on +// another machine or to another Windows user. +func protect(plain []byte) ([]byte, error) { + in := dataBlob(plain) + var out windows.DataBlob + err := windows.CryptProtectData(in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(out.Data)))) + return copyBlob(out), nil +} + +func unprotect(b []byte) ([]byte, error) { + in := dataBlob(b) + var out windows.DataBlob + err := windows.CryptUnprotectData(in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(out.Data)))) + return copyBlob(out), nil +} + +func dataBlob(b []byte) *windows.DataBlob { + if len(b) == 0 { + return &windows.DataBlob{} + } + return &windows.DataBlob{Size: uint32(len(b)), Data: &b[0]} +} + +func copyBlob(b windows.DataBlob) []byte { + if b.Data == nil || b.Size == 0 { + return nil + } + return append([]byte(nil), unsafe.Slice(b.Data, b.Size)...) +} diff --git a/internal/netprint/server.go b/internal/netprint/server.go index ee5dd65..3879510 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -31,6 +31,7 @@ type Server struct { controlSrv *http.Server mdnsCancel context.CancelFunc mdns mdnsReport + emailErr string } // Run loads config, listens, and blocks until ctx is canceled. @@ -91,12 +92,10 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) return nil, fmt.Errorf("control listen: %w", err) } s.controlURL = "http://" + ctlLn.Addr().String() + "/" - hosts := lanHosts() - if len(hosts) > 0 { - s.printer.AdminURL = fmt.Sprintf("http://%s/", net.JoinHostPort(hosts[0], fmt.Sprint(port))) - } + s.seedEmailError() + s.printer.AdminURL = s.receiptsURL() s.printer.OnDocument = func(id int, format, title, user string, doc []byte) { - ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.queueReceiptMail) } admin := &Admin{Store: st, Name: func() string { return s.snapshot().Name }, Port: func() int { return s.port }} @@ -109,6 +108,13 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) http.NotFound(w, r) return } + if !receiptsNetworkAllowed(currentNetworkPublic(), s.snapshot().PublicNetworks()) { + http.Error(w, "receipts are available on Private and Domain networks", http.StatusForbidden) + return + } + if !s.allowReceipts(w, r) { + return + } admin.ServeHTTP(w, r) }) s.ippSrv = &http.Server{Handler: pub} diff --git a/internal/netprint/store.go b/internal/netprint/store.go index d24ead7..799906c 100644 --- a/internal/netprint/store.go +++ b/internal/netprint/store.go @@ -30,6 +30,7 @@ type Record struct { ForwardedTo string `json:"forwardedTo,omitempty"` ForwardError string `json:"forwardError,omitempty"` Notified string `json:"notified,omitempty"` + EmailError string `json:"emailError,omitempty"` } // Store persists the job index under DataDir/jobs.json. diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go index 8b45a0a..feafa47 100644 --- a/internal/netprint/tray_menu.go +++ b/internal/netprint/tray_menu.go @@ -15,6 +15,7 @@ const ( idPublic = 9 idReceipts = 10 idSettings = 11 + idEmail = 12 idQuit = 100 idRecent = 20 ) @@ -73,7 +74,22 @@ func BuildMenu(st Status) []MenuEntry { out = append(out, MenuEntry{ID: idPublic, Label: "Allow Public networks", Enabled: st.Running, Checked: st.AllowPublic, Kind: "public"}) } out = append(out, MenuEntry{Separator: true}, - MenuEntry{ID: idReceipts, Label: "Open receipts", Enabled: st.Running && st.AdminURL != "", URL: firstURL(st), Kind: "open"}, + MenuEntry{ID: idEmail, Label: "Email stamped PDFs", Enabled: st.Running, Checked: st.EmailEnabled, Kind: "email"}, + ) + if st.EmailError != "" { + out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"}) + } else if st.EmailEnabled { + to := st.EmailTo + if to == "" { + to = DefaultEmailTo + } + out = append(out, MenuEntry{Label: "Email — on, to " + to, Kind: "status"}) + } + if st.ReceiptsURL != "" { + out = append(out, MenuEntry{Label: "Receipts " + st.ReceiptsURL, Kind: "status"}) + } + out = append(out, + MenuEntry{ID: idReceipts, Label: "Open receipts", Enabled: st.Running && (st.ReceiptsURL != "" || st.ControlURL != "" || st.AdminURL != ""), URL: localReceipts(st), Kind: "open"}, ) for i, rec := range st.Recent { if i >= 6 { @@ -136,14 +152,17 @@ func clipMenu(s string, n int) string { return s[:n-1] + "…" } -func firstURL(st Status) string { +func localReceipts(st Status) string { if u := strings.TrimRight(st.ControlURL, "/"); u != "" { - return u + "/" + return u + "/receipts" + } + if st.ReceiptsURL != "" { + return st.ReceiptsURL } if st.AdminURL != "" { return st.AdminURL } - return "http://127.0.0.1:8630/" + return "http://127.0.0.1:8630/receipts" } func receiptURL(st Status, rec Record) string { @@ -178,6 +197,8 @@ func Dispatch(st Status, e MenuEntry) error { return c.SetTrayAtLogin(!st.TrayAtLogin) case "public": return c.SetAllowPublic(!st.AllowPublic) + case "email": + return c.SetEmailEnabled(!st.EmailEnabled) case "open": return OpenURL(e.URL) case "quit": From a7e42e60348fe665078f1201855ed516efe62f94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 01:07:27 +0000 Subject: [PATCH 17/27] Return the stamped PDF to the device that sent it. POST /api/stamp runs the print pipeline and responds with the certificate, or a zip of the original, the certificate, and the receipt. The receipts page links an iPhone shortcut, and mDNS advertises _verae._tcp without a second IPP name. Co-authored-by: George Lambert --- docs/ios-shortcut/README.md | 36 ++++ docs/ios-shortcut/share-extension.md | 47 +++++ docs/network-printer.md | 10 +- internal/netprint/advertise.go | 12 +- internal/netprint/dnsservice.go | 4 +- internal/netprint/mdns.go | 79 ++++++++ internal/netprint/receipts.go | 10 + internal/netprint/server.go | 17 ++ internal/netprint/shortcut_page.go | 39 ++++ internal/netprint/stamp_api.go | 259 +++++++++++++++++++++++++ internal/netprint/stamp_api_test.go | 276 +++++++++++++++++++++++++++ 11 files changed, 785 insertions(+), 4 deletions(-) create mode 100644 docs/ios-shortcut/README.md create mode 100644 docs/ios-shortcut/share-extension.md create mode 100644 internal/netprint/shortcut_page.go create mode 100644 internal/netprint/stamp_api.go create mode 100644 internal/netprint/stamp_api_test.go diff --git a/docs/ios-shortcut/README.md b/docs/ios-shortcut/README.md new file mode 100644 index 0000000..5c30eb1 --- /dev/null +++ b/docs/ios-shortcut/README.md @@ -0,0 +1,36 @@ +# Verae Timestamp iPhone shortcut + +Build this in the Shortcuts app. It shows up in the Share sheet, sends the PDF or photo to the Verae printer on your Wi-Fi, and saves the stamped certificate. A signed `.shortcut` file is not included: Apple only accepts shortcuts signed on a Mac or iPhone, and this tree is built on Linux. + +Open the receipts page on a computer (`http://:631/receipts`, or 8632 if CUPS is using 631). The QR code opens `http://:/ios-shortcut` on the phone, which repeats these steps and shows the stamp address for this machine. + +## Build it + +1. Open **Shortcuts** and tap **+**. Name the shortcut **Verae Timestamp**. +2. Tap the shortcut’s **(i)** or settings and turn on **Show in Share Sheet**. +3. Under share sheet types, leave **PDFs** and **Images** selected. You can clear the other types. +4. Add **Get Contents of URL**. + - URL: `http://:/api/stamp` + If the receipts page has a PIN, use `http://:/api/stamp?access=YOURPIN`. + - Method: **POST**. + - Request Body: **File**. + - File: **Shortcut Input**. + - Add a header `X-Filename` if you want the job title to be the file name. The server also accepts `Content-Disposition: attachment; filename="Contract.pdf"`. PDF and JPEG are recognized from the file bytes, so a photo from the share sheet works. +5. Add **Quick Look** and pass the contents of the URL. That is the stamped certificate PDF. +6. Add **Save File**. Leave **Ask Where to Save** on and pick iCloud Drive or On My iPhone when it asks. +7. Show the receipt id: + - Add **Get Dictionary Value**. + - Dictionary: the **Headers** of the Get Contents of URL result. + - Key: `X-Verae-Receipt-Id`. + - Add **Show Result** (or **Show Notification**) with that value. + - `X-Verae-SHA256` is the document hash. `X-Verae-Receipt-JSON` is the URL of the `.verae.json` summary on the printer. + +To get a zip instead, change the URL to end in `?format=zip` (use `&access=` if you already have `?access=`). **Save File** then receives `original.pdf`, `certificate.pdf`, and `receipt.json`. + +## Use it + +In Files or Photos, open a PDF or picture, tap **Share**, and choose **Verae Timestamp**. If it is missing, tap **More** and enable it. + +The printer records the request as a job, lists it on the receipts page, and emails the stamped PDF when **Email stamped PDFs** is on. The shortcut does not wait for the email. The PDF in the shortcut result is the certificate. + +The stamp address is only for Private and Domain networks, same as the printer. A wrong or missing PIN returns `401` and no PDF. diff --git a/docs/ios-shortcut/share-extension.md b/docs/ios-shortcut/share-extension.md new file mode 100644 index 0000000..a240bbd --- /dev/null +++ b/docs/ios-shortcut/share-extension.md @@ -0,0 +1,47 @@ +# Share extension for the Verae Timestamping iOS app + +The app (`com.flightlookup.verae-timestamping`) is not in this repository. This note is the design for a **Verae Timestamp** row in the iOS Share sheet that stamps a PDF or photo on the LAN print server and keeps the result in Files and in the app. + +The shortcut in `README.md` is the path that works before that extension ships. The server side it needs is already here: `POST /api/stamp` and a `_verae._tcp` Bonjour record. + +## Extension + +- Extension point: `com.apple.share-services`. +- Display name: **Verae Timestamp**. +- Activation rule: the first attachment conforms to `com.adobe.pdf` or `public.image` (which covers JPEG and HEIC). Ignore other share types. +- Process the attachment in the extension, then call `completeRequestReturningItems` with an item provider for the stamped PDF so the system share sheet can hand it back. Also write a copy into the app group so the main app can list it without waiting for Files. + +Suggested app group: `group.com.flightlookup.verae-timestamping`. Put the certificate, the receipt id, the SHA-256, and the receipt JSON in that container. The main app reads the same folder on launch. + +## Finding the printer + +Browse Bonjour in `local.` with `NWBrowser`: + +1. Prefer `_verae._tcp`. The TXT record is `txtvers=1`, `path=/api/stamp`, `ver=1`, and `url=http://:/api/stamp`. The SRV record is the port (631, or 8632 when CUPS owns 631). Resolve the host and POST to `http://:` plus `path`. +2. If that browse is empty, browse `_ipp._tcp` and keep the instance whose TXT `ty` or `product` is **Verae Timestamp**. Use the host and port from that service and POST to `/api/stamp`. The AirPrint `adminurl` ends in `/receipts`; the stamp URL is the same origin with `/api/stamp`. + +This repository advertises `_verae._tcp` from the print server’s mDNS socket. It is a separate service name from `._ipp._tcp.local`, so it does not create a second IPP advertisement. On Windows, `DnsServiceRegister` still owns only the IPP name. The shared UDP 5353 socket answers AirPrint subtype PTRs for IPP and, separately, the `_verae._tcp` PTR, SRV, and TXT. It does not add IPP SRV, TXT, or address records while DNS-SD is up. + +Stop the browse when the extension finishes. Cache the last host and port in the app group so a later share can try it first and fall back to a browse if the POST fails. + +## Upload + +`POST /api/stamp` with the file bytes as the body, or as multipart field `file`. + +- Header `X-Filename`: the document title. +- Header `X-Verae-User`: optional, shown on the receipts page. Default is `stamp`. +- The response body is `application/pdf` (the certificate). +- Headers: `X-Verae-Receipt-Id`, `X-Verae-SHA256`, `X-Verae-Receipt-JSON`. +- `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. + +The job is stored like a print, appears on `/receipts`, and is emailed when email is enabled. The extension should treat the HTTP response as the copy to save, not wait for the email. + +## PIN and network + +The route uses the same Private/Domain check and optional receipts PIN as `/receipts`. Send the PIN as `X-Verae-Access`, `Authorization: Bearer `, or `?access=`. Store the PIN in the Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`), not in the app group. A `401` means the PIN is missing or wrong. A `403` means the computer is on a Public network and Public access is off. Show that text; do not save an empty file. + +## Saving + +- Write the certificate into the app group and refresh the main app’s receipt list (a Darwin notification on the app group is enough). +- Offer **Save to Files** with a document picker, or by returning the PDF from the extension so the user can pick a folder. +- Keep the receipt id and SHA-256 next to the file. The JSON URL in `X-Verae-Receipt-JSON` is on the LAN and needs the same PIN. diff --git a/docs/network-printer.md b/docs/network-printer.md index a45e810..a7a836c 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -35,7 +35,15 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Receipts on the phone -`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON field is `receiptsUrl`. +`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON field is `receiptsUrl`. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. + +## Stamp and return + +`POST http://:/api/stamp` accepts a PDF or JPEG, either as the raw body (set `X-Filename` or `Content-Disposition` for the title) or as multipart with a `file` field. It runs the same pipeline as a print job: the job is stored, it shows on the receipts page, and it is emailed when email is on. The response is the stamped certificate PDF. Headers `X-Verae-Receipt-Id`, `X-Verae-SHA256`, and `X-Verae-Receipt-JSON` identify the receipt. `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. The same Private and Domain rule and the same PIN apply. Send the PIN as `?access=`, the `X-Verae-Access` header, or `Authorization: Bearer`. A failure does not return a PDF. + +The server also advertises `_verae._tcp` (PTR, SRV, and TXT `path=/api/stamp`) beside the IPP printer. That record is a different service name, so it does not add a second `_ipp._tcp` advertisement. While Windows DNS-SD owns the printer, the shared UDP socket still answers only the AirPrint subtype PTRs for `_ipp._tcp` and does not add IPP SRV, TXT, or address records. The `_verae._tcp` SRV and TXT go out on that socket because DNS-SD does not register that name. Address records stay with the printer advertisement. + +The Share sheet steps are in `docs/ios-shortcut/README.md`. A signed `.shortcut` file is not in this tree: signing needs the Shortcuts app on a Mac or iPhone. The design for a native share extension in the Verae Timestamping iOS app (`com.flightlookup.verae-timestamping`) is `docs/ios-shortcut/share-extension.md`. Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward does not fail the timestamp. diff --git a/internal/netprint/advertise.go b/internal/netprint/advertise.go index 2c78d6e..1ce79b6 100644 --- a/internal/netprint/advertise.go +++ b/internal/netprint/advertise.go @@ -191,6 +191,7 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er go func() { <-ctx.Done() _, _ = writeMDNS(conn, current().Announce(true)) + _, _ = writeMDNS(conn, current().VeraeAnnounce(true)) _ = conn.Close() }() sent := 0 @@ -204,6 +205,11 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er return } sent++ + if v := current().VeraeAnnounce(false); len(v) > 0 { + if _, verr := writeMDNS(conn, v); verr != nil { + log.Printf("netprint: mDNS _verae._tcp announcement failed: %v", verr) + } + } log.Printf("netprint: mDNS announcement sent (%d bytes) to 224.0.0.251 and ff02::fb port 5353", n) if sink != nil { sink(mdnsReport{ @@ -237,7 +243,11 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er continue } query := append([]byte(nil), buf[:n]...) - resp := current().Reply(query) + svc := current() + resp := svc.Reply(query) + if resp == nil { + resp = svc.ReplyVerae(query) + } if resp == nil { continue } diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 2ff0530..62966f8 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -111,7 +111,7 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap sink(mdnsReport{ Advertising: true, Mode: "dns-sd", - Detail: st.dnsDetail + "; shared UDP 5353 subtype PTR _universal._sub and _print._sub", + Detail: st.dnsDetail + "; shared UDP 5353 subtype PTR _universal._sub and _print._sub; _verae._tcp", LastSent: time.Now(), }) case st.dnsOK: @@ -129,7 +129,7 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap sink(mdnsReport{ Advertising: true, Mode: "udp", - Detail: "shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub", + Detail: "shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub, _verae._tcp", Err: st.dnsErr, LastSent: time.Now(), }) diff --git a/internal/netprint/mdns.go b/internal/netprint/mdns.go index 1f84974..ab5202e 100644 --- a/internal/netprint/mdns.go +++ b/internal/netprint/mdns.go @@ -30,6 +30,8 @@ type Service struct { Port int IPs []net.IP TXT []string + // StampURL is the LAN stamp-and-return address advertised on _verae._tcp. + StampURL string // SubtypesOnly answers _universal._sub and _print._sub PTRs and nothing // else. Windows uses that mode while DnsServiceRegister owns the primary // _ipp._tcp name: a second SRV or TXT for the same instance is a conflict, @@ -192,6 +194,83 @@ func (s Service) Announce(goodbye bool) []byte { return s.encodeResponse(0, qs, ttl) } +func (s Service) veraeLabels() []string { + return []string{sanitizeLabel(s.Instance), "_verae", "_tcp", "local"} +} + +func (s Service) veraeTXT() []string { + txt := []string{"txtvers=1", "path=/api/stamp", "ver=1"} + if u := strings.TrimSpace(s.StampURL); u != "" { + txt = append(txt, "url="+trimTXT(u, 200)) + } + return txt +} + +// VeraeAnnounce advertises _verae._tcp. It is a different service name from +// _ipp._tcp, so it does not create a second copy of the printer. While +// SubtypesOnly is set, address records stay with DnsServiceRegister. +func (s Service) VeraeAnnounce(goodbye bool) []byte { + ttl := uint32(mdnsTTL) + if goodbye { + ttl = 0 + } + inst := s.veraeLabels() + ans := []dnsRR{{ + name: []string{"_verae", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: inst, + }} + extra := []dnsRR{ + {name: inst, typ: dnsTypeSRV, class: dnsClassIN | dnsCacheFlush, ttl: ttl, srvPort: s.Port, srvTarget: s.hostLabels()}, + {name: inst, typ: dnsTypeTXT, class: dnsClassIN | dnsCacheFlush, ttl: ttl, txt: s.veraeTXT()}, + } + if !s.SubtypesOnly { + extra = append(extra, s.addressRRs(ttl)...) + } + return encodeMessage(0, 0x8400, nil, ans, extra) +} + +func (s Service) addressRRs(ttl uint32) []dnsRR { + var out []dnsRR + for _, ip := range s.IPs { + if v4 := ip.To4(); v4 != nil { + out = append(out, dnsRR{name: s.hostLabels(), typ: dnsTypeA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v4}) + continue + } + if v6 := ip.To16(); v6 != nil { + out = append(out, dnsRR{name: s.hostLabels(), typ: dnsTypeAAAA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v6}) + } + } + return out +} + +// ReplyVerae answers a browse or resolve for _verae._tcp. +func (s Service) ReplyVerae(query []byte) []byte { + if len(query) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(query[2:4]) + if flags&0x8000 != 0 { + return nil + } + qs, err := parseQuestions(query) + if err != nil || len(qs) == 0 { + return nil + } + var matched []dnsQuestion + for _, q := range qs { + n := lowerJoin(q.name) + switch { + case n == "_verae._tcp.local" && (q.typ == dnsTypePTR || q.typ == dnsTypeANY): + matched = append(matched, q) + case n == lowerJoin(s.veraeLabels()) && (q.typ == dnsTypePTR || q.typ == dnsTypeSRV || q.typ == dnsTypeTXT || q.typ == dnsTypeANY): + matched = append(matched, q) + } + } + if len(matched) == 0 { + return nil + } + return s.VeraeAnnounce(false) +} + func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte { var answers []byte var add []byte diff --git a/internal/netprint/receipts.go b/internal/netprint/receipts.go index 357e0fe..b32167d 100644 --- a/internal/netprint/receipts.go +++ b/internal/netprint/receipts.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" + "encoding/base64" "encoding/hex" "fmt" "html" @@ -12,6 +13,8 @@ import ( "net/http" "strconv" "strings" + + "github.com/skip2/go-qrcode" ) // currentNetworkPublic is the Windows network category, replaced in tests. @@ -156,6 +159,13 @@ p{margin:4px 0} a.btn{display:inline-block;min-height:44px;line-height:44px;padding:0 14px;margin:8px 8px 0 0;background:#111;color:#fff;text-decoration:none;border-radius:8px} `) fmt.Fprintf(&b, "

%s

Recent timestamped jobs, newest first.

", html.EscapeString(name)) + if r.Host != "" { + shortcut := "http://" + r.Host + "/ios-shortcut" + fmt.Fprintf(&b, `

iPhone Shortcut

`) + if png, err := qrcode.Encode(shortcut, qrcode.Medium, 256); err == nil { + fmt.Fprintf(&b, `

Scan to build the Share sheet shortcut.

QR code for the iPhone shortcut

`, base64.StdEncoding.EncodeToString(png)) + } + } list := a.Store.List() shown := 0 for i := len(list) - 1; i >= 0 && shown < 100; i-- { diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 3879510..4b79ec9 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -104,6 +104,17 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) s.printer.ServeHTTP(w, r) return } + if r.URL.Path == "/api/stamp" || r.URL.Path == "/api/stamp/" { + if !receiptsNetworkAllowed(currentNetworkPublic(), s.snapshot().PublicNetworks()) { + http.Error(w, "the stamp API is available on Private and Domain networks", http.StatusForbidden) + return + } + if !s.authorizeStamp(w, r) { + return + } + s.handleStamp(w, r) + return + } if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/settings" { http.NotFound(w, r) return @@ -115,6 +126,10 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) if !s.allowReceipts(w, r) { return } + if r.URL.Path == "/ios-shortcut" || r.URL.Path == "/ios-shortcut/" { + s.serveShortcutGuide(w, r) + return + } admin.ServeHTTP(w, r) }) s.ippSrv = &http.Server{Handler: pub} @@ -156,12 +171,14 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) c := s.snapshot() ips := lanIPs() adminURL := s.printer.AdminURL + stampURL := strings.TrimSuffix(adminURL, "/receipts") + "/api/stamp" return Service{ Instance: c.Name, Host: hostLabel(c.Hostname), Port: s.port, IPs: ips, TXT: AirPrintTXT(c.Name, uuid, adminURL, "", ""), + StampURL: stampURL, } }, s.setMDNS) if err != nil && ctx.Err() == nil { diff --git a/internal/netprint/shortcut_page.go b/internal/netprint/shortcut_page.go new file mode 100644 index 0000000..4494bd6 --- /dev/null +++ b/internal/netprint/shortcut_page.go @@ -0,0 +1,39 @@ +package netprint + +import ( + "fmt" + "html" + "net/http" + "strings" +) + +func (s *Server) serveShortcutGuide(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + stamp := "http://" + r.Host + "/api/stamp" + if r.Host == "" { + stamp = strings.TrimSuffix(s.receiptsURL(), "/receipts") + "/api/stamp" + } + page := fmt.Sprintf(`iPhone shortcut + + +

Receipts

+

Verae Timestamp shortcut

+

Build this in the Shortcuts app so the Share sheet sends a PDF or photo here and returns the stamped certificate. A signed shortcut file is not served: Apple signs those on a Mac or iPhone.

+
    +
  1. Open Shortcuts, tap +, and name it Verae Timestamp.
  2. +
  3. Turn on Show in Share Sheet. Keep PDFs and Images selected.
  4. +
  5. Add Get Contents of URL. URL %s. Method POST. Request Body: File. File: Shortcut Input. If a PIN is set, add ?access= and the PIN.
  6. +
  7. Add a header X-Filename when you want that name as the job title. PDF and JPEG are recognized from the file itself.
  8. +
  9. Add Quick Look on the result, then Save File, and leave Ask Where to Save on.
  10. +
  11. Add Get Dictionary Value. Dictionary: Headers of the URL result. Key X-Verae-Receipt-Id. Show Result. X-Verae-SHA256 is the hash and X-Verae-Receipt-JSON is the receipt summary.
  12. +
  13. For a zip of the original, the stamped PDF, and the receipt JSON, use %s?format=zip.
  14. +
+

From Files or Photos, tap Share and choose Verae Timestamp. The printer lists the job and emails it when email is on.

+`, html.EscapeString(stamp), html.EscapeString(stamp)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(page)) +} diff --git a/internal/netprint/stamp_api.go b/internal/netprint/stamp_api.go new file mode 100644 index 0000000..070e1d1 --- /dev/null +++ b/internal/netprint/stamp_api.go @@ -0,0 +1,259 @@ +package netprint + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "log" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" +) + +// handleStamp runs the print pipeline and returns the stamped PDF, or a zip +// of the original, the certificate, and the receipt JSON when format=zip. +func (s *Server) handleStamp(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + doc, title, format, err := readStampBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(doc) == 0 { + http.Error(w, "empty document", http.StatusBadRequest) + return + } + if len(doc) > maxDocument { + http.Error(w, "document is too large", http.StatusRequestEntityTooLarge) + return + } + id, err := s.store.AllocateID() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + user := stampUser(r.Header.Get("X-Verae-User")) + log.Printf("netprint: stamp api job %d title=%q bytes=%d", id, title, len(doc)) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.queueReceiptMail) + rec, ok := s.store.Get(id) + if !ok || rec.State != "completed" || rec.CertificatePDF == "" { + msg := "stamp failed" + if rec.Error != "" { + msg = rec.Error + } + http.Error(w, msg, http.StatusUnprocessableEntity) + return + } + cert, err := readStampFile(rec.CertificatePDF) + if err != nil { + http.Error(w, "stamped PDF is missing", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Verae-Receipt-Id", rec.ReceiptID) + w.Header().Set("X-Verae-SHA256", rec.SHA256) + w.Header().Set("X-Verae-Receipt-JSON", s.receiptJSONURL(r, id)) + if strings.EqualFold(r.URL.Query().Get("format"), "zip") { + payload, err := stampZip(rec, cert) + if err != nil { + http.Error(w, "could not build zip", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="verae-stamp.zip"`) + _, _ = w.Write(payload) + return + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="certificate.pdf"`) + _, _ = w.Write(cert) +} + +func (s *Server) receiptJSONURL(r *http.Request, id int) string { + host := r.Host + if host == "" { + host = strings.TrimPrefix(strings.TrimSuffix(s.receiptsURL(), "/receipts"), "http://") + } + return fmt.Sprintf("http://%s/jobs/%d/receipt.json", host, id) +} + +func (s *Server) authorizeStamp(w http.ResponseWriter, r *http.Request) bool { + pin, err := s.receiptsPIN() + if err != nil { + log.Printf("netprint: stamp lock: %v", err) + http.Error(w, "stamp unavailable", http.StatusForbidden) + return false + } + if pin == "" || stampTokenOK(r, pin) { + return true + } + w.Header().Set("WWW-Authenticate", `Bearer realm="verae-stamp"`) + http.Error(w, "PIN required", http.StatusUnauthorized) + return false +} + +func stampTokenOK(r *http.Request, pin string) bool { + if c, err := r.Cookie(receiptsCookie); err == nil && cookieMatches(c.Value, pin) { + return true + } + if access := strings.TrimSpace(r.URL.Query().Get("access")); access != "" && pinMatch(access, pin) { + return true + } + if h := strings.TrimSpace(r.Header.Get("X-Verae-Access")); h != "" && pinMatch(h, pin) { + return true + } + authz := strings.TrimSpace(r.Header.Get("Authorization")) + if len(authz) > 7 && strings.EqualFold(authz[:7], "bearer ") && pinMatch(strings.TrimSpace(authz[7:]), pin) { + return true + } + return false +} + +func readStampBody(r *http.Request) (doc []byte, title, format string, err error) { + ct := r.Header.Get("Content-Type") + mediaType, _, _ := mime.ParseMediaType(ct) + if strings.HasPrefix(mediaType, "multipart/") { + r.Body = http.MaxBytesReader(nil, r.Body, maxDocument+1<<20) + mr, err := r.MultipartReader() + if err != nil { + return nil, "", "", fmt.Errorf("bad multipart body") + } + var part *multipart.Part + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, "", "", fmt.Errorf("bad multipart body") + } + name := p.FormName() + if part == nil || name == "file" || name == "document" || name == "pdf" { + if part != nil { + part.Close() + } + part = p + if name == "file" || name == "document" || name == "pdf" { + break + } + } else { + p.Close() + } + } + if part == nil { + return nil, "", "", fmt.Errorf("multipart body has no file") + } + defer part.Close() + doc, err = io.ReadAll(io.LimitReader(part, maxDocument+1)) + if err != nil { + return nil, "", "", fmt.Errorf("read document") + } + title = stampTitle(part.FileName()) + format = part.Header.Get("Content-Type") + return doc, title, format, nil + } + doc, err = io.ReadAll(io.LimitReader(http.MaxBytesReader(nil, r.Body, maxDocument+1), maxDocument+1)) + if err != nil { + return nil, "", "", fmt.Errorf("read document") + } + title = stampTitle(firstNonEmpty(r.Header.Get("X-Filename"), r.URL.Query().Get("filename"), filenameParam(r.Header.Get("Content-Disposition")))) + format = mediaType + return doc, title, format, nil +} + +func filenameParam(v string) string { + _, params, err := mime.ParseMediaType(v) + if err != nil { + return "" + } + return params["filename"] +} + +func stampTitle(name string) string { + name = strings.ReplaceAll(name, "\\", "/") + name = filepath.Base(name) + name = strings.Map(func(r rune) rune { + if r < 32 || r == '"' { + return -1 + } + return r + }, name) + name = strings.TrimSpace(name) + if name == "" || name == "." || name == "/" { + return "Stamp" + } + return name +} + +func stampUser(user string) string { + user = strings.TrimSpace(strings.Map(func(r rune) rune { + if r < 32 { + return -1 + } + return r + }, user)) + if user == "" { + return "stamp" + } + if len(user) > 80 { + user = user[:80] + } + return user +} + +func readStampFile(path string) ([]byte, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("missing") + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, maxDocument+1)) +} + +func stampZip(rec Record, cert []byte) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + add := func(name string, b []byte) error { + if len(b) == 0 { + return nil + } + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = w.Write(b) + return err + } + origPath := rec.OriginalPDF + if origPath == "" { + origPath = filepath.Join(filepath.Dir(rec.CertificatePDF), "document.pdf") + } + orig, _ := readStampFile(origPath) + if err := add("original.pdf", orig); err != nil { + _ = zw.Close() + return nil, err + } + if err := add("certificate.pdf", cert); err != nil { + _ = zw.Close() + return nil, err + } + js, _ := readStampFile(rec.ReceiptJSON) + if err := add("receipt.json", js); err != nil { + _ = zw.Close() + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/netprint/stamp_api_test.go b/internal/netprint/stamp_api_test.go new file mode 100644 index 0000000..c646883 --- /dev/null +++ b/internal/netprint/stamp_api_test.go @@ -0,0 +1,276 @@ +package netprint + +import ( + "archive/zip" + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" +) + +func TestStampAPIReturnsCertificate(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, localStamp(), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + on := true + s.mu.Lock() + s.cfg.EmailEnabled = &on + s.cfg.EmailTo = DefaultEmailTo + s.mu.Unlock() + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: "pw"}); err != nil { + t.Fatal(err) + } + prev := sendMail + var mu sync.Mutex + var mailed mailer.Message + sendMail = func(c mailer.Config, m mailer.Message) error { + mu.Lock() + mailed = m + mu.Unlock() + return nil + } + t.Cleanup(func() { sendMail = prev }) + + req, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp"), strings.NewReader("%PDF-1.4 contract")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/pdf") + req.Header.Set("X-Filename", "Contract.pdf") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusOK || !bytes.HasPrefix(body, []byte("%PDF-cert")) { + t.Fatalf("%d %q", res.StatusCode, body) + } + if res.Header.Get("X-Verae-Receipt-Id") != "QmabStamp" || res.Header.Get("X-Verae-SHA256") == "" || !strings.Contains(res.Header.Get("X-Verae-Receipt-JSON"), "/receipt.json") { + t.Fatalf("headers %+v", res.Header) + } + page := getBody(t, ippURL(s, "/receipts")) + if !strings.Contains(page, "Contract.pdf") || !strings.Contains(page, "QmabStamp") || !strings.Contains(page, "iPhone Shortcut") || !strings.Contains(page, "data:image/png;base64,") { + t.Fatal(page) + } + guide := getBody(t, ippURL(s, "/ios-shortcut")) + if !strings.Contains(guide, "Share Sheet") || !strings.Contains(guide, "/api/stamp") || !strings.Contains(guide, "X-Verae-Receipt-Id") { + t.Fatal(guide) + } + other := httpStatus(t, ippURL(s, "/api/v1/status")) + if other != http.StatusNotFound { + t.Fatalf("control route on the LAN listener: %d", other) + } + deadline := time.Now().Add(2 * time.Second) + var subject, text string + for time.Now().Before(deadline) { + mu.Lock() + subject, text = mailed.Subject, mailed.Text + mu.Unlock() + if strings.Contains(text, "QmabStamp") { + break + } + time.Sleep(15 * time.Millisecond) + } + if !strings.Contains(subject, "Verae timestamp: Contract.pdf") || !strings.Contains(text, "QmabStamp") { + t.Fatalf("mail %q %q", subject, text) + } + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file", "Photo.jpg") + if err != nil { + t.Fatal(err) + } + _, _ = part.Write([]byte("%PDF-1.4 note")) + _ = mw.Close() + mreq, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp?format=zip"), &buf) + if err != nil { + t.Fatal(err) + } + mreq.Header.Set("Content-Type", mw.FormDataContentType()) + mres, err := http.DefaultClient.Do(mreq) + if err != nil { + t.Fatal(err) + } + zbody, _ := io.ReadAll(mres.Body) + mres.Body.Close() + if mres.StatusCode != http.StatusOK || mres.Header.Get("Content-Type") != "application/zip" || mres.Header.Get("X-Verae-Receipt-Id") != "QmabStamp" { + t.Fatalf("zip %d %s %q", mres.StatusCode, mres.Header.Get("Content-Type"), zbody) + } + zr, err := zip.NewReader(bytes.NewReader(zbody), int64(len(zbody))) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, f := range zr.File { + names[f.Name] = true + } + for _, name := range []string{"original.pdf", "certificate.pdf", "receipt.json"} { + if !names[name] { + t.Fatalf("zip names %+v", names) + } + } +} + +func TestStampAPIUsesPINAndNetwork(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, localStamp(), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + if err := saveSecrets(s.secretsFile(), Secrets{ReceiptsPIN: "2468"}); err != nil { + t.Fatal(err) + } + res, err := http.Post(ippURL(s, "/api/stamp"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + locked, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusUnauthorized || bytes.Contains(locked, []byte("%PDF")) { + t.Fatalf("%d %q", res.StatusCode, locked) + } + ok, err := http.Post(ippURL(s, "/api/stamp?access=2468"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + got, _ := io.ReadAll(ok.Body) + ok.Body.Close() + if ok.StatusCode != http.StatusOK || !bytes.HasPrefix(got, []byte("%PDF-cert")) { + t.Fatalf("access %d %q", ok.StatusCode, got) + } + req, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp"), strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/pdf") + req.Header.Set("X-Verae-Access", "2468") + hres, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + hbody, _ := io.ReadAll(hres.Body) + hres.Body.Close() + if hres.StatusCode != http.StatusOK || !bytes.HasPrefix(hbody, []byte("%PDF-cert")) { + t.Fatalf("header %d %q", hres.StatusCode, hbody) + } + + prev := currentNetworkPublic + currentNetworkPublic = func() bool { return true } + t.Cleanup(func() { currentNetworkPublic = prev }) + blocked, err := http.Post(ippURL(s, "/api/stamp?access=2468"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + bb, _ := io.ReadAll(blocked.Body) + blocked.Body.Close() + if blocked.StatusCode != http.StatusForbidden || bytes.Contains(bb, []byte("%PDF-cert")) { + t.Fatalf("public %d %q", blocked.StatusCode, bb) + } +} + +func TestVeraeAnnounceStaysOffIPPRecords(t *testing.T) { + svc := sampleService() + svc.SubtypesOnly = true + svc.StampURL = "http://192.0.2.10:631/api/stamp" + if err := assertSubtypePTRsOnly(svc.Announce(false), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + ans, extra, _, err := parsePacket(svc.VeraeAnnounce(false)) + if err != nil { + t.Fatal(err) + } + var sawPTR, sawSRV, sawTXT, sawAddr bool + for _, rr := range append(ans, extra...) { + switch rr.typ { + case dnsTypePTR: + if rr.name == "_verae._tcp.local" && rr.ptr == "verae timestamp._verae._tcp.local" { + sawPTR = true + } + if rr.name == "_ipp._tcp.local" { + t.Fatal("verae announcement included _ipp._tcp") + } + case dnsTypeSRV: + sawSRV = rr.port == 631 + case dnsTypeTXT: + if strings.Contains(strings.Join(rr.txt, "\n"), "path=/api/stamp") && strings.Contains(strings.Join(rr.txt, "\n"), "http://192.0.2.10:631/api/stamp") { + sawTXT = true + } + case dnsTypeA, dnsTypeAAAA: + sawAddr = true + } + } + if !sawPTR || !sawSRV || !sawTXT || sawAddr { + t.Fatalf("ptr %v srv %v txt %v addr %v", sawPTR, sawSRV, sawTXT, sawAddr) + } + if svc.ReplyVerae(Query(1, []string{"_verae", "_tcp", "local"}, dnsTypePTR)) == nil { + t.Fatal("did not answer _verae._tcp") + } + full := sampleService() + full.StampURL = svc.StampURL + _, extra, _, err = parsePacket(full.VeraeAnnounce(false)) + if err != nil { + t.Fatal(err) + } + var sawA bool + for _, rr := range extra { + if rr.typ == dnsTypeA { + sawA = true + } + } + if !sawA { + t.Fatal("full _verae announcement omitted the address") + } +} + +func localStamp() StampFunc { + return func(pdf, title, user string) (job.Result, error) { + dir := filepath.Dir(pdf) + cert := filepath.Join(dir, "certificate.pdf") + js := filepath.Join(dir, title+".verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil { + return job.Result{}, err + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"QmabStamp\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{ + SHA256: strings.Repeat("ab", 32), ReceiptID: "QmabStamp", + CertificatePDF: cert, OriginalPDF: pdf, ReceiptJSON: js, + }, nil + } +} + +func httpStatus(t *testing.T, url string) int { + t.Helper() + res, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + return res.StatusCode +} From 927229ab4f936041df693fa59fbe1897d8bd557b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 01:27:23 +0000 Subject: [PATCH 18/27] AirDrop stamped PDFs from the macOS menu bar. The menu-bar app, not the root daemon, opens the AirDrop share sheet for each finished job when the setting is on. The unsigned arm64 package installs the daemon and LaunchAgent, and Darwin DNSServiceRegister advertises the _universal and _print subtypes. Co-authored-by: George Lambert --- .github/workflows/installers.yml | 2 +- docs/network-printer.md | 30 +- internal/netprint/advertise_darwin.go | 6 + internal/netprint/advertise_other.go | 9 + internal/netprint/advertise_unix.go | 4 - internal/netprint/airdrop.go | 262 ++++++++++++++++ internal/netprint/airdrop_darwin.go | 317 ++++++++++++++++++++ internal/netprint/airdrop_other.go | 19 ++ internal/netprint/airdrop_test.go | 361 +++++++++++++++++++++++ internal/netprint/config.go | 45 ++- internal/netprint/control.go | 51 +++- internal/netprint/dnsservice.go | 112 ++++++- internal/netprint/dnsservice_darwin.go | 131 ++++++++ internal/netprint/events.go | 201 +++++++++++++ internal/netprint/server.go | 111 +++++-- internal/netprint/stamp_api.go | 2 +- internal/netprint/tray_darwin.go | 11 +- internal/netprint/tray_menu.go | 42 ++- platform/macos/airdrop/main.swift | 39 +++ platform/macos/pkg/build-netprint-pkg.sh | 30 +- platform/macos/pkg/build-unsigned-pkg.sh | 78 +++++ platform/macos/pkg/cmd/mkxar/main.go | 22 ++ platform/macos/pkg/mkxar/xar.go | 208 +++++++++++++ platform/macos/pkg/mkxar/xar_test.go | 52 ++++ scripts/build-netprint-installers.sh | 7 +- 25 files changed, 2070 insertions(+), 82 deletions(-) create mode 100644 internal/netprint/advertise_other.go create mode 100644 internal/netprint/airdrop.go create mode 100644 internal/netprint/airdrop_darwin.go create mode 100644 internal/netprint/airdrop_other.go create mode 100644 internal/netprint/airdrop_test.go create mode 100644 internal/netprint/dnsservice_darwin.go create mode 100644 internal/netprint/events.go create mode 100644 platform/macos/airdrop/main.swift create mode 100755 platform/macos/pkg/build-unsigned-pkg.sh create mode 100644 platform/macos/pkg/cmd/mkxar/main.go create mode 100644 platform/macos/pkg/mkxar/xar.go create mode 100644 platform/macos/pkg/mkxar/xar_test.go diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index d6a67cf..178ed55 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -14,7 +14,7 @@ jobs: with: go-version: "1.26.1" - name: Toolchain - run: sudo apt-get update && sudo apt-get install -y nsis + run: sudo apt-get update && sudo apt-get install -y nsis cpio g++ git - name: Build driver and network-printer installers run: VERSION=0.3.4 ./scripts/build-netprint-installers.sh - uses: actions/upload-artifact@v4 diff --git a/docs/network-printer.md b/docs/network-printer.md index a7a836c..6fed60a 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -6,10 +6,10 @@ The printer is off until you install the standalone package or turn on the drive | | Windows | macOS | Debian / Ubuntu | |---|---|---|---| -| Standalone installer | `VeraeNetPrint-windows-*-setup.exe` | `VeraeNetPrint-macos.pkg` | `verae-netprint_*_amd64.deb` | +| Standalone installer | `VeraeNetPrint-windows-*-setup.exe` | `VeraeNetPrint-macos-arm64-0.3.4.pkg` | `verae-netprint_*_amd64.deb` | | Driver installer option | Unchecked checkbox on the options page | Customize → “Network timestamp printer” (unselected) | debconf “Enable the Verae network timestamp printer?” (default no), or `apt install verae-netprint` | | Service | `VeraeNetPrint` | LaunchDaemon `com.verae.netprint` | `verae-netprint.service` | -| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | `verae-print tray` (menu bar) | `verae-print tray` (StatusNotifier / AppIndicator) | +| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | LaunchAgent `com.verae.netprint.tray` (`verae-print tray`, Aqua session) | `verae-print tray` (StatusNotifier / AppIndicator) | | Standalone command | `%ProgramFiles%\Verae\NetPrint\bin\verae-print.exe` | `/Library/Application Support/Verae/NetPrint/bin/verae-print` | `/usr/lib/verae-netprint/verae-print` | | Driver command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/lib/verae-print/verae-print` | | CLI on `PATH` | the driver binary when the driver is installed | `/usr/local/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | `/usr/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If `DNSServiceRegister` fails, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to `http://:/receipts` (the AirPrint admin URL). The page lists recent jobs, newest first, with the stamped PDF and the original. Each job also has a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, whether discovery is advertising, and the receipts URL. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, turn **Email stamped PDFs** on or off, open recent receipts, and open settings (printer name, forward-to printer, signing key, mail, and the receipts PIN). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -55,7 +55,29 @@ An optional checkbox (silent `/QUEUE=1`) adds a local Windows printer named **Ve On Debian and Ubuntu, `postinst` opens the same three ports with `ufw` when ufw is installed and already active. The rules are removed when the last package is removed. -On macOS, the Application Firewall is per application (System Settings → Network → Firewall). Install runs `/usr/libexec/ApplicationFirewall/socketfilterfw --add` and `--unblockapp` on the service binary when that tool is present. If other devices still cannot connect, allow `verae-print` in that pane. +On macOS, the Application Firewall is per application (System Settings → Network → Firewall). Install runs `/usr/libexec/ApplicationFirewall/socketfilterfw --add` and `--unblockapp` on the service binary when that tool is present. If other devices still cannot connect, allow `verae-print` in that pane. The first time the service advertises or accepts a LAN connection, macOS asks once for Local Network permission for `verae-print`. Allow it. The menu-bar app may ask as well; allow that too. + +## macOS package + +The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. It installs: + +- `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 binary) +- LaunchDaemon `com.verae.netprint` (root, the print server) +- LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `verae-print tray` as the logged-in user + +The package from CI is not signed and not notarized. macOS blocks a normal double-click until the package is signed with a Developer ID Installer certificate and notarized. To install this unsigned package, right-click it in Finder and choose Open, then Open again. Or, in Terminal: + +```bash +sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / +``` + +`postinstall` runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there. + +## AirDrop on the Mac + +Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off until you turn it on. When a job completes, the daemon records it and tells the menu-bar app on `GET http://127.0.0.1:8630/api/v1/events` (an event stream on the loopback control API). The agent downloads the certificate and the receipt JSON through that API, because the daemon's files are mode `0600`. It then opens the AirDrop share sheet with both files, using `NSSharingService(named: .sendViaAirDrop)`. A notification shows the receipt ID and an **AirDrop again** action. The same action is on the menu. The root daemon never opens the sheet. + +`platform/macos/airdrop/main.swift` is that share sheet as a small helper (`verae-airdrop`). A Mac build with `swiftc` installs it next to `verae-print` and the agent uses it. The Linux arm64 build has no Swift compiler, so the same call is made from the menu-bar process. macOS also asks for notification permission the first time the agent posts **AirDrop again**. The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer. diff --git a/internal/netprint/advertise_darwin.go b/internal/netprint/advertise_darwin.go index 416926a..3654049 100644 --- a/internal/netprint/advertise_darwin.go +++ b/internal/netprint/advertise_darwin.go @@ -2,5 +2,11 @@ package netprint +import "context" + // SO_REUSEPORT lets this responder share UDP 5353 with mDNSResponder. const soReusePort = 0x200 + +func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseForOS("darwin", ctx, current, newDarwinDNS(), sink) +} diff --git a/internal/netprint/advertise_other.go b/internal/netprint/advertise_other.go new file mode 100644 index 0000000..e3eee2d --- /dev/null +++ b/internal/netprint/advertise_other.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package netprint + +import "context" + +func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseUDP(ctx, current, sink) +} diff --git a/internal/netprint/advertise_unix.go b/internal/netprint/advertise_unix.go index 5257b8d..6ae8ab8 100644 --- a/internal/netprint/advertise_unix.go +++ b/internal/netprint/advertise_unix.go @@ -36,7 +36,3 @@ func listenMDNS() (mdnsConn, error) { _ = conn.SetReadBuffer(1 << 20) return conn, nil } - -func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { - return advertiseUDP(ctx, current, sink) -} diff --git a/internal/netprint/airdrop.go b/internal/netprint/airdrop.go new file mode 100644 index 0000000..838ff3a --- /dev/null +++ b/internal/netprint/airdrop.go @@ -0,0 +1,262 @@ +package netprint + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// airDropAgainTitle is the notification action and the menu action. +const airDropAgainTitle = "AirDrop again" + +// nsAirDropService is NSSharingService.Name.sendViaAirDrop. +const nsAirDropService = "com.apple.share.AirDrop.send" + +// AirDropOffer is a stamped certificate and its receipt JSON, saved where +// the logged-in user can read them. +type AirDropOffer struct { + ID int + Seq int64 + ReceiptID string + PDF string + JSON string +} + +var ( + airDropMu sync.Mutex + airDropLast AirDropOffer + airDropHas bool + shareAirDropOffer = func(AirDropOffer) error { + return fmt.Errorf("AirDrop runs in the macOS menu-bar app") + } +) + +func rememberAirDrop(o AirDropOffer) { + airDropMu.Lock() + airDropLast = o + airDropHas = o.PDF != "" + airDropMu.Unlock() +} + +func currentAirDrop() (AirDropOffer, bool) { + airDropMu.Lock() + defer airDropMu.Unlock() + if !airDropHas { + return AirDropOffer{}, false + } + return airDropLast, true +} + +func clearAirDrop() { + airDropMu.Lock() + airDropLast = AirDropOffer{} + airDropHas = false + airDropMu.Unlock() +} + +func airDropAgain() error { + o, ok := currentAirDrop() + if !ok { + return fmt.Errorf("no stamped PDF to AirDrop") + } + return shareAirDropOffer(o) +} + +// withAirDropAgain adds the menu action for the last certificate the agent saved. +func withAirDropAgain(items []MenuEntry) []MenuEntry { + o, ok := currentAirDrop() + if !ok || o.ReceiptID == "" { + return items + } + extra := MenuEntry{ + ID: idAirDropAgain, Label: airDropAgainTitle + " · " + o.ReceiptID, + Enabled: true, Kind: "airdrop-again", + } + out := make([]MenuEntry, 0, len(items)+1) + inserted := false + for _, e := range items { + if !inserted && e.Kind == "quit" { + out = append(out, extra) + inserted = true + } + out = append(out, e) + } + if !inserted { + out = append(out, extra) + } + return out +} + +func airDropDir() string { + d, err := os.UserCacheDir() + if err != nil || d == "" { + d = os.TempDir() + } + return filepath.Join(d, "Verae", "AirDrop") +} + +// materializeAirDrop copies the certificate and receipt JSON through the +// loopback control API into dir. The daemon's files are mode 0600 and owned +// by root; the menu-bar user reads these copies instead. +func materializeAirDrop(ev JobEvent, dir string) (AirDropOffer, error) { + if ev.CertificateURL == "" { + return AirDropOffer{}, fmt.Errorf("job %d has no certificate", ev.ID) + } + dest := filepath.Join(dir, strconv.Itoa(ev.ID)) + if err := os.MkdirAll(dest, 0o700); err != nil { + return AirDropOffer{}, err + } + pdf := filepath.Join(dest, "certificate.pdf") + if err := downloadTo(ev.CertificateURL, pdf); err != nil { + return AirDropOffer{}, err + } + js := "" + if ev.ReceiptJSONURL != "" { + js = filepath.Join(dest, "receipt.json") + if err := downloadTo(ev.ReceiptJSONURL, js); err != nil { + return AirDropOffer{}, err + } + } + return AirDropOffer{ID: ev.ID, Seq: ev.Seq, ReceiptID: ev.ReceiptID, PDF: pdf, JSON: js}, nil +} + +func downloadTo(url, dest string) error { + res, err := airDropClient.Get(url) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: HTTP %d", url, res.StatusCode) + } + f, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + _, copyErr := io.Copy(f, io.LimitReader(res.Body, maxDocument)) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +// airDropClient has no timeout: the event stream stays open. +var airDropClient = &http.Client{} + +// presentAirDrop saves the files, asks the GUI session to share them, shows +// the notification, and acknowledges the event so a later launch does not +// open the sheet again. +func presentAirDrop(ev JobEvent, base, dir string, share func(AirDropOffer) error, notify func(AirDropOffer)) error { + if !ev.AirDrop { + return nil + } + offer, err := materializeAirDrop(ev, dir) + if err != nil { + return err + } + rememberAirDrop(offer) + if err := share(offer); err != nil { + return err + } + if notify != nil { + notify(offer) + } + if err := ackAirDrop(base, offer.ID); err != nil { + log.Printf("netprint: airdrop ack job %d: %v", offer.ID, err) + } + return nil +} + +func ackAirDrop(base string, id int) error { + body := fmt.Sprintf(`{"id":%d}`, id) + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(base, "/")+"/api/v1/airdrop/ack", strings.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := airDropClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + io.Copy(io.Discard, res.Body) + if res.StatusCode >= 300 { + return fmt.Errorf("ack HTTP %d", res.StatusCode) + } + return nil +} + +// watchAirDrop reads the loopback event stream and presents each opted-in job. +func watchAirDrop(ctx context.Context, base, dir string, share func(AirDropOffer) error, notify func(AirDropOffer)) { + after := int64(0) + seen := map[int64]bool{} + for ctx.Err() == nil { + url := strings.TrimRight(base, "/") + "/api/v1/events?after=" + strconv.FormatInt(after, 10) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return + } + res, err := airDropClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + continue + } + sc := bufio.NewScanner(res.Body) + sc.Buffer(make([]byte, 64*1024), 1<<20) + var name string + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "event:"): + name = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:") && name == "job": + var ev JobEvent + if json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &ev) != nil { + continue + } + if ev.Seq == 0 || seen[ev.Seq] { + continue + } + if ev.AirDrop { + if err := presentAirDrop(ev, base, dir, share, notify); err != nil { + res.Body.Close() + break + } + } + seen[ev.Seq] = true + if ev.Seq > after { + after = ev.Seq + } + case line == "": + name = "" + } + } + res.Body.Close() + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} diff --git a/internal/netprint/airdrop_darwin.go b/internal/netprint/airdrop_darwin.go new file mode 100644 index 0000000..8473fb6 --- /dev/null +++ b/internal/netprint/airdrop_darwin.go @@ -0,0 +1,317 @@ +//go:build darwin + +package netprint + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "unsafe" + + "github.com/ebitengine/purego" + "github.com/ebitengine/purego/objc" +) + +const ( + airDropActionID = "airdrop-again" + airDropCategoryID = "verae.airdrop" + airDropDefaultTap = "com.apple.UNNotificationDefaultActionIdentifier" + unAlert = 4 // UNAuthorizationOptionAlert + unActionForeground = 4 // UNNotificationActionOptionForeground +) + +func init() { + shareAirDropOffer = shareAirDropPlatform +} + +func startAirDropWatch(ctx context.Context) { + prepareAirDropNotifications() + go watchAirDrop(ctx, "http://127.0.0.1:8630", airDropDir(), shareAirDropPlatform, notifyAirDrop) +} + +func airDropMethods() []objc.MethodDef { + return []objc.MethodDef{{ + Cmd: objc.RegisterName("userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"), + Fn: didReceiveAirDrop, + }} +} + +func shareAirDropPlatform(o AirDropOffer) error { + if helper := airDropHelperPath(); helper != "" { + args := []string{o.PDF} + if o.JSON != "" { + args = append(args, o.JSON) + } + cmd := exec.Command(helper, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + log.Printf("netprint: verae-airdrop: %v", err) + } else { + return nil + } + } + return shareAirDropObjC(o) +} + +func airDropHelperPath() string { + exe, err := os.Executable() + if err != nil { + return "" + } + p := filepath.Join(filepath.Dir(exe), "verae-airdrop") + st, err := os.Stat(p) + if err != nil || st.IsDir() { + return "" + } + return p +} + +func shareAirDropObjC(o AirDropOffer) error { + var err error + onMain(func() { + err = performAirDrop(o) + }) + return err +} + +func performAirDrop(o AirDropOffer) error { + paths := []string{o.PDF} + if o.JSON != "" { + paths = append(paths, o.JSON) + } + // com.apple.share.AirDrop.send is NSSharingService.Name.sendViaAirDrop. + svc := cls("NSSharingService").Send(objc.RegisterName("sharingServiceNamed:"), nsString(nsAirDropService)) + if svc == 0 { + return fmt.Errorf("AirDrop sharing service is unavailable") + } + items := cls("NSMutableArray").Send(objc.RegisterName("array")) + for _, p := range paths { + if p == "" { + continue + } + url := cls("NSURL").Send(objc.RegisterName("fileURLWithPath:"), nsString(p)) + items.Send(objc.RegisterName("addObject:"), url) + } + app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) + app.Send(objc.RegisterName("activateIgnoringOtherApps:"), byte(1)) + svc.Send(objc.RegisterName("performWithItems:"), items) + runtime.KeepAlive(items) + log.Printf("netprint: AirDrop sheet receipt=%s", o.ReceiptID) + return nil +} + +func notifyAirDrop(o AirDropOffer) { + onMain(func() { postAirDropNote(o) }) +} + +func postAirDropNote(o AirDropOffer) { + if !notesReady { + return + } + center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) + if center == 0 { + return + } + content := cls("UNMutableNotificationContent").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + title := "Verae Timestamp" + body := "Receipt " + o.ReceiptID + if o.ReceiptID == "" { + body = fmt.Sprintf("Receipt job %d", o.ID) + } + content.Send(objc.RegisterName("setTitle:"), nsString(title)) + content.Send(objc.RegisterName("setBody:"), nsString(body)) + content.Send(objc.RegisterName("setCategoryIdentifier:"), nsString(airDropCategoryID)) + ident := o.ReceiptID + if ident == "" { + ident = fmt.Sprintf("job-%d", o.ID) + } + req := cls("UNNotificationRequest").Send( + objc.RegisterName("requestWithIdentifier:content:trigger:"), + nsString(ident), content, objc.ID(0), + ) + center.Send(objc.RegisterName("addNotificationRequest:withCompletionHandler:"), req, airDropBlock) + runtime.KeepAlive(content) + runtime.KeepAlive(req) +} + +var ( + notesReady bool + airDropBlock unsafe.Pointer + blockDesc blockDescriptor + blockLit blockLiteral + mainMu sync.Mutex + mainFn func() +) + +type blockLiteral struct { + isa uintptr + flags int32 + reserved int32 + invoke uintptr + descriptor uintptr +} + +type blockDescriptor struct { + reserved uintptr + size uintptr +} + +func prepareAirDropNotifications() { + if _, err := purego.Dlopen("/System/Library/Frameworks/UserNotifications.framework/UserNotifications", 1); err != nil { + log.Printf("netprint: UserNotifications: %v", err) + return + } + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", 1) + if err != nil { + log.Printf("netprint: libSystem: %v", err) + return + } + isa, err := purego.Dlsym(lib, "_NSConcreteGlobalBlock") + if err != nil || isa == 0 { + log.Printf("netprint: AirDrop notification block: %v", err) + return + } + blockDesc = blockDescriptor{size: unsafe.Sizeof(blockLiteral{})} + blockLit = blockLiteral{ + isa: isa, + flags: 1 << 28, // BLOCK_IS_GLOBAL + invoke: purego.NewCallback(mainBlockInvoke), + descriptor: uintptr(unsafe.Pointer(&blockDesc)), + } + airDropBlock = unsafe.Pointer(&blockLit) + onMain(func() { registerAirDropCategory() }) + notesReady = true +} + +func registerAirDropCategory() { + center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) + if center == 0 || trayTarget == 0 { + return + } + center.Send(objc.RegisterName("setDelegate:"), trayTarget) + center.Send(objc.RegisterName("requestAuthorizationWithOptions:completionHandler:"), uintptr(unAlert), airDropBlock) + action := cls("UNNotificationAction").Send( + objc.RegisterName("actionWithIdentifier:title:options:"), + nsString(airDropActionID), nsString(airDropAgainTitle), uintptr(unActionForeground), + ) + actions := cls("NSArray").Send(objc.RegisterName("arrayWithObject:"), action) + empty := cls("NSArray").Send(objc.RegisterName("array")) + cat := cls("UNNotificationCategory").Send( + objc.RegisterName("categoryWithIdentifier:actions:intentIdentifiers:options:"), + nsString(airDropCategoryID), actions, empty, uintptr(0), + ) + set := cls("NSSet").Send(objc.RegisterName("setWithObject:"), cat) + center.Send(objc.RegisterName("setNotificationCategories:"), set) + runtime.KeepAlive(action) + runtime.KeepAlive(cat) +} + +func didReceiveAirDrop(self, _cmd, center, response, completion objc.ID) { + action := "" + if response != 0 { + action = objcUTF8(objc.Send[objc.ID](response, objc.RegisterName("actionIdentifier"))) + } + if action == airDropActionID || action == airDropDefaultTap || action == "" { + if err := airDropAgain(); err != nil { + log.Printf("netprint: AirDrop again: %v", err) + } + } + callVoidBlock(completion) +} + +func callVoidBlock(b objc.ID) { + if b == 0 { + return + } + invoke := *(*uintptr)(unsafe.Pointer(uintptr(b) + 16)) + if invoke == 0 { + return + } + purego.SyscallN(invoke, uintptr(b)) +} + +var onMainGate sync.Mutex + +func onMain(fn func()) { + if fn == nil { + return + } + if objc.Send[byte](cls("NSThread"), objc.RegisterName("isMainThread")) != 0 { + fn() + return + } + onMainGate.Lock() + defer onMainGate.Unlock() + if mainBlockPtr() == nil { + fn() + return + } + done := make(chan struct{}) + mainMu.Lock() + mainFn = func() { + fn() + close(done) + } + mainMu.Unlock() + queue := cls("NSOperationQueue").Send(objc.RegisterName("mainQueue")) + queue.Send(objc.RegisterName("addOperationWithBlock:"), airDropBlock) + <-done +} + +func mainBlockPtr() unsafe.Pointer { + if airDropBlock != nil { + return airDropBlock + } + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", 1) + if err != nil { + return nil + } + isa, err := purego.Dlsym(lib, "_NSConcreteGlobalBlock") + if err != nil { + return nil + } + blockDesc = blockDescriptor{size: unsafe.Sizeof(blockLiteral{})} + blockLit = blockLiteral{ + isa: isa, + flags: 1 << 28, + invoke: purego.NewCallback(mainBlockInvoke), + descriptor: uintptr(unsafe.Pointer(&blockDesc)), + } + airDropBlock = unsafe.Pointer(&blockLit) + return airDropBlock +} + +func mainBlockInvoke(uintptr) { + mainMu.Lock() + fn := mainFn + mainFn = nil + mainMu.Unlock() + if fn != nil { + fn() + } +} + +func objcUTF8(id objc.ID) string { + if id == 0 { + return "" + } + p := objc.Send[unsafe.Pointer](id, objc.RegisterName("UTF8String")) + if p == nil { + return "" + } + var b []byte + for i := uintptr(0); ; i++ { + c := *(*byte)(unsafe.Add(p, i)) + if c == 0 || i > 1<<20 { + break + } + b = append(b, c) + } + return string(b) +} diff --git a/internal/netprint/airdrop_other.go b/internal/netprint/airdrop_other.go new file mode 100644 index 0000000..b0276c9 --- /dev/null +++ b/internal/netprint/airdrop_other.go @@ -0,0 +1,19 @@ +//go:build !darwin + +package netprint + +import "context" + +func startAirDropWatch(context.Context) {} + +func shareAirDropPlatform(AirDropOffer) error { + return errAirDropMac +} + +var errAirDropMac = errAirDropUnavailable() + +type airDropUnavailable struct{} + +func (airDropUnavailable) Error() string { return "AirDrop runs in the macOS menu-bar app" } + +func errAirDropUnavailable() error { return airDropUnavailable{} } diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go new file mode 100644 index 0000000..feeb1de --- /dev/null +++ b/internal/netprint/airdrop_test.go @@ -0,0 +1,361 @@ +package netprint + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" +) + +func TestAirDropDefaultsOffAndMenu(t *testing.T) { + if Defaults().AirDropOn() { + t.Fatal("AirDrop is opt-in") + } + if !strings.Contains(string(ExampleJSON("/tmp")), `"airDropEnabled": false`) { + t.Fatal("sample config should show the switch off") + } + st := Status{Running: true, SupportsAirDrop: true, AirDropEnabled: true, Advertise: true} + var sawDrop, sawAdv bool + for _, e := range BuildMenu(st) { + if e.Kind == "airdrop" { + sawDrop = e.Checked && e.Enabled && e.Label == "AirDrop stamped PDF after each job" + } + if e.Kind == "advertise" { + sawAdv = e.Checked && e.Label == "Advertise on the network" + } + } + if !sawDrop || !sawAdv { + t.Fatalf("drop %v advertise %v", sawDrop, sawAdv) + } + plain := BuildMenu(Status{Running: true}) + for _, e := range plain { + if e.Kind == "airdrop" || e.Kind == "advertise" { + t.Fatal("AirDrop controls are macOS only") + } + } + line := discoveryLine(Status{Running: true, MDNSAdvertising: true, MDNSMode: "dns-sd", MDNSDetail: "DNSServiceRegister Verae Timestamp._ipp._tcp.local regtype _ipp._tcp,_universal,_print port 631; _verae._tcp"}) + if line != "Discovery — advertised (Bonjour _universal and _print)" { + t.Fatal(line) + } +} + +func TestAirDropAgainUsesSavedFiles(t *testing.T) { + clearAirDrop() + t.Cleanup(clearAirDrop) + prev := shareAirDropOffer + var got AirDropOffer + shareAirDropOffer = func(o AirDropOffer) error { + got = o + return nil + } + t.Cleanup(func() { shareAirDropOffer = prev }) + rememberAirDrop(AirDropOffer{ReceiptID: "QmReceipt", PDF: "certificate.pdf", JSON: "receipt.json"}) + items := withAirDropAgain(BuildMenu(Status{Running: true})) + var item MenuEntry + for _, e := range items { + if e.Kind == "airdrop-again" { + item = e + } + } + if item.Label != airDropAgainTitle+" · QmReceipt" { + t.Fatal(item.Label) + } + if err := Dispatch(Status{Running: true}, item); err != nil { + t.Fatal(err) + } + if got.PDF != "certificate.pdf" || got.JSON != "receipt.json" || got.ReceiptID != "QmReceipt" { + t.Fatalf("%+v", got) + } +} + +func TestStampJobTellsMenuBarNotTheDaemon(t *testing.T) { + cfg, path := testConfig(t) + on := true + cfg.AirDropEnabled = &on + s, err := Start(cfg, path, func(pdf, title, user string) (job.Result, error) { + dir := filepath.Dir(pdf) + cert := filepath.Join(dir, "certificate.pdf") + js := filepath.Join(dir, "scan.verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil { + return job.Result{}, err + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"QmAir\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{SHA256: strings.Repeat("ab", 32), ReceiptID: "QmAir", CertificatePDF: cert, ReceiptJSON: js, OriginalPDF: pdf}, nil + }, &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + + prev := shareAirDropOffer + shareAirDropOffer = func(AirDropOffer) error { + t.Error("daemon called AirDrop") + return nil + } + defer func() { shareAirDropOffer = prev }() + + var mu sync.Mutex + var got AirDropOffer + var notes int + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + dir := t.TempDir() + go watchAirDrop(ctx, s.ControlURL(), dir, func(o AirDropOffer) error { + mu.Lock() + got = o + mu.Unlock() + return nil + }, func(AirDropOffer) { mu.Lock(); notes++; mu.Unlock() }) + + id, err := s.store.AllocateID() + if err != nil { + t.Fatal(err) + } + ProcessJob(s.store, s.snapshot(), nil, id, "application/pdf", "Note", "stamp", []byte("%PDF-1.4 note"), s.stamp, nil, s.afterJob) + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + pdf := got.PDF + mu.Unlock() + if pdf != "" { + break + } + time.Sleep(20 * time.Millisecond) + } + mu.Lock() + offer := got + n := notes + mu.Unlock() + if offer.ReceiptID != "QmAir" || n != 1 { + t.Fatalf("offer %+v notes %d", offer, n) + } + b, err := os.ReadFile(offer.PDF) + if err != nil || string(b) != "%PDF-cert" { + t.Fatalf("pdf %q %v", b, err) + } + jb, err := os.ReadFile(offer.JSON) + if err != nil || !strings.Contains(string(jb), "QmAir") { + t.Fatalf("json %q %v", jb, err) + } + if err := Dispatch(Status{Running: true, AirDropEnabled: true, ControlURL: s.ControlURL()}, MenuEntry{Kind: "airdrop", ID: idAirDrop}); err != nil { + t.Fatal(err) + } + st, err := NewClient(cfg.ControlPort).Status() + if err != nil || st.AirDropEnabled { + t.Fatalf("toggle %+v %v", st.AirDropEnabled, err) + } + + acked := false + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + body := readEvents(t, stringsTrimRight(s.ControlURL())+"/api/v1/events?after=0") + if strings.Contains(body, ": ok") && !strings.Contains(body, "QmAir") { + acked = true + break + } + time.Sleep(30 * time.Millisecond) + } + if !acked { + t.Fatal("acked job was still replayed") + } +} + +func readEvents(t *testing.T, url string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return "" + } + defer res.Body.Close() + b, _ := io.ReadAll(res.Body) + return string(b) +} + +func stringsTrimRight(s string) string { + return strings.TrimRight(s, "/") +} + +func TestDarwinDNSRegistersAirPrintSubtypes(t *testing.T) { + if dnsPort(631) != 0x7702 { + t.Fatalf("port %x", dnsPort(631)) + } + svc := sampleService() + svc.StampURL = "http://192.168.1.184:631/api/stamp" + insts := serviceToDarwinDNS(svc) + if len(insts) != 2 { + t.Fatal(len(insts)) + } + ipp, verae := insts[0], insts[1] + if ipp.RegType != "_ipp._tcp,_universal,_print" || ipp.Name != "Verae Timestamp" || ipp.InstanceName != "Verae Timestamp._ipp._tcp.local" { + t.Fatalf("ipp %+v", ipp) + } + if strings.Contains(ipp.InstanceName, "_sub") || strings.Contains(ipp.RegType, "_sub") || ipp.Host != "" { + t.Fatalf("subtype target or host %+v", ipp) + } + if verae.RegType != "_verae._tcp" || verae.InstanceName != "Verae Timestamp._verae._tcp.local" { + t.Fatalf("verae %+v", verae) + } + if !strings.Contains(strings.Join(verae.TXT, "\n"), "path=/api/stamp") || !strings.Contains(strings.Join(verae.TXT, "\n"), "url=http://192.168.1.184:631/api/stamp") { + t.Fatal(verae.TXT) + } + + pkt := &fakePkt{} + prev := listenMDNSFn + called := 0 + listenMDNSFn = func() (mdnsConn, error) { + called++ + return pkt, nil + } + t.Cleanup(func() { listenMDNSFn = prev }) + api := &fakeDNS{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + var mu sync.Mutex + var report mdnsReport + go func() { + done <- advertiseForOS("darwin", ctx, func() Service { return svc }, api, func(r mdnsReport) { + mu.Lock() + report = r + mu.Unlock() + }) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && api.count() < 2 { + time.Sleep(10 * time.Millisecond) + } + if api.count() != 2 || called != 0 { + t.Fatalf("registered %d udp calls %d names %v", api.count(), called, api.names()) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } + mu.Lock() + detail := report.Detail + mu.Unlock() + if report.Mode != "dns-sd" || !strings.Contains(detail, "_ipp._tcp,_universal,_print") || !strings.Contains(detail, "_verae._tcp") || strings.Contains(detail, "PTR _ipp._tcp") { + t.Fatalf("%+v", report) + } + if len(api.deregistered) != 2 { + t.Fatalf("deregistered %d", len(api.deregistered)) + } +} + +func TestDarwinDNSFailureUsesFullUDP(t *testing.T) { + pkt := &fakePkt{} + prev := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { return pkt, nil } + t.Cleanup(func() { listenMDNSFn = prev }) + api := &fakeDNS{err: errors.New("dns_sd unavailable")} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- advertiseForOS("darwin", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && pkt.count() < 1 { + time.Sleep(10 * time.Millisecond) + } + if pkt.count() < 1 { + t.Fatal("no UDP announcement") + } + if err := assertPrimaryPTRs(pkt.packet(0), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestMacAirDropAndPackageSources(t *testing.T) { + checks := []struct { + file string + need []string + }{ + {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter"}}, + {"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, + {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint"}}, + {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons"}}, + {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed"}}, + {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, + {"advertise_darwin.go", []string{`advertiseForOS("darwin"`}}, + } + for _, c := range checks { + b, err := os.ReadFile(c.file) + if err != nil { + t.Fatal(err) + } + text := string(b) + for _, needle := range c.need { + if !strings.Contains(text, needle) { + t.Fatalf("%s missing %s", c.file, needle) + } + } + } + // The daemon publishes an event. It does not call the share sheet. + b, err := os.ReadFile("events.go") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "NSSharingService") || strings.Contains(string(b), "shareAirDrop") { + t.Fatal("job events must stay in the daemon") + } +} + +func TestAirDropSettingRoundTrip(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + c := NewClient(cfg.ControlPort) + if err := c.SetAirDropEnabled(true); err != nil { + t.Fatal(err) + } + st, err := c.Status() + if err != nil || !st.AirDropEnabled || st.Name != "Verae Timestamp" { + t.Fatalf("%+v %v", st, err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(saved), `"airDropEnabled": true`) { + t.Fatal(string(saved)) + } + var parsed Config + if err := json.Unmarshal(saved, &parsed); err != nil { + t.Fatal(err) + } + if !parsed.AirDropOn() || parsed.Name != "Verae Timestamp" { + t.Fatalf("%+v", parsed) + } +} diff --git a/internal/netprint/config.go b/internal/netprint/config.go index b360824..2245ba2 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -62,6 +62,10 @@ type Config struct { SMTPPort int `json:"smtpPort,omitempty"` SMTPTLS string `json:"smtpTLS,omitempty"` SMTPUser string `json:"smtpUser,omitempty"` + // AirDropEnabled asks the macOS menu-bar app to open the AirDrop share + // sheet after each completed job. Nil and false do not. The root daemon + // never presents the sheet; it only tells the user's agent. + AirDropEnabled *bool `json:"airDropEnabled,omitempty"` } const ( @@ -83,6 +87,9 @@ func (c Config) MDNS() bool { return c.Advertise == nil || *c.Advertise } // EmailOn reports whether completed jobs should be mailed. func (c Config) EmailOn() bool { return c.EmailEnabled != nil && *c.EmailEnabled } +// AirDropOn reports whether the menu-bar app should AirDrop each completed job. +func (c Config) AirDropOn() bool { return c.AirDropEnabled != nil && *c.AirDropEnabled } + // MailRecipients splits EmailTo. An empty setting uses DefaultEmailTo. func (c Config) MailRecipients() []string { raw := strings.TrimSpace(c.EmailTo) @@ -113,20 +120,22 @@ func Defaults() Config { keep := true adv := true emailOff := false + airDropOff := false return Config{ - Name: "Verae Timestamp", - Port: 631, - FallbackPort: DefaultFallbackPort, - ControlPort: DefaultControlPort, - DataDir: defaultDataDir(), - KeepPDF: &keep, - Advertise: &adv, - EmailEnabled: &emailOff, - EmailTo: DefaultEmailTo, - SMTPHost: DefaultSMTPHost, - SMTPPort: 587, - SMTPTLS: "starttls", - SMTPUser: DefaultEmailTo, + Name: "Verae Timestamp", + Port: 631, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, + DataDir: defaultDataDir(), + KeepPDF: &keep, + Advertise: &adv, + EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } } @@ -276,6 +285,10 @@ func mergeConfig(dst *Config, src Config) { if strings.TrimSpace(src.SMTPUser) != "" { dst.SMTPUser = strings.TrimSpace(src.SMTPUser) } + if src.AirDropEnabled != nil { + v := *src.AirDropEnabled + dst.AirDropEnabled = &v + } } func (c *Config) normalize() error { @@ -309,6 +322,10 @@ func (c *Config) normalize() error { f := false c.EmailEnabled = &f } + if c.AirDropEnabled == nil { + f := false + c.AirDropEnabled = &f + } if strings.TrimSpace(c.EmailTo) == "" { c.EmailTo = DefaultEmailTo } @@ -338,6 +355,7 @@ func ExampleJSON(dataDir string) []byte { adv := true public := false emailOff := false + airDropOff := false c := Config{ Name: "Verae Timestamp", Port: 631, @@ -349,6 +367,7 @@ func ExampleJSON(dataDir string) []byte { Advertise: &adv, AllowPublic: &public, EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, EmailTo: DefaultEmailTo, SMTPHost: DefaultSMTPHost, SMTPPort: 587, diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 17a0076..180d8e0 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/url" + "runtime" "strings" "time" @@ -46,6 +47,9 @@ type Status struct { SMTPUser string `json:"smtpUser"` SMTPPasswordSet bool `json:"smtpPasswordSet"` ReceiptsPINSet bool `json:"receiptsPinSet"` + Advertise bool `json:"advertise"` + AirDropEnabled bool `json:"airDropEnabled"` + SupportsAirDrop bool `json:"supportsAirDrop"` } // ServiceControl is how the service asks the OS to start, stop, or launch at login. @@ -127,6 +131,16 @@ func (c *Client) SetEmailEnabled(on bool) error { return c.put("/api/v1/settings", map[string]bool{"emailEnabled": on}, nil) } +// SetAdvertise turns Bonjour advertising on or off without restarting IPP. +func (c *Client) SetAdvertise(on bool) error { + return c.put("/api/v1/settings", map[string]bool{"advertise": on}, nil) +} + +// SetAirDropEnabled turns the macOS AirDrop-after-job option on or off. +func (c *Client) SetAirDropEnabled(on bool) error { + return c.put("/api/v1/settings", map[string]bool{"airDropEnabled": on}, nil) +} + // Auth asks the service to store an xVerae- signing key. func (c *Client) Auth(apiKey string) error { return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil) @@ -230,6 +244,9 @@ func (s *Server) status() Status { SMTPUser: cfg.SMTPUser, SMTPPasswordSet: passwordSet, ReceiptsPINSet: pinSet, + Advertise: cfg.MDNS(), + AirDropEnabled: cfg.AirDropOn(), + SupportsAirDrop: runtime.GOOS == "darwin", } } @@ -361,6 +378,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { ClearPassword *bool `json:"clearPassword"` ReceiptsPIN *string `json:"receiptsPin"` ClearPIN *bool `json:"clearPin"` + Advertise *bool `json:"advertise"` + AirDropEnabled *bool `json:"airDropEnabled"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { http.Error(w, "bad settings", http.StatusBadRequest) @@ -386,6 +405,14 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { v := *body.EmailEnabled cfg.EmailEnabled = &v } + if body.Advertise != nil { + v := *body.Advertise + cfg.Advertise = &v + } + if body.AirDropEnabled != nil { + v := *body.AirDropEnabled + cfg.AirDropEnabled = &v + } if body.EmailTo != nil { cfg.EmailTo = strings.TrimSpace(*body.EmailTo) } @@ -426,7 +453,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { applyFirewall(*body.AllowPublic) log.Printf("netprint: firewall public networks=%v", *body.AllowPublic) } - log.Printf("netprint: settings saved name=%q email=%v", cfg.Name, cfg.EmailOn()) + log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) + s.syncAdvertiser() writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"}) } @@ -507,6 +535,12 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { cfg.AllowPublic = &pub emailOn := r.FormValue("emailEnabled") == "1" cfg.EmailEnabled = &emailOn + if runtime.GOOS == "darwin" { + adv := r.FormValue("advertise") == "1" + cfg.Advertise = &adv + air := r.FormValue("airDropEnabled") == "1" + cfg.AirDropEnabled = &air + } cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo")) cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost")) if smtpPort > 0 && smtpPort < 65536 { @@ -549,7 +583,8 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { return } } - log.Printf("netprint: settings saved name=%q email=%v", cfg.Name, cfg.EmailOn()) + log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) + s.syncAdvertiser() http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) return } @@ -564,6 +599,15 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(sec.ReceiptsPIN) != "" { pinNote = "A PIN is saved. Leave the field blank to keep it." } + mac := "" + if runtime.GOOS == "darwin" { + mac = fmt.Sprintf(`

Mac

+ +

mDNSResponder registers _ipp._tcp with the AirPrint _universal and IPP Everywhere _print subtypes. macOS asks once for Local Network permission for verae-print. Allow it.

+ +

The menu-bar app, running in your login session, opens the AirDrop share sheet with the stamped certificate and the receipt JSON. The root daemon does not.

`, + checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn())) + } var b strings.Builder b.WriteString("Verae network printer") b.WriteString("") @@ -595,12 +639,13 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {

%s

+%s

Receipts on this computer

`, htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port, checkedAttr(cfg.PublicNetworks()), checkedAttr(cfg.EmailOn()), htmlEscape(cfg.EmailTo), htmlEscape(cfg.SMTPHost), cfg.SMTPPort, selectedAttr(cfg.SMTPTLS != "tls"), selectedAttr(cfg.SMTPTLS == "tls"), htmlEscape(cfg.SMTPUser), - htmlEscape(pwNote), htmlEscape(pinNote)) + htmlEscape(pwNote), htmlEscape(pinNote), mac) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(b.String())) } diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 62966f8..3a7e959 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -9,9 +9,17 @@ import ( "time" ) -// dnsSDInstance is one DNS-SD registration (primary service or a subtype). +// dnsSDInstance is one DNS-SD registration. +// On Windows, InstanceName is the full "._ipp._tcp.local" and RegType +// is empty: DnsServiceRegister cannot express a subtype. +// On macOS, Name is the instance label and RegType is the dns_sd service type, +// including subtypes ("_ipp._tcp,_universal,_print"). mDNSResponder publishes +// the subtype PTRs aimed at Name._ipp._tcp.local. Host is empty so +// mDNSResponder fills in this Mac's own hostname and addresses. type dnsSDInstance struct { InstanceName string + Name string + RegType string Host string Port int IPv4 []net.IP @@ -45,6 +53,39 @@ func serviceToDNS(s Service) []dnsSDInstance { }} } +// serviceToDarwinDNS is the registration set for mDNSResponder. +// One IPP registration carries both AirPrint subtypes. A second registration +// advertises the stamp API. Neither name contains "._sub.", which would make +// the PTR target the subtype name instead of the primary instance. +func serviceToDarwinDNS(s Service) []dnsSDInstance { + name := sanitizeLabel(s.Instance) + return []dnsSDInstance{ + { + InstanceName: name + "._ipp._tcp.local", + Name: name, + RegType: "_ipp._tcp,_universal,_print", + Port: s.Port, + TXT: append([]string(nil), s.TXT...), + }, + { + InstanceName: name + "._verae._tcp.local", + Name: name, + RegType: "_verae._tcp", + Port: s.Port, + TXT: s.veraeTXT(), + }, + } +} + +// dnsPort is the port in network byte order, which DNSServiceRegister requires. +func dnsPort(p int) uint16 { + if p < 0 || p > 65535 { + return 0 + } + v := uint16(p) + return v<<8 | v>>8 +} + // advertiseWithDNS publishes the primary _ipp._tcp name through DnsServiceRegister // (or a test double) and blocks until ctx is canceled. func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { @@ -88,10 +129,77 @@ func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClie // socket advertises the full set so plain browse still works. The socket sets // SO_REUSEADDR and joins the mDNS groups so it shares the port with the // Windows stack instead of replacing it. +// +// macOS mDNSResponder accepts subtypes in the regtype +// ("_ipp._tcp,_universal,_print"). Those PTRs target the primary instance. +// The UDP socket stays closed while that registration is up, so the Mac does +// not advertise a second copy. func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { - if goos != "windows" { + switch goos { + case "windows": + return advertiseWindows(ctx, current, api, sink) + case "darwin": + return advertiseDarwinDNS(ctx, current, api, sink) + default: return advertiseUDP(ctx, current, sink) } +} + +func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + if api == nil { + return fmt.Errorf("DNSServiceRegister client is nil") + } + insts := serviceToDarwinDNS(current()) + if len(insts) == 0 { + return fmt.Errorf("DNSServiceRegister: empty service") + } + var ok []dnsSDInstance + var veraeErr error + for i, inst := range insts { + if err := api.Register(inst); err != nil { + if i == 0 { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + udpErr := advertiseUDP(ctx, current, sink) + if udpErr != nil && ctx.Err() == nil { + return fmt.Errorf("DNSServiceRegister %s: %w; shared UDP 5353: %v", inst.RegType, err, udpErr) + } + return nil + } + veraeErr = err + log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + continue + } + ok = append(ok, inst) + } + detail := fmt.Sprintf("DNSServiceRegister %s regtype %s port %d", ok[0].InstanceName, ok[0].RegType, ok[0].Port) + if veraeErr != nil { + detail += "; _verae._tcp not registered" + } else if len(ok) > 1 { + detail += "; _verae._tcp" + } + log.Printf("netprint: mDNS %s", detail) + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: detail, + LastSent: time.Now(), + }) + } + <-ctx.Done() + for _, inst := range ok { + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err) + } + } + log.Printf("netprint: mDNS DNSServiceRegister removed") + return nil +} + +func advertiseWindows(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { var mu sync.Mutex st := struct { dnsOK bool diff --git a/internal/netprint/dnsservice_darwin.go b/internal/netprint/dnsservice_darwin.go new file mode 100644 index 0000000..dd00460 --- /dev/null +++ b/internal/netprint/dnsservice_darwin.go @@ -0,0 +1,131 @@ +//go:build darwin + +package netprint + +import ( + "fmt" + "log" + "runtime" + "sync" + "unsafe" + + "github.com/ebitengine/purego" +) + +// darwinDNS registers with mDNSResponder through DNSServiceRegister. +// Subtypes are part of the regtype. The call does not use cgo; libSystem is +// loaded when the process runs on a Mac. +type darwinDNS struct { + mu sync.Mutex + refs []uintptr + stop []func() +} + +func newDarwinDNS() dnsSDClient { return &darwinDNS{} } + +var ( + dnsOnce sync.Once + dnsErr error + + dnsRegister func(sdRef *uintptr, flags uint32, ifIndex uint32, name, regtype, domain, host *byte, port uint16, txtLen uint16, txtRecord unsafe.Pointer, callback uintptr, context unsafe.Pointer) int32 + dnsProcess func(sdRef uintptr) int32 + dnsDeallocate func(sdRef uintptr) +) + +func loadDNSService() error { + dnsOnce.Do(func() { + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", 1) + if err != nil { + dnsErr = err + return + } + purego.RegisterLibFunc(&dnsRegister, lib, "DNSServiceRegister") + purego.RegisterLibFunc(&dnsProcess, lib, "DNSServiceProcessResult") + purego.RegisterLibFunc(&dnsDeallocate, lib, "DNSServiceRefDeallocate") + }) + return dnsErr +} + +func (d *darwinDNS) Register(inst dnsSDInstance) error { + if err := loadDNSService(); err != nil { + return fmt.Errorf("DNSServiceRegister: %w", err) + } + name := cString(inst.Name) + regtype := cString(inst.RegType) + domain := cString("local") + var host *byte + hostBuf := []byte{} + if inst.Host != "" { + hostBuf = append([]byte(inst.Host), 0) + host = &hostBuf[0] + } + txt := encodeDNSTXT(inst.TXT) + var txtPtr unsafe.Pointer + if len(txt) > 0 { + txtPtr = unsafe.Pointer(&txt[0]) + } + var ref uintptr + rc := dnsRegister(&ref, 0, 0, name, regtype, domain, host, dnsPort(inst.Port), uint16(len(txt)), txtPtr, 0, nil) + runtime.KeepAlive(name) + runtime.KeepAlive(regtype) + runtime.KeepAlive(domain) + runtime.KeepAlive(hostBuf) + runtime.KeepAlive(txt) + if rc != 0 { + return fmt.Errorf("DNSServiceRegister %s %s: code %d", inst.Name, inst.RegType, rc) + } + done := make(chan struct{}) + go func() { + defer close(done) + for { + if err := dnsProcess(ref); err != 0 { + return + } + } + }() + d.mu.Lock() + d.refs = append(d.refs, ref) + d.stop = append(d.stop, func() { + dnsDeallocate(ref) + <-done + }) + d.mu.Unlock() + log.Printf("netprint: DNSServiceRegister %s %s port %d", inst.Name, inst.RegType, inst.Port) + return nil +} + +func (d *darwinDNS) Deregister(inst dnsSDInstance) error { + d.mu.Lock() + if len(d.stop) == 0 { + d.mu.Unlock() + return nil + } + stop := d.stop[0] + d.stop = d.stop[1:] + if len(d.refs) > 0 { + d.refs = d.refs[1:] + } + d.mu.Unlock() + stop() + return nil +} + +func cString(s string) *byte { + b := append([]byte(s), 0) + return &b[0] +} + +func encodeDNSTXT(txt []string) []byte { + var b []byte + for _, s := range txt { + if s == "" { + continue + } + if len(s) > 255 { + s = s[:255] + } + b = append(b, byte(len(s))) + b = append(b, s...) + } + return b +} diff --git a/internal/netprint/events.go b/internal/netprint/events.go new file mode 100644 index 0000000..b30118b --- /dev/null +++ b/internal/netprint/events.go @@ -0,0 +1,201 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// JobEvent is one completed job, told to the menu-bar app over the loopback +// control API. The daemon does not open a share sheet. +type JobEvent struct { + Seq int64 `json:"seq"` + Kind string `json:"kind"` + ID int `json:"id"` + ReceiptID string `json:"receiptId"` + Title string `json:"title"` + AirDrop bool `json:"airdrop"` + CertificateURL string `json:"certificateUrl"` + ReceiptJSONURL string `json:"receiptJsonUrl"` + At time.Time `json:"at"` +} + +type jobBus struct { + mu sync.Mutex + next int64 + recent []JobEvent + subs map[chan JobEvent]struct{} + acked map[int]bool +} + +func (s *Server) afterJob(rec Record) { + s.queueReceiptMail(rec) + s.publishJobEvent(rec) +} + +func (s *Server) publishJobEvent(rec Record) { + if rec.State != "completed" { + return + } + base := strings.TrimRight(s.controlURL, "/") + ev := JobEvent{ + Kind: "completed", + ID: rec.ID, + ReceiptID: rec.ReceiptID, + Title: rec.Title, + AirDrop: s.snapshot().AirDropOn(), + CertificateURL: fmt.Sprintf("%s/jobs/%d/receipt.pdf", base, rec.ID), + ReceiptJSONURL: fmt.Sprintf("%s/jobs/%d/receipt.json", base, rec.ID), + At: time.Now(), + } + s.jobs.publish(ev) + if ev.AirDrop { + log.Printf("netprint: job %d airdrop queued receipt=%s", rec.ID, rec.ReceiptID) + } +} + +func (b *jobBus) publish(ev JobEvent) { + b.mu.Lock() + if b.subs == nil { + b.subs = map[chan JobEvent]struct{}{} + } + if b.acked == nil { + b.acked = map[int]bool{} + } + b.next++ + ev.Seq = b.next + b.recent = append(b.recent, ev) + if len(b.recent) > 16 { + b.recent = b.recent[len(b.recent)-16:] + } + subs := make([]chan JobEvent, 0, len(b.subs)) + for ch := range b.subs { + subs = append(subs, ch) + } + b.mu.Unlock() + for _, ch := range subs { + select { + case ch <- ev: + default: + } + } +} + +func (b *jobBus) subscribe(after int64) (backlog []JobEvent, ch chan JobEvent) { + b.mu.Lock() + defer b.mu.Unlock() + if b.subs == nil { + b.subs = map[chan JobEvent]struct{}{} + } + ch = make(chan JobEvent, 32) + b.subs[ch] = struct{}{} + if after == 0 { + if ev, ok := b.pendingLocked(); ok { + backlog = []JobEvent{ev} + } + return backlog, ch + } + for _, ev := range b.recent { + if ev.Seq > after { + backlog = append(backlog, ev) + } + } + return backlog, ch +} + +func (b *jobBus) unsubscribe(ch chan JobEvent) { + b.mu.Lock() + delete(b.subs, ch) + b.mu.Unlock() +} + +func (b *jobBus) ack(id int) { + b.mu.Lock() + defer b.mu.Unlock() + if b.acked == nil { + b.acked = map[int]bool{} + } + b.acked[id] = true +} + +// pendingLocked is the newest AirDrop job the menu bar has not acknowledged. +// The caller holds b.mu. +func (b *jobBus) pendingLocked() (JobEvent, bool) { + for i := len(b.recent) - 1; i >= 0; i-- { + ev := b.recent[i] + if !ev.AirDrop || b.acked[ev.ID] { + continue + } + if time.Since(ev.At) > 10*time.Minute { + continue + } + return ev, true + } + return JobEvent{}, false +} + +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unavailable", http.StatusInternalServerError) + return + } + after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Connection", "keep-alive") + backlog, ch := s.jobs.subscribe(after) + defer s.jobs.unsubscribe(ch) + for _, ev := range backlog { + writeJobEvent(w, ev) + } + fmt.Fprintf(w, ": ok\n\n") + fl.Flush() + tick := time.NewTicker(15 * time.Second) + defer tick.Stop() + for { + select { + case <-r.Context().Done(): + return + case ev := <-ch: + writeJobEvent(w, ev) + fl.Flush() + case <-tick.C: + fmt.Fprintf(w, ": ping\n\n") + fl.Flush() + } + } +} + +func writeJobEvent(w http.ResponseWriter, ev JobEvent) { + b, err := json.Marshal(ev) + if err != nil { + return + } + fmt.Fprintf(w, "event: job\ndata: %s\n\n", b) +} + +func (s *Server) handleAirDropAck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID int `json:"id"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil || body.ID <= 0 { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + s.jobs.ack(body.ID) + writeJSON(w, map[string]bool{"ok": true}) +} diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 4b79ec9..ba76f88 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -30,8 +30,11 @@ type Server struct { ippSrv *http.Server controlSrv *http.Server mdnsCancel context.CancelFunc + mdnsGen int mdns mdnsReport emailErr string + uuid string + jobs jobBus } // Run loads config, listens, and blocks until ctx is canceled. @@ -92,10 +95,11 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) return nil, fmt.Errorf("control listen: %w", err) } s.controlURL = "http://" + ctlLn.Addr().String() + "/" + s.uuid = uuid s.seedEmailError() s.printer.AdminURL = s.receiptsURL() s.printer.OnDocument = func(id int, format, title, user string, doc []byte) { - ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.queueReceiptMail) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) } admin := &Admin{Store: st, Name: func() string { return s.snapshot().Name }, Port: func() int { return s.port }} @@ -141,6 +145,8 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) ctrlMux.HandleFunc("/api/v1/firewall", s.handleFirewall) ctrlMux.HandleFunc("/api/v1/settings", s.handleSettings) ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth) + ctrlMux.HandleFunc("/api/v1/events", s.handleEvents) + ctrlMux.HandleFunc("/api/v1/airdrop/ack", s.handleAirDropAck) ctrlMux.HandleFunc("/settings", s.handleSettingsPage) ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !loopback(r) { @@ -162,34 +168,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) })} go func() { _ = s.ippSrv.Serve(ippLn) }() go func() { _ = s.controlSrv.Serve(ctlLn) }() - if cfg.MDNS() { - ctx, cancel := context.WithCancel(context.Background()) - s.mdnsCancel = cancel - s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) - go func() { - err := startAdvertiser(ctx, func() Service { - c := s.snapshot() - ips := lanIPs() - adminURL := s.printer.AdminURL - stampURL := strings.TrimSuffix(adminURL, "/receipts") + "/api/stamp" - return Service{ - Instance: c.Name, - Host: hostLabel(c.Hostname), - Port: s.port, - IPs: ips, - TXT: AirPrintTXT(c.Name, uuid, adminURL, "", ""), - StampURL: stampURL, - } - }, s.setMDNS) - if err != nil && ctx.Err() == nil { - log.Printf("netprint: mDNS not advertised: %v", err) - s.setMDNS(mdnsReport{Mode: "error", Err: err.Error(), Detail: err.Error()}) - } - }() - } else { - log.Printf("netprint: mDNS disabled") - s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) - } + s.syncAdvertiser() log.Printf("netprint: IPP on %s, control on %s", listenerAddrs(ippLn), ctlLn.Addr()) return s, nil } @@ -200,6 +179,71 @@ func (s *Server) snapshot() Config { return s.cfg } +// syncAdvertiser starts or stops Bonjour to match the Advertise setting. +// IPP keeps running either way. A name or port change still needs a restart +// before mDNSResponder replaces a registration it already accepted. +func (s *Server) syncAdvertiser() { + if s.snapshot().MDNS() { + s.startMDNS() + return + } + s.stopMDNS() +} + +func (s *Server) startMDNS() { + s.mu.Lock() + if s.mdnsCancel != nil { + s.mu.Unlock() + return + } + s.mdnsGen++ + gen := s.mdnsGen + ctx, cancel := context.WithCancel(context.Background()) + s.mdnsCancel = cancel + s.mu.Unlock() + s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) + go func() { + err := startAdvertiser(ctx, func() Service { + c := s.snapshot() + adminURL := "" + if s.printer != nil { + adminURL = s.printer.AdminURL + } + return Service{ + Instance: c.Name, + Host: hostLabel(c.Hostname), + Port: s.port, + IPs: lanIPs(), + TXT: AirPrintTXT(c.Name, s.uuid, adminURL, "", ""), + StampURL: strings.TrimSuffix(adminURL, "/receipts") + "/api/stamp", + } + }, s.setMDNS) + s.mu.Lock() + current := s.mdnsGen + s.mu.Unlock() + if current != gen { + return + } + if err != nil && ctx.Err() == nil { + log.Printf("netprint: mDNS not advertised: %v", err) + s.setMDNS(mdnsReport{Mode: "error", Err: err.Error(), Detail: err.Error()}) + } + }() +} + +func (s *Server) stopMDNS() { + s.mu.Lock() + s.mdnsGen++ + cancel := s.mdnsCancel + s.mdnsCancel = nil + s.mu.Unlock() + if cancel != nil { + cancel() + } + log.Printf("netprint: mDNS disabled") + s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) +} + func (s *Server) setMDNS(r mdnsReport) { s.mu.Lock() s.mdns = r @@ -217,8 +261,13 @@ func (s *Server) ControlURL() string { return s.controlURL } // Shutdown stops listeners and sends an mDNS goodbye. func (s *Server) Shutdown(ctx context.Context) error { - if s.mdnsCancel != nil { - s.mdnsCancel() + s.mu.Lock() + cancel := s.mdnsCancel + s.mdnsCancel = nil + s.mdnsGen++ + s.mu.Unlock() + if cancel != nil { + cancel() } var err error if s.ippSrv != nil { diff --git a/internal/netprint/stamp_api.go b/internal/netprint/stamp_api.go index 070e1d1..2fcf1f4 100644 --- a/internal/netprint/stamp_api.go +++ b/internal/netprint/stamp_api.go @@ -41,7 +41,7 @@ func (s *Server) handleStamp(w http.ResponseWriter, r *http.Request) { } user := stampUser(r.Header.Get("X-Verae-User")) log.Printf("netprint: stamp api job %d title=%q bytes=%d", id, title, len(doc)) - ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.queueReceiptMail) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) rec, ok := s.store.Get(id) if !ok || rec.State != "completed" || rec.CertificatePDF == "" { msg := "stamp failed" diff --git a/internal/netprint/tray_darwin.go b/internal/netprint/tray_darwin.go index cbf3a7a..300e8b3 100644 --- a/internal/netprint/tray_darwin.go +++ b/internal/netprint/tray_darwin.go @@ -36,15 +36,20 @@ func runTray(ctx context.Context) error { menu.Send(objc.RegisterName("setDelegate:"), trayTarget) item.Send(objc.RegisterName("setMenu:"), menu) rebuildDarwinMenu(menu) + watchCtx, watchCancel := context.WithCancel(ctx) + defer watchCancel() + startAirDropWatch(watchCtx) app.Send(objc.RegisterName("run")) return nil } func registerTrayClass() { - _, _ = objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{ + methods := []objc.MethodDef{ {Cmd: selOpen, Fn: menuWillOpen}, {Cmd: selTray, Fn: trayAction}, - }) + } + methods = append(methods, airDropMethods()...) + _, _ = objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, methods) } func menuWillOpen(self, _cmd, menu objc.ID) { @@ -67,7 +72,7 @@ func trayAction(self, _cmd, sender objc.ID) { func rebuildDarwinMenu(menu objc.ID) { st := Probe() - items := BuildMenu(st) + items := withAirDropAgain(BuildMenu(st)) trayMu.Lock() trayItems = items trayMu.Unlock() diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go index feafa47..f4c6063 100644 --- a/internal/netprint/tray_menu.go +++ b/internal/netprint/tray_menu.go @@ -6,18 +6,21 @@ import ( ) const ( - idStatus = 1 - idStart = 3 - idStop = 4 - idRestart = 5 - idLaunch = 7 - idTray = 8 - idPublic = 9 - idReceipts = 10 - idSettings = 11 - idEmail = 12 - idQuit = 100 - idRecent = 20 + idStatus = 1 + idStart = 3 + idStop = 4 + idRestart = 5 + idLaunch = 7 + idTray = 8 + idPublic = 9 + idReceipts = 10 + idSettings = 11 + idEmail = 12 + idAirDrop = 13 + idAdvertise = 14 + idAirDropAgain = 15 + idQuit = 100 + idRecent = 20 ) // MenuEntry is one row in the tray or menu bar. The UI layers only display it. @@ -73,9 +76,15 @@ func BuildMenu(st Status) []MenuEntry { if st.SupportsPublic { out = append(out, MenuEntry{ID: idPublic, Label: "Allow Public networks", Enabled: st.Running, Checked: st.AllowPublic, Kind: "public"}) } + if st.SupportsAirDrop { + out = append(out, MenuEntry{ID: idAdvertise, Label: "Advertise on the network", Enabled: st.Running, Checked: st.Advertise, Kind: "advertise"}) + } out = append(out, MenuEntry{Separator: true}, MenuEntry{ID: idEmail, Label: "Email stamped PDFs", Enabled: st.Running, Checked: st.EmailEnabled, Kind: "email"}, ) + if st.SupportsAirDrop { + out = append(out, MenuEntry{ID: idAirDrop, Label: "AirDrop stamped PDF after each job", Enabled: st.Running, Checked: st.AirDropEnabled, Kind: "airdrop"}) + } if st.EmailError != "" { out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"}) } else if st.EmailEnabled { @@ -123,6 +132,9 @@ func discoveryLine(st Status) string { switch st.MDNSMode { case "dns-sd": if st.MDNSAdvertising { + if strings.Contains(st.MDNSDetail, "_ipp._tcp,_universal") { + return "Discovery — advertised (Bonjour _universal and _print)" + } return "Discovery — advertised (Windows DNS-SD)" } case "udp": @@ -199,6 +211,12 @@ func Dispatch(st Status, e MenuEntry) error { return c.SetAllowPublic(!st.AllowPublic) case "email": return c.SetEmailEnabled(!st.EmailEnabled) + case "advertise": + return c.SetAdvertise(!st.Advertise) + case "airdrop": + return c.SetAirDropEnabled(!st.AirDropEnabled) + case "airdrop-again": + return airDropAgain() case "open": return OpenURL(e.URL) case "quit": diff --git a/platform/macos/airdrop/main.swift b/platform/macos/airdrop/main.swift new file mode 100644 index 0000000..b44ab38 --- /dev/null +++ b/platform/macos/airdrop/main.swift @@ -0,0 +1,39 @@ +import AppKit + +// verae-airdrop opens the AirDrop share sheet for the files it is given. +// The menu-bar app launches this helper when it is installed next to +// verae-print. The same NSSharingService call lives in the menu-bar app +// for builds that do not include this helper. +// +// Usage: verae-airdrop certificate.pdf receipt.json + +final class AirDropDelegate: NSObject, NSSharingServiceDelegate { + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + exit(0) + } + + func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) { + fputs("AirDrop failed: \(error.localizedDescription)\n", stderr) + exit(1) + } +} + +let paths = Array(CommandLine.arguments.dropFirst()).filter { !$0.isEmpty } +if paths.isEmpty { + fputs("usage: verae-airdrop certificate.pdf [receipt.json]\n", stderr) + exit(2) +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = AirDropDelegate() +guard let service = NSSharingService(named: .sendViaAirDrop) else { + fputs("AirDrop sharing service is unavailable\n", stderr) + exit(1) +} +service.delegate = delegate +let urls = paths.map { URL(fileURLWithPath: $0) } +app.activate(ignoringOtherApps: true) +service.perform(withItems: urls) +RunLoop.main.run(until: Date().addingTimeInterval(600)) +exit(0) diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 44e79c1..1b79c45 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Standalone Verae Network Printer .pkg. +# Linux CI writes VeraeNetPrint-macos-arm64-.pkg. It is not signed. # On a Mac with pkgbuild this produces an installer. Signing happens only when # signing.env is present (same file as the driver package). Without pkgbuild # (Linux CI) the caller still ships the darwin binaries and this script. @@ -16,14 +17,28 @@ if [[ ! -x "$BIN" && ! -f "$BIN" ]]; then exit 1 fi +ARCH="${PKG_ARCH:-arm64}" STAGE="$OUT/payload" SCRIPTS="$OUT/scripts" rm -rf "$STAGE" "$SCRIPTS" -mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" "$SCRIPTS" +mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" \ + "$STAGE/Library/LaunchDaemons" \ + "$STAGE/Library/LaunchAgents" \ + "$SCRIPTS" cp -f "$BIN" "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" +# The package itself installs the root daemon and the Aqua LaunchAgent. +# postinstall bootstraps both; these copies are what launchd reads. +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" +chmod 644 "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" +if command -v swiftc >/dev/null 2>&1; then + swiftc -O -o "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" \ + "$ROOT/platform/macos/airdrop/main.swift" + chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" +fi # preinstall runs before the payload replaces verae-print. postinstall # bootstraps the service again. cp -f "$ROOT/platform/macos/netprint/bootout.sh" "$SCRIPTS/bootout.sh" @@ -42,9 +57,12 @@ exec "/Library/Application Support/Verae/NetPrint/bin/verae-print" netprint inst EOF chmod 755 "$SCRIPTS/preinstall" "$SCRIPTS/postinstall" "$SCRIPTS/bootout.sh" +PKG_NAME="VeraeNetPrint-macos-${ARCH}-${VERSION}.pkg" if ! command -v pkgbuild >/dev/null 2>&1; then - echo "pkgbuild not available — payload and scripts are in $OUT" + echo "pkgbuild not available — building unsigned $PKG_NAME" tar -C "$OUT" -czf "$OUT/VeraeNetPrint-macos-payload.tar.gz" payload scripts + "$ROOT/platform/macos/pkg/build-unsigned-pkg.sh" "$STAGE" "$SCRIPTS" "$OUT/$PKG_NAME" "$VERSION" + echo "built $OUT/$PKG_NAME (unsigned)" exit 0 fi @@ -54,7 +72,8 @@ pkgbuild \ --identifier "ai.verae.netprint.standalone" \ --version "$VERSION" \ --install-location "/" \ - "$OUT/VeraeNetPrint-macos.pkg" + "$OUT/$PKG_NAME" +cp -f "$OUT/$PKG_NAME" "$OUT/VeraeNetPrint-macos.pkg" SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}" if [[ -f "$SIGNING_ENV" ]]; then @@ -63,6 +82,7 @@ if [[ -f "$SIGNING_ENV" ]]; then ID_PKG="${APPLE_INSTALLER_SIGN_ID:-Developer ID Installer: FlightLookup Inc. (G8CKY7VMZC)}" productbuild --package "$OUT/VeraeNetPrint-macos.pkg" --sign "$ID_PKG" --timestamp \ "$OUT/VeraeNetPrint-macos-signed.pkg" - mv -f "$OUT/VeraeNetPrint-macos-signed.pkg" "$OUT/VeraeNetPrint-macos.pkg" + mv -f "$OUT/VeraeNetPrint-macos-signed.pkg" "$OUT/$PKG_NAME" + cp -f "$OUT/$PKG_NAME" "$OUT/VeraeNetPrint-macos.pkg" fi -echo "built $OUT/VeraeNetPrint-macos.pkg" +echo "built $OUT/$PKG_NAME" diff --git a/platform/macos/pkg/build-unsigned-pkg.sh b/platform/macos/pkg/build-unsigned-pkg.sh new file mode 100755 index 0000000..2a01fe3 --- /dev/null +++ b/platform/macos/pkg/build-unsigned-pkg.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Build an unsigned flat package on Linux. pkgbuild is not here. +# The result installs with: sudo installer -pkg VeraeNetPrint-macos-arm64-*.pkg -target / +# Finder: right-click the pkg and choose Open. It is not signed or notarized. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +STAGE="${1:?payload root}" +SCRIPTS="${2:?scripts dir}" +DEST="${3:?output pkg}" +VERSION="${4:?version}" + +if ! command -v cpio >/dev/null 2>&1; then + echo "cpio is required to build the macOS package" >&2 + exit 1 +fi + +MKBOM="${MKBOM:-}" +if [[ -z "$MKBOM" ]]; then + if command -v mkbom >/dev/null 2>&1 && mkbom -h >/dev/null 2>&1; then + MKBOM="$(command -v mkbom)" + fi +fi +if [[ -z "$MKBOM" || ! -x "$MKBOM" ]]; then + TOOL="$ROOT/build/tool" + mkdir -p "$TOOL" + if [[ ! -x "$TOOL/bomutils/build/bin/mkbom" ]]; then + rm -rf "$TOOL/bomutils" + git clone --depth 1 https://github.com/hogliux/bomutils.git "$TOOL/bomutils" + make -C "$TOOL/bomutils" all + fi + MKBOM="$TOOL/bomutils/build/bin/mkbom" +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +COMP="$WORK/netprint.pkg" +mkdir -p "$COMP" + +# odc cpio, root:admin (0:80), matching the Bom. GNU cpio accepts --owner. +( cd "$STAGE" && find . | sort | cpio -o --format odc --owner 0:80 | gzip -c ) > "$COMP/Payload" +( cd "$SCRIPTS" && find . | sort | cpio -o --format odc --owner 0:80 | gzip -c ) > "$COMP/Scripts" +"$MKBOM" -u 0 -g 80 "$STAGE" "$COMP/Bom" + +FILES="$(find "$STAGE" | wc -l | tr -d ' ')" +KB="$(du -sk "$STAGE" | awk '{print $1}')" +cat >"$COMP/PackageInfo" < + + + + + + + +EOF + +cat >"$WORK/Distribution" < + + Verae Network Printer + + + + + + + + + netprint.pkg + +EOF + +( + cd "$ROOT" + go run ./platform/macos/pkg/cmd/mkxar -o "$DEST" "$WORK" +) +echo "unsigned package $DEST" diff --git a/platform/macos/pkg/cmd/mkxar/main.go b/platform/macos/pkg/cmd/mkxar/main.go new file mode 100644 index 0000000..bd36f2c --- /dev/null +++ b/platform/macos/pkg/cmd/mkxar/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/platform/macos/pkg/mkxar" +) + +func main() { + out := flag.String("o", "", "output xar path") + flag.Parse() + if *out == "" || flag.NArg() != 1 { + fmt.Fprintln(os.Stderr, "usage: mkxar -o out.pkg directory") + os.Exit(2) + } + if err := mkxar.WriteDir(*out, flag.Arg(0)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/platform/macos/pkg/mkxar/xar.go b/platform/macos/pkg/mkxar/xar.go new file mode 100644 index 0000000..7ca972f --- /dev/null +++ b/platform/macos/pkg/mkxar/xar.go @@ -0,0 +1,208 @@ +// Package mkxar writes an uncompressed xar archive. macOS flat packages are +// xar archives whose members are stored, not gzip-compressed, because the +// Payload inside is already gzip. +package mkxar + +import ( + "bytes" + "compress/zlib" + "crypto/sha1" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +type fileRec struct { + id int + name string + isDir bool + mode os.FileMode + data []byte + kids []*fileRec + offset int +} + +// WriteDir writes root's children as the top level of a xar archive. +func WriteDir(dst string, root string) error { + entries, err := readTree(root) + if err != nil { + return err + } + var files []*fileRec + id := 1 + var walk func(list []*fileRec) + walk = func(list []*fileRec) { + for _, e := range list { + e.id = id + id++ + files = append(files, e) + walk(e.kids) + } + } + walk(entries) + + heap := []byte{} + // 20-byte SHA-1 of the TOC is filled in after the TOC exists. + heap = append(heap, make([]byte, 20)...) + for _, e := range files { + if e.isDir { + continue + } + e.offset = len(heap) + heap = append(heap, e.data...) + } + + toc := buildTOC(entries) + sum := sha1.Sum([]byte(toc)) + copy(heap[:20], sum[:]) + + var zbuf bytes.Buffer + zw, err := zlib.NewWriterLevel(&zbuf, zlib.BestSpeed) + if err != nil { + return err + } + if _, err := zw.Write([]byte(toc)); err != nil { + return err + } + if err := zw.Close(); err != nil { + return err + } + compressed := zbuf.Bytes() + + var hdr [28]byte + binary.BigEndian.PutUint32(hdr[0:4], 0x78617221) + binary.BigEndian.PutUint16(hdr[4:6], 28) + binary.BigEndian.PutUint16(hdr[6:8], 1) + binary.BigEndian.PutUint64(hdr[8:16], uint64(len(compressed))) + binary.BigEndian.PutUint64(hdr[16:24], uint64(len(toc))) + binary.BigEndian.PutUint32(hdr[24:28], 1) // sha1 + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := out.Write(hdr[:]); err != nil { + return err + } + if _, err := out.Write(compressed); err != nil { + return err + } + _, err = out.Write(heap) + return err +} + +func readTree(dir string) ([]*fileRec, error) { + ents, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() }) + var out []*fileRec + for _, ent := range ents { + info, err := ent.Info() + if err != nil { + return nil, err + } + rec := &fileRec{name: ent.Name(), mode: info.Mode()} + path := filepath.Join(dir, ent.Name()) + if ent.IsDir() { + rec.isDir = true + rec.kids, err = readTree(path) + if err != nil { + return nil, err + } + } else { + rec.data, err = os.ReadFile(path) + if err != nil { + return nil, err + } + } + out = append(out, rec) + } + return out, nil +} + +func buildTOC(entries []*fileRec) string { + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString("\n \n") + b.WriteString(" \n 0\n 20\n \n") + for _, e := range entries { + writeFile(&b, e, 1) + } + b.WriteString(" \n\n") + return b.String() +} + +func writeFile(b *strings.Builder, e *fileRec, depth int) { + pad := strings.Repeat(" ", depth) + fmt.Fprintf(b, "%s\n", pad, e.id) + fmt.Fprintf(b, "%s %s\n", pad, xmlEscape(e.name)) + if e.isDir { + fmt.Fprintf(b, "%s directory\n", pad) + } else { + fmt.Fprintf(b, "%s file\n", pad) + } + fmt.Fprintf(b, "%s %04o\n", pad, e.mode.Perm()) + fmt.Fprintf(b, "%s 0\n%s 0\n", pad, pad) + if !e.isDir { + sum := sha1.Sum(e.data) + hexSum := hex.EncodeToString(sum[:]) + fmt.Fprintf(b, "%s \n", pad) + fmt.Fprintf(b, "%s %d\n", pad, e.offset) + fmt.Fprintf(b, "%s %d\n", pad, len(e.data)) + fmt.Fprintf(b, "%s %d\n", pad, len(e.data)) + fmt.Fprintf(b, "%s \n", pad) + fmt.Fprintf(b, "%s %s\n", pad, hexSum) + fmt.Fprintf(b, "%s %s\n", pad, hexSum) + fmt.Fprintf(b, "%s \n", pad) + } + for _, k := range e.kids { + writeFile(b, k, depth+1) + } + fmt.Fprintf(b, "%s\n", pad) +} + +func xmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">") + return r.Replace(s) +} + +// TOC extracts the uncompressed table of contents. Tests use it. +func TOC(r io.ReaderAt, size int64) (string, error) { + if size < 28 { + return "", fmt.Errorf("short xar") + } + var hdr [28]byte + if _, err := r.ReadAt(hdr[:], 0); err != nil { + return "", err + } + if binary.BigEndian.Uint32(hdr[0:4]) != 0x78617221 { + return "", fmt.Errorf("not a xar") + } + comp := binary.BigEndian.Uint64(hdr[8:16]) + rawLen := binary.BigEndian.Uint64(hdr[16:24]) + buf := make([]byte, comp) + if _, err := r.ReadAt(buf, 28); err != nil { + return "", err + } + zr, err := zlib.NewReader(bytes.NewReader(buf)) + if err != nil { + return "", err + } + defer zr.Close() + out, err := io.ReadAll(zr) + if err != nil { + return "", err + } + if uint64(len(out)) != rawLen { + return "", fmt.Errorf("toc length %d want %d", len(out), rawLen) + } + return string(out), nil +} diff --git a/platform/macos/pkg/mkxar/xar_test.go b/platform/macos/pkg/mkxar/xar_test.go new file mode 100644 index 0000000..194ae93 --- /dev/null +++ b/platform/macos/pkg/mkxar/xar_test.go @@ -0,0 +1,52 @@ +package mkxar + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteDirRoundTrip(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "netprint.pkg"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Distribution"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + payload := bytesRepeat(1000) + if err := os.WriteFile(filepath.Join(root, "netprint.pkg", "Payload"), payload, 0o644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(t.TempDir(), "out.pkg") + if err := WriteDir(dst, root); err != nil { + t.Fatal(err) + } + f, err := os.Open(dst) + if err != nil { + t.Fatal(err) + } + defer f.Close() + st, err := f.Stat() + if err != nil { + t.Fatal(err) + } + toc, err := TOC(f, st.Size()) + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{"Distribution", "netprint.pkg", "Payload", "application/octet-stream"} { + if !strings.Contains(toc, needle) { + t.Fatalf("toc missing %s\n%s", needle, toc) + } + } +} + +func bytesRepeat(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i) + } + return b +} diff --git a/scripts/build-netprint-installers.sh b/scripts/build-netprint-installers.sh index c842974..d880655 100755 --- a/scripts/build-netprint-installers.sh +++ b/scripts/build-netprint-installers.sh @@ -78,14 +78,15 @@ else cp -f "$OUT/darwin-amd64/verae-print" "$OUT/macos/verae-print-amd64" cp -f "$OUT/darwin-arm64/verae-print" "$OUT/macos/verae-print" fi -chmod +x platform/macos/pkg/build-netprint-pkg.sh -BIN="$OUT/macos/verae-print" OUT="$OUT/macos" VERSION="$VERSION" ./platform/macos/pkg/build-netprint-pkg.sh +chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/build-unsigned-pkg.sh +# Apple Silicon package. The arm64 slice is the binary George runs. +BIN="$OUT/darwin-arm64/verae-print" PKG_ARCH=arm64 OUT="$OUT/macos" VERSION="$VERSION" ./platform/macos/pkg/build-netprint-pkg.sh cp -f platform/macos/pkg/distribution.xml "$OUT/macos/distribution.xml" cp -f platform/macos/netprint/scripts/postinstall "$OUT/macos/postinstall-netprint.sh" ( cd "$OUT" - sha256sum *.deb *.exe "$OUT/macos"/* 2>/dev/null | tee SHA256SUMS || true + sha256sum *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh 2>/dev/null | tee SHA256SUMS || true ) echo echo "Artifacts in $OUT" From a63ad55343cb18a7223e6d4660b48b3755b7353d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 01:29:53 +0000 Subject: [PATCH 19/27] Skip the AirDrop helper compile unless swiftc is on macOS. Linux CI has a Swift compiler that cannot import AppKit. The menu-bar app opens the share sheet itself when verae-airdrop is not in the package. Co-authored-by: George Lambert --- internal/netprint/airdrop_test.go | 2 +- platform/macos/pkg/build-netprint-pkg.sh | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go index feeb1de..8ca82d8 100644 --- a/internal/netprint/airdrop_test.go +++ b/internal/netprint/airdrop_test.go @@ -298,7 +298,7 @@ func TestMacAirDropAndPackageSources(t *testing.T) { {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter"}}, {"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint"}}, - {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons"}}, + {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin"}}, {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, {"advertise_darwin.go", []string{`advertiseForOS("darwin"`}}, diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 1b79c45..904846e 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -34,7 +34,9 @@ cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Libr cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" chmod 644 "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" -if command -v swiftc >/dev/null 2>&1; then +# swiftc on Linux cannot import AppKit. The menu-bar binary opens the same +# share sheet itself when verae-airdrop is absent, so Linux CI skips the helper. +if [[ "$(uname -s)" == "Darwin" ]] && command -v swiftc >/dev/null 2>&1; then swiftc -O -o "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" \ "$ROOT/platform/macos/airdrop/main.swift" chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" From e685b0241e8638f2cb08c2cb14b7b723729d8375 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 01:47:53 +0000 Subject: [PATCH 20/27] Build the macOS package with pkgbuild and leave CUPS localhost alone. The arm64 pkg and the Swift AirDrop helper are built on a macos-14 runner, then checked with pkgutil and installer. Receipts move to /Users/Shared/Verae Receipts, and port 631 is bound only on LAN addresses. Co-authored-by: George Lambert --- .github/workflows/installers.yml | 64 +++++++++++- docs/network-printer.md | 19 ++-- internal/netprint/airdrop_test.go | 6 +- internal/netprint/config.go | 2 +- internal/netprint/control.go | 9 ++ internal/netprint/listen.go | 127 +++++++++++++++++++++++ internal/netprint/listen_test.go | 67 +++++++++++- internal/netprint/pipeline.go | 7 +- internal/netprint/receipts_dir.go | 120 +++++++++++++++++++++ internal/netprint/receipts_dir_test.go | 104 +++++++++++++++++++ internal/netprint/server.go | 34 ++++-- internal/netprint/service_darwin.go | 6 +- internal/netprint/store.go | 11 +- platform/macos/pkg/build-netprint-pkg.sh | 58 ++++++++--- platform/macos/pkg/check-pkg.sh | 42 ++++++++ scripts/build-netprint-installers.sh | 6 +- 16 files changed, 632 insertions(+), 50 deletions(-) create mode 100644 internal/netprint/receipts_dir.go create mode 100644 internal/netprint/receipts_dir_test.go create mode 100644 platform/macos/pkg/check-pkg.sh diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 178ed55..263a0d4 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -19,10 +19,70 @@ jobs: run: VERSION=0.3.4 ./scripts/build-netprint-installers.sh - uses: actions/upload-artifact@v4 with: - name: verae-netprint-installers + name: verae-netprint-linux path: | build/netprint/*.deb build/netprint/*.exe - build/netprint/SHA256SUMS build/netprint/macos/** if-no-files-found: error + + macos-pkg: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26.1" + - name: Build arm64 binary, AirDrop helper, and package + run: | + set -euo pipefail + VERSION=0.3.4 + OUT="$PWD/build/netprint/macos" + mkdir -p "$OUT" + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o "$OUT/verae-print" ./cmd/verae-print + chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/check-pkg.sh + VERSION="$VERSION" BIN="$OUT/verae-print" PKG_ARCH=arm64 OUT="$OUT" ./platform/macos/pkg/build-netprint-pkg.sh + cp -f "$OUT/verae-print" "$OUT/verae-print-arm64" + test -x "$OUT/payload/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" + test -f "$OUT/VeraeNetPrint-macos-arm64-${VERSION}.pkg" + - name: pkgutil --expand, pkgutil --check-signature, and installer -pkginfo + run: bash platform/macos/pkg/check-pkg.sh build/netprint/macos/VeraeNetPrint-macos-arm64-0.3.4.pkg + - uses: actions/upload-artifact@v4 + with: + name: verae-netprint-macos + path: | + build/netprint/macos/*.pkg + build/netprint/macos/verae-print + build/netprint/macos/verae-print-arm64 + if-no-files-found: error + + publish: + needs: [netprint, macos-pkg] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: verae-netprint-linux + path: dist + - uses: actions/download-artifact@v4 + with: + name: verae-netprint-macos + path: macpkg + - name: SHA256SUMS + run: | + set -euo pipefail + rm -f dist/macos/*.pkg dist/SHA256SUMS + mkdir -p dist/macos + cp -f macpkg/*.pkg macpkg/verae-print macpkg/verae-print-arm64 dist/macos/ + cd dist + sha256sum *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh | tee SHA256SUMS + test -f macos/VeraeNetPrint-macos-arm64-0.3.4.pkg + - uses: actions/upload-artifact@v4 + with: + name: verae-netprint-installers + path: | + dist/*.deb + dist/*.exe + dist/SHA256SUMS + dist/macos/** + if-no-files-found: error diff --git a/docs/network-printer.md b/docs/network-printer.md index 6fed60a..1bf532d 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -14,7 +14,7 @@ The printer is off until you install the standalone package or turn on the drive | Driver command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/lib/verae-print/verae-print` | | CLI on `PATH` | the driver binary when the driver is installed | `/usr/local/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | `/usr/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | | Config | `%ProgramData%\Verae\PrintStamp\netprint.json` | `/Library/Application Support/Verae/PrintStamp/netprint.json` | `/etc/verae/netprint.json` | -| Receipts | `%ProgramData%\Verae\NetPrint` | `/Library/Application Support/Verae/NetPrint` | `/var/lib/verae-netprint` | +| Receipts | `%ProgramData%\Verae\NetPrint` | `/Users/Shared/Verae Receipts` | `/var/lib/verae-netprint` | The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network printer does not use that port. @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If `DNSServiceRegister` fails, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If `DNSServiceRegister` fails, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. On macOS, cupsd keeps `127.0.0.1:631` and `[::1]:631`. This printer binds 631 only on the Mac's non-loopback addresses, so printing from another device uses port 631 and localhost CUPS is unchanged. mDNS advertises 631. If none of those LAN binds succeed, it listens on 8632 on every interface and advertises 8632. Status JSON `ippBind` is `lan` or `all`, and `port` is the port that was bound. On Windows and Linux the listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, trying 631 and falling back to 8632 when 631 is taken. It does not use 8631. A connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to `http://:/receipts` (the AirPrint admin URL). The page lists recent jobs, newest first, with the stamped PDF and the original. Each job also has a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, whether discovery is advertising, and the receipts URL. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, turn **Email stamped PDFs** on or off, open recent receipts, and open settings (printer name, forward-to printer, signing key, mail, and the receipts PIN). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -35,7 +35,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Receipts on the phone -`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON field is `receiptsUrl`. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. +`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. Share it read-only over SMB. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. ## Stamp and return @@ -59,13 +59,14 @@ On macOS, the Application Firewall is per application (System Settings → Netwo ## macOS package -The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. It installs: +The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. GitHub Actions builds it on a `macos-14` runner with `pkgbuild` and `productbuild`, then runs `pkgutil --expand`, `pkgutil --check-signature`, and `installer -pkginfo`. It installs: - `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 binary) +- `/Library/Application Support/Verae/NetPrint/bin/verae-airdrop` (the Swift AirDrop helper, built on that Mac) - LaunchDaemon `com.verae.netprint` (root, the print server) - LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `verae-print tray` as the logged-in user -The package from CI is not signed and not notarized. macOS blocks a normal double-click until the package is signed with a Developer ID Installer certificate and notarized. To install this unsigned package, right-click it in Finder and choose Open, then Open again. Or, in Terminal: +Receipts and job files go to `/Users/Shared/Verae Receipts`, not next to those binaries. The package from CI is not signed and not notarized. macOS blocks a normal double-click until the package is signed with a Developer ID Installer certificate and notarized. To install this unsigned package, right-click it in Finder and choose Open, then Open again. Or, in Terminal: ```bash sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / @@ -75,20 +76,20 @@ sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / ## AirDrop on the Mac -Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off until you turn it on. When a job completes, the daemon records it and tells the menu-bar app on `GET http://127.0.0.1:8630/api/v1/events` (an event stream on the loopback control API). The agent downloads the certificate and the receipt JSON through that API, because the daemon's files are mode `0600`. It then opens the AirDrop share sheet with both files, using `NSSharingService(named: .sendViaAirDrop)`. A notification shows the receipt ID and an **AirDrop again** action. The same action is on the menu. The root daemon never opens the sheet. +Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off until you turn it on. When a job completes, the daemon records it and tells the menu-bar app on `GET http://127.0.0.1:8630/api/v1/events` (an event stream on the loopback control API). The agent downloads the certificate and the receipt JSON through that API and opens the AirDrop share sheet with both files, using `NSSharingService(named: .sendViaAirDrop)`. A notification shows the receipt ID and an **AirDrop again** action. The same action is on the menu. The root daemon never opens the sheet. -`platform/macos/airdrop/main.swift` is that share sheet as a small helper (`verae-airdrop`). A Mac build with `swiftc` installs it next to `verae-print` and the agent uses it. The Linux arm64 build has no Swift compiler, so the same call is made from the menu-bar process. macOS also asks for notification permission the first time the agent posts **AirDrop again**. +`platform/macos/airdrop/main.swift` is that share sheet as `verae-airdrop`. The macOS package build compiles it with `swiftc` and installs it next to `verae-print`. The menu-bar app launches that helper. macOS also asks for notification permission the first time the agent posts **AirDrop again**. The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer. -A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, and `receiptsPinSet`. It does not include the SMTP password or the receipts PIN. +A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `receiptsDir`, `ippBind`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, and `receiptsPinSet`. It does not include the SMTP password or the receipts PIN. macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary. ## Troubleshooting - The icon says stopped. Start it from the menu. On Linux, `systemctl status verae-netprint`. On Windows, `sc query VeraeNetPrint`. On macOS, `sudo launchctl print system/com.verae.netprint`. -- Other devices do not see it. Open `http://127.0.0.1:8630/api/v1/status` and read `mdnsMode` / `mdnsError`, and `%ProgramData%\Verae\logs\netprint.log` on Windows. `mdnsMode` `dns-sd` means Windows DNS-SD accepted the printer. Confirm the host has a LAN address and the network is Private, or turn on Allow Public networks. CUPS on the same machine is fine: this printer falls back to port 8632 and still advertises that port. It does not fall back to 8631. +- Other devices do not see it. Open `http://127.0.0.1:8630/api/v1/status` and read `mdnsMode` / `mdnsError`, and `%ProgramData%\Verae\logs\netprint.log` on Windows. `mdnsMode` `dns-sd` means Windows DNS-SD accepted the printer. Confirm the host has a LAN address and the network is Private, or turn on Allow Public networks. On a Mac, CUPS keeps localhost port 631. This printer uses 631 on the LAN addresses (`ippBind` `lan`) or 8632 when that bind does not succeed (`ippBind` `all`, `port` 8632). It does not fall back to 8631. Receipt files are in `receiptsDir`. - Windows setup seemed to hang with `/S`. Every installer dialog has a silent default. Read `%ProgramData%\Verae\logs\netprint-installer.log`. - Jobs fail with an authentication error. Open Settings and save a current `xVerae-` key. The service reads the machine token (`/etc/verae/xverae-token`, the macOS Application Support file, or `%ProgramData%\Verae\PrintStamp\xverae-token`) and the usual per-user token. - Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) and `image/jpeg` are converted to PDF on this machine before they are hashed. PDF is accepted as-is. diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go index 8ca82d8..e53842e 100644 --- a/internal/netprint/airdrop_test.go +++ b/internal/netprint/airdrop_test.go @@ -298,8 +298,10 @@ func TestMacAirDropAndPackageSources(t *testing.T) { {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter"}}, {"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint"}}, - {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin"}}, - {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed"}}, + {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop"}}, + {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop"}}, + {"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop"}}, + {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, {"advertise_darwin.go", []string{`advertiseForOS("darwin"`}}, } diff --git a/internal/netprint/config.go b/internal/netprint/config.go index 2245ba2..f0c88a7 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -149,7 +149,7 @@ func defaultDataDir() string { } if os.Geteuid() == 0 { if runtime.GOOS == "darwin" { - return "/Library/Application Support/Verae/NetPrint" + return darwinSharedReceiptsDir } return "/var/lib/verae-netprint" } diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 180d8e0..1ecb7df 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -50,6 +50,12 @@ type Status struct { Advertise bool `json:"advertise"` AirDropEnabled bool `json:"airDropEnabled"` SupportsAirDrop bool `json:"supportsAirDrop"` + // ReceiptsDir is the folder of job files. On macOS it is separate from + // the program so it can be shared read-only. + ReceiptsDir string `json:"receiptsDir"` + // IPPBind is "lan" when macOS bound 631 on LAN addresses only, or "all" + // when the listener is on every interface (including the 8632 fallback). + IPPBind string `json:"ippBind"` } // ServiceControl is how the service asks the OS to start, stop, or launch at login. @@ -247,6 +253,8 @@ func (s *Server) status() Status { Advertise: cfg.MDNS(), AirDropEnabled: cfg.AirDropOn(), SupportsAirDrop: runtime.GOOS == "darwin", + ReceiptsDir: cfg.DataDir, + IPPBind: s.ippBind, } } @@ -617,6 +625,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { } receipts := htmlEscape(s.receiptsURL()) fmt.Fprintf(&b, `

Phone receipts: %s

`, receipts, receipts) + fmt.Fprintf(&b, `

Receipt files: %s. Share this folder read-only. It does not contain the program.

`, htmlEscape(cfg.DataDir)) fmt.Fprintf(&b, `
diff --git a/internal/netprint/listen.go b/internal/netprint/listen.go index f630355..bd112de 100644 --- a/internal/netprint/listen.go +++ b/internal/netprint/listen.go @@ -5,6 +5,8 @@ import ( "fmt" "log" "net" + "runtime" + "strconv" "strings" "sync" "syscall" @@ -32,6 +34,131 @@ func ippBindHosts(bind string) (v4, v6 string) { return "", ip.String() } +func bindIsWildcard(bind string) bool { + bind = strings.Trim(strings.TrimSpace(bind), "[]") + switch bind { + case "", "0.0.0.0", "::": + return true + default: + return false + } +} + +// lanOnlyIPP makes listenIPP use the macOS CUPS rule on whatever port the +// test chose. Production turns that rule on only for Darwin port 631. +var lanOnlyIPP bool + +// shouldBindLANOnly is the macOS cupsd rule. cupsd owns 127.0.0.1:631 and +// [::1]:631. Binding 0.0.0.0:631 fails, or would take localhost with it. +// Binding each LAN address on 631 leaves those loopback sockets to CUPS. +func shouldBindLANOnly(cfg Config) bool { + if !bindIsWildcard(cfg.Bind) { + return false + } + if lanOnlyIPP { + return true + } + return runtime.GOOS == "darwin" && cfg.Port == 631 +} + +type lanAddr struct { + network string + host string +} + +func lanListenAddrs() ([]lanAddr, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + var out []lanAddr + seen := map[string]bool{} + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ip := addrIP(a) + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() { + continue + } + if v4 := ip.To4(); v4 != nil { + if v4.IsLinkLocalUnicast() { + continue + } + key := "4 " + v4.String() + if seen[key] { + continue + } + seen[key] = true + out = append(out, lanAddr{network: "tcp4", host: v4.String()}) + continue + } + ip = ip.To16() + zone := "" + if ip.IsLinkLocalUnicast() { + zone = ifi.Name + } + key := "6 " + ip.String() + "%" + zone + if seen[key] { + continue + } + seen[key] = true + host := ip.String() + if zone != "" { + host += "%" + zone + } + out = append(out, lanAddr{network: "tcp6", host: host}) + } + } + return out, nil +} + +// listenLAN binds port on each non-loopback address. Localhost is not bound. +func listenLAN(port int) (net.Listener, error) { + addrs, err := lanListenAddrs() + if err != nil { + return nil, err + } + if len(addrs) == 0 { + return nil, fmt.Errorf("no LAN address") + } + var lns []net.Listener + var first error + for _, a := range addrs { + address := net.JoinHostPort(a.host, strconv.Itoa(port)) + var ln net.Listener + var err error + if a.network == "tcp6" { + ln, err = listenTCP6(address) + } else { + ln, err = net.Listen("tcp4", address) + } + if err != nil { + if first == nil { + first = err + } + log.Printf("netprint: %s: %v", address, err) + continue + } + lns = append(lns, ln) + } + if len(lns) == 0 { + if first == nil { + first = fmt.Errorf("no LAN address") + } + return nil, first + } + if len(lns) == 1 { + return lns[0], nil + } + return newMultiListener(lns), nil +} + func listenPort(v4host, v6host string, port int) (net.Listener, error) { var lns []net.Listener if v4host != "" { diff --git a/internal/netprint/listen_test.go b/internal/netprint/listen_test.go index fb332cd..2988879 100644 --- a/internal/netprint/listen_test.go +++ b/internal/netprint/listen_test.go @@ -11,10 +11,13 @@ import ( func TestListenIPPAcceptsIPv4AndLinkLocalIPv6(t *testing.T) { port := freePort(t) - ln, got, err := listenIPP(Config{Port: port}) + ln, got, mode, err := listenIPP(Config{Port: port}) if err != nil { t.Fatal(err) } + if mode != "all" { + t.Fatalf("mode %s", mode) + } t.Cleanup(func() { _ = ln.Close() }) if got != port { t.Fatalf("port %d", got) @@ -50,10 +53,13 @@ func TestListenIPPAcceptsIPv4AndLinkLocalIPv6(t *testing.T) { func TestListenIPPSpecificIPv4StaysIPv4(t *testing.T) { port := freePort(t) - ln, _, err := listenIPP(Config{Bind: "127.0.0.1", Port: port}) + ln, _, mode, err := listenIPP(Config{Bind: "127.0.0.1", Port: port}) if err != nil { t.Fatal(err) } + if mode != "all" { + t.Fatalf("mode %s", mode) + } t.Cleanup(func() { _ = ln.Close() }) if _, ok := ln.(*net.TCPListener); !ok { t.Fatalf("specific bind opened %T", ln) @@ -95,6 +101,63 @@ func TestFirewallRuleAllowsLinkLocal(t *testing.T) { } } +func TestListenLeavesLocalhostForCUPS(t *testing.T) { + lanOnlyIPP = true + t.Cleanup(func() { lanOnlyIPP = false }) + primary := freePort(t) + fallback := freePort(t) + for fallback == primary { + fallback = freePort(t) + } + cups, err := net.Listen("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(primary))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cups.Close() }) + if cups6, err := net.Listen("tcp6", net.JoinHostPort("::1", strconv.Itoa(primary))); err == nil { + t.Cleanup(func() { _ = cups6.Close() }) + } + ln, got, mode, err := listenIPP(Config{Port: primary, FallbackPort: fallback}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + probe, err := net.DialTimeout("tcp4", cups.Addr().String(), time.Second) + if err != nil { + t.Fatalf("localhost:%d no longer belongs to the stand-in CUPS listener: %v", primary, err) + } + _ = probe.Close() + if got == primary { + if mode != "lan" { + t.Fatalf("mode %s", mode) + } + for _, part := range strings.Split(listenerAddrs(ln), " and ") { + host, _, err := net.SplitHostPort(part) + if err != nil { + t.Fatal(err) + } + ip := net.ParseIP(host) + if ip == nil || ip.IsLoopback() { + t.Fatalf("bound loopback %s", part) + } + } + conn, err := net.DialTimeout("tcp", strings.Split(listenerAddrs(ln), " and ")[0], time.Second) + if err != nil { + t.Fatal(err) + } + _ = conn.Close() + return + } + if got != fallback || mode != "all" { + t.Fatalf("port %d mode %s", got, mode) + } + conn, err := net.DialTimeout("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(fallback)), time.Second) + if err != nil { + t.Fatal(err) + } + _ = conn.Close() +} + func firstLinkLocal() (net.IP, string) { ifaces, err := net.Interfaces() if err != nil { diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go index e4e5f21..dd36f11 100644 --- a/internal/netprint/pipeline.go +++ b/internal/netprint/pipeline.go @@ -69,12 +69,12 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s return } dir := filepath.Join(st.Dir(), fmt.Sprintf("job-%d", id)) - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := mkdirReceipts(dir); err != nil { fail(err) return } src := filepath.Join(dir, "document.pdf") - if err := os.WriteFile(src, pdf, 0o600); err != nil { + if err := writeReceiptFile(src, pdf); err != nil { fail(err) return } @@ -110,6 +110,7 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s } rec.State = "completed" rec.Completed = time.Now() + publishReceiptPerms(dir) if err := st.Put(rec); err != nil { log.Printf("netprint: job %d index: %v", id, err) } @@ -179,5 +180,5 @@ func annotateUser(path, user string) { if err != nil { return } - _ = os.WriteFile(path, append(out, '\n'), 0o600) + _ = writeReceiptFile(path, append(out, '\n')) } diff --git a/internal/netprint/receipts_dir.go b/internal/netprint/receipts_dir.go new file mode 100644 index 0000000..ba95fc5 --- /dev/null +++ b/internal/netprint/receipts_dir.go @@ -0,0 +1,120 @@ +package netprint + +import ( + "log" + "os" + "path/filepath" + "runtime" + "strings" +) + +const ( + // legacyDarwinDataDir is the old receipts location, next to the program. + legacyDarwinDataDir = "/Library/Application Support/Verae/NetPrint" + // darwinSharedReceiptsDir is the receipts folder. It is not the folder + // that holds verae-print, so it can be shared read-only over SMB. + darwinSharedReceiptsDir = "/Users/Shared/Verae Receipts" +) + +func receiptDirMode() os.FileMode { + if runtime.GOOS == "darwin" { + return 0o755 + } + return 0o700 +} + +func receiptFileMode() os.FileMode { + if runtime.GOOS == "darwin" { + return 0o644 + } + return 0o600 +} + +func mkdirReceipts(dir string) error { + if err := os.MkdirAll(dir, receiptDirMode()); err != nil { + return err + } + return os.Chmod(dir, receiptDirMode()) +} + +func writeReceiptFile(path string, data []byte) error { + if err := os.WriteFile(path, data, receiptFileMode()); err != nil { + return err + } + return os.Chmod(path, receiptFileMode()) +} + +// publishReceiptPerms makes a finished job readable by the console user on +// macOS. Other systems keep the directory private. +func publishReceiptPerms(dir string) { + if receiptDirMode() == 0o700 { + return + } + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + mode := receiptFileMode() + if info.IsDir() { + mode = receiptDirMode() + } + return os.Chmod(path, mode) + }) +} + +func receiptDataName(name string) bool { + switch name { + case "jobs.json", "next-job", "uuid": + return true + default: + return strings.HasPrefix(name, "job-") + } +} + +// migrateReceiptTree moves job files from oldDir to newDir. Program files, +// including bin/, stay where they are. +func migrateReceiptTree(oldDir, newDir string) error { + if err := mkdirReceipts(newDir); err != nil { + return err + } + entries, err := os.ReadDir(oldDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, e := range entries { + if !receiptDataName(e.Name()) { + continue + } + src := filepath.Join(oldDir, e.Name()) + dst := filepath.Join(newDir, e.Name()) + if _, err := os.Stat(dst); err == nil { + continue + } + if err := os.Rename(src, dst); err != nil { + return err + } + } + return nil +} + +// useSharedReceipts moves a macOS install off the program folder and remembers +// the new path in the config file. +func useSharedReceipts(cfg *Config, configPath string) error { + if runtime.GOOS != "darwin" || filepath.Clean(cfg.DataDir) != filepath.Clean(legacyDarwinDataDir) { + return nil + } + if err := migrateReceiptTree(legacyDarwinDataDir, darwinSharedReceiptsDir); err != nil { + return err + } + cfg.DataDir = darwinSharedReceiptsDir + if strings.TrimSpace(configPath) == "" { + return nil + } + if err := Save(configPath, *cfg); err != nil { + log.Printf("netprint: save receipts dir: %v", err) + } + return nil +} diff --git a/internal/netprint/receipts_dir_test.go b/internal/netprint/receipts_dir_test.go new file mode 100644 index 0000000..cd32dae --- /dev/null +++ b/internal/netprint/receipts_dir_test.go @@ -0,0 +1,104 @@ +package netprint + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSharedReceiptsDirIsNotTheProgramFolder(t *testing.T) { + if darwinSharedReceiptsDir != "/Users/Shared/Verae Receipts" { + t.Fatal(darwinSharedReceiptsDir) + } + if strings.Contains(darwinSharedReceiptsDir, "NetPrint") || strings.Contains(darwinSharedReceiptsDir, "/bin") { + t.Fatal(darwinSharedReceiptsDir) + } + src, err := os.ReadFile("service_darwin.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "darwinSharedReceiptsDir") || strings.Contains(string(src), `ExampleJSON("/Library/Application Support/Verae/NetPrint")`) { + t.Fatal("install still writes receipts beside the binary") + } +} + +func TestMigrateReceiptsLeavesBinaries(t *testing.T) { + old := t.TempDir() + neu := filepath.Join(t.TempDir(), "Verae Receipts") + if err := os.Mkdir(filepath.Join(old, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "bin", "verae-print"), []byte("bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "com.verae.netprint.plist"), []byte("plist"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(old, "job-1"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "job-1", "certificate.pdf"), []byte("%PDF"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "jobs.json"), []byte("[]\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "next-job"), []byte("2\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := migrateReceiptTree(old, neu); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(filepath.Join(old, "bin", "verae-print")); err != nil || string(b) != "bin" { + t.Fatalf("binary moved: %q %v", b, err) + } + if _, err := os.Stat(filepath.Join(old, "com.verae.netprint.plist")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(neu, "bin")); !os.IsNotExist(err) { + t.Fatal("bin was copied into the receipts folder") + } + if b, err := os.ReadFile(filepath.Join(neu, "job-1", "certificate.pdf")); err != nil || string(b) != "%PDF" { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(neu, "jobs.json")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(neu, "next-job")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(old, "jobs.json")); !os.IsNotExist(err) { + t.Fatal("jobs.json still in the program folder") + } +} + +func TestStatusExposesReceiptsDir(t *testing.T) { + dir := t.TempDir() + st, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + s := &Server{ + cfg: Config{DataDir: dir, Name: "Verae Timestamp"}, + store: st, + ctrl: &fakeCtrl{}, + port: 631, + ippBind: "lan", + } + got := s.status() + if got.ReceiptsDir != dir { + t.Fatal(got.ReceiptsDir) + } + if got.IPPBind != "lan" { + t.Fatal(got.IPPBind) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"receiptsDir"`) || strings.Contains(string(b), "netprint-secrets") { + t.Fatal(string(b)) + } +} diff --git a/internal/netprint/server.go b/internal/netprint/server.go index ba76f88..57892b3 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -35,6 +35,7 @@ type Server struct { emailErr string uuid string jobs jobBus + ippBind string } // Run loads config, listens, and blocks until ctx is canceled. @@ -64,6 +65,9 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) if err := cfg.normalize(); err != nil { return nil, err } + if err := useSharedReceipts(&cfg, configPath); err != nil { + return nil, err + } st, err := OpenStore(cfg.DataDir) if err != nil { return nil, err @@ -84,11 +88,12 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) AllocateID: st.AllocateID, }, } - ippLn, port, err := listenIPP(cfg) + ippLn, port, bindMode, err := listenIPP(cfg) if err != nil { return nil, err } s.port = port + s.ippBind = bindMode ctlLn, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ControlPort)) if err != nil { _ = ippLn.Close() @@ -169,7 +174,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) go func() { _ = s.ippSrv.Serve(ippLn) }() go func() { _ = s.controlSrv.Serve(ctlLn) }() s.syncAdvertiser() - log.Printf("netprint: IPP on %s, control on %s", listenerAddrs(ippLn), ctlLn.Addr()) + log.Printf("netprint: IPP on %s (%s), receipts %s, control on %s", listenerAddrs(ippLn), s.ippBind, cfg.DataDir, ctlLn.Addr()) return s, nil } @@ -302,14 +307,27 @@ func ippListenPorts(cfg Config) []int { return out } -func listenIPP(cfg Config) (net.Listener, int, error) { - v4host, v6host := ippBindHosts(cfg.Bind) +func listenIPP(cfg Config) (net.Listener, int, string, error) { try := ippListenPorts(cfg) + if shouldBindLANOnly(cfg) && len(try) > 0 { + primary := try[0] + ln, err := listenLAN(primary) + if err == nil { + log.Printf("netprint: port %d on LAN interfaces; localhost:%d left for CUPS", primary, primary) + return ln, primary, "lan", nil + } + log.Printf("netprint: LAN port %d: %v", primary, err) + try = try[1:] + } + v4host, v6host := ippBindHosts(cfg.Bind) var last error for _, port := range try { ln, err := listenPort(v4host, v6host, port) if err == nil { - return ln, port, nil + if shouldBindLANOnly(cfg) { + log.Printf("netprint: port %d (fallback); localhost CUPS kept %d", port, cfg.Port) + } + return ln, port, "all", nil } last = err log.Printf("netprint: port %d: %v", port, err) @@ -317,7 +335,7 @@ func listenIPP(cfg Config) (net.Listener, int, error) { if last == nil { last = errors.New("no port") } - return nil, 0, fmt.Errorf("listen: %w", last) + return nil, 0, "", fmt.Errorf("listen: %w", last) } func loadUUID(dir string) string { @@ -328,7 +346,7 @@ func loadUUID(dir string) string { } } id := newUUID() - _ = os.MkdirAll(dir, 0o700) - _ = os.WriteFile(path, []byte(id+"\n"), 0o644) + _ = mkdirReceipts(dir) + _ = writeReceiptFile(path, []byte(id+"\n")) return id } diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index 4a688ec..bfe5118 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -148,8 +148,12 @@ func Install(owner, tokenFile string, opts InstallOptions) error { if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { return err } + if err := os.MkdirAll(darwinSharedReceiptsDir, 0o755); err != nil { + return err + } + _ = os.Chmod(darwinSharedReceiptsDir, 0o755) if _, err := os.Stat(cfgPath); os.IsNotExist(err) { - if err := os.WriteFile(cfgPath, ExampleJSON("/Library/Application Support/Verae/NetPrint"), 0o644); err != nil { + if err := os.WriteFile(cfgPath, ExampleJSON(darwinSharedReceiptsDir), 0o644); err != nil { return err } } diff --git a/internal/netprint/store.go b/internal/netprint/store.go index 799906c..220279b 100644 --- a/internal/netprint/store.go +++ b/internal/netprint/store.go @@ -41,7 +41,7 @@ type Store struct { } func OpenStore(dir string) (*Store, error) { - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := mkdirReceipts(dir); err != nil { return nil, err } s := &Store{dir: dir} @@ -103,8 +103,11 @@ func (s *Store) AllocateID() (int, error) { next := s.highWaterLocked() for { dir := filepath.Join(s.dir, fmt.Sprintf("job-%d", next)) - err := os.Mkdir(dir, 0o700) + err := os.Mkdir(dir, receiptDirMode()) if err == nil { + if cerr := os.Chmod(dir, receiptDirMode()); cerr != nil { + return 0, cerr + } if werr := s.writeSeq(next + 1); werr != nil { return 0, werr } @@ -160,7 +163,7 @@ func (s *Store) readSeq() (int, error) { func (s *Store) writeSeq(n int) error { tmp := s.seqPath() + ".tmp" - if err := os.WriteFile(tmp, []byte(strconv.Itoa(n)+"\n"), 0o600); err != nil { + if err := writeReceiptFile(tmp, []byte(strconv.Itoa(n)+"\n")); err != nil { return err } return os.Rename(tmp, s.seqPath()) @@ -185,7 +188,7 @@ func (s *Store) flush() error { return err } tmp := s.path() + ".tmp" - if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + if err := writeReceiptFile(tmp, append(b, '\n')); err != nil { return err } return os.Rename(tmp, s.path()) diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 904846e..c9fb1a2 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Standalone Verae Network Printer .pkg. -# Linux CI writes VeraeNetPrint-macos-arm64-.pkg. It is not signed. -# On a Mac with pkgbuild this produces an installer. Signing happens only when -# signing.env is present (same file as the driver package). Without pkgbuild -# (Linux CI) the caller still ships the darwin binaries and this script. +# Built with pkgbuild and productbuild on macOS (the GitHub macos-14 runner). +# The package is unsigned until a Developer ID Installer certificate is present. +# Linux does not write a .pkg: a xar built there fails pkgutil with +# "Checksums do not match". +# CI package name: VeraeNetPrint-macos-arm64-0.3.4.pkg set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" @@ -34,9 +35,12 @@ cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Libr cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" chmod 644 "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" -# swiftc on Linux cannot import AppKit. The menu-bar binary opens the same -# share sheet itself when verae-airdrop is absent, so Linux CI skips the helper. -if [[ "$(uname -s)" == "Darwin" ]] && command -v swiftc >/dev/null 2>&1; then +# swiftc on Linux cannot import AppKit. The macOS runner always builds the helper. +if [[ "$(uname -s)" == "Darwin" ]]; then + if ! command -v swiftc >/dev/null 2>&1; then + echo "swiftc is required to build verae-airdrop" >&2 + exit 1 + fi swiftc -O -o "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" \ "$ROOT/platform/macos/airdrop/main.swift" chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" @@ -60,31 +64,51 @@ EOF chmod 755 "$SCRIPTS/preinstall" "$SCRIPTS/postinstall" "$SCRIPTS/bootout.sh" PKG_NAME="VeraeNetPrint-macos-${ARCH}-${VERSION}.pkg" -if ! command -v pkgbuild >/dev/null 2>&1; then - echo "pkgbuild not available — building unsigned $PKG_NAME" +if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null 2>&1; then + echo "pkgbuild/productbuild not available — payload only; the package is built on macos-14" tar -C "$OUT" -czf "$OUT/VeraeNetPrint-macos-payload.tar.gz" payload scripts - "$ROOT/platform/macos/pkg/build-unsigned-pkg.sh" "$STAGE" "$SCRIPTS" "$OUT/$PKG_NAME" "$VERSION" - echo "built $OUT/$PKG_NAME (unsigned)" exit 0 fi +if [[ ! -x "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" ]]; then + echo "verae-airdrop missing from the package root" >&2 + exit 1 +fi +COMPONENT="$OUT/netprint-component.pkg" +rm -f "$COMPONENT" "$OUT/$PKG_NAME" pkgbuild \ --root "$STAGE" \ --scripts "$SCRIPTS" \ --identifier "ai.verae.netprint.standalone" \ --version "$VERSION" \ --install-location "/" \ - "$OUT/$PKG_NAME" -cp -f "$OUT/$PKG_NAME" "$OUT/VeraeNetPrint-macos.pkg" + "$COMPONENT" + +DIST="$OUT/distribution-standalone.xml" +cat >"$DIST" < + + Verae Network Printer + + + + + + + + + netprint-component.pkg + +EOF SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}" if [[ -f "$SIGNING_ENV" ]]; then # shellcheck source=/dev/null source "$SIGNING_ENV" ID_PKG="${APPLE_INSTALLER_SIGN_ID:-Developer ID Installer: FlightLookup Inc. (G8CKY7VMZC)}" - productbuild --package "$OUT/VeraeNetPrint-macos.pkg" --sign "$ID_PKG" --timestamp \ - "$OUT/VeraeNetPrint-macos-signed.pkg" - mv -f "$OUT/VeraeNetPrint-macos-signed.pkg" "$OUT/$PKG_NAME" - cp -f "$OUT/$PKG_NAME" "$OUT/VeraeNetPrint-macos.pkg" + productbuild --distribution "$DIST" --package-path "$OUT" --sign "$ID_PKG" --timestamp "$OUT/$PKG_NAME" +else + productbuild --distribution "$DIST" --package-path "$OUT" "$OUT/$PKG_NAME" fi +rm -f "$COMPONENT" echo "built $OUT/$PKG_NAME" diff --git a/platform/macos/pkg/check-pkg.sh b/platform/macos/pkg/check-pkg.sh new file mode 100644 index 0000000..d995b63 --- /dev/null +++ b/platform/macos/pkg/check-pkg.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Confirm a pkgbuild/productbuild package is an Apple package. +# pkgutil --expand fails with "Checksums do not match" on the Linux xar. +set -euo pipefail +PKG="${1:?package path}" +if [[ ! -f "$PKG" ]]; then + echo "missing $PKG" >&2 + exit 1 +fi +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +pkgutil --expand "$PKG" "$WORKDIR/expanded" +pkgutil --expand-full "$PKG" "$WORKDIR/full" + +set +e +sig="$(pkgutil --check-signature "$PKG" 2>&1)" +sigrc=$? +set -e +printf '%s\n' "$sig" +if [[ $sigrc -ne 0 ]] && ! grep -qi 'no signature' <<<"$sig"; then + echo "pkgutil --check-signature failed" >&2 + exit 1 +fi + +info="$(installer -pkginfo -pkg "$PKG" 2>&1)" +printf '%s\n' "$info" +if [[ -z "$info" ]]; then + echo "installer -pkginfo returned nothing" >&2 + exit 1 +fi + +bom="$(find "$WORKDIR/full" -name Bom -print -quit)" +if [[ -z "$bom" ]]; then + echo "expanded package has no Bom" >&2 + exit 1 +fi +lsbom "$bom" >"$WORKDIR/bom.txt" +grep -q 'verae-airdrop' "$WORKDIR/bom.txt" +grep -q 'LaunchAgents' "$WORKDIR/bom.txt" +grep -q 'LaunchDaemons' "$WORKDIR/bom.txt" +echo "package ok: $PKG" diff --git a/scripts/build-netprint-installers.sh b/scripts/build-netprint-installers.sh index d880655..4edb25f 100755 --- a/scripts/build-netprint-installers.sh +++ b/scripts/build-netprint-installers.sh @@ -86,7 +86,11 @@ cp -f platform/macos/netprint/scripts/postinstall "$OUT/macos/postinstall-netpri ( cd "$OUT" - sha256sum *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh 2>/dev/null | tee SHA256SUMS || true + shopt -s nullglob + files=( *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh ) + if [[ ${#files[@]} -gt 0 ]]; then + sha256sum "${files[@]}" | tee SHA256SUMS + fi ) echo echo "Artifacts in $OUT" From f96ade41bd7303063ddeab00f4ca856ae6ead74e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 02:09:48 +0000 Subject: [PATCH 21/27] Ship the Mac menu bar as an app bundle and use one real stamp path. The tray and AirDrop helper now live in Verae NetPrint.app so notifications can start, /api/stamp uses the same client as Print-Job and refuses mock receipts, and the default printer name includes the hostname with an mDNS rename when that name is taken. Co-authored-by: George Lambert --- .github/workflows/installers.yml | 4 +- cmd/verae-print/installer_cmds_test.go | 19 ++ cmd/verae-print/main.go | 2 +- cmd/verae-print/netprint_cmd.go | 16 +- cmd/verae-print/serve.go | 10 + cmd/verae-print/stampcore.go | 20 +- docs/network-printer.md | 18 +- internal/job/job.go | 17 ++ internal/job/job_test.go | 33 +++ internal/netprint/airdrop_darwin.go | 36 ++- internal/netprint/airdrop_test.go | 8 +- internal/netprint/config.go | 60 ++++- internal/netprint/control.go | 3 +- internal/netprint/dnsservice.go | 207 ++++++++++++++---- internal/netprint/dnsservice_darwin.go | 97 +++++++- internal/netprint/mdns_dnssd_test.go | 40 ++++ internal/netprint/name_test.go | 54 +++++ internal/netprint/pipeline.go | 24 ++ internal/netprint/server.go | 29 +++ internal/netprint/service_darwin.go | 23 +- internal/netprint/stamp_api_test.go | 50 +++++ .../netprint/com.verae.netprint.tray.plist | 2 +- platform/macos/pkg/build-netprint-pkg.sh | 32 ++- platform/macos/pkg/check-pkg.sh | 9 + 24 files changed, 717 insertions(+), 96 deletions(-) create mode 100644 internal/netprint/name_test.go diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 263a0d4..2e5bdcb 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -43,7 +43,9 @@ jobs: chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/check-pkg.sh VERSION="$VERSION" BIN="$OUT/verae-print" PKG_ARCH=arm64 OUT="$OUT" ./platform/macos/pkg/build-netprint-pkg.sh cp -f "$OUT/verae-print" "$OUT/verae-print-arm64" - test -x "$OUT/payload/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" + test -x "$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS/verae-airdrop" + test -x "$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS/verae-print" + test -f "$OUT/payload/Applications/Verae NetPrint.app/Contents/Info.plist" test -f "$OUT/VeraeNetPrint-macos-arm64-${VERSION}.pkg" - name: pkgutil --expand, pkgutil --check-signature, and installer -pkginfo run: bash platform/macos/pkg/check-pkg.sh build/netprint/macos/VeraeNetPrint-macos-arm64-0.3.4.pkg diff --git a/cmd/verae-print/installer_cmds_test.go b/cmd/verae-print/installer_cmds_test.go index fb1fb44..9d3ac4e 100644 --- a/cmd/verae-print/installer_cmds_test.go +++ b/cmd/verae-print/installer_cmds_test.go @@ -4,10 +4,29 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "unicode/utf16" ) +func TestNetworkStampUsesDesktopPath(t *testing.T) { + netprintSrc, err := os.ReadFile("netprint_cmd.go") + if err != nil { + t.Fatal(err) + } + core, err := os.ReadFile("stampcore.go") + if err != nil { + t.Fatal(err) + } + text := string(netprintSrc) + if !strings.Contains(text, "stampWithClient(") || strings.Contains(text, "mock-cert") || strings.Contains(text, `DocumentName: "document.pdf"`) { + t.Fatal("netprint stamp is not the desktop stamp path") + } + if !strings.Contains(string(core), "func stampWithClient(") || !strings.Contains(string(core), "stampWithClient(pdf, t.Title") { + t.Fatal("stampFile does not use stampWithClient") + } +} + func TestNetprintUnknownCommandDoesNotStartServer(t *testing.T) { if code := runNetprint([]string{"launch-server"}); code != 2 { t.Fatalf("unknown command exit %d, want 2", code) diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index 3e5b04c..c62d16e 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -21,7 +21,7 @@ func main() { if len(os.Args) < 2 && isHelperExe() { os.Args = append(os.Args, "serve") } - if len(os.Args) < 2 && isTrayExe() { + if len(os.Args) < 2 && (isTrayExe() || isMenuBarApp()) { os.Args = append(os.Args, "tray") } if len(os.Args) < 2 { diff --git a/cmd/verae-print/netprint_cmd.go b/cmd/verae-print/netprint_cmd.go index 00a5586..5c8257a 100644 --- a/cmd/verae-print/netprint_cmd.go +++ b/cmd/verae-print/netprint_cmd.go @@ -10,7 +10,6 @@ import ( "strings" "syscall" - "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/netprint" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" @@ -110,18 +109,7 @@ func runTrayCmd() int { } func stampNetwork(pdf, title, user string) (job.Result, error) { - _ = title _ = user - lj := vlog.Job(vlog.NewJobID("net")) - client, ch, err := clientForChannel(auth.PreferredChannel(), lj) - if err != nil { - return job.Result{}, err - } - return job.Run(job.Options{ - SourcePDF: pdf, - OutDir: filepath.Dir(pdf), - DocumentName: "document.pdf", - Channel: ch, - Client: client, - }) + // Same client, document name, and job.Run call as a desktop Print-Job. + return stampWithClient(pdf, title, filepath.Dir(pdf), "", vlog.Job(vlog.NewJobID("net"))) } diff --git a/cmd/verae-print/serve.go b/cmd/verae-print/serve.go index 9a1a32a..3b9ab13 100644 --- a/cmd/verae-print/serve.go +++ b/cmd/verae-print/serve.go @@ -36,6 +36,16 @@ func isTrayExe() bool { return strings.Contains(strings.ToLower(filepath.Base(exe)), "netprint-tray") } +// isMenuBarApp reports whether this process is the macOS menu-bar bundle. +// Opening Verae NetPrint.app with no arguments starts the tray. +func isMenuBarApp() bool { + exe, err := os.Executable() + if err != nil { + return false + } + return strings.Contains(filepath.Clean(exe), "Verae NetPrint.app"+string(filepath.Separator)) +} + // fallbackDropDir is the folder the "Verae TimeStamp PDF (Local Port)" queue writes into. func fallbackDropDir() string { if v := os.Getenv("VERAE_FALLBACK_DIR"); v != "" { diff --git a/cmd/verae-print/stampcore.go b/cmd/verae-print/stampcore.go index f2d80f8..566361b 100644 --- a/cmd/verae-print/stampcore.go +++ b/cmd/verae-print/stampcore.go @@ -36,15 +36,29 @@ func clientForChannel(ch auth.Channel, lj vlog.Job) (*veraeapi.Client, auth.Chan return client, ch, nil } -// stampFile stamps one PDF with the current key (no holding / prompting). -func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) { +// stampWithClient is the real stamp used by the desktop driver, Print-Job, and +// POST /api/stamp. It hashes the file and calls the Verae client. It does not +// invent a receipt. +func stampWithClient(pdf, title, outDir, outPath string, lj vlog.Job) (job.Result, error) { client, ch, err := clientForChannel(auth.PreferredChannel(), lj) if err != nil { return job.Result{}, err } client.HTTPDo = loggingDo(lj) + return job.Run(job.Options{ + SourcePDF: pdf, + OutDir: outDir, + OutPath: outPath, + DocumentName: safeStem(title) + ".pdf", + Channel: ch, + Client: client, + }) +} + +// stampFile stamps one PDF with the current key (no holding / prompting). +func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) { start := time.Now() - res, err := job.Run(job.Options{SourcePDF: pdf, OutDir: t.OutDir, OutPath: t.OutPath, DocumentName: safeStem(t.Title) + ".pdf", Channel: ch, Client: client}) + res, err := stampWithClient(pdf, t.Title, t.OutDir, t.OutPath, lj) if err != nil { return res, err } diff --git a/docs/network-printer.md b/docs/network-printer.md index 1bf532d..9656b82 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -1,6 +1,6 @@ # Verae network timestamp printer -Optional IPP Everywhere printer. Phones and other computers on the LAN print to **Verae Timestamp**. This machine hashes the document, registers the SHA-256 with Verae the same way the desktop drivers do, and keeps the receipt. Document bytes are not uploaded. +Optional IPP Everywhere printer. Phones and other computers on the LAN print to it. The default name is **Verae Timestamp** plus this computer's name, for example **Verae Timestamp (Georges-MacBook-Pro)**. Settings can change it. A saved name that is still the old shared **Verae Timestamp** is upgraded on the next start. If that name is already registered on the network, mDNS renames it to **Name (2)** and the new name is saved. This machine hashes the document, registers the SHA-256 with Verae the same way the desktop drivers do, and keeps the receipt. Document bytes are not uploaded. The printer is off until you install the standalone package or turn on the driver installer option. Both paths install one service and one tray / menu-bar control. They do not share an install folder: the standalone package never overwrites the driver binary. If both are installed, the service runs whichever binary is the same version or newer, and removing one leaves the other in place. @@ -9,7 +9,7 @@ The printer is off until you install the standalone package or turn on the drive | Standalone installer | `VeraeNetPrint-windows-*-setup.exe` | `VeraeNetPrint-macos-arm64-0.3.4.pkg` | `verae-netprint_*_amd64.deb` | | Driver installer option | Unchecked checkbox on the options page | Customize → “Network timestamp printer” (unselected) | debconf “Enable the Verae network timestamp printer?” (default no), or `apt install verae-netprint` | | Service | `VeraeNetPrint` | LaunchDaemon `com.verae.netprint` | `verae-netprint.service` | -| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | LaunchAgent `com.verae.netprint.tray` (`verae-print tray`, Aqua session) | `verae-print tray` (StatusNotifier / AppIndicator) | +| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | LaunchAgent `com.verae.netprint.tray` (`/Applications/Verae NetPrint.app`, Aqua session) | `verae-print tray` (StatusNotifier / AppIndicator) | | Standalone command | `%ProgramFiles%\Verae\NetPrint\bin\verae-print.exe` | `/Library/Application Support/Verae/NetPrint/bin/verae-print` | `/usr/lib/verae-netprint/verae-print` | | Driver command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/lib/verae-print/verae-print` | | CLI on `PATH` | the driver binary when the driver is installed | `/usr/local/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | `/usr/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If `DNSServiceRegister` fails, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. On macOS, cupsd keeps `127.0.0.1:631` and `[::1]:631`. This printer binds 631 only on the Mac's non-loopback addresses, so printing from another device uses port 631 and localhost CUPS is unchanged. mDNS advertises 631. If none of those LAN binds succeed, it listens on 8632 on every interface and advertises 8632. Status JSON `ippBind` is `lan` or `all`, and `port` is the port that was bound. On Windows and Linux the listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, trying 631 and falling back to 8632 when 631 is taken. It does not use 8631. A connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. +3. On another device, add the printer. Its default name includes the computer, for example **Verae Timestamp (Georges-MacBook-Pro)**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print` and `kDNSServiceFlagsNoAutoRename`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If the name is already in use, the callback reports the conflict and both services are registered again as **Name (2)**. That name is saved and becomes the IPP printer name. If `DNSServiceRegister` fails for another reason, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. On macOS, cupsd keeps `127.0.0.1:631` and `[::1]:631`. This printer binds 631 only on the Mac's non-loopback addresses, so printing from another device uses port 631 and localhost CUPS is unchanged. mDNS advertises 631. If none of those LAN binds succeed, it listens on 8632 on every interface and advertises 8632. Status JSON `ippBind` is `lan` or `all`, and `port` is the port that was bound. On Windows and Linux the listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, trying 631 and falling back to 8632 when 631 is taken. It does not use 8631. A connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to `http://:/receipts` (the AirPrint admin URL). The page lists recent jobs, newest first, with the stamped PDF and the original. Each job also has a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, whether discovery is advertising, and the receipts URL. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, turn **Email stamped PDFs** on or off, open recent receipts, and open settings (printer name, forward-to printer, signing key, mail, and the receipts PIN). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -39,7 +39,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Stamp and return -`POST http://:/api/stamp` accepts a PDF or JPEG, either as the raw body (set `X-Filename` or `Content-Disposition` for the title) or as multipart with a `file` field. It runs the same pipeline as a print job: the job is stored, it shows on the receipts page, and it is emailed when email is on. The response is the stamped certificate PDF. Headers `X-Verae-Receipt-Id`, `X-Verae-SHA256`, and `X-Verae-Receipt-JSON` identify the receipt. `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. The same Private and Domain rule and the same PIN apply. Send the PIN as `?access=`, the `X-Verae-Access` header, or `Authorization: Bearer`. A failure does not return a PDF. +`POST http://:/api/stamp` accepts a PDF or JPEG, either as the raw body (set `X-Filename` or `Content-Disposition` for the title) or as multipart with a `file` field. It runs the same pipeline as Print-Job, including the same `stampWithClient` call the desktop driver uses (`job.Run` against the Verae client, with the document title as the file name). The job is stored, it shows on the receipts page, and it is emailed when email is on. The response is the stamped certificate PDF. Headers `X-Verae-Receipt-Id`, `X-Verae-SHA256`, and `X-Verae-Receipt-JSON` identify the receipt. `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. A receipt id beginning with `mock-cert` is refused, so a test double is not returned outside test builds. The same Private and Domain rule and the same PIN apply. Send the PIN as `?access=`, the `X-Verae-Access` header, or `Authorization: Bearer`. A failure does not return a PDF. The server also advertises `_verae._tcp` (PTR, SRV, and TXT `path=/api/stamp`) beside the IPP printer. That record is a different service name, so it does not add a second `_ipp._tcp` advertisement. While Windows DNS-SD owns the printer, the shared UDP socket still answers only the AirPrint subtype PTRs for `_ipp._tcp` and does not add IPP SRV, TXT, or address records. The `_verae._tcp` SRV and TXT go out on that socket because DNS-SD does not register that name. Address records stay with the printer advertisement. @@ -61,10 +61,10 @@ On macOS, the Application Firewall is per application (System Settings → Netwo The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. GitHub Actions builds it on a `macos-14` runner with `pkgbuild` and `productbuild`, then runs `pkgutil --expand`, `pkgutil --check-signature`, and `installer -pkginfo`. It installs: -- `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 binary) -- `/Library/Application Support/Verae/NetPrint/bin/verae-airdrop` (the Swift AirDrop helper, built on that Mac) -- LaunchDaemon `com.verae.netprint` (root, the print server) -- LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `verae-print tray` as the logged-in user +- `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 daemon binary) +- `/Applications/Verae NetPrint.app` (the menu-bar app: `Contents/Info.plist` with bundle id `ai.verae.netprint`, `LSUIElement`, and `NSLocalNetworkUsageDescription`; `Contents/MacOS/verae-print` and `Contents/MacOS/verae-airdrop`) +- LaunchDaemon `com.verae.netprint` (root, the print server, the binary under Application Support) +- LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `/Applications/Verae NetPrint.app/Contents/MacOS/verae-print tray` as the logged-in user Receipts and job files go to `/Users/Shared/Verae Receipts`, not next to those binaries. The package from CI is not signed and not notarized. macOS blocks a normal double-click until the package is signed with a Developer ID Installer certificate and notarized. To install this unsigned package, right-click it in Finder and choose Open, then Open again. Or, in Terminal: @@ -78,7 +78,7 @@ sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off until you turn it on. When a job completes, the daemon records it and tells the menu-bar app on `GET http://127.0.0.1:8630/api/v1/events` (an event stream on the loopback control API). The agent downloads the certificate and the receipt JSON through that API and opens the AirDrop share sheet with both files, using `NSSharingService(named: .sendViaAirDrop)`. A notification shows the receipt ID and an **AirDrop again** action. The same action is on the menu. The root daemon never opens the sheet. -`platform/macos/airdrop/main.swift` is that share sheet as `verae-airdrop`. The macOS package build compiles it with `swiftc` and installs it next to `verae-print`. The menu-bar app launches that helper. macOS also asks for notification permission the first time the agent posts **AirDrop again**. +`platform/macos/airdrop/main.swift` is that share sheet as `verae-airdrop`. The macOS package build compiles it with `swiftc` and installs it inside `Verae NetPrint.app`, next to the tray executable. The menu-bar app launches that helper. macOS asks for notification permission the first time the agent posts **AirDrop again**. `UNUserNotificationCenter` is used only when the process is that app bundle (`bundleIdentifier` set and the bundle path ends in `.app`). A bare binary skips the call instead of crashing, because `currentNotificationCenter` throws when `mainBundle` is a directory. The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer. diff --git a/internal/job/job.go b/internal/job/job.go index 0a21d4e..c761a2e 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -43,6 +43,20 @@ type Options struct { var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`) +// RefuseMockReceipt rejects a test double. Print-Job and /api/stamp both +// call this. A receipt id with the mock-cert prefix is not a Verae receipt, +// and it is refused outside test builds that opt into a real id. +func RefuseMockReceipt(id, merkle string) error { + lid := strings.ToLower(strings.TrimSpace(id)) + if strings.HasPrefix(lid, "mock-cert") || strings.HasPrefix(lid, "mock-") { + return fmt.Errorf("mock receipt %s is not used outside test builds", strings.TrimSpace(id)) + } + if strings.TrimSpace(merkle) == "42" && strings.Contains(lid, "mock") { + return fmt.Errorf("mock receipt %s is not used outside test builds", strings.TrimSpace(id)) + } + return nil +} + // SanitizeReceiptID makes a receipt id safe for filenames. func SanitizeReceiptID(id string) string { id = strings.TrimSpace(id) @@ -154,6 +168,9 @@ func Run(opt Options) (Result, error) { if err != nil { return Result{}, err } + if err := RefuseMockReceipt(rec.ReceiptID, rec.Merkle); err != nil { + return Result{}, err + } if rec.ReceiptID == "" { return Result{}, fmt.Errorf("stamp returned empty receipt id") } diff --git a/internal/job/job_test.go b/internal/job/job_test.go index 9660123..d8392a3 100644 --- a/internal/job/job_test.go +++ b/internal/job/job_test.go @@ -13,6 +13,39 @@ import ( "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" ) +func TestRunRejectsMockReceiptOutsideTestBuilds(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.pdf") + if err := os.WriteFile(src, []byte("%PDF-1.4 hello"), 0o600); err != nil { + t.Fatal(err) + } + client := &veraeapi.Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.test", + XVeraeToken: "xVerae-x", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body := `{"receipt_id":"mock-cert-abc","merkle":"42","created_at":"2026-10-05T00:00:00Z"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }, + } + _, err := Run(Options{SourcePDF: src, OutDir: filepath.Join(dir, "out"), DocumentName: "Contract.pdf", Channel: auth.ChannelZapier, Client: client}) + if err == nil || !strings.Contains(err.Error(), "mock receipt") { + t.Fatal(err) + } + real := &veraeapi.Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.test", + XVeraeToken: "xVerae-x", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body := `{"receipt_id":"bafyRealBlock","merkle":"42"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }, + } + if _, err := Run(Options{SourcePDF: src, OutDir: filepath.Join(dir, "real"), DocumentName: "Contract.pdf", Channel: auth.ChannelZapier, Client: real}); err != nil { + t.Fatal(err) + } +} + func TestSanitizeAndStem(t *testing.T) { if SanitizeReceiptID("bafy/xyz!") != "bafy-xyz" { t.Fatal(SanitizeReceiptID("bafy/xyz!")) diff --git a/internal/netprint/airdrop_darwin.go b/internal/netprint/airdrop_darwin.go index 8473fb6..7958f98 100644 --- a/internal/netprint/airdrop_darwin.go +++ b/internal/netprint/airdrop_darwin.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "sync" "unsafe" @@ -111,7 +112,7 @@ func notifyAirDrop(o AirDropOffer) { } func postAirDropNote(o AirDropOffer) { - if !notesReady { + if !notesReady || !notificationsAllowed() { return } center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) @@ -163,6 +164,12 @@ type blockDescriptor struct { } func prepareAirDropNotifications() { + // UNUserNotificationCenter throws NSInternalInconsistencyException when + // mainBundle is a directory (bundleProxyForCurrentProcess is nil). Skip + // the call unless this process is inside a real .app. + if !notificationsAllowed() { + return + } if _, err := purego.Dlopen("/System/Library/Frameworks/UserNotifications.framework/UserNotifications", 1); err != nil { log.Printf("netprint: UserNotifications: %v", err) return @@ -189,7 +196,34 @@ func prepareAirDropNotifications() { notesReady = true } +var noteSkip sync.Once + +// notificationsAllowed reports whether UserNotifications can be used. +// currentNotificationCenter crashes when the process is not an app bundle. +func notificationsAllowed() bool { + bundle := cls("NSBundle").Send(objc.RegisterName("mainBundle")) + path := "" + ident := "" + if bundle != 0 { + if url := bundle.Send(objc.RegisterName("bundleURL")); url != 0 { + path = objcUTF8(url.Send(objc.RegisterName("path"))) + } + ident = objcUTF8(bundle.Send(objc.RegisterName("bundleIdentifier"))) + } + if ident != "" && strings.HasSuffix(path, ".app") { + return true + } + noteSkip.Do(func() { + log.Printf("netprint: notifications skipped; bundleProxyForCurrentProcess is nil (%s)", path) + }) + return false +} + func registerAirDropCategory() { + if !notificationsAllowed() { + notesReady = false + return + } center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) if center == 0 || trayTarget == 0 { return diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go index e53842e..6582db3 100644 --- a/internal/netprint/airdrop_test.go +++ b/internal/netprint/airdrop_test.go @@ -295,11 +295,11 @@ func TestMacAirDropAndPackageSources(t *testing.T) { file string need []string }{ - {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter"}}, + {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter", "notificationsAllowed", "bundleIdentifier"}}, {"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, - {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint"}}, - {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop"}}, - {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop"}}, + {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint", "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print"}}, + {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint"}}, + {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "Verae NetPrint.app", "LSUIElement"}}, {"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop"}}, {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, diff --git a/internal/netprint/config.go b/internal/netprint/config.go index f0c88a7..34bbe92 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -73,6 +73,10 @@ const ( DefaultEmailTo = "marchon@gmail.com" // DefaultSMTPHost is Gmail's submission server. An app password works with STARTTLS. DefaultSMTPHost = "smtp.gmail.com" + // legacyServiceName is the old shared default. Two hosts that both use it + // advertise the same Bonjour instance. An empty name, and a saved config + // that still has exactly this string, become defaultServiceName. + legacyServiceName = "Verae Timestamp" ) // PublicNetworks reports whether Public firewall profiles should be allowed. @@ -122,7 +126,7 @@ func Defaults() Config { emailOff := false airDropOff := false return Config{ - Name: "Verae Timestamp", + Name: defaultServiceName(), Port: 631, FallbackPort: DefaultFallbackPort, ControlPort: DefaultControlPort, @@ -207,12 +211,60 @@ func Load(explicitPath string, flags Config) (Config, error) { } } mergeConfig(&out, flags) + applyDefaultName(&out) if err := out.normalize(); err != nil { return Config{}, err } return out, nil } +// defaultServiceName is unique per computer, for example +// "Verae Timestamp (Georges-MacBook-Pro)". Settings can replace it. +func defaultServiceName() string { + host := hostLabel("") + if host == "" { + return legacyServiceName + } + return legacyServiceName + " (" + host + ")" +} + +// applyDefaultName fills an empty name and upgrades the old shared default. +// Any other saved name is kept. +func applyDefaultName(c *Config) { + name := strings.TrimSpace(c.Name) + if name == "" || name == legacyServiceName { + c.Name = defaultServiceName() + } +} + +// persistQualifiedName writes the host-qualified name when the file on disk +// still has the old shared default (or no name). A name the user chose is left +// as it was saved. +func persistQualifiedName(path string, cfg Config) error { + if strings.TrimSpace(path) == "" { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var disk Config + if err := json.Unmarshal([]byte(strings.TrimPrefix(string(b), "\uFEFF")), &disk); err != nil { + return err + } + diskName := strings.TrimSpace(disk.Name) + if diskName != "" && diskName != legacyServiceName { + return nil + } + if diskName == strings.TrimSpace(cfg.Name) { + return nil + } + return Save(path, cfg) +} + func mergeFile(dst *Config, path string) error { b, err := os.ReadFile(path) if err != nil { @@ -292,8 +344,8 @@ func mergeConfig(dst *Config, src Config) { } func (c *Config) normalize() error { - if c.Name == "" { - c.Name = "Verae Timestamp" + if strings.TrimSpace(c.Name) == "" { + c.Name = defaultServiceName() } if c.Port <= 0 || c.Port > 65535 { return fmt.Errorf("port must be 1-65535") @@ -357,7 +409,7 @@ func ExampleJSON(dataDir string) []byte { emailOff := false airDropOff := false c := Config{ - Name: "Verae Timestamp", + Name: defaultServiceName(), Port: 631, FallbackPort: DefaultFallbackPort, ControlPort: DefaultControlPort, diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 1ecb7df..1285a11 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -628,6 +628,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(&b, `

Receipt files: %s. Share this folder read-only. It does not contain the program.

`, htmlEscape(cfg.DataDir)) fmt.Fprintf(&b, ` +

The default is Verae Timestamp plus this computer's name, so two machines are not both called Verae Timestamp. A name that is already on the network is renamed. Any other name you type here is kept.

@@ -697,7 +698,7 @@ func loopback(r *http.Request) bool { func StoppedStatus() Status { return Status{ Running: false, - Name: "Verae Timestamp", + Name: defaultServiceName(), Port: 631, SupportsPublic: supportsPublicFirewall(), } diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 3a7e959..27dc5c4 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -2,9 +2,11 @@ package netprint import ( "context" + "errors" "fmt" "log" "net" + "strings" "sync" "time" ) @@ -77,6 +79,81 @@ func serviceToDarwinDNS(s Service) []dnsSDInstance { } } +// errNameConflict is returned when the instance name is already registered +// on the network. The advertiser picks the next "Name (2)" and tries again. +var errNameConflict = errors.New("mDNS name conflict") + +const ( + // dnsFlagNoAutoRename is kDNSServiceFlagsNoAutoRename. The callback reports + // kDNSServiceErr_NameConflict instead of silently publishing "Name (2)". + dnsFlagNoAutoRename = 8 + // dnsErrNameConflict is kDNSServiceErr_NameConflict. + dnsErrNameConflict = int32(-65548) +) + +// conflictName is the instance label for attempt. Attempt 1 is the requested +// name. Later attempts append " (2)", " (3)", and so on. +func conflictName(name string, attempt int) string { + name = strings.TrimSpace(name) + if name == "" { + name = legacyServiceName + } + if attempt <= 1 { + return name + } + return fmt.Sprintf("%s (%d)", name, attempt) +} + +func retitleTXT(txt []string, name string) []string { + if len(txt) == 0 { + return txt + } + out := make([]string, len(txt)) + for i, s := range txt { + switch { + case strings.HasPrefix(s, "ty="): + out[i] = "ty=" + trimTXT(name, 100) + case strings.HasPrefix(s, "product="): + out[i] = "product=(" + trimTXT(name, 80) + ")" + default: + out[i] = s + } + } + return out +} + +var ( + renameMu sync.Mutex + renameHook func(string) +) + +func setMDNSRename(fn func(string)) { + renameMu.Lock() + renameHook = fn + renameMu.Unlock() +} + +func noteMDNSRename(name string) { + renameMu.Lock() + fn := renameHook + renameMu.Unlock() + name = strings.TrimSpace(name) + if fn != nil && name != "" { + fn(name) + } +} + +func conflictWait(api dnsSDClient) <-chan struct{} { + type waiter interface{ Conflicts() <-chan struct{} } + if api == nil { + return nil + } + if w, ok := api.(waiter); ok { + return w.Conflicts() + } + return nil +} + // dnsPort is the port in network byte order, which DNSServiceRegister requires. func dnsPort(p int) uint16 { if p < 0 || p > 65535 { @@ -149,54 +226,100 @@ func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDCl if api == nil { return fmt.Errorf("DNSServiceRegister client is nil") } - insts := serviceToDarwinDNS(current()) - if len(insts) == 0 { - return fmt.Errorf("DNSServiceRegister: empty service") + base := strings.TrimSpace(current().Instance) + if base == "" { + base = legacyServiceName } - var ok []dnsSDInstance - var veraeErr error - for i, inst := range insts { - if err := api.Register(inst); err != nil { - if i == 0 { - for _, prev := range ok { - _ = api.Deregister(prev) - } - log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) - udpErr := advertiseUDP(ctx, current, sink) - if udpErr != nil && ctx.Err() == nil { - return fmt.Errorf("DNSServiceRegister %s: %w; shared UDP 5353: %v", inst.RegType, err, udpErr) - } - return nil + conflicts := conflictWait(api) + for attempt := 1; attempt <= 8; attempt++ { + if conflicts != nil { + select { + case <-conflicts: + default: } - veraeErr = err - log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + } + name := conflictName(base, attempt) + svc := current() + svc.Instance = name + svc.TXT = retitleTXT(svc.TXT, name) + insts := serviceToDarwinDNS(svc) + if len(insts) == 0 { + return fmt.Errorf("DNSServiceRegister: empty service") + } + var ok []dnsSDInstance + var veraeErr error + conflict := false + var hard error + for i, inst := range insts { + err := api.Register(inst) + if errors.Is(err, errNameConflict) { + conflict = true + break + } + if err != nil { + if i == 0 { + hard = err + break + } + veraeErr = err + log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + continue + } + ok = append(ok, inst) + } + if conflict { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: mDNS name %q is in use; renaming", name) continue } - ok = append(ok, inst) - } - detail := fmt.Sprintf("DNSServiceRegister %s regtype %s port %d", ok[0].InstanceName, ok[0].RegType, ok[0].Port) - if veraeErr != nil { - detail += "; _verae._tcp not registered" - } else if len(ok) > 1 { - detail += "; _verae._tcp" - } - log.Printf("netprint: mDNS %s", detail) - if sink != nil { - sink(mdnsReport{ - Advertising: true, - Mode: "dns-sd", - Detail: detail, - LastSent: time.Now(), - }) - } - <-ctx.Done() - for _, inst := range ok { - if err := api.Deregister(inst); err != nil { - log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err) + if hard != nil { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: DNSServiceRegister %s: %v", insts[0].RegType, hard) + udpErr := advertiseUDP(ctx, current, sink) + if udpErr != nil && ctx.Err() == nil { + return fmt.Errorf("DNSServiceRegister %s: %w; shared UDP 5353: %v", insts[0].RegType, hard, udpErr) + } + return nil + } + detail := fmt.Sprintf("DNSServiceRegister %s regtype %s port %d", ok[0].InstanceName, ok[0].RegType, ok[0].Port) + if veraeErr != nil { + detail += "; _verae._tcp not registered" + } else if len(ok) > 1 { + detail += "; _verae._tcp" + } + if attempt > 1 { + noteMDNSRename(name) + } + log.Printf("netprint: mDNS %s", detail) + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: detail, + LastSent: time.Now(), + }) + } + select { + case <-ctx.Done(): + for _, inst := range ok { + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err) + } + } + log.Printf("netprint: mDNS DNSServiceRegister removed") + return nil + case <-conflicts: + for _, inst := range ok { + _ = api.Deregister(inst) + } + log.Printf("netprint: mDNS name %q is in use; renaming", name) } } - log.Printf("netprint: mDNS DNSServiceRegister removed") - return nil + return fmt.Errorf("mDNS name %q still conflicts", base) } func advertiseWindows(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { diff --git a/internal/netprint/dnsservice_darwin.go b/internal/netprint/dnsservice_darwin.go index dd00460..14f1f2f 100644 --- a/internal/netprint/dnsservice_darwin.go +++ b/internal/netprint/dnsservice_darwin.go @@ -14,14 +14,27 @@ import ( // darwinDNS registers with mDNSResponder through DNSServiceRegister. // Subtypes are part of the regtype. The call does not use cgo; libSystem is -// loaded when the process runs on a Mac. +// loaded when the process runs on a Mac. A name conflict is reported on +// conflicts so the advertiser can register "Name (2)" instead. type darwinDNS struct { - mu sync.Mutex - refs []uintptr - stop []func() + mu sync.Mutex + refs []uintptr + stop []func() + conflicts chan struct{} } -func newDarwinDNS() dnsSDClient { return &darwinDNS{} } +func newDarwinDNS() dnsSDClient { + return &darwinDNS{conflicts: make(chan struct{}, 1)} +} + +func (d *darwinDNS) Conflicts() <-chan struct{} { return d.conflicts } + +// regState is the DNSServiceRegister context. It stays pinned until the +// registration is deallocated so the callback can read it. +type regState struct { + name string + conflicts chan struct{} +} var ( dnsOnce sync.Once @@ -30,6 +43,12 @@ var ( dnsRegister func(sdRef *uintptr, flags uint32, ifIndex uint32, name, regtype, domain, host *byte, port uint16, txtLen uint16, txtRecord unsafe.Pointer, callback uintptr, context unsafe.Pointer) int32 dnsProcess func(sdRef uintptr) int32 dnsDeallocate func(sdRef uintptr) + + dnsReplyOnce sync.Once + dnsReplyPtr uintptr + + regMu sync.Mutex + regStates = map[uintptr]*regState{} ) func loadDNSService() error { @@ -64,14 +83,47 @@ func (d *darwinDNS) Register(inst dnsSDInstance) error { if len(txt) > 0 { txtPtr = unsafe.Pointer(&txt[0]) } + st := ®State{name: inst.Name, conflicts: d.conflicts} + var pin runtime.Pinner + pin.Pin(st) + key := uintptr(unsafe.Pointer(st)) + regMu.Lock() + regStates[key] = st + regMu.Unlock() + flags := uint32(0) + var cb uintptr + var ctx unsafe.Pointer + keep := false + if reply := dnsReplyCallback(); reply != 0 { + flags = dnsFlagNoAutoRename + cb = reply + ctx = unsafe.Pointer(st) + keep = true + } + release := func() { + regMu.Lock() + delete(regStates, key) + regMu.Unlock() + pin.Unpin() + } + if !keep { + release() + } var ref uintptr - rc := dnsRegister(&ref, 0, 0, name, regtype, domain, host, dnsPort(inst.Port), uint16(len(txt)), txtPtr, 0, nil) + rc := dnsRegister(&ref, flags, 0, name, regtype, domain, host, dnsPort(inst.Port), uint16(len(txt)), txtPtr, cb, ctx) runtime.KeepAlive(name) runtime.KeepAlive(regtype) runtime.KeepAlive(domain) runtime.KeepAlive(hostBuf) runtime.KeepAlive(txt) + runtime.KeepAlive(st) if rc != 0 { + if keep { + release() + } + if rc == dnsErrNameConflict { + return errNameConflict + } return fmt.Errorf("DNSServiceRegister %s %s: code %d", inst.Name, inst.RegType, rc) } done := make(chan struct{}) @@ -88,12 +140,45 @@ func (d *darwinDNS) Register(inst dnsSDInstance) error { d.stop = append(d.stop, func() { dnsDeallocate(ref) <-done + if keep { + release() + } }) d.mu.Unlock() log.Printf("netprint: DNSServiceRegister %s %s port %d", inst.Name, inst.RegType, inst.Port) return nil } +func dnsReplyCallback() uintptr { + dnsReplyOnce.Do(func() { + defer func() { + if recover() != nil { + dnsReplyPtr = 0 + } + }() + dnsReplyPtr = purego.NewCallback(dnsRegisterReply) + }) + return dnsReplyPtr +} + +func dnsRegisterReply(sdRef, flags, errCode, namePtr, regtype, domain, context uintptr) uintptr { + if context == 0 { + return 0 + } + regMu.Lock() + st := regStates[context] + regMu.Unlock() + if st == nil || st.conflicts == nil || int32(uint32(errCode)) != dnsErrNameConflict { + return 0 + } + select { + case st.conflicts <- struct{}{}: + default: + } + runtime.KeepAlive(namePtr) + return 0 +} + func (d *darwinDNS) Deregister(inst dnsSDInstance) error { d.mu.Lock() if len(d.stop) == 0 { diff --git a/internal/netprint/mdns_dnssd_test.go b/internal/netprint/mdns_dnssd_test.go index 57ed8f8..9c93b8b 100644 --- a/internal/netprint/mdns_dnssd_test.go +++ b/internal/netprint/mdns_dnssd_test.go @@ -26,6 +26,8 @@ type fakeDNS struct { deregistered []dnsSDInstance err error failSubtypes bool + conflicts chan struct{} + conflictOn string } func (f *fakeDNS) Register(inst dnsSDInstance) error { @@ -38,9 +40,17 @@ func (f *fakeDNS) Register(inst dnsSDInstance) error { return f.err } f.registered = append(f.registered, inst) + if f.conflictOn != "" && inst.Name == f.conflictOn && f.conflicts != nil { + select { + case f.conflicts <- struct{}{}: + default: + } + } return nil } +func (f *fakeDNS) Conflicts() <-chan struct{} { return f.conflicts } + func (f *fakeDNS) Deregister(inst dnsSDInstance) error { f.mu.Lock() defer f.mu.Unlock() @@ -402,6 +412,36 @@ func TestAllowPublicRoundTrip(t *testing.T) { } } +func TestConflictNameAndDarwinRename(t *testing.T) { + base := "Verae Timestamp (Georges-MacBook-Pro)" + if conflictName(base, 1) != base || conflictName(base, 2) != base+" (2)" { + t.Fatalf("%q %q", conflictName(base, 1), conflictName(base, 2)) + } + api := &fakeDNS{conflicts: make(chan struct{}, 1), conflictOn: "Verae Timestamp"} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- advertiseForOS("darwin", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + var names []string + for time.Now().Before(deadline) { + names = api.names() + if strings.Contains(strings.Join(names, " "), "(2)") { + break + } + time.Sleep(10 * time.Millisecond) + } + if !strings.Contains(strings.Join(names, " "), "Verae Timestamp (2)._ipp._tcp.local") { + t.Fatalf("names %v", names) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + func swapAdvertiser(t *testing.T, fn func(context.Context, func() Service, mdnsSink) error) func() { t.Helper() prev := startAdvertiser diff --git a/internal/netprint/name_test.go b/internal/netprint/name_test.go new file mode 100644 index 0000000..0a24bb9 --- /dev/null +++ b/internal/netprint/name_test.go @@ -0,0 +1,54 @@ +package netprint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaultServiceNameIsUniquePerHost(t *testing.T) { + want := legacyServiceName + " (" + hostLabel("") + ")" + if got := defaultServiceName(); got != want || got == legacyServiceName || !strings.Contains(got, "(") { + t.Fatalf("default %q", got) + } + d := Defaults() + if d.Name != want { + t.Fatalf("Defaults name %q", d.Name) + } + sample := string(ExampleJSON(t.TempDir())) + if !strings.Contains(sample, `"name": "`+want+`"`) { + t.Fatalf("example config:\n%s", sample) + } + kept := Config{Name: "Kitchen", Port: 631} + if err := kept.normalize(); err != nil || kept.Name != "Kitchen" { + t.Fatalf("%+v %v", kept, err) + } + dir := t.TempDir() + path := filepath.Join(dir, "netprint.json") + if err := os.WriteFile(path, []byte(`{"name":"Verae Timestamp","port":631}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + loaded, err := Load(path, Config{}) + if err != nil { + t.Fatal(err) + } + if loaded.Name != want { + t.Fatalf("legacy name stayed %q", loaded.Name) + } + if err := persistQualifiedName(path, loaded); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(b), want) { + t.Fatalf("not saved: %s %v", b, err) + } + custom := Config{Name: "Kitchen", Port: 631, DataDir: dir} + if err := Save(path, custom); err != nil { + t.Fatal(err) + } + again, err := Load(path, Config{}) + if err != nil || again.Name != "Kitchen" { + t.Fatalf("%+v %v", again, err) + } +} diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go index dd36f11..e042cea 100644 --- a/internal/netprint/pipeline.go +++ b/internal/netprint/pipeline.go @@ -83,6 +83,10 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s fail(err) return } + if err := job.RefuseMockReceipt(res.ReceiptID, receiptMerkle(res.ReceiptJSON)); err != nil { + fail(err) + return + } if user != "" && res.ReceiptJSON != "" { annotateUser(res.ReceiptJSON, user) } @@ -166,6 +170,26 @@ func jpegToPages(doc []byte) ([]Page, error) { return []Page{{Width: w, Height: h, DPI: 300, RGB: rgb}}, nil } +func receiptMerkle(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + b, err := os.ReadFile(path) + if err != nil { + return "" + } + var m map[string]any + if json.Unmarshal(b, &m) != nil || m == nil { + return "" + } + switch v := m["merkle"].(type) { + case string: + return v + default: + return "" + } +} + func annotateUser(path, user string) { b, err := os.ReadFile(path) if err != nil { diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 57892b3..c1c537e 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -50,6 +50,9 @@ func Run(ctx context.Context, explicitPath string, flags Config, stamp StampFunc if strings.TrimSpace(path) == "" { path = SystemConfigPath() } + if err := persistQualifiedName(path, cfg); err != nil { + log.Printf("netprint: default name: %v", err) + } s, err := Start(cfg, path, stamp, nil) if err != nil { return err @@ -206,6 +209,7 @@ func (s *Server) startMDNS() { ctx, cancel := context.WithCancel(context.Background()) s.mdnsCancel = cancel s.mu.Unlock() + setMDNSRename(s.noteAdvertisedName) s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) go func() { err := startAdvertiser(ctx, func() Service { @@ -249,6 +253,31 @@ func (s *Server) stopMDNS() { s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) } +func (s *Server) noteAdvertisedName(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + s.mu.Lock() + if s.cfg.Name == name { + s.mu.Unlock() + return + } + s.cfg.Name = name + cfg := s.cfg + path := s.configPath + if s.printer != nil { + s.printer.Name = name + } + s.mu.Unlock() + if strings.TrimSpace(path) != "" { + if err := Save(path, cfg); err != nil { + log.Printf("netprint: mDNS rename save: %v", err) + } + } + log.Printf("netprint: mDNS renamed the printer to %q", name) +} + func (s *Server) setMDNS(r mdnsReport) { s.mu.Lock() s.mdns = r diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index bfe5118..a1763d7 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -14,10 +14,20 @@ import ( ) const ( - daemonPlist = "/Library/LaunchDaemons/com.verae.netprint.plist" - agentPlist = "/Library/LaunchAgents/com.verae.netprint.tray.plist" + daemonPlist = "/Library/LaunchDaemons/com.verae.netprint.plist" + agentPlist = "/Library/LaunchAgents/com.verae.netprint.tray.plist" + trayAppExecutable = "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print" ) +// trayProgram is the menu-bar executable inside Verae NetPrint.app. +// The LaunchDaemon keeps using the binary under Application Support. +func trayProgram() string { + if st, err := os.Stat(trayAppExecutable); err == nil && !st.IsDir() { + return trayAppExecutable + } + return mustExe() +} + func plistDaemon(exe, config string) string { return fmt.Sprintf(` @@ -85,7 +95,7 @@ func (osControl) SetTrayAtLogin(on bool) error { uid := consoleUID() target := "gui/" + uid if on { - if err := os.WriteFile(agentPlist, []byte(plistAgent(mustExe())), 0o644); err != nil { + if err := os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644); err != nil { return err } _ = exec.Command("launchctl", "enable", target+"/"+trayLabel).Run() @@ -168,12 +178,15 @@ func Install(owner, tokenFile string, opts InstallOptions) error { if err := os.MkdirAll("/Library/LaunchAgents", 0o755); err != nil { return err } - if err := os.WriteFile(agentPlist, []byte(plistAgent(chosen)), 0o644); err != nil { + if err := os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644); err != nil { return err } linkCLI(exe) copyToken(tokenFile) allowAppFirewall(chosen) + if tray := trayProgram(); tray != chosen { + allowAppFirewall(tray) + } _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() if err := launchctl("bootstrap", "system", daemonPlist); err != nil { return err @@ -193,7 +206,7 @@ func Uninstall(owner string) error { if other := peerBinary(owner); other != "" { cfgPath := SystemConfigPath() _ = os.WriteFile(daemonPlist, []byte(plistDaemon(other, cfgPath)), 0o644) - _ = os.WriteFile(agentPlist, []byte(plistAgent(other)), 0o644) + _ = os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644) _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() _ = launchctl("bootstrap", "system", daemonPlist) } diff --git a/internal/netprint/stamp_api_test.go b/internal/netprint/stamp_api_test.go index c646883..790f357 100644 --- a/internal/netprint/stamp_api_test.go +++ b/internal/netprint/stamp_api_test.go @@ -246,6 +246,56 @@ func TestVeraeAnnounceStaysOffIPPRecords(t *testing.T) { } } +func TestStampAPIRejectsMockReceipt(t *testing.T) { + cfg, path := testConfig(t) + var calls int + stamp := func(pdf, title, user string) (job.Result, error) { + calls++ + dir := filepath.Dir(pdf) + js := filepath.Join(dir, "mock.verae.json") + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"mock-cert-9\",\"merkle\":\"42\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{ReceiptID: "mock-cert-9", SHA256: strings.Repeat("ab", 32), ReceiptJSON: js, OriginalPDF: pdf}, nil + } + s, err := Start(cfg, path, stamp, &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + res, err := http.Post(ippURL(s, "/api/stamp"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status %d body %q", res.StatusCode, body) + } + if id := res.Header.Get("X-Verae-Receipt-Id"); id != "" || strings.Contains(string(body), "mock-cert") && res.StatusCode == http.StatusOK { + t.Fatalf("mock receipt returned: %s %q", id, body) + } + if !strings.Contains(string(body), "mock receipt") { + t.Fatalf("body %q", body) + } + if calls != 1 { + t.Fatalf("stamp calls %d", calls) + } + id, err := s.store.AllocateID() + if err != nil { + t.Fatal(err) + } + ProcessJob(s.store, s.snapshot(), nil, id, "application/pdf", "Printed", "ada", []byte("%PDF-1.4 print"), stamp, nil, nil) + rec, ok := s.store.Get(id) + if !ok || rec.State == "completed" || !strings.Contains(rec.Error, "mock receipt") { + t.Fatalf("%+v", rec) + } +} + func localStamp() StampFunc { return func(pdf, title, user string) (job.Result, error) { dir := filepath.Dir(pdf) diff --git a/platform/macos/netprint/com.verae.netprint.tray.plist b/platform/macos/netprint/com.verae.netprint.tray.plist index e4c4ccd..50aa3c7 100644 --- a/platform/macos/netprint/com.verae.netprint.tray.plist +++ b/platform/macos/netprint/com.verae.netprint.tray.plist @@ -4,7 +4,7 @@ Labelcom.verae.netprint.tray ProgramArguments - /Library/Application Support/Verae/NetPrint/bin/verae-print + /Applications/Verae NetPrint.app/Contents/MacOS/verae-print tray RunAtLoad diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index c9fb1a2..4370739 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -22,12 +22,36 @@ ARCH="${PKG_ARCH:-arm64}" STAGE="$OUT/payload" SCRIPTS="$OUT/scripts" rm -rf "$STAGE" "$SCRIPTS" +APP="$STAGE/Applications/Verae NetPrint.app" mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" \ "$STAGE/Library/LaunchDaemons" \ "$STAGE/Library/LaunchAgents" \ + "$APP/Contents/MacOS" \ "$SCRIPTS" cp -f "$BIN" "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" +# The menu-bar app and the AirDrop helper live in one bundle. LSUIElement=1 +# keeps it out of the Dock. The LaunchAgent runs the executable inside it. +cp -f "$BIN" "$APP/Contents/MacOS/verae-print" +chmod 755 "$APP/Contents/MacOS/verae-print" +cat >"$APP/Contents/Info.plist" < + + + CFBundleExecutableverae-print + CFBundleIdentifierai.verae.netprint + CFBundleNameVerae NetPrint + CFBundleDisplayNameVerae NetPrint + CFBundlePackageTypeAPPL + CFBundleShortVersionString${VERSION} + CFBundleVersion${VERSION} + LSMinimumSystemVersion14.0 + LSUIElement + NSHighResolutionCapable + NSLocalNetworkUsageDescriptionVerae NetPrint looks for the timestamp printer on the local network and opens AirDrop to send the stamped PDF. + +EOF +printf 'APPL????' >"$APP/Contents/PkgInfo" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" # The package itself installs the root daemon and the Aqua LaunchAgent. @@ -41,9 +65,9 @@ if [[ "$(uname -s)" == "Darwin" ]]; then echo "swiftc is required to build verae-airdrop" >&2 exit 1 fi - swiftc -O -o "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" \ + swiftc -O -o "$APP/Contents/MacOS/verae-airdrop" \ "$ROOT/platform/macos/airdrop/main.swift" - chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" + chmod 755 "$APP/Contents/MacOS/verae-airdrop" fi # preinstall runs before the payload replaces verae-print. postinstall # bootstraps the service again. @@ -69,8 +93,8 @@ if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null tar -C "$OUT" -czf "$OUT/VeraeNetPrint-macos-payload.tar.gz" payload scripts exit 0 fi -if [[ ! -x "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" ]]; then - echo "verae-airdrop missing from the package root" >&2 +if [[ ! -x "$APP/Contents/MacOS/verae-airdrop" ]]; then + echo "verae-airdrop missing from Verae NetPrint.app" >&2 exit 1 fi diff --git a/platform/macos/pkg/check-pkg.sh b/platform/macos/pkg/check-pkg.sh index d995b63..b8493e1 100644 --- a/platform/macos/pkg/check-pkg.sh +++ b/platform/macos/pkg/check-pkg.sh @@ -39,4 +39,13 @@ lsbom "$bom" >"$WORKDIR/bom.txt" grep -q 'verae-airdrop' "$WORKDIR/bom.txt" grep -q 'LaunchAgents' "$WORKDIR/bom.txt" grep -q 'LaunchDaemons' "$WORKDIR/bom.txt" +grep -q 'Verae NetPrint.app' "$WORKDIR/bom.txt" +plist="$(find "$WORKDIR/full" -path '*Verae NetPrint.app/Contents/Info.plist' -print -quit)" +if [[ -z "$plist" ]]; then + echo "Verae NetPrint.app has no Info.plist" >&2 + exit 1 +fi +grep -q 'LSUIElement' "$plist" +grep -q 'NSLocalNetworkUsageDescription' "$plist" +grep -q 'ai.verae.netprint' "$plist" echo "package ok: $PKG" From 969415e5c6f16f12ead72a57e1c350a2016e425d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 16:18:24 +0000 Subject: [PATCH 22/27] Keep the Mac menu bar up and open the live receipts page. Load AppKit on the main thread, log why the tray would exit, and fall back to an AppKit status item if NSApplication run returns. Open Receipts uses the status URL. Installers can share that folder read-only over SMB. Co-authored-by: George Lambert --- .github/workflows/installers.yml | 10 +- cmd/verae-print/installer_cmds_test.go | 9 + cmd/verae-print/netprint_cmd.go | 17 +- cmd/verae-print/tray_main_darwin.go | 11 + docs/network-printer.md | 8 +- internal/netprint/airdrop_test.go | 12 +- internal/netprint/delivery_test.go | 16 +- internal/netprint/service_darwin.go | 10 + internal/netprint/tray_darwin.go | 308 +++++++++++++++-- internal/netprint/tray_menu.go | 36 +- platform/macos/menubar/main.swift | 410 +++++++++++++++++++++++ platform/macos/pkg/build-netprint-pkg.sh | 44 ++- platform/macos/pkg/check-pkg.sh | 18 +- platform/macos/pkg/smb-share.sh | 39 +++ platform/windows/installer-netprint.nsi | 47 ++- platform/windows/installer-windows.nsi | 43 ++- 16 files changed, 976 insertions(+), 62 deletions(-) create mode 100644 cmd/verae-print/tray_main_darwin.go create mode 100644 platform/macos/menubar/main.swift create mode 100644 platform/macos/pkg/smb-share.sh diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 2e5bdcb..4d2f485 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -43,10 +43,16 @@ jobs: chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/check-pkg.sh VERSION="$VERSION" BIN="$OUT/verae-print" PKG_ARCH=arm64 OUT="$OUT" ./platform/macos/pkg/build-netprint-pkg.sh cp -f "$OUT/verae-print" "$OUT/verae-print-arm64" - test -x "$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS/verae-airdrop" - test -x "$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS/verae-print" + APP="$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS" + test -x "$APP/verae-airdrop" + test -x "$APP/verae-menubar" + test -x "$APP/verae-print" test -f "$OUT/payload/Applications/Verae NetPrint.app/Contents/Info.plist" test -f "$OUT/VeraeNetPrint-macos-arm64-${VERSION}.pkg" + "$APP/verae-menubar" --smoke | tee "$OUT/menubar-smoke.txt" + "$APP/verae-print" tray --smoke | tee "$OUT/tray-smoke.txt" + grep -E 'smoke ok|no window server' "$OUT/menubar-smoke.txt" + grep -E 'smoke ok|no window server' "$OUT/tray-smoke.txt" - name: pkgutil --expand, pkgutil --check-signature, and installer -pkginfo run: bash platform/macos/pkg/check-pkg.sh build/netprint/macos/VeraeNetPrint-macos-arm64-0.3.4.pkg - uses: actions/upload-artifact@v4 diff --git a/cmd/verae-print/installer_cmds_test.go b/cmd/verae-print/installer_cmds_test.go index 9d3ac4e..4e4b6b8 100644 --- a/cmd/verae-print/installer_cmds_test.go +++ b/cmd/verae-print/installer_cmds_test.go @@ -40,6 +40,15 @@ func TestNetprintUnknownCommandDoesNotStartServer(t *testing.T) { } } +func TestTrayUnknownArgument(t *testing.T) { + old := os.Args + t.Cleanup(func() { os.Args = old }) + os.Args = []string{"verae-print", "tray", "--nope"} + if code := runTrayCmd(); code != 2 { + t.Fatalf("tray exit %d, want 2", code) + } +} + func TestReadTextFileUTF16(t *testing.T) { s := "Email=a@b.co\r\nPassword=p=\"w\\x\r\n" u := utf16.Encode([]rune(s)) diff --git a/cmd/verae-print/netprint_cmd.go b/cmd/verae-print/netprint_cmd.go index 5c8257a..64069d6 100644 --- a/cmd/verae-print/netprint_cmd.go +++ b/cmd/verae-print/netprint_cmd.go @@ -100,7 +100,22 @@ func parseAllowPublic(s string) (*bool, error) { } } -func runTrayCmd() int { +func runTrayCmd() (code int) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "tray panic: %v\n", r) + code = 1 + } + }() + for _, a := range os.Args[2:] { + switch a { + case "--smoke", "-smoke": + _ = os.Setenv("VERAE_TRAY_SMOKE", "1") + default: + fmt.Fprintf(os.Stderr, "unknown tray argument %q\n", a) + return 2 + } + } if err := netprint.RunTray(context.Background()); err != nil { fmt.Fprintln(os.Stderr, err) return 1 diff --git a/cmd/verae-print/tray_main_darwin.go b/cmd/verae-print/tray_main_darwin.go new file mode 100644 index 0000000..19ddabe --- /dev/null +++ b/cmd/verae-print/tray_main_darwin.go @@ -0,0 +1,11 @@ +//go:build darwin + +package main + +import "runtime" + +func init() { + // -[NSApplication run] returns immediately when it is not on the process + // main thread. Lock before the scheduler can move this goroutine. + runtime.LockOSThread() +} diff --git a/docs/network-printer.md b/docs/network-printer.md index 9656b82..7391dfb 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -35,7 +35,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Receipts on the phone -`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. Share it read-only over SMB. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. +`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN. The menu bar **Open Receipts…** item opens the live `receiptsUrl` from status (for example `http://192.168.1.108:631/receipts` or `http://.local:631/receipts`), not a fixed address. **Open Settings…** opens the control page. The menu also shows how to print from an iPhone: Share → Print → Select Printer → the advertised printer name. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. The macOS installer has an optional checkbox, **Share receipts over SMB (read-only)**, off unless you check it. It shares that folder with guest access off. The Windows installers have the same checkbox, off unless you check it or pass `/SMB=1`, and create a read-only share named `Verae Receipts`. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. ## Stamp and return @@ -62,7 +62,7 @@ On macOS, the Application Firewall is per application (System Settings → Netwo The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. GitHub Actions builds it on a `macos-14` runner with `pkgbuild` and `productbuild`, then runs `pkgutil --expand`, `pkgutil --check-signature`, and `installer -pkginfo`. It installs: - `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 daemon binary) -- `/Applications/Verae NetPrint.app` (the menu-bar app: `Contents/Info.plist` with bundle id `ai.verae.netprint`, `LSUIElement`, and `NSLocalNetworkUsageDescription`; `Contents/MacOS/verae-print` and `Contents/MacOS/verae-airdrop`) +- `/Applications/Verae NetPrint.app` (the menu-bar app: `Contents/Info.plist` with bundle id `ai.verae.netprint`, `LSUIElement`, and `NSLocalNetworkUsageDescription`; `Contents/MacOS/verae-print`, `Contents/MacOS/verae-menubar`, and `Contents/MacOS/verae-airdrop`) - LaunchDaemon `com.verae.netprint` (root, the print server, the binary under Application Support) - LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `/Applications/Verae NetPrint.app/Contents/MacOS/verae-print tray` as the logged-in user @@ -72,7 +72,9 @@ Receipts and job files go to `/Users/Shared/Verae Receipts`, not next to those b sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / ``` -`postinstall` runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there. +`postinstall` checks that `verae-print`, `verae-airdrop`, and `verae-menubar` are in the app, then runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there. + +`verae-print tray` loads AppKit and stays in `NSApplication run` on the main thread. If that run returns without Quit, or the status item cannot be created, it logs the reason to `~/Library/Logs/Verae/netprint-tray.log` and execs `verae-menubar` in the same app (a small AppKit status item with status, Start, Stop, Open Receipts…, Open Settings…, and AirDrop again). A failure exits 1. It does not exit 0 when the menu bar cannot stay up. `verae-print tray --smoke` prints `smoke ok` or `no window server` and does not enter the run loop. Customize on the installer shows **Share receipts over SMB (read-only)**, unselected unless you check it. ## AirDrop on the Mac diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go index 6582db3..b37f76d 100644 --- a/internal/netprint/airdrop_test.go +++ b/internal/netprint/airdrop_test.go @@ -298,10 +298,14 @@ func TestMacAirDropAndPackageSources(t *testing.T) { {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter", "notificationsAllowed", "bundleIdentifier"}}, {"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint", "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print"}}, - {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint"}}, - {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "Verae NetPrint.app", "LSUIElement"}}, - {"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop"}}, - {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild"}}, + {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "verae-menubar", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint", "Share receipts over SMB"}}, + {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "verae-menubar", "Verae NetPrint.app", "LSUIElement", "Share receipts over SMB"}}, + {"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop", "verae-menubar", "tray --smoke"}}, + {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild", "netprint-tray.log", "Open Receipts…"}}, + {"tray_darwin.go", []string{"netprint-tray.log", "AppKit.framework", "verae-menubar", "NSApplication run returned without quit"}}, + {"../../platform/macos/menubar/main.swift", []string{"NSStatusItem", "Open Receipts…", "Open Settings…", "AirDrop again", "no window server"}}, + {"../../platform/macos/pkg/smb-share.sh", []string{"/Users/Shared/Verae Receipts", "smb_read_only", "sharing"}}, + {"../../platform/windows/installer-netprint.nsi", []string{"/SMB=", "GRANT:Everyone,READ", "Verae Receipts"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, {"advertise_darwin.go", []string{`advertiseForOS("darwin"`}}, } diff --git a/internal/netprint/delivery_test.go b/internal/netprint/delivery_test.go index d156903..fe81c7a 100644 --- a/internal/netprint/delivery_test.go +++ b/internal/netprint/delivery_test.go @@ -444,8 +444,20 @@ func TestTrayShowsReceiptsURLAndEmail(t *testing.T) { open = e.URL } } - if !sawURL || !sawMail || !sawErr || open != "http://127.0.0.1:8630/receipts" { - t.Fatalf("url %v mail %v err %v open %s", sawURL, sawMail, sawErr, open) + var sawReceipts, sawSettings, sawPhone bool + for _, e := range BuildMenu(st) { + if e.ID == idReceipts && e.Label == "Open Receipts…" { + sawReceipts = true + } + if e.ID == idSettings && e.Label == "Open Settings…" && strings.Contains(e.URL, "/settings") { + sawSettings = true + } + if strings.Contains(e.Label, "Share → Print → Select Printer → Verae Timestamp") { + sawPhone = true + } + } + if !sawURL || !sawMail || !sawErr || open != "http://192.0.2.10:631/receipts" || !sawReceipts || !sawSettings || !sawPhone { + t.Fatalf("url %v mail %v err %v open %s receipts %v settings %v phone %v", sawURL, sawMail, sawErr, open, sawReceipts, sawSettings, sawPhone) } } diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index a1763d7..9ab8b2e 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -216,11 +216,21 @@ func Uninstall(owner string) error { if uid := consoleUID(); uid != "" { _ = exec.Command("launchctl", "bootout", "gui/"+uid+"/"+trayLabel).Run() } + removeReceiptsShare() _ = os.Remove(daemonPlist) _ = os.Remove(agentPlist) return nil } +// removeReceiptsShare drops the optional read-only SMB point. The receipts +// folder itself stays. sharing -r is a no-op when the point was never created. +func removeReceiptsShare() { + if _, err := exec.LookPath("sharing"); err != nil { + return + } + _ = exec.Command("sharing", "-r", "Verae Receipts").Run() +} + func currentServiceExe() string { b, err := os.ReadFile(daemonPlist) if err != nil { diff --git a/internal/netprint/tray_darwin.go b/internal/netprint/tray_darwin.go index 300e8b3..eced89d 100644 --- a/internal/netprint/tray_darwin.go +++ b/internal/netprint/tray_darwin.go @@ -4,69 +4,319 @@ package netprint import ( "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" "runtime" + "strings" "sync" + "syscall" "unsafe" + "github.com/ebitengine/purego" "github.com/ebitengine/purego/objc" ) +// trayLogRel is the menu-bar log under the user's home directory. +const trayLogRel = "Library/Logs/Verae/netprint-tray.log" + var ( - trayOnce sync.Once - trayTarget objc.ID - trayMenu objc.ID - trayMu sync.Mutex - trayItems []MenuEntry - selTray = objc.RegisterName("trayAction:") - selOpen = objc.RegisterName("menuWillOpen:") + trayOnce sync.Once + trayTarget objc.ID + trayMenu objc.ID + trayMu sync.Mutex + trayItems []MenuEntry + trayUserQuit bool + trayLogFile *os.File + selTray = objc.RegisterName("trayAction:") + selOpen = objc.RegisterName("menuWillOpen:") ) func runTray(ctx context.Context) error { - runtime.LockOSThread() - trayOnce.Do(registerTrayClass) - app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) - app.Send(objc.RegisterName("setActivationPolicy:"), int64(1)) - bar := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) - item := bar.Send(objc.RegisterName("statusItemWithLength:"), float64(-1)) - btn := item.Send(objc.RegisterName("button")) - btn.Send(objc.RegisterName("setTitle:"), nsString("Verae")) - menu := cls("NSMenu").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) - trayMenu = menu - trayTarget = cls("VeraeNetPrintTray").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) - menu.Send(objc.RegisterName("setDelegate:"), trayTarget) - item.Send(objc.RegisterName("setMenu:"), menu) - rebuildDarwinMenu(menu) - watchCtx, watchCancel := context.WithCancel(ctx) - defer watchCancel() - startAirDropWatch(watchCtx) - app.Send(objc.RegisterName("run")) + setupTrayLog() + log.Printf("netprint tray: start pid=%d args=%q", os.Getpid(), os.Args) + if os.Getenv("VERAE_TRAY_SMOKE") == "1" { + err := runTraySmoke() + syncTrayLog() + return err + } + if err := startDarwinTray(ctx); err != nil { + return failTray(err) + } + syncTrayLog() return nil } -func registerTrayClass() { +func startDarwinTray(ctx context.Context) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("tray panic: %v", r) + } + }() + if err = loadAppKit(); err != nil { + return err + } + if !mainThread() { + return fmt.Errorf("NSApplication must run on the main thread") + } + runtime.LockOSThread() + app, err := buildStatusItem() + if err != nil { + return err + } + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + startAirDropWatch(watchCtx) + log.Printf("netprint tray: entering NSApplication run") + syncTrayLog() + app.Send(objc.RegisterName("run")) + if trayUserQuit { + log.Printf("netprint tray: quit") + return nil + } + return fmt.Errorf("NSApplication run returned without quit") +} + +func runTraySmoke() error { + log.Printf("netprint tray: smoke") + if err := loadAppKit(); err != nil { + return err + } + if !mainThread() { + return fmt.Errorf("NSApplication must run on the main thread") + } + _, err := buildStatusItem() + if err != nil { + if !aquaSession() { + fmt.Println("no window server") + log.Printf("netprint tray: no window server") + return nil + } + return err + } + removeStatusItem() + fmt.Println("smoke ok") + log.Printf("netprint tray: smoke ok") + return nil +} + +func failTray(reason error) error { + if reason == nil { + reason = fmt.Errorf("tray failed") + } + log.Printf("netprint tray: %v", reason) + syncTrayLog() + if os.Getenv("VERAE_TRAY_SMOKE") == "1" { + return reason + } + helper, err := menuBarHelper() + if err != nil { + return fmt.Errorf("%w; menubar fallback: %v", reason, err) + } + log.Printf("netprint tray: exec %s", helper) + syncTrayLog() + if err := syscall.Exec(helper, []string{helper}, os.Environ()); err != nil { + return fmt.Errorf("%w; menubar fallback: %v", reason, err) + } + return nil +} + +func menuBarHelper() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + p := filepath.Join(filepath.Dir(exe), "verae-menubar") + st, err := os.Stat(p) + if err != nil || st.IsDir() || st.Mode()&0o111 == 0 { + return "", fmt.Errorf("verae-menubar missing next to %s", exe) + } + return p, nil +} + +func setupTrayLog() { + home, err := os.UserHomeDir() + if err != nil || home == "" { + home = "/tmp" + } + path := filepath.Join(home, trayLogRel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + log.SetOutput(os.Stderr) + log.Printf("netprint tray: cannot create %s: %v", filepath.Dir(path), err) + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.SetOutput(os.Stderr) + log.Printf("netprint tray: cannot open %s: %v", path, err) + return + } + trayLogFile = f + log.SetOutput(io.MultiWriter(os.Stderr, f)) + log.Printf("netprint tray: log %s", path) +} + +func syncTrayLog() { + if trayLogFile != nil { + _ = trayLogFile.Sync() + } +} + +func loadAppKit() error { + const ( + rtldLazy = 0x1 + rtldGlobal = 0x8 + ) + flags := rtldLazy | rtldGlobal + if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", flags); err != nil { + return fmt.Errorf("Foundation: %w", err) + } + if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", flags); err != nil { + return fmt.Errorf("AppKit: %w", err) + } + if objc.GetClass("NSApplication") == 0 || objc.GetClass("NSStatusBar") == 0 { + return fmt.Errorf("NSApplication missing after AppKit load") + } + log.Printf("netprint tray: AppKit loaded, mainThread=%v", mainThread()) + return nil +} + +func mainThread() bool { + return objc.Send[byte](cls("NSThread"), objc.RegisterName("isMainThread")) != 0 +} + +func aquaSession() bool { + if os.Getenv("SECURITYSESSIONID") != "" { + return true + } + out, err := exec.Command("/bin/launchctl", "managername").Output() + return err == nil && strings.TrimSpace(string(out)) == "Aqua" +} + +func buildStatusItem() (objc.ID, error) { + if err := ensureTrayClass(); err != nil { + return 0, err + } + app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) + if app == 0 { + return 0, fmt.Errorf("NSApplication sharedApplication is nil") + } + app.Send(objc.RegisterName("setActivationPolicy:"), int64(1)) // accessory, no Dock icon + bar := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) + if bar == 0 { + return 0, fmt.Errorf("NSStatusBar is nil") + } + item := bar.Send(objc.RegisterName("statusItemWithLength:"), float64(-1)) + if item == 0 { + return 0, fmt.Errorf("status item is nil") + } + btn := item.Send(objc.RegisterName("button")) + if btn == 0 { + return 0, fmt.Errorf("status item button is nil") + } + if img := cls("NSImage").Send( + objc.RegisterName("imageWithSystemSymbolName:accessibilityDescription:"), + nsString("printer"), nsString("Verae"), + ); img != 0 { + img.Send(objc.RegisterName("setTemplate:"), byte(1)) + btn.Send(objc.RegisterName("setImage:"), img) + } + btn.Send(objc.RegisterName("setTitle:"), nsString("Verae")) + menu := cls("NSMenu").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + if menu == 0 { + return 0, fmt.Errorf("NSMenu is nil") + } + trayMenu = menu + trayTarget = cls("VeraeNetPrintTray").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + if trayTarget == 0 { + return 0, fmt.Errorf("tray target is nil") + } + menu.Send(objc.RegisterName("setDelegate:"), trayTarget) + item.Send(objc.RegisterName("setMenu:"), menu) + rebuildDarwinMenu(menu) + log.Printf("netprint tray: status item ready") + return app, nil +} + +func removeStatusItem() { + bar := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) + item := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) + _ = bar + _ = item + // statusItemWithLength returns the item we would need to remove. Smoke + // exits immediately; the item disappears with the process. +} + +func ensureTrayClass() error { + var regErr error + trayOnce.Do(func() { + regErr = registerTrayClass() + }) + if cls("VeraeNetPrintTray") == 0 { + if regErr == nil { + regErr = fmt.Errorf("VeraeNetPrintTray was not registered") + } + return regErr + } + return nil +} + +func registerTrayClass() error { methods := []objc.MethodDef{ {Cmd: selOpen, Fn: menuWillOpen}, {Cmd: selTray, Fn: trayAction}, } methods = append(methods, airDropMethods()...) - _, _ = objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, methods) + if _, err := objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, methods); err != nil { + log.Printf("netprint tray: register with notifications: %v", err) + methods = []objc.MethodDef{ + {Cmd: selOpen, Fn: menuWillOpen}, + {Cmd: selTray, Fn: trayAction}, + } + if _, err2 := objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, methods); err2 != nil { + return err2 + } + } + return nil } func menuWillOpen(self, _cmd, menu objc.ID) { + defer func() { _ = recover() }() rebuildDarwinMenu(menu) } func trayAction(self, _cmd, sender objc.ID) { + defer func() { + if r := recover(); r != nil { + log.Printf("netprint tray: menu action panic: %v", r) + } + }() tag := int(objc.Send[int64](sender, objc.RegisterName("tag"))) trayMu.Lock() items := append([]MenuEntry(nil), trayItems...) trayMu.Unlock() st := Probe() for _, e := range items { - if e.ID == tag { - _ = Dispatch(st, e) + if e.ID != tag { + continue + } + err := Dispatch(st, e) + if IsQuit(err) { + trayUserQuit = true + log.Printf("netprint tray: quit selected") + syncTrayLog() + app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) + app.Send(objc.RegisterName("terminate:"), objc.ID(0)) + app.Send(objc.RegisterName("stop:"), objc.ID(0)) return } + if err != nil { + log.Printf("netprint tray: %s: %v", e.Kind, err) + } + return } } diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go index f4c6063..d8fae34 100644 --- a/internal/netprint/tray_menu.go +++ b/internal/netprint/tray_menu.go @@ -94,11 +94,13 @@ func BuildMenu(st Status) []MenuEntry { } out = append(out, MenuEntry{Label: "Email — on, to " + to, Kind: "status"}) } - if st.ReceiptsURL != "" { - out = append(out, MenuEntry{Label: "Receipts " + st.ReceiptsURL, Kind: "status"}) + receipts := liveReceipts(st) + if receipts != "" { + out = append(out, MenuEntry{Label: "Receipts " + receipts, Kind: "status"}) } out = append(out, - MenuEntry{ID: idReceipts, Label: "Open receipts", Enabled: st.Running && (st.ReceiptsURL != "" || st.ControlURL != "" || st.AdminURL != ""), URL: localReceipts(st), Kind: "open"}, + MenuEntry{ID: idReceipts, Label: "Open Receipts…", Enabled: receipts != "", URL: receipts, Kind: "open"}, + MenuEntry{Label: iphonePrintHint(st), Kind: "status"}, ) for i, rec := range st.Recent { if i >= 6 { @@ -118,7 +120,7 @@ func BuildMenu(st Status) []MenuEntry { settings = "http://127.0.0.1:8630/settings" } out = append(out, - MenuEntry{ID: idSettings, Label: "Settings…", Enabled: st.Running, URL: settings, Kind: "open"}, + MenuEntry{ID: idSettings, Label: "Open Settings…", Enabled: st.Running || settings != "", URL: settings, Kind: "open"}, MenuEntry{Separator: true}, MenuEntry{ID: idQuit, Label: "Quit menu", Enabled: true, Kind: "quit"}, ) @@ -164,17 +166,31 @@ func clipMenu(s string, n int) string { return s[:n-1] + "…" } -func localReceipts(st Status) string { +// liveReceipts is the address advertised for other devices. The menu opens +// that URL. The loopback control page is only a fallback when status has +// no receipts address yet. +func liveReceipts(st Status) string { + if u := strings.TrimSpace(st.ReceiptsURL); u != "" { + return u + } + if u := strings.TrimSpace(st.AdminURL); u != "" { + return u + } if u := strings.TrimRight(st.ControlURL, "/"); u != "" { return u + "/receipts" } - if st.ReceiptsURL != "" { - return st.ReceiptsURL + if st.Running { + return "http://127.0.0.1:8630/receipts" } - if st.AdminURL != "" { - return st.AdminURL + return "" +} + +func iphonePrintHint(st Status) string { + name := strings.TrimSpace(st.Name) + if name == "" { + name = "Verae Timestamp" } - return "http://127.0.0.1:8630/receipts" + return clipMenu("iPhone: Share → Print → Select Printer → "+name, 140) } func receiptURL(st Status, rec Record) string { diff --git a/platform/macos/menubar/main.swift b/platform/macos/menubar/main.swift new file mode 100644 index 0000000..1e2b593 --- /dev/null +++ b/platform/macos/menubar/main.swift @@ -0,0 +1,410 @@ +import AppKit +import Foundation + +// verae-menubar is the AppKit status item used when the Go menu bar cannot +// stay running. It lives in Verae NetPrint.app next to verae-print. +// Usage: verae-menubar [--smoke] + +private let controlDefault = "http://127.0.0.1:8630" +private let logName = "Library/Logs/Verae/netprint-tray.log" + +private let session: URLSession = { + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 3 + cfg.timeoutIntervalForResource = 8 + cfg.waitsForConnectivity = false + return URLSession(configuration: cfg) +}() + +private func logPath() -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return (home as NSString).appendingPathComponent(logName) +} + +private func trayLog(_ msg: String) { + let line = ISO8601DateFormatter().string(from: Date()) + " verae-menubar " + msg + "\n" + line.withCString { fputs($0, stderr) } + let path = logPath() + let dir = (path as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: path), + let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile() + try? handle.write(contentsOf: data) + try? handle.close() + } else { + FileManager.default.createFile(atPath: path, contents: data) + } + } +} + +private func sibling(_ name: String) -> String { + var dir = (CommandLine.arguments[0] as NSString).deletingLastPathComponent + if dir.isEmpty || dir == "." { + dir = "/Applications/Verae NetPrint.app/Contents/MacOS" + } + return (dir as NSString).appendingPathComponent(name) +} + +private func aquaSession() -> Bool { + if ProcessInfo.processInfo.environment["SECURITYSESSIONID"] != nil { + return true + } + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/launchctl") + p.arguments = ["managername"] + let pipe = Pipe() + p.standardOutput = pipe + try? p.run() + p.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let name = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return name == "Aqua" +} + +private struct PrinterStatus { + var running = false + var name = "Verae Timestamp" + var jobsToday = 0 + var receipts = "" + var control = controlDefault + var address = "" + var port = 0 +} + +private func fetchStatus() -> PrinterStatus { + var st = PrinterStatus() + guard let url = URL(string: controlDefault + "/api/v1/status") else { return st } + let sem = DispatchSemaphore(value: 0) + var body: Data? + session.dataTask(with: url) { data, response, _ in + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + body = data + } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 3) + guard let body, let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { + return st + } + st.running = obj["running"] as? Bool ?? false + if let name = obj["name"] as? String, !name.isEmpty { st.name = name } + st.jobsToday = obj["jobsToday"] as? Int ?? 0 + st.receipts = obj["receiptsUrl"] as? String ?? "" + if st.receipts.isEmpty { st.receipts = obj["adminUrl"] as? String ?? "" } + if let c = obj["controlUrl"] as? String, !c.isEmpty { st.control = c } + st.port = obj["port"] as? Int ?? 0 + if let addrs = obj["addresses"] as? [String], let first = addrs.first { st.address = first } + return st +} + +private func postJSON(_ path: String, body: [String: Any]) -> Bool { + guard let url = URL(string: controlDefault + path), + let data = try? JSONSerialization.data(withJSONObject: body) else { return false } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = data + let sem = DispatchSemaphore(value: 0) + var ok = false + session.dataTask(with: req) { _, response, _ in + if let http = response as? HTTPURLResponse, http.statusCode < 300 { ok = true } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 4) + return ok +} + +private final class EventWatch: NSObject, URLSessionDataDelegate { + var buffer = "" + var after: Int64 = 0 + var seen = Set() + let queue = OperationQueue() + var sse: URLSession? + var task: URLSessionDataTask? + weak var tray: MenuTray? + + func start() { + queue.maxConcurrentOperationCount = 1 + sse = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: queue) + connect() + } + + func connect() { + guard let sse, let url = URL(string: "\(controlDefault)/api/v1/events?after=\(after)") else { return } + task = sse.dataTask(with: url) + task?.resume() + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + buffer += String(data: data, encoding: .utf8) ?? "" + while let range = buffer.range(of: "\n\n") { + let block = String(buffer[.. after { after = seq } + } + + func present(id: Int, receipt: String, pdfURL: String, jsonURL: String) -> Bool { + let root = (FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path ?? "/tmp") + let dir = ((root as NSString).appendingPathComponent("Verae/AirDrop/\(id)")) + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let pdf = (dir as NSString).appendingPathComponent("certificate.pdf") + let js = (dir as NSString).appendingPathComponent("receipt.json") + guard download(pdfURL, to: pdf) else { return false } + var jsonPath = "" + if !jsonURL.isEmpty { + guard download(jsonURL, to: js) else { return false } + jsonPath = js + } + DispatchQueue.main.async { [weak self] in + self?.tray?.remember(pdf: pdf, json: jsonPath, receipt: receipt) + } + guard launchAirDrop(pdf: pdf, json: jsonPath) else { return false } + if !ack(id) { trayLog("airdrop ack job \(id) failed") } + return true + } + + func download(_ urlString: String, to dest: String) -> Bool { + guard let url = URL(string: urlString) else { return false } + let sem = DispatchSemaphore(value: 0) + var data: Data? + session.dataTask(with: url) { body, response, _ in + if let http = response as? HTTPURLResponse, http.statusCode == 200 { data = body } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 30) + guard let data else { return false } + return FileManager.default.createFile(atPath: dest, contents: data) + } + + func launchAirDrop(pdf: String, json: String) -> Bool { + let helper = sibling("verae-airdrop") + guard FileManager.default.isExecutableFile(atPath: helper) else { + trayLog("verae-airdrop missing") + return false + } + let p = Process() + p.executableURL = URL(fileURLWithPath: helper) + p.arguments = json.isEmpty ? [pdf] : [pdf, json] + do { + try p.run() + return true + } catch { + trayLog("verae-airdrop: \(error.localizedDescription)") + return false + } + } + + func ack(_ id: Int) -> Bool { + postJSON("/api/v1/airdrop/ack", ["id": id]) + } +} + +final class MenuTray: NSObject, NSMenuDelegate { + let item: NSStatusItem + let menu = NSMenu() + var status = PrinterStatus() + var lastPDF = "" + var lastJSON = "" + var lastReceipt = "" + let events = EventWatch() + + init(item: NSStatusItem) { + self.item = item + super.init() + menu.delegate = self + item.menu = menu + events.tray = self + events.start() + rebuild() + } + + func remember(pdf: String, json: String, receipt: String) { + lastPDF = pdf + lastJSON = json + lastReceipt = receipt + rebuild() + } + + func menuWillOpen(_ menu: NSMenu) { + status = fetchStatus() + rebuild() + } + + func statusLine() -> String { + let state = status.running ? "Running" : "Stopped" + var where_ = status.name + if !status.address.isEmpty && status.port > 0 { + where_ = "\(status.name) · \(status.address):\(status.port)" + } + return "\(state) — \(where_) · \(status.jobsToday) jobs today" + } + + func receiptsURL() -> String { + if !status.receipts.isEmpty { return status.receipts } + return controlDefault + "/receipts" + } + + func settingsURL() -> String { + var base = status.control + while base.hasSuffix("/") { base.removeLast() } + if base.isEmpty { base = controlDefault } + return base + "/settings" + } + + func rebuild() { + menu.removeAllItems() + add(statusLine(), action: nil, enabled: false) + add("iPhone: Share → Print → Select Printer → \(status.name)", action: nil, enabled: false) + menu.addItem(.separator()) + add("Start", action: #selector(startService), enabled: !status.running) + add("Stop", action: #selector(stopService), enabled: status.running) + menu.addItem(.separator()) + add("Open Receipts…", action: #selector(openReceipts), enabled: !receiptsURL().isEmpty) + add("Open Settings…", action: #selector(openSettings), enabled: true) + if !lastPDF.isEmpty { + let label = lastReceipt.isEmpty ? "AirDrop again" : "AirDrop again · \(lastReceipt)" + add(label, action: #selector(airDropAgain), enabled: true) + } + menu.addItem(.separator()) + add("Quit menu", action: #selector(quit), enabled: true) + } + + func add(_ title: String, action: Selector?, enabled: Bool) { + let row = NSMenuItem(title: title, action: action, keyEquivalent: "") + row.target = self + row.isEnabled = enabled + menu.addItem(row) + } + + @objc func startService() { + if !postJSON("/api/v1/service", ["action": "start"]) { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/launchctl") + p.arguments = ["bootstrap", "system", "/Library/LaunchDaemons/com.verae.netprint.plist"] + try? p.run() + } + status = fetchStatus() + rebuild() + } + + @objc func stopService() { + _ = postJSON("/api/v1/service", ["action": "stop"]) + status = fetchStatus() + rebuild() + } + + @objc func openReceipts() { open(receiptsURL()) } + @objc func openSettings() { open(settingsURL()) } + + @objc func airDropAgain() { + guard !lastPDF.isEmpty else { + trayLog("no stamped PDF to AirDrop") + return + } + if !events.launchAirDrop(pdf: lastPDF, json: lastJSON) { + trayLog("AirDrop again failed") + } + } + + func open(_ s: String) { + guard let u = URL(string: s) else { return } + NSWorkspace.shared.open(u) + } + + @objc func quit() { + trayLog("quit") + NSApp.terminate(nil) + } +} + +private func smoke() -> Never { + trayLog("smoke") + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if item.button == nil { + if !aquaSession() { + trayLog("no window server") + fputs("no window server\n", stdout) + exit(0) + } + trayLog("status item button is nil") + fputs("status item button is nil\n", stderr) + exit(1) + } + item.button?.title = "Verae" + NSStatusBar.system.removeStatusItem(item) + trayLog("smoke ok") + fputs("smoke ok\n", stdout) + exit(0) +} + +if !Thread.isMainThread { + trayLog("not on the main thread") + fputs("verae-menubar: not on the main thread\n", stderr) + exit(1) +} + +if CommandLine.arguments.contains("--smoke") { + smoke() +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) +guard let button = item.button else { + trayLog("status item button is nil") + fputs("status item button is nil\n", stderr) + exit(1) +} +button.title = "Verae" +if let img = NSImage(systemSymbolName: "printer", accessibilityDescription: "Verae") { + img.isTemplate = true + button.image = img +} +let tray = MenuTray(item: item) +_ = tray +trayLog("status item ready") +app.run() +trayLog("NSApplication.run returned without quit") +fputs("NSApplication.run returned without quit\n", stderr) +exit(1) diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 4370739..8edda15 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -68,6 +68,9 @@ if [[ "$(uname -s)" == "Darwin" ]]; then swiftc -O -o "$APP/Contents/MacOS/verae-airdrop" \ "$ROOT/platform/macos/airdrop/main.swift" chmod 755 "$APP/Contents/MacOS/verae-airdrop" + swiftc -O -o "$APP/Contents/MacOS/verae-menubar" \ + "$ROOT/platform/macos/menubar/main.swift" + chmod 755 "$APP/Contents/MacOS/verae-menubar" fi # preinstall runs before the payload replaces verae-print. postinstall # bootstraps the service again. @@ -82,6 +85,19 @@ exit 0 EOF cat >"$SCRIPTS/postinstall" <<'EOF' #!/bin/bash +set -euo pipefail +APP="/Applications/Verae NetPrint.app/Contents/MacOS" +missing=0 +for b in verae-print verae-airdrop verae-menubar; do + if [[ ! -x "$APP/$b" ]]; then + echo "missing $APP/$b" >&2 + missing=1 + fi +done +if [[ "$missing" -ne 0 ]]; then + exit 1 +fi +echo "menu-bar bundle ok" export VERAE_NETPRINT_OWNER=standalone exec "/Library/Application Support/Verae/NetPrint/bin/verae-print" netprint install --owner standalone EOF @@ -97,6 +113,10 @@ if [[ ! -x "$APP/Contents/MacOS/verae-airdrop" ]]; then echo "verae-airdrop missing from Verae NetPrint.app" >&2 exit 1 fi +if [[ ! -x "$APP/Contents/MacOS/verae-menubar" ]]; then + echo "verae-menubar missing from Verae NetPrint.app" >&2 + exit 1 +fi COMPONENT="$OUT/netprint-component.pkg" rm -f "$COMPONENT" "$OUT/$PKG_NAME" @@ -108,20 +128,40 @@ pkgbuild \ --install-location "/" \ "$COMPONENT" +SMB_SCRIPTS="$OUT/smb-scripts" +SMB_COMPONENT="$OUT/smb-component.pkg" +rm -rf "$SMB_SCRIPTS" +mkdir -p "$SMB_SCRIPTS" +cp -f "$ROOT/platform/macos/pkg/smb-share.sh" "$SMB_SCRIPTS/postinstall" +chmod 755 "$SMB_SCRIPTS/postinstall" +rm -f "$SMB_COMPONENT" +pkgbuild \ + --nopayload \ + --scripts "$SMB_SCRIPTS" \ + --identifier "ai.verae.netprint.smb" \ + --version "$VERSION" \ + --install-location "/" \ + "$SMB_COMPONENT" + DIST="$OUT/distribution-standalone.xml" cat >"$DIST" < Verae Network Printer - + + + + + netprint-component.pkg + smb-component.pkg EOF @@ -134,5 +174,5 @@ if [[ -f "$SIGNING_ENV" ]]; then else productbuild --distribution "$DIST" --package-path "$OUT" "$OUT/$PKG_NAME" fi -rm -f "$COMPONENT" +rm -f "$COMPONENT" "$SMB_COMPONENT" echo "built $OUT/$PKG_NAME" diff --git a/platform/macos/pkg/check-pkg.sh b/platform/macos/pkg/check-pkg.sh index b8493e1..eb8f8ba 100644 --- a/platform/macos/pkg/check-pkg.sh +++ b/platform/macos/pkg/check-pkg.sh @@ -35,8 +35,18 @@ if [[ -z "$bom" ]]; then echo "expanded package has no Bom" >&2 exit 1 fi -lsbom "$bom" >"$WORKDIR/bom.txt" +: >"$WORKDIR/bom.txt" +found=0 +while IFS= read -r bom; do + lsbom "$bom" >>"$WORKDIR/bom.txt" + found=1 +done < <(find "$WORKDIR/full" -name Bom -print) +if [[ "$found" -eq 0 ]]; then + echo "expanded package has no Bom" >&2 + exit 1 +fi grep -q 'verae-airdrop' "$WORKDIR/bom.txt" +grep -q 'verae-menubar' "$WORKDIR/bom.txt" grep -q 'LaunchAgents' "$WORKDIR/bom.txt" grep -q 'LaunchDaemons' "$WORKDIR/bom.txt" grep -q 'Verae NetPrint.app' "$WORKDIR/bom.txt" @@ -48,4 +58,10 @@ fi grep -q 'LSUIElement' "$plist" grep -q 'NSLocalNetworkUsageDescription' "$plist" grep -q 'ai.verae.netprint' "$plist" +dist="$WORKDIR/expanded/Distribution" +if [[ ! -f "$dist" ]]; then + echo "expanded package has no Distribution" >&2 + exit 1 +fi +grep -q 'Share receipts over SMB' "$dist" echo "package ok: $PKG" diff --git a/platform/macos/pkg/smb-share.sh b/platform/macos/pkg/smb-share.sh new file mode 100644 index 0000000..494343e --- /dev/null +++ b/platform/macos/pkg/smb-share.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Optional read-only SMB share of the macOS receipts folder. +# The installer runs this only when that choice is checked. +# The program stays in Application Support and is not in this folder. +set -euo pipefail + +DIR="/Users/Shared/Verae Receipts" +NAME="Verae Receipts" + +mkdir -p "$DIR" +chmod 755 "$DIR" + +if ! command -v sharing >/dev/null 2>&1; then + echo "sharing(8) is not available; the SMB share was not created" >&2 + exit 1 +fi + +# Replace a previous point so a rerun stays read-only and does not turn on write. +if sharing -l 2>/dev/null | grep -F "$NAME" >/dev/null 2>&1; then + sharing -r "$NAME" || true +fi + +# -S publishes SMB. -g 000 keeps guest access off. Authenticated users of +# this Mac can mount the share. smb_read_only is set explicitly because the +# sharing flags differ across macOS 14 and macOS 26. +sharing -a "$DIR" -S "$NAME" -n "$NAME" -g 000 + +if dscl . -read "/SharePoints/${NAME}" >/dev/null 2>&1; then + dscl . -create "/SharePoints/${NAME}" smb_shared 1 + dscl . -create "/SharePoints/${NAME}" smb_guestaccess 0 + dscl . -create "/SharePoints/${NAME}" smb_read_only 1 + dscl . -create "/SharePoints/${NAME}" directory_path "$DIR" +fi + +launchctl enable system/com.apple.smbd >/dev/null 2>&1 || true +launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.smbd.plist >/dev/null 2>&1 || \ + launchctl kickstart -k system/com.apple.smbd >/dev/null 2>&1 || true + +echo "SMB share \"${NAME}\" -> ${DIR} (read-only)" diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi index 88299ec..49e047f 100644 --- a/platform/windows/installer-netprint.nsi +++ b/platform/windows/installer-netprint.nsi @@ -53,8 +53,10 @@ Var LogFile Var LogDir Var AllowPublic Var LocalQueue +Var ShareSMB Var hPublic Var hQueue +Var hShare Var FwProfile Function .onInit @@ -68,6 +70,7 @@ Function .onInit Call SetupLogPath StrCpy $AllowPublic "0" StrCpy $LocalQueue "0" + StrCpy $ShareSMB "0" ${GetParameters} $R0 ClearErrors ${GetOptions} $R0 "/PUBLIC=" $R1 @@ -83,6 +86,13 @@ Function .onInit StrCpy $LocalQueue "1" ${EndIf} ${EndIf} + ClearErrors + ${GetOptions} $R0 "/SMB=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $ShareSMB "1" + ${EndIf} + ${EndIf} FunctionEnd Function un.onInit @@ -130,19 +140,24 @@ FunctionEnd !macroend Function NetOptionsCreate - !insertmacro MUI_HEADER_TEXT "Network options" "Both stay off unless you turn them on." + !insertmacro MUI_HEADER_TEXT "Network options" "These stay off unless you turn them on." nsDialogs::Create 1018 Pop $0 - ${NSD_CreateCheckbox} 0 0 100% 28u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." + ${NSD_CreateCheckbox} 0 0 100% 24u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." Pop $hPublic - ${NSD_CreateCheckbox} 0 36u 100% 28u "Add a local Windows printer named &Verae Timestamp (off by default).$\r$\nPrints to this computer through http://127.0.0.1 on the port the service binds." + ${NSD_CreateCheckbox} 0 32u 100% 24u "Add a local Windows printer named &Verae Timestamp (off by default).$\r$\nPrints to this computer through http://127.0.0.1 on the port the service binds." Pop $hQueue + ${NSD_CreateCheckbox} 0 64u 100% 28u "Share the receipts folder over SMB, read-only (off by default).$\r$\nOther computers can open stamped files in Verae Receipts. The program is not in that folder." + Pop $hShare ${If} $AllowPublic == "1" ${NSD_Check} $hPublic ${EndIf} ${If} $LocalQueue == "1" ${NSD_Check} $hQueue ${EndIf} + ${If} $ShareSMB == "1" + ${NSD_Check} $hShare + ${EndIf} nsDialogs::Show FunctionEnd @@ -159,6 +174,25 @@ Function NetOptionsLeave ${Else} StrCpy $LocalQueue "0" ${EndIf} + ${NSD_GetState} $hShare $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $ShareSMB "1" + ${Else} + StrCpy $ShareSMB "0" + ${EndIf} +FunctionEnd + +Function ShareReceiptsSMB + ReadEnvStr $R7 "ProgramData" + ${If} $R7 == "" + StrCpy $R7 "C:\ProgramData" + ${EndIf} + StrCpy $R7 "$R7\Verae\NetPrint" + CreateDirectory "$R7" + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 + nsExec::Exec 'net share "Verae Receipts"="$R7" /GRANT:Everyone,READ /REMARK:"Verae timestamp receipts (read-only)"' + Pop $0 FunctionEnd !macro NETPRINT_FIREWALL_REMOVE @@ -202,6 +236,9 @@ Section "Verae Network Printer" SecMain ${If} $LocalQueue == "1" StrCpy $R9 '$R9 --local-queue' ${EndIf} + ${If} $ShareSMB == "1" + Call ShareReceiptsSMB + ${EndIf} nsExec::ExecToStack $R9 Pop $0 Pop $1 @@ -209,7 +246,7 @@ Section "Verae Network Printer" SecMain FileOpen $2 "$LogFile" a ${IfNot} ${Errors} FileSeek $2 0 END - FileWrite $2 "netprint install allow-public=$R8 local-queue=$LocalQueue exit $0$\r$\n$1$\r$\n" + FileWrite $2 "netprint install allow-public=$R8 local-queue=$LocalQueue smb=$ShareSMB exit $0$\r$\n$1$\r$\n" FileClose $2 ${EndIf} ${If} $0 != 0 @@ -239,6 +276,8 @@ Section "Uninstall" ClearErrors ${If} $R1 != 1 !insertmacro NETPRINT_FIREWALL_REMOVE + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 ${EndIf} !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-print.exe" !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-netprint-tray.exe" diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index 5ad9a71..e620df5 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -86,8 +86,10 @@ Var hNetChk Var NetPrint ; 1 when the optional LAN printer is selected Var hNetPublic Var hNetQueue +Var hNetSMB Var NetPublic ; 1 to also allow Public networks (off by default) Var NetQueue ; 1 to add the local "Verae Timestamp" queue (off by default) +Var NetSMB ; 1 to share the receipts folder read-only over SMB (off by default) Var NetStopped ; 1 when this upgrade stopped VeraeNetPrint to unlock verae-print.exe Var FwProfile Var TargetProfile @@ -176,6 +178,7 @@ Function .onInit StrCpy $NetPrint "0" StrCpy $NetPublic "0" StrCpy $NetQueue "0" + StrCpy $NetSMB "0" StrCpy $NetStopped "0" ${GetParameters} $R0 ClearErrors @@ -192,6 +195,13 @@ Function .onInit StrCpy $NetQueue "1" ${EndIf} ${EndIf} + ClearErrors + ${GetOptions} $R0 "/SMB=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $NetSMB "1" + ${EndIf} + ${EndIf} StrCpy $Checked 0 StrCpy $TokenReady 0 StrCpy $KeyChoice "skip" @@ -644,19 +654,24 @@ Function NetExtraCreate ${If} $NetPrint != "1" Abort ${EndIf} - !insertmacro MUI_HEADER_TEXT "Network printer" "Optional. Both stay off unless you turn them on." + !insertmacro MUI_HEADER_TEXT "Network printer" "Optional. These stay off unless you turn them on." nsDialogs::Create 1018 Pop $Dlg - ${NSD_CreateCheckbox} 0 0 100% 28u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." + ${NSD_CreateCheckbox} 0 0 100% 24u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." Pop $hNetPublic - ${NSD_CreateCheckbox} 0 36u 100% 28u "Add a local Windows printer named &Verae Timestamp (off by default)." + ${NSD_CreateCheckbox} 0 32u 100% 20u "Add a local Windows printer named &Verae Timestamp (off by default)." Pop $hNetQueue + ${NSD_CreateCheckbox} 0 58u 100% 28u "Share the receipts folder over SMB, read-only (off by default).$\r$\nOther computers can open stamped files in Verae Receipts. The program is not in that folder." + Pop $hNetSMB ${If} $NetPublic == "1" ${NSD_Check} $hNetPublic ${EndIf} ${If} $NetQueue == "1" ${NSD_Check} $hNetQueue ${EndIf} + ${If} $NetSMB == "1" + ${NSD_Check} $hNetSMB + ${EndIf} nsDialogs::Show FunctionEnd @@ -673,6 +688,12 @@ Function NetExtraLeave ${Else} StrCpy $NetQueue "0" ${EndIf} + ${NSD_GetState} $hNetSMB $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetSMB "1" + ${Else} + StrCpy $NetSMB "0" + ${EndIf} FunctionEnd ; ======================= Install ======================= @@ -837,6 +858,18 @@ Section "Verae Print Stamp + printer" SecMain ${If} $NetQueue == "1" StrCpy $R9 '$R9 --local-queue' ${EndIf} + ${If} $NetSMB == "1" + ReadEnvStr $R7 "ProgramData" + ${If} $R7 == "" + StrCpy $R7 "C:\ProgramData" + ${EndIf} + StrCpy $R7 "$R7\Verae\NetPrint" + CreateDirectory "$R7" + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 + nsExec::Exec 'net share "Verae Receipts"="$R7" /GRANT:Everyone,READ /REMARK:"Verae timestamp receipts (read-only)"' + Pop $0 + ${EndIf} nsExec::ExecToStack $R9 Pop $0 Pop $1 @@ -844,7 +877,7 @@ Section "Verae Print Stamp + printer" SecMain FileOpen $2 "$LogDir\netprint-installer.log" a ${IfNot} ${Errors} FileSeek $2 0 END - FileWrite $2 "driver netprint install allow-public=$R8 local-queue=$NetQueue exit $0$\r$\n$1$\r$\n" + FileWrite $2 "driver netprint install allow-public=$R8 local-queue=$NetQueue smb=$NetSMB exit $0$\r$\n$1$\r$\n" FileClose $2 ${EndIf} ${If} $0 == 0 @@ -892,6 +925,8 @@ Section "Uninstall" Pop $0 nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' Pop $0 + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 ${EndIf} !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-print.exe" !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-printhelper.exe" From 86ebbbaea9bc0566e970bfe7ca9e64c44d8ef9d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 16:20:46 +0000 Subject: [PATCH 23/27] Fix the menu-bar helper so swiftc accepts it on macOS 14. The status item types are visible to the menu class, and the JSON helper calls match its signature. Co-authored-by: George Lambert --- platform/macos/menubar/main.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/platform/macos/menubar/main.swift b/platform/macos/menubar/main.swift index 1e2b593..5efc166 100644 --- a/platform/macos/menubar/main.swift +++ b/platform/macos/menubar/main.swift @@ -23,7 +23,7 @@ private func logPath() -> String { private func trayLog(_ msg: String) { let line = ISO8601DateFormatter().string(from: Date()) + " verae-menubar " + msg + "\n" - line.withCString { fputs($0, stderr) } + _ = line.withCString { fputs($0, stderr) } let path = logPath() let dir = (path as NSString).deletingLastPathComponent try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) @@ -63,7 +63,7 @@ private func aquaSession() -> Bool { return name == "Aqua" } -private struct PrinterStatus { +struct PrinterStatus { var running = false var name = "Verae Timestamp" var jobsToday = 0 @@ -99,7 +99,7 @@ private func fetchStatus() -> PrinterStatus { return st } -private func postJSON(_ path: String, body: [String: Any]) -> Bool { +private func postJSON(_ path: String, _ body: [String: Any]) -> Bool { guard let url = URL(string: controlDefault + path), let data = try? JSONSerialization.data(withJSONObject: body) else { return false } var req = URLRequest(url: url) @@ -116,7 +116,7 @@ private func postJSON(_ path: String, body: [String: Any]) -> Bool { return ok } -private final class EventWatch: NSObject, URLSessionDataDelegate { +final class EventWatch: NSObject, URLSessionDataDelegate { var buffer = "" var after: Int64 = 0 var seen = Set() From 970061d6896c386c89f91e5d447895751944062c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 16:23:23 +0000 Subject: [PATCH 24/27] Register the menu-bar Objective-C methods with the selector type purego expects. The status-item class was never registered, so the tray treated a failed setup as success. Smoke now fails closed until the class exists. Co-authored-by: George Lambert --- internal/netprint/airdrop_darwin.go | 2 +- internal/netprint/tray_darwin.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/netprint/airdrop_darwin.go b/internal/netprint/airdrop_darwin.go index 7958f98..61754ef 100644 --- a/internal/netprint/airdrop_darwin.go +++ b/internal/netprint/airdrop_darwin.go @@ -246,7 +246,7 @@ func registerAirDropCategory() { runtime.KeepAlive(cat) } -func didReceiveAirDrop(self, _cmd, center, response, completion objc.ID) { +func didReceiveAirDrop(self objc.ID, _cmd objc.SEL, center, response, completion objc.ID) { action := "" if response != 0 { action = objcUTF8(objc.Send[objc.ID](response, objc.RegisterName("actionIdentifier"))) diff --git a/internal/netprint/tray_darwin.go b/internal/netprint/tray_darwin.go index eced89d..5aeb36d 100644 --- a/internal/netprint/tray_darwin.go +++ b/internal/netprint/tray_darwin.go @@ -283,12 +283,12 @@ func registerTrayClass() error { return nil } -func menuWillOpen(self, _cmd, menu objc.ID) { +func menuWillOpen(self objc.ID, _cmd objc.SEL, menu objc.ID) { defer func() { _ = recover() }() rebuildDarwinMenu(menu) } -func trayAction(self, _cmd, sender objc.ID) { +func trayAction(self objc.ID, _cmd objc.SEL, sender objc.ID) { defer func() { if r := recover(); r != nil { log.Printf("netprint tray: menu action panic: %v", r) From 5ed87da68f2060519c20aaed22b6a3117a975924 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 16:34:51 +0000 Subject: [PATCH 25/27] Keep the macOS receipts SMB share across reboots. Save the installer choice as shareReceiptsSMB, recreate the share when it is missing, and show the live smb:// address on the receipts page. Co-authored-by: George Lambert --- cmd/verae-print/main.go | 1 + cmd/verae-print/netprint_cmd.go | 4 +- docs/network-printer.md | 6 +- internal/netprint/admin.go | 2 + internal/netprint/airdrop_test.go | 13 +- internal/netprint/config.go | 80 +++++++----- internal/netprint/control.go | 154 +++++++++++++++-------- internal/netprint/receipts.go | 5 + internal/netprint/server.go | 55 +++++++- internal/netprint/service.go | 25 +++- internal/netprint/service_darwin.go | 9 -- internal/netprint/share.go | 118 +++++++++++++++++ internal/netprint/share_darwin.go | 101 +++++++++++++++ internal/netprint/share_other.go | 11 ++ internal/netprint/share_test.go | 111 ++++++++++++++++ internal/netprint/tray.go | 5 +- internal/netprint/tray_menu.go | 11 ++ platform/macos/menubar/main.swift | 20 +++ platform/macos/pkg/build-netprint-pkg.sh | 2 +- platform/macos/pkg/check-pkg.sh | 2 +- platform/macos/pkg/smb-share.sh | 34 +++-- 21 files changed, 648 insertions(+), 121 deletions(-) create mode 100644 internal/netprint/share.go create mode 100644 internal/netprint/share_darwin.go create mode 100644 internal/netprint/share_other.go create mode 100644 internal/netprint/share_test.go mode change 100644 => 100755 platform/macos/pkg/smb-share.sh diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index c62d16e..3bcf4a6 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -88,6 +88,7 @@ Usage: verae-print netprint install --owner driver|standalone [--allow-public true|false] [--local-queue] verae-print netprint uninstall --owner driver|standalone verae-print netprint launch-tray # Windows: start the tray as the logged-in user + verae-print netprint enable-receipts-share # macOS: save shareReceiptsSMB and publish the folder verae-print tray # menu-bar / system-tray control for the network printer verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH] diff --git a/cmd/verae-print/netprint_cmd.go b/cmd/verae-print/netprint_cmd.go index 64069d6..5d7007b 100644 --- a/cmd/verae-print/netprint_cmd.go +++ b/cmd/verae-print/netprint_cmd.go @@ -18,7 +18,7 @@ import ( func runNetprint(args []string) int { if len(args) > 0 { switch args[0] { - case "install", "uninstall", "launch-tray": + case "install", "uninstall", "launch-tray", "enable-receipts-share": return runNetprintInstall(args) } // An unknown word must not fall through into the server. That is how @@ -67,6 +67,8 @@ func runNetprintInstall(args []string) int { switch args[0] { case "launch-tray": err = netprint.LaunchTrayNow() + case "enable-receipts-share": + err = netprint.EnableReceiptsShare() case "install": pub, perr := parseAllowPublic(*allowPublic) if perr != nil { diff --git a/docs/network-printer.md b/docs/network-printer.md index 7391dfb..3768cb4 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -35,7 +35,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Receipts on the phone -`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN. The menu bar **Open Receipts…** item opens the live `receiptsUrl` from status (for example `http://192.168.1.108:631/receipts` or `http://.local:631/receipts`), not a fixed address. **Open Settings…** opens the control page. The menu also shows how to print from an iPhone: Share → Print → Select Printer → the advertised printer name. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. The macOS installer has an optional checkbox, **Share receipts over SMB (read-only)**, off unless you check it. It shares that folder with guest access off. The Windows installers have the same checkbox, off unless you check it or pass `/SMB=1`, and create a read-only share named `Verae Receipts`. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. +`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN. The menu bar **Open Receipts…** item opens the live `receiptsUrl` from status (for example `http://192.168.1.108:631/receipts` or `http://.local:631/receipts`), not a fixed address. **Open Settings…** opens the control page. The menu also shows how to print from an iPhone: Share → Print → Select Printer → the advertised printer name. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. The macOS installer Customize pane has **Share receipts folder over the network (SMB)**, off unless you check it. When checked, postinstall shares that folder as **Verae Receipts** (`sharing -a` with `-s 001 -g 000`: SMB only, guest off) and enables `com.apple.smbd` so File Sharing stays on across reboots. The choice is saved as `shareReceiptsSMB` in `netprint.json`. Settings and the menu bar can turn it on or off later. If the choice is on and the share is missing, the daemon recreates it at startup, and the menu-bar app asks it to again at login. Status JSON includes `shareReceiptsSMB` and `smbUrl` (for example `smb://Georges-MacBook-Pro.local/Verae%20Receipts`). The receipts page shows that address. The first connection from iPhone Files needs a username and password from this Mac. Guest access is off. The Windows installers have the same checkbox, off unless you check it or pass `/SMB=1`, and create a read-only share named `Verae Receipts`. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. ## Stamp and return @@ -74,7 +74,7 @@ sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / `postinstall` checks that `verae-print`, `verae-airdrop`, and `verae-menubar` are in the app, then runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there. -`verae-print tray` loads AppKit and stays in `NSApplication run` on the main thread. If that run returns without Quit, or the status item cannot be created, it logs the reason to `~/Library/Logs/Verae/netprint-tray.log` and execs `verae-menubar` in the same app (a small AppKit status item with status, Start, Stop, Open Receipts…, Open Settings…, and AirDrop again). A failure exits 1. It does not exit 0 when the menu bar cannot stay up. `verae-print tray --smoke` prints `smoke ok` or `no window server` and does not enter the run loop. Customize on the installer shows **Share receipts over SMB (read-only)**, unselected unless you check it. +`verae-print tray` loads AppKit and stays in `NSApplication run` on the main thread. If that run returns without Quit, or the status item cannot be created, it logs the reason to `~/Library/Logs/Verae/netprint-tray.log` and execs `verae-menubar` in the same app (a small AppKit status item with status, Start, Stop, Open Receipts…, Open Settings…, Share receipts folder over SMB, and AirDrop again). A failure exits 1. It does not exit 0 when the menu bar cannot stay up. `verae-print tray --smoke` prints `smoke ok` or `no window server` and does not enter the run loop. Customize on the installer shows **Share receipts folder over the network (SMB)**, unselected unless you check it. That choice is saved as `shareReceiptsSMB`. The first connection from iPhone Files needs a username and password from this Mac. ## AirDrop on the Mac @@ -84,7 +84,7 @@ Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer. -A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `receiptsDir`, `ippBind`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, and `receiptsPinSet`. It does not include the SMTP password or the receipts PIN. +A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `receiptsDir`, `ippBind`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, `receiptsPinSet`, `shareReceiptsSMB`, and `smbUrl`. It does not include the SMTP password or the receipts PIN. macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary. diff --git a/internal/netprint/admin.go b/internal/netprint/admin.go index 97035db..c097bc1 100644 --- a/internal/netprint/admin.go +++ b/internal/netprint/admin.go @@ -17,6 +17,8 @@ type Admin struct { Name func() string Port func() int Public bool + // SMBURL is the live receipts share, empty when sharing is off. + SMBURL func() string } func (a *Admin) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go index b37f76d..9cae5fc 100644 --- a/internal/netprint/airdrop_test.go +++ b/internal/netprint/airdrop_test.go @@ -20,7 +20,7 @@ func TestAirDropDefaultsOffAndMenu(t *testing.T) { if Defaults().AirDropOn() { t.Fatal("AirDrop is opt-in") } - if !strings.Contains(string(ExampleJSON("/tmp")), `"airDropEnabled": false`) { + if !strings.Contains(string(ExampleJSON("/tmp")), `"airDropEnabled": false`) || !strings.Contains(string(ExampleJSON("/tmp")), `"shareReceiptsSMB": false`) { t.Fatal("sample config should show the switch off") } st := Status{Running: true, SupportsAirDrop: true, AirDropEnabled: true, Advertise: true} @@ -298,13 +298,14 @@ func TestMacAirDropAndPackageSources(t *testing.T) { {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter", "notificationsAllowed", "bundleIdentifier"}}, {"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint", "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print"}}, - {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "verae-menubar", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint", "Share receipts over SMB"}}, - {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "verae-menubar", "Verae NetPrint.app", "LSUIElement", "Share receipts over SMB"}}, + {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "verae-menubar", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint", "Share receipts folder over the network (SMB)", "shareReceiptsSMB"}}, + {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "verae-menubar", "Verae NetPrint.app", "LSUIElement", "Share receipts folder over the network (SMB)"}}, {"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop", "verae-menubar", "tray --smoke"}}, - {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild", "netprint-tray.log", "Open Receipts…"}}, + {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild", "netprint-tray.log", "Open Receipts…", "shareReceiptsSMB", "smbUrl", "username and password"}}, {"tray_darwin.go", []string{"netprint-tray.log", "AppKit.framework", "verae-menubar", "NSApplication run returned without quit"}}, - {"../../platform/macos/menubar/main.swift", []string{"NSStatusItem", "Open Receipts…", "Open Settings…", "AirDrop again", "no window server"}}, - {"../../platform/macos/pkg/smb-share.sh", []string{"/Users/Shared/Verae Receipts", "smb_read_only", "sharing"}}, + {"../../platform/macos/menubar/main.swift", []string{"NSStatusItem", "Open Receipts…", "Open Settings…", "AirDrop again", "no window server", "Share receipts folder over SMB", "receipts-share/ensure"}}, + {"../../platform/macos/pkg/smb-share.sh", []string{"/Users/Shared/Verae Receipts", "smb_read_only", "sharing", "-s 001", "-g 000", "launchctl enable system/com.apple.smbd", "enable-receipts-share", "share-receipts-smb.on"}}, + {"share_darwin.go", []string{"-s", "001", "-g", "000", "launchctl", "system/com.apple.smbd", "smb_read_only", "receiptsShareName", "darwinSharedReceiptsDir"}}, {"../../platform/windows/installer-netprint.nsi", []string{"/SMB=", "GRANT:Everyone,READ", "Verae Receipts"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, {"advertise_darwin.go", []string{`advertiseForOS("darwin"`}}, diff --git a/internal/netprint/config.go b/internal/netprint/config.go index 34bbe92..cdf119c 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -66,6 +66,9 @@ type Config struct { // sheet after each completed job. Nil and false do not. The root daemon // never presents the sheet; it only tells the user's agent. AirDropEnabled *bool `json:"airDropEnabled,omitempty"` + // ShareReceiptsSMB shares the macOS receipts folder over SMB. Nil and + // false do not. The installer checkbox and Settings both write this. + ShareReceiptsSMB *bool `json:"shareReceiptsSMB,omitempty"` } const ( @@ -94,6 +97,11 @@ func (c Config) EmailOn() bool { return c.EmailEnabled != nil && *c.EmailEnabled // AirDropOn reports whether the menu-bar app should AirDrop each completed job. func (c Config) AirDropOn() bool { return c.AirDropEnabled != nil && *c.AirDropEnabled } +// ShareReceiptsOn reports whether the receipts folder should be an SMB share. +func (c Config) ShareReceiptsOn() bool { + return c.ShareReceiptsSMB != nil && *c.ShareReceiptsSMB +} + // MailRecipients splits EmailTo. An empty setting uses DefaultEmailTo. func (c Config) MailRecipients() []string { raw := strings.TrimSpace(c.EmailTo) @@ -125,21 +133,23 @@ func Defaults() Config { adv := true emailOff := false airDropOff := false + shareOff := false return Config{ - Name: defaultServiceName(), - Port: 631, - FallbackPort: DefaultFallbackPort, - ControlPort: DefaultControlPort, - DataDir: defaultDataDir(), - KeepPDF: &keep, - Advertise: &adv, - EmailEnabled: &emailOff, - AirDropEnabled: &airDropOff, - EmailTo: DefaultEmailTo, - SMTPHost: DefaultSMTPHost, - SMTPPort: 587, - SMTPTLS: "starttls", - SMTPUser: DefaultEmailTo, + Name: defaultServiceName(), + Port: 631, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, + DataDir: defaultDataDir(), + KeepPDF: &keep, + Advertise: &adv, + EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, + ShareReceiptsSMB: &shareOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } } @@ -341,6 +351,10 @@ func mergeConfig(dst *Config, src Config) { v := *src.AirDropEnabled dst.AirDropEnabled = &v } + if src.ShareReceiptsSMB != nil { + v := *src.ShareReceiptsSMB + dst.ShareReceiptsSMB = &v + } } func (c *Config) normalize() error { @@ -378,6 +392,10 @@ func (c *Config) normalize() error { f := false c.AirDropEnabled = &f } + if c.ShareReceiptsSMB == nil { + f := false + c.ShareReceiptsSMB = &f + } if strings.TrimSpace(c.EmailTo) == "" { c.EmailTo = DefaultEmailTo } @@ -408,23 +426,25 @@ func ExampleJSON(dataDir string) []byte { public := false emailOff := false airDropOff := false + shareOff := false c := Config{ - Name: defaultServiceName(), - Port: 631, - FallbackPort: DefaultFallbackPort, - ControlPort: DefaultControlPort, - DataDir: dataDir, - KeepPDF: &keep, - ForwardPrinter: "", - Advertise: &adv, - AllowPublic: &public, - EmailEnabled: &emailOff, - AirDropEnabled: &airDropOff, - EmailTo: DefaultEmailTo, - SMTPHost: DefaultSMTPHost, - SMTPPort: 587, - SMTPTLS: "starttls", - SMTPUser: DefaultEmailTo, + Name: defaultServiceName(), + Port: 631, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, + DataDir: dataDir, + KeepPDF: &keep, + ForwardPrinter: "", + Advertise: &adv, + AllowPublic: &public, + EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, + ShareReceiptsSMB: &shareOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } b, _ := json.MarshalIndent(c, "", " ") return append(b, '\n') diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 1285a11..d94eeea 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -50,6 +50,15 @@ type Status struct { Advertise bool `json:"advertise"` AirDropEnabled bool `json:"airDropEnabled"` SupportsAirDrop bool `json:"supportsAirDrop"` + // ShareReceiptsSMB is the saved choice to publish the receipts folder. + ShareReceiptsSMB bool `json:"shareReceiptsSMB"` + // SupportsSMBShare is true on macOS, where sharing(8) can create the point. + SupportsSMBShare bool `json:"supportsSMBShare"` + // SMBURL is the live address, for example + // smb://Georges-MacBook-Pro.local/Verae%20Receipts. Empty when the share is off. + SMBURL string `json:"smbUrl,omitempty"` + // SMBError is set when the share could not be created or refreshed. + SMBError string `json:"smbError,omitempty"` // ReceiptsDir is the folder of job files. On macOS it is separate from // the program so it can be shared read-only. ReceiptsDir string `json:"receiptsDir"` @@ -147,6 +156,16 @@ func (c *Client) SetAirDropEnabled(on bool) error { return c.put("/api/v1/settings", map[string]bool{"airDropEnabled": on}, nil) } +// SetShareReceiptsSMB turns the macOS receipts SMB share on or off. +func (c *Client) SetShareReceiptsSMB(on bool) error { + return c.put("/api/v1/settings", map[string]bool{"shareReceiptsSMB": on}, nil) +} + +// EnsureReceiptsShare asks the daemon to recreate the share when the saved choice is on. +func (c *Client) EnsureReceiptsShare() error { + return c.post("/api/v1/receipts-share/ensure", map[string]bool{}, nil) +} + // Auth asks the service to store an xVerae- signing key. func (c *Client) Auth(apiKey string) error { return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil) @@ -210,6 +229,7 @@ func (s *Server) status() Status { s.mu.Lock() mdns := s.mdns emailErr := s.emailErr + smbErr := s.smbErr s.mu.Unlock() last := "" if !mdns.LastSent.IsZero() { @@ -222,39 +242,43 @@ func (s *Server) status() Status { emailErr = "SMTP password is not set" } return Status{ - Running: true, - Name: cfg.Name, - Port: s.port, - Addresses: addrs, - AdminURL: admin, - JobsToday: today, - Recent: recent, - ForwardPrinter: cfg.ForwardPrinter, - LaunchAtLogin: launch, - TrayAtLogin: tray, - KeyPresent: auth.HasXVeraeToken(), - ControlURL: s.controlURL, - MDNSAdvertising: mdns.Advertising, - MDNSMode: mdns.Mode, - MDNSDetail: mdns.Detail, - MDNSError: mdns.Err, - MDNSLastSent: last, - AllowPublic: cfg.PublicNetworks(), - NetworkPublic: cachedNetworkPublic(), - SupportsPublic: supportsPublicFirewall(), - ReceiptsURL: admin, - EmailEnabled: cfg.EmailOn(), - EmailTo: cfg.EmailTo, - EmailError: emailErr, - SMTPHost: cfg.SMTPHost, - SMTPUser: cfg.SMTPUser, - SMTPPasswordSet: passwordSet, - ReceiptsPINSet: pinSet, - Advertise: cfg.MDNS(), - AirDropEnabled: cfg.AirDropOn(), - SupportsAirDrop: runtime.GOOS == "darwin", - ReceiptsDir: cfg.DataDir, - IPPBind: s.ippBind, + Running: true, + Name: cfg.Name, + Port: s.port, + Addresses: addrs, + AdminURL: admin, + JobsToday: today, + Recent: recent, + ForwardPrinter: cfg.ForwardPrinter, + LaunchAtLogin: launch, + TrayAtLogin: tray, + KeyPresent: auth.HasXVeraeToken(), + ControlURL: s.controlURL, + MDNSAdvertising: mdns.Advertising, + MDNSMode: mdns.Mode, + MDNSDetail: mdns.Detail, + MDNSError: mdns.Err, + MDNSLastSent: last, + AllowPublic: cfg.PublicNetworks(), + NetworkPublic: cachedNetworkPublic(), + SupportsPublic: supportsPublicFirewall(), + ReceiptsURL: admin, + EmailEnabled: cfg.EmailOn(), + EmailTo: cfg.EmailTo, + EmailError: emailErr, + SMTPHost: cfg.SMTPHost, + SMTPUser: cfg.SMTPUser, + SMTPPasswordSet: passwordSet, + ReceiptsPINSet: pinSet, + Advertise: cfg.MDNS(), + AirDropEnabled: cfg.AirDropOn(), + SupportsAirDrop: runtime.GOOS == "darwin", + ShareReceiptsSMB: cfg.ShareReceiptsOn(), + SupportsSMBShare: supportsSMBShare(), + SMBURL: smbURLIfOn(cfg), + SMBError: smbErr, + ReceiptsDir: cfg.DataDir, + IPPBind: s.ippBind, } } @@ -372,22 +396,23 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { return } var body struct { - Name *string `json:"name"` - ForwardPrinter *string `json:"forwardPrinter"` - Port *int `json:"port"` - AllowPublic *bool `json:"allowPublic"` - EmailEnabled *bool `json:"emailEnabled"` - EmailTo *string `json:"emailTo"` - SMTPHost *string `json:"smtpHost"` - SMTPPort *int `json:"smtpPort"` - SMTPTLS *string `json:"smtpTLS"` - SMTPUser *string `json:"smtpUser"` - SMTPPassword *string `json:"smtpPassword"` - ClearPassword *bool `json:"clearPassword"` - ReceiptsPIN *string `json:"receiptsPin"` - ClearPIN *bool `json:"clearPin"` - Advertise *bool `json:"advertise"` - AirDropEnabled *bool `json:"airDropEnabled"` + Name *string `json:"name"` + ForwardPrinter *string `json:"forwardPrinter"` + Port *int `json:"port"` + AllowPublic *bool `json:"allowPublic"` + EmailEnabled *bool `json:"emailEnabled"` + EmailTo *string `json:"emailTo"` + SMTPHost *string `json:"smtpHost"` + SMTPPort *int `json:"smtpPort"` + SMTPTLS *string `json:"smtpTLS"` + SMTPUser *string `json:"smtpUser"` + SMTPPassword *string `json:"smtpPassword"` + ClearPassword *bool `json:"clearPassword"` + ReceiptsPIN *string `json:"receiptsPin"` + ClearPIN *bool `json:"clearPin"` + Advertise *bool `json:"advertise"` + AirDropEnabled *bool `json:"airDropEnabled"` + ShareReceiptsSMB *bool `json:"shareReceiptsSMB"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { http.Error(w, "bad settings", http.StatusBadRequest) @@ -421,6 +446,12 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { v := *body.AirDropEnabled cfg.AirDropEnabled = &v } + shareChanged := false + if body.ShareReceiptsSMB != nil { + v := *body.ShareReceiptsSMB + cfg.ShareReceiptsSMB = &v + shareChanged = true + } if body.EmailTo != nil { cfg.EmailTo = strings.TrimSpace(*body.EmailTo) } @@ -461,8 +492,11 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { applyFirewall(*body.AllowPublic) log.Printf("netprint: firewall public networks=%v", *body.AllowPublic) } - log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) + log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v share=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn(), cfg.ShareReceiptsOn()) s.syncAdvertiser() + if shareChanged { + s.noteShare(cfg.ShareReceiptsOn()) + } writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"}) } @@ -543,11 +577,15 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { cfg.AllowPublic = &pub emailOn := r.FormValue("emailEnabled") == "1" cfg.EmailEnabled = &emailOn + shareChanged := false if runtime.GOOS == "darwin" { adv := r.FormValue("advertise") == "1" cfg.Advertise = &adv air := r.FormValue("airDropEnabled") == "1" cfg.AirDropEnabled = &air + share := r.FormValue("shareReceiptsSMB") == "1" + cfg.ShareReceiptsSMB = &share + shareChanged = true } cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo")) cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost")) @@ -591,8 +629,11 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { return } } - log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) + log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v share=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn(), cfg.ShareReceiptsOn()) s.syncAdvertiser() + if shareChanged { + s.noteShare(cfg.ShareReceiptsOn()) + } http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) return } @@ -609,12 +650,19 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { } mac := "" if runtime.GOOS == "darwin" { + shareNote := "" + if u := smbURLIfOn(cfg); u != "" { + shareNote = fmt.Sprintf(`

Files: %s. On iPhone, open the Files app and connect to that server. The first connection needs a username and password from this Mac. Guest access is off.

`, htmlEscape(u), htmlEscape(u)) + } mac = fmt.Sprintf(`

Mac

mDNSResponder registers _ipp._tcp with the AirPrint _universal and IPP Everywhere _print subtypes. macOS asks once for Local Network permission for verae-print. Allow it.

-

The menu-bar app, running in your login session, opens the AirDrop share sheet with the stamped certificate and the receipt JSON. The root daemon does not.

`, - checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn())) +

The menu-bar app, running in your login session, opens the AirDrop share sheet with the stamped certificate and the receipt JSON. The root daemon does not.

+ +

Shares /Users/Shared/Verae Receipts as Verae Receipts. SMB only, guest access off, read-only. File Sharing stays on across reboots. The folder does not contain the program.

+%s`, + checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn()), checkedAttr(cfg.ShareReceiptsOn()), shareNote) } var b strings.Builder b.WriteString("Verae network printer") diff --git a/internal/netprint/receipts.go b/internal/netprint/receipts.go index b32167d..5ef2d9b 100644 --- a/internal/netprint/receipts.go +++ b/internal/netprint/receipts.go @@ -159,6 +159,11 @@ p{margin:4px 0} a.btn{display:inline-block;min-height:44px;line-height:44px;padding:0 14px;margin:8px 8px 0 0;background:#111;color:#fff;text-decoration:none;border-radius:8px} `) fmt.Fprintf(&b, "

%s

Recent timestamped jobs, newest first.

", html.EscapeString(name)) + if a.SMBURL != nil { + if u := strings.TrimSpace(a.SMBURL()); u != "" { + fmt.Fprintf(&b, `

Open these files from another device: %s

On iPhone, open the Files app and connect to that server. The first connection needs a username and password from this Mac. Guest access is off.

`, html.EscapeString(u), html.EscapeString(u)) + } + } if r.Host != "" { shortcut := "http://" + r.Host + "/ios-shortcut" fmt.Fprintf(&b, `

iPhone Shortcut

`) diff --git a/internal/netprint/server.go b/internal/netprint/server.go index c1c537e..4cc07d6 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -33,6 +33,7 @@ type Server struct { mdnsGen int mdns mdnsReport emailErr string + smbErr string uuid string jobs jobBus ippBind string @@ -110,7 +111,12 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) } - admin := &Admin{Store: st, Name: func() string { return s.snapshot().Name }, Port: func() int { return s.port }} + admin := &Admin{ + Store: st, + Name: func() string { return s.snapshot().Name }, + Port: func() int { return s.port }, + SMBURL: func() string { return smbURLIfOn(s.snapshot()) }, + } pub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && (strings.Contains(r.Header.Get("Content-Type"), "application/ipp") || strings.HasPrefix(r.URL.Path, "/ipp")) { s.printer.ServeHTTP(w, r) @@ -155,6 +161,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth) ctrlMux.HandleFunc("/api/v1/events", s.handleEvents) ctrlMux.HandleFunc("/api/v1/airdrop/ack", s.handleAirDropAck) + ctrlMux.HandleFunc("/api/v1/receipts-share/ensure", s.handleEnsureShare) ctrlMux.HandleFunc("/settings", s.handleSettingsPage) ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !loopback(r) { @@ -177,10 +184,56 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) go func() { _ = s.ippSrv.Serve(ippLn) }() go func() { _ = s.controlSrv.Serve(ctlLn) }() s.syncAdvertiser() + go s.syncReceiptsShare() log.Printf("netprint: IPP on %s (%s), receipts %s, control on %s", listenerAddrs(ippLn), s.ippBind, cfg.DataDir, ctlLn.Addr()) return s, nil } +func (s *Server) noteShare(on bool) { + err := applyReceiptsShare(on) + s.mu.Lock() + if err != nil { + s.smbErr = err.Error() + } else { + s.smbErr = "" + } + s.mu.Unlock() + if err != nil { + log.Printf("netprint: receipts share: %v", err) + return + } + if on { + if u := smbURLIfOn(s.snapshot()); u != "" { + log.Printf("netprint: receipts share %s", u) + } + return + } + log.Printf("netprint: receipts share off") +} + +func (s *Server) syncReceiptsShare() { + if !s.snapshot().ShareReceiptsOn() { + return + } + s.noteShare(true) +} + +func (s *Server) handleEnsureShare(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.snapshot().ShareReceiptsOn() { + s.noteShare(true) + } + st := s.status() + writeJSON(w, map[string]any{ + "shareReceiptsSMB": st.ShareReceiptsSMB, + "smbUrl": st.SMBURL, + "smbError": st.SMBError, + }) +} + func (s *Server) snapshot() Config { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/netprint/service.go b/internal/netprint/service.go index eb58803..662e15c 100644 --- a/internal/netprint/service.go +++ b/internal/netprint/service.go @@ -141,18 +141,31 @@ type InstallOptions struct { } func noteInstallOptions(cfgPath string, opts InstallOptions) { - if opts.AllowPublic == nil { - return - } cfg, err := Load(cfgPath, Config{}) if err != nil { log.Printf("netprint: install options: %v", err) return } - v := *opts.AllowPublic - cfg.AllowPublic = &v + changed := false + if opts.AllowPublic != nil { + v := *opts.AllowPublic + cfg.AllowPublic = &v + changed = true + } + if consumeReceiptsShareStamp(&cfg) { + changed = true + } + if !changed { + return + } if err := Save(cfgPath, cfg); err != nil { - log.Printf("netprint: save allowPublic: %v", err) + log.Printf("netprint: save install options: %v", err) + return + } + if cfg.ShareReceiptsOn() { + if err := applyReceiptsShare(true); err != nil { + log.Printf("netprint: receipts share: %v", err) + } } } diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index 9ab8b2e..9855c50 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -222,15 +222,6 @@ func Uninstall(owner string) error { return nil } -// removeReceiptsShare drops the optional read-only SMB point. The receipts -// folder itself stays. sharing -r is a no-op when the point was never created. -func removeReceiptsShare() { - if _, err := exec.LookPath("sharing"); err != nil { - return - } - _ = exec.Command("sharing", "-r", "Verae Receipts").Run() -} - func currentServiceExe() string { b, err := os.ReadFile(daemonPlist) if err != nil { diff --git a/internal/netprint/share.go b/internal/netprint/share.go new file mode 100644 index 0000000..3dce608 --- /dev/null +++ b/internal/netprint/share.go @@ -0,0 +1,118 @@ +package netprint + +import ( + "log" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const receiptsShareName = "Verae Receipts" + +// receiptsShareStampPath is written by the optional installer package when +// the config file does not exist yet. Install consumes it once. +func receiptsShareStampPath() string { + return filepath.Join(filepath.Dir(SystemConfigPath()), "share-receipts-smb.on") +} + +// formatSMBURL is the address shown on the receipts page, for example +// smb://Georges-MacBook-Pro.local/Verae%20Receipts. An IP is used as-is. +func formatSMBURL(host string) string { + host = strings.TrimSpace(host) + host = strings.TrimSuffix(host, ".") + if host == "" || strings.EqualFold(host, "localhost") { + host = "localhost" + } else if net.ParseIP(host) == nil && !strings.HasSuffix(strings.ToLower(host), ".local") { + host += ".local" + } + return "smb://" + host + "/" + url.PathEscape(receiptsShareName) +} + +func smbHost() string { + h, err := os.Hostname() + if err == nil { + h = strings.TrimSpace(h) + h = strings.TrimSuffix(h, ".local") + if h != "" && !strings.EqualFold(h, "localhost") { + return h + } + } + if ips := lanIPv4(); len(ips) > 0 { + return ips[0].String() + } + return "localhost" +} + +func smbURLIfOn(cfg Config) string { + if !cfg.ShareReceiptsOn() || !supportsSMBShare() { + return "" + } + return formatSMBURL(smbHost()) +} + +// EnableReceiptsShare records shareReceiptsSMB and creates the share. +// It does not start the print server. The macOS installer calls it. +func EnableReceiptsShare() error { + path := SystemConfigPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + cfg, err := Load(path, Config{}) + if err != nil { + return err + } + on := true + cfg.ShareReceiptsSMB = &on + if err := cfg.normalize(); err != nil { + return err + } + if err := Save(path, cfg); err != nil { + return err + } + _ = os.Remove(receiptsShareStampPath()) + if err := applyReceiptsShare(true); err != nil { + return err + } + kickstartNetprint() + return nil +} + +// consumeReceiptsShareStamp turns the share on when the installer left a +// marker because the config file was not there yet. The marker is removed +// so a later upgrade does not turn the share back on after Settings saved off. +func consumeReceiptsShareStamp(cfg *Config) bool { + p := receiptsShareStampPath() + if _, err := os.Stat(p); err != nil { + return false + } + on := true + cfg.ShareReceiptsSMB = &on + _ = os.Remove(p) + return true +} + +// healReceiptsShare asks a running daemon to recreate a missing share. +// The menu-bar process is not root, so it does not call sharing(8) itself. +func healReceiptsShare() { + if os.Getenv("VERAE_TRAY_SMOKE") == "1" { + return + } + go func() { + c := NewClient(DefaultControlPort) + var last error + for i := 0; i < 5; i++ { + if err := c.EnsureReceiptsShare(); err == nil { + return + } else { + last = err + } + time.Sleep(time.Second) + } + if last != nil { + log.Printf("netprint tray: receipts share: %v", last) + } + }() +} diff --git a/internal/netprint/share_darwin.go b/internal/netprint/share_darwin.go new file mode 100644 index 0000000..0f5f861 --- /dev/null +++ b/internal/netprint/share_darwin.go @@ -0,0 +1,101 @@ +//go:build darwin + +package netprint + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func supportsSMBShare() bool { return true } + +// applyReceiptsShare creates or removes the read-only SMB point. +// The folder stays root-owned and not world-writable. Guests stay off. +// File Sharing is enabled so the share is still there after reboot. +func applyReceiptsShare(on bool) error { + if !on { + removeReceiptsShare() + return nil + } + if err := os.MkdirAll(darwinSharedReceiptsDir, 0o755); err != nil { + return err + } + _ = os.Chown(darwinSharedReceiptsDir, 0, 0) + if err := os.Chmod(darwinSharedReceiptsDir, 0o755); err != nil { + return err + } + if _, err := exec.LookPath("sharing"); err != nil { + return fmt.Errorf("sharing(8) is not available") + } + if !receiptsSharePresent() { + // -s 001 is SMB only. -g 000 keeps guest access off. + out, err := exec.Command("sharing", + "-a", darwinSharedReceiptsDir, + "-n", receiptsShareName, + "-S", receiptsShareName, + "-s", "001", + "-g", "000", + ).CombinedOutput() + if err != nil && !receiptsSharePresent() { + return fmt.Errorf("sharing: %w (%s)", err, strings.TrimSpace(string(out))) + } + } + setReceiptsShareReadOnly() + ensureFileSharing() + return nil +} + +func receiptsSharePresent() bool { + out, err := exec.Command("sharing", "-l").CombinedOutput() + if err != nil { + return false + } + text := string(out) + return strings.Contains(text, receiptsShareName) && strings.Contains(text, darwinSharedReceiptsDir) +} + +func setReceiptsShareReadOnly() { + if _, err := exec.LookPath("dscl"); err != nil { + return + } + rec := "/SharePoints/" + receiptsShareName + if err := exec.Command("dscl", ".", "-read", rec).Run(); err != nil { + return + } + for _, kv := range [][2]string{ + {"smb_shared", "1"}, + {"smb_guestaccess", "0"}, + {"smb_read_only", "1"}, + {"directory_path", darwinSharedReceiptsDir}, + } { + _ = exec.Command("dscl", ".", "-create", rec, kv[0], kv[1]).Run() + } +} + +// ensureFileSharing turns smbd on and leaves it enabled across reboots. +// A running smbd is not restarted. +func ensureFileSharing() { + _ = exec.Command("launchctl", "enable", "system/com.apple.smbd").Run() + out, err := exec.Command("launchctl", "print", "system/com.apple.smbd").CombinedOutput() + if err == nil && strings.Contains(string(out), "state = running") { + return + } + if exec.Command("launchctl", "bootstrap", "system", "/System/Library/LaunchDaemons/com.apple.smbd.plist").Run() == nil { + return + } + _ = exec.Command("launchctl", "kickstart", "system/com.apple.smbd").Run() +} + +// removeReceiptsShare drops the SMB point. The receipts folder stays. +func removeReceiptsShare() { + if _, err := exec.LookPath("sharing"); err != nil { + return + } + _ = exec.Command("sharing", "-r", receiptsShareName).Run() +} + +func kickstartNetprint() { + _ = exec.Command("launchctl", "kickstart", "-k", "system/com.verae.netprint").Run() +} diff --git a/internal/netprint/share_other.go b/internal/netprint/share_other.go new file mode 100644 index 0000000..a5d6a3f --- /dev/null +++ b/internal/netprint/share_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package netprint + +func supportsSMBShare() bool { return false } + +func applyReceiptsShare(bool) error { return nil } + +func removeReceiptsShare() {} + +func kickstartNetprint() {} diff --git a/internal/netprint/share_test.go b/internal/netprint/share_test.go new file mode 100644 index 0000000..5127c09 --- /dev/null +++ b/internal/netprint/share_test.go @@ -0,0 +1,111 @@ +package netprint + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func TestFormatSMBURL(t *testing.T) { + cases := map[string]string{ + "Georges-MacBook-Pro": "smb://Georges-MacBook-Pro.local/Verae%20Receipts", + "Georges-MacBook-Pro.local": "smb://Georges-MacBook-Pro.local/Verae%20Receipts", + "192.168.1.108": "smb://192.168.1.108/Verae%20Receipts", + "": "smb://localhost/Verae%20Receipts", + } + for in, want := range cases { + if got := formatSMBURL(in); got != want { + t.Fatalf("%q -> %s, want %s", in, got, want) + } + } + if Defaults().ShareReceiptsOn() { + t.Fatal("share defaults off") + } + sample := string(ExampleJSON("/tmp")) + if !strings.Contains(sample, `"shareReceiptsSMB": false`) { + t.Fatal(sample) + } +} + +func TestReceiptsPageShowsSMBURL(t *testing.T) { + st, err := OpenStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const link = "smb://Georges-MacBook-Pro.local/Verae%20Receipts" + a := &Admin{Store: st, SMBURL: func() string { return link }} + req := httptest.NewRequest(http.MethodGet, "/receipts", nil) + rr := httptest.NewRecorder() + a.ServeHTTP(rr, req) + body := rr.Body.String() + if !strings.Contains(body, link) || !strings.Contains(body, "username and password") { + t.Fatal(body) + } +} + +func TestMenuShareToggleUsesLiveURL(t *testing.T) { + st := Status{ + Running: true, SupportsSMBShare: true, ShareReceiptsSMB: true, + SMBURL: "smb://Georges-MacBook-Pro.local/Verae%20Receipts", + } + var sawToggle, sawURL bool + for _, e := range BuildMenu(st) { + if e.Kind == "share" && e.Checked && e.Label == "Share receipts folder over SMB" { + sawToggle = true + } + if strings.Contains(e.Label, st.SMBURL) { + sawURL = true + } + } + if !sawToggle || !sawURL { + t.Fatalf("toggle %v url %v", sawToggle, sawURL) + } + off := BuildMenu(Status{Running: true}) + for _, e := range off { + if e.Kind == "share" { + t.Fatal("share toggle is macOS only") + } + } +} + +func TestShareReceiptsSettingRoundTrip(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + c := NewClient(cfg.ControlPort) + if err := c.SetShareReceiptsSMB(true); err != nil { + t.Fatal(err) + } + st, err := c.Status() + if err != nil || !st.ShareReceiptsSMB { + t.Fatalf("%+v %v", st, err) + } + if supportsSMBShare() { + if st.SMBURL == "" || !strings.Contains(st.SMBURL, "Verae%20Receipts") { + t.Fatal(st.SMBURL) + } + } else if st.SMBURL != "" { + t.Fatal(st.SMBURL) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(saved), `"shareReceiptsSMB": true`) { + t.Fatal(string(saved)) + } + if err := c.EnsureReceiptsShare(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/netprint/tray.go b/internal/netprint/tray.go index 889f9aa..321e951 100644 --- a/internal/netprint/tray.go +++ b/internal/netprint/tray.go @@ -5,7 +5,10 @@ import "context" // RunTray blocks in the platform menu (Windows notification area, macOS status // item, or a freedesktop StatusNotifierItem). It talks to the print service // over the loopback control API. -func RunTray(ctx context.Context) error { return runTray(ctx) } +func RunTray(ctx context.Context) error { + healReceiptsShare() + return runTray(ctx) +} // trayLoopExit describes why the Windows message loop returned. func trayLoopExit(code int32) string { diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go index d8fae34..3f3b7f3 100644 --- a/internal/netprint/tray_menu.go +++ b/internal/netprint/tray_menu.go @@ -19,6 +19,7 @@ const ( idAirDrop = 13 idAdvertise = 14 idAirDropAgain = 15 + idShare = 16 idQuit = 100 idRecent = 20 ) @@ -85,6 +86,14 @@ func BuildMenu(st Status) []MenuEntry { if st.SupportsAirDrop { out = append(out, MenuEntry{ID: idAirDrop, Label: "AirDrop stamped PDF after each job", Enabled: st.Running, Checked: st.AirDropEnabled, Kind: "airdrop"}) } + if st.SupportsSMBShare { + out = append(out, MenuEntry{ID: idShare, Label: "Share receipts folder over SMB", Enabled: st.Running, Checked: st.ShareReceiptsSMB, Kind: "share"}) + } + if st.SMBError != "" { + out = append(out, MenuEntry{Label: "SMB — " + clipMenu(st.SMBError, 90), Kind: "status"}) + } else if st.SMBURL != "" { + out = append(out, MenuEntry{Label: "Files — " + st.SMBURL, Kind: "status"}) + } if st.EmailError != "" { out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"}) } else if st.EmailEnabled { @@ -231,6 +240,8 @@ func Dispatch(st Status, e MenuEntry) error { return c.SetAdvertise(!st.Advertise) case "airdrop": return c.SetAirDropEnabled(!st.AirDropEnabled) + case "share": + return c.SetShareReceiptsSMB(!st.ShareReceiptsSMB) case "airdrop-again": return airDropAgain() case "open": diff --git a/platform/macos/menubar/main.swift b/platform/macos/menubar/main.swift index 5efc166..52ab0d9 100644 --- a/platform/macos/menubar/main.swift +++ b/platform/macos/menubar/main.swift @@ -71,6 +71,9 @@ struct PrinterStatus { var control = controlDefault var address = "" var port = 0 + var supportsShare = false + var shareOn = false + var smbURL = "" } private func fetchStatus() -> PrinterStatus { @@ -96,6 +99,9 @@ private func fetchStatus() -> PrinterStatus { if let c = obj["controlUrl"] as? String, !c.isEmpty { st.control = c } st.port = obj["port"] as? Int ?? 0 if let addrs = obj["addresses"] as? [String], let first = addrs.first { st.address = first } + st.supportsShare = obj["supportsSMBShare"] as? Bool ?? false + st.shareOn = obj["shareReceiptsSMB"] as? Bool ?? false + st.smbURL = obj["smbUrl"] as? String ?? "" return st } @@ -254,6 +260,7 @@ final class MenuTray: NSObject, NSMenuDelegate { item.menu = menu events.tray = self events.start() + _ = postJSON("/api/v1/receipts-share/ensure", [String: Any]()) rebuild() } @@ -300,6 +307,13 @@ final class MenuTray: NSObject, NSMenuDelegate { menu.addItem(.separator()) add("Open Receipts…", action: #selector(openReceipts), enabled: !receiptsURL().isEmpty) add("Open Settings…", action: #selector(openSettings), enabled: true) + if status.supportsShare { + let title = status.shareOn ? "Share receipts folder over SMB ✓" : "Share receipts folder over SMB" + add(title, action: #selector(toggleShare), enabled: status.running) + } + if !status.smbURL.isEmpty { + add("Files — \(status.smbURL)", action: nil, enabled: false) + } if !lastPDF.isEmpty { let label = lastReceipt.isEmpty ? "AirDrop again" : "AirDrop again · \(lastReceipt)" add(label, action: #selector(airDropAgain), enabled: true) @@ -335,6 +349,12 @@ final class MenuTray: NSObject, NSMenuDelegate { @objc func openReceipts() { open(receiptsURL()) } @objc func openSettings() { open(settingsURL()) } + @objc func toggleShare() { + _ = postJSON("/api/v1/settings", ["shareReceiptsSMB": !status.shareOn]) + status = fetchStatus() + rebuild() + } + @objc func airDropAgain() { guard !lastPDF.isEmpty else { trayLog("no stamped PDF to AirDrop") diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 8edda15..1052a8e 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -157,7 +157,7 @@ cat >"$DIST" < - + netprint-component.pkg diff --git a/platform/macos/pkg/check-pkg.sh b/platform/macos/pkg/check-pkg.sh index eb8f8ba..8f8ebbb 100644 --- a/platform/macos/pkg/check-pkg.sh +++ b/platform/macos/pkg/check-pkg.sh @@ -63,5 +63,5 @@ if [[ ! -f "$dist" ]]; then echo "expanded package has no Distribution" >&2 exit 1 fi -grep -q 'Share receipts over SMB' "$dist" +grep -q 'Share receipts folder over the network (SMB)' "$dist" echo "package ok: $PKG" diff --git a/platform/macos/pkg/smb-share.sh b/platform/macos/pkg/smb-share.sh old mode 100644 new mode 100755 index 494343e..0fa7a5c --- a/platform/macos/pkg/smb-share.sh +++ b/platform/macos/pkg/smb-share.sh @@ -1,13 +1,19 @@ #!/bin/bash -# Optional read-only SMB share of the macOS receipts folder. +# Optional SMB share of the macOS receipts folder. # The installer runs this only when that choice is checked. # The program stays in Application Support and is not in this folder. +# The first connection from iPhone Files needs a username and password +# from this Mac. Guest access stays off. set -euo pipefail DIR="/Users/Shared/Verae Receipts" NAME="Verae Receipts" +STAMP="/Library/Application Support/Verae/PrintStamp/share-receipts-smb.on" +BIN="/Library/Application Support/Verae/NetPrint/bin/verae-print" mkdir -p "$DIR" +# Root-owned, console-user-readable, not world-writable. No binaries live here. +chown root:wheel "$DIR" 2>/dev/null || chown root:admin "$DIR" 2>/dev/null || true chmod 755 "$DIR" if ! command -v sharing >/dev/null 2>&1; then @@ -15,15 +21,13 @@ if ! command -v sharing >/dev/null 2>&1; then exit 1 fi -# Replace a previous point so a rerun stays read-only and does not turn on write. +# Replace a previous point so a rerun stays SMB-only, guest-off, and read-only. if sharing -l 2>/dev/null | grep -F "$NAME" >/dev/null 2>&1; then sharing -r "$NAME" || true fi -# -S publishes SMB. -g 000 keeps guest access off. Authenticated users of -# this Mac can mount the share. smb_read_only is set explicitly because the -# sharing flags differ across macOS 14 and macOS 26. -sharing -a "$DIR" -S "$NAME" -n "$NAME" -g 000 +# -s 001 is SMB only. -g 000 keeps guest access off. +sharing -a "$DIR" -n "$NAME" -S "$NAME" -s 001 -g 000 if dscl . -read "/SharePoints/${NAME}" >/dev/null 2>&1; then dscl . -create "/SharePoints/${NAME}" smb_shared 1 @@ -32,8 +36,20 @@ if dscl . -read "/SharePoints/${NAME}" >/dev/null 2>&1; then dscl . -create "/SharePoints/${NAME}" directory_path "$DIR" fi +# File Sharing stays enabled across reboots. Do not bounce smbd if it is already up. launchctl enable system/com.apple.smbd >/dev/null 2>&1 || true -launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.smbd.plist >/dev/null 2>&1 || \ - launchctl kickstart -k system/com.apple.smbd >/dev/null 2>&1 || true +if ! launchctl print system/com.apple.smbd 2>/dev/null | grep -q "state = running"; then + launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.smbd.plist >/dev/null 2>&1 || \ + launchctl kickstart system/com.apple.smbd >/dev/null 2>&1 || true +fi -echo "SMB share \"${NAME}\" -> ${DIR} (read-only)" +mkdir -p "$(dirname "$STAMP")" +echo enabled >"$STAMP" + +# Persist shareReceiptsSMB in netprint.json and reload the daemon when it is already installed. +# If this package runs before the main package, the stamp above is enough. +if [[ -x "$BIN" ]]; then + "$BIN" netprint enable-receipts-share +fi + +echo "SMB share \"${NAME}\" -> ${DIR} (SMB only, guest off, read-only)" From 8f55e17f73ec44812f8b0d5a9de129b8145ba73e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 05:37:25 +0000 Subject: [PATCH 26/27] Send a document title as a verae-meta-git/1 history. A non-blank document name is the first snapshot title, with restamp left false so a reprint keeps the first job. A blank name stays digest-only. --- internal/metadatahist/history.go | 341 ++++++++++++++++++++++++++ internal/metadatahist/history_test.go | 52 ++++ internal/veraeapi/client.go | 29 ++- internal/veraeapi/client_test.go | 77 ++++++ 4 files changed, 493 insertions(+), 6 deletions(-) create mode 100644 internal/metadatahist/history.go create mode 100644 internal/metadatahist/history_test.go diff --git a/internal/metadatahist/history.go b/internal/metadatahist/history.go new file mode 100644 index 0000000..a04fa54 --- /dev/null +++ b/internal/metadatahist/history.go @@ -0,0 +1,341 @@ +// Package metadatahist builds verae-meta-git/1 documents the same way as +// node-red-contrib-verae lib/metadata-history.js. Blank values do not delete +// fields. A document that already uses the schema is stored as sent. +package metadatahist + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "time" +) + +const Schema = "verae-meta-git/1" + +func IsBlank(value any) bool { + if value == nil { + return true + } + switch v := value.(type) { + case string: + for _, r := range v { + if r != ' ' && r != '\t' && r != '\n' && r != '\r' { + return false + } + } + return true + case []any: + return len(v) == 0 + case map[string]any: + return len(v) == 0 + default: + return false + } +} + +func isObject(value any) (map[string]any, bool) { + m, ok := value.(map[string]any) + return m, ok +} + +func clone(value any) any { + switch v := value.(type) { + case []any: + out := make([]any, len(v)) + for i := range v { + out[i] = clone(v[i]) + } + return out + case map[string]any: + out := make(map[string]any, len(v)) + for k, item := range v { + out[k] = clone(item) + } + return out + default: + return v + } +} + +func jsonScalar(value any) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(value) + b := bytes.TrimRight(buf.Bytes(), "\n") + return string(b) +} + +func canonical(value any) string { + switch v := value.(type) { + case []any: + parts := make([]string, len(v)) + for i := range v { + parts[i] = canonical(v[i]) + } + return "[" + join(parts) + "]" + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = jsonScalar(k) + ":" + canonical(v[k]) + } + return "{" + join(parts) + "}" + default: + return jsonScalar(value) + } +} + +func join(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += "," + } + out += p + } + return out +} + +func same(a, b any) bool { return canonical(a) == canonical(b) } + +func ExtendSnapshot(base, patch map[string]any) map[string]any { + out := map[string]any{} + if base != nil { + if cloned, ok := clone(base).(map[string]any); ok { + out = cloned + } + } + if patch == nil { + return out + } + for key, next := range patch { + if IsBlank(next) { + continue + } + prev, has := out[key] + prevObj, prevIsObj := isObject(prev) + nextObj, nextIsObj := isObject(next) + prevArr, prevIsArr := prev.([]any) + nextArr, nextIsArr := next.([]any) + switch { + case has && prevIsObj && nextIsObj: + out[key] = ExtendSnapshot(prevObj, nextObj) + case prevIsArr && nextIsArr: + extra := nonBlank(nextArr) + if len(extra) > 0 { + out[key] = append(append([]any{}, prevArr...), extra...) + } + case nextIsArr: + extra := nonBlank(nextArr) + if len(extra) == 0 { + continue + } + if prevIsArr { + out[key] = append(append([]any{}, prevArr...), extra...) + } else { + out[key] = extra + } + case nextIsObj: + nestedBase := map[string]any{} + if prevIsObj { + nestedBase = prevObj + } + nested := ExtendSnapshot(nestedBase, nextObj) + if !IsBlank(nested) { + out[key] = nested + } + default: + out[key] = clone(next) + } + } + return out +} + +func nonBlank(items []any) []any { + out := make([]any, 0, len(items)) + for _, item := range items { + if !IsBlank(item) { + out = append(out, clone(item)) + } + } + return out +} + +func ChangesBetween(before, after map[string]any) map[string]any { + added := map[string]any{} + extended := map[string]any{} + updated := map[string]any{} + if before == nil { + before = map[string]any{} + } + if after == nil { + after = map[string]any{} + } + keys := map[string]struct{}{} + for k := range before { + keys[k] = struct{}{} + } + for k := range after { + keys[k] = struct{}{} + } + ordered := make([]string, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sort.Strings(ordered) + for _, key := range ordered { + next, hasNext := after[key] + if !hasNext { + continue + } + prev, hasPrev := before[key] + if !hasPrev { + added[key] = clone(next) + continue + } + if same(prev, next) { + continue + } + prevObj, prevIsObj := isObject(prev) + nextObj, nextIsObj := isObject(next) + prevArr, prevIsArr := prev.([]any) + nextArr, nextIsArr := next.([]any) + switch { + case prevIsObj && nextIsObj: + nested := ChangesBetween(prevObj, nextObj) + if !IsBlank(nested["added"]) || !IsBlank(nested["extended"]) || !IsBlank(nested["updated"]) { + extended[key] = nested + } + case prevIsArr && nextIsArr && len(nextArr) >= len(prevArr): + appended := nextArr[len(prevArr):] + if len(appended) > 0 { + extended[key] = map[string]any{"appended": clone(appended)} + } + default: + updated[key] = map[string]any{"from": clone(prev), "to": clone(next)} + } + } + return map[string]any{"added": added, "extended": extended, "updated": updated} +} + +func commitID(commit map[string]any) string { + material := canonical(map[string]any{ + "parent": commit["parent"], + "at": commit["at"], + "message": commit["message"], + "snapshot": commit["snapshot"], + }) + sum := sha256.Sum256([]byte(material)) + return hex.EncodeToString(sum[:]) +} + +func IsHistory(value any) bool { + m, ok := isObject(value) + if !ok || m["schema"] != Schema { + return false + } + if _, ok := m["commits"].([]any); !ok { + return false + } + _, ok = isObject(m["snapshot"]) + return ok +} + +func historyFromSnapshot(snapshot map[string]any, at string) map[string]any { + stored, _ := clone(snapshot).(map[string]any) + commit := map[string]any{ + "parent": nil, + "at": at, + "message": "stamp", + "snapshot": stored, + "changes": ChangesBetween(map[string]any{}, stored), + } + commit["id"] = commitID(commit) + return map[string]any{ + "schema": Schema, + "head": commit["id"], + "snapshot": clone(stored), + "commits": []any{commit}, + } +} + +func coerceHistory(existing any, at string) map[string]any { + if IsHistory(existing) { + cloned, _ := clone(existing).(map[string]any) + return cloned + } + if m, ok := isObject(existing); ok && !IsBlank(m) { + return historyFromSnapshot(m, at) + } + return map[string]any{ + "schema": Schema, + "head": nil, + "snapshot": map[string]any{}, + "commits": []any{}, + } +} + +func hasChanges(changes map[string]any) bool { + return !IsBlank(changes["added"]) || !IsBlank(changes["extended"]) || !IsBlank(changes["updated"]) +} + +// Append builds the next history document. A nil result means the caller +// should omit metadata. restamp with a blank patch still appends one commit. +func Append(existing any, patch map[string]any, restamp bool, at, priorAt, message string) map[string]any { + if at == "" { + at = time.Now().UTC().Format(time.RFC3339Nano) + } + blankPatch := patch == nil || IsBlank(patch) || IsBlank(ExtendSnapshot(map[string]any{}, patch)) + if !restamp && blankPatch && !IsHistory(existing) && (existing == nil || IsBlank(existing)) { + return nil + } + if priorAt == "" { + priorAt = at + } + doc := coerceHistory(existing, priorAt) + head, _ := doc["head"].(string) + if !restamp && blankPatch { + if head != "" { + return doc + } + return nil + } + base, _ := doc["snapshot"].(map[string]any) + snapshot := ExtendSnapshot(base, patch) + changes := ChangesBetween(base, snapshot) + if !restamp && !hasChanges(changes) && head != "" { + return doc + } + msg := message + if msg == "" { + if head != "" { + msg = "restamp" + } else { + msg = "stamp" + } + } + var parent any + if head != "" { + parent = head + } + commit := map[string]any{ + "parent": parent, + "at": at, + "message": msg, + "snapshot": snapshot, + "changes": changes, + } + commit["id"] = commitID(commit) + commits, _ := doc["commits"].([]any) + doc["commits"] = append(commits, commit) + doc["head"] = commit["id"] + doc["snapshot"] = snapshot + return doc +} diff --git a/internal/metadatahist/history_test.go b/internal/metadatahist/history_test.go new file mode 100644 index 0000000..6bd84e2 --- /dev/null +++ b/internal/metadatahist/history_test.go @@ -0,0 +1,52 @@ +package metadatahist + +import "testing" + +func TestAppendMatchesNodeReference(t *testing.T) { + first := Append(nil, map[string]any{"title": "deed", "tags": []any{"a"}}, false, "2026-10-11T01:00:00.000Z", "", "stamp") + if first["head"] != "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7" { + t.Fatalf("head1=%v", first["head"]) + } + second := Append(first, map[string]any{"title": "deed-2", "tags": []any{"b"}, "place": "office"}, true, "2026-10-11T02:00:00.000Z", "", "correct the title") + if second["head"] != "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918" { + t.Fatalf("head2=%v", second["head"]) + } + snap := second["snapshot"].(map[string]any) + if snap["title"] != "deed-2" || snap["place"] != "office" { + t.Fatalf("snapshot=%v", snap) + } + tags := snap["tags"].([]any) + if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" { + t.Fatalf("tags=%v", tags) + } + commits := second["commits"].([]any) + tip := commits[1].(map[string]any) + if tip["parent"] != first["head"] { + t.Fatalf("parent=%v", tip["parent"]) + } + if _, ok := tip["changes"].(map[string]any)["removed"]; ok { + t.Fatal("removed key") + } +} + +func TestBlankRestampKeepsSnapshot(t *testing.T) { + base := map[string]any{"title": "deed", "note": "keep", "tags": []any{"a"}} + next := Append(base, map[string]any{"title": "", "note": nil, "tags": []any{}}, true, "2026-10-11T01:47:00.000Z", "2026-10-11T01:00:00.000Z", "") + snap := next["snapshot"].(map[string]any) + if snap["title"] != "deed" || snap["note"] != "keep" { + t.Fatalf("snapshot=%v", snap) + } + commits := next["commits"].([]any) + if len(commits) != 2 { + t.Fatalf("commits=%d", len(commits)) + } +} + +func TestOmitWhenEmpty(t *testing.T) { + if Append(nil, nil, false, "2026-10-11T01:00:00.000Z", "", "") != nil { + t.Fatal("expected omit") + } + if Append(nil, map[string]any{}, false, "2026-10-11T01:00:00.000Z", "", "") != nil { + t.Fatal("expected omit") + } +} diff --git a/internal/veraeapi/client.go b/internal/veraeapi/client.go index e227571..1a8ccff 100644 --- a/internal/veraeapi/client.go +++ b/internal/veraeapi/client.go @@ -12,6 +12,7 @@ import ( "time" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/metadatahist" ) const ( @@ -73,18 +74,30 @@ func (c *Client) Stamp(digest, documentName string) (Receipt, error) { case auth.ChannelZapier: return c.stampZapier(digest, documentName) default: - return c.stampVerae(digest) + return c.stampVerae(digest, documentName) } } -func (c *Client) stampVerae(digest string) (Receipt, error) { +func historyForTitle(documentName string) map[string]any { + title := strings.TrimSpace(documentName) + if title == "" { + return nil + } + return metadatahist.Append(nil, map[string]any{"title": title}, false, "", "", "stamp") +} + +func (c *Client) stampVerae(digest, documentName string) (Receipt, error) { base := c.veraeBase() - body, _ := json.Marshal(map[string]any{ + payload := map[string]any{ "data": digest, "digest": digest, "hashAlg": "SHA256", "restamp": false, - }) + } + if doc := historyForTitle(documentName); doc != nil { + payload["metadata"] = doc + } + body, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body)) if err != nil { return Receipt{}, err @@ -287,12 +300,16 @@ func (c *Client) enrichFromHash(base string, rec Receipt) (Receipt, error) { func (c *Client) stampZapier(digest, documentName string) (Receipt, error) { base := c.zapierBase() - body, _ := json.Marshal(map[string]any{ + payload := map[string]any{ "sha256": digest, "attachPdf": true, "wait": true, "documentName": documentName, - }) + } + if doc := historyForTitle(documentName); doc != nil { + payload["metadata"] = doc + } + body, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body)) if err != nil { return Receipt{}, err diff --git a/internal/veraeapi/client_test.go b/internal/veraeapi/client_test.go index 4ae0f54..c20ca06 100644 --- a/internal/veraeapi/client_test.go +++ b/internal/veraeapi/client_test.go @@ -2,16 +2,93 @@ package veraeapi import ( "encoding/base64" + "encoding/json" "io" "net/http" "strings" "testing" + "time" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" ) const digest64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +func TestStampVeraeSendsTitleHistory(t *testing.T) { + var posted map[string]any + c := &Client{ + Channel: auth.ChannelVerae, + VeraeBase: "https://api.example.test", + HTTPDo: func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/api/timestamp" { + raw, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(raw, &posted) + body := `{"jobId":"job-1","certificate":"QmHist","existing":false}` + return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/status/") { + body := `{"status":"completed","result":"QmHist","completedAt":"2026-10-11T01:00:00Z"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" { + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil + } + t.Fatalf("path %s", req.URL.Path) + return nil, nil + }, + } + c.PollInterval = time.Millisecond + if _, err := c.Stamp(digest64, "Deed.pdf"); err != nil { + t.Fatal(err) + } + meta, _ := posted["metadata"].(map[string]any) + if meta["schema"] != "verae-meta-git/1" { + t.Fatalf("metadata=%v", posted["metadata"]) + } + snap, _ := meta["snapshot"].(map[string]any) + if snap["title"] != "Deed.pdf" { + t.Fatalf("snapshot=%v", snap) + } + if posted["restamp"] != false { + t.Fatalf("restamp=%v", posted["restamp"]) + } +} + +func TestStampVeraeOmitsMetadataWithoutTitle(t *testing.T) { + var posted map[string]any + c := &Client{ + Channel: auth.ChannelVerae, + VeraeBase: "https://api.example.test", + HTTPDo: func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/api/timestamp" { + raw, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(raw, &posted) + body := `{"jobId":"job-1","certificate":"QmPlain","existing":false}` + return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/status/") { + body := `{"status":"completed","result":"QmPlain","completedAt":"2026-10-11T01:00:00Z"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" { + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil + } + t.Fatalf("path %s", req.URL.Path) + return nil, nil + }, + } + c.PollInterval = time.Millisecond + if _, err := c.Stamp(digest64, " "); err != nil { + t.Fatal(err) + } + if _, ok := posted["metadata"]; ok { + t.Fatalf("metadata=%v", posted["metadata"]) + } + if posted["restamp"] != false { + t.Fatalf("restamp=%v", posted["restamp"]) + } +} + func TestStampVeraeImmediateCertificate(t *testing.T) { c := &Client{ Channel: auth.ChannelVerae, From f8444ec9beefb3407ac7de49782eaa73b2ec4e6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 06:44:16 +0000 Subject: [PATCH 27/27] Deploy the Linux verae-print checkout after tests pass. --- .forgejo/workflows/test-and-deploy.yml | 24 +++++ scripts/deploy-138.sh | 117 +++++++++++++++++++++++++ scripts/test-138.sh | 5 ++ 3 files changed, 146 insertions(+) create mode 100644 .forgejo/workflows/test-and-deploy.yml create mode 100755 scripts/deploy-138.sh create mode 100755 scripts/test-138.sh diff --git a/.forgejo/workflows/test-and-deploy.yml b/.forgejo/workflows/test-and-deploy.yml new file mode 100644 index 0000000..d38656b --- /dev/null +++ b/.forgejo/workflows/test-and-deploy.yml @@ -0,0 +1,24 @@ +name: test-and-deploy + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: 138 + steps: + - uses: actions/checkout@v4 + - name: Unit tests + run: bash scripts/test-138.sh + + deploy-138: + needs: test + if: success() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: 138 + steps: + - uses: actions/checkout@v4 + - name: Deploy the host checkout + run: bash scripts/deploy-138.sh diff --git a/scripts/deploy-138.sh b/scripts/deploy-138.sh new file mode 100755 index 0000000..2d4206b --- /dev/null +++ b/scripts/deploy-138.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Test, build, and install the Linux verae-print binary on ns1. +# Does not install a CUPS queue, and does not restart verae-timestamping-svc +# or forgejo-runner. Does not read secret files. +set -euo pipefail + +LOCK="${LOCK:-/tmp/verae-print-deploy.lock}" +DEST="${DEST:-/opt/verae/printtoveraetimestamp}" +BIN_LINK="${BIN_LINK:-/usr/local/bin/verae-print}" +BACKUP_ROOT="${BACKUP_ROOT:-/SSD2/verae-deploy-backups}" +GO_ROOT="${GO_ROOT:-/usr/local/lib/go1.26.9}" +GO_TARBALL="${GO_TARBALL:-go1.26.9.linux-amd64.tar.gz}" +GO_SHA256="${GO_SHA256:-42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +mkdir -p "$(dirname "$LOCK")" +exec 9>"$LOCK" +if ! flock -w 900 9; then + echo "another print deploy holds the lock" >&2 + exit 1 +fi + +prepare_cache() { + sudo mkdir -p "$BACKUP_ROOT/go-cache" "$BACKUP_ROOT/go-mod" + sudo chmod 1777 "$BACKUP_ROOT" "$BACKUP_ROOT/go-cache" "$BACKUP_ROOT/go-mod" + export GOCACHE="$BACKUP_ROOT/go-cache" + export GOMODCACHE="$BACKUP_ROOT/go-mod" + export GOPROXY="${GOPROXY:-https://proxy.golang.org,direct}" +} + +ensure_go() { + if [[ -x "$GO_ROOT/bin/go" ]] && "$GO_ROOT/bin/go" version | grep -q 'go1.26.9 '; then + export PATH="$GO_ROOT/bin:$PATH" + return + fi + local tmp + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/go.tgz" "https://go.dev/dl/${GO_TARBALL}" + echo "${GO_SHA256} ${tmp}/go.tgz" | sha256sum -c - + tar -C "$tmp" -xzf "$tmp/go.tgz" + sudo rm -rf "$GO_ROOT" + sudo mv "$tmp/go" "$GO_ROOT" + rm -rf "$tmp" + export PATH="$GO_ROOT/bin:$PATH" +} + +run_tests() { + prepare_cache + ensure_go + cd "$ROOT" + go test ./... -count=1 +} + +install_tree() { + local built stage previous ts sha + ts="$(date -u +%Y%m%dT%H%M%SZ)" + sha="$(git -C "$ROOT" rev-parse HEAD)" + cd "$ROOT" + built="$(mktemp)" + go build -o "$built" ./cmd/verae-print + if ! grep -a -q 'verae-meta-git/1' "$built"; then + echo "built binary is missing verae-meta-git/1" >&2 + rm -f "$built" + exit 1 + fi + if ! "$built" version >/dev/null; then + echo "built binary failed version" >&2 + rm -f "$built" + exit 1 + fi + stage="$(mktemp -d "$BACKUP_ROOT/print-stage.XXXXXX")" + sudo rsync -a \ + --exclude '.git/' \ + --exclude 'bin/' \ + "$ROOT/" "$stage/" + sudo mkdir -p "$stage/bin" + sudo cp "$built" "$stage/bin/verae-print" + sudo chmod 755 "$stage/bin/verae-print" + printf '%s\n' "$sha" | sudo tee "$stage/DEPLOYED_SHA" >/dev/null + rm -f "$built" + if ! sudo "$stage/bin/verae-print" version >/dev/null; then + sudo rm -rf "$stage" + echo "staged print binary failed version" >&2 + return 1 + fi + previous="" + if [[ -d "$DEST" ]]; then + previous="$BACKUP_ROOT/print-src-${ts}" + sudo mv "$DEST" "$previous" + fi + if ! sudo mv "$stage" "$DEST"; then + if [[ -n "$previous" ]]; then + sudo mv "$previous" "$DEST" + fi + echo "could not move the print checkout into place" >&2 + return 1 + fi + sudo ln -sfn "$DEST/bin/verae-print" "$BIN_LINK" + if ! "$BIN_LINK" version >/dev/null; then + echo "installed verae-print failed version" >&2 + if [[ -n "$previous" ]]; then + sudo rm -rf "$DEST" + sudo mv "$previous" "$DEST" + sudo ln -sfn "$DEST/bin/verae-print" "$BIN_LINK" + fi + return 1 + fi + echo "deployed ${ts} ${sha} version ok" +} + +run_tests +if [[ "${1:-}" == "--test-only" ]]; then + echo "tests ok" + exit 0 +fi + +install_tree diff --git a/scripts/test-138.sh b/scripts/test-138.sh new file mode 100755 index 0000000..76f6210 --- /dev/null +++ b/scripts/test-138.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Unit-test PrintToVeraeTimeStamp. Does not install the binary. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec bash "$ROOT/scripts/deploy-138.sh" --test-only
WhenJobUserStateReceipt
+

Verae · Print it

+

Print → Verae Timestamping

+

+ Digests-only timestamping. Create a free account at + customer.verae.ai, + mint an xVerae- signing key, then stamp from Print or the CLI. +

+
+ + + Sample Verae Timestamp Registration receipt + Verae explorer showing the receipt as VALID +
+ +
+

Downloads

+

Version 0.3.2

+ +
+
+
+

macOS

+

Apple silicon & Intel · Developer ID signed & notarized · Print Destination

+
+ +
+ +
+
+

Windows

+

GUI installer (NSIS) · Start Menu + PATH · finish page runs setup

+
+ +
+ +
+
+

Debian / Ubuntu

+

.deb package · verae-print + optional CUPS backend

+
+ +
+ +
+
+

Linux (tarball)

+

Portable CLI + install.sh for any distro

+
+ +
+
+ +

+ Checksums: SHA256SUMS +

+
+ +
+

Quick start

+
    +
  1. macOS: open the .pkg → first launch saves your signing key → Print → Destination → Verae Timestamping.
  2. +
  3. Windows: run the setup .exe → Finish with “Run setup” → paste your xVerae- key → verae-print stamp file.pdf.
  4. +
  5. Debian/Ubuntu: sudo apt install ./verae-print_amd64.deb → verae-print setup.
  6. +
  7. Outputs: Documents/Verae Timestamps/.
  8. +
+
+ +
+

Learn more

+ +
+ What is a global timestamp? +

A global timestamp is public, permanent proof that a specific document existed in a specific form at a specific moment. When you print to Verae Timestamping, your computer calculates the document’s SHA-256 fingerprint, a 64-character code that changes completely if even one character of the file changes. Only that fingerprint is registered on the Verae Timestamping blockchain; the document itself never leaves your computer.

+

Once it’s recorded in a block, the entry can’t be edited or backdated. Anyone, anywhere, can later check that your file matches the fingerprint and see when it was registered. You don’t have to trust us, your lawyer, or anyone else’s word for it.

+
+ +
+ Who is Verae Inc.? +

Verae builds cryptographic timestamping that gives businesses independently verifiable proof of their records. The company was co-founded by Stuart Haber, who co-invented cryptographic timestamping in 1991. That work is the foundation under every blockchain since.

+

Verae’s patent-pending technology seals records the moment they’re created without ever seeing their contents, which stay in your own storage. Verae is based at 575 Lexington Ave, 14th Floor, New York, NY 10022. Learn more at verae.com or write to info@verae.com.

+
+ +
+ How does online verification work? +

Every receipt includes a lookup link and QR code. Opening it takes you to the public VeraeTime explorer, which checks the document’s fingerprint against the blockchain record:

+
    +
  1. The link carries the document’s SHA-256 fingerprint and the receipt ID.
  2. +
  3. The explorer finds the matching entry on the Verae Timestamping blockchain and shows the block it’s in and when it was registered.
  4. +
  5. If the fingerprint and receipt match the chain, it shows VALID. To prove a particular file is the one that was stamped, compute its SHA-256 (for example shasum -a 256 file.pdf) and confirm it matches.
  6. +
+

Try it with the sample receipt shown in the screenshots above (block 113224):

+

sha256 001ba8b3e94aaa3af8730343a821727b354b8545fb19b908bb2b6f1e4350678f

+ Open the sample verification + View the sample receipt +
+
+ + +