From 563a6671858bb6446fcc24546f424b3e379f9063 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 16:19:15 -0700 Subject: [PATCH] feat: the web build is the desktop app, built for wasm32 Snowbound in the browser is now crates/snowbound itself, compiled for wasm32-unknown-unknown with web.rs as its platform module beside macos.rs, linux.rs and windows.rs, and web/index.html and web/glue.js as the page around it. The welcome, sidebar, toolbar, menus, command palette, Options and every dialog are the desktop's; crates/web and its own shell are gone. notebook runs there whole: sessions, the replica, publishing, merging, conflict pages and the background. The page has one thread, so the section thread is served as each request is sent and the sync worker and background are async loops the page's event loop runs (notebook::task); natively each still runs on a thread of its own with blocking waits. Files go through notebook::fs, std's natively and in the browser files kept in memory and in IndexedDB, with a SQLite VFS over the same files, so a replica lives beside its notebook. The first web build's sections move into a notebook of their own. Assisted-by: claude-opus-5.5 --- Cargo.lock | 27 + arc/platforms.md | 18 + crates/canvas/Cargo.toml | 2 + crates/canvas/assets/fonts/Caladea-Bold.ttf | Bin 0 -> 84492 bytes .../assets/fonts/Caladea-BoldItalic.ttf | Bin 0 -> 83356 bytes crates/canvas/assets/fonts/Caladea-Italic.ttf | Bin 0 -> 83780 bytes crates/canvas/assets/fonts/Caladea-OFL.txt | 93 ++ .../canvas/assets/fonts/Caladea-Regular.ttf | Bin 0 -> 81600 bytes crates/canvas/src/editor/format.rs | 4 +- crates/canvas/src/editor/recording.rs | 2 +- crates/canvas/src/gpu/page.rs | 19 +- crates/canvas/src/gpu/page/background.rs | 6 +- crates/canvas/src/gpu/page/picture.rs | 8 +- crates/canvas/src/interaction/mod.rs | 6 +- crates/canvas/src/layout.rs | 17 +- crates/draw/Cargo.toml | 2 + crates/draw/src/edit.rs | 6 +- crates/notebook/Cargo.toml | 11 + crates/notebook/src/background.rs | 308 +++-- .../notebook/src/bin/onestore-diagnostic.rs | 7 +- crates/notebook/src/discover/source.rs | 15 +- crates/notebook/src/fs.rs | 21 + crates/notebook/src/fs/web.rs | 722 +++++++++++ crates/notebook/src/fs/web/sqlite.rs | 143 +++ crates/notebook/src/lib.rs | 18 +- crates/notebook/src/location.rs | 42 +- crates/notebook/src/package.rs | 8 +- crates/notebook/src/session.rs | 69 +- crates/notebook/src/sidecar.rs | 2 +- crates/notebook/src/task.rs | 159 +++ crates/notebook/src/worker.rs | 221 ++-- crates/notebook/src/working.rs | 168 ++- crates/onestore/Cargo.toml | 2 + crates/onestore/src/create.rs | 2 +- crates/onestore/src/lib.rs | 1 + crates/snowbound/Cargo.toml | 29 +- crates/snowbound/src/attachment.rs | 10 +- crates/snowbound/src/commands.rs | 2 +- crates/snowbound/src/guide.rs | 22 +- crates/snowbound/src/history.rs | 2 +- crates/snowbound/src/library.rs | 88 +- crates/snowbound/src/main.rs | 351 ++--- crates/snowbound/src/manage.rs | 43 +- crates/snowbound/src/media_web.rs | 61 + crates/snowbound/src/menus.rs | 12 +- crates/snowbound/src/pane.rs | 2 +- crates/snowbound/src/paste.rs | 24 +- crates/snowbound/src/prefetch.rs | 4 + crates/snowbound/src/print.rs | 24 +- crates/snowbound/src/print_web.rs | 18 + crates/snowbound/src/recording.rs | 13 +- crates/snowbound/src/recycle.rs | 14 +- crates/snowbound/src/save_as.rs | 4 +- crates/snowbound/src/search.rs | 97 +- crates/snowbound/src/server.rs | 9 +- crates/snowbound/src/settings.rs | 14 +- crates/snowbound/src/spell_web.rs | 8 + crates/snowbound/src/surface.rs | 61 +- crates/snowbound/src/sync.rs | 8 +- crates/snowbound/src/tags.rs | 22 +- crates/snowbound/src/themes.rs | 10 +- crates/snowbound/src/undo.rs | 20 +- crates/snowbound/src/unpack.rs | 10 +- crates/snowbound/src/unread.rs | 16 +- crates/snowbound/src/update.rs | 57 +- crates/snowbound/src/watch_web.rs | 13 + crates/snowbound/src/web.rs | 1126 +++++++++++++++++ crates/snowbound/web/glue.js | 342 +++++ crates/snowbound/web/index.html | 77 ++ crates/ui/Cargo.toml | 2 + crates/ui/src/lib.rs | 3 +- tools/TESTING.md | 1 + tools/canvas/README.md | 2 +- tools/ci.py | 20 + tools/release_web.py | 109 ++ 75 files changed, 4105 insertions(+), 774 deletions(-) create mode 100644 crates/canvas/assets/fonts/Caladea-Bold.ttf create mode 100644 crates/canvas/assets/fonts/Caladea-BoldItalic.ttf create mode 100644 crates/canvas/assets/fonts/Caladea-Italic.ttf create mode 100644 crates/canvas/assets/fonts/Caladea-OFL.txt create mode 100644 crates/canvas/assets/fonts/Caladea-Regular.ttf create mode 100644 crates/notebook/src/fs.rs create mode 100644 crates/notebook/src/fs/web.rs create mode 100644 crates/notebook/src/fs/web/sqlite.rs create mode 100644 crates/notebook/src/task.rs create mode 100644 crates/snowbound/src/media_web.rs create mode 100644 crates/snowbound/src/print_web.rs create mode 100644 crates/snowbound/src/spell_web.rs create mode 100644 crates/snowbound/src/watch_web.rs create mode 100644 crates/snowbound/src/web.rs create mode 100644 crates/snowbound/web/glue.js create mode 100644 crates/snowbound/web/index.html create mode 100644 tools/release_web.py diff --git a/Cargo.lock b/Cargo.lock index 63681513ff912cd15b8fbfaebd7c5ccf46e21c71..0d33777aff3a96a41613618c304b701e22d727c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,6 +664,7 @@ dependencies = [ "serde_json", "sha2", "skrifa", + "web-time", "wgpu", ] @@ -1010,6 +1011,7 @@ dependencies = [ "roxmltree", "subsetter", "swash", + "web-time", "wgpu", "windows", ] @@ -1316,9 +1318,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2079,9 +2083,11 @@ dependencies = [ "cab", "getrandom 0.4.3", "hmac", + "js-sys", "libc", "nix", "onestore", + "rsqlite-vfs", "rusqlite", "serde", "serde_json", @@ -2090,6 +2096,9 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", "windows-sys 0.61.2", "zeroize", ] @@ -2471,6 +2480,7 @@ dependencies = [ "serde_json", "sha1", "subtle", + "web-time", "zeroize", ] @@ -3500,7 +3510,9 @@ dependencies = [ "canvas", "draw", "fontique", + "getrandom 0.4.3", "image", + "js-sys", "libc", "notebook", "objc2 0.5.2", @@ -3519,9 +3531,13 @@ dependencies = [ "ui", "unicode_names2", "ureq", + "wasm-bindgen", + "wasm-bindgen-futures", "wayland-backend", "wayland-client", "wayland-protocols", + "web-sys", + "web-time", "wgpu", "windows-sys 0.61.2", "winit", @@ -3923,6 +3939,7 @@ dependencies = [ "parley", "png", "pollster", + "web-time", "wgpu", "winit", ] @@ -4377,6 +4394,7 @@ dependencies = [ "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -4401,6 +4419,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "30.0.1" diff --git a/arc/platforms.md b/arc/platforms.md index 97c307868b48b21cc6a22bba9c5e4b911b47b26e..2753dcfc15e846a2ce29a3296833c281fdaecb5b 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -146,6 +146,24 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. Windows has no menu bar: the toolbar and the command palette run the command table, with OneNote 2010's Ctrl chords. +### The browser + +- The same `snowbound`, built for `wasm32-unknown-unknown`: `web.rs` is its platform + module, and `web/index.html` and `web/glue.js` are the page around it. `glue.js` brings the + canvas's pointer, wheel and touch, and a hidden text area's keys, composition and paste, as + the `ui::Event`s winit would; `web.rs` stands in for winit's window and event loop and runs + `State` a turn per animation frame. `tools/release_web.py` builds and publishes it. +- The page has one thread. The notebook's section thread, sync worker and background run as + tasks on its event loop (`notebook::task`), and work the desktop gives a thread runs once + the frame is done (`spawn`). +- Files are `notebook::fs`'s: std's elsewhere, here held in memory and kept in IndexedDB, + with SQLite's VFS over the same files, so replicas and notebooks live side by side under + `/Notebooks` and `/Cache`. One tab at a time holds them. +- Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the + browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert + ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility + are still to come. + ## iOS: native around the canvas On iOS the split moves. Touch text editing depends on affordances that users diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index 50e4d9b00a870c70e2cfdd293b53375236accf0d..a73d0b670249dd67faba67f2095dbd1bd5134b49 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -19,6 +19,8 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" icu_normalizer = "2.3" skrifa = "0.44.0" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" accesskit = { version = "0.25.0", optional = true } color_quant = { version = "1.1", optional = true } diff --git a/crates/canvas/assets/fonts/Caladea-Bold.ttf b/crates/canvas/assets/fonts/Caladea-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a4a9703fbce8958e5dc141a5b498255d9e7a3d97 GIT binary patch literal 84492 zcmdSCd0<>s**|{Hy?55kB(txX$(^~gWSQ*Cq)D1ilQdnDmUJa)3r)JWK&fRb1xrwh zP*wpE5fM>&5mBcF5q(in5q;Gc(XR`y3ya&UyuOI*7m+5v&vWj~WYPuD-|wGa;pX1^ z+;h)8=h@D4p7T6s7-x*R@JC_l(AwooamN{7q2O#@I<#ciyVkpxamzv6Kf847thKka z{`SX=TbD7G9b39~{lM3UpKvg~_%0ORd)C_Kw%)CW_oIpjQGRmU1ylP!@{jRr7_*?f za(vsR7pv|%(>lhge}LzkckbVH!M$DXXBe};!C0kt*VILLm!Uojoz1(>zkKJnCqBN9 zF%{*1c;D_FQ`>!?`|WbnpTYC4-2j+Y7%ss5cHAd+UvTlkk9^vbXG}6PX3Cv^;kKz~ zn%}>fu}BMJ^8FV~9o#PuRxL#N>rh_ZH+8{|Z@>F5KV$4~7o+?~_Fs6>#YyY!FEch1 zV60;O{sTMqw|)5gLA2L{@?QjI@G4vU_qq=q+)~x|S5_te77+GdpZO!c$=FZSuBWNM9TDFeegopV` zFYaWfNO9>2W#RIjKT7-2Z1n`YmT4zZ||W3+HK+`7c%}jWC<^Ip&fM&b}^x8TIaFk~D&M>sdguGoRGW zJp8M8uLlQbL4Fo9@;6X^oCSCX3-h|!??@|gon-;}3Ko_&<8YzuLcHIM@=v486ZqBz z%py%Or(~M_qg*ljN7QYQEVF+EZo{HK)Tfh=8)siDhspbAPtYOyO?@vv2oGOp9&r#p z-p)bzDnEYA)Dr#(ho>Gvz$6@&a{LmGOGgIyBwUvt|Hf(r?g{^=J3hx+L4#I$CO?ig zfHuX0Xms)+Xhw9SV-M;i`n`>V=v#gWIuotS52AbVAo{+(*%!!zlg_*hqNAiP~^7`@LvZcBrDEg!Gpwy9kZ|V9%jY)W#Y*qUrHN5NAM

?1JC8L0$&qv7x|lby!^NTbOX-|zCVk31@9C8 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 z z2`*xm7YY24TZ-tgNXN%)N}`zKII9xyJM_7g(CL0bADVI#ed2m~(9&MvwzKZHV7P}PKpN>W$^AD91gmI%$MMJw~w!9L(GhK^F#D1;Tv2c-i@-Y zId8xZ?;(Pqtrl~Ij2D37qId>?YPvrF%?dLMX51v+7r_xh@d)IZKKa>1j+hX!1Ho#< zb_M6;#gx|dBt_DAvwlR0Vsh6m8AVJn)L?ULtQ2j$TfI&M96yQt2&lSa;qFH6FN7RF z)stO0^BTgAOZ|PHz@ZDDB^-Xg3{K;p4!@oMj}8akCFtjKbT1iL~&KKiW%3VkzyfCLy z2?@@ZGZoR(5ZYOrFW~j2L>hva17pfJXF;aZ#rWgGs8b%A>;Yyqeq^~;Z4a6&olbYy zD5nci$xt{@H)Y|VFgAOO%q{DCE|0VFP!%3&;@LS8qrhPK$4jq0yzpAYK^(FXi z5@e&)9?~g#`J4EQ)GpRK`(y0T_IX&A5%_Tja*J#h@UIq+U~v?&BOYiMxRI=!t?+WF zIXs)fdn(qh3xTkRz>Y?vsc2HG6KPUD*zO{E%b$g2(LL^I6Kcc+-0zlXC11wU{fKYj z;`iUNZMZQVY+Z8a9ebB_^OogH7b!2t`wjo&=uA5UYsWwI;AiR=da4J`{>Uftjib{0 zj@*^2<$W{uYd_H4IrG)p6!lBAU*P%j0-oo_?tMb{=>5Hszl!G=b7%i1--DQPtkLs) zuF8U$b;N~bp(Hn&icw?3n9m@9;{Y=mAcKgKV+NCA*u|_?HU;k0li*61Mx%KvkY+ZA zS7Jt9VV=@&%*7FTNm*?MlMz0%vg!mbF}o8PXV4vQRv8J30;sRh z)ic!h_FD(9Krx)K91}ZD*qk+K~Y}Bor`+>g?hG?!AUpk@Z0&7cusNZO(ITx z6}vSTSOmt^;)r=9uRMGRoaUFDrU?4H-e^)xMg_wU+zSYFGnouim?vOf4kYG$_LF;E0?9ZOCSznEQJ6y^(wh5>}bk zAl_2I?S$J?#M}#jr3fg*+$S-y6HY$31+YGWq~To_Gv;q>O9eCzG=mY z{br?e4cOP4OO^vDPn>aht~kQCPO2NS7m-*hAzTeuJb}}!3X#keMupW&wKlV_ftAPQ zlhIzV>~;Ucybyl=jjapYL6UdNF;Uz(DTqoC6G{3+=e(g#J(#ngU> zV$D^Tdj}k5=ToOm(m)eSxT5t8z3=Z8yPOnpPWG=3zfp=&c|ET}&@Qagj&{KC?kw+lDi`ldI?)_9=&o2-%sl#tC_(%2fG}qPPx9~ee zc@eKKeG_oZ0Z>n@;0M#;50~Ic?B7lG>+mm?;BkI+8T@@Ec)DQs>gAs$gYQ%RK!?a5=*7;t|Ink%LdXkKmh^RDKj{TNFETjHn7CNL&;~>co%GOD_OPJA z;SonLo&jn4W^74)7~`vP3icf0RLB8mF+mhC*3OASA?ct+e@&i#+wbK%&iIPH^dmlq zQGMR$$W4Pyga#1H+d~-$4T+=`OV&kq-F$Cd8tvAl@J-Vbiej7P_q2t5Rr=atI_vdR zxD^Ys5|*4kW1;t`NLFZSR$H*rOUsCjDRwGz z>3k!vV+)Z5y_S747o`=}G&dU;#3b1ifct&ziiNTTiS!b)7-1z*d}X`7>C!~b-QT}vtbcj`vZhoM zNTIna3JaRrn;~9b6DeQbEG%=fY0At8yfS+y+Ma# zm)wq^=b_=Bi`7n#x3@r_EmP8SXVl985j26VN9exAM<__R@a|{+{n6`8LHChb2VEb_08>X$StdKCHo3fcD zlPM;aMd@=L`m2`df9l;_KfLi4t8|*zKW5eUY76 zzI8k0KYaPjnM4f@WoZ%X5-mJo99wqZOLIv@(A$EZLeFC^1U>H|-zDH84@bZ;UJCtx zqzq1aUWeE7`|w<>lZw2Z9`-?o&{%GCvDW1B9X*1ZU9*OK{2w(CL!m6Ggam6Z}PS{3m>_QV??<^D&9%ZMxr7B3g@_B@IjaSLeJAZk7_7 zLB@Or5-pELBa)#IADF~QW;lZR4kjiD+1gbcxiDzXQ?+$xE_aG@t!!*aI0G)HI}vwV zX(56f-08x2MqWZ%Ev2|L5jNu&iw|-!g9(;A|3firNzZ1w?uenLrjXDSkKd$cGG&@c zH!fbLCp5JWd8&#TP1}cNvV4cgW!kh6tX#n5^Mdy^0Lb>yZ5)z`=kXgbam-T)p2Y8? z%#|ma=E+X0*qB1*o)`HWIy}X(8c=Ev*-AQG<+(CA*-AP*!S6x4 zh@KrHAFB@ZT$4L1!WCmW1)rG6yrfnOE;PFM&zc2hYweEW!F^|)QmD&g<9QZ#ED#tG~l zqPssStA*U36r_a#J)H9ro)D&tB(5?zEUqxD_;77Ft;JA#uzYh0e<}@6KiJUchm+rj zr8D8Rf_oJDTMXv+Uv>36!vS~VI^WLJ;=tw==LLt4UUi_op(0w}nr&z^9o&D#w6QJG z-c=V2wfgHD7KSeQu6pT}-J7?jm-g6qY&v^$iPkjk>NM5(!-Cd&ZWr-hvcP-S;H(sK zzP^T)${yr=tz}Q+QAK zmaSd3X5~ol(q1H!*?MdXI@_8X6Y+2`ZpXeCZavqkl@2{#F*)XzxY*qh?{^dghRST>7x;xui(#dFb ziV%&R_OJp|kld_a`u_;myspA-QXQdKth(i)e;(J@CS4(GMODNXUXk>?ao)dlYKqoT3W@Z&BxgZ+}RYxM# z;xuC}roQP32U{p-NAzFES252L>7neGgc2Q(m;fPzTPvgFhe-*#p1Gf^26I=fZ)7(A*c+?L`h_4#G=d9g&}qK*rVFBZ5!&MWr{9(@MAk$Ifc*v|D_SkNfJ z*5xW_Nt=@$D-{x~Xjd_zQ#pR}36sCAv^<)mUB!IGCwdl4@qxbjpI}-rl_&*fka1d? z#;4)^oJ*4xYt~+U$-2s3QRzY!xt`|&?NM5xuEI8yTCk?6t$`;>DK018pe1RNzX1H@ zl(!ql!W#9hfuBcx6Y&+BkaJbf7Ua5YScfR)^kUtS>;^a(;UJorVclXVq*U;G-;_xjiMSP_Sqw;NcP!%E-{@7}M!E9!rS``|?ry0EtxD?*4|g)(85&LyyV62Qud)dG%3 zUbNqbO&Wk_Op48gLzCYXbNEo2u%mk-Bw6?32$s1pMszZk0f z$@=bvFO|?U^T&9$Q?wO225TRrli~kFA7X_#M;%VOSBKXZV%Eao3CguC!W-~AW7KE8 zJjsV%UZc1W^dT_2Su&%1FZ*I4H<@yCySqBE0?9}(P_O_cHT%k7NPmZVE@m|Afgspe zSdnRM9j5nA#4)j=jab}giX3w)epHY4Jf%hR)0fMo8M)z@ar&g9n0w5t5%Y1YuO7Q8 z)FML_drlR!d|67PQwW*RI`bqk1yLk+F3Y|zWkZX^Uy{J~QaDMK*J!K~Nx;6;X%eB4 z1pGA;$nuzyfEMoR`r=$lFz`=v!@o=40R9^V&&BvPfLbumetdS8n66ajlch3Ae%Bew ze6ds}&OdyHGIx~9r1^*RGSbQ4`g*C1%2%ABp6`{))bW?kP|szhG70{`{CZ#yWM+RR z*(DoRTx(zt4CNNv5zokyiJE97HX4wz^9J|~nh8cPW(?P2z!5_aw&{Yy7cO`i?SkBjig(&)eCTbs2LY1;~WNKx4%36It2FUIxfq4uTt$B&NB7>w9pB zc{rQGNJYD(AYOFJO1mA!kQidI6mKX4ctPn6;V&z^U^c(?yA$xP*T!Rv)wb8RHDyu= zv5%+G@*21>wS>L6#SsY;W#pXXtxT1>D+`&#m`vcG?wNeKnCT8IlkN4C(2Fevnp{!TbAXdx)Zz8~P8|+Afbyz~@b3^U zQIE(^7WI&gMEZ5I3{Lgv@OqB=X>3KT2IVu4vg>oce)uu9cnn#0QS4KvM`|dANR4`^ zF&U0-=rIlx7kgE~+S-q~v&i`X3E_L#iYi5vM#HIvjU3yupRP3W(T4{5u)|7S8bKPg zEl%RCQZMEQX}o#%G{0y4S*LMMYPFN0w@9g8HENE{xhm(MP}%-y{7PyobdE*gyZtYmsj!?30G# zT+IT0rhIf!M%Xs6`eB=TvHyb#yCnjfa9M6i$_~FCZ7mfF==(p|Doir;EHFn~fE7km z%21c4^c6-CWQ+*Fx@c|Ei~R!1w|}6R|G90w;-}gBp^YXl<(qr8WlH-#=(~Jj`vd9A zWayM{@>TVLxm_Qy)faFea?yocwxeGpmmd~74~kuo^(jSi0&VoLP#UgMYsSufzTsL=(Lpnp5fJ6Z|WJ|D1@Opq%+Wc8{*G!`X}v(S{h* z5Gkv#H!GuEovEZE!-^_BHJRbMpC*=dT94oH9cF8i&r9rav-&A=u-$x5ZL# znYCYrXIcl=@myQb-#Y%ue%0X`{~nd69Dms+_yqPc@ri^ug%X_jL5If+aTj`d;s+g` z#(r3H<%u73xJvoHR1f8$i~3Pc@Zr5>aH?O2$922BP@d}7;c5Df*-|}JzYbUV_A)s2 zONVRtt$CDZA_rW2x4ZD&%gW&NT^(Lu$XC&JgcS4y8|9oVFQ9>9`UO2 z&AY?xYNnyCw&MFrpt|z#zP<0k%Mn`;std(ao^UpTzNzTD8z7s<3W40g=a zClqX#_VsmVuUOf#G3431ylr(8w4sdl@0Y#~DjdxDeQ<$b3Zt~a2EzWNh=3~F4lYrO zjKz>o5?76V9N=o#w}YdM9h9A)IwNM^rSt$R5C4`g*1gJ+Y#=R%}tQ7 z5G!sqBRqbggeu7iem2wI|>YV>>6>EN*5y?b||*L^U3s`LNd%PY@3gZgv7UZS0a<$PH^9 zd!Jx431(=Fdf|#@3ebb;psxR7U=cWt3S*0oo5EO&ML~VT5NvRQk(rBMJUCqmLX6Xw z_G_m@j33eF$bT>F;HA&|bgRE?%e(?k7G1a`(PnB4xcsiU8F;3tZK2h=Z(peIWQ4#5 zm#u|8zkmgawbeDG3cKjJtUv}wy!q?WkEGAQ{(Cs*?DtCCJZ!+mjjb_>f*Oi>d{`NU ztuc+kTjHj}Og0%zvH^K{SRKXhewb*1+fe$s2Z1fiR;wN~O?iB8_PT-y-Zy(+gax5_ zBlrm^Ps)=_HdKg6Yq;q2fD42Cv_4 zY~H-+)+X0LOLjCB=xm601|p$|*3nSk!`E6?_Kd7Lw7Iv3huk5TJ6+S7MLsXAt8iV5 zrDwsaX;NjdZM50l^0{!!En{o>c72J;o204ZNVN+;I<|zH`7pPb6~vaz!L4QRQe>C- z`J)3iEB5l^h$X_V@8$};Y1vT>F+hMWOhk&Tj=<}uleGzfrSM%Cr_mUbftmK4?)`ah zQC?n4@vYO9E(A23{54v9`)9wFYsZJIvSOWU9=95~b%LGJ$GJ9&2>rnF<%}&~yL=50 zyEK(RLU&yvg(aXGY(_yWP-Gb52%6*Re%#Xsk`;MKSK!zXUF?*KO+`!bE4=^lZRdUI z@u-jkd52vbIvdfl!wWj96o!8&J z;qI;5KX`Vkx#Q1)&^E=8Oh@CS&zA5be7AH1TaVS@ij|96HJ=H|nHY7bNED6a5}Src<5=n;1GKsm{{THiuuJzH0wsIRL}G|*zWzC?)? zaHRehCCcx^VsUX6h8tir_w&CyFv^J7K}(TsiOD#Y*kTrC@y;{ z93GDY+ge3kQHLD~O*TcbBR)~FVR!Y)s;IL%?y)(%UWX+RuXd?bHlM>ozx!deDUxD$ z^CRBM$||e38oz~L^BFm%*7q5&ToS(&n7?cx}IFA?lMkI#h(GRMM2QVBYg?e<^T1(=_GbZqtY zYt|fHz53{yp^F9wFB%%WXvxsUgFPcFmNoT+T>+cf>8OdO>f`ZHpeN!OyKeRB>&EoI zbxST8UUKOY{6BojlGPj5jZdVow&|+`S#m=|ixYt`#oa3NaNErmv&RK?y>To9FN#SP zGr}S*ogFH+8X*S+O@2EtD3b@G9MNWrV%`IX5io8s?y1lbjS~<}DFuY+&cbVOT+Ctm zE#Jf1{elIA=>>}yNv#amw50qtaGmbNwg+uqkI7dfA#kT-X-hQ4%(w%L7e_P>K67-+N zpk-sR2Lv`4gjS$@1pxw5^y^7mv&q5@Hmku~dG18L!CGOIE1N6)^0m_I6;`*jXC!w( zcG#^!`SAb4-kZS3Q5^r`-Lvkz^YP zhkx7&ga82&2=F672#^DF6Ce;mAmKj4^&<|!KnQUN5JDiq+V`!VS!pF(U?=e2`+xpV zqv@HQ>FJ}oy1Kf$s#?l3E{d$gU9ZHyX8KrVD1hhe)mZ$&8O&Gz!>G2~)y5AETHDi4 z+ce?^zaRVa?w{ikb#Bh;dAXDiSZJA7U|ufztV#`~T*kITXGCsW+DSmCQq|wvfA3+J zU&hzLxbIgy%n5bG^sGy;-<|I_tV=6kwoeU*j&u)V4@7BL69>s*y#UpU8E z;(5VuYntuA&SY)2eAA4S!;za82vM0B|!0= z#C)O*d1RCf4nqJJJzN2%NvM$GG%XQ|g3mkuZK)M!IJ+k^qR$4|D5@|W9ogl+l z$Whnr058^TCD{HDIBjv5SzvVvP~71bX{|HCOw{fZx-7?GasqRFty#6*+TH%w|XUBqW_G#6RC&U}APZefneZ*Vjf<@mw;S+E#eZZno8 zjrhyAsjbGc8r&J6ynwzXMWLr~396FMlA=^`lnE8=#)a`77+ax_^5rIo2u=AWN1lah z9j2XxM72Tel@T^eO`uTL22hj&q^Pp`uUrKFqWAL#tgEY7P~@7A%s5+3@2~?T<4di1 zv=B&nSVQ?C-Z1v4bUdk)2snCp0J?$J3>?K?9lVnYPD{xk!QDtr=oWL;a(rWDdA1@4 zEhfu_CO8s5Ro_6%%G&VCC7K+!OH&#Smuegyi)Qgm$+K{_P92IwLTc@7g=c1xjr~FT zJ@mg^tWlI2>rdmlZ8Xk~tGI#uA7JmL48$@_N&Up6p=Q>sSv57YXV=i4oa9+cvNe~L z&6rVE)(mgB9^p4jZZA7uE(kcfbcATi}y#_yTa}*o)?NfvCQefuKeX zoVR!1UL4B6HLEXW*d%nTMb0p(y>BggSH402+9mg|!$XQjJ35-u4hi<((4jQ4n1BYY zBLH#e9du9#Yj8{0qS_x@60+KYlu#5CsDy>-3icuV75Ld&kzNidPx4OiYM~ix|aPKEA1{;E6QC=hB35n4Z1bv`&`9fLzgL*Oo`o*XkLaA z*N}XMq}!A1OzLVL4owyn=GzXREDLn|gicX&)B2NvpvvdX6SP&?j74B zKTG$BZ*Rx5K)OeX_o@^{yi4%RJ@%>+uMqu)q2H*uFHM$4RF<1XS)Ri)|JcpSvx)LK zGv!O44?pWHy$4kC2~n?d;0ObbsCYpdH5AX90Dp4fs^W#|CVGZ8sbl}c{k$3QcasJx z8e~7(Vu!9+-5>wRn~C5g5I^^e<0mrlW9&8l6rb_A>4R>?_zh!+^BL?{Q>A5L`A=OOjwXsD@}84L=}~qsI}bWp*t^kY#UjW8>II@dG9UtH z%VR=~!zt4@ohNOFSuP zEOvhayN*82I0<;*CKsd~d!`(KvWlspG7w4Q*=fVWO8GMvUYn`N*u$ASR9#}TQ71Xy z0tQdB1p&KCLcc-oD#{%O;;L?lF(h%q<$%#|J zfbuL#|AVEWUuFgAil-vhJPi%QohU0rTmVTA&6Y#b5^TfkxB1GkjUBXRC~KKT0H`rb z);Y#O5#FY;(Be8jBmFGCiisPr}e5)z%B~lRsK}P<|($!FG z+6(gSXXU99kKG2AlFR!|QD2vx!xu_R1Y_Vg`2?ZQ03)jQg8- z8b}fr{NtMp(Qcr{HN{wkM+)&syhLmzO^`v^Wcj#KIz>uHn2%{S$_&Y!77>-|+*BS4 z#}ZLbenF1aQ0xv>4Rx+w?ztysYD|DdG>q+FFgU|YOf~|CMemUz zGcKxB#;0ub(uNO(=tylyv92ii`lz@N4ArC|zPBW5+jg=OC#uMmodr(DPBUxsL8 zu0-t^d!3z!xtNLQAs+0<17#>{eO9g+iuC$KN|e?{b}?TCA83b^)^)RIH#g6k)qHVh zDz&IH)q*u?N~f$cm#_Jc3J+@U<=Kt&k#EB!$53}Rg8@MjM z5Rs&-5yMaE45s_pbJE91$C|>JtFV$XgKn>}h6j6lrH?-wL7q}pTpcS)(P&TP8*W%Z zJ697C$Bv1TYAM$I1^+{z=gUb@B4gjYYU~VA_O@Yoq=yn_|~Y#xAoS%IHpS^?~6Zj zqi4%^zq`fbLcda9<7t2A+uwd?E`3P#?BZ?G8(0(Yrd%}DgJvg@T&2RWk^zwg*Pyey z^239_J?^;QN^jJgKl|8RFXvm1@1@N5AeR_DvcI>AG11jvjn|>Z<@o%!+s!JYx&AW} zDan5_P~R8jZM2*qw!7 zC^o*q2qJaUz{Puj7GioJD&cWcB?Pws0R$0dIh1QsC)QX5J$5Q&-wM|_&UEM@Gbb!~qReAuP*?{jkT*wamY)O=)2eKb!c|g#G*Gl;X zE_b01?seZW?R2>V0e9eX9n}_T@S~9i&8uB02f80;m=t07=1UwAN0^v@sMyOiNEb$v zWo|ntMStx@@e!goOZ*&hCp5K? z=NJUJ@T!unp+HdK#Q43HiDTL$j&i%gj8QvI_oSfPh01B!LV%5EDnV~TAYsQ` z#vZjt3Ic$G)k^}215}h~`{&jD>z@mV{oo=o7s}Y9lyOeV5RG^N9W7fRLx*H=C?mDD zfR7z_1fcQe2W#AYXUrROi1wGck8B_*3`zE*8QE}qI{U{|AWpPk2Bw@9r%zxHvQq)0 ziFPDiNE3my2VfWG`qQ?f=##LSrcNo4UKd}SU4*6kH6z+FkUjwvpYn{t>L(jUlylBF zFyy>HA4Uf#zyV;?l3k`rFv>3A=6P92o&gasI0=)mT==2VV!XftU$V5J4&nfpE_r`p zPfy|Q$S>UY{LGDbVZ>vZcq)nqhXbyBT^OwjN^xhZoa+v;(90L@O z#?zdXbT~VM2b6rUHjD57F!8++4rIpjnv~@)vom%p`KwrEdNFK_N!A{NhV)4(?CGXe zg}g9GjbRO4Z23TX<}}{|5)5NE00|pgLPkNF8S0H#x!2M#trF{W&{}{XgVa?%Ct7zZ zKaROtygXiBR*HNB5wYuo*rkLJXWVqdDzn%mg@$dT(~?oK%`%AJ)&{S>KWa`52VO+%8O3;d)19M591TzKL}BsEe)^ zH`wAAme^?OrGCG^&=1GbUJqCmyPcF2X%)Ao0hSx$bm?k z=0W#S=HBp<;2_Rz<`dn=n1^CZinheAb8m59xopd_Yit+bA2&DKuDm(DWs9JFZs@e{ z!yISI9#_H1*0cwh`DV}7m7XCe{iUazvTfVRCvU4MFR!6fcgyuZ*md*u*Wc1`=J&pL z_E}ea?@XfPkXbQ)D|~Y0GH`3TW^6$S6z|l-8ZV>SC|g+Bi1#6-8rm2qqcAdDc~X3bfTQ#;>YIgA7H){gU+nKYNTbx?xM>)!1wFUEf>Jpe= zDGp9((F8L|j+rI2O=QIdp~-ZlXvkYo6-%}SOBW{_m*v}5bj&&oc0H&Ya9FZRH^GDDKni@LidoEHUuhai zdy*HyN=eO00v22hJxJQ(1y#cwQj&x|kl7r?hVRbTzr4mQ8@hdKRyb9$RVYshvXE!_ZFO@OS#^dOfW&=#o^ znt#-b#+L|GV)>F|rfP#k)E2P$v3QQPiDKej1Qvl;nJ8Xnpd-fZyQe<%GxuY~&9%KF zv$k|pSJYINR9DsN&N%tX-_<5p-{BuPvT04Sy>-^?_M)6E8)f*K0pB;JE713jNLefj zSpATkWR$CDPy?EaRc(2fYqHIQgpKw-Q)DYt{7LV|o%3!8%iF*cxIz0MF~Q<1R-B!X zWY@SXk`SL&ufTZSFs5X7%iOXKbw~sEj2lW;FR5if7jp_?wfu z^+!~$jd(h0mmRgHvv`HKVA-4_j^#g^SyWn6QyQywkNv21MyRsT?w(ybqm3X3pG$w| zpGnt&7b;-YsS5Z8z_^2s)`?wv*#hdQsB9nV#Cw3$L1~HIZZEJ0N~2*~tpcr`LiplX z=oTuSgcMJl=xD4H2(d$;N3yE66qvE%>@_kz$k@T_2_*bH;b|QV67jI%Zm0kyl08G;))h3}t(akjQ z%daXRTawzR1j-<&q7XkNrSnp ze(sT+Z!L4$PO`*~Jyzf))%$3r-c{@fQSW6r@R+48utPFj#r1HNJ6>(Q5R60p2^B=; zwWOs9AvU)5fr^fv>aWw0*9V<`sWm?mrTwy;hR>;=nn;2&B4#w8#Hdn(@o=xG>1~s0 zDmAW*lsnvczMv<(qHXSBOIF`F_QW*RePB&zVn)!7z2^h|^5!|sl>_tJF}SE8mv}>U zr>Ju+o5#K<>U;sXC)kImRpn09y$S8iT3aNOUegJUFEqFr4W1cM;UWWyn{a!fn;2N3 z2nU5J%dhvi z>l>5nT|h0Pv%nubx}mLWK}9UNq=XM25Rbf-#U7s{*J90YjV3DfsbGQMVYfPi?uzP} zm92hrG7+oyT{;!HBo^f-@fFgw;Kh6gvA#?iLV3PCuhSv)uO?RHss1#?$ug2`p75gf zL}G493BvZ0P&^(A#^XVLQoOCWcy2sCx45_slBrT6Wu-N+9L1W*8>)*$yHknC=I+Q8 zX5rwE(Z~#qtf5{_YMlKBu7W)fSm|+1Tx`VvXE(AR6X>fa5`KSPo?0!N!};U>SW!?e zJ?*#!7pkt(ti}^UitLgzWlSgGMfP(Z6Aks-RjZaQ!x;<&fUb1-2n$EGO_IreWuA{nZyp!rahb_Y1=0lU~5BE&)fv18d80@8B9&VTf z0jnubMZR2ebrzM-+E1lwYcr5YElMrym|Huic6M{)G*Gb}0#t%o5Q>bmpU#?K*8LYj zi#IA@8~bhskT)nd2LZtS0GWX?@p?zF75pRqF6K9(Q~`(us34YXXtR3K_uv6={q&bd zncrNYpx)slF6+JGir&lk;w!IQcLljzLRvG~dy|sLza1;( zi)rl@@;C%~sRH@uveHx{s{|0!q7^})wen`{I=Lh$3ps7hE(^^%=KZ91*RI~5aQ@)f z9DboF)bn7Epw)JvWP{vZ1>89pxi^xcW0oi*mGejIj-++yHkOdwk{f(Hcpiw@wF}8^ zX#r*)(4*yAwu#n5CB7OL_!|(i;H5M>#hItq7L;<#NAp88AFabYwJt<+RLOnEtPjVY zZf{MrpE2t}&gQq4Zs(mxuIn$Gg-o}UwV?cmvD5iK`BRv$)sR064-s@XZ9Z2Mh1FX! zCc*VJtv)ImWl%TekQiv7eA*2f0594){L*OQ)l;Kg4!vhOut7>esf|bx1kj{x18Foy zEk(zy&N=OyEi((?_Oh_t8K}4C*ZaJck%BU(ZkI$1l6c%?GyYQm5H=lYlV2|YkDJFFxZ6o21&@#iOXNpueTW`T75pLIstTjNN zdj3?=*bu+4Xsn+9Y0SlAr-~xDWt`7W7rjnS7xGk;Z?ZasNLDEzA$dK&aBQfEKQ)#t zq-0-^ONvy%_W=%8z=8UakIhKILJCWa7!pAEF{BeKr`BlHKngfH!4jlevwUKpBA?m# z(kRv(vq?H-nk~rS#?sP)0-SrPe>|o9;3vut3rfq^J&?HttgG1xl7(N75sTK}pjdhq z9zvu;)dVCU%|Yi-ft}`|x{x)@ZyGzB|E`zMTQ~N+Xe-bL{s6|^W`@0EY8)E)!LFAi zn2L8orGx|lJJH(|HFj|75C#Z83|@HQDRaPT7#e80-(eHaRR>~)c6yQH&58Ooeyb}`-Tcy^ zJ?T|j46t|)==BE4Rbaq}MPV1TQUfl@VhT820i7mCy90X~G!89f+{Sb}7?h+=yP%OvRdgE3#LEJIJS<^*S zW2G5#A2!NHHud$a>%=hE)KFd;k2+vK<5niRli>vl0eFd#JuU#LOnV?YJRl&6bPz8f zs0kGNGs4Sj zp@G}gexD=h_EzNPhqQUz5cIf%Mw=QzzTOima+^HrpbOaQpw>cv@59cncN{jRAS51ukqhYcfz)AJ{w9|Qn0Fn?(lqaA4$RxGk5tqyZ*dD&56*r+pz88_PFj^2upD{Al*xV=Te;_~_#Et?wW^;8r&WBPn`AvRBy zYU^`rn^V2D=cpSSn;H$xjg1YFIZg3Wr^Dm-_<{w&mb$vuV`erjE0&+;hn>EFuVU34 z+wA^^nJY^!b=k_hTid!TJnrhXt*wWXqY&7E{sS`MYhX|D_msm2js7SNaHSB^VExWy z&M5mg!#qOvHbBJ6Y@&?>Ak!gfF%c9T0ArDtME@)SPqSDKppBRus8Qjd{_+V@wxo_1`{M zt3S)SzA&0+EOi%E)UKUrIPpZ6&uA|2x{cpBH#9RINqQYVZ$adBPrjwZ>9#x&De(Fn z-ee>`lh7OZ-GWuXeNtG=H~LdSlMd1zn+2|OBo7P(;Xx7=Nf5bXb6T_?`HGg(vNgfFCoLnQS zQC)>{LL%K%9P#@+ZkJvXu3XNfI;!N6jTB#L@eZqrs3Vrp!=W%X%5s;g<8{iABl@VT zt^yKt*giC1;asMJMWUr)_CVxQmoKlpxavp@7SHD%_q}o0IqCI@Q0t=b`Xv^ZtK+Cb zZ&An@3anjnoo@>ds`J8+1>h$u$9`ZO_c^BK9Aq- z)7({h_`JE@h8~mj^{_jTuw6dpi+dU(KcIp~(|7WpNG*^ZZAlfH(Iy_K!N;QD%^)p- z^E9vuD!vdBKdjd*+C|2H0Sv~}33q0MWe{Jh)e~!&-tqTpNSG#MYQTl=yWnlOz)DLB z@b8hY1C$6%v=)~EOQr|Ni{vi;t(9fQf*kr)0*qD=HIb?RTArJ*rh(*jG5xYz~{$Pxh0@AOn{obnyb!O7?=>w8Q@$G=LEk(f-+ zGAnrp_y8{039H0s+^`&!1QKeLK4o{pXvFT0M`Y7inW4#O6A;yi!x@_(D|nSX4rIiX z(AB6RgB#g})KGUKXe-p}jdq8{rsg`VZ&}(ax*C#)M`017hVFZz&uBbKr`Gq7>!?SP zWsx|R8a(DaXV~MZ&{s5ayVsp_MBS?D%F0lY%VBld@~evSX3w_fhH}SF7yjHpM;xH_ z`QYhV*kI%oRK^b z-OS>U099B}Uu7v>a4$05rG=szz=#Nsm@3uPDcu#KX|ni?389~}W?_AExi!k&hE)br zo8^R|j$=y$o`}X4^jB9{K!VRS=EfQVL1(nOqdDHuRI$YC)q5N{c{w?TL@b)rah-XS zFKL`p(R=L7=EArw*X?!{bhcN|t+324N)|ivq6Iddh*p;RVC0u?H{_a(Ilu#qd7!21 zv8LR>j!GR-5B2uiq{QKTfVPhb=3(8)mv4tlO4_d-JR{sRO4yJSe5BZ|hqmc4uFEk4 zdKm45Q9lxGU=7t(mGEJ2b-?Js3K1FYV};cxD6Yaf0|SwAQTCTXe+`4-XsE#`M&lPa z3T&AQ#@a=$f~EBuPf)vQxk{zql79xm3tVEuOzaAHCTz(}I~;>5ooU$-&~Vc5Ty2SC z$BEv0jr6?)x0oL{`Ant~lgDVeE+6}5VzjefJSL>o33AIP(55(&xi$@?xHROJa4 zhe2a=NL7s)5Y{x|AxC1lB-Y2OsqD)RCG~S$6n_35@pD7~iOD4%{ z`GZqr(3D>j^4p@ShbHdPDR?5l!kJh@l`W|Y5kaisU{Z^D7*v(9O12s}ORK3W^-|h4 zP{dGya2_~H_gy4>j%ai~%+`UB0@o&t*2PFc0_M{pOSXHa$dVaJjROwX6sfUX?hE*K zl)H*8OD(1{7k}Y;Yep=Lg;qh2#3~=x(lA%WDuu8&qSyH|8rKIeGMS|!a=Bw=Ilz%Eu$Q4{H91U4UcMeN6L-OAHe8CkN_c36#0e<{BSc(d!1P@*kG9w zginG`LAXy)YjzUKpp*8aG+G@a^Hm)jPQy?R*82v7el^G)t-t6Ez@KLMTg(*>`D5^- zv@S>8JW?JhOTsh29E~|%NZYS(AiS~^fyOWGyLw!ekjm?$o z$khb$J>mIt7Ij*D>a#WR`WZfVFp!sfCSNwTi+7cml+~)$g^5tE$zpQ23tUm?^!(VM z4f#V2-WEx6yX}rmv#7nQ1styiqwmVdGi0VwP%q#tU_=6q)!;R#1=X6Rq=Z3UEVI;$ z^@eO6>d7K%D)-rkt?KIPTD8*e@%RHCPv8bo%$0{NU%skq#fnwc4RdBU)X$mIa5Tz9 zyhFM0I~ikZQn`g53ke}Q!3gSv6%_FfE^19-(WUAkwieEd6g_aC@<#C&BjzY@Xe8ct zrwrDxFF#==AuEgtVHH~sU5*3#DT<>puzY6i%D7UU?XAxB%WhR|hJ-`)fDvws4BzBu zjeC^++0g}_zmB_B9XkT>QYv;zW6u{Go#nvl|fF+_HRnZoiE#U-Bbiy~t>TTyO@DH-j^ z9U@<2ocMcT6b;Q;tQXSY5|`&b*&_}H9I-dIlnYg$JiR0L+O@VqU2Uz!Ydvy>R=;d~ zn3;{a(M;Ni`*-|fojO+S_=`@81fEV{DfEPVkEK-^TIRXEMt*U=9(GbFb%sw_0=v2M zQ|DrmrL#jcUT+4kGE;{QN|$qUUMIJj!KWy@4+0=qZUL3S_AQc0=2)Pm%JR%C56g<< zTN0Yhmi4w=$zlmDwsWi1(j)({ELjR7w6uHa;aw}(61I5Ze5|O=Zft;+LpAX&m@V2P zjx5dvX5%#6W-c-Zs0d%O*cYcx7k0@r$AjTwY*Dp&qrudrSAmsj*G^_-bQkI+{pp6Y zwEB~!&u3`DH2|mIf*>53%+rJ_+)hi0&2KR~re$nY(0~6#y#sieVhh+&oTZAq+@zid ztTYv%DJr=cOOulRNLQ}P0=XwfdUEJbQkE_(R_ja}-A<-AOV~7LrPHb0DtQt^?X)(qgf&^?622Y_Ro_jpydF9_1Hl)P5Psh9)H|{OrXRE^KLG zY~fK0*RDByRaeV`miZlXQRz8#HPMjOp2#nQdWYx%Nf&jK z8e;N^Czuc$WaK({#su4segBwXcxj&10M_40;(uq2>Vl47Rek+jkK@!$&Jx=BaIf0# z$+4HHx%Y3XnW2_bN@iAfRmX$*5%}Q<1R_;)7PJlY?EAPrneeno7bVK8RxHS=6fXaK zE&4pOyLkq$Xl+-$K%GmSlOy&Vz9Ty8m|Rb(H(X-PgJ97cEA&R4-UW5@mhyGy%xcA^ zf@9Ce0)etTUuScnXY5t-6<7n^i22fE7!uB429}qaV!KY$XvhX26u4V4Tvyd*4A+GX z;sWVWe0w@nC`0Ji_rk*<`7rX4l8k|*D`K2Zt>^mg>qmt{)hdH#&zZx z>hq}dFO+2tM$c(&D>(+eu}8^jTQz4ci1gSV=}CNZG_;*i{zI_WmPGmG4*bcFCkJpa_#MQEZZ59kC877gbMmeLjJ-wONk)|8p}W!?#0K8TrQXF68!6697ERDM0+E}Y3i zF?cfh9;ttG`3Wik-UNgB=&h{PK<3#;*(I*&8QX0Ya3?IACSooq8c)s!4r zp=`1ZvkO+t=pPQ?7@S^WN&!g(mg{JbJS?*HU{)=`KC;p|ul`$g+ zPY0-60NIx0)y=N-m%tD)$7-S70G=q1`HJ1DfJN&H*Ep0dKam`C*5O+@iTQ*R^h8cnuGE^+Snq*P3hk?^<77=O2~a4V6{(H=?*G?@Y0emaKD$okd9JGG9!>p+PoD=W=SdnXGvU zf3u`{sk9+jVtHgk=ZRAhNgDX}mRTdcb@gSxa{uh1Q+HHU@ygQ5nu=0ga!u2b19?C0 zO4k1F%9GD%-?*i~+CF<$D|lVFi;zAAPvNDubmiM}P$m+J2=iHqpM}?wFh?iFN+v=8mn>N z-)I!XF1q%8f1x|z6}c&q22mC_yD8YPWcNt`mW<gUCTywP9_+Z;vM zyun|}RSgr{nA=q~KyVDcx=qM^>dgc5DY5PFG-(a5@jSyYSJM^JxPz0W1*PHdG-psHD9Iml-fbPT+kuWsls;- zJO4WGVy?d6(c`#3lv?_b)I8I$4`!Y!*2T_{()_2)13Q;$vNEXfI}K`$TBt{n=!>cQ zjXHx0+JTyGaRbl5dPs^i((+i0_EL^j$EwOoyiso?Sb${Q@EB)8_Vt91L_J+1BzjKV z7y@*3(RTrnq>TqaLRppZMn!{>_+6!9BqLn^(RUn!u3-Kf4rJ+!r zCsI~;#>&>xxv}C{v?ShMjJuWbvYJ>V)!a~8+|*c7+*DM!P&iS5Z9sYjzfd&`x+vjP zP~O)A`<(Cv0%+X?+8#icFHc5EEx`q24nYZZqUVrVN4#d~(QEuo)>yvRebPwIfO(yC z-qH=`MyVvZZ}Z|cmYLK(603u)+)2^{kf#+YwsMT01@r^6vjm$=!*2(P%|PVjp-r1B zK`b!-GglREG&&8%Uc1YbtBMuX-ePp>OYqBJG~yS3xqfpvX3I7C3bYI6nB^Zvx5Keu z4(Yg&KIaZapR*Jq$yVq}A5Qw5T1>;FSq)JS_BqYc=!D7}3MLd-Aw|c=4npq%3Y`!d z)6`2ZyJu}|bT}GY8(Wf9j#5WS8R>NzvHEZzomHU+Asal(^-~6xY%OJ{V5S8*I*>lA zlsY07@)fS?n71kth?TDC4A=s0Pap;t{#D6HBsh)2=h$Nv1w7CL)x80xfR5au%fOmmV4DF=By44%1UnqK@o$PS7fxA*gSdy901BK&9cGTe= z&1$1IqX??U@(seFR_oM$!Qtx4FpSMx(yRj6G&U z`pnYmAt&QqJUN;FxF8@^h27nxkJ`PVd;QAgb)9vKTINjXqFNwa$x>xJcoF3xA_8v~ zs*itK`~^Cy?;R9wc$2X&)>$3fcL)e0_pQqXzQk#r6FO$tg$*vs)T%-^h@cC4;)F2E z7d4eD2%~~hDRibYsG-6BiDa0zABv0{f=p`L&dx*vONWPbuIiYZn3I@Yn^ecuu_BuT zzDuTWl_Jo3*egufg@g~aA-tj?p6Ducc|AI}rzuhpFHhLqPdIfy(1%@fSFP#f&mCMN zR7HJ$hs9!btx80_;a%3;x^7+ToSBOc1@IcyfPT7Gz&!$eEqHik*Qb2gSO;t2Mc~|) z%&#vIL=BvPP~u&*%YhDB3Bm?_kzqZe=(H*bHWb}nqS3Mih*6L@ILadr)IO|WiZ9eV z5B8Jplz+j3XjH8x52_}9XI^DXw^Yv1bWYpyZk6Yxf&MU9>dc58!|Na45$6YX$d_vzVdSp`| zY;^k~t|W9vI1jcSoow|7atlANfxP+7faR%3p|3DQO5u~d0DB8v2fnNbR_4S@SZL38 zTnq6$A@&u^XUo{lDciyY%}o%1rb;Bet^oRUw0um~o3)^tU@CQbw38MB>S43WggGMX z5d$Z!SCTTOR>*#h>#3hajcBvFNuNQgy7OSZ>rO?S!gq*snRDsfw%Idl;j=MPX#7hn=Z(v! z=LeGG@@ZN*9^djT<6DNwy9&wsfW;DX3iorPAMj3yua%v_gly*%bbv+Dy=bd4@H{$R z%8rp<#(qPb-z~i?Hj#jBA>Mok>;0HDri^*$Sr`{Ff0LK`VP}Pj6sHw9NhmB}VPA3I z?HqrFOu$3a@aI!4>Auqyi^PcWA>rG!3+wReUgEdxw|xG*Isw1zPfS@O7hcJWupEP& zum<%B8*OB^Wi?vnJMWXgyK!xk+mw)n>a}<)7!atE2VRGpt_q z&s6!QP>#=(OSdY1+Sn=lbi}Dio7q|DyWy|cX7;{+!)A5`;9K&4*k<%1Ap`M z`RQ1Ru;47Dv;fd)eRGjPrEH}n`;{c$lD36zD#TR(K(M4yJm8G`m4Tll@((1IOXv$( zRMdTYG)(IR6RwXGF{?{gT2fpPhjp*n5)6bSc1)r&?hfT^a&ztF4q{qVu(7IyjTL;F zq7EijmV$sYQv-SRf2yX2Vp0Z)BH4pwjtQG9vb*}ty4g5Hok-qghwi*s|06#jn2ECO zc1|K<2)Am4NNYjUgzImyn{%*+ce6Q#g8)ACk>FWNA7Haf<_5v&wEdc~yVsIo;joboQ@0*<@FlwSnBR zU#lD1=BHBg+ZxnkzrJ2_%(U9;Zr;#yPDjT%JsWPWvs-66B<Jo9rD(To*!F1%t2^Vt|9-_ExnR$ z-#4Vo_dQIggy>Ujk`5?p&J&_$!pm?q|J~Txz5M!hPhoDjkfqs+{JWTG)UW`6ZNOUc z-CQMR8kF`-_?dWDx)kzk#b1$K2LVRh9**n6r*(8`yE?2|zh5hPmOc68vN3fcfp3=a zBI!}=hb3g-kU^s!*lCC_ggJwdXl7eWg<*Dxr94etL%dEChl3-jlTzWIAw9lXq-~{a z`IjDMC$jH#DBiL!K{EDwmX#8P2ckD;}I zU?_?rTF#QkhwZY(TZ1l|uXE)`T^&zmQsUb)6}6&+P+5>J_6L0iGwMVtN~*lP6Sk9V zA+|`}KV{G%VN2Hl^--#0LtKZ%+yA_`{mCcW_x`z^lAmQT%tAe98jX-t6WnuA*N>4t zd=hHNrVpPA&?600H!FIV_O*$mo&+i;(&uu&_|{4k0vjMh*$&!eh69-66;R10rwyad z@Zw47pYYK~*;y4WJ&15idZ)u2y zyn(SF?x&82HpkiodrGWLKz9SbY4uL5OGLylHn9vrs}hsfAttXp00)ZTC*~52`Gu^6 z)u(Do{63ojk~T=sh+L7aAtu*EWk?fW!ZJTv4vVT$_yc1FC8Zi*1ua#AX@QNL7(uWw zBwXt{X=RmGeQD7Elon5QKQA%x=Da{-wdTkpD+_v8bFIUbUs+g~m{(I=lWz+8;7nDQ z>}->&&Cys5XP+)BTXV-+N33O*&+myuOZcvxv*$Oit6vP^|(ZOk}(*Z)U!>g zUbhuH^#lX?xoVv@9CBFHYJ&<6f+bL4NxKscgbZo|)`Wg3d}BbHa*BRw9E)@)bWJ=N zuP!g8jf}l;HjI3X5i3#sy6QY?(0}LeHr@diYn$0C#zzPy=t}yC89A6zdgsSDUO>R`+f{l*b`_) zl^>Ce*yCeot{Qvx*)c)S64>Em9dvdNf=49&CLuOS7>2qH^a(P$ z4;BU=mX;ih=7O1tkq=8PU?pn>5&p#2m~wQ%JZ~uEuevB|^BPUL*fnQi)FH~i_)BaQ zvSis8ra6;Nl$atgaY#q*mDtcDC&wO!j5JUyRTtSkrlP1Xu%Ob8KEv3-_{T+pV%<~e z}cg)1#RD6 zDWcf{hI;>w@t=#J&GyA=AZy-n1%Dst&d9iYD#yv4l^q0df z;6uv2g?V`z?TNmDH@8Y22c*=!j^TfDEBH>A^kzQS2OGzrQBnna#R24=56aM zHVkd4+`O%Cc(iA(D@NbwyQG^^Eyl!Qc$#X*oU428TAHn8QN@{ewNhdHqA%do~T?M=%y_8SEYI z8wqv|4dc>ZaWIh%6sKpfcS=%JdDMN!jvW>IRhmFi(KCDyQMxvxA{|46qrsI!8%KBa z4EF``V4#0P-{44JZ*bdSZ{Kim6pgTQL1%F3*1kbGa;F@jEI0wD>IyX0#J7}8&-R}F zfu8jPeZd`QA;7DnWqGh?v^l60Y-Gc5|JKowijn?-ilO06l}kH1r+>rR*bv*wPJoVf zKMY+q!_Yg3L7@svZxVN1c+!V!8${g$ID7G{2fvpg{t!C`zcz?3TG%#RH{;nb8^Qa8 zNNE%)kAQKh#IXtKQ;cm;$g03sLu?D4((gWeGm2D45o0T!Z^G{$IM41!tRBRfoOkh* z->vv!0OhJis#T)gGq6n7g)#)$j45%;p%Ng+o}6ZI{P|&`G$Y7+hz%lziBur8}L#USwO@iM5@)^XtAY3s~j@xm+2~XrVK@879OkM_2yJ4h4 zaP7joVdYmpYD}g2GEzF&bb3T-KEG6iL)7weyYFChR2)Q0XRx9|)alDfh45Eykq$s^ z5akMD36)x56s02A5q8Tls5Lg=_aGqM2bc$en*@8pPijrMC#=Na+lk*xQA3Iw1ly2F zxpPtqWq5ZWd?K8Zd)9&S&6K7`U_-y?b?XsQId>p^Dn}3{r+iyrGfelRfOQbhv*A91 zx=^3qil-yUk#KqdSHho7=v7PcedoXR4Msc6(&;aK2Z+Q0{xEL??~7QNqhN%M0+Hh& z3B}-*OCj+pM=>Cq5G1AsJ22OQ2W`Oq%uT4q49v`CvAL`r#hZtYya02mMJVWEm^Um1 z*;&q3paHwEN9AGA^*S6J!5a7@I+CqrkHSO3uR&aX0uKpVwt?%|Pcd5S**?s`d%1xd zp_F1~e`0++hg;Zh*ke4GJdxE>!lk6$>PxdqnnZ4Lz#m@u0 z0Go^ld5FEii+Gqv*hU^@&+r&~7QlR-$FURrh2R)&X1jO^FXd&toW05Z#VdFvuj17_ z$^OD#fCS*T0Qie=P*BI~c>{0cO;G%p!Dq5R^I7a(KAX?sDfTvRVg0-nYED#OLw(d;#ZdD_@A+tU+*jCp(TW=1bVSd@1{sFXPMk3ciweu~EJX zXtV?T)Jtp|wCWD$-Fyu{f*;A(@}sa1-O;>UU@K`>;x3IU^XMB(k@vZzgOg7(Rr|=QBll>Pvm5=gmd^_L4kLM@w6T$qR z#P;%&***Leek$LI)ye;2UuVBy4}z}_gNHhoeFHnrv;cA2*f-hB><{b}c0PNR{SW&c zxUtvRZuUAqji1iXz<#7>!J*+f{OkN1{9Jw>|0X}5e~VuLz295eZ6MlK%;E#^#;9i3 zf{VO{{fIpTp3epjZykFFT%{TQ6H@Fb@QOyvh3{l{vD?`l>{skzc0YT7-N}Lvhaw+c7`8k6+0C&OTxvv(uTLJpx(aU)i_O$-c#Y$lk{cDg=J=5_UJc zj-AGS$zv7dvpJcC^i;nJo2+x%kK6<*4&WLNR;uzx`Qa~Z#!e~(|mujE(p|K?Zo z@AGT;wfs8%1AaaKA-{ormtD>-V?SUQu`BqE{3iY*{$u_V{!@N4-^Fj?xANQg?fedY zC%+5Qqo476;JN%h{&Rjme}MmjKgfT{AL75_5A#R(ulaBIqx>=cIJoI2v8&P3{2Bf% ze~v%TU*NyxFY=f8%lsAos(Q()&Q9I7!TzeMmMU$_`r*FqeY%z{JsXCH26Zh%n}!Da zjy1Ln_YZFB*|2RCuiC}c)V862c*C|W8wdK1H@5W-jrME+OEapSy8&FHqD=iqg^1EcY%`J z0y#PDg3PB@JYNYL&h@Px& z(XNtnTQw=SRZ10C$yL;>q9)O<5*5|1mNQsAk%3`#Z+{HO+A1jXb0t2gITX?>s5Vd zh9SAdh9FPX z#%q)Pt~UEeefE!r>>rKUKU%VXv}XTk%leVbPA!?8T2fBU&^Iy)93SoL)sFOS7#i#~ zfI|1tgnqMmbTbfHz8^7d?BAZbH;;g*4=OLD1q-GAV@!iRTZcwQhljRq?$Znm4Q?7y z0M?;9v2S>&VsxVx>C$C%2mR8Ev~i=`IJ9k8T#-J#AL&1y-is{gMr4By);qFcXt)n=bB^0KGzwC+9TI`QUQ^$3+xoZn4D=0d=ri^Yj`pFc z2G);`-;QoI(^Ev2@T z86F&B~{}gX>57tbtge=^uDZHjvw3Kn9H}bVCUCQ(zOBX}`f)|iz-ZpObzA#}`-ggI&_v~XG4c&z z2s7A-81U&@RROwftJ#gQr3G1f=aM@E?>8*j8|0 zx5Fo;G`FL(i&+MGMhC%Vsi}^rG0i88mL}Gt)itVLsoqxW)g|gq z^`QD3^)>4I)Q_lNQ-7$@YYH^AnkAaen$t8_XztWJqj?vr%LQ66O4^gOS81QoNxE8H zhwi(&cl9&%=j&fLBn@X6UNk0*vyENGV~pn+e`>tlc#rWH#z%}#7@sq~Y}{>p-_&8c z!gRf97vQ8z|CyO^=Hr})^8}ptrvJsO(tCL|>ZD~&>HlFfaL&Xz3ug*vD^3%>-HUJc z;@iFWb}zo&%kD_O!tTU*H_m(0uOOY>NM|?tWmS3)?)DI@)9>TE_wn8P`0jmt_x|VP zqeJ{p5&u)nh8W_1iuj+7r#5~0sBZ8Ej58nSJe()syf^&;zI_EHc?Es)`{@_hH8`)u zc|FcM(vP9^kD>IBA?9Q3cQ|+B{5{S$aoUmQZlt*zY3@dvyOHK@q_P{S>_#fPk;?8V zFnAWJyn<9-K`O5x6@tkP$o~e+?{w+6klI^F?Jd}ly@M3rLW*x8#XU%I4^rHN6!##- z-6+>?lxsK2^*U1AjTCny#ob78H&T2UDLyPxd;uxGfD~UqijN`17m(r$NO3Px+=~?V zB1LMaJxFm6Qrv?SUqFg`km4SsxCbfjL5kNR#cM@teSj1{K#CtAwGWWm2f&*<0LwdZ z-YsDIF1~*k-@l9R-^KUuqW|3sOrlV}fA=78?TfMT|HU}i-Z_20nEuTd_0a?O-2eRP z^d}-Ge)+Wa{|hO8x#Iz~%>PUA zs~T5{mhL~=9{A}OkHKFYV`7w+$Lh%=cJ}Bk->cbg;N?3f{SJESJLswJfG5+Szk1V8 z!}sxS*qnU`esM2r#yJD$Oq{cD&c-E0+ewm@% z{{lC*-ydp-Bl|Jxwi|ikn}hjFe~|v?8HFcAmuf0X_vO7Qpe3z!Lh3|Q^P^?%ZP@YOpQ!;Jg4 z4ltYH{h#SN@h8^|>HjN}KIJA-{wvb``$Wty_b&a1$>sRG)KT|W3EG(w;{Kg9)=<-b zrLWR|O~0GIKmAzx{)sfwFOA=8#_uL>a!JlH@rN4kLms=(%dSNq`gYcv%hH$8FZ8ZA zaXu(o1^sIx##eQh{!97|8K*O~&RqA;F};tTrT>)vB>fs4`^U(BqO|Yp>8I1bryPJU z;xBW}jyrx&(vhq`U>m+l|1s;)q+8(Ub?MX7uqH)I?LhcG&K=WX6Z-i_c>fY={CxU% zqGk7{-^P>o1iV3ckjme1et`2Gw9;Fczly(14_AH(3bKD+Ro=dv{!GvyqA4;~K-QwX z+Y9^zu?)YB#+te5B+QMGhoNxvY&t|Uj`W6AU_kO-jfYqyuCbq7Haqp;M=*# z>ps-+T!io7t3BX49k{+Qks?9z(Eid-q#qGb|2X}7`OD1pKD`MV-xO;&Q zbiEbdJP5g70J`fBPm~_%O&PuL1mjEQ4|I4h)gtrz-?@r0;$MK`pVLns%=hVEqr?vp z&ZKXZ<7ckfUnA9zndCZ)_!a+X;3-JjD-Kkf>~H?l`}EtO_n;16#UI9!kHtw4{{%3f zgq2@vjw_+3BOmp+(m4^zzcRm9eo?;TbanE$wjY%`P&%mPKhaP12YM$z`-Q?Dhc%lM5P{v*ngDG$c7Pcv}KJo|F48O|qtRr)HCqRhQbhu@QW0r22o z3ZFhc8ci-0N{tx~Jv;PKc5+Hnf6*L7{AEfcms>NHdr)Fw)K~7rB{O~*2t`+{nPYMvpzexwEu#%Wgc(5Es^#( z=k=eA3g9u6G{N;@B}4R%S0;GLuM{TZwaJ3WzjdoXCyVN(ANaQtI;4+?f=u*$&;cPg zIHV-@i;P}*KX?!^rvvqSPmXY~bP%(Jj|dhDJpL+EPhg}n+I)3==PT`(Su@l90QR3f zkaFH69xne4XDwDE^H7UjUz8x`48KQDd3d^b;CY`0-+n7*aUX#1{27W@JC>|7dE6(jWY0h;T&U0{{kMm-jm*cz|=k+*G!b&G` z3O~k4eEZ!vsSo`M=VLgZ!TBOik`cTi?kPTka!f%N%D8h$fosMrsc&rG!MA$3BVG&*|!Yv~FmI!M__-m91#@ckx>%{#Wx;LF; zx(e87M$H3oO0^p&%BRD6t`0gkx{q;w#yFf9zhfW34lIN@1$S&U4+w* zGa-Ie=xfDerf-(7;%N%+J8^d4JRIi=oJT3~j=_B|&Xe@p^=NH9T3CM?^eCu>@u#O& zrt8(>!t~VIO#frOGCR}XuSZ+!f2F5ZrZ?2mbfwm2`q%W-+H^OW%G ze;y~E8_e=cgB$lYoF#^cp+I?7WvDmIRPI}q>wM+9gsvGl{ij#ty9Q_1#C06*8Te0k z743rWj#kPgu9I>o5Aa;*9@M2(N**Vq%-N7=@(zt73pXC*L2sbmG=Ci ztLT?VZ_+i>Prs}yVEYZ6XG~n>{*QbG9hmN#p$}iy75QZ7%yd^le-JNo&CsF$^s3OY zY3bRefXxNS_n$aH-^Q=#KZYxCX6W5?*9;vz)awmM`x=~?Yv%i(%J?%*S2OfAb5-bU zhTcwhrBn=6N?POeI765J(<{<$RnngBI$wGJMOQH%AiYUfeTCt6<@*fW?-9Q_w6iew z{~zN#ek5|=DMG=8NT<_1pD%b3f;ZB6xLyQ zi$BWcIT7;>5xy;+kSv_uQ5Y3pT_?hbc)L&BlZ=f^qQ1OIL4X;PE8GHsjnlFSMARI13 zT1}ecAPzXlXFjyHF+<3MF0dWC;~LBq++d2x;>`%ovj7}uF%ApXR^Hx|kF6DD!)wqmzz@l+EN`#FlNPipo=3zC;hH@B@-x}x!Y($DpLWi8T z5Yr&nM#TGXwhLvxl|2gG_Q%=tuonF-^wAen3|L%N0}7OjTI8Y;xoBZAxe{p;oPxX* zF}@CLfm>DRsKcQvbY@S1p1SkKf3HREB-_^zW|``uDd8 z{rfu@>EGWk^zWY)`uEQZJ^MciJ^Oc2KKPd6wZITqm7>;!0dNKdpRW>M1p81pq=9{? z1p81pszq#rh^-az2rl#S%>upv@#q^azQOhu_=bybB)&w!rV_Li{J{VYI;2E6WdimX zFmoZyqBb<)y#+N%*b+M?P#u=c2{hVHvCv_-I1QABHll2F7TCF&mNlCLDU$+72N1E$let zPS|R~?C~VTB%IA*J7GOw0p^~KlAeRZiB`A>xqq8oit>JkeGfTa0sPAs_(%2LB=Eo| zpim*8K>F`StO!xfX)bHVeGZOF0R@hfZFoX8$L?@ADn!jCQS%B>bL_i>LoI3!Part7 zqUJhLbEh(SWale2#ZO3vLQYBbR!tS~P zme(7QLm!Sx0e#vp^*F>C2CO9k>k0vD+E4Rr)RbUdAz-Z$u+|A!>jkU}(6@hxI^KYz zOxWiZ!lw5=l>g`K7pUVyILe_>^f0K@BREQ6@%?K&`3;Uz(1S;D|2U3v(4c2f-xpz3 zY)1ci3n>sqBKz;Z00Qs9mL-pUfWyE(Wd8yjK7*CGUC=8B?8kL10jgzWA=rxN00Sy` z1-!jf@@i%hy(c32T1@nzi0DHe(TCik4_QPX$`yUcBKlCS=tDtRW-o*8<#N6pK4n($ z6}Vpsx)g@3^Xbe9d*?HlOJEz>&NqqHHi-5%qV7e&da~=0M60VrtE*vq5knoQ?KNnx z1h9uFIQ&B5&~%@T0|=vMJwitRE+7>b_+BC)RW2YE6p%^?NT~#*)B;i%qXC^# zf$s+RM6hD~BdjkLSRYfyj0WC--q6IG;G>Dg87({(%wf4a#ZxSgx4?6O9UcqX&{x{w zv%m&V1@o9y^beEhA4btX%%Xqfi2h*~{Ub*nV?_Umik=aLhk~{6n7E#=N1xchH=uv? z!b?E`?}MKL2j2)!1wPSl+@jyW4i2S_0mI?Y62}+)>BV>sk7l@H`#8`g?C^t={=g52 zQvy|{kzXFuXjfSmC_2$=;t8-vy}%>2pwxOnsWpO58w3WK1=i$<_UEGIjiTi(f~sl- zRn-ZKX%h4?SI|dEV1!!GM2(<{27wD^(c0J=l#?|Zr&9BT`t3>fbTQNbf3S2TuF?o9 zUI3hI#(0^rteY<^>y8qZb)&+v?i<3g?rLFKcc-wddqP;&f!7u3kY!yyu(BGSIoicD z>Op4o`XK6;gtv|kb z;WMNMR+s!m5xyqEH$?c32;URoMdLkp;m-uG7^?-B6N$eK!gzymWZ%Qg!Lkv zDZ*9}&fl{6vIf6spui!@Ywln9m~26QNUtei0Ul zFd;&Cl0uwX5jKl3HL`8%h^j+`og!Qz!ox*)ln8r8c#H_Qig3FKPZ}NEwncTC2+tAW z`69eng#W9w^9PA52*UXL-kqI+0~@grBZ;CI5xao3wbfw+Eo{>UvGiXlLM%np1W}O? zL_$a@DPjt-k`$661PhC#xAgbTuE&u=JX5^cdAs{&cK6=S%(sVZ*n(E+6K43curU+Fo3p@;y&a-Pg{x$mi#sKe>#rW|YJU`(^pixIU@jIul#SZjS@SlS}3VIlH z7wT4&;L03QZ#U%GBRX%B6WY>d&MUP>M6_>w9xZB#lGU))!+47cp77eO6R%qVm1q(l z>~u=okuYvSK$zJl~IuJPQTcH~Qgk(A;4Xp#sFi+% zb~JmQ8@tM*-$R?--iq;^_ytoQ`vq_n=0-Q%OWwn-Sf4LMxzEM>nUZgNKJy`@nbP~{ zzxf(hTQPKp&%|97{uMY)(;=e{-gT1U9T3avz#Ce7ol$p*cPKh2>&JJ#uqO5Bsd@!c km7c55_*5EDMy6`$@hZc8E2tgvxUWUa90^&T`$nX)o|&*@CX*cqF$4@-i0lM{D2P7A z4L6<-wZkH-h(1I_MaAV)R0uruA@ZJx%R_|e?{{u>_jHm8@V@^)P}9|?ZdKiT&bep1 z=iFwTF;<5^8m5e_UAh?GIOC(Y;?uBrWOPwoqw5aF^|#~u4;HUFZSCkimp#b1VJBlf zXDwd4e(17)T)mI+g-^ zA3JyIz+So4-huYlp}n$a>fCL|-nV2UV}HeW{h#)pcmDpKj*T}l_SON$%;CNJw(V{I z^fLkU*Mj!n0A_G28+1H1eD-F0|KFHhei;zyCs?z*okgTYI2zG@0dq)sz#m7O ze`6ZHnfav^xaW_hlJrKY#9u7EO6UA%jEnKmnBL`($4WnPsA_bxO%vN8^U$vxUKZMz&qi7uH$o{F&(%je}r`qT`C9B zr}mIe$919?9TTO$3%U{Q-sK>=)*gbsMC00n=v_I8?(gOJ6EjITh(As|q>thm^U6F! zeDe;66k|TRF8GRgt8$D0R^c<^wYd);;CkZ23NK0u3lcx_^KtCN(F;B&zN|flSxoRL z@#=j4vI+VPb6N7!JtuQUb@9aRhOk z1=t_)3AtPPcbxwjpISUK$(ummw^&AgiDmfF(rfH{r8n6BDgBl`Rr&{euJjV#>EKJ4 zmVceC;$O#n_&saJyTklp_C9_L^XwSAig&TBIBsOC`HiI?^JzSHy7VGwIK#-&ZnB1KpX?|hZNS^68F zVG?MnlT46JPUb<|jZzwIoUDUgRJsVb%ggt%Vfl+Vet>tJ#d`6%PJ1R>hiA6onQc-A z?dP!s+NLEVc&HA1)y*1lL;y2L%a~EL8)OCkI@^w;iNDFb(j9C7{I^8XvH<@B%SdwR zKIv>`l|IB)@_z6r7AA`J+%}0NO(&MGKq$%|Mhpa<3fDi5mJ+5Uv^3T|a{03Vk zKZoNJY>)K0vh3OjdUTd{@)y_wjH?Yt7Y-|qAPzr{CLBFDF2GTbW4Y9eF+9!o%g0!X z7fZim|HaM$E?j(f=>@)@HOfBLBK-#A*;D$t^mEoH4S^572|iA@G3LuS#~aPQO1LVS!dj+K-ot%wc z9sNf1FVTM}9wn;8m9)~TbSU$ber16&qU={LS3a+NNx3Vgjk#jpSUi@FHOJ11eI#~g zT#D=B_IO>~6A#Ag;|=j#d`WyNzAYgo9EsT9|NWm*sf1oxq2XKEJdE&kHpMPspWy?1 z8Q+TDyeWF~1bXvJ7#TX*gx+MLhoX-}Uyr__xD=nFh~Bg-eKo!L+)2GTJ$7@oH%{~> z5RaC7Ga271dZVB>yi_Xv4OWK&tUiw8QS`0+uk;c)>Fk+XXO_;qg|pyHV8%b=dYgJ* z^X~=w3vG6{_N+Wb)MHh_wDDt1lvy5NrG;Naq){d$bNf(J<9(onZR94ItSmy zKWR_p4A4E&0qJ7tQt6;{g>*B))xWbXPCk?NRbbM7wBIjXsD7`&tIwBt8z1JU@%?;~ zYxxlG2aWW63vcEPyovYn7T(Ht@ojuJ@8CMVfcNo*+|2Xb$_soRALJJ3iFSSge?LE; z9{|6-kJoVnzl@LZJv_^e+{HJ8;urHvxD9-`l6P}Izla~?EBG1wOnw&cgpBTE^VtyV z2j32}#cVBG!B)Wv-NMdfo59Cp>^yb>EY$PC(fgtA4zL^8wd{I!n0b9pKSVv4!k&&@2DSmax0nQuY+sl5z-p_uE+5by+A!NfP&}Wyj7uiAR zh0EA~u*=!+*cI$$=!*Zutp7c`ioL?FWPe~cv%kS|f1OzwVTF^ouOiOVsg83c>Q=$N=-dz zU_g1 za&qXXhwt4taSixyx=x!Hx z;aZ}|R*i3)xZBIAWm8d;FUq;1XLy|6SM&}Kmm7qXtxEB+RYgsD)7@Ea9Uh$?E$T+c zV?{YNvF7w~G!7jeSBk4v0aTa>DaBqo>z$ZTj;gKEqbxw>Z%VP1uD4Rl$5xFiz{cSz zrD$3;J_(3I*GzQQO=sPcp~;Dfi4d?=vmLY{M(r`m+_vvp@o*bPnY8zv?IIJ924&z-%TeT@*X!ZEys?gM$iSfil zY(gm(){f&^i11SGN>fpvFB*q)cSFGmtQ+t(F_ZwiB!;Gn(w6N-K8=nP^^HwMV_u=& z+Au5)+kz(OZ((wRnoN#}-kS1v8*OZObf_^_WgK(H>I`SBTq7?1J%V=ac0fmYV`nCq&9D-g>JjfxCq5uce=y=gNJgFR>REkcZ zt*Ka-U%qbqsAlWPM6zhzmN?KNQrmGVe$zt?> zF`&m2>l7q}pl~ICj#b7fUv>R4%@wd0Fndf!q4iOlZj@NB*w%Pd%RQsxkd8#fjvDlws!eBJ=@LEf z!qe2}jGinnAcp4bEy0=hUSTqgEJ*Yo?c^RBYd7$L0nB;NBxcQ2Z&R@+-|XveD)zox zQ*ih+n)iW3%$ri0mBl0*fZC;p4=+wEhAbL~288&9YU<_O;|7N3L5O*aJ~Y!nuBJq@ zqZT$)G!N&t9d1r2O8;RzbN=zolxFpLMIB~CxwTT9B;it6J${cy(JG;PG-+*cVu(bp z3DO(yM-LKq!SKQue_UQ&U0Xq6O2T?*fp z;!@R!QHgjm=S?Qc)Pf3VDlV+NO<=!Fu1T_VP8@QD#0AKYX2Rm4qHlP7RS3#e>7Qsm z+RELSw}Z!BT@zY$+|{Auu2$}?ecp+84CjmUa(S1(Hn}@@Jno>+|R`j!1HnytSrH|`MDU;jPOz(O%(-8fdY>@?O~>1hP#@J-I(Re zP61zz$8oo-*n>7>`C=bFRuC3Pfo^3Hv~dMnEAzzA#T7vFY5BVuTZEHUIN@}%I)678 zkTp0FkhRoiF)pp6HgvL{+R(`cYC|U*^Y=g+4&!VbXHfDuo5t` z`Za>HouXfKwoCMj&UTA_(b?IeUvzek=og)xEBZxedqltJ>^$JOze>Qp;#;wR3-41; z2XVTOkWS5tL-=++bS+M)&3^Ti+FT&o&;@F9A#NV1KK1?Lo4DhEdP;X(q@Ggqi}9QR z^^upTr_|t5^^_W1hPDf;&pRl-iRWFeo>KEe>M1q<0Pb9%KJNGIW&~{<< zc~^^X;(6Dor_}sf^^}@lhdURl&%0hdr3Q!9Q)+NS{%*4{af`ap-5NjDEM~-Ic`vV510@%nz z=xByO4;c_AIIXcN6$K>&P z;+|&Dy`IOs3%tvGNnfk4-?zkf%=fDA4Zp^}#lPEsq5lg1P5zJjzv#c)|ET||zypED z13wP@Ch*^ZH-nD^pA7yicr5s8@QsirahXMj9jCk)g=S$fn5l$kUN$BfqcD)_2wy>etktRli$_L%!XuJgWRS))?!K4aHW* zHpRBb_Qeh&0vU}r#=GM~@s;sS@$K<_@q_Wh@!R5e#J?JUDE>tJnfP<@SK@z1B=C;J zSCdyHZ%R$3&Pg3eU6t-lkEB%(Q0uGfOhR$#!N7*|F?+c58NT zgRa5fkZfpe=x?~U;jKnPqr0)#_(srFyAzu0ke=XG6syQS{edT#Gs z)~D&az3S>C>i=2)bNw&(zuy1W zfNsDs5FAJjG!JYTm>k$WaMi%41|ArAX5jS&%7U>4XD!&f;Oh%@3vVpc72<{V!gyhK z;nKo~3l9{2QTXeid$4ox!-KC6d57XdYlf~Gx_RjKp}U4282Z7`i$niC^yct^;UACG zjSP+4IP&J`rqM?h=@#u?^y9_;#f^*m7mqJKu=v)+&o0@ww14T=r3aVZyY$&*{$)2V zyK~uN%YM0Bv%GcrrOR(0vy8pI;)H?6F zJJ$DaShMlAji29m_r}LIKE3fb8(-P@#<*_WJsux#9iKOT)A;Wvx+kvLq}kNI>5fg$ zZuzQ3IL5#eg^-Jrd%{bG;>0DUociIr&)$lPb*C3>=(H+urorH+*LFh|a z!{r@J!ld8GBnfd%oS#;Qe@>^F&uS^f*iOq%wM) zmACIapb7akGq38}oc^a51$wkEYWXL!m*j)e`nT_MxU|`8ELngXc}&NVSfG_PyUMsgr}SK_#Uh93nWA_+N>0h85}tZb zG@Z>D41rvCcaJNb&h*6EJ#M|%x2DcH^da#A8fK(C9BCPHypVtk=|O^*4w~0j4Wzs zl(^aX?U`?F96R$VkHKmI%{WV8HM$>bT=lH4&~4*f3Wp?Mb_@$~vc%;>G>$2OTqet! zDd1ZZ)2sxbo<)=CWRfuKO?P#6ceGR26Y+G0dY*{u(BF>s9v?>YnM>~Zw{_e4(*BGt zP#^AJ8NX=t+Hmisy{4-VKKa5>S9X5T9g7B|`|g@}WVzBc4-c0Fo(}`hP8MM;g?t6e z=$Oi6Kr9$ft{@it7O4xTQyFayLe(zm5{aH_UN>-ZU=Htv<8h9Q$$P-^qcAR#z|&0hKwr_(KO@M&YD}b z2K}M;c5k6ey-jn7UiqHyo0urr(y4?q?yQTs;y#n1ewG9EAYdkqNzwzR?9eB`mKl9V zcMqsb^QX#vuXz1lvsrTgYx?xMq@^*|sP{O>vkxaj0guI$ov~=EEcki7Jd$}u!&8wT zWn*0PR?`Z-{73^xT*m1#;55N5E;y< zb@%i@oOI|h^*(*c!*_4mBc`6!y1X!vzcQSXec=|ve`I^E9W-5a`S*^g6R<8~@7r?c z?r%C_H_7w+sAB8B6^|&h{Eeh%2O#CDvv*G zL1VQ(|2$1sI(z#sF0(lK)1T<1%3u64^uLaU3jw=T^q-I68s?x7$9|t=@E_k*wR=^9 z*LLH$SF0~n-}7K8%`d`xoXlN-(!+bO%!aqHl|F9p<#ZMKE`|>jWIQ9Lvt~zmy8YVy zmK&B2N^7g5d1P`=7+C>jIrsxHhBWIew8gx3tE@$`1T>ArmusK{*=AXgKnErw?dNj2 z(yo-#8Swk`hR|H2)1)(U5Ah4}h+gj^jX39s`PUmwd;VZ))&94xKlaE)wu1KKb0DFi zKEy+>Sxml$208x3%ukcn8vOqWeGjr#1+z{@SAAf5sVpyjFck20Uxgm>Bs2_oC{*y% z6{z(?CtjNPdR-#p(~wd>9;e=pSs@ir?5c{)>cSbvDyc5GwIY`~XXj{x)@qrt)HzO& zNAhO$H^wrBWHcP|JM9LoY(!_q5CkBNgE2@UX{E>Ouk&gv$i-B1xoEb{^0rG($iztr zAuE!O_1bjvoBoiZchn(=V!h3TPW<7VvK$$IJYaGhzPH%(TkUst)?0 zz-MD26YzOm4x?UY0HVf3$AT-Ummw_Y67K~A9ycjN4iZ;;Q$ZU#3<)9qP}f+$`KEgo zif&Xm;0F(k1hYEn6Ae25+jqf8+g+Yns!1`U=lA$@L|{qCvqRDskqr@I`9ed*R8WL8 z;8Pk{V==zcWee5(}iN!Qk!q^d)OQbKd3jXVLmxdv`zm zFLA_7Mij7|f&zon$;zv3VRi zgTJ3%+hl9kDpBphc#FQ%++#?olAfKfw8vlC0NH7HFdxAn64DD`J5B-X$_m_4 zYu9$L_H-)OpxU(=*smV9&)d;XoS@Qz_*OM_GZjLRWiDX4#I0xN+L!9(OiR=Jj)CFf z(F+%KZfMQN(s8FNmul&68(KUvxPPfW7&vO3=kt4%{*J~zCBCwI;dGDE9ZR)F9SKJu z+?7wZ#Zw#lM|bu@61Ia_=SiOioyH3mp9LDXJ}f~Alm%%3B!TU~_dz`~xNVXIF4t;9 zT8PqM`I`37Nf##yMvptbCJiSE>bl1TvOt890oX(1?GdM)8R!aiHheO;KIaR%6dhbU zM|AO%OFq#QbvW^*GrL~QhKIBrZpjM?=^2d5@*}Vg9+Bl0X+3|tYypwKu?P_%(q|b4 z85Ri|;Z{PtszwCJAwz1&a!e)}mSJgMV~}K+kQXp&$VsA3;vm*XCxzpr%3e&|Z@%Ad zt1%y(e((H&kx^?O>Du3s`0aLIcBOvf>bjldFI05xc^~_*tGj#S7{8zbnfdIYONozI zXXy>;Bj~G}WeX`Y2F8pAy$l_oIw2%&foaj*I;Y=7dZ|wBqF%^X(ylz7kq1NcC@j$siT*YP`F-^rrj*-W_3QEiX31 z5;lkH9CouwhxE9y(CjzP$0pPUcFb7nkQQM{!eFT2s-krkI#ICV`4JW&A6YPYMy{aM zfc%tS+VFP9V?J_LEF^|0Bt~_Lo|3)G+h>V66XXolaWkrBRrS7{-sS zaO*!2)M6|)$J_s^`8Sd7t2Y&VY<= zxZZe;q6?D4KJ(CboK~`2!BxnXoQF7Sg6%2TOt6HEKvWpMLdDfgppga>9u_$MKL~PvrKq-9gY zF^LrhxJ2@lzRKvSIoi;?6b&`-phVDOqTo>!hBN}DF_obBMJgRn8%6wrbS#Zgj8i93 zAfT>`WwD?rGcTbASt7jJ0ki*;Ds8b_nY6f|37quESD%i0SPir=3b$;LwF z#g2f99vdc?1Ch; zVGd{Ak`|-}Ct&_chaeThE1-X+DKKC{gp=ec*f})Eqfy49xhRb%!omqph~i1!bcV)5 z7QUbu=;xC$GBvbRv9|P>K^;H8AQX&h(r&lbAt%qzhZ=g$T)uMArK|kI>3XGcp+zyr zTG%e!;EFLNl}^HlEJvNn!#Y0f z#Si zy2J`2*XuP?2CXERFP&yA-Ii``%8?tNOnE#h6U1EA684clhUo=1^r;M3=D!T|8Yl?m z2Nncl?m2Db;?trzuRW`?5zWGN%e>Br)x9B}%SgVpP16Imh$-e8S-n7i`uO&nmsu=Z zooOj!^V-gy$(X&4q&uEXXS4lhOgv~X-+bjBb-erq%tb#e3|LGaH*6(7hPWk0sDB?RKa4Re!gtlP}CGe+bfuv6L@|+M{1M?KIWd!tOo0QmD$VS{zjmhG2 zhgp(&|1&O5!gXEY!6M0qGx-+-AY?KJJ|wPYvP{kl1;WW8>!%VKvH!Xv!_ zKGC4J=eyRowWN{_KDQ^RbmgrseVtwJSU7Y0%JbLLJSzRUltNhiRhGm&x}i`P<~lhG zcht!xy&m)-2~UAj=scBNB-F|LQ|FFE@-9a7(rz&zhF(whY$LT5XnWVj69sQ7g`inm zsx{YuJ|yBmUxFr`P7POAa0i^}fGicGLIuczV{4?oy(Q_18BEq#ba>drTgP%s@*n(w zsst8$Z#{HjuQTHJH;gvKLciXbYVmk$*u33zRNiO zg7u4p{D&1^dQ<9<9)$OGRlya3^z&mSBNv4x%wy1mo;1*tfy)91MnmyI*!kcx;%FdP zGliK6g`iX8KA}SHU0MQ_Axs4kPMj#%@rV}IoJqJ-?gok|YE-Qu1Q8k83E0IIp;Xl% zvYR5;oZHDOA)Ql zk`sEwIrAS=gY&#MpQG`SR6=)9TM;R}$?H&;8E4-CK;{Z^#{n;0i5Qzxz{$xY_)qc4 zp>y7Z-S_}vlwVTO6@z`G7h(G z=DfgI+$AX<+10+e@qVu(HrV$Sz3E31nTzlzxEFW|(K?R&aQGY1@)N+2Ej1X{}*ARh+VM^xlHqnvBnll1|O9z_{UFN0(?Fj<9_DOJz( z7>1r5G8y5^bIla2M4Arc9w6F&gyD1DmQuD4zJta zgVQ7WP9iGwwq0JhrF%$M*wR1jlAYzb(vb7WXv+U}{U8KpHmq$02e zfBdqv+f~6=T|*gP4$Xq;{$WpDRjM?GFFq#}sVDsGecP;x6yxbz2j{u2*k=WokwlTm zCY4OUhZP~wniyqbju>Uo7I4>{B1Ty;Uxc>3;q0NyC$^nEc=@K~3lpiP`N2d=zjb=! zbt|@S8NYU{_bcb1=KTZ1-4wQAj#3uV=T(+P)h297J4%h(941*${+vFENM%ZhCdR-) z44ev6K-K`oIvt+^w{le%%4EinJ0sK}d5I034IRySJT{ZTVlr0oU`v*be$|XD^QQ=C zA`Vxv6I5f2&<}Qo2jEEQ;uQ?by;I9hD_pd;KJU@mEhg2fd~wNwh6R>{Den5^O7APl zr#y`+BMvP;V|@DNF^eTOWiXiu-CTFazCB}0o;6qm-m^mY*Rwo3tuW?si)}VT}{9KnVZuWpk`1C&oon*nP6(|Y-$3=>5M9kAbB)JG zS3x%@6?m;=fXp$vYIrqqU_(^tp?QJt52}suTDj7OtRH_OnNB5z^#hZE z=#41~N()yPU(3EOVlArjtVS+6e1FNgo&MguF_CQZq^xZwvpZz1A4@i57jEqxcGn4( zPoCEr!eZI0fnadn=FT6wv@2(RYPFgjh`KbjoK@)YUcA@pazk!;pysJ&_6`0Te+43C z|MH?AL$riEdYC>|j8g(C#8`$B>355hb!CB1PlibHUT2!sGTh*otWC|zSG!_<;t zAnU;@8F}G6op)tEU}^1Xb2JXOtr%$5boGa<&Yt9b3zYhQF}kwBHhvcu$hvLQ8^U|V2yRu)~GSyL*P@CIlWV~t(tT&#j+kakw3w@+9S8BiU>_f<`P z-qvRCj>U4?JI(pS!_IJ2Tfer^ZCDwt_d1Qi=6Eb>d8FVDIQ_oSQB5McSIIj=_H?-K z$dPDWtj;gpsWjRRa25km<=E|DY}CQgPhb#m22=X8{&aPup-yG5y`LNeMkWz z_{$!Up7OIu8i?@G<++p+HnnM@A)R+^OTgOJHE1>jT_CW-@AURP=WyCxcBw7%n^Y{a zxCt|Gj}j4tzG2-5B7T?G#)-z8&Y1a9z-h5tY&xYGQlQnxWqa<2V6TwLBWJQCYDz<)HGbr)t?NLZlg zIxztu0UvrjGOt8{y}8lrX|#I`7E9P;NClUz+23cL`I#SXv?XdZIy|93G|;&ywr0}A z?~3gM4k;~c0n%JcUR~`|(Xc%!{ zctiG_% zQwS&W!C=TQdCMuIEiT@*^CELjv*BN|m(d#v^Td z`)eV6mfK%@t%~apR&jI#{~~atDbwsb%m^;+WIb$YVG;aR){_a#dac*(v_Qv@*REk# zGsHg;@zm{tiz{(Gu%y*i^I#hqy1U3v$YtGbf5J!$JPU)3Y*eOy8;21^(yJF_lH zMGei)gxrzgBi{X?fIHZ{_QZ7AUMo*3OL}r$$@+bFh3pPb#Nqhf=dyBRX0s=RG^Dql zug#*>1f}`j=GNi1bgbL<_Rpk;lCe4z;JSP>T1v02;$(9bC)e=*5Pb6^P?~apTd+Hq zz2I$wD=!6Xl38wyVSPv*%X1T7uErAwA)BC+_c5J8tJP5=Cbsg@?67h(Gw95Q+DuHX zRzFE``p^msH<|RC#W}JGRm*Ou=0OIX_R#;!lM2J?Ll96xcm5xr0__-V#kyH*duux| z(9+!GsY`gASiX1J5U0ReDaSe_1Pc~SkIUVZXVap{;wm$C$|c)UMt%i<$Xf@iLb6y6 zrw5V8*%|B9yXywBA4*3Mv2bQ*T20QR!T0qEZ;$y87JhH{(+zgJwC)RfO}fqfUwSDK z{ZUqt3?J=n?mWNC6p1Y9Xl{=BtxmhcTIcI@HcanOlcxETwb{{aeVV8-8p;ne z2L1I$yWi#t`!ZU8gXyaC)l}-a@Ow3Xqz1oxs`$O0BfTDUqMY`_GU44I=y8Pq2A@(1 z`xHqxV$ONs?Jg<|yMSs389+#@l3?fSC64T7jl9`_n0-*JcZnP!#NNDKhSFbeQmKzd z!a+R1?WFatK#mc9C?i|2V~?5_iVSxMnnXhNcHr#rpR1|aNYzgEn+K92UAvXiw9_}5 zZw$*x=@V*>w#I6CY0*oRxbgPqDP8-&7JZHKwK1kj-zDsFQW@h?!E;72#;f?pYT%>? zRrq!MYc+7fi3&f0eV68X&egnF11J5cwm)3fvxM8FGT~f>Uyt>)+2@enRN+?wm$Puv zn=1TT?6pO<=|7=2DgS&S4;6gf++rH&kjxf0?5-G=RN~x#1xZzEEn;R1x0v@aiI%h(A zov*3RiFCb+_7C{Owwc=~i&M=st)#(-YG7u3=Lz@Cw)BF<-rG|P@8N;8Sn6$WZ|`dF>g;I6IviA_ z$L(}kMKB*(oUr9oyQiWIRTe-ZbWL(u6v<;aD$t6D_6CKo_KW1KTn@!}l z{={1TQsU;Hn58{d>xPk;Bjx;AH8UO5=jV@XsN|Y$P|w~T9fiyi`SwCaSp>bWhA#@5 zk$mP?2)I?iui~(d%kbL;f2i>5IBI9g@EdE|U(M%N;kU>nqty0?d0!QNnUGN`{Ce!M zRC&&2FkC9_uf!fq75L|6l2K~=YxyGdi|>J<6*DMY%RiCEBwWlJOMdJ~w0qN5~F zkTp=Y*eOks!br2$Jm&SRH4imizD_ngAq067kz|?{qT1)0&a+b)5dt#X4>!7evvbxT z2>7()v&5oI>@!o&hS}Mf2?SF0Qo2GKk>Ot*?|m}OPj$T4aHJyu&y@Fnoxn52@ML~9 zN6tRszmya?j&X)HRxuAhDdwRxI}Zg-|0I7N&vb}3SMhe1W(LgFVw$FE8P8Hu`bxFU zcliaSx61SREp0=JxP;ZjLnmHSX%}a)T%?X@mlS>n=AS-O zmAwdGW4S_)0KTwS`LpKiOs5u!{NX|Www$1uNal}j^DGe53TNz@B360uRj#_jtW15oTUoBe<8g|!R1_EGYf_RVMHY~?fR6+p7|hj2tRAN=qVUW& z7kFB0Ye+;gf zE8Y>QYhWA|4vjZxGu4d(%jJMs(VD7<@3x~6h*B?2r!23D#ldrF6LLQF_h zFlxzVMFpeT%%;HG47u_sqo|ysigAn8B%zLRg$*X9aZ3GCEowx3_oS!lsJOBAvFM|C zs;J|HOnIk=(llG8K2(e7+-zS@dg}kKvlQMhqu^bh``&w7SpJ?mI>!S=WvIZYY08SA zM9c(}7j{ipM6r5vJ?*U&#{vg?kbLisJ5zR8H-@C;DPAIoIdT`Ah7j!oAhvB$7 z9Eb$mnLgc<5|IFp^YrZ3C=%dwWM}%DT6-sYT02`NdfU28S6zGAqr1&t(8(eWz>~+t z0ZjAuKRi9YX8(iJ6KgTPvK-zlcouGXEQcvBLme$~HqhQ?W&P0UgmR(M&${PVFWNAqfSm2tkCX8qRJ*Lcd9;!Jf}q zGuGi)_iT-e2tBBEF60`(AAks~MC(yxb?0ocOXjfoaH3rBG89Zqo2N6!)x}7ks20Bb zb>w+b2WE0VKk}E^!Whz5?ZCr%g7;zdVon_4_Xs`K4%<?IdfHtZn7K<^vMd6lAgQmp)jqs=g3Eu&k>s9cmn^7H5$;Hvgl0FO^o4%X8QIv9>Om zHXo+(in<>$U*Q1=8E~uyPWFNdzmCHTnxp;I{EZqo*$Zm>!yMSCwkLZ*g;&Cm%+IUyJ2Q;?G5hvrv7~0NYup>+j2^;KM}gU?sSqR4Pskt@%Q}(*hWW zMRySVD~hhl@*s4=Wyp!E)Fu*K8TbTv`apMQV?(kKdsU{L$Kl zioYB9FKggrhp6ytB#(fLdL}{t5XMij;H`7Qua;1I1$wIOiOy>K!_vhyaH6vczY@Q5 zLG4BDj(9%oQ^8+%%?ZC+UAHQ?r{}Bf5A$!=w5R8*@GJQxHE zZ97b8y#c-t)g|f5j(wJC;eKdg-_2%b!<>_*U{ph2Vy2K~E%aro+LUG--!|7BG*ir} z3@UW;Nt6~ua~iP$%518LSgoVULE-j_9WEA9XX|(LvTBfvMP>eg60fs*A}&u~B-RuP z=>7Ugv}G+4!%iyb2?Q=ISMn@L7!t{t!|6--{3*qs59oacP24+ta)FP-%0H_X_k4Gj z4!^GAHO_AW4v+&vbx-moi1c|_j196C?1@6aXn+K7$w?;dNUx+fVIOjRA2*oEAfXbd zUP9B=Om8xq^yWi2r#*LeS-Hl7`V0+Zyvd{{B_&qaIdX)jY(`0>wT24)@BLQFgcbK2 zr)=D4j2S_JhM}Q&-0L-&hE@!XEgp>z#tQ@UyE~injahHZtAv9lkI9{jIY^3?7Z&vy zUCl~|$aYYTnhxnyWFiWJj8ME)a`BG`LUqC1VV|v&78jTFhOw^b#ronm=OEWf^;hzC zPpD3D%}f8SIZKO;twY$8(&eMY#<%Y~nRF-BW>x3Wm#g%Ph&a5=Ovoqw6LP6~*%0<` z|A9&%PXxK<*gRazT3aOS`ZCsyG|%M-)T7kFSObo5Bd$oxk!L}4ZAL(V6v!c)Nn&P9 zE~eduDa9*UwRVdH%`jy(pk5+Idz|!Q%HdZkj=KSqs?r{N%2ZBIxOJis8y@cKV{CYA zc=_T*eM5aig#{QxPb!&;r;wWEzzh>U#6n5c{ZK?zB}B7Ir7pq?iSnXWIb|!OOH>QH z^*Tg#R26pG=>FXkMqMf#$vSi`p-8g*jDfuki?=OZvERHhAXw zeEae?uQ#>O@nVm&ecnLOukY<1JzcNsO8Dxdiq~!PN0m@=UaYOHzqRYMMt`uqwcej; z$S2~X+!O0d`uu0Ed8TQfM$?-Q1VKANBOyz_ggFOUii!%FlcGLNz+);LzilxK|2}A~ z!jt?%xX#hOS=6)jvq#l+N2depjxEg|82^d#x+58_jpda@#Kq=Vb`(j2Qf1lkJ*`w8 zf6KeBR36tHt|D0&{cL_7OO>gUmnyxg41H(sLXB&P#Z12Axazs&=`AO_6Zv%;%cXXF z!EqIJ^%+fU4*wxz8-SD%w{eUvazEDekrZ9tp$(H*LkomltO@rRv0j| ztF9=*>E>YWe#zX~Q`~|`Hi89~ACkB3jBmT0mEmU|t zzoQC&MWXqj!jl~HsNDVq{Dx5l9xmH7^qdzEW2?Yp{Bt$!iGJ#H6n-~!L;#!O_{q9x0Q`2T$LvrkN)zV4ba z^ZaDT{FEco=0l}K^fCKwJNG|xC9q!Rr`M1ZQlWRGyp}HN1_fWH1i#j+{G`?s3jR~! zVU;h-@Sov1Dm+D-JRt_5K8N^Eg)96lLGDW39ezivV2?v8L=b7{%peDi96YK5YlVfX zK`itTqiQk}Lzn@HR6x3}(q(h55;Le&`ByN&UeG3SHwE+g9Mhw=` z)|hot9M9=)h*Q;3svbQU7xO8k+-Ywl4?mFdMIf0rnE%995_r1S!>5H%iOQ7Q{7))rxG=L zqe8xX2RD)L!o|H*%ePt3QH9rY_=I>S!B+`7s_-xuK9zv4gd8y=cGu1(3LB`#w>gez zbqjoCWV}^kzL45F{Xrvs>B}R77YFE{#5Ldn~=*Yy`nsYK9IcW6t$XW zHlJNzaCd@Q^I`vLl0Hd-qllW-vTxWpOF1O#Ne8iT2D;%HWwK#L2ytCcyOv~5Xp9#D zA=^&4hpH;wjLqxqXm84ggI>3wF2zx6sf$f*@ZU)>l{OJnnS<1KB%9LFL{oaLv%d3q zZ_(+Ahfg56)C!r|R3Az;%`m4eaJ--BbZc|zlQ4}T1J8Q!{|+JdRD9C!BLXgJTu&48 z;`?YQ^DqzNlM|f%6MT|pI|}BY1EHtmR~`|GLOkjansWTclCV~gFS>=+8Bs$;E041+ zV4xx`jtrWbCTeX?nx#C!l-SX;?83nV5Ecz3#+Ic~d1la-2fD;aUt>hEnyj9HMGu!$ zV@X!#gl)(g^PZM_HkS-%0)~J&lnX63&i5?)qXD(5w1>pR%%%;EA;ds{4+Sw#s^N{J zidT(!?m8jn$ypG(&4YZ19Q)AnVjLpc?XNQ<@R>nMI|Mb0p^T2oSWW`#0EWLsx5O&` z7E)&^Z=F`Rh3%|*&!SofTZv#I3T)Q4sZ{h*9wC+Opr!@IO%6c=r&(}Z|E4FV3 zmdD+aVzh{|-j7F};fBujA4XlTz#Y}~j?1?IcZ~vfHa5w=rOM}TRpc|Z`B#Bowas_g zo5EhEydYsOg9g;*<27xbWUrKvfh=-|zEjQ-m_eHfw8=2+3G7D%9_f89gw#3YFtCP& zRA3G?k+Ip7OgwiG6B|=ps5<=3u(T(bB&h}ATGn!u22dk%fie6K(JQ8H$4jD$IG6P_ z`PX1CUbVUy+k+bIx^ds78XobDP&sj9Q}9ZEf9};#xz5Kv(C z$hJIsLOSYBfj`J#z{%EC;Yl?Q9SzUA#NjVGo+nj#iF?U@QSXh=`W57Q48Qp4 zz%1RKs(YkHe?wsjjm zpG`y?tQK9+VsW&#`TT~Y)3w8SPFOAQO~!b_0pcb_fvL=d>9oT#&4U>K8Ji56&@?E$B6pjn&$`kdDZrP(yyxR6XkUXwLSST z>T{y}QR)|Smwt`3V>2|j0y)0CumpJs8Y!8GMyyy=q&x(KjY!d5P5#I=So^Bo@TX#? z$6}#SEFDXQlu&(Lwhn>O;5%g`XfT!1*n6c%k0q>E6cj8kY|c4Cfz~7nn1@^29JY{Q z<`)IcqmCwNq3&U41OH6ha+EN;+qk{HyLB~6nvrX22wF3@ZTm%P=CUJOei8-F)v*xH zpDW}1_9{+HVm_#2kMPID*ntx{g84x6q>tUF3S;E1>hwko7!gdWxx-f!FR5S4@*2PR zTK4C#4;LcIlvP7EUS%Vw7_#<_Ml~Tp*x#2sVM<(w5hMD zx1%kQPrzR^2rILJs*!LzCUo0xhmn2e^{c!kY6<=@W!kQ9ul0y(W&j}ty zE{vE1sz0s5Nw%r*Bv;o~oX8oY8fF0}*~Y$el57*Tz~Y&0tqR1sH{m{D#s>J$1SZVPO-H9&I|IVx~p)?1^H9l z0pW)g`9&jrJ^z2K$a_yw&dil+jq>{OlVfNN>9vtl6gTs;Ypa$Er_Z@2-vDiR%ChGw z?LIB+OO2Q_h*p#C|=zVLNyc_(4>CzV?Gd2WM2zZZp{sMy3p=MLQ zV@knpDjV&pN>VrLeh%hjcpa6m8|8?(Lw;c0wo0QZn!aG98(AY!gQ2xDUI1)7B26 zYyxs8Q+D@N>#3*PRZ+b_1axmDc2BY04*ukd4`1b&Z`E2Uw&+`z$Y&+q(^aw1GrgOZ zt&S@B`PLXVdE>8cTKCC2joQRY8={JySW}~mpUxLK)wqDIG&nQ#p{x5k)}Qv25qaal zlc*^d@&W6wLguJCK!uauRpIsg-kSELcU5?pKTrcFy{p1w{Jt7E=@=Cr)=pL$l?-aG~qwG1goITi)1mn)HcVZLsejAE;QOH8pE9xh(KT!3e z?!=4GNmLCF|3KZ1iFQ0spbi@4*jwOB(dHa_y`W`1%EHxTXWEbn*%>;liQaH1E(0L3RgIN^3oaDd+luL ztH^0RQn2?%C5^5L9-59<3>LrSfmD_dB^pn^YX zMKXhZ?ZmDSa1Ut{yUCHp5H_(hTI{xH)TGsD7FloHGx@&V>$mS8Ja=H}SPqr*bsi74 zTDS3>kaa6VD)OKcT3c@d8m6+XEvM6dmJH` zf5X7Mf_bT~btIn1=7&0GwyFEt@zP%4b|dy7+`vkOP!>_IK2$Lep&o7+Wmtq_?btk8 z09NrlP>pBlC9LEcfIezdvpb;?c5P*MLRy3e+D%)`$o;H0ktCt51{cir5ChSmy5-^QgR#nlXQd{*X#r}k2Cz4j(pU|-4qNR;%yE|h31drrmBcqaZORUxF4w>3)W`un_ zOO?*{fHM@@tnOKO5}7M|7W(qhTyw}9^m~K74R%Fw7&MY?aON48#pX=-l+I?)tviiA zS7jeVUYY{E*MQzD*`Etubi@F$ z9&mD1W&ij&cY5!AXJ=-oZtpvr-IUpyWV7iBDUh!^>#O)uL2L4xih=7DG(Ii-~0Lhew&)$dLB$WEY$VA%b-G6#r$A) zkJ@CC$I$(xZOsk#cx+b6;CDHVswuOSK*gfiI}S>Bv1P0$)7clo;!9UpAS?;-N$l3J zHE;l$l8^^JOIYTFMlksO16Y&*jd@e3IBnMYE93Dh7{3p~PF&t?lPriNh|2<^$t^WyTjYuBw}zh`4-_lFH_2 zM8A0Df?2&IbEKL&RVW#4D%aUzby8PiEs61mnrs38#w%9WtY}`kD`0I+q}SA!)Q6I@ zz0shrG}9EV)6aIC)RCUsx@(a=Y>t%~bY&%>ghQuL>SKPF$>NB^(i2PMwksy3^Fa_= z$W0k%S*fH`WbBekHw)-e8{k~wL`T~)jFOWD0t11+p(!a891&pKYYUDOFgr>5^)dq; zE=uw$f}R(6c_Ry2gmjqX~7&0JRPJa_+=8#i^1_byrF-MDLEwJGX#$LGX@ z!E2UYz4N>u?(6&L`hm-rmX}wQuV1zCthsPul|AS(2LdObS%2zTJHC4A+Os>8wavd+ zIse<5=3R@M=F?ey8#L7}lrCm#*(n*r%Fd>^N2dk~j6r)kLmQ~jpHY&YBCvL@!%n`}Ot!4@vn+090S z&k^Ek6Q-fc>@+!Sj+i4Da|5dTY$kg+sIyxni$e0*O*XDE8k8{WbH2l1wHn+d3aid$ za(MMtg%t#Cv%%s^1_Dl3LgCZe?M92k=yfYBdK+@mTPTWT(7XXp*dHq!wp~u@?Z+SJOCGA1@X%FkZfHhjO`6)h49?)oLT9{`l^aV%o?(mdflj` zIbj65>?`o~iJ3iPN()pgk)NjS#{6g<1#J#3IW8aH$_lvG7WH|LWn4ipX0yQW*0DQj(|o^H*w&Vz_# zb3=Vibv#tr*zJRnDaAql3V5($k9s;qf>yT;Xc+|J$=ls(>(Y5nGWhmejeL*6W)!zOhU>E0@uXppwS~c zvnsqo-^rg(Hz(BQ%sb!5MttkaV`yYCx!=cpwRj0Op?|XK&jvny)$=|>HpY%jo3ejv z;Ik2tV}?I2t>LG`GGL3ytI}#h&zGP#p^XnYLy1GSi`c%<=LYb_$&E|P$u}H%A)x|V zfOQjC1pvi51r(LHsst@-&4(-Pkxfm3s(`o5QP&a;83! z)-Jot(4KKRt(IVf>eM3zxDIDi9rKDhnY56kTZn4xi;|x+Di!o76zcJ4l05-OsefbX zxzhO|MVT8fBux$98p!6l1*<#-nTD+Ky zcy&o(z8mLiU17`si-G#FzQUO4n6@xx_9T?9aCA{ezu;LE-F2>SXITk&pxfgM7sj`R z<+`;Rafucd81d8X zF@+*hy-i=$pvRxfuEb*_o^)iiceYduIh?#)jp(r?>VyWa!;SF)Xjv)ndpQZs6p6}^ zr!Fz3v^mHdbhKDpW_?~3=ED5_Xz>^HZ?n|N_d|9>!NQ_s7YgKT4LXZdJ{eyMnOaDl zQ8|u?CX(T4L-sq;1>H1PI-~?2g#3_=rA68*Ghhjb(<-RzQrhzYVI)TDKpFsVh)K)w zXk%7;k_6f0s;668JVfJaNeeb+^!#q6E@6#Fqjd(S&tzRYTS|1#1z9^1i$#>`d3^-; z=1F#53R)D&A+?H`E641>BMDF` zjxj3+@?ta75;2r6_h_z$T?hLH0k%QpX(F)|-tTfc>`K-rV?!QM&K?lQ38Dd#Z6R3# zX+{;UW(z}h49q(SiJ6Ddhm8jg`OJ2az=kU&*{c1j*PYUDRck#|D z*AH{z5y^vnlOidYzYv4M~s^^;xXY>Jh=H7rMk&m(~v83x{ zULRn;ywo8=fwSK9PYAq2+zDR`tG^%>LZ>oMs5a?GP(}l(MheITc3>viW$fdyMBvH5 z^sOHB8)~KiT7f75t?3un&-wtU`6^-kg zsPjXxja0^~0Y@~v8nysiWf>*jO3XRL`31^;262-3I-j=!mek;Iruz!x56W=>Tdldj zaIbU^>K9@O_+f+t4S*%I5WG3UrAW>oB9SA;kh8QVMPQ}{`v@pDQ*ajNP)#1}h-1+z zmBXdbrhG6MTUrt}EH26S%bSkX=Pc?iF~P{8XT{3Jn<~vOP4^VRc~!0+EU4e0b4ytF z10|$KL;*_<>xM1=rg&yJgb*q-fgD(Ed$@-5$Ow9 zP0egPqledG7Q?Pr(ASP*T)_&!VF?yxW#f4ga8cqS6kEy$+w5KA39&kJMMXt(MN_gW zo=B0=Sjesv=>A18A&-rK79iS3w1whkpH)*`R$dZ11AYn*we!&3Q~F^yO`swxyYO z#5G!`VMX-j#{QoAhDAMB%x>!2FuQTl1{4W+^Hy$>|AY1|roIjh&z9#-<_}5F!@89EUXvR27Z4Uq615GEMKzC3Y`$i_>+fyN!JXg!@WkU+Hu%1q zeRA8EX8Wn_-$&YXKCMxOv@JGbmdV~oHOa@?n}RFGH(#^PmEkQ&<7TD$>s{q=%l# zjPy1q0eVf^fwQ?H>PBkak1qYuKBGc!Y3r!OFV0|FZ) z+*6o6^|+P7fVu!@v~K|2CAds;q=k$i0Z53Z6G2{HARf)_JIz+u-Er$fRg%=Pw@*8F zDWoccn!ScjW}fT2R1OY-$6r_3+`U#PUq zD-ex9Tk@V{(26{#K#)Gw(RA%n*cXUfOdyMyP~%gMY|-u&^IT;m`XYdjjJ0c{^F}>z+j5r{LOWj_N(_GJgQ?s(X+T(UE znNvv^0oM6B{0V*?@+sq;3Tk~P`wI0BThIRkz3?sUvB8Yr44qhkJ+t$%z43jB;b8Uv zp(X-u6OJq+%VIPAaCBi$AesUiU^3T~@&AZN(p!=-cdZL}09Zsdlz$)p8vC2{Fzw-) z5|O{aF)pC|OGrg;x&+E|8qE+*`~Hj)q=%0#f!aoGxDRb8<2a6@ZQay1ykEz^#_vVh zWxNNfmI~g(9u|1#>$%g}XW7|+c|Ex=AO5Bc^A3MghIznE@Ewx`x-@)&$Zr9=3wO@K zFhEXn4ImHQ3tXh3a-Ias@S3pt#lTtJ(U)Tnd%yOO7x4%q%@*P*5HFpkgN}wH)(z?x z(+_>k`*7@=$P#s=JadR!hNCjov5x`E$9pn%{*rjVNW5Rfeulfm;9E~x5873XRkP6t zs!1W8@DLK~ih&yj#v(g_x{`tcIL-iWu=t9f$}J=TBepe2#!N+1k(R+R;|cB5@)XL6 z#@q?GgvdK`BubS$2LwYPE%+&ovRyN<`|;8dg;=rXGcpU&WjA|$I;B#X*fx3fnLmL! zGwT{-a?`4_L#m&7ZL**)i@$fH~j<4RGiAWzh{7lUN z96n0SnERu&2~@QJ`WJpU%_cn*z4`)h^vV_jEGqOMVq!9HHyYq20-dl%JizcJ(XKdK z3I`3L5?H$R`vC484qR-c4@&ZAa)81X#)*`+C?sK5l58=zwGaD7%Qt!l=PXK(BJA~^ zQNGhR*14#CcjwdPyURbjZ1=Jo?U&*|9`3S#_O9G+I+aO~FMm_=11Ebw{jOiQqx+&Q z19WLzP*E1Em|qEdyl3y-|HSFyGH?GAlfAP}dvc;TjVb{Cy@tytVaEr&j!X%3W@)+* zIS{i=-av4Q@Y`t%ppgTBg9kYfAz^cTSwsH?x{i-Ghfc9C^%}A}^s`+2XKkvW+j96D z2Af27u95%W(NKl-MHXQ-nMznK16B;w7jRp70Mkw}fC(tGV{wAQz%B8_6GS#lVh`5@&{E)-Aj72JCD0SJ2GKY2vJ|FcjvDUS{3 z#WWz)=mgt19*+vf@swCZ0dd1J5a3GQzk%4wkhrikD1Mp`M||aV>PS^I-ZMHob4uN^ z#hq=-&Y9nGa!X?@5wev<>gV(<)bWciPo+95UG~ycxUnx;HNT=|qsP+S(m9kaofWTa zDDjsB;{6Tn^XF3AfVD|p&<&ixY++ws!Qk8oW+n)?rLzc$yW!=5^QvIzM8!lZB zi}{ePCl6C0awhD;ppk%v5~n+S7VxB)R*)Xj;2!{$v-;6y|~+^;^=dAYqoWrcAw-Dhy&LttoV? zOnsEejvR+_JB|qUQhN}*JkUJhu-+r%jYN8ys@K%vnzq+-MqYc~`qSRl&b6m>e0X)& zyo&OwMnfW@**SRoKieDD+#g)mHFvD1XXCu8wrEdz8wANfsYSn5pkK?_$r&@8*+8*{ zvoOvt#QCA!fi@-}KZ469`<^GfM@aeltd&BjpqZe202{u@lSNy_dvO7RQEOGU(-?+pTFt2*fn{3in$Jbn)YIhE`)XY{W zZin3siC6E*-nBC2EJx`I!1y%c|Yk+m^PK zR0Le1MRm1=c4MA2!iJ1bx>?KUAC=E+(4xgpOK-YljE%@fpz=T6VP%C}0lqu~7c zD~a>hNBJFdvMVL&%i+T(VIEZFo|Q_9&tGsY&a3!sJ=qmT>4}oV4Zuyha<3|ekWUhP zFIR2^r^@xDZ3BGjL;T0tQD!G9PHqJF6q$O+jtay|r2C~}1oAwEbV-qpd_SJS-^%V{ z{^@iuud67e-&IK8hjKq8$~}{n=XQ#6Kb|l54@fVUyK`nbj6*BxH7wnPeHa{4LCt(8 zK7koWO0&=GE%Grq#a%gay^6_mU_s6D`dJI=m)F!ZhDuU(p%VU~o|gKhRXr^=%W9fC zo5G3Sx^RN#go>>~8?F&!V1YhJIiyN2yk9^(EK}LZE;c3w#0+#?0f-1hW|){KH1Yk5 zNnJ(-4tU)YUt(sieBp<#y49-+I(5d6x0O%EkLHT(NlnMV0gS zwAEEDIJK>|GJEhCFLB~Uf2ll0{ngC6*mCxT8GTj@+?JFZAv0j&3L|v>XY`uQU{&aJ zssRi167xOxo>GYZYv}H7X({w!_ww##ix#$YwRFyDCo))F%~Abne=q$RF6!G@ek+gx zQ+UR&ckOCEJKyKo>!-)zQ&W#8)c?mAhv{J=I0Cr5f%0OA% z0bmy7O9-{LS;?J}gQNfmn~tWVnQMH5zRy@bgX=YX@n=7~=`+wp5c&NPI>WHlc^sO* z+FL2}9DiiVR`SKbBykqN#VpmJ4i;9IsYWhfGeUSDOXomQZGcE_f`yoXMy?VnMG{ZN zm4Y3D$l^P^gWkKXcjJ0T8UL0gJC9!`s`o=tR+L>P#X$pukS0438WM7(f6F-4*CMX` zwoRuXFAi;yl#~Jw#zA`<_5yVuiC!!OUIPSztJ!wa;F0(m+_{GRhL$B zI-5l3Y$DDq5jw9(zU%v*vt)m_y1jn0g&w(kZF|#Z-haxb;i@?(cz0C?^;v|S%inor1D0wz*fRY~}Uj<18h&*v1Uu1M=9iWUxt%PtYy!61ns!F{)26I&S z7oiH-2_}M=L#$g6c7O^)7=~mQ((2=W*^-HQD`TY471?X+`SmZ~wc)bMMSO|8wn*L5D&((c;Y_d~ z!GJ2yIV7zP1zqJ>bBge{1xB>vk7uEdo*Cb@&4J6(OHNGFi9Bn;>n957YG_~ zvsjr2!jc5eh#m;Gu|R(FZNl95$dpYY0bQ1HFPU2##!#(ZUJBd) z(y&JhC`M<32Al~}jATSL>;Ql_NtP-km}KH`1p#Y1bn*%V;}Dju1JhCQhUp=7dh7&B zoEpIwOVsBxzsTaK5Bha_jYXMEc_23$^`qNE72IYE*h)3!F{MZ6^u&Bd zdn~C}Cj+h$o!+7KB~2wpifeRP!hWvT)x#XIng^q%07TxSev{W2_HmuM!fz{a8C*7F ziCJaUV;EuV*bW2!t~rsih%KhJa$Ud`Na*cKZ`iBz!$P;g;RtyY7L^W>6Kd4T;xo@~ z3q^Epc*wGf*KwW7qA2xy^*(_wO3a;mk-siI0Li|^44x3XI%4p4xOfpPIS7{qgffPS zr$-KqY5qe(12%=ZY8!@6BAP}CSr#diqX^O<{ENbM{85o)N02Zz2*1Bc(EDTuw*rLc ziN17r0&Jf?R?!d?@Dn;x$s~Okh@6U6&+_JS zcT{O~``jfkfoDpj@bXqeMR}@RvO`olCB<4BsvF%-b(ziWua2aa%YL@5wV|$m*+n1MX%&~Cgin;B}@Fc^GIk|tpN7jvO0UO9{ zgchiJE;krKOBAA~IQbH+G8$kE3Rt%wB7$y&RRGVEWJyXe1wn!^7)&s_X);AjM1okr z=67b=TAQ0-YS5O5mAPPMPB!wMjG~*;=|-HB^AAOHlX?e=evO#Luo$JDdXO!4O*606 z(Z8I@)0~@XBt;u;ZP72DwQ8s}UJ{2T;gX#-pOPfQr&Qn!N%{+kFuy+szyidY&cG#f7;lpkwC2${Hy1{e^Y(Kz^OJBh6bU?^Sh9h_)<}#=AtT?I! z$#ENaS28&SUr88?=nZt*g)A263ZSqgOA@K76rFd;K?Bjq(Mu$_M?HhR8f!eMN#>1Q z%SWx6x>?Y94l2ZmJB1{Fc%{q<`-2l~%XcYMZk(87wQc*nFOn`I%j7%$AbU zGLNPt;9GzlcUz>|@AZ1iRL_+4HZ9trA2O zf%A>segJ?asG{i)GcooOM=U|Gf&mgt(N=`KxkUn_jDST^^ZA;v((bmUTT@jXvw{mZ znJiFb`NY@`lilt&&0f$D;XQ7f(_^t;H87{OAv#aJ+9+vGab0)XWv10e%^ImLT3QJ@ z#{IcADJ(q(A8Sn+7>HyXLghkiUk_+lgVx~y0JS~%Sy7E+HDgV~8!7y%aUIUiI-St$ zR&GH%h#w+T$7n3pT1g!vCWv9Q zQ)(Is7?H+ie(ya(^D~+NI3s02{_K+KpsSPzKpU47+Fcs`)>=~6Y&ueBta1t0n!3%p zwv$_>$0{n~DUZ|T56_JXJ+Bs*&z$`k6g7|1{&Txa^c$L2*8>)r0JB#CkLiX+-#r;e zH!zQCn`BV;a)qIc8x;i&f_C83z*#WXASFQ?MgquY28G%%jMfYLe_%mi+Z@0WRz?<+ zW4-({?TxeL)a1eTy(fp0yPlq&B|VE5&hP4&)9!G`V&!p%GfGbGSYbhUMBk#1#VHAE zeF}jo2+sjv5DZ9?ZRJKGvcu(Z#==Hsm0Azi|YK{ ziSn9x)g=+GRxFk@rUu1@=g(i)R90ioK5y|^Y%Vym3x}Nk9XqTkYpL0@VS&|Fb2r+>VyHeM6SrYdxH6RZeu^gfer0nf3Kt;{TIKu_1z zU^3QXMmlUT@u}>`UWs{#!|IM{HxKA~{U%tc$onq@KLKwVStG3JM{N!#GzP$d6XsYo zIHYSD5pcAnNl5wt@b?DR8})|VC%`-MX7xQ}=2E&vbC(_ikl%vEKd zQMGvk*JIXQU^*ZDu(4Sc@GH4f?I_px-r&Qz?79?pSpUoCGFNy!=9_J%@^YQ)X0Kmg zfrdY5cdJt=cp7@+&mIlfjc9ibzmhLj)Z_F6>MfK)W!9OHX$Cw5Vn9M9>#*k$bA^OR z+%~&XV*2e29d0RV}~Q|cO;%$tbtvJ~z*<8hUS1hA&yFNpAB4d*8j(F@Z1 zhRR(IcZnesc9&N4*UjnQF#07!_9;#+LU~i1tVLrTlc% zr;(kTF+=YL)B-h63O<@F6ij57BiLywxZ*CauzQDu1aj(7Yrzcj3BtWY*f6 zS&I_^H^k@Mp|biKt@N>q3XePDg??GURaWb6u2>k+mxrt8l~%-qVW-_{u_qJZGVBmE zCz2SWPeQoF!M-B%-{71)&wn!lu%qJ??0ifNoRhHy;b43!riS3K34R=qip|7ooW6u%o^69of>fAKa{qFf+x{V)DzR%9YB-Di8nP7E37T~^yFO#Zc^_t% zz`F~;qX~?DF1nlA-vHqPwFXUx84b|l4q(!dNP-H=a%vriZvZ8fMq#UF4Ty5u%?x^| z%V>8)B2Y~ZyoXd!{VatfTXj`sg-m-z%}Qd4Xw0C)0+d0hhfahZ=L}mcKq_(goC2yu zjJR;#_GOW{zRF?OINesgQ*F-p0|6|WIT4-HVoB9o9oO+?y6oM2S;ySL9VWG!Yeptb zR(;$I4d|-Y+3kDO%T~k_gLBYNt>~wRq#JOW@Iq2l&`e|`SU^VLSs({Zy-)DIiLecx(e=AD}AV*0+7r0)E9Hv0ovw%E3a;$#+`PTJ zWAl#s>cv$(HT{*bvdvJC#k^JKs-@ZdHCDquE=xh${00ED@-k>rvnX`r$tdIWJ7xY| z-kM67pbz0WnAe;PoMPM|Q34x_1Vm0t$1i!qZx7pUT=FpXwtmd( zT6P`0slwQJC8jGc4M_kE=jc=qfB#qyO`1%_+TT{(y$4>Qw=yG zYPA7v9%Dly0l7~!6`Z1?P7pw=C{M(p*i~k;M;vAOJrZY!L~@4H@WO5>ZygdXP0x$y)4Zg!a^g`6N9qEo7M$IKD*RuF4Wq<#RxWs4RqTD559 z(!~pUJ2P`;&uXQ)T~!f{*`tI3MIA+mqDvq_GPVd)1y~A2nBp{EPMD$?Y8f>u!WI+7 z=;BXBD;cE_JZ0A(~J|Q>z=w7uKX#rYgH?oFNE6Zq-_PmFpz6W$q;h z7OZWG_#%qz3ua6|m)Qus(d9dJahcm|uE31wZ3{J)MMKT?Wu={!W${KmxBCpSQm4M6 z%&NDY_0hJPc&*)%O;u`v+UPNUpf%>+P<{&Qs|)n2EqqLdJ;5Z5`p3B%O4l0gTyB)M zY$#EH?qvebug1hV{&yw5V-C@>T*lM}twuf0U~$!`6C@mk#i$qtk%j=P%D>nO;-z)~ zD>4c{eQw8bnFmn zw|URnK&?rYnS^>8F+KCNiu{otn<{x2YiMa^0<97xz;u*sC*XHl@kmuwdhZz{{;p7C zy0%TK^Mx+j?y7)njw$<^-s!ilw5AN)|AN$6(P2HSa!&2ysyY}h)0LF^5C6WpA{i*} znAgca(iy2+);-T#Tg%IB6*m5GRoD}CR;^pQ-fHw#*IISFrn5(J&}B%HH!R6yHz%(! zz`(*zi>KTht+QEueurmglQMgFZGX15I@B2ScGXptWuI5+l4b*BIzKLbhb>bazzNTk zo6qiqr8vg#z7wJX_;#K2Z3e%G_-4*+XSd7WZkNBMq#v^7;+rM=I(tC=_JH`7JuJP# zHYheBoh|!qcE9}XemPwUfd58C5576Fzh>W*zmbS3%9{uMuq}%1_~y)Jk0|eP=~cE~ zF^X@V+=c8OIo&;Sy0r8Qd>h6$d-g54Jn8Oyd1pwEvvrD3;+rei&hDdh(%tvT>D2hP zQSniHb7!BG+b$K_-YPwfx}1h@*6iQe9ddbhh;&EmY+=9DLTBp*;5I(C8n%^z6u7}P zAn7yNX*5WLfaeFWAqvy^3M@2*LYND8d6PCTQ38ds4)F&X zK`a&+0pK9X9Y>$4f_DVJYL$0}llP|*A#b_I6R~)UT)1P8YKJY;cIorb$_%lEncg6H zYAUA*M>WuVQ4Li30N|5wsr2qYM{PkH`@l8G6zn7u-Z(gO}S@D<{PX$8Hpr?lq1wy zov`@iEOo*=CDPgxYBk9}5&nyXOeQ?i@@htpQXNTp=&sGpb5HGSZqyE*9KPBl7~F< z_bq#H@Bcymd4}Uc;qF@o0`epE9|-j*^d!{J!*0pgiAf1+j${Nz>csJk2t`C2gmx2% z+mL|+Jsam#Bmqtfd=5ucI#a7QgZihUgBcxV64Q}!9uqR6$Bdmx$`O^oeCgzN+?asZ zI(yXaD6_*gMWsUlML&YW!0H?da0+O=aOn8+z99dm+iSN1dwXinQ$LRcd@ck0Y&+C^ z4R_WFJT3dr?C;7KYZPBkincfAexg_ic%x^@Obj~Gn7pzWE*PU&Wizz6Z4QOn2j;Se zM<9?6QSHX(W`WS4)L)kUuTvN9=k49Ukhaa%9S-oG2<_j^>^$ie_;>NM1ZyCv>)N14 z4`Z3U9~ndL2J*ARde{esRVLLs@t~3hv;YYzX?YUHSwJRkNW#5CAQ6a1LdeFKh$RfD zLQ7$iy2UZAAV{0UN~gJ6n5^Oash?r!KXlzr)Dh{t-yxu&UK ziI8)0Kq{=6bpXDufM*zWdvnyQgbfO=!hys{rcj_33QH?Qe}ecI3;T*sYbJx&7Q2cRIl{wfl|FR7TuEtG(PG2%(Rbp^xqneUt_^xena< zM>AmznMB%-C7?}Ja-DJ=sQsJf&sONsWm>%kE1!({%t4>!DNh=h0fXSTMn^UkHB6_{ z=pfp{6nd3nCx${A01)#FJ6PHQSo+aM=r}j`80j@i-GLL#0Mv^0>swpb53L_uwW4)# zYk!4I*h-P1DlsJg0&(j-4%>q?Y=#pci1C9bGLnBjF{)YCe8K}P)&Y(2)nbe*u?A?A z|1%SU={KdK%nP96f%QfMcpod<>!9wT>R*gehy6yf$Pa}F4MZ2n9XeTY0-Xu`5gJ== z;A))JcLISHQt*Co0O(*~EDSE>S~NPuKJZ?XuFHg$EUBteLh513+6BE;9aWi@CS|1( z*l%|ganL&_4;e%gYuee15ml<&MVF$@*wb zU~V$n?XY~o9e2;^t6kH|?|A3I6mq}ZUsYBbhUsRDqqSzXKYF)KskAh&)3i2MEhx|C zPJ9GaY!u_SLyTXTwXja`4!)3qehIJv8y0CP5UmAuafJrFn@)f-44o502LuDKjDvxr z0M4h-U?-IYTWLMY0_o&2NChif0=AKjQ;|Xmrp<{Zfn)Zru1G|w>{`;bcusqyCDL43 zt_&+fT~WV*IM9W00vQE$z`hL3NnoBtJ_Jeqy$#O8VsJJ^%L1Re#HckxAy8?l8k{II zY@Ai;48{4?Cp0Rdus;*}vM-@fj6$k4eCnnJVXrxK;sb)S##_0B^ephAGDttuer_gv zMB;r+dRn{(ixAlVA2ROtxGPc<%D2c41v!8MShrIv*S6X>( zK$k(lh2$mE8x02ixLIKWLdXVyUO`u1HlYp1q;ZP{tT&uw4F=s7m>&$mT41JqMq;Dh zaNxvKugj$JE~M6YPg(-Ur{MXwHN)*`G7$+ES;(Wz{vuQ$={xpSx{Rk%Cxw=LG#_ z&$<)xi!9%^vN9Y>!EKMxUzzkYHF+Wlj4{vc$i?}O!5?p9zss1(?yUoRB=BM37n|^4 z^7917ouFt+@cu$bqRp>uWwqSaVrLh7;$+Uw|SW{F{*^f5Z4=q z;MLJp19K-(pwoSFuS>8meaXoYhGhf1-7cSw4`Kyo1Ygf*4DCv0|V98Pog)8o5NBOnGlgTLm_LjI_V)$v*KX~gMwLyR(jvr5VZbh#WdhoG9u z#4VfnmI}BODn=iK%cNt)Uk7R!4Jq-yp0wydq{MaFAAB=PGh7*;~Y~(O0+89nutM4 z&aP~*Y4fa}>`VM3y6jnebs%W&TWc(hb(9|{eZ&~#DZPoSTV_`#^?NEc7UK5Q6E8(# z^*F0nyw7@_*St5qPMi=J|LMP^*Llr}=ylR=0bgGqh+gNj|EgZ6A3ARS{{?!T=X?Nq zonG{J#s4n7PXBw+>#WAul>VR7>wNJ2(CbWNT?aovz0RBd1-;I_=+_1R8}vHARIJzO zaK2Z)PV)2I^gpiGdF2P9*SQFKo%J7xUT5Qb*XsmL$(s9v)BrnKWxQVC^7Y&gdM*5H zOAqGW1}Pse*`#zU|BLijVD-gz1Bq4(laoOI_)U&PHJGLobod5?{vcunTG(9XIe1CPm^gvSu)i?rZX-M33g`s)qVNIHcE^>^@JD z|8#M6=pJHk}HsBEif_I>O*}Etct{Q3blpoRYB#YNO~i$>P8gZe4VM@ ztAs9#zUa!I*fbaakX!D*b1V2TWcI;F&X}g`syaB4?m=E`6hy_~EbSSWoo>8nuX1}! zvw|IlmnAcyA83C?F2P>{&C}m+UU&3BIy9NW|pJ9nlC8d`U~IK(dTF zzIEza&?-y)!I1V7mt7>iwAt9Ms;YbYqlzJsKSZ6Rm%uymGH59kQT8cY3FI*t@N~eJ z3{mzWb|$D7@cRa7JqUBiRMDv8%*|JAIQ!BTm3C06Y}>SE%URZWgY)<+AMQSLS0sC1 zX`MUBS5_}uvwI*r$v=)hAvuiuq+1}L0@=69fKv^LFKp17{P?sr zCoQ^UVDTrb>ux}`b3ejm1wznWyMj4Tw6H!ID zB=nq!oZqOxoV#&wy=1o>zDT1pOhH}aP(*^fpB z`7N8C5jJQNEXVHWSA#lG4OvJ<-Z~%YWy|&JXF=|kt^(g7Zx6?z0oyLAfJx`*T_>Nd zp3~E=_9oPlrT6Kld$Zzzy*QcpT|M=rWI*T zv|g^uR#fY^z5K7KRMy=4Gzy%L`aCA;lgLC>1PF<-(FabzLR4l3?<8uM_3|`v4YU*D zp5%fTSMt|+WsGO7sLzKsE-Tar`F3)zq5}DfH7eUvB~#mdCRFUGoFPdqH2%^WPnz19 zPHH@z{Kej%{h|)-?0x0ub$p+C<9wZNKAPIA*Z0c(U&|~!0Xt}9k4)GUOMB93F$Bm$ z6Rn+F6ImYmTr#M#cM$dj47kAUy_1n(`j0uOqd1b2SlK} zth}2@KcE2#dY}yZ@(NFayV`iUApCXrZCTgQURq-JOKPpNG+E+x)_I&I%TwGpe{e(B z{H+@*%5C;=ISAWicY#mN>)5=rF6a!nY$~h9zG-11=C+slg4vsTItGTjI=1XguU%Xg z+1Njq-~xC^?6=?{nV16(#xP|sK=WY}%q@042LNBbVgSlZdz zh}Oncm=tvIMqiM)G3aF=hk;xMb(TCI33OBqXlZt#nR=svI2XlvahY;GuNA5pw5k^E(XH9;D4XJ&gWC~1_DfDMFVi1RAoAO*%+ zpwkQ<5abyp9c`Mhte|F4aR#FR^9(YYTAfb22BJec$o1*8x~0v{NibWRTbm)km265j z)>I|pp;EWYYC%!@me@Km zz2M|+JZx|tzMVgx-R(f1$-*(l^20g)HIq?QURBv&6OU9a!X|pL-B_JE z2B-&lWbn!R2nz)G#jT)4t5T_SDjgWcurvp;g9tbb8qET}jVos#zDjbG-~HEeekO0u zem8sm>g*4Gkfpi#Z`cvf_2&|~XL3J>&0*Me6!wMzDaqb2DnJ}atit@vO5QweYxvd~ zriQ5wt^8N)F6k@C2l$m)==CC-7-a(tC0WP>ll3HrJ?04ogBGbu>Q02L-mu$~uJ$Nw zV*h(b+nB(+%iv?qUF3`VarWbxG<3xABF^dX*;(*Y`8fN-%s8}kIaywj{{-qL9^vW` z+%NgPgh{qJ|Ez$d^RE1}68P%J^3N*3gX{9oYG!A*=ASiC0Qy?~S&OCnX8svt&x84A z1B>#m{Ie0y+w#vQb}7Fw|7>P{$p<*jl{(b#9;q?^%$ZsGW&RmLE7ITc&kEM2(B+?% zETQPiKdYD@LV9vOY8F$Rn}61@vlL&U;3 zswe+!W=-m$uCYC*j}LF(F&S>Ct8WOe+A$RF8WpCx*vH!y?`BQw&dp2f~x%sPgWC@m=Au zZD_#w*vRncKzRP}*uH`7qxca{ggZtD$A>1utH#E08?HH#NXLpZFgkcdR@5!%mHqqo z*BsRy)JZi1<0lYh)ebbIcWiVrymD;YeorpcIxHbXv;W2 zUKk~8!}T|^<^*O(VJ6jx-g$p=p*b&)NH2PB6!i+Tm3Z2Qr~RTFS`l&# zdA1E9)&zQ+X5n7ZTAI@|n)2#diMiR2-%HU#iW_FrIrk%{t>P`^P=#;Dnp-r#aVgUG|4V6rM6=vK5bg(9ImRD{VhXTsa0YSWj)CTuU`YUxa#jKV)>RBAaTG5D zrAA=5&5*K41J9j>Mzq7C$sB0Q_Mmd}vDg>lEVl^NTns6Ur2tgR*$NEbDz+LOy9Qq6 z*0J^MWOfSMz`g@~_}l2HJ779O&9;IOcqev#EjtVfz#t5Q=pjR6WWQxY+{Df73HBtn zu4G4zhdsr;>}mE4`zQMz_rZ@t05*Puyp)G{m`B*Fyo^WLHXdW& z=W+G}bmR|tf+yK!JOvB8cf&+y1w^>2*e}>yuv1>cYk3{}13L(+=8w?TKjsa*kvH*X z-U6!#t$Y@5W4{MA@;~@&K8Md`zvA=QFwX!`jIf=&gLkq~-UZ-s1Mh}4UdG0F5AWsk zIau9%0bhuLoPa3ZseBRdXTRf%*_(U`U&@#9<$ML3ZN8K5 z;v?*ju;siP7C6p<<>662#`o}3`8c28lYB4V$6n+6+1L1K{B(W>IK^kO3)#c$Tfonw z!0Ik$7r{$I2c~Kl`vm(b`)~FfyM#T@USKc5D)5W!W%ddz+ zKc8O!<9;9G7xIhXcjF#*F95g=`Yl077%SOLz({X|?BOHuE@20TxQUT(10(p<8MYC) zrXJ_+2iR93Nq#?jls(420U7o$u`dJLR0HR9G8e?6JwguL4_gr>?Cbnu_ILI=JH*a~ zoukKrd;f`j3d`z~?2GKrEW{%03Rt0kkloBa!v2e00Gi)_0>3?v-2$T4Rs0kD68=ee zR{kuzo?piP!7t~Z;#WY^@+$sm(4Vg1*YfN5XZUCN_55@E2L5?=HM^Ew!@j^SXP@CW z@|*b0{0saR{zZN({}TT)zm4C{@8EaxyZGJwEBqdQFTao9&mZ7lg>Q+6U=#X3_}BS= z@^A2O@`w4i_HnP9NpXA?#jONq)8U8)~ef|UfEdL=t$bY0B%QK$Nb2L1f0WXc6w2F48wrsJp=%Jp5C&u*cdoqFNq@W|khp>H~-oLggyx?faZ z-7l(?f7_|-M;YoR@~0)@lcHyIyLzd}-n4YbUNnDv@9vR-y_1@ya$$z06C(o?JLEJg z^0%T~n_JW?#aqM5>1rrfp#|zy)6p90JJhS?QdZ9>Wp%zutK}xCR?`ruSBr+J*T@;K zna)_ZW^j0jFy#sL8sPT(h79YbKdRS>S8^}+wyM{QvXpItv@k>86^<4vOU(BeX83W#J*glJiZx^@ymaas&mc zN99+e(_>TL)S?(dr;o|wGd5#<#^f3p#)@XaxSYj!Q5MZ<^@QAU6Vr85PSPxx6w%as z<=pqq$bE0VeS7nxzgLX@UU~HQ%Ng#U&QQ01YV`NZqksDJNA>B&qd!Z1hDfeIW2%>R z;yaE$TXftZUGTB8%QM<-fG#?{Wt>*0tt;cjWJ#`FodquWyj=4f*&Da{RiE zZhGI)P~WWV**iWaBGq-y%HQQgb)AhOQe9duysooZIWjyxpx!eyF*G?~o~nM$)`5v3 z^HdwePa}agG(`S3)tcgOO~pT2ihrbwf3z0==qUctS^T4`=to0w1`Tosx}k|lOy9|& zLG{GY*0Iq+9YE|5o#}TNCwE|u%Fh#qZNvKt&&CNr<RyNgQz}wvLSt;j8J?y31$)0VNx9rDxOJ%gjheA2 z!FA2*-NU1_x6w?jua_rVeSM2+$JkyuQe8urn3BCMU5z@iY-iu_7twq z0MF#W`F@A%Yrqph^8{Wj0gp`r8$XwS5?5d$c>g$m9M8ZN5YvkqYJp)1fn?ye^4)_w zDCkHJ)DzfLH!v2dySIN8Gmi{Rjsjy+67rAM;yeJ#m7ejgj#Xh`R`NT_MSddbIrXlg z@llo#*BWtcA*v9YE3UobO7>MKFDmcoJI)j0eeqq1Qk}p9D?|$*h6qfO+M?uPYlC-f z1W%M-87vafJzC0mHSEga*@Tlx1^TxWr;HUiL2LugeFm_(Yk}W!eh$SJtQ;j@oBJo< zlzWzs=bq$~^84KD=$Y}{>*6=qnYrf?<0ZV?l=~~*A41wqxxb))*CNh1>{L-YxUJ6p zfUkf*0~MZrh^N2sRVZdn?vH$3?yute%lxF=pHchexu4?uA$-^4`!ASO^$gXz=GvMk zs4vm4U(bCtcMo#KU-5JP&6T+;7`1dO~bHw0eJp+7x68Ppp zSY0fUTBO<1PHB&HqjanEy!0AuV}un6#R0|h%Gt_mm2X4v7H+RqPL*F3RwYzbss>f7 zYObnV)u&pfI!QI6x>ogs>X7PfH8@9VvwDI0BkHfKA6LJov1?j28#SNNJg@a?PtsnY zy;ggJ_7?5!+IzH*YJaSKQTwX)b)8!0)%EEHb!X|W*4?9fO7|<+YV_-y^h==kzh1vt ze}?{`p~mnr!>h*G#;c5fHuafqHml7$%ukwsie69UUdHTs3?pD+pUeG@-GK0Ugqsm= zLAV#;K7%l^ zAh;0bBbZ=Wu$)@>0d_5OL=wf8qBOF%0e0e7A=S%lp#bB;s`W1 zK8J7v!siigMz{sxHiWwo?nSr{;Q@r7AUucgJi-eIFC)By@WJKmMV$|!&WBLvL#Xp1 z)cFwVe29G(;d2N#AbcL-W`tW1ZbP^m;a-IM5FS8yDEE8Roz}%6)cp|Zeh76xgt{NX zdHHy~@>#U(-|ZQy|C{7<2K9dv^?wufe-rh86ZL-+xxbG3zmEF9j{3ij`oE6)zlr+4 ziTb~e`a`=Nfky8qc=sHhpGSBB;br`O1@}CJzq?AAn)Po+VKS8*7V$k#bjP>uq-~U@v%e%|*u1CZ5qu)^4*YZ2Z(O*yG z&3oUcj=zWe-}6P@EsL+mZ~F(g^Zmb^|7S3a|JTp|GyVL*V?zIbC_w=n|78wIQ1HI= zIAeKng7M?bRFZp!1#>sS%hSuSO7Su~7w^wQI3M9kyuS+J(+F21T!SE$Lemqba;^_Q z01USpXXZC@zt0`Y-JW|Y_fYN|xF3Q?O$x8!T6n%GcWdsZ+#hm3&;11dZpHiGAjXq8 zS-s;Quu}#+@`yjY)zd5Fg75O*bgaTV=t56@EyS9>k>DEaPsPil0g?OHND#W@0{Q}H6cMbaTHS`wxsSroL=YE1b zev$jtbS}l;MT?H=FN#66LQCJ65&O;JU;o}SNn*a=f4Nst=ieVYSG45?e4QHOLa*m= z5)*BGE%$3VCHnDYycIo+`TeecxqsyTgxnv@J)65acRkUD@C*M$X>Z}_Ej(SBI|u*% zf`8w`zdOX&H&M5ETDW8 z{ec=sbLy{{dsDLwrR7%wD1=jgC_7IZD~ef2qd_#H)8eo2HTM-fe+S{5+~@FGUbVlQ zenNepgCFuA;`$WE4eJnXpjj`&jX&jH#OM(m`IC4@>s9`8%sEaaPyc~7$Z~(jc>TTT zojf7?X-i%>j*Xn(%kUrd*RI8+0)IQIZq`NI8skWCSPs14|Q zpzIa>0TPMtxrcMN0bc(aJ4VqTei!vr?mpy3WAp^(62Y72au3Rp0r_9R82*XAgSsaE za(7~dq2$~R$oUzR{X+5T!7otDj$HjH-Ajp)*U$60(CYprsCe`?|4#mLyerm6@r<|y z=`e$_hQ+$dJ(9akPKnw-fdIIHUM-CC^|^0}{<{WH`VNGHpdwEF&72!kum7!w>AH%Z z{S)Tvuihm?5P^$h2xy@yeuEYj!42%*59I!t`vLal|CpY$)A7;21&9OZu+#663)+sa z-v>=SKkM=P5rjiba+eX7;D09Y21cv~?}*;{LH)tOHRVt1_Jb%V5BgT3~|^L)ou4LpO!6Y%3rtj+hl zzsYzGVOS?tpZ8wEaq`XMM`D+hYYnBJc{}VW^j_KZ^nLhnX zj+pz}RD$>Ap|}LOEW9}mJU&)|N7k<>j%2!o@D=CVt^~LxX7tAE!1@k>X7On5rN_oU1PKNuBcs2Di_sQJo)Q=N+2);w48Q%aSf0p|hYW=Nz3V~VV{zhv| zVDL`?PrDU3Hh#aupPVN*-fa9Dxa39%?2{Y$afH(lV9E(H9~{!joL!1=6~bo_ZbTsM zvpW&)Lm=(5hY=n}AUT_75g=~>X%h}vZ!Y9*{){{AG9xJGT6P2Ef^UZ`(9QV8As>QR zxx2*mQ{sA|xLz!-pBC2_#q}z2eL!4SitFX#`bTkn8#?+t2Q7PC$(ISwT_vu!itByi z`cHAaUtE7Et}Dg$cDh2w1tKTx5!im>>@xBEbEydiCT}!6h}mL9kCecK))NTNARI(^ z9^n;)Um<|Qq5*$i^9DkeajgqbBx+A@R`0$k>3QG&J&A%oD1 z01MV4&40nF+Y0tu&1=Y2{Aset8-K`^A$Nux>6aThia%`%`O+KU-?%m*SMi6O^MA-6 zf7%r&gYKxAxDRSKiu)ezPI3Q`cAvOkqWy^WV~BUH_6l4fk3w(pbh~^QtEd4YH{=0{TqU%&#Nvy7P2rQ3`Eg zz8!fl%)j?~AJknW=bOLh)9Ws!w#y3gpY66wYKM?N#wL18`oyLaT9 zUr)!o7uMOk-Q{(N{KR@J+zadS{kmiR-YB|t>aKkfWj%`Uj`spQc)#u_uK-t$cNg#m z@x)zstL{#!_rH5b4)^8TpTDDoneeLs$Bw)UxQ6t{ycgiz|JU5P#l}_KVf@V5UGJ`U z*IwJ}b?lJD_!2wzIwi!oNl2l92_TFV0tA$j)F>4c>JTd=sv01DfTpOWYEg?sqE-TW zp-RO?h(46w0I8^@C^Ws0mp-&rt5S+u)C$ByoAmd6d)8S;bsyT7dbD%Kb7tnueDk0G z%sFSi|M@SD)`R4092x0gE3>guGM z^)+i4!+T@>hOw(E} zoN~&mUBXfOz4j~iB{3mB;QD_?ex&zNA^N)!>-X6Wj@O6}Vb&?1Q(m}$O8Jb#Ud@Kz zIQ&J&|3NFs_%i1$vHRIf#BkjL|8O6BjN$gNB2K}MJddI-2p6#1c^WPwmS1_#!c$L5 z%Hs4bh*skC+#(shB(?0M#Cs&#MA?8R@8DMjLtr2^LM~eo#U?IYM)yLlQn_`sP9cY6TQYh z{u8iZXJ}75CDi$$o5MxyxV|)8M$7Ar&Kcfd3zfbw3@Z19&+*xA?a;69!PhSR`q>em z{X90Xb#^(i-PA@)N-+yEw6+&K^v0^w%z`d63#w)o^q5(&%*=w7;IkeK#Vi;yvtZcF zf^B9N>@>4rpP2-(1(Udo<9lY4)>U9Uyj*9O+Eyc85jk(QWMEt7D@RiHIc03kd-<7JrJ|=pnZ@! z+>MKo%|q1WldwxmFi<~5O%CI#^u-sb&qJ_JJK&!lp$!l-79 z#DHieEL?QYTOur6kU`o}GYYX$s2K%pp5SUmA!QWOMgcn~xE7<3H40-$l}4XX7=*d9 zit_q#t;pFRVU5QEayCp@qjCj%&quiOD6T$2i;TiCkJB3MnZR|@zZ$bLEYaza+kpv& zb>u!DS4B?OQ#0|zmoaLmDCzyUUdGu5uDX#iKH&c7cYo+pL)&SKdLnjD)Z7#L*vK8k z&w+al+#873jCjh3r;T{ch-Zv=)`;hf_#mVD8`SU#o($yC9zP%~?NNl~{1e*vO7v6O zC{0piw49)gKaXCgT_>4aI;~GS(4TM6u0Qd-plaPxWY&0x*1a2@qIIWX?3{~2Pf!R6hH8TVp~^(>Y!aXIt(^Yr0bBtZSw z*W&>u~?o(TuH&^|a+FWK(>mIQkB*g6`2%o9}{k+F0j} zb;ek?80#!pAEzd-;i{m1g0S>>9_(L7cBRc*>4D!;j&yp#eIQE!Z!#{V-&@@WZSI41 z_d%!opxb?*dFe_^{Tj=A)~KZ{?^z?Y+DK)MRL)3^8>yO+N*gJxDk61~_6bI)29ge5U+ z6ef&9-Y5(hg|tx^GzwZ%z*RE}BSvAhC9#YRg@WfXbWS3V>%zLI2df6_ku1&T!>oEX zMjdd9Hen*~!f;{KjctPuAcGf&i=zrw4K9hgEV)HXZpo6{X2~sCa@(3Dcg&JI%4+Dw zXccw~ZjO4xPOKL!!+OE4s2>XkALb)tvtbrHj*{*^OZRf-l+BE}9P^^q6Ff&k!eO%^ z)%rF5u$RXFnn5$1m4?)JB6>)nnILWLr}f5iIaU zi*$=LcuSK$$s6mU>!y7q^Wpd+ejT4UP(|U_X?~Yw&ODl>%m~pe-(cGfBq_wU%Mx2c z<5XxvpX0TH%78+Fxsb@~#8( zcZE&%KWKl;ex;eQzsdYBGPWtxjqduFYl+Av@G0lEmMzr5YR?E)# zlA&NpYWR{U4AwUJ>Yw0VlHZPo7-d{vWM<(G8*kaq?0!EMFuvrc%cKr(TMk1!=6a>0 z{meZ_d19nq6m^&CsIuoNSu(r_Je<2Oee#1W&rQwTI zx_;c}RNNhKHjX>vB5|Eb9N$PnuRJOXB3R|ShcjAjx1lYkvHg;wO=U*@PS%tg!Zvh) zj@O4re7p(NT38qFN@9;Bv5bpwv?+{@TXNl`mV{^deJ`nHn`0hzY`tx?DSY$v)A$cy zcP0+2nS;-=N)kRB9;{pbAk|@7k#jBho;t^optUQ^RQh41ZJXRn+YT*VMtH2r0?5`g zHd`oro|3C*paCRnE%U?_wsK-{dK~{?_$vP0;cNK!1fJ`MYx!;CcOAd$iM*^VBVp_jHw1vPilfyh zA3tHOsJo>vqn*V)KIEL@A`541LCg2`0{yFzRg(o9-8b?dTv$Rkp#OEft@`#4vC?Yd zO~+`%37>f^981a@;(ytG(FoOgKX~4bJ^$(sYX})r?aGw4l{xAL%IpGrtVM(WF?6e1 maV=Mmu*~vx@K7t%;>fv?1ag;r^|EWPJHjrm!p>pW0qSZOZdfSz@_iA~I*IuuC+cntUb;mTv7%(Nq1PsIhLK-1~ zluJm$rOJ?E2;owlg!FQ`9E1QV97(twNeBlC*8V@=d6HJU*pR#5|JHtbPxI!@oB7VS z&-XiGoH6FWhlWK)H>?=PH_rI@0M7dH(XnNY2Ioe`rQhKDcgNSR+3=Ow9WOF27Z@9S zWPHP>;Wxf~{1D?y4>G2Gbj^n5w!YjoXoKH}`ZK#Oo;`S{W!Lu^Gj!kukL5A|Tg!ndR?NsSkekw+LhJpep<)(%m>k zm@IvQT}kbVwh8(;q~W=zCEr|;{}+o%n{ocz+#LVY+!=m`MWug2k0zMRo94bG(fKhZ z$s=gzb|y(JpBXL}RmZf=c?jZlqx$j9fR?ig{XMdTynEwvfj5Wy-mXKU5foC)PMdksRGyDj? zUx;^YWMRpP>(2m-KF>IRWbOsJ<|mm(jEBbbI)^+w_XHg@?!^beLApsj1Uv{PuXg}m z1heu%u&cn2V0X47Klkin_!5lEhg1VN6RgX}2QXiN`#FzGaE}huk&glXn3wV)=7;8} zd|<9H?MV^ zVGZIw@QQG&d`J#fU&b@SH5`G`nXbzRm1zkNE4cU~t0R2mtvEugiT$1F2rm~O0#6B7 z&wjX2PDe(1vV_Nk%SV_^;4|TL`Pj&;0>2F`ML7Ns9iRcCgYqG0f#{+70BsO`2pZ{{ zJ1Vu$T_-i;cn3?$mxD%cW&7j}>}EQo``H9wHH;&~zQC5SyI33hZ`OiiDQ{w(T+6!I zf3gnt4DNk~b#onSW&2U~(A+oJN9Vr9*c^E8+~r8#-=BN7dxJ9(BG+->NxuVZQh3j^Y`MG?FiWu(zGd!jvW0DAPviUd zm_`0QWXd-|i*DA5vF&7+2psl+)`LWUz&eubG7c9$QH--j^KqPSWyhF~nVE$(vtE{G zTiFb|njPi+d^w-x*YUUWIms_|OW%~fEj=M?WW8*b9daggJoKf|UqUZO+>uZu7D+`~ zBJGjB$UtN$G8#D?xh`^VCd`2^pM-uzAU<}`Zqb0`@) z*@)hxL&ri7hyEOTDdLQHBN5S?wn%SPZ$A90-kcY`qtY8YdQ%$13X;KySR^E#gFn;{CvKRZ|5Cs5E$FfhS>n{c7%?`bR?Cb17_OI-5_HXRp*;DK>elhzcd!9YV{>c7|{f+&By~2jrhk&E+huruu z8)N^>ma|W?73@=N8T&Y!V4r3yAx}0!=B#EX*?RE&O_;GY?0z=I9%ftE7eQ-}uxV)N z=d&*Z&;Nz(Vc%xE*f-gB_EokU^gqkK!SCVkXM5SBY#;kB`yS-d_t^n<0s8^Fko}Nd z1b%-p`!RnX`za{?C+r~m8M~4_!49#Xvn!wvUjzPdE&CNa%6`M%$bQYPW4~o@V$VXi z{tr<7GwepljvLtT*d6Sz(A@vbZh;(pD|-Pv;ZN*#_80bc$jCdPBfOivm(8(zIA?dm z)cOFZ@*|K__pz(klibC<+|6sboqO0j*~{#0>_uqmFR{(+AxPRY>^-c=8+ev~ihr7q z@-c9g&+uh@oUh=Q@`Jpae}%uBKg=KG5AiSZFY!nCJNPbsiocV;i+`T)}@nc3S|kaF>8Jla^0bCKN-$SyY<9w|6SA~Q3?CneX&@X4e+QjkVA z9*Gppaa@ed?k;H79=TtVBs5cq?g>Vz^!^$*9}Y%v86Upi$(^_tFR-;!d#3OAaH`o@ z(Bukow%{I_qW2X%BO|2>fynMi;md0an$*_&Gu$#Vwri}Q8=Hz2@SO8I(lmo{_G) z=&EZbFf%R#N3Cz6VwoLUa{cIeW^+c2SK}TDEg~diU&ffoG?u zOCy+$?8bQF!_$ogZ7wnvDQJ?j7?U!xcB-JnhYR}nFlGRc%rq8s0^EQ^WcNvB=Wv8> z(r^Q6_vy1B&y4LVXd9xqITAS$Ie~YbY|$nGp>El6(dkGbzhMgZ0tA;* zR~idSu3#9+-VXsMpsvT)_;4KP5+9x|NIUly_%3v$pfofV47mvPwg$t}u$`!a{^n<< zsmjc#=&dn#zoCYWj14zLD~MytEkq=<+6vF23nLi%Ol0gte3m9v0GkD9LJE-pI$7=| zrZzr1s=mTiiLJcWlVL#uwMhVVZ={D8&G_W#2_K}&$iJ3^j4zM*A9J!Snr%q~i zk4`5FmOb$!jRj|J<+`br8`Q!;6y;7)?#i8Hj*(4MCmoKF0-qf&*t0}(K&|1ER{F5v zqrg3w3pu%V>Lk%2z%_gVGmO{T8lrJLR=x&4(0zIYluUQ0F_Llge;lO?C+i$j2u9(I z1IQxY2YjY=MI{R}cHFOT-PHO2K%$?NHxh{87 zp>rsAQcve_?xcav*&M*4PDDXB1CYlfO$EM<$fmK7tGel_+^3FxzI$&+mb6ZXA8|B%vR9EI8gVjlo_Axi4%u^ z%^JYV#zI?pu20tJ#t8Katqmu&+&wl0?ntxPUIm_W)#=DZIz`Vr(VE)M*sJ*k*w9&f zOXbW1Lj-@LL-C%I9o$W0?E*Y7fU`a`1FD(rX)JW-n!E#zg`U@|3Jl+c>b;mE=1E4H zBICpx0NNENPK?LL!HcFK0l|JDntC{Qy8z)nFfmWTi)tG1)ugC)(#(bnrjhKP6HW0* zWZ(qa>|adS)7oV(~Mx=o$n;eA#SIAdWXI!#HsgZ$W!! zc?mi~yjce##$;%La1bSndV&^bNo-LkWPFxb>ioNaECXd&z@jK@?}@>vkS?%|Q%#WpNOtPIq!yyl%2X6|Nqk#@TNNcnoy1pT-b}nS zTMz+_g(c;;3Fw!mYlbwP#X~MnaUT4miJ-Ww;2oJ-8-Q?)3`{qjY~e1@?a~Ez*9X=v zxI4VyZu!~j_7*=ek}LFOt1FZrD!*_vSLn~40QwP*odBhut8bXlra}w4I3|WnXq-|Z zKMPH5SRFc{a~zb`1jOOfO$>se#VyJVl6H>U98L9&qc|4T!@klRA(HwHc}nB z*hF>cVl&mDi!HeazzaukHH9k(d0b8B9)J&muD0SzEjy3uaVk5X>WQmuR8L%Or+VUQ z2fcF))n@3ObTLcsq>G*OPP*7db(Z5|H`SqwJyeG-_EH_X*eCimimUyiUvzbW=oeib z5dEU73q`-^>LSrEy1H2Oi>@va{i3T&0po$n1RfOM3VGakgL=6Xmxl=GRIM9g=KRzvZNe4(n6&%{ct9F|W&OzKp6S ze76ig%<|$NeSZ+8Z1iM;-?9!?!YxzuefQ|e41GVPXKDc(oerE#Q_(~EV~lI^x9r*| zYT^d{eW5|}>s(x}X*k8_ju$kyL1d3Upxv!wqodUS*ZcD^@ef46;M3R!pID28bvWr1 z|5tdUlG2CgUdHt==3bslsx_HU&hdBQcpI8tUio(idj)=#cS0y!R3Bqu!r+f93t7_isL(&*2Nzx@se}x!RuE(b~1O->v<5?Qd$I zuYJX@_q+TNf6jlW|3m&y`ycY3_W#`f8~=-e$-q=#ci>>)&w*ml81w{V!KPqea9Qxv z!H0sUL-&RX@CJS>^wZE&;m5+yME1iUc`Wi+O5KaBn&`fT)t7>k)> zzE~pG5*vUga${^Jc2Vp|?8ev~v3p|o#XcMRa(r+6P$H3NNwTCl8A`UK%&AbSB{iIS zH1)I8uTpUH&w`cQpC{UZ&=22Vq*;n9Yl zHT){OHhX^dxok0K%f)g>ayRDg$la5>uW@_hfyOHu-_&?}<9i!F+IWAHrzzId)bz=w zN1A@v^oyovn_g)CW%KV_wzgc<^2e6Hwdz_Ot)bS2)~?p!))(4Xo4L)`mS}5f8)#eJ zwy|xd?V`3LZ8x^v(RNS!r#tTHT-N#Ht{b~sy1(3Wv^UW^(R)Yl{k=c!eW@?fccAaS zzSI3u|7ib_{txvR2l4|G0~-gn59}W}G;r;}jRUt2+%@o_flm(nbl{f*&kjn1!NIP< zwSxx--#+-Q!N&%l8_Escy2Q5Rt|j*^Ikn`MOP0EV|R=_wQTvacP{(r zvQx``y6pLJ^Z5GlA1^<;{GsJPU146)w&LK5$5y;JQ8SU6SU+)e;ujOItUNe5F!|D| zTUI@@`t7Suu`~uK(4BmJO#i9^TZm`KHb9-29=^5d!0)D=^|m^MydJAME3BU>lW8#?dB^Y1$U-t!CF?%1B+ z{^K349m{vzF~eq-&wPC5rP<}Ph1pkj*6eKAIkEHb&JXSU>CP8+MRrZ?`s}W!cC+1) z-7UK(b|2aO_T7){etu8Qp5Z+|-1FzX=kI-XpJ!jkzNvjz?>n*Y&V3&O8@ZAVNE@Xc zxKdzrF3AtrYvAA2@JTJ#z@@Fx9aFeYg2(qL=w8-vc^{KN^jnxD!LNzyH4gmQ?RJ}9 z7s@81_9(~UO?$oF-HJDzR^rmeVw^uw+{CXc-uzEnyO%CWt>4u>u{<>(#fzLj{`*_` zo<01=yZPbE(cIkA_-Mqsx*I&CHQ(gr8s@NBWG+e6BP`)+r2Swg{#7z$aDcD&csxFj z&*gGEb$Wj`)z!`0ogR-@iFS8&r{fB);@wGE=20uRE!}I4dNsxW*7YU54-H3_X#b$) z$1;cO4Mu6lnGc(wnr*F-g|Q4nL%WGay) zXnRtf9bH}OIO4HXntGj%>2z-Nx~;3*i(%coXT{qOtlk`pxaznolN#UE(%;^=qdT(f z(h=pV%Z@#Ic%(Jj<*|DL{v^Nbix+QL)0E40w*e0b?&|?}E30G8`CJ+5=%9*t05p(M zE(6+Tv(?#x=|oDq7{GvRqDzIREP58pI5$T+wLdxW_D8??11%rBtaICz=5?AtsNQIj zLV)1GYrprar1{8a&!1gW)cSSS8o-NIf&?C<*@yC0>Wvv-k`i3e%abb$rnOV~K7dXm z(K4qjY2>3!tJCYWki>dLr&o@Gz;s$!2kH@YgYGj$G0ZT7Az)Y?$oH!cYmWUdo}SLv zq>?Us%UEZ8j(n}!MQowspVg=8f|)` zi)eaBA*M2}V(NOWsz2Q79-W+A@%uUIipYr&W%f6*>V0fN6d;U|#cy>AcBRf`ncl zdKDz(Do7A>h;~N+1E2%{g3Vf^HA=w;>i4^&B^c~Y2gbC;|I~@m12ZuCf%-YDOV_Ex zf5~o0($GOq(Ycqf`t@O!VoUQwL{AaWlSa@JkYavbtI0|`s{^lyYKg2;Ea^#rt`vRX ztaKF@bVX#sRkT%~1Z`c@wOr$_@6~<2zU5%I{?Ikm^p!gF#Y^su_^-EU z8~zl*Y99Km!F-(pjb(_!YP6sx7OVAw+^O3@M6TH{LD}=K(t^?g@@lu+=W=^=`d}HX z)C~nNEcHl{RmO`xM#0wzx{0*Fu%fm$?n<$_RH9W=)YZmZo1=Z&;u)x_IRklUWA1A%@WTz38`{1@pBvX0Trh_jW6r$I2H0RV4KHA; zJzVe2>Qol4HjU=frb**Wq#ImP=TAGg)f}GH-M(sAI!|;R?S6L4ZeIw`Lv-Nd4WeDF zqvxB6N+K>3MgRdvA8ZF^0Rz!#B{398)|5SIx7T_-gc@bpp_GTDNu`0)ogFGpD2fn% z@kGkGCETt3t2P~)iuRJo+v`qk<`lzWn^EjQ8-Z+mw zYcU3RGAWsD|55yv)1yfdu0ZJXuc6%IK)a3cpVR>XQKhLUTaGhck3!0(>sb9qurcdp)wgU z_wsi1Bg&HbSTNwT>b0_hE=3=UJ$@u(gxDb)y4nlI+DlN=POup=HI1sQZ8c(u#$hJ2nZCH5cEoLk{&_- z{4A4CdOc1%*gwECDLUp~MZFAQ5tdNN?{m9JTycS*2gRQ zcS+*2KWs=84E7;kLcvwg)o4jj5* z;OZ&kvNp+b>4k$`10xp>bPe2oi|)vw!{5I4s;e&e;&ryBZU%Yw? zqPj}(YXkfetceZf`$-3uxmIK5k~SI!0cxOvYJp%{=>kO2lz>>$jX}>%$;PBBl}lCg zjy6^8^m>U!lirqYlHyfh#Z(heRnGnVqw7~WY7Gs#M9y?xG^$S;oynXtrSeWbcKH>_ zT20DU2Jszh_P9;;v3Ge~O3EL9e>P@(!9fu3U$b?;+5J5D>WgzP^REEjU2Jc@CJtp2 zyc>YVsGvp}phnd(2`GXH4?-A`0iC=@qowf>dnRSt+v+HP5oPNKiKH3aeKXC-q#pk+L3C_Cq_3Vz$v4c z)L!^aqHIgv!28t#Z~pYl&= zi@(t|xTJc?KjZ_`mF0hyB)z>ODoJPl%|_}DXT#tt_W>v4tRbKF`}8mu!GKj&fg}Jp zi!4WF;&O2o^VHW8mlMWF?aUj^9G6g3ejI@`6KnC1=gh#V$tw+{~MI znGAYZWQZXIL?OOw9i$pKO5##5SqKK$xB$+Yi1uZzKjRtscBDi5lr|SEK5GdZcS(m} z7=q^)JNb7Bn{}2mcfuT!X4QASEZ>QD)v_d244Ptado~yf+#DAtb}& z+MKWWlqG0sdQMTK8Aqw(ef%%_qQbvUa5GxZ+yjhzq8KDt&3zYmwo>pHNQfHDV>sk8 zna6l>*~I<%Q2|k=W-dR zug5Gtt8IyV>Joi2q1zIUSf@W2>(TsCmvwO0e>d`^)%T09i+OaMDgN2*(xiOJCmwfe z%ldT={Um#^jz#iyK3k2&ETYg#VqcXSwNPGF^RK%KaAgq6s=j}u(HXfU9M=9tTbp#O zz6U`2oz|apd^;XmrYZhLTW>P`h;OhG;EcxNE1VO|WRtP@FTX4)`ykM{Gm=6203=#p zzS{u)<>O_Y8GctX;qd<`pjW0~S=R1Q$#^8;7>P%SOgSi2P-eFvQZI*Iq}pW)gsqb1 zcN}?x+f$>rYfZkPYi}FZ7cYI|rFH%qqp2nm8ocgJBYJ7Kq)m-V(%S+WP5qgF;rcAJ zq<4p8xfyaJ13vo;{sj8i$R_gRX1%0ki8{9|X+7l6f!d~pim5w>Zn0USjrb}(*XiV0 zrG%!D2xF1PNG_d38=+(>mNZnE{9dvkb)6vMvZWvE?C2(40VH1574{DG?jP>#@djdv8dvptz;TAqmomGzsa?%3$BmA)c9VzXLw5m%zcp1GhSfBDM& zzBO;m_aS-~f-EH|F1#=@>?=WX zLEw6@KA4F|@w`6~&yX9&lZNPaf-k~DKwb>5SH`F6jS(u3Qj*{|wgo$)0}h>YJmjr0 zw~VeB9?HLAEWNxbUYD6{NhB1vDeU6|r_IT<#wdA;kL}#JW8JMA8pp3$(Kp1e{#d`= zjUj;MX-@9uzksH(D{rcq_qmh>pK!e#LidHT5nteWA;GuEHM6jS%00+ClTIWA_gLhd zA=y`+nUZ%V(|2HGWJSX7^hyT3&y^X=v}fza!hz`47UfgZx2^5V1#>Q&#cOkTC$Czv zWJ$!EtgEN7sQC4J%tr>Yb}GLKl1wrnT)i<_R|~&kh%2&Y5;3ugPKI@ln=~t!ADyl= zHUn1_&8%K43BOW3TCMM@?`&y8gXu&%mP#6{4Ph@-z%H66nkEu%Ks(`I5?Hqg?Jii_ zzjrX&YtuToJmk;~cUgSqfH$Lax+5wA)~(ET4gdpF$=vcxyiQrWee><>Ec&_&P4(#- z$@8&WUudjx0uLJ<4Y@%=#0zTIU){45p|KK6V-7!xIrOq98_f5)oKRl)BuN-(WKd+G zgF!BUnlRo9{`rt&E;W|9$iMx{m|Ee{|xR8coNEP)_IlOA<;PTP2$>SSZ>U-AJ zg?D#rx3<*s71*^wP@TQihvS=;Q*FBwdR8~+~dOZsB_*8|Vy4$dX{ozjDd3H^HB znBf|&9t2)iF`D78)M+)4l;}9ZGqqZI7OGSb6eCHx9hjjyGUN0+4P37e>sKQX&jLx- zqCN(O@sB>2Z>@d=CK~aY*M8UZblwi&4Y5JH7rs1SI$?wlM@N7nNmJ1o2phVcK!4i*Jb;OikOjwj`n%hePq0Yn=V9Zm(iD zw-(>O>Eew<6La4K+#dyQBw0IKpI=kQb#jAUg8L7E2FC^91QdwNl74B>B>b%vY7lTJsZQNcnPMd@5)7GiCI3$G zVy|zQoU|M3CbHu_U3*6QJ6#c@XQ}VTEA}jH(_7;y&_Ou*cz1g^>9E&$z0r$z4`v6q zZC-KPR-aEQ9(?!hTt9Yb=yD&(pmz*o<*Xib@H^0d2RoLxhQOIx!jLen7zHWvQD96O ziaNPMG3}@iPrJY}Jgk`om4HL*fPnCk2lCn1sR`HwfSx*CRJVFMPY&!3)?S~CCtdY= zNKy?z06ZwMH${mPQ-{~H!qrP`T`8-anXEf=zCD;z~TiFLEj=3raWd^xPZPzL!P%hAm~%EOxg0Npo9=0 zu<=TrAFu5RVd`}&nlba?Jl209B0>tR5WfCPItQj1-TZp-D!r8i0{WAH?E}q)6vr# zT-DaqAJ&+L1PS~;Vu zG#a$1fa>bE8=MBC%2fi2-)p<|gk1oKUum(LrHI8L6L5Ok?R(nyTgw~;{Z;j?hXJ2O zzC5Du|FtjgnhiY{Pp+kpz0-kZ(QCzH1WpeT|+cS@c2T$ zeII?OM@Hg~{P}jXq~je9xOI`eCo4&?sT5RlEeI2c#Gvil~@nq6B zqA)jvuxn^wtf9N1tF;;JX5g}l^^}F36PVjUwvgaCB~+!w(pXw$XZc9QPybT|>Vr)tF?v;f`h;Xyj0F5PJ!_XwKwfV2GyFh!S7uLd) zp=GjgSd~Xj;shQmwGF>oLG@ZKs*wjAIGjhCV+bwK0$YGGnuT?s62iBYNW?oyAa!>w zra{$WD>?DVyaA6J&}M8zdH!V)bB)P5xH8g^h*<2h-s$dWGC8LThN5g)bU+d|v zOD5tC+1%=n=dvMQDBw*-w9$dg@ML=`7^|_=cpDNye?wioJ5xUjU6aNf#+dg? z9W2PUeZJNwY2eUPC%6t$R^kU`4h}#LNmvIOO=aaWkPng+K7NkdkWy$p$N~ww-R&-~ zZ92Q--Eb7NlZvhgS==Qkd3$}McDb&;E$GPDK4^NsE}QUl#C#|*CwVK$?0wa?IYK1HpNziwMe0`g2{!qYTb69INZYLR;p@2`( z1lM+U^?PNPmBeN3ijdiAxg!>ezHje88Kle3dw`s(8ec-^%lob7*2JX2?DlH)lRI%$teZ z3Fr>$4guUroh$c~7`n>!sh2T$JK^yh4u!PAL7&;yvOQ>#15SgZvu%^hFL!qaYD`Kh z`cTrA{$#zWjlbDLqEsrr?Vh!%%^ca(cdWTL?X)GWk~4gMd+}+7TZ+#?S-S)ycmXhe z1Y;mO#+{5fWm#&eudR{6!&|YgSg~dj(8s_D_%Q}_;#!1vK%-znKNF^mSk?>VGp|(* zQ?p3hAUh`Jjr)A6wvi^ehBX;Lq`MnDpadGPD&Z;sf$mL=LeLDa_nL>NliJ&iHA`BD z`pm(g*4Y;@Yu98N97d?%PKU`j(%!X9Yci1D0X1B42j4lJsFjmW-|Ds{$Lky%ypf-( z@3NZ>G`pU4?Oh+Vn4rq}BIvQ0=cK^HMv4#f8Zng_D+K$w5V67^V9Mh8MwZJWtkGAd z{w_FYOS3EHd;vn1O#E(P(l3uE8(ZYMh;AZdvb8R2bO1Xin=Dq7QXhK8V6d1>7IP-} zcsS@E8|V9LJ&~|feCs8<>!S|4z|+vqrs4-`oJNybaag^jn1{Jn_t7P)kGl%{i+g&{HzcFN*PDVC1575(rMnw0 zEI6+?Aw5wfF&l*{z~{Tv-`y27dKF8J)nxF7yhm1#>Wim?y*)v@-D1^R9bo1AVi!eB z{3DWN>h^0jN67P*EFR_D*N~Jo*ZH6gHo(?=8tl4J-VPg7i#d?_jr>MogR14rD&@Cu z^Lc=XX|>FR7HDK!)#p{rUE9;Mo7B(oSuq{>}WOct7I$<~l@NUlw}P<#|^l z*JXbM1Req`iTGp~ z(=diSlB*OnSUVp~IC*8}a9cWTcNmNzhYJA-xp1)FueaSIq6jtfk%dS%7ZHW?Fxp*# z(OdXs7@wvzXP;(9aMUj3lP}MYaq#=zdIV5;-3|+kL9%)^>^Rc^1lY0Y0z_>}Tmh`K z+F&^(GMnw`&UR%xv-K`lZQKC0iKhw8JcNP{u>v?B8%cvEy;IdS6sJ^jk`M=$+hsmx zyU1#{c_hhF)3`WzQotcF&=75CPVBxSvqm9aKr+*zQcG zdQ*{Tm+nkK`eC@%;iBWr8IFxG=3%q60-Kxo0|I}Z0223M{ig{&CQIJdq6aswk4TC> z%MD67x@-heaX|>45g~Yo5P@ebMc`r4XrG0fnNe#t?t?N@4mzMvJQG)A@m3aW(C|jp zrZhz4YB=Bjrmg(gS(-7bQGx%1K0yigH8(f6Hn%qBGU-?}VUOGGQD@9iL&1LC94o5e zEGZG_M{#~lQhGt0A11lG6bs9pGO{i|#kX0FR!oQYpR+qs0Yv<9t=oBi?m>6JOffi# z;#yr@))g(BKv2^&TK=QfZ)Sbc)Vp=^aQtr?zVvI_`T&=UXT04p3e3?M%@2RYibYsh zL*C-=XdcCU$yZ|@M?j+rzfsx-Jy3sA;)P(LGfQ)=#-IuwMNRJBHpae&NCg^G&aQ23C|--g~x3 zrPWB^D0{|}-ee->PNv8+&dwHdtEeHxs%IF&WuBnZ>F(-ql{o+0!l2wGd;8qs=uk^0 z}w^o&t z>{jcaD9Lp7z4PtA8G9(5tv@&Lbyejg)75rv#=aY%M+~4t#6;(LGVfi&xp`0vGYiWe z3YfA!$BhOpNRsU32~>wc0L4nHXf^N*Q4Fu{C?J6)TGNcQM!@K&?Qm^Bm`u?R-m@k(P+0itJD_1CtxXl zT3e^ac}sVnBYc}j9QK5*qQv;KzB(5%brJH7;Z$&yFO9mQ3~ZjvsfZgY1jh|tQ1ek+@{u_YZS5GF=Vk&`DyHNM{^=!bYBy5 zhM8ctbQ4gQhI9cWm1~x*||m$hb`)eHaq9r1iZ`7cJVgo zVCu6^2!#v&FnTZQL@90wg0BXPmU_L69g`5NK;h?DN5jh6F|auih+CGP=wuW-as(X& z_XLIYlekpUf+qpgiqh@1O6AyY{(uPK20?_EyY@DfBfOIzaD6FOj`IEllty6x*HTSg zJV->Lcl>T+)9!y&L%mBdk{%H-0)7h^-CrIj%89S2<+t&#Rh84+sO2|tSh_$WHg`aJ zSn!WMd0Q+R3OXHjMUu-A5#+)SfW>eUP8{wjuH8$X!Ezun`8RP_tlOXEw(8%6BiHRi zzyf(VgcBde6xNta?!;1HXWKi1dDr zg21;J-LCl9oei;2QiLX0tjm`3pB0y?ETt6so#yWq-w~kx#Nf)>CV&7qBjUgXEQzp) zXF#8>loRh(%WvTysVXNJtL4Y}C#uScXQ<^TxH>0`+rI((lg+n(VWs}9G$)n!63MU(4Mf@M zbVD{tNe7(NIuf?&WN!k5O7sCbxy;MH+rJOb`VN^t*d)%Xcl)$!lNzlHk};#Oalk03TW%X(!;-iR1^InDKk za^%h?xJ9s1jF26ylxU7Zh8Yx{;TS`Jt3iJh5<}5xlzq%>Rs;G(=3eDeuQ%?jF-cgk zU1jAKi*Z){BJ?RDg3W5dVxAd0}3AbHuuBW{fB+yWwNs*t!X?Hp7 zNgI^5a=NLv+T9`a=tcgHMJX%qIe_$wOq`#xiVQzwIbP_E7=_Qn<=S8W)r!xfJ6YFX z$<0u%I)cm$y{a*5rHt=u+Dw2l9#x-*(p;6O0iFguOKKCe?kUl_>dR2eiI=M7!k0ny zp&L+MN4fm8svoQQ5uzP$sU4vom&&Ofwfr`a2jm;dJ48OkI&RB*^-!AnxsLU6t*$cw zZcz^tnQSoO7D06#i8+L%=&*nR9-@&U^tgh!GvOT3YT>0o1no}fU16#07HL(V(_tC^ zAAgV#uYrPWro}!lv-XqX;pnvJg}h{1J{9R5uj3&(XTAtOv^WFgiq&Uxn6#`;zAAY{`taFuLbjf#oZW%K z1KzXcm0+OIH$@JO(1BE3IL>EqgF^tSxh3`Ny1WSv5y@$nC`4Md!4n3QU=iz(!et$T zLS@Z9JrqB-1JaZduS#hq$x<~LiT~%WH=Pe~mW+qtPVv{eUC5n9jEh#rw#jLe-jihu zl1|8vQgS;BC(Ha=h^JlB&u5=Bd_ws|TFpS|kEf@V>1=hHigfpUDoXLcb1zUbiq`c) zjvLCUDkO6S36)fFWOy@){Ggib;`J%;N%_ zu!M<42`s7}fL2I64+&NRP9nR}@AoJC@svB3tWL`mW{1*_1gj`i4>9BfAs_S&MQ%?c z-x21antf{C{FUOr+x)gtBGtr&J)O~JeYT;TNkx{&irb!+_LDyMnJv?8?Fujc1wFtR zMgEQ85w&70HI^f269M^f|Ts1ph`=IoWz@`3>--&cB!R zIko&&{z6qb>2qrN&HVf57e_xkM4nJTdvo62*ON(s;W%N2xS(y9)4hVoLJ;b9DRO_| zQjcmUF0KPEh2|^PBE6KX#YHVe$!#j)5BgbueXTdyO!_&8xgr$rvzw2qgY+C}O45Cl zhE%hAjXh~ucL{l#l$&(kr50Vly2#BEv}z_N&ymrDQB~%e){0cvzJ=pA zCo1KnL#pLBVgGU}Cmr%mfg3PuNY|IbRpo>OYWZ;qe7#bia6m0TA@x+16Aq~5H(>8{ zs-FYBtQYOqsqOO%tIDZ;wfwm1%PiHW_SNze>N@1B+PQ%*scMJDrPjZxAh-sncmfMq6M}5%^W>P|3zMm^lp>`E@&sfcUG03sFa7XpZ$FOcT~!Q z{Fni0@esfj%eEWVrR#iS*sUPB3GJzK72h~`TweB3el~SvkAA|%A zfU+{T_f%jUO$O!HCVls`a9a%H6 zJU$#>I@sUU(Ufb*(C#mF0h7n%&P8odO=$6-OjNA-3$0fDhDcpiSdiyHDqF3n`ns7` ze2>Lub(?KR?T!XokL}O;OUtp}e-+5jl7l51EY2E>UFq{bn2ONyY^pa=S(^Rj;t6s6gZ>hk(4!9xv95ZtR1hPSM0n}V3&B9rQ@OFz4OMJRn6JjtaJs^Zc29Zi)qL6S>ivJSoB>5Oh^W>on z4At!r*SRS`r$j($RAS|sRoZp%oDs3KuN}H<=LW7Z);FfI7L%0p`0AQA^>i$^4^1y! zK5_jzEN3-0Anm7q8ilr|sct^8r8(+vov6`1GHzejGt{HBwvTPr>kO^|M?4brnC-GB z5b%Y2qM^F}fu`(4)>2E!LD6hOydlnwq4tPBvSadRy$2OtUjx`Y(TtcM!4E%1^96ng zUqop>@0PxV`GiIVUt|FO_!QP}IWMHj>$k*9Pa)5!TqgmZxu6baj`$npybW=!x@PNi zV47RKQ0B2(Tv@YiF0F@Rg=oQ&ZG3*ocHs)@>nz#6#&g6-&%I=O&IezA$+r5@1xvOG zb;&keUa~DwdD%e-SK(`0B&z$0a4XIy7NzRdt;?Rx1bI&>vyV3|%HQ*6SKqQiZ zFG~s3X~M53Ag-KJV}sl%TM>U})MHr(A=M1*n3;|tX55Tl=>p5xht)x`#*Q=>qfwp( zdj|YXrjjhARi)nUwidM6xRU%)N*2hd;2k?asTlJqMs*X13}pmTU~wa6}) z5Kv!<^g^x+__>bwIjJ-13~IH~EZh%^RQzPQD%vMi|7=fyQ){J6)-GJ@a_3FIWsk*P4|0wV?DeyK-ac%Hdh`dOF zpK5tf#kW%VKcgMBJjvnxsnjPtRLdjknqnpAu%Ep(Z-VsD^*2aHv3CaX^cL*6ra@5L zF$RYQH!}Dy5G5xYG&1oDunEo$v)FGfz6!brq#AUEj;co_Zj{db2qr>Urg#ik*4y2b zvpeGmYIoJgOyDZXk|KbGF;#;DM~~A&GJUONB8qr-3UL;yUzz>$-yy{?R-KKwd0ZKn z<`WWYjwn~vEl5gibtM+2BnES4eK{v_D)+esNr{*W%o*it{z*O$Fiih5m~2bb;?mpiUGgq1YZlXsI}VoDkHc;l(v9NQ$++j z!U6f3HKD@!d|3TpzAhg!$up^V#OyW0xIjmY2^01;a?D3M#@ni{g|1C{9inj6Xml^6 z1XQ>#p~Efr)YZChTv|H6tu~->MG@5RUKz2P&3%fRC^{UJJZ@(==5d5$<@1?e`+_x} z@g6Y+q=*q&j>UUDmQtc9SMx-n8->X8|I}duC6NQ>X$m-$X}hu13!LCsEh}7V9#az2;nzhD`yQ06ipbXR1;1=F z{siCw4I^)wXXOh3H{@5seyhQ+e*IYG_dhQ4d#ZClz*epEP4+#Zn^U}r(9KKjOjOnR z7JC%G8L7sVJS!T4e+unrL>=r<=I30(s({680_BtU3e$ifX!zCTV}LT@8sh$FFAyPw z@d@)WmP-&bQ`s%)<08C-a{Wkn3WS1ALss)ik)R!@4d}WZ8?u$u?WP{r=CosoGOE0O z?$rbf;zrTp#UZ$r&Ze>7PME+WrZ@U|RYP4+I<(JJMnE34%R$`cmljUq3(Tz;C5 zRMjUPNUdMT---4pAMJqH17d(Ztm37$xn7dAEnwbMs%j0<{g1n%J`vDjs|h)v&uh7ohEiStiY)hGF= zwiCkd(4t?=HTN6-FZ@|}>SI{gM(kCs8+v^*5wsvE?i^t)V$LgJE%mP-)>7K@qYHby ziKrHSPXvkG`G$&PJQo8OxoN3wUgog1tJ4Z}$O z##YP*Yw;_(VjQ{OA9WOq4M+qh_=#}{c>nTM@cyZgSL(Ry_`Afo0VnB4;L%Zx8o%_K zU+sit54RS~cdV|JjXx8%Kqx!q%myPe@6@0bsM@()49K1nAp!Vx&W2=z{bj53`ljVtTfEnB>{AVqK{|0#ft9&C==WgL+QR^i5 z)>qZBLH%a{*Nzx#JUn&P{H)2s#%M@%x1rdsHBP<@w!h!-uBAH?q0Rk}2 z;SApi6@k*RR8L%!`jGb6Kl~KgY|YhAfngncy_dloEIOyYM?3*eDD%B%c@UeKfVVkT zjE^ks>d0jivATfQ9n(V-WTdGOB6vkQ^=o_IU{P1)P^8j}QnR_Klybdh=xmO-#Q510 zwIR+cN!UH>^z~;=qu}0irEpi~dS}&K2RY#gXI9__<#NZ_<)6o1eo$+md{dBDnkjm{ zau6B0*!vac#y+C~Zgc$(xW$p*TSDx`#1wKu z*`S(Wt*5;009;d0QjkobfnuW5sar9r+f}Gcn7{*jBm<9FrZdyg+=!M_F<$~3DuNS< zG!n2O%HIY1tr!+ACd*RL$anD3kGK0-%>@M??x-Gd?v{Oah zSdXbb!hf-FV+y<1&%eX#EVVUaBb_hxj29=wRu$_$aIxKmNg*BtI6WtLL^}#dKF(H^ zlYCUm!~BY>a*~f~d63p9EA5cKP%V%0o2tsmPE^bNq?=djlYCUm<6PxgYJW&Rs^uY) zzd~o~5IOu4?2f#@yCH#{GM4l^Wkt?&?AHZ#2wJ4RY(VBPXNpEvum*}u5-n2tX)`IX zCccyOcQPOKdNF5W6|{a)by{aYTG5`xkI_1)9le=gD^iIRb_JkS(9q&lP@$QYd_BTo zgnM|g$3V+(T-Y9IHHzE z`46kg2}jg&znZgz_K{OA+V3v4pDfjtW*Z zul?|AytxezcUN^=(I89^{T8;y>vep=YZp9;AS<1ukW){lgv*5k7LlJN!b`hpKa_K9 z`bRDX>|jxHo|d*j5lc7oIgWz4Ld2ALELkHf=lB03FNCBUHea%3a_iLOnr-dp^$ZNe z14x+idV!lv2bo^73}SU6z} zxE${KiI$<2ow}aMcskcS(No;B?hPA(Bi8{(WXP#CteE$wplYcuP)XO1Rj2{3R|sEp zrSQA~)z1YCk+3P4{+Mu#0bu|&lFC8wHAxbR7(_L~4G3+FJ59Wt6h|{ZSn-wpuNuh@ zo$Yz#(jqAQAL#|OEs{KI*y@SpN$la7NO{r;GZcce`isu8f1^5YZV1rs1rAuDK#0i| zTRKj3CwvaS*wWFDDHR(!c6kiWn>P$hB>mQaVhZ|(Cp+8sHQV|WlR2uRP5fe>pw}I^ zv9!TsB3$0!F`Mu0b7Ff(i_)}nb5o`+ZMS-CPVcVmMvVvFTC>rrfm6Znw|?>tII>Ec zJ*snSz})89-T4~qu_@J8(Z5QCSjkqCff2_;VYfPYhnPr;x>R<66JR1m$SoEob{dg= z9;RP{>7o_z@^f&E!t4EyJg917dA7vvNGDUB06b}8=K%Z?=pp1((N*uhLqMi&@*vSf zZX9T5VxD#?fSxp1FW5f3*({}coEF+BF7FTfT2hl2xuu!2?4MZ%ud8YDBH82qb@7zl zE_R9Y2E9Da3EWJL$q<}EjqX!f2m&`mU~tJ05HT65<=l~` z_zW-U`RaNOZM@=fONI7f!}!hTUvu@g!D!;pmdS02fTdRJu1~d(wRCCMY}>Sa=-@JA zyhrv0ul@hndk?_4ifavc=5DXr-n1+2YPGxSUAtqkn$kmkq`(FLNg%*VyykYGxx5F3)l&H@BRND*>msR zxpSwSnK^Uj%qg|C7K1b7&gT!*`HD)GUbuY2+7+idY%Rt8Ybu%xOI>CNkVY4zVo6?Q zJZXMsPjYxcyvSS;h6|#6Ut58-!l!p>BZYZrk;KX}H4o8aY0*%l!2egKTju_c;&&QLbI)C~-_El?FSGsjBFfovT;7KB4THW-s&>n`@2KrCZAQ;ps8m&n4J*U-S zbwqvng&w2D?zfxl;XH%GYBu?u`CMzk2+&wv7N^}==nNIYd@Yh#9AQ|&&5?3clHUOn zKsd%y!<7H|PLs`M@&r{jgWclv8Eq`Xv1IiG06oySuWtC1pY{c%KtJdPC;)9S)SMjCcu+7F;xGmh;)KFJbRuV6? z<=gUn9u#7Yo3X#Y13oPa4;hq#Dge1I1Qk8C;_Z%;GtVosEj}Mf7t7aJ=VVvcg!n(%xp2k0 z)oV%%?72DS9FxO4fAQjuHe0+hGVT=+ausmBIszs760S9*l3^T`WvyG( z!Pc}^MP^NAAf`8{^_cbsfX<*DHpvjf4G@EvBOsQWu2()2ES4;!Kk&P?>4N5F0(Ns} zb4PnyV?%vibyX}n3*44t19zfcI18Zh?D6DFFmVO(1lUg|%L}yXN8PnYh4`9M)w!w0 zys*)%jz!hW5_+${M0KI+Gx3l=Vm&(8FM7r7FD(wm3_7cMsXj2|vTZf^{g>tj0zrr@ z6JA}&hcGrj1F6s?yEg3%f(cOW$6gan%(r7hNto4#*E^TZ}m% za=|HBD?e$p>oM8TFWP=MEmJ^rtEaB|JSlOi?sVz3t@;k*zk<*~!DB3!!JV2Z_& zN(rrrXv&+?MPajuvU|?e@aR9IUe4ZsC82r$xPG~y)M71v`X3tYyqz>4_d;N6*dq{PTn(sBd%FTJk<(w}suuHERBrl}S zUc|AY_vxp-D#MF%#DD(o=U=BMz>>2LzKH)`x&d6>h#0{hNOgk40wGV%7}yJVp_QN+ zLQ@ky6;!H(lb?KUa_3JfUy^Q^`hlO@r(XBdUJrTYu@_;_V-;j$>P229IQZfWXX8*( z2D4A%Ymp2`nmFD9j)k}omzF|mxqvi3DgUHcuz@9Mg`p!1TI+>4jIKbvc(JRhBvxS$ z)dYJxRb=pmKXi}Fv4 zk|QuG4m=)O;k>#;n>)vyv)*%tbh0PM9>#1QV7tkAM6!9ny@!nL zb%{%C$i-+m!y|pv<;*cV3@iM|qYC_~V(g0zELYUS2CYvmXdY3OIoVJ}bcruOgvi<( zSUnn$&UU%B`W7$tiMq5Sp9YbS(oev)VEtk)DE(w$`fNWr5@3DOxm)yd>w>_lot|O0 zU1F{+{zZ%YTZqy}y`VkIw`1K5uoh9TYWOvvUO`uxIa`QM>pgmxMWfj+bBL2tENh2% zS*O+3T#)0n2VIVQx6NvA4%@siHD$5qyQC6ZZmq#+cR97zV4VTl>`n*fbxQK^Rz?1# zM${^S@q>D;#SMxhARTe+%a;mSff)%A@_Z%w6NI1A9do2a7AxW(pH=RMOM3twby5}j z2|G3x7A3n;AZM!yR`3bRc)#674rr(xC%B|7uj*fw28mUVmu;|+kbsDRkI7HFXFpwavzPMp60n-?3$uU4J%jG0y z=-?p&>HKepiI`Izs`-1o3ukHuaB>yMIUNSQnoQG>JU-{A6-A~YCwGqy@@~^lXWD!= zmutKGksL>k$Nv$JbYZ|@^Vm0dCTvcdH)p_4ZRHpnc6L2zX%mZ!mO{IQ1_r$w1ig$= zfk%=TQXOG53@9QXGm;(jUFHGEzkC!mhwW66rv*$)*iQwC=2WwV^7#m`BqDcU?I84p z39^w6$E9WD4M=X_Aum8UB6FBdjR{dd>R_!rFlU`c&;(V?JvrwXZK??b$dc2;hZtsR z*VKrE2IP!-{{;0ujfF`{OVqp6i7L9NT78GqX;4&_Km-33314=^QH8L8r&6^b;%2eF z9r1E?p{Y1l86eK3EIs)!X>_QI?Cyjt=|U1QkZJiiaAPHKql#NHgv-FY%Up%Hb`fXI zY*XSICB|xiw^TeQV^HFBQXF&ULd4mpo>1cEOI7e_jQPi%8K)ArRx%;KPa@8h2`F)o zVH`AK97x=I1(jJNdGPyeq;X6gP||!H^P&;+LdD$~YCEMXk9~{Fs zs!!?Po0PxFypy?vPh=iMIkV@hj8DaU?MD+J-~{W=GWOQNQV&$3CJZ~0Pz6Dsz00<; z?U>SjnrZSZ=P;m;=j=3}t%Bq8MRJ`8I-fJIlmhzlrOfHQw)`6~r>f$;vStb< zsEnF~7YmF^1yQZNI}jBPNT=5kIwKIA6;(XXADrLd4uY;SdMvrI{6O7F;gGY@>%t%c z-MG=I_!2tY!dRgCT!%CXylv!5FkxvkVYq|wZ^~(aPit`Y zW5FECf&N}BT|h@(GcHoa3L-&45^j@oZGo~lO=vJzVGI-aRNi#z6l^0ln!8eIYbw#(etw~pHONnx!h<)r9a@!HPrKoY=yrwaapU^gm#>;X#ToVfm|fE4)D8Px`Y)$D{cqb z;@}Kth(g#nin_+o*L3i_564URzn9t!LX_O~&9ROhJv1G9nYItJQ$b0!xVX5kxHeH% z7%w6vRal@?_C<6`J&ht{5>ZCA)V?@~ujwHcJqzyF>*|W~!&N#%5FVShc6KZcSHvnS zIQd66W_vA^Oa$ZeN<*=5uF+}Dal4vYlJ&)HWx>K!!lt(cJX2Fyz@SZ;7c%WUD*6#? zMcP2o8|zkjSE&T2kY54e%NSP@>d{MTaJ0c#r)QoTfwTcP+q1 zVg-^K%n}?Y*I6{wKOkQpMVD;LLQi(T{sup0eph_ ziNnq)FXwT*Q1NmJ0|)Otyq)cXwPYRlW`38cl4oV6NVgR%BdS|{zkL(MfZEpV}(GPWM&ix5^o}no%E#@EG84G&RSLUfsRji zNN>J(7R~E=q}7VFI#!S-9a;=$=%GM~TUw2tBp$b<>WAB68`1Xkxenn+9gQ6!1nJGG ztG_*UwOs#onKAwu=|N^j{mmNGpAfMFhvyK!_Ui6j^O;Wt#!lN1Jo)yqE4Y3rxOwWY zmtTK|SipJaWqezpd}|WlVjGr75eHJzCD!cT5NuoX`A-C#+;aIB&kEGuK6csEUp7ME zlbTnSndBqV1CS)CxCgvt!i-sjj%Q#S;4BQ=*$CY&sCKNJIu-WVciyiVJ^fVeebNIF z<3X*l0Bgh>$g@hx6KWjClBeB8i-ZoI6jOkZN#uCJ{ihnGzZ(nQ`#S2mA~Vi+N6xcgtXayTs>JWB2V5Y%dwNBvL}t zC6TDZVE~bDf;l6QKap}?-4D8pb8K)HVAsG`gv3)P)obP@3qyXWVwqxvnu`lt6P}u~ zXobxe(iS+YitUB9_3P>)n#~<8HLJbBmSAZtRFRvg=Y#|uXiKGNOV>U5TI^)9ZLwic z5r)Ug3@k4I@0O#mcneaQ5D8~M4u;YaSnsl_s3FOE!tOLjrduP+@(4QAEBo%l37dBFm!dDAyu*SLn$Dea1hFD$6@h@hGb?xLhC^~kdr)tFj%W|iA==YL z?Lj=soA;XN-#j~DO-;=2XxKSUdf zSt(H4y@4|UnO=$`{AFr-)CB7f*S&)+_XH`6^RJcy7x6gKRSJv6sko#84!tRqRV2SVGpU%8_dpaVWb@0)O!6LLhjx}$& z)C)>7pL~g0Ek-C+EzI?KRje2I6TYE< z$Fksb;$kONOkr`CR4&m!BEU=xnTkokRyEnHJ~_R|d3xn3j*+hF^jQdd9A{LX>Ky5= zN}tvJtID$~HD9jZTYs1NOZbn6ZY(!BsRm||_aT23Ho`Oz;gu3%?&!cJ zo>(klBM-ij$h4q@OfAdfNpK;xx(L5~YK!61*Q6^GRcVVq&mC&s*s$ot_AAcem5qCD zK094h-dN&ud2)jl3rhzUq|do(6Y{6}0QNsbeQH^Mx{quu_#qpUz}dKpxL7)DX&?{- zE;+1ZL(HAQVjYaw11JjdtjHNa1`(09tQM|+N}#$+BIyiRiOz2htL^L)USfL*5j>GN z#nppFv642mC)eq1J-MoTUDxUzt*5jv9*PG8E?ZSa%lw7CIzF*xSu9amlw)!De5GxX zo^;btUf$O9P-8<=w8$3r=0!JjCR^!zmGBGWPfEAKUwmgeP1dIYaM(2Ei$DPpYZ_38 z3nSW}hRMSrnWIJ*h?9Q!L_)NS!4RzjtbKC%kxK9yFR!ItN-a5$$LNoy;Cx_Sqi5=| z2g1Hmy)*7@&}-KP*97l{NR@Z$q#tsR^HrUJ$M3zD>Z8fL!ym$#c@bNbUJ!)ROFlGh zK?6ZG3cx4LY0wD_)zV<#$xh;Du#$O#s7k9+Abo6@rT>e$@GwqJoY8sLQtJ6EW^=&nUCH6odFP1#1 z@swB|udzzp+V&Iovc)}n2*smXZj704N;iV@7h!ejYN&K#w!l;wVdg9sQf5EWycM>* zBd)M)$GeocwxYV2HrO0Y*G!QDk_~u|tsD8~xMk;6kZYt?KKDO5#ET^#%kb;*3W!1;FDz|6!6JBMK}}9PC1WP7F~@e zaNJB2Y-00C4Uu@$rg;ljv^Q1O!!hRF<+0kHhUT*Nc(}62UDt>a-P^O|)O=S%ar23_ zJ@e{Lj0C#tJBO0|hbI@6_oQGIX6lxrp0aRRuEUUjVsllJ#zI}@ZT??q4~>QLbP1MO z74L)12-Zu=Ljd>?+9gQEfP|so!ayjGMTPPZ&eY`-_3RkXC0N#2hp6*SR~5`#uxL%& z_Kwx-w%>^5Iy2NBg#x^=tP1>|^0l-q>7Kk?gKqR$BoXW8k&N zGr#9IVBSyfwXnhCRs6=vsS_av=snnv(U6ZcZ_G|Z`>}j}L*>*-avIP#{h6PGuYM~_ zK=w73*?}G8I_N4Br{^qw#Z(J(5Em!21N0D?NSJC7gb}_Qz;{9AyLe`&_|7iAJA*$l z{oT&PzFUUxoZ`DPv3Hvg-{G)Pl=qeC?KOE0imga56=%Eq#Ry5`2x z0l1b9Dpy(y#Ikmh$!8>4iJyb>Y&QxM*cOe$k3VeCp;SL{j^8;6**AzGzna z8(BA73jal0Wf%YeMhHnm1i-?{zP4cw6ySzMWiV(4p(6;@8bG3_r>Q9mjh>}F1APmd zx|_Pvt+JZUVUTegCuG8N;FL9gM<^pay?}daKl{NjF1P(V+7<8K$?X& z=4+BI<4^LJv6llwmk#>faQ33cwutyuVC!uI1r-twhmaTtaLP-SimgT(rrLmHOf`Uw zDUq~x8>xXhYk~KOijAhCZC}Nn@(#ltxPFy4+<9mH9e1EVQ7)E`$9MzA^O1SfsJzH&=jZ78XPtI#&8-XS z4czL|*MDm6Pl9KNoyZ^O&tNQQ@4{rKy|8>xPbdGOJNH$ceS5*z&aFD{&I_)%tmHEM zzoO)dEAU-4>*p`IQ4jhZ{q6g2NJkuW&Aaq$!Q_!08RrW3m|9&A`UQ@4{VSp4#2$< z4e*pGTZ1vvq~}-LQUlr#6eCVD0Ma-#OwsFzzbUYD6fhufj;YtP~ zQ)%X0S1RT5A9oTSOjMb6&$by9?w%!D6Z1u3~ z#^g~!r;fz!y0u!_O% zDeWO7F5IiBm$(WGd_~T}0=EHscaMYiEP|kw+l1-h1%6M=v=A&s9mw7p89PWSA(2+4 z`wnJc+!8PGnyhx8!{l^E0^F#LxE)@z&TQ2rO5IkgJzz6ML~A!a6~}Q#*ICjp_BP zmIi&0uI7EP9RW|88lCzSBiEYX(NU|R#gqJttJpBM(V(;K5{L=CKCsxq;cUxJd!!v~ zY40=VLH;068a8F(e)2{2*h zqb%`a8j=YB(j^Ok-tTc(tm@LZ3`vW_30DFY_JAo-?6v@ecC!;P93U@n+r0*(Pv?tz zeP*l84>vIo&DUD{ELULL$_yF3vYXbynxGTSMNUOS0&4EKa{3FpY$p8w$cW zng*PambyS|wD?U*}v7@awZ5J0b^KG0hvXVheE2EL4hsXuf!tHY;<^V0l6xStHu zoQ`Flg}m5(&4HPk9iAhO6+y_NP7U$G(zx*Fke#C0tuQu3 zfWKh9304WA{~5Z5W&r}`GjZgyX;U&!n&Ndk`wH@0xsoRwDJg-fNkz-Hwq7NwAiuaI zKcdYsS_<>$ZEOFcqqL-~T(UWeiYqFm{B%XI+-dcPJbATM#Y-An)>O6)Cgn&^*c+;^ zE^I2*yRFXp&hqw6$#WV)mHqXp<#l>nPIaKTH`RPXy$5b`=v-J@ioJFhJ2`y<_yw9K z4w-Y%(a_UZOZx~_F_Up0=04M5{=-BxhlfUZZXp+gDq<%=`^acC50bm4h?!Rxc?E_n6rqI>xBlhOH=rGA%>yW`QtYXi4RlKx9t)u(X#xYX31Na?jEG}(Hv=`UcmUJZMl@>QA<`p$Hmb4@|Yiw+4 zX=rNtkIKbMs@vl|9(Q-Vy|!;rb$hJK=j)19E$r-A)Ysm*5R_VR=6n1piF_fqK$7^< zbSN(b-_XfCs>xu#R71B2!wbxLAh!@Q14eLlIKJ0Dc9=6oFIaY(akvCQoR zIG8w|rKW)eo|`T?T59bDhb9(`Y*eK_;qZA11-HwSkh#|AT``@!sS2oSC6jFuT%d|s z1GtD;qZ4-8b0m_G@pw}0KA@iS$dHVfF(EZglNc+6`&J*>&tZy9dX?Z=j?7;Y=j%^u&dBlkB{2HUh?;M?on%s4s~l5;52mo-meIoRTD9 zT?S~WmVdT2?#=OQfUpY+u^~AHNMxJN?X-saI?IxUIP-_?BC8dePheM9EJFnca`HYU z-B%fp6k%)P&2hFjlodI2Kr_{Qcdr~-SqiN(mtE~Jm~Ex)sRoG5z{RHar28;=GS7n! z`8(*4e#jc$nRfPr=~L^LOxmSfWh&xkRaU&e8TgkoBVY~|4WytYf;r^MO~fS^_UpjT z$5JW#nqN#PA&4+9N;|u&*8dmU|?YFz?v1y7B8CL?e-SNN)m87M6T-~ogSBY z@3n-(bWvL4FlR|zHyKYUlNW>u7Cr^+f*h<%L;t(!(o+-fNa}s_qJ{CsqF@3Fs4LJz3)HP6UFju7AxAFMdf<26 z9kk_0;L?XSpJ;2)7|l9uK_I%Qz-kQzwb&iy1xp+93N+zZeyE_@9EwGo^){oBb25d4SOhDvffu}l)~B+JBc z>?Sd8v0>dd<0}E(WLyuc3q~V4O7W>sNWU@SjSP&cBYYx~VcURj8(CvjWl6E!1>sS< z+bIspU^EZ=dIj*hS^__b_sed$qH$VXvtTCL5cEFMd&!rjbfMW_X#4mum-MF>U&VD- z2Sc64sprl4<(}Y80J8o{*GB;OZQbU=TobpN^GjTPH%8_erAuR6Z~YY<_8PV6fKGcC zoj1WD;9Yv)q_j$JG;5!*7*&Wfm@+Upp$Ai$vl9M!9#WOzJgYbz#|Q)$9~*tK17x(Y zD3Y(>m2(N(K!-}_hhl@!1I39KoqnXo@+#J7ds=-dQ+T}=~ZJuO-NaQ6w31)UJBQjCAd5>(m! z7)-jrj{@s;>1U6x9X7uPW*LLVq`eiJwE*`@jyET`#dMKY9Ud8>`m3P7fz?>L3MbcPu%H7QL@*)D zq9GW;%5`JGR`p@{(BZvim`kt;ifdsa6ie)C?I@)4Me~ zTe-`hqbg2Tr>sVgH^0*F^qj?OHB*o96>UxRCpB7a{B5&VuQjSXRv*0AIa(^$^fz1U zmJ}Ao2eGSx>^w~3Vr?N_eTczt8d@&S%>-7$nIJZ@WQ1TfF)7x=!I!3=82045Wfs$3 zZ&0L5lwl@&QIZwrM9nX5i1*gjr((Uep5j2%TM{U|i05mjp5-fAQmGBi^HRwT&9R#0 zl|9wVDi^FSAo#=1N~RWk#Q%mA<({-Xm}9{BDTg&s7Q@jg=~t03WgH2iR;9)u!qK&~ z7qV<*@fLj~@7@-pBl5`-=%EQ=0z@YSMrWsnPoT8P0p)X;@u(-pXX+4JWm(gnAOUK; zb+he3i!Kwyd9==$66X25y=3CEA>?aXWeeoynH(;~0(3CwHw6dkJ^q5#>IU9su);{0 zu<-d)wxsesO@Bu&KGdj*Ur~H0(e-^H3`U-ma&)9wo=Yb#Ta}%7?AYS zUd$T-4#-iAXGJQr)=x1=!MxZCjs@5kdM#YN;q&$IduqZ;1BbbK+7S~xo8$~;gffpK z2O2z7`e7fFe^uHDLe%fDdWgb7q|pKfX)~B`h+_3B0Ncdnkd1B=P~!>6bxixcg<0mg z>ljf9V$Dj(e!7Bp6>TXC!cCXj?c3@c#AI1x$?eumy{d&fTg!T#epNdhF-wa?4%$7MIRefQ#AFKi-n8iPuiS z=%?4|2d;`_-c_d{N7coaupxfBY$Guj6tE`FwdP80)TOyeS{i~@at>yT7WNX4_EwFz zitWT@OlyWd+&D9sR3-z)E_?y!m{h}dkjI4aG=ar7Sjx~3VxB}7gTI$Gz<^HAVNh(s z>I{n-7EAUS@}F|mmQ&8azWYARtkbLh$y{mCLVeY0m?EW0i{CI!y*3^1@9*x04UVDy z%^TLQT;9E;dvWi4KpQNYh}~P{ig==>#HwUsXRWZRFu3Fe3yW1$8=avpg+Fv6+KB^+ zb1h|QpwS5GBw7fsRyBf5VNkTBrEi+$6&2PxRhN}SV+9sbU^rz|g0 zTH*PaF;U3B9uE6U@}qUpnw|oOt)Mhv+cG?{=LO;V)FgYY?pYMiwN;i48Y)Zj!r+Cv zN`9c3dg0_Xb@c}ybhv!MaMWEGjLj>ph|GJ#6UB6fUd7GQ_gSCnWbB+QnJw(cuqej( z?LWrRINsfjcPEH<*37wwyjv~(m<_1b;k%qn;*fU{@MG4h+VRdl^$NR3&hs8Q&nD?5 zR;lvioin3l-<02dQ+~G_d9GLW;GHYez`iZN`?mb&Z@eSx-)P@_oG-6aor-s!jE6lazk5)A zw-t3cS+viVi67Fw!}OBCTL=riBtNK;1!=1rW7C7ZKD>29qDP)o5L{pwxj0-HBvCkF z=XthFGvw4{^*2bUB>W^9CzMgJcnBFESpo$+PolH#O9VUxw#xjuuFs)u1~2A?A%lO8 zb7z!`wV($+vU;TJaVD70;8Q$=11XKmB77i2+Js2ceMqMPlWZ89Zy2$rMQ>VD0cPc) zs=!!&9wd-F%#)#^a!&a8SWdx_#@`yS8ut%HYB4SATlp zNLk!O{6$?wu-&}0w`a6XrXPD|SBu)*58NK2eVGP06&8!BfBOiii$K*$BwPk8QWBX zBW%c_R6pwHW0Pqp^nfS;+3ChnKM|tD#G>5{NKiPTfsGjzWJzgj0}c!XC&XrwmJ*c) zyP1TXP)*yO_e%-U07s$2S>S*Jf>I|rkyi*IrwR;lL4ac0`Nrvxeu1X95%~PYUB5o) z3fathjjeEMiH@)2zKYA$yrp>Rjj89{wMCNjg?JqEQ#&1tg)X%h`o4;ys`a0DgITJfNf2KCT-&`3)Yi2yV-619n% z^3p&&5R2p^8(+LIW@uHHVrebfnJXBHJP|OFD$&$RL5+8nFytuU}9j!{#MPunWo>teyiepvr zvQR@wS+cgWxG`3hC?&UYz){a;mhY_d{MKN5bqx(_^}5aLHmz9Ju%uyeSBE;KPRVl2MLCdTrtbJhcss|}XO~c)*^hx8 z#P=RUN4|P&ETe-j9cu@R@xL77zh3llIVjYAwvL^e-Uh~iMqPwOHx$H5oC9v@%7tBx zDvc3GR#=PSAq2gu#Vpc-O`|u#`>Wmnp+9h4!CiytD|qhHkxvL>{HX()B?AN9-2>|e z)^_)IFDXeBI7!4AR;%C%iu!@@4okP%nna$GC%C|e$xnpU1cNYx~i%7Xb#PV`lVtvtKu7+Vm<|e`Y)fSSAmEU(itG^qN$edD53aLlh7v2f(o^c1R`o7ygcjr;{ob{8Zc~ z-j~77>8m&wBwsiuvHR2Z_U4j8u;JDLdoSTQKU7GM24HZ-%Z<@&G8xA~d015z*&hyg zmgEFLt2J-Tp<{P)bY|EHdv3yFVB?%m%|_F{WBqn*x=8VubCU1GmCO4U_H?z)Yl7Rf zLwx7hAiFeM!+N&w9O_5Nya-FKxYQh4dQ^%i&=A;$t!=`*%Ydmu2|+S0Ms#+q&#>14x` z-;8t826k`Snm`{_gG3~HfjW*%wc<#eL?>V^o!H2r8Szf&fRU^OjIF|l>qeZXYxA+X zOdq)`!WPHyNxE!WG-EooM$sNSyaX@+$UJQWOW~N*n-IDjV(Wxe9-Adu!U7NnH6x5i z5lNF}F9~@S7=J|X!DY`JBbX>Y;#E!$ax4~JMbSj3ttYdFwX_o@?*8izIDoUd z%L}xQv_9;y>QzM*Iva?UbYYPT6Whym_6paV3VYw?FrK&;M}0Mm6FVv&(L1@@Em_(J z;?bO)9o%AZTYWVJfDzepI0&7H4^yA*!sFCuYsSF75_R}z^w~Cjus$0rK{@ZDoP72n z>$CmnAJS(NzRXCM@c)QDTgiv5&lUl^NoVC>sLvLajzgcV9{Oy5|KHPRyW+#pXY0Ux z{o}t>pY7^@SfA~qI0q^Gm+G^9{h!ijD+BD_#C-do)@Qrq$og!JO~C~ z?lIb4a)QD2NwU2p-3jAML_fhl+Y8dKVb;j!G3(g1M*fvQm!V2`Y7_dY{a1GfXWr&${yO)Gw`DO1#y~ zpTVzcRT=60F>N8edN8WkX>h0nwl1i-TEUUZ@6?~Q=1lX>HL4x;^(xzY?<`(z5;_z( zNy`-Qi@@(P6TeH1mJ2=cIi{5WCB78G_{1y}8ZNp77}u((^QPfGFu7p9&G6|p7fR1= z($-7m$@k7s?Zg)`5OG&v-{WD>%$UXEU5n~TV6ebWp)=+#AQmsI8VPDs1vwvu(?q$C zt^LHxo#)i#^r+{pU%h#sZC=kj{?y*C)3=pP{d~QjkCrW6vvbSTnPdPF<P_ zN+I=VNAHIAy~zx>d1s|sR$sdEl(Y71J~xyD?OeTY>UGNr+ZJ{{c;vOtNDRgo8~b)0{42j=(QDEd4i44xcc#G1 zCU|rrkK2*2tOtUK^N|H~sW7;S!%l)$&;ud%Z>dEL!gUtBBY^gQxN&y*GZbvPT~KIu{Z8=1Ea zzB1YZh6G;-zrL8X$dy{BXt_k8P6jAxNCirh@IpwiYs$>aVw}Ix_#OT<-{#Wh=WAU9 zCu>g@@T!5-*n86FMSZ|EhwhZuN#=7^DP6QiuFED=>rXxW&7z_yYY)k6EI@r86!nRx zqcA>#p2KA!MEPWH@ho6P9kOzhE~bN^cg&mUeR?^6il)jr+)>>rvRd8GLd z&o)1TG5hz-g*+|3wG-Abn~Zw++);6Z5b2w%)RjlIo0~VOK`xwEp1Tyi+)@!3I9q$( zUkrblUB(i^&1%>=dQ5s!=(iSPy=Y~d(i>ojQYwg$@I@b{^g@hjZ1ZrgkI@buFvR-| z*ov#v#Mjm7H9FY5*29t2THN$nNG5~%yR^0zLLlne*4lZEsaQ>{x~xQ9sE)#zM6nsj zi1Wc`L)F@0jI?-ne+T5{$R*cX?HX<^CU@0 zB}rb&12HKt#HAF4)sHpLt6JY$7;%OyrW|XiW=To3+-LFEP5p-upAv#pagv`RIVv%~ zv2Hzyb<510X&c6jFi!=0LmpvakhHKAM5c?LqBR!zEjGJtVTsh^iX_Y?OTK>sD8>Ss z>QUJ2BIrT`Gj6vU;93E|6($S-*I^+5A+5ztq9qZs{*1xJ6^zhS#x&?y9M8}WSID#p zh7D{4rD%;H=7HcCz#fqUha?IU0sG~5>mz}xzS^QIvYz01Q{uf#GpddYuDC%se*OM%TGvX~iH!`wZ*?oY29pE(B5Gf(AmQ z8a;HqnBHK}uZA#)0rFD@y`jIpzPLCyx4xmiA(bRke3j+7MY)N%+vj#!w7I3=-(aay z(ZM8HEEh+WwN4=&F?$ecF-K({3oRvd+$TK}NVql>zujBVsNS#5g}eh~maclfBR{Er zCBLzBQB`we$)ajiRUp5rGLZj}1_E``X)3en;7yWds!2Y^G)Z}Qm2`l}vUNj! zQwkRzn(PecPj=qFG{+1o z&4+6K2X>8g3+4mzfx&f^Ob8VcAw(A7lC?sn1t#F!_K@2fFDR7SV)-^F4>^N9)nJZ+ z_I+O)eRvTghYf2E4mdiYU+EGfTfvp23fko>iVzu($iF@~mb- zRlf49VF6XM@~lPtO66I{_JSgm^VhQls@Iig1M5?#lxGv`QGZl;V0`S9p-q$F_Fa?P#>OYYMcXDP zcT6-@R&E`h+_r0D#ip_CmD_d=jZY3v4(}M-IzG5%c++5|@^!It@0}bR8QxS~QB_sl zykJE*+^ocx%P+EJ$-h<&jZX}ZjfO??BPSf52oHuQ$5FxUgX5=$$F`ul<6|Skql4l3 z!(*onZXLysa6H^Tx_NwPBD`X39Jk?$V@Y(RG=rm?56Oyv02u7vy}RPD#uG>?2FH&f z$%<`gNYB{lWO(`5mdV|N<3nLY08&Gv6GNNByGAz;jfW@E8OwY7!u>mjM&-nPa)Pq( z45X?n&}lQ@QZ|F94GxbCZX6j3??z7nUOnwg!-JDeVWnado5qKCOiolx43AWdjc={& z@98`G8xX!@;Ip3&G1*}l_}#`PSs2r@3QF!Zcv^v&A>2E$oQ)uC#;-yA9zgmrb_#xN z5?{2lUAS*U+&G)S`y!FkByyf$O<)ICBJW|5Vi(&81IC+>ayw$T;kzMxGl^U$kph~* zNI8yQTkvZW?v;m>U3|#zUh(aSNL`Jbt3=(K*#gu(jDO9Cq%Q}eqtfIr4sF-$cdJCr zCXn+O8%0htxzGLT{c{`^@F18?igpnMx1;=Vb}Hh=&`ue;1W&5dDBgwHd{OFYc;1Q_ z`AwL`@unTWH{5=KX6$V~R$CJPvj<`d4ubJ)a#V z6{1ZaPA)W_J5+M5x-OSG28EA^&4GnQjK z_2G9vT1aWbY$oSE z#~xvivcI#(xF6avxjYDKHhC~e73LB42VTIVYzr@BkMkJ&IY9Xf9_I;mDKBDQXSYKg zw1k)PGWI+>02{{@ypmV3SJ^K?W&8@j{xz@RwY&~0aY>%y4ZM*z!Rlr+Y{s+|IL2Ko@URm-@}sENBCZTHs8n3;pg&?^7BASU%)@cKh8hFKgn$DPIedTV0KvY z3BlKmn%xMB@&@)5_CxkDNP@M2v+)urIx}{{X?8N`Mu@&Za`;|YBK-#Y5qp4rhkciQ z8Fo=W4yw|LlN&d7LtaQV`mr0z1%Kn){6h9u_(XmiUMcjD<+ZZc*=I4aKFz+w-oVLP zgnf>E9`eRFv2)oE*aiGk>_0)Pp2uzh^XM{u5xxZ{RoboA}NA7XBrEEB`Y85B?SY zRsJ=88~-}Lo!`Ol(lKT9j>ZsuhO<}93MJuNY}o7 zaMSqMsIGl%>)7bfsmAv4;nA&wn|4j&RkygCIyViEZ`!qe%gE3kW9R0v$-zw^Y$ml` zn?S3L;x;}uIH~Ox*=f5)q_KNCnXX$Ypj$40%CGM3=rqlr$yL;;x~9fBfBG}^f{laY zrUf%;w7qhDdzI{ZELU_`$!=Kb@L{>bPnk(6 za;vFLDP5d0pSpE?XlQf<fOy5-3B~?QJ`k355V{`gv zOs;`xY}P0km$Mk3l|_9@J0SyZVy15DNg4%{BAIrVocpdhx$jciw@c~$U847Q$-Tc@ z&T#ikhKAkKy}w)T{nKY&YEPft`;FQ&#OKB{rlD*Q@3FDjXb`XHNr0`ks>UFSoE1~2 zyh$o|8Njtw4a)C!<=&y(JLP+IjeLihFTypoHFEl@_AdHeQ&U~9?%p*%CSF!`G%9!b zqpFTtk*F#q7hcs-uO1m5AJpy`ni!fKv`$yQV$+*df}VZ!=GB z!x)vHCrn$0Ps=`=CxDell^0U)LIIxYx_W8&6wS!k=++4ZK0UfKhQ`M#CbwvjC*3A@ z(=WZq7Z18EW4p%19eLCHiQzr;USv-Xq6kVbIxI3U4DH!8GPr&7#u2%M+HUz?U2hQ^ z*ojSJ<3o6B*|}?M68PsdZ10CQn}&Ao8a{1sWN38Lka2i)atJ*%vT<_yadL;5Vv%&y zB$xpr%jz02qM#F=G>(mf9f6f+aKwTvN5-~->o77pHYpO;R5$7d$H&KZ@7iG$cfs2* zh{w%iyGP}xk)bV<^3%q#$!+q}t{t04&B~{nM>kFk$pu%{Yqt-N(%MENvAS9wY}M6C z&9<>!a-yo5PBA2VlAW~%F>NabM<#9S*Y6k_A0FFGD<2xa*))QQvVCxnew!x6vse|u zHR9lfkxnE7EyCDW5hPF;8c;$|1O?>*#U9Wc95hERt|3qs9JECQ*C_tc*K;2`23`6$|i>kvqI&UVs)73iL?;d(o05)NM5!?->QI+KIudl}bPKqo@I1#~fK zc_%=Pf0Tb3S5P5{e-QjR710Vv=|c_mps=7!jPRy%|2yvBa^rhyixE`$RZQyY>0809 zBZHHppqSK%C(a=D1K>g#8SiXg5e8)?$5C$b6GZTs_SB*AQ5F~13UN&m&xf^&Ymc~+ zy%)-h$~*jy{e+00eOIGYm*6;33y6>V8m@Y#=2d=c__oA@qK>@iNAkV+X~t{c6VDdx zOp@4bEX1y26?PCCL7$%nYVHO=Q_WX`_rC@*2x~KM@%5P>p+;0Tbn;QT)tNUC_Xogm zedd+v>r#|UF@PFh3j5-4`IdQ;FU!1&^lyt_FXGpW`1J>vh#JWJ9l!p_muL3#6`8;A zRk*K49Lj|F-BOfHX;V!e_lzaRHO32!cN_m;vYA>wb&lCy#c#yUG`fXddDe_haFEio^-t6c*`j{ZE%`f=4^5vqr==BeWpQLr5cZ zAbcM0Bz9lsIehmD=KXz{m#|AIz%Haf#5{=mYnhkX>j-aTUPc*@9cS)b)a&?aWJ0?S zpxp=1<^yQ+0kru5+I#?QK7ckKK${Ps%?FV8OK9_ZXo;15G4o4y9m4epHzC}Da2LYe z2=^e|huQG}TKW{8e}nK_gx@2CP#R!{;6|8_uoGbdVG>~%!XAV(5I(4c2Qm+_UuB*; ztc2H4>gxz^WS&Efzt77vSAK}4-j{g*?fNB3e&z#8eh7X2K<0JS;Q{ok4t;7ua3Z)7 z<|FJxm_V3B*oCkM;S7W?qGi`1T#s-Q!Yv4QA>55{4+7QUhv1$32;qUu{n(QxP@l(8 zpN9bJhXCt`0PBY^mYxA5o&hADK`ky3kbp)9cr+#iH^O{`od^>MlL)&I_8^>r0E?dR zo|eK>1McW|K+y-Q-0zasIV(VjBE%3X5w1b_BEoeD*CX77a0|j$5N=1f3*l~rdl2qJ z_%DQ~5PpO3TZCs2eviQM_LW1*KC~wH0n<@U4xlCnP?H0w$pO^l04qnRM7RdwiwM^t zT#s-Q!Yv42LAV{^E`+-g?m_r3gr^XGgYa8~XApjm@IO<+ezajf+OQvO*pD{sNB#Gs z{`*n?{iy$b)PFzfzaRDAkNWRN{r98(`%(Y>sQ-Sz>(6Mzezajf+OQvO*pD{sM;rEo zk8va@eOM4Ozwc_Y$*bhiuE)Dp9nV^Oe0l#X*5l)= z!M{@l#+MS#WQ;hKG5#;nTOS^6c|26!|DQ_z zzeC6VyC#Rz_Wy>-;V?P&{m#E4<-dNv^zRameOLLxd&{{y&42wbs?mP2=gnT{mia%k z2mSxTKKkEn_`ku=_H*;1po$5Nks&;f#l6{)D(cW@BdG zsm3!fIPA^4csr-Q;HUkQf1*BrK5{024>+M&C}!)Gc?xYK=)D5%1M!Ea|3W*TqaPTB zANZH~8#JLFg@^YWGhd<8CzOMK*}Hi9eC8bd+mC-w;osMkw-|GB=Kp-V53OY87byQ# ziltJsZ*gWvF$WNShwyyn1=2e_{BLF);CvT45i@@{?_rrI5&n*uGCdBabJ1s0qEzMF zZC0vmJ#9q;Sc0@sK;aI0bmO}J*324iMVfy5p^ZuP5k>M zm8HD>efA08d>?`E!Q)E4znsqFRebY0jUhk-xQ70Kub;?#Q#@UpxfZhvvokwe@b)$I z^&zty>xWOIr;{@Ahi{(A{87eC**N9R9IPY9(KU+!#Piqj8zUUERrFEj{>)e8l<1*v zQ%mXnbnjk^Q|WtY6ah;!R_4E_~$dKcJAVB~)^zr()+CY{~#<&f204k?SnuX0@W z9c+Go@L#sBgm*qTbvDHZuOXDLoa)2M8Dr!C@Y8|Jn^cSAALYQJ?|e`jWz3Lm<%fC4 zy8WK0_bVS%huNhby_EvK`*D4nR?QFYkJ-73dWiKA_*B7xhrXZt`}p&p`@Q^&C`RDP zcaR&^neZZccst%d%mp#Fa%Mghzlj$5J;hONj}Ez+uV$WPs)gvKnK=20)@QN$zed01 zwOXcZ4o!LF-yz5n-PfqijtN`#`c>LhGXI zTGvk?K?T1nSX+teyAhb+8Qi~y{gpz|;N69XeLo!mdAHruKMr}wd^$6FNYwlP6lRc* zl4Q<1)be*?Hv#%i{E2lj^A|yx5Ow%4T5&6I%=9cfVy<$Y+Kp@(F z1m#@Cu7g&~S0Twt^f%8u4{HiM^J8(nOUsxZWVHkBaLV;(DvNzD-xA zhvfmDd0Jc_64$SZD_KA1naAnMY~uNc;`*w%eokC}Mpx)tp+2y^ax<=<6VLxC#TYl; zWV#=t#SDl9VV3D}gkK{-+EWKPO&z4!bgv=2g#h_XY{y{t0>O;nU@H9vxQmC3ryj7? z=OGj#lps_gBoSH=IuI5hEJ0YId;`fZ{W|ut?k(gh{$TG&zB?F0?hHBNPoIYz<@hS( zOEF1tZ9%T$4>>D;$RB@@n_MC88|aGPBl<1kzDGYU?hZXnW+MI){dxLN;(oRMGF)%a z-%7D~x=p#?t=z8`vG?o0r~G~xX&%%+p?^~Ot?an<&x>>~>y;glo<@ZD(+ipaHXkJV zX>b{=xCRVi@sb(h%Du)=Mo*CRY%sLqK44e~*uG_0rMy2;xo^X}jR=znqgnVJ>m4}? zcpoi2^4w=QL)1gLqnG9W_@KM!FQlDw&-UMkbw~c$ad5P|7#~O{?s{dM{G)f|bF}f5 z9cM?oXUE;4_v|=4-h0w;0riVMdsn_Ud`cb{`s}zq^qw8(N4tyhk9_Ccv-9D@x+DMW zygJ%FJKsL&F6JZES82Dn>y`QWVcjtgJ|pL!z3auiN4f8N&*Fs->yGlW_~mGKfp?Hj z+znUA_~)O!BR>UyW$%c2k03IQ$FlhB(7V8INO#107Vmvnca(Fj(ob3ZdFVZhPmgvN zcozB0!M9nw`_JBy?}f%G>=@-&mo%)-EvCrr+6L{*X@*^JqNQGpfyGMYs8i4*ST~V(GDD1@${`Eo)hA^ zS)`X+AyFypa`Eja#Pu2R_H*J{&~(s5p&Z6VJnj8Sqlu`<|EI2ViIJ*^!tkx?zWtb{ z+h*Pj$UqM>4ATS3Q$c)>m>5hDP%%M;fZ$_L0&0vP3pHq>#+YEDhJ~n6V~iRX8quK9 zm?*{j zt`Y{=Wa8UjV~lSkAJD>K`adiAlC+TLgkPwkp4s(+h&SBCH&`An2p5G*!llTlfk?2D z-z+7+zA%XV9te+tP_78G9`1{dGHYC&?W>Q2|F5f4xu**rii}E;aj7HYD*hFuo&Q_d zPnja)x*ZwU;N*GS^OBFO>|q^M7g^qk-r< zWSt{*qN6zZYqv8?Nsb=I=ZS=M>WdH@WH7a8G8 zyaOnJcyWmQ`^@bU{_s zQt~U<7~Q-fI01JjLr7)-xk_9s@u5V1f@7NJ6zAbkXWnZz4SF`s_AD9nENS;F>F}KD z^qlJOoa*!}nFTiJ%4BM|5#1ZtO|qWK)>Fk&)0>mmkV_-7_MPM%q;t@3QRJgD zmS)a!%vz4A{bf6H{FZ)?lCt*Tc_jJ+8kHX}Sf)kGw9PWjTbd9EC3vc;8caTQx!*%nu6vA8+5xM8qyu1)&E#aWLPUk^G?FX%WoVBi*&1FORBzJ1y;j~{ zt6;CCwRtf#aiiZEq*eA65fr9-&(XyVzPy(MeFoV80Eq6u%2;V9KA_L#ZSb2ShZ4NWXLrFDSUMXCnNMCZ zebe-y;2M$-O+PXHV$-H=+mmlhzcc-53!oawucp77{yBcj){V&t(Etntv~B#3P2-_@ z$Bxanhi=nprnT+2-?c3ao6a*`wDYd*JHs;5i%eJCsX8l7SDUVbZ|_k5$X1y=jVn|AzL z;|jB}dSeeu2F(CqDXnBrsgp-n!D#M2T8vPY@Ti)TzXiImXUA15Z_E{`L5tb9qWw@4suqQ~tXSO95XLS?KE8@N~13c@dsz z$%9qS^>yMTyvgUiCMOk_JnPa1Z}1deKKVNIVQ|f27)r|jM7;`+ghw05Qq4qIpirVX3TNoyRig#E4%lq QM~~IgPe1MJpJqz(7a@sjzyJUM literal 0 HcmV?d00001 diff --git a/crates/canvas/assets/fonts/Caladea-OFL.txt b/crates/canvas/assets/fonts/Caladea-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..b9d685b2fb49216f452fc0cf9728ce6673d910cb --- /dev/null +++ b/crates/canvas/assets/fonts/Caladea-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Caladea Project Authors (https://github.com/huertatipografica/Caladea) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/canvas/assets/fonts/Caladea-Regular.ttf b/crates/canvas/assets/fonts/Caladea-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a490e8f0ee259187614fb36ff3e6fae98dc5b355 GIT binary patch literal 81600 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 Format { /// Seconds since 1980, as note tags date themselves. fn time32() -> Option { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let now = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .ok()? .as_secs(); u32::try_from(now.checked_sub(315_532_800)?).ok() diff --git a/crates/canvas/src/editor/recording.rs b/crates/canvas/src/editor/recording.rs index 4149634ba7bdc414d6d70af2184e857b9ca2559d..1c967e00d9df445a25a158e053aa38b520192bab 100644 --- a/crates/canvas/src/editor/recording.rs +++ b/crates/canvas/src/editor/recording.rs @@ -5,7 +5,7 @@ use super::*; use onestore::page::{Attachment, MediaIndex}; -use std::time::Instant; +use web_time::Instant; /// A recording in progress. pub(super) struct Live { diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 9142cd115e8945e2db9350aea161d90f7909b76f..7eb353153b15af151f8e4d1e11657995d6c5e813 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -16,10 +16,11 @@ use onestore::page::Page; use picture::Picture; use std::{ fmt, - sync::{Arc, Mutex, OnceLock, mpsc}, + sync::{Arc, Mutex}, task::Waker, - time::{Duration, Instant}, + time::Duration, }; +use web_time::Instant; /// Retained drawing data in the source page's coordinate system. pub struct PageScene { @@ -40,11 +41,13 @@ type Slot = Arc>>; /// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot /// returned has been dropped by then; the result lands in the slot and wakes `waker`. +#[cfg(not(target_arch = "wasm32"))] fn queue( start: Instant, waker: &Waker, work: impl FnOnce() -> T + Send + 'static, ) -> Slot { + use std::sync::{OnceLock, mpsc}; static WORKER: OnceLock>> = OnceLock::new(); let slot = Slot::default(); let requester = Arc::downgrade(&slot); @@ -69,6 +72,17 @@ fn queue( slot } +/// `queue` without threads, as in the browser: `work` runs now. +#[cfg(target_arch = "wasm32")] +fn queue( + _: Instant, + waker: &Waker, + work: impl FnOnce() -> T + Send + 'static, +) -> Slot { + waker.wake_by_ref(); + Arc::new(Mutex::new(Some(work()))) +} + /// Device pixels per point a raster is made at for `scale`: the next half power of two, /// so zooming makes rasters again in steps. fn density(scale: f32) -> f32 { @@ -793,6 +807,7 @@ impl PageScene { /// Where the page read without an editor lies, in page coordinates: the rectangle all /// its content covers, and the top and bottom of each line of text and each picture, which /// a sheet of paper does not cut through where it can help it. + #[cfg(feature = "pdf")] pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> { let mut bounds = [ f32::INFINITY, diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index adb2ffe47df38eb4303d2c9a70969c370ef6195b..2b8b3e316088041c309bc8d52913437f04f7d4ba 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -6,10 +6,8 @@ use crate::gpu::Paper; use draw::RasterImage; use resvg::{tiny_skia, usvg}; use sha2::{Digest, Sha256}; -use std::{ - task::Waker, - time::{Duration, Instant}, -}; +use std::{task::Waker, time::Duration}; +use web_time::Instant; /// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb` /// colours over a view box the size in pixels of the picture OneNote stores, that picture's diff --git a/crates/canvas/src/gpu/page/picture.rs b/crates/canvas/src/gpu/page/picture.rs index 8d79bece61f304c13de7274b539e2eec15ee891b..98607ac27c73e6802930668f7c97f8af6b4f7391 100644 --- a/crates/canvas/src/gpu/page/picture.rs +++ b/crates/canvas/src/gpu/page/picture.rs @@ -4,12 +4,8 @@ use super::{SETTLE, Slot, density, queue}; use draw::RasterImage; use onestore::ExGuid; -use std::{ - collections::BTreeMap, - sync::Arc, - task::Waker, - time::{Duration, Instant}, -}; +use std::{collections::BTreeMap, sync::Arc, task::Waker, time::Duration}; +use web_time::Instant; /// Decoded bytes a scene's pictures keep at once. Pictures in view shrink to fit it, and /// the rest of the renderer's frame budget stays for template art and the interface. diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 028a72c20b0015d5fd2d612f4e2da612fd1133a4..21d76cc70adc1f2440695a09bc6406dea58da358 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -26,10 +26,8 @@ use draw::{ Primitive, Stroke, edit::{self, Clicks, Command, Key, Modifiers, Movement, NamedKey, SelectionUnit}, }; -use std::{ - error::Error, - time::{Duration, Instant}, -}; +use std::{error::Error, time::Duration}; +use web_time::Instant; const HANDLE_HEIGHT: f32 = 6.75; /// Accessible names of the page date's fields. diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index b2d39360abf5066e85d97447ebbe1df64a927420..c8112f6d5c3ff2f8d4a1535320f063bd04e99e2a 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -96,7 +96,10 @@ impl fmt::Display for LayoutError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnsupportedSubstituteFont => { - write!(f, "Choose an Arimo, Carlito, Tinos or Cousine font file.") + write!( + f, + "Choose an Arimo, Carlito, Caladea, Tinos or Cousine font file." + ) } _ => write!(f, "Text layout failed: {self:?}"), } @@ -110,7 +113,9 @@ pub(crate) const DEFAULT_FONT: &str = "Arial"; pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0; /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font -/// Licence files beside them, by the family each stands in for. +/// Licence files beside them, by the family each stands in for. The browser fetches them +/// beside the module instead (`register_substitute`), keeping them out of it. +#[cfg(not(target_arch = "wasm32"))] const BUNDLED: [(&str, &[&[u8]]); 4] = [ ( "Calibri", @@ -151,6 +156,7 @@ const BUNDLED: [(&str, &[&[u8]]); 4] = [ impl Default for TextEngine { /// An engine that lays out each bundled family in its substitute where it is missing. fn default() -> Self { + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut engine = Self { // Clones share loaded font files, so glyphs one lays out draw from the same cache. fonts: FontContext { @@ -161,6 +167,7 @@ impl Default for TextEngine { arial_substitutes: BTreeSet::new(), substitutes: BTreeMap::new(), }; + #[cfg(not(target_arch = "wasm32"))] for (family, faces) in BUNDLED { if engine.fonts.collection.family_id(family).is_none() { for face in faces { @@ -187,8 +194,9 @@ impl Clone for TextEngine { } impl TextEngine { - /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as - /// Courier New before creating layouts, in place of the bundled substitute. + /// Register Arimo as Arial, Carlito as Calibri, Caladea as Cambria, Tinos as Times New + /// Roman or Cousine as Courier New before creating layouts, in place of the bundled + /// substitute. pub fn register_substitute(&mut self, data: Blob) -> Result<&'static str, LayoutError> { self.register(data, false) } @@ -208,6 +216,7 @@ impl TextEngine { let (substitute, target) = match family.as_str() { "Arimo" => ("Arimo", "Arial"), "Carlito" => ("Carlito", "Calibri"), + "Caladea" => ("Caladea", "Cambria"), "Tinos" => ("Tinos", "Times New Roman"), "Cousine" => ("Cousine", "Courier New"), _ => return Err(LayoutError::UnsupportedSubstituteFont), diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index be32f891644047e9eb95e4b2a1d0fddeae43e4e2..3814408b20b83257f9ab06b9a828dba57d46d7aa 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -16,6 +16,8 @@ pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] [dependencies] parley.workspace = true +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" base64 = { version = "0.22.1", optional = true } bytemuck = { version = "1.25", features = ["derive"], optional = true } diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs index f01ccbd8356c5c38915f646dd5c134aa715af2ac..a85270749f17334f02052aa982c644bd724f87e8 100644 --- a/crates/draw/src/edit.rs +++ b/crates/draw/src/edit.rs @@ -5,10 +5,8 @@ use parley::{ Affinity, Brush, Layout, editing::{Cursor, Selection}, }; -use std::{ - collections::BTreeSet, - time::{Duration, Instant}, -}; +use std::{collections::BTreeSet, time::Duration}; +use web_time::Instant; #[derive(Clone, Debug, PartialEq)] pub enum Key { diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index 14589453f38a6197506a1921b47763638c8841cd..64d8d9a330acfabf4465ec6cb20fab2ae49dfec1 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -25,6 +25,17 @@ getrandom = "0.4.3" hmac = "0.13.0" # OneNote packages (.onepkg) are cabinet files. cab = "0.6" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" + +# The browser: its randomness, clock, timers and event loop, and SQLite's files kept with +# the notebooks' (`fs`). +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4.3", features = ["wasm_js"] } +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +rsqlite-vfs = "0.1.1" [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.31", default-features = false, features = ["fs"] } diff --git a/crates/notebook/src/background.rs b/crates/notebook/src/background.rs index e90889ca0f485562e1e5413d8d100c373e3db2ee..7f9909aaafd8b81d41022f04ecc6a38976d12fbd 100644 --- a/crates/notebook/src/background.rs +++ b/crates/notebook/src/background.rs @@ -2,6 +2,7 @@ //! keeps every section of an open notebook: queued edits publish and other clients' changes //! are noticed without the section being open. +use crate::fs; use crate::{ EditStatus, Error, Remote, Replica, Result, discover::{Entry, Listed}, @@ -17,9 +18,9 @@ use std::{ Arc, Mutex, Weak, atomic::{AtomicBool, Ordering}, }, - thread, - time::{Duration, Instant}, + time::Duration, }; +use web_time::Instant; /// How long a section that could not be reached waits to be tried again; OneNote 2010 /// retries a failing section about every 31 seconds. @@ -137,168 +138,163 @@ impl Background { }); let weak = Arc::downgrade(&shared); let owner = Arc::clone(&shared); - thread::Builder::new() - .name("onestore-background".into()) - .spawn(move || { - let signal = &owner.signal; - let mut bound: Option<(B, L)> = None; - let mut news = false; - while !signal.stopped.load(Ordering::Acquire) { - if signal.offline.load(Ordering::Acquire) - && !signal.requested.load(Ordering::Acquire) - { - // Working online again lists every folder again. - if let Ok(mut watched) = owner.watched.lock() { - watched.watching(false); - watched.lost = true; - } - bound = None; - let _ = receiver.recv(); - continue; - } - let now = Instant::now(); - let next = { - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - if std::mem::take(&mut watched.lost) { - bound = None; - watched.watching(false); - for watch in &mut watched.sections { - watch.due = now; - } - } - watched.next(now, bound.is_some()) - }; - let (path, replica, seen, current, image) = match next { - Next::Wait(wait) => { - signal.requested.store(false, Ordering::Release); - if std::mem::take(&mut news) { - notify(); - } - let _ = match wait { - Some(wait) => receiver.recv_timeout(wait).ok(), - None => receiver.recv().ok(), - }; - continue; - } - Next::Connect(connection) => { - let reports = Reports { - shared: weak.clone(), - connection: connection + 1, - }; - let connected = connect(reports); - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - // Whatever ended before now ended with the last connection. - watched.connection += 1; - watched.lost = false; - match connected { - Ok((mut files, reported)) => { - watched.watching(reported); - let folders = watched.rescanning(); - drop(watched); - let listings = list(&mut files.1, folders); - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - watched.listed(&listings, Some(copies), Instant::now()); - bound = Some(files); - } - Err(error) => { - let error = Error::RemoteIo(error); - let retry = now + RETRY; - for watch in &mut watched.sections { - news |= watch.fail(&error, None); - watch.due = watch.due.max(retry); - } - watched.relist = watched.relist.map(|due| due.max(retry)); - } - } - continue; - } - Next::List(folders) => { - let Some((_, list_folder)) = &mut bound else { - continue; - }; - let listings = list(list_folder, folders); - let Ok(mut watched) = owner.watched.lock() else { - return; - }; - watched.listed(&listings, None, Instant::now()); - continue; - } - Next::Check { - path, - replica, - seen, - current, - image, - } => (path, replica, seen, current, image), - }; - let Some((bind, _)) = &mut bound else { - continue; - }; - let (queued, outcome) = step( - &mut bind(&path), - replica.as_deref(), - seen.as_deref(), - current, - image, - copies, - ); - if outcome.as_ref().is_err_and(disconnected) { - bound = None; + crate::task::spawn("onestore-background", move || async move { + let signal = &owner.signal; + let mut bound: Option<(B, L)> = None; + let mut news = false; + while !signal.stopped.load(Ordering::Acquire) { + if signal.offline.load(Ordering::Acquire) + && !signal.requested.load(Ordering::Acquire) + { + // Working online again lists every folder again. + if let Ok(mut watched) = owner.watched.lock() { + watched.watching(false); + watched.lost = true; } + bound = None; + crate::task::wait(&receiver, None).await; + continue; + } + let now = Instant::now(); + let next = { let Ok(mut watched) = owner.watched.lock() else { return; }; - let interval = watched.interval(); - let Watched { - sections, changed, .. - } = &mut *watched; - let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else { + if std::mem::take(&mut watched.lost) { + bound = None; + watched.watching(false); + for watch in &mut watched.sections { + watch.due = now; + } + } + watched.next(now, bound.is_some()) + }; + let (path, replica, seen, current, image) = match next { + Next::Wait(wait) => { + signal.requested.store(false, Ordering::Release); + if std::mem::take(&mut news) { + notify(); + } + crate::task::wait(&receiver, wait).await; continue; - }; - let now = Instant::now(); - watch.current = false; - news |= match outcome { - Ok((stamp, moved)) => { - watch.stamp = Some(stamp); - watch.due = now + interval; - if moved { - changed.push(path); - } - let before = summary(&watch.status); - if let Some(queued) = queued { - watch.status = SyncStatus { - synced: Some(crate::now()), - error: None, - queued, + } + Next::Connect(connection) => { + let reports = Reports { + shared: weak.clone(), + connection: connection + 1, + }; + let connected = connect(reports); + let Ok(mut watched) = owner.watched.lock() else { + return; + }; + // Whatever ended before now ended with the last connection. + watched.connection += 1; + watched.lost = false; + match connected { + Ok((mut files, reported)) => { + watched.watching(reported); + let folders = watched.rescanning(); + drop(watched); + let listings = list(&mut files.1, folders); + let Ok(mut watched) = owner.watched.lock() else { + return; }; + watched.listed(&listings, Some(copies), Instant::now()); + bound = Some(files); + } + Err(error) => { + let error = Error::RemoteIo(error); + let retry = now + RETRY; + for watch in &mut watched.sections { + news |= watch.fail(&error, None); + watch.due = watch.due.max(retry); + } + watched.relist = watched.relist.map(|due| due.max(retry)); } - moved || before != summary(&watch.status) - } - Err(error) => { - watch.due = now + RETRY; - watch.fail(&error, queued) } - }; + continue; + } + Next::List(folders) => { + let Some((_, list_folder)) = &mut bound else { + continue; + }; + let listings = list(list_folder, folders); + let Ok(mut watched) = owner.watched.lock() else { + return; + }; + watched.listed(&listings, None, Instant::now()); + continue; + } + Next::Check { + path, + replica, + seen, + current, + image, + } => (path, replica, seen, current, image), + }; + let Some((bind, _)) = &mut bound else { + continue; + }; + let (queued, outcome) = step( + &mut bind(&path), + replica.as_deref(), + seen.as_deref(), + current, + image, + copies, + ); + if outcome.as_ref().is_err_and(disconnected) { + bound = None; } - if let Ok(mut watched) = owner.watched.lock() { - watched.watching(false); - if owner.discard.load(Ordering::Acquire) { - for replica in watched - .sections - .iter() - .filter_map(|watch| watch.replica.as_ref()) - { - discard(replica); + let Ok(mut watched) = owner.watched.lock() else { + return; + }; + let interval = watched.interval(); + let Watched { + sections, changed, .. + } = &mut *watched; + let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else { + continue; + }; + let now = Instant::now(); + watch.current = false; + news |= match outcome { + Ok((stamp, moved)) => { + watch.stamp = Some(stamp); + watch.due = now + interval; + if moved { + changed.push(path); } + let before = summary(&watch.status); + if let Some(queued) = queued { + watch.status = SyncStatus { + synced: Some(crate::now()), + error: None, + queued, + }; + } + moved || before != summary(&watch.status) + } + Err(error) => { + watch.due = now + RETRY; + watch.fail(&error, queued) + } + }; + } + if let Ok(mut watched) = owner.watched.lock() { + watched.watching(false); + if owner.discard.load(Ordering::Acquire) { + for replica in watched + .sections + .iter() + .filter_map(|watch| watch.replica.as_ref()) + { + discard(replica); } } - })?; + } + })?; Ok(Self(shared)) } @@ -668,7 +664,7 @@ impl Watched { && watch .replica .as_ref() - .is_some_and(|replica| !replica.exists()); + .is_some_and(|replica| fs::metadata(replica).is_err()); watch.due = if read { staggered += 1; now + STAGGER * (staggered - 1) @@ -750,7 +746,7 @@ fn discard(replica: &Path) { for suffix in ["-wal", "-shm", ""] { let mut file = replica.as_os_str().to_owned(); file.push(suffix); - let _ = std::fs::remove_file(file); + let _ = fs::remove_file(file); } } } @@ -833,14 +829,14 @@ fn step( let Some(replica) = replica else { return (Some(0), Ok((stamp, moved))); }; - if !replica.exists() { + if fs::metadata(replica).is_err() { if !copies { return (Some(0), Ok((stamp, moved))); } let copied = (|| { let image = remote.read().map_err(Error::RemoteIo)?; if let Some(folder) = replica.parent() { - std::fs::create_dir_all(folder)?; + fs::create_dir_all(folder)?; } Replica::seed(replica, &image, None)?; Ok(Stamp::of(&image)?) diff --git a/crates/notebook/src/bin/onestore-diagnostic.rs b/crates/notebook/src/bin/onestore-diagnostic.rs index f5bc0fa7ff1552d0572fb5b56ede701b2c139ffd..23bb70ca1889732c6fe646a18877f34396fb705b 100644 --- a/crates/notebook/src/bin/onestore-diagnostic.rs +++ b/crates/notebook/src/bin/onestore-diagnostic.rs @@ -1,3 +1,4 @@ +use notebook::fs; use onestore::{ Arena, ExGuid, Section, TextAttribute, document::{Format, Layout}, @@ -9,7 +10,7 @@ use onestore::{ use serde::Deserialize; use serde_json::{Value, json}; use std::{ - env, fs, + env, io::{self, Write}, }; @@ -96,7 +97,7 @@ fn run(args: &[std::ffi::OsString]) -> Result> return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into()); } if args[0] == "snapshot" { - let bytes = onestore::read_file(&args[1])?; + let bytes = fs::read_file(&args[1])?; let mut file = fs::OpenOptions::new() .write(true) .create_new(true) @@ -199,7 +200,7 @@ fn run(args: &[std::ffi::OsString]) -> Result> let Some(transaction) = transaction else { return Ok(json!({"ok": true, "state": "Unchanged"})); }; - Ok(match transaction.commit_file(&args[1]) { + Ok(match fs::commit_file(&transaction, &args[1]) { Ok(()) => json!({"ok": true, "state": "Committed"}), Err(error) => json!({"ok": false, "state": format!("{:?}", error.state), "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}), diff --git a/crates/notebook/src/discover/source.rs b/crates/notebook/src/discover/source.rs index 37c912c0c39807f8a6742e5734df84c93024749b..4d37789b1ce57aebac9f3ad2e03822db7ea61542 100644 --- a/crates/notebook/src/discover/source.rs +++ b/crates/notebook/src/discover/source.rs @@ -1,4 +1,5 @@ use super::{Entry, EntryKind, Listed, Source, component}; +use crate::fs; use std::{ collections::BTreeSet, io, @@ -14,7 +15,7 @@ fn evicted(name: &str) -> Option<&str> { /// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and /// later list a file iCloud Drive evicted (`SF_DATALESS`). -fn dataless(metadata: &std::fs::Metadata) -> bool { +fn dataless(metadata: &fs::Metadata) -> bool { const SF_DATALESS: u32 = 0x4000_0000; #[cfg(target_os = "macos")] let flags = std::os::macos::fs::MetadataExt::st_flags(metadata); @@ -41,8 +42,8 @@ pub struct Local { impl Local { pub fn open(root: impl AsRef) -> io::Result { - let root = root.as_ref().canonicalize()?; - if !root.is_dir() { + let root = fs::canonicalize(root)?; + if !fs::metadata(&root)?.is_dir() { return Err(io::ErrorKind::NotADirectory.into()); } Ok(Self { root }) @@ -52,7 +53,7 @@ impl Local { if !relative.is_empty() && !relative.split('/').all(component) { return Err(io::ErrorKind::InvalidInput.into()); } - let path = self.root.join(relative).canonicalize()?; + let path = fs::canonicalize(self.root.join(relative))?; if !path.starts_with(&self.root) { return Err(io::ErrorKind::PermissionDenied.into()); } @@ -63,7 +64,7 @@ impl Local { impl Source for Local { fn entries(&mut self, path: &str, limit: usize) -> io::Result> { let mut entries = Vec::new(); - for entry in std::fs::read_dir(self.path(path)?)? { + for entry in fs::read_dir(self.path(path)?)? { let entry = entry?; if entries.len() == limit { return Err(io::ErrorKind::FileTooLarge.into()); @@ -108,7 +109,7 @@ impl Source for Local { } fn read(&mut self, path: &str, limit: usize) -> io::Result> { - onestore::read_file_limited(self.path(path)?, limit) + fs::read_file_limited(self.path(path)?, limit) } } @@ -190,7 +191,7 @@ impl Source for Smb<'_> { /// Reads the file's stamp, then its replica's base if that has the same. fn copy(&mut self, path: &str, known: [u8; 16]) -> Option> { let replica = crate::session::replica_file(self.copies.as_ref()?, &known); - if !replica.exists() { + if fs::metadata(&replica).is_err() { return None; } let stamp = self.client.stamp(&self.path(path).ok()?).ok()?; diff --git a/crates/notebook/src/fs.rs b/crates/notebook/src/fs.rs new file mode 100644 index 0000000000000000000000000000000000000000..d991f487d3ae80fc3ef593929d5917868f83ccb8 --- /dev/null +++ b/crates/notebook/src/fs.rs @@ -0,0 +1,21 @@ +//! The file system notebooks and their replicas live in: std's natively. In the browser, +//! which gives a module none, it is `web`'s, which keeps SQLite's files beside the notebooks'. + +#[cfg(not(target_arch = "wasm32"))] +pub use onestore::{confirm_file, place_file, read_file, read_file_limited, supersede_file}; +#[cfg(not(target_arch = "wasm32"))] +pub use std::{fs::*, path::absolute, process::id as process_id}; + +/// `Transaction::commit_file` through this file system. +#[cfg(not(target_arch = "wasm32"))] +pub fn commit_file( + transaction: &onestore::Transaction, + path: impl AsRef, +) -> Result<(), onestore::CommitError> { + transaction.commit_file(path) +} + +#[cfg(target_arch = "wasm32")] +mod web; +#[cfg(target_arch = "wasm32")] +pub use web::*; diff --git a/crates/notebook/src/fs/web.rs b/crates/notebook/src/fs/web.rs new file mode 100644 index 0000000000000000000000000000000000000000..c130e7e830c1f01ef810ca2a2280e6cbc4e7876c --- /dev/null +++ b/crates/notebook/src/fs/web.rs @@ -0,0 +1,722 @@ +//! The browser's file system: std's calls over files held in memory, which the host loads +//! before anything opens (`restore`) and writes out as they change (`changes`). SQLite reaches +//! the same files through `sqlite`, its default VFS here, so a replica is a file like any other. +//! One thread: no file is ever locked. + +use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction}; +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + ffi::OsString, + io::{self, ErrorKind, Read, Seek, SeekFrom, Write}, + path::{Component, Path, PathBuf}, + rc::Rc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +mod sqlite; +pub(crate) use sqlite::install as install_sqlite; + +/// A file's bytes and when they last changed, in milliseconds since 1970. +struct Data { + bytes: Vec, + modified: f64, + /// Where the file is, which a rename moves; none once it is removed. + path: Option, +} + +type Shared = Rc>; + +enum Node { + Directory, + File(Shared), +} + +#[derive(Default)] +struct Files { + nodes: BTreeMap, + /// Paths written, made or removed since the host last asked. + changed: BTreeSet, +} + +thread_local! { + static FILES: RefCell = RefCell::new(Files { + nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]), + changed: BTreeSet::new(), + }); +} + +/// What a path holds, as the host keeps it. +pub enum Saved { + Directory, + /// The bytes, and when they last changed in milliseconds since 1970. + File(Vec, f64), +} + +/// Puts what the host kept at `path` back, as it was before the page last closed. +pub fn restore(path: impl AsRef, saved: Saved) { + let path = normal(path.as_ref()); + FILES.with_borrow_mut(|files| { + let node = match saved { + Saved::Directory => Node::Directory, + Saved::File(bytes, modified) => Node::File(Rc::new(RefCell::new(Data { + bytes, + modified, + path: Some(path.clone()), + }))), + }; + files.nodes.insert(path, node); + }); +} + +/// Whether any path changed since `changes` was last called. +pub fn changed() -> bool { + FILES.with_borrow(|files| !files.changed.is_empty()) +} + +/// The paths changed since the last call, each with what it holds now, if anything. +pub fn changes() -> Vec<(PathBuf, Option)> { + FILES.with_borrow_mut(|files| { + std::mem::take(&mut files.changed) + .into_iter() + .map(|path| { + let saved = files.nodes.get(&path).map(|node| match node { + Node::Directory => Saved::Directory, + Node::File(data) => { + let data = data.borrow(); + Saved::File(data.bytes.clone(), data.modified) + } + }); + (path, saved) + }) + .collect() + }) +} + +fn now() -> f64 { + js_sys::Date::now() +} + +/// `path` absolute, without `.` or `..`. +fn normal(path: &Path) -> PathBuf { + let mut normal = PathBuf::from("/"); + for component in path.components() { + match component { + Component::Normal(name) => normal.push(name), + Component::ParentDir => { + normal.pop(); + } + Component::RootDir | Component::CurDir | Component::Prefix(_) => {} + } + } + normal +} + +fn not_found(path: &Path) -> io::Error { + io::Error::new(ErrorKind::NotFound, format!("{} not found", path.display())) +} + +impl Files { + fn file(&self, path: &Path) -> io::Result { + match self.nodes.get(path) { + Some(Node::File(data)) => Ok(Rc::clone(data)), + Some(Node::Directory) => Err(ErrorKind::IsADirectory.into()), + None => Err(not_found(path)), + } + } + + fn parent_exists(&self, path: &Path) -> io::Result<()> { + match path.parent().map(|parent| self.nodes.get(parent)) { + None | Some(Some(Node::Directory)) => Ok(()), + Some(Some(Node::File(_))) => Err(ErrorKind::NotADirectory.into()), + Some(None) => Err(not_found(path.parent().unwrap_or(path))), + } + } + + /// A new empty file at `path`, whose folder exists. + fn create(&mut self, path: PathBuf) -> io::Result { + self.parent_exists(&path)?; + if self.nodes.contains_key(&path) { + return Err(ErrorKind::AlreadyExists.into()); + } + let data = Rc::new(RefCell::new(Data { + bytes: Vec::new(), + modified: now(), + path: Some(path.clone()), + })); + self.nodes + .insert(path.clone(), Node::File(Rc::clone(&data))); + self.changed.insert(path); + Ok(data) + } + + fn children(&self, folder: &Path) -> impl Iterator { + self.nodes + .range(folder.to_path_buf()..) + .skip(1) + .take_while(move |(path, _)| path.starts_with(folder)) + .filter(move |(path, _)| path.parent() == Some(folder)) + } +} + +fn with(act: impl FnOnce(&mut Files) -> T) -> T { + FILES.with_borrow_mut(act) +} + +/// Marks `data` changed now, where it still has a path. +fn touched(data: &mut Data) { + data.modified = now(); + if let Some(path) = data.path.clone() { + with(|files| files.changed.insert(path)); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileType(bool); + +impl FileType { + pub fn is_dir(&self) -> bool { + self.0 + } + + pub fn is_file(&self) -> bool { + !self.0 + } + + pub fn is_symlink(&self) -> bool { + false + } +} + +#[derive(Clone, Debug)] +pub struct Metadata { + directory: bool, + length: u64, + modified: f64, +} + +impl Metadata { + pub fn is_dir(&self) -> bool { + self.directory + } + + pub fn is_file(&self) -> bool { + !self.directory + } + + pub fn file_type(&self) -> FileType { + FileType(self.directory) + } + + #[allow(clippy::len_without_is_empty, reason = "std's Metadata has none")] + pub fn len(&self) -> u64 { + self.length + } + + pub fn modified(&self) -> io::Result { + Ok(UNIX_EPOCH + Duration::from_secs_f64(self.modified.max(0.0) / 1e3)) + } +} + +fn metadata_of(node: &Node) -> Metadata { + match node { + Node::Directory => Metadata { + directory: true, + length: 0, + modified: 0.0, + }, + Node::File(data) => { + let data = data.borrow(); + Metadata { + directory: false, + length: data.bytes.len() as u64, + modified: data.modified, + } + } + } +} + +pub fn metadata(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + with(|files| { + files + .nodes + .get(&path) + .map(metadata_of) + .ok_or_else(|| not_found(&path)) + }) +} + +pub fn symlink_metadata(path: impl AsRef) -> io::Result { + metadata(path) +} + +pub fn exists(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + Ok(with(|files| files.nodes.contains_key(&path))) +} + +/// This page's number among the processes that may share its files, as other tabs do. +pub fn process_id() -> u32 { + use std::sync::OnceLock; + static ID: OnceLock = OnceLock::new(); + *ID.get_or_init(|| { + let mut bytes = [0; 4]; + let _ = getrandom::fill(&mut bytes); + u32::from_ne_bytes(bytes) + }) +} + +/// `path` from the root, as every path here is. +pub fn absolute(path: impl AsRef) -> io::Result { + Ok(normal(path.as_ref())) +} + +pub fn canonicalize(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + match with(|files| files.nodes.contains_key(&path)) { + true => Ok(path), + false => Err(not_found(&path)), + } +} + +pub fn read(path: impl AsRef) -> io::Result> { + let path = normal(path.as_ref()); + let data = with(|files| files.file(&path))?; + Ok(data.borrow().bytes.clone()) +} + +pub fn read_to_string(path: impl AsRef) -> io::Result { + String::from_utf8(read(path)?).map_err(|error| io::Error::new(ErrorKind::InvalidData, error)) +} + +pub fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> io::Result<()> { + let path = normal(path.as_ref()); + let data = with(|files| files.file(&path).or_else(|_| files.create(path.clone())))?; + let mut data = data.borrow_mut(); + data.bytes = contents.as_ref().to_vec(); + touched(&mut data); + Ok(()) +} + +pub fn copy(from: impl AsRef, to: impl AsRef) -> io::Result { + let bytes = read(from)?; + write(to, &bytes)?; + Ok(bytes.len() as u64) +} + +/// A copy, as nothing here writes a file under two names at once: a link is made to put a +/// finished file in place, then the first name removed. +pub fn hard_link(original: impl AsRef, link: impl AsRef) -> io::Result<()> { + let bytes = read(original)?; + let link = normal(link.as_ref()); + let data = with(|files| files.create(link))?; + data.borrow_mut().bytes = bytes; + Ok(()) +} + +pub fn create_dir(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + files.parent_exists(&path)?; + if files.nodes.contains_key(&path) { + return Err(ErrorKind::AlreadyExists.into()); + } + files.nodes.insert(path.clone(), Node::Directory); + files.changed.insert(path); + Ok(()) + }) +} + +pub fn create_dir_all(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + for folder in path.ancestors().collect::>().into_iter().rev() { + match with(|files| { + files + .nodes + .get(folder) + .map(|node| matches!(node, Node::Directory)) + }) { + Some(true) => {} + Some(false) => return Err(ErrorKind::NotADirectory.into()), + None => create_dir(folder)?, + } + } + Ok(()) +} + +pub fn remove_file(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + let data = files.file(&path)?; + data.borrow_mut().path = None; + files.nodes.remove(&path); + files.changed.insert(path); + Ok(()) + }) +} + +pub fn remove_dir(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + match files.nodes.get(&path) { + Some(Node::Directory) => {} + Some(Node::File(_)) => return Err(ErrorKind::NotADirectory.into()), + None => return Err(not_found(&path)), + } + if files.children(&path).next().is_some() { + return Err(ErrorKind::DirectoryNotEmpty.into()); + } + files.nodes.remove(&path); + files.changed.insert(path); + Ok(()) + }) +} + +pub fn remove_dir_all(path: impl AsRef) -> io::Result<()> { + let path = normal(path.as_ref()); + with(|files| { + let gone: Vec = files + .nodes + .range(path.clone()..) + .take_while(|(each, _)| each.starts_with(&path)) + .map(|(each, _)| each.clone()) + .collect(); + if gone.is_empty() { + return Err(not_found(&path)); + } + for each in gone { + if let Some(Node::File(data)) = files.nodes.remove(&each) { + data.borrow_mut().path = None; + } + files.changed.insert(each); + } + Ok(()) + }) +} + +/// Moves a file or folder, replacing a file at `to`, as a POSIX rename does. +pub fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { + let (from, to) = (normal(from.as_ref()), normal(to.as_ref())); + with(|files| { + if !files.nodes.contains_key(&from) { + return Err(not_found(&from)); + } + files.parent_exists(&to)?; + if from == to { + return Ok(()); + } + match files.nodes.get(&to) { + Some(Node::Directory) if files.children(&to).next().is_some() => { + return Err(ErrorKind::DirectoryNotEmpty.into()); + } + Some(Node::File(data)) => data.borrow_mut().path = None, + _ => {} + } + let moved: Vec = files + .nodes + .range(from.clone()..) + .take_while(|(each, _)| each.starts_with(&from)) + .map(|(each, _)| each.clone()) + .collect(); + for each in moved { + let Some(node) = files.nodes.remove(&each) else { + continue; + }; + let target = to.join(each.strip_prefix(&from).unwrap_or(Path::new(""))); + if let Node::File(data) = &node { + data.borrow_mut().path = Some(target.clone()); + } + files.changed.insert(each); + files.changed.insert(target.clone()); + files.nodes.insert(target, node); + } + Ok(()) + }) +} + +pub struct DirEntry { + path: PathBuf, + metadata: Metadata, +} + +impl DirEntry { + pub fn path(&self) -> PathBuf { + self.path.clone() + } + + pub fn file_name(&self) -> OsString { + self.path.file_name().unwrap_or_default().to_owned() + } + + pub fn file_type(&self) -> io::Result { + Ok(self.metadata.file_type()) + } + + pub fn metadata(&self) -> io::Result { + Ok(self.metadata.clone()) + } +} + +pub struct ReadDir(std::vec::IntoIter); + +impl Iterator for ReadDir { + type Item = io::Result; + + fn next(&mut self) -> Option { + self.0.next().map(Ok) + } +} + +pub fn read_dir(path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + with(|files| { + match files.nodes.get(&path) { + Some(Node::Directory) => {} + Some(Node::File(_)) => return Err(ErrorKind::NotADirectory.into()), + None => return Err(not_found(&path)), + } + let entries: Vec = files + .children(&path) + .map(|(child, node)| DirEntry { + path: child.clone(), + metadata: metadata_of(node), + }) + .collect(); + Ok(ReadDir(entries.into_iter())) + }) +} + +#[derive(Clone, Debug, Default)] +pub struct OpenOptions { + read: bool, + write: bool, + append: bool, + truncate: bool, + create: bool, + create_new: bool, +} + +impl OpenOptions { + pub fn new() -> Self { + Self::default() + } + + pub fn read(&mut self, read: bool) -> &mut Self { + self.read = read; + self + } + + pub fn write(&mut self, write: bool) -> &mut Self { + self.write = write; + self + } + + pub fn append(&mut self, append: bool) -> &mut Self { + self.append = append; + self + } + + pub fn truncate(&mut self, truncate: bool) -> &mut Self { + self.truncate = truncate; + self + } + + pub fn create(&mut self, create: bool) -> &mut Self { + self.create = create; + self + } + + pub fn create_new(&mut self, create_new: bool) -> &mut Self { + self.create_new = create_new; + self + } + + pub fn open(&self, path: impl AsRef) -> io::Result { + let path = normal(path.as_ref()); + let data = with(|files| match files.file(&path) { + Ok(_) if self.create_new => Err(ErrorKind::AlreadyExists.into()), + Err(error) + if error.kind() == ErrorKind::NotFound && (self.create || self.create_new) => + { + files.create(path.clone()) + } + found => found, + })?; + if self.truncate && self.write { + let mut data = data.borrow_mut(); + data.bytes.clear(); + touched(&mut data); + } + Ok(File { + data, + position: 0, + append: self.append, + }) + } +} + +/// An open file, which reads and writes the file under whatever name it has since. +pub struct File { + data: Shared, + position: u64, + append: bool, +} + +impl File { + pub fn open(path: impl AsRef) -> io::Result { + OpenOptions::new().read(true).open(path) + } + + pub fn create(path: impl AsRef) -> io::Result { + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + } + + pub fn create_new(path: impl AsRef) -> io::Result { + OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(path) + } + + pub fn metadata(&self) -> io::Result { + Ok(metadata_of(&Node::File(Rc::clone(&self.data)))) + } + + pub fn sync_all(&self) -> io::Result<()> { + Ok(()) + } + + pub fn sync_data(&self) -> io::Result<()> { + Ok(()) + } + + pub fn set_len(&self, size: u64) -> io::Result<()> { + let mut data = self.data.borrow_mut(); + data.bytes.resize(size as usize, 0); + touched(&mut data); + Ok(()) + } + + fn read_from(&self, offset: u64, output: &mut [u8]) -> usize { + let data = self.data.borrow(); + let rest = data.bytes.get(offset as usize..).unwrap_or_default(); + let count = rest.len().min(output.len()); + output[..count].copy_from_slice(&rest[..count]); + count + } + + fn write_to(&self, offset: u64, input: &[u8]) { + let mut data = self.data.borrow_mut(); + let end = offset as usize + input.len(); + if data.bytes.len() < end { + data.bytes.resize(end, 0); + } + data.bytes[offset as usize..end].copy_from_slice(input); + touched(&mut data); + } +} + +impl Read for File { + fn read(&mut self, output: &mut [u8]) -> io::Result { + let count = self.read_from(self.position, output); + self.position += count as u64; + Ok(count) + } +} + +impl Write for File { + fn write(&mut self, input: &[u8]) -> io::Result { + if self.append { + self.position = self.data.borrow().bytes.len() as u64; + } + self.write_to(self.position, input); + self.position += input.len() as u64; + Ok(input.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Seek for File { + fn seek(&mut self, position: SeekFrom) -> io::Result { + let length = self.data.borrow().bytes.len() as i128; + let target = match position { + SeekFrom::Start(offset) => i128::from(offset), + SeekFrom::End(offset) => length + i128::from(offset), + SeekFrom::Current(offset) => i128::from(self.position) + i128::from(offset), + }; + self.position = u64::try_from(target).map_err(|_| ErrorKind::InvalidInput)?; + Ok(self.position) + } +} + +impl CommitIo for File { + fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result { + Ok(self.read_from(offset, bytes)) + } + + fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result { + self.write_to(offset, bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// `onestore::read_file`: a read here is whole, as nothing commits while it runs. +pub fn read_file(path: impl AsRef) -> io::Result> { + read(path) +} + +pub fn read_file_limited(path: impl AsRef, limit: usize) -> io::Result> { + let bytes = read(path)?; + if bytes.len() > limit { + return Err(ErrorKind::FileTooLarge.into()); + } + Ok(bytes) +} + +fn writable(path: impl AsRef) -> io::Result { + OpenOptions::new().read(true).write(true).open(path) +} + +fn not_committed(error: io::Error) -> CommitError { + CommitError { + state: CommitState::NotCommitted, + error, + } +} + +pub fn commit_file(transaction: &Transaction, path: impl AsRef) -> Result<(), CommitError> { + transaction.commit(&mut writable(path).map_err(not_committed)?) +} + +pub fn confirm_file(path: impl AsRef, base: &Stamp) -> Result<(), CommitError> { + onestore::confirm(&mut writable(path).map_err(not_committed)?, base) +} + +pub fn place_file(path: impl AsRef, ancestor: [u8; 16], name: &str) -> io::Result<()> { + onestore::place(&mut writable(path)?, ancestor, name) +} + +/// Puts the file at `with` in the place of the file at `path`, provided `path` still has +/// `base`'s stamp. +pub fn supersede_file( + path: impl AsRef, + base: &Stamp, + with: impl AsRef, +) -> Result<(), CommitError> { + base.check(&mut writable(&path).map_err(not_committed)?) + .map_err(not_committed)?; + rename(with, path).map_err(not_committed) +} diff --git a/crates/notebook/src/fs/web/sqlite.rs b/crates/notebook/src/fs/web/sqlite.rs new file mode 100644 index 0000000000000000000000000000000000000000..c8dae1c369229f2ccffc175e45d55f155fdee056 --- /dev/null +++ b/crates/notebook/src/fs/web/sqlite.rs @@ -0,0 +1,143 @@ +//! SQLite's default VFS in the browser: the files of `fs`, so that a replica, its write-ahead +//! log and the notebook it caches are kept and written out alike. +#![allow(unsafe_code)] + +use super::{FILES, Node, Shared, normal, touched, with}; +use rsqlite_vfs::{ + OsCallback, SQLiteIoMethods, SQLiteVfs, SQLiteVfsFile, VfsError, VfsFile, VfsResult, VfsStore, + ffi::{SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, sqlite3_vfs}, +}; +use std::{path::Path, rc::Rc, sync::Once, time::Duration}; + +/// Registers the VFS as SQLite's default, once. +pub(crate) fn install() { + static INSTALLED: Once = Once::new(); + INSTALLED.call_once(|| { + rsqlite_vfs::register_vfs::("snowbound", (), true) + .expect("SQLite takes the browser's VFS"); + }); +} + +struct File(Shared); + +impl VfsFile for File { + fn read(&self, output: &mut [u8], offset: usize) -> VfsResult { + let data = self.0.borrow(); + let rest = data.bytes.get(offset..).unwrap_or_default(); + let count = rest.len().min(output.len()); + output[..count].copy_from_slice(&rest[..count]); + output[count..].fill(0); + Ok(count == output.len()) + } + + fn write(&mut self, input: &[u8], offset: usize) -> VfsResult<()> { + let mut data = self.0.borrow_mut(); + let end = offset + input.len(); + if data.bytes.len() < end { + data.bytes.resize(end, 0); + } + data.bytes[offset..end].copy_from_slice(input); + touched(&mut data); + Ok(()) + } + + fn truncate(&mut self, size: usize) -> VfsResult<()> { + let mut data = self.0.borrow_mut(); + data.bytes.truncate(size); + touched(&mut data); + Ok(()) + } + + fn flush(&mut self) -> VfsResult<()> { + Ok(()) + } + + fn size(&self) -> VfsResult { + Ok(self.0.borrow().bytes.len()) + } +} + +fn error(code: i32, error: std::io::Error) -> VfsError { + VfsError::new(code, error.to_string()) +} + +struct Store; + +impl Store { + fn data(file: &SQLiteVfsFile) -> VfsResult { + // SAFETY: SQLite hands back the file `xOpen` named, whose name lives until `xClose`. + let name = unsafe { file.name() }; + with(|files| files.file(&normal(Path::new(name)))).map_err(|io| error(SQLITE_IOERR, io)) + } +} + +impl VfsStore for Store { + fn add_file(_: *mut sqlite3_vfs, name: &str, _: i32) -> VfsResult<()> { + // SQLite's own files beside a replica, such as its write-ahead log, need no folder made. + let path = normal(Path::new(name)); + with(|files| files.create(path)) + .map(drop) + .map_err(|io| error(SQLITE_CANTOPEN, io)) + } + + fn contains_file(_: *mut sqlite3_vfs, name: &str) -> VfsResult { + let path = normal(Path::new(name)); + Ok(FILES.with_borrow(|files| matches!(files.nodes.get(&path), Some(Node::File(_))))) + } + + fn delete_file(_: *mut sqlite3_vfs, name: &str) -> VfsResult<()> { + super::remove_file(name).map_err(|io| error(SQLITE_IOERR_DELETE, io)) + } + + fn with_file VfsResult>(file: &SQLiteVfsFile, act: F) -> VfsResult { + act(&File(Self::data(file)?)) + } + + fn with_file_mut VfsResult>( + file: &SQLiteVfsFile, + act: F, + ) -> VfsResult { + act(&mut File(Rc::clone(&Self::data(file)?))) + } +} + +struct Methods; + +impl SQLiteIoMethods for Methods { + type File = File; + type AppData = (); + type Store = Store; + + const VERSION: i32 = 1; +} + +struct Vfs; + +impl OsCallback for Vfs { + /// Nothing else holds a lock to wait for. + fn sleep(_: Duration) {} + + fn random(bytes: &mut [u8]) { + let _ = getrandom::fill(bytes); + } + + fn epoch_timestamp_in_ms() -> i64 { + js_sys::Date::now() as i64 + } +} + +impl SQLiteVfs for Vfs { + const VERSION: i32 = 1; + + fn sleep(duration: Duration) { + ::sleep(duration) + } + + fn random(bytes: &mut [u8]) { + ::random(bytes) + } + + fn epoch_timestamp_in_ms() -> i64 { + ::epoch_timestamp_in_ms() + } +} diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 5545185fe49a4c7a2b3f711e91e43ed80f6aa827..49ddb44369cede88a5564f65a1d04c1abfd27794 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -3,9 +3,11 @@ #![doc = include_str!("../README.md")] pub mod discover; +pub mod fs; #[cfg(feature = "smb")] pub mod smb; +use fs::OpenOptions; use onestore::{ ExGuid, op::{Edit, OpError}, @@ -14,7 +16,6 @@ use onestore::{ }; use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; use std::{ - fs::OpenOptions, io, path::{Path, PathBuf}, sync::{Arc, Mutex, MutexGuard, mpsc}, @@ -38,6 +39,7 @@ pub use recovery::{Recovery, RecoverySummary}; pub mod sidecar; mod sync; pub use sync::{EditStatus, Remote, Synced}; +mod task; mod worker; mod working; #[cfg(feature = "smb")] @@ -137,7 +139,7 @@ impl Replica { source: impl FnOnce() -> Result>, ) -> Result { let path = path.as_ref(); - if !path.exists() { + if fs::metadata(path).is_err() { match Self::seed(path, &source()?, key) { Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} seeded => seeded?, @@ -154,14 +156,14 @@ impl Replica { static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut building = path.as_os_str().to_owned(); - building.push(format!(".creating-{}-{build}", std::process::id())); + building.push(format!(".creating-{}-{build}", fs::process_id())); let building = PathBuf::from(building); let built = Self::build(&building, source); - let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?)); + let linked = built.and_then(|()| Ok(fs::hard_link(&building, path)?)); for suffix in ["", "-wal", "-shm"] { let mut file = building.as_os_str().to_owned(); file.push(suffix); - let _ = std::fs::remove_file(file); + let _ = fs::remove_file(file); } linked } @@ -463,6 +465,8 @@ fn signed(value: u64) -> Result { /// Opens a cache without writing to it: exclusive locking, a full sync of every commit /// (`F_FULLFSYNC` on macOS, the WAL's syncs included) and foreign keys, each queried back. fn cache_connection(path: &Path) -> Result { + #[cfg(target_arch = "wasm32")] + fs::install_sqlite(); let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; connection.busy_timeout(Duration::ZERO)?; connection.execute_batch( @@ -512,8 +516,8 @@ fn write_ahead(connection: &Connection) -> Result<()> { pub(crate) fn now() -> u64 { use std::sync::atomic::{AtomicU64, Ordering}; static LAST: AtomicU64 = AtomicU64::new(0); - let unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .unwrap_or_default(); let clock = (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100); diff --git a/crates/notebook/src/location.rs b/crates/notebook/src/location.rs index 7e3cc33e0113b0a7ba86f88f7ea1bdbf9731c788..b8d1278eaab9910d4b45d3e1441f5e143e1ff49d 100644 --- a/crates/notebook/src/location.rs +++ b/crates/notebook/src/location.rs @@ -6,6 +6,7 @@ //! A location is a string naming where a notebook or lone section is reached: the canonical //! path of a local folder or file (`local`), or `smb` for one on a share. +use crate::fs; use onestore::{Header, Stamp}; use std::{ io, @@ -26,11 +27,10 @@ pub fn folder(cache: &Path, location: &str) -> PathBuf { /// its canonical path, or its folder's with its name. pub fn local(path: &Path) -> io::Result { let canonical = - path.canonicalize() - .or_else(|error| match (path.parent(), path.file_name()) { - (Some(parent), Some(name)) => Ok(parent.canonicalize()?.join(name)), - _ => Err(error), - })?; + fs::canonicalize(path).or_else(|error| match (path.parent(), path.file_name()) { + (Some(parent), Some(name)) => Ok(fs::canonicalize(parent)?.join(name)), + _ => Err(error), + })?; Ok(canonical.to_string_lossy().into_owned()) } @@ -46,13 +46,15 @@ pub fn smb(address: &str, share: &str, root: &str) -> String { /// must be closed. pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> { let old = folder(cache, from); - if !old.exists() { + if fs::metadata(&old).is_err() { return Ok(()); } let new = claimed(cache, to)?; - for entry in std::fs::read_dir(&old)? { + for entry in fs::read_dir(&old)? { let name = entry?.file_name(); - if Path::new(&name).extension() == Some("sqlite".as_ref()) && !new.join(&name).exists() { + if Path::new(&name).extension() == Some("sqlite".as_ref()) + && fs::metadata(new.join(&name)).is_err() + { adopt(&old.join(&name), &new.join(&name))?; } } @@ -64,9 +66,9 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> { fn claimed(cache: &Path, location: &str) -> io::Result { let folder = folder(cache, location); let label = folder.join(LOCATION); - if !label.exists() { - std::fs::create_dir_all(&folder)?; - std::fs::write(&label, location)?; + if fs::metadata(&label).is_err() { + fs::create_dir_all(&folder)?; + fs::write(&label, location)?; } Ok(folder) } @@ -93,7 +95,7 @@ pub(crate) fn claim( let folder = claimed(cache, location)?; let missing: Vec<&[u8; 16]> = sections .iter() - .filter(|identity| !crate::session::replica_file(&folder, identity).exists()) + .filter(|identity| fs::metadata(crate::session::replica_file(&folder, identity)).is_err()) .collect(); if missing.is_empty() { return Ok(folder); @@ -107,7 +109,7 @@ pub(crate) fn claim( .iter() .map(|orphan| (crate::session::replica_file(orphan, identity), false)); for (replica, legacy) in legacy.chain(left) { - if !replica.exists() { + if fs::metadata(&replica).is_err() { continue; } let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) { @@ -132,15 +134,15 @@ pub(crate) fn claim( /// The replica folders of local locations that no longer exist. fn orphans(cache: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(cache.join("replicas")) else { + let Ok(entries) = fs::read_dir(cache.join("replicas")) else { return Vec::new(); }; entries .filter_map(|entry| { let folder = entry.ok()?.path(); - let location = std::fs::read_to_string(folder.join(LOCATION)).ok()?; + let location = fs::read_to_string(folder.join(LOCATION)).ok()?; let path = Path::new(&location); - (path.is_absolute() && !path.exists()).then_some(folder) + (path.is_absolute() && fs::metadata(path).is_err()).then_some(folder) }) .collect() } @@ -160,7 +162,7 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> { let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned()); source.push(suffix); target.push(suffix); - match std::fs::rename(&source, &target) { + match fs::rename(&source, &target) { Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {} moved => moved?, } @@ -170,12 +172,12 @@ fn adopt(from: &Path, to: &Path) -> io::Result<()> { /// Removes a replica folder that holds no replica. fn tidy(folder: &Path) { - let empty = std::fs::read_dir(folder).is_ok_and(|mut entries| { + let empty = fs::read_dir(folder).is_ok_and(|mut entries| { entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION)) }); if empty { - let _ = std::fs::remove_file(folder.join(LOCATION)); - let _ = std::fs::remove_dir(folder); + let _ = fs::remove_file(folder.join(LOCATION)); + let _ = fs::remove_dir(folder); } } diff --git a/crates/notebook/src/package.rs b/crates/notebook/src/package.rs index 62c3f3b9c332e3d785e6b6ee3be644ddac871aac..7244b200e182fb80aeae3164c941bcec4cb637e8 100644 --- a/crates/notebook/src/package.rs +++ b/crates/notebook/src/package.rs @@ -7,7 +7,7 @@ //! MSZIP, which every cabinet reader takes, and reads either. use crate::{ - Error, Result, + Error, Result, fs, session::{Notebook, lists, moved}, }; use onestore::{TocEdit, op::Edit, page::Page}; @@ -196,13 +196,13 @@ pub fn unpack(mut files: Files, root: &Path) -> Result<()> { transaction.apply(image)?; } } - std::fs::create_dir(root)?; + fs::create_dir(root)?; for (path, bytes) in &files { let file = root.join(path); if let Some(parent) = file.parent() { - std::fs::create_dir_all(parent)?; + fs::create_dir_all(parent)?; } - std::fs::File::create_new(file)?.write_all(bytes)?; + fs::File::create_new(file)?.write_all(bytes)?; } Ok(()) } diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 4cbc83aeb2754d5185aeae814144aab200182590..90e4cda27b0385cd206331fad3d25ea8d799d67c 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -3,7 +3,7 @@ pub use crate::background::{Background, Known}; use crate::{ - EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, + EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, }; use onestore::{ CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction, @@ -132,17 +132,17 @@ impl Storage for Directory { } fn exists(&self, path: &str) -> bool { - self.path(path).exists() + fs::metadata(self.path(path)).is_ok() } fn read(&self, path: &str) -> Result> { - Ok(onestore::read_file(self.path(path))?) + Ok(fs::read_file(self.path(path))?) } fn read_file(&self, path: &str, limit: usize) -> Result> { use std::io::Read; let mut bytes = Vec::new(); - std::fs::File::open(self.path(path))? + fs::File::open(self.path(path))? .take(limit as u64 + 1) .read_to_end(&mut bytes)?; if bytes.len() > limit { @@ -153,15 +153,16 @@ impl Storage for Directory { fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { let path = self.path(path); - if discover::placeholder(&path).is_some_and(|placeholder| placeholder.exists()) { + if discover::placeholder(&path).is_some_and(|placeholder| fs::metadata(placeholder).is_ok()) + { return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); } - std::fs::File::create_new(path)?.write_all(bytes)?; + fs::File::create_new(path)?.write_all(bytes)?; Ok(()) } fn create_directory(&self, path: &str) -> Result<()> { - Ok(std::fs::create_dir(self.path(path))?) + Ok(fs::create_dir(self.path(path))?) } /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted; @@ -207,37 +208,33 @@ impl Storage for Directory { } fn rename(&self, from: &str, to: &str) -> Result<()> { - Ok(std::fs::rename(self.path(from), self.path(to))?) + Ok(fs::rename(self.path(from), self.path(to))?) } fn replace(&self, from: &str, to: &str) -> Result<()> { - Ok(std::fs::rename(self.path(from), self.path(to))?) + Ok(fs::rename(self.path(from), self.path(to))?) } fn delete(&self, path: &str) -> Result<()> { let path = self.path(path); - if path.is_dir() { - std::fs::remove_dir(path)?; + if fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir()) { + fs::remove_dir(path)?; } else { - std::fs::remove_file(path)?; + fs::remove_file(path)?; } Ok(()) } fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { - Ok(onestore::place_file(self.path(path), ancestor, name)?) + Ok(fs::place_file(self.path(path), ancestor, name)?) } fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { - Ok(transaction.commit_file(self.path(path))?) + Ok(fs::commit_file(transaction, self.path(path))?) } fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { - Ok(onestore::supersede_file( - self.path(path), - base, - self.path(with), - )?) + Ok(fs::supersede_file(self.path(path), base, self.path(with))?) } } @@ -363,7 +360,7 @@ impl Notebook { pub const NEW_COLOR: u32 = 0x00aeba91; pub fn open(root: impl AsRef, cache: impl AsRef) -> Result { - let root = root.as_ref().canonicalize()?; + let root = fs::canonicalize(root)?; Self::with(Box::new(Directory(root.clone())), Some(root), cache) } @@ -376,7 +373,7 @@ impl Notebook { color: u32, page: &PageCreation, ) -> Result { - std::fs::create_dir(root.as_ref())?; + fs::create_dir(root.as_ref())?; let mut notebook = Self::open(root, cache)?; notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?; notebook.refresh()?; @@ -412,13 +409,13 @@ impl Notebook { ) -> Result { let cache = cache.as_ref().to_path_buf(); let listings = cache.join("listings"); - std::fs::create_dir_all(&listings)?; + fs::create_dir_all(&listings)?; let name: String = ::digest(storage.location())[..16] .iter() .map(|byte| format!("{byte:02x}")) .collect(); let listing = listings.join(format!("{name}.json")); - let mut read = std::fs::read(&listing) + let mut read = fs::read(&listing) .ok() .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default(); @@ -1271,7 +1268,7 @@ impl Notebook { let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect(); let mut superseded = Vec::new(); for folder in folders { - let Ok(entries) = std::fs::read_dir(folder) else { + let Ok(entries) = fs::read_dir(folder) else { continue; }; for path in entries.filter_map(|entry| Some(entry.ok()?.path())) { @@ -1337,8 +1334,8 @@ impl Notebook { let section = self.section_path(path)?; let replica = self.replica_path(path)?; // Held until the file is replaced, so that no session queues edits to the old file. - let held = replica - .exists() + let held = fs::metadata(&replica) + .is_ok() .then(|| crate::closed(&replica)) .transpose()?; if let Some(held) = &held @@ -1525,7 +1522,7 @@ impl Notebook { .into()); }; // A section replaced by a link out of the notebook is not the catalog's section. - let file = root.join(path).canonicalize()?; + let file = fs::canonicalize(root.join(path))?; if !file.starts_with(root) { return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); } @@ -1533,14 +1530,14 @@ impl Notebook { // worker checks next, as a share's does. if let discover::SectionState::Readable { document, .. } = §ion.state { let cache = replica_file(&replicas, document); - if cache.exists() { + if fs::metadata(&cache).is_ok() { let (replica, remote, mut connect) = (Replica::open(&cache)?, file.clone(), connect); return Section::start(file, replica, move || connect(&remote), notify); } } let replica = |identity: &[u8; 16], _: &[u8]| { - std::fs::create_dir_all(&replicas)?; + fs::create_dir_all(&replicas)?; Ok(replica_file(&replicas, identity)) }; Section::open_in(file, key, replica, connect, notify) @@ -1712,7 +1709,7 @@ fn remove_replica(replica: &Path) -> io::Result<()> { for suffix in ["", "-wal", "-shm"] { let mut file = replica.as_os_str().to_owned(); file.push(suffix); - match std::fs::remove_file(file) { + match fs::remove_file(file) { Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), _ => {} } @@ -1839,7 +1836,7 @@ impl Section { connect: impl FnMut(&Path) -> io::Result + Send + 'static, notify: impl Fn() + Send + 'static, ) -> Result { - let file = file.as_ref().canonicalize()?; + let file = fs::canonicalize(file)?; let location = file.to_string_lossy().into_owned(); let replicas = |identity: &[u8; 16], source: &[u8]| { let folder = crate::location::claim(cache.as_ref(), &location, &[*identity], |_| { @@ -1875,7 +1872,7 @@ impl Section { replica: Replica, notify: impl Fn() + Send + 'static, ) -> Result { - let file = std::path::absolute(file)?; + let file = fs::absolute(file)?; let remote = file.clone(); Self::start( file, @@ -2200,14 +2197,14 @@ struct FileRemote(PathBuf); impl Remote for FileRemote { fn read(&mut self) -> io::Result> { - onestore::read_file(&self.0) + fs::read_file(&self.0) } /// Read without the whole-file lock, which would block OneNote's readers: a change /// detector, never a snapshot to edit. fn stamp(&mut self) -> io::Result { use std::io::Read; - let mut file = std::fs::File::open(&self.0)?; + let mut file = fs::File::open(&self.0)?; let mut header = [0; 1024]; file.read_exact(&mut header)?; let length = file.metadata()?.len(); @@ -2215,11 +2212,11 @@ impl Remote for FileRemote { } fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> { - transaction.commit_file(&self.0) + fs::commit_file(transaction, &self.0) } fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> { - onestore::confirm_file(&self.0, base) + fs::confirm_file(&self.0, base) } } diff --git a/crates/notebook/src/sidecar.rs b/crates/notebook/src/sidecar.rs index 22aa7df13fe6b4147bde092b5bb34c2d0e2afa69..c9a43c719d12b55373eeaf33e31daaefa2c49890 100644 --- a/crates/notebook/src/sidecar.rs +++ b/crates/notebook/src/sidecar.rs @@ -158,7 +158,7 @@ pub(crate) fn map( /// A name beside `path` no other writer picks. fn temporary(path: &str) -> String { use std::hash::{BuildHasher, RandomState}; - let unique = RandomState::new().hash_one((std::process::id(), crate::now())); + let unique = RandomState::new().hash_one((crate::fs::process_id(), crate::now())); format!("{path}.{unique:016x}.tmp") } diff --git a/crates/notebook/src/task.rs b/crates/notebook/src/task.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ff7bc01631e04d97d0569ad8b5c451524004d97 --- /dev/null +++ b/crates/notebook/src/task.rs @@ -0,0 +1,159 @@ +//! The sync worker's and background's loops: threads natively, and in the browser, which +//! gives a module one thread, tasks on its event loop. A loop is an `async` block whose only +//! waits are `wait`; natively each wait blocks its thread, so `complete` runs the block to the +//! end in one poll. + +use std::{io, time::Duration}; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use std::{ + sync::mpsc::{Receiver, SyncSender as Sender}, + thread::JoinHandle, +}; + +/// A wake that waits, at most one, as `mpsc::sync_channel(1)` keeps one. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn channel() -> (Sender<()>, Receiver<()>) { + std::sync::mpsc::sync_channel(1) +} + +/// Waits for a wake or `timeout`, without one for ever. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option) { + let _ = match timeout { + Some(timeout) => receiver.recv_timeout(timeout).ok(), + None => receiver.recv().ok(), + }; +} + +/// Runs the loop `start` makes, whose waits block, on a thread of its own named `name`. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn spawn>( + name: &str, + start: impl FnOnce() -> F + Send + 'static, +) -> io::Result> { + std::thread::Builder::new() + .name(name.into()) + .spawn(move || complete(start())) +} + +/// `work`'s output, where nothing it awaits is pending. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn complete(work: impl Future) -> T { + let mut work = std::pin::pin!(work); + match work + .as_mut() + .poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) + { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => unreachable!("Native waits block rather than pend"), + } +} + +#[cfg(target_arch = "wasm32")] +pub(crate) use web::*; + +#[cfg(target_arch = "wasm32")] +mod web { + use super::*; + use std::{ + sync::{Arc, Mutex}, + task::{Poll, Waker}, + }; + use wasm_bindgen::{JsCast, prelude::*}; + + #[derive(Default)] + struct Bell { + rung: bool, + waiting: Option, + } + + pub(crate) struct Sender(Arc>, std::marker::PhantomData); + pub(crate) struct Receiver(Arc>, std::marker::PhantomData); + + pub(crate) fn channel() -> (Sender<()>, Receiver<()>) { + let bell = Arc::new(Mutex::new(Bell::default())); + ( + Sender(Arc::clone(&bell), Default::default()), + Receiver(bell, Default::default()), + ) + } + + impl Sender<()> { + pub(crate) fn try_send(&self, (): ()) -> Result<(), ()> { + let waiting = { + let mut bell = self.0.lock().map_err(|_| ())?; + bell.rung = true; + bell.waiting.take() + }; + if let Some(waiting) = waiting { + waiting.wake(); + } + Ok(()) + } + } + + /// Wakes `waker` after `delay` through the global scope's `setTimeout`. + fn after(delay: Duration, waker: Waker) { + let global = js_sys::global(); + let Ok(set_timeout) = js_sys::Reflect::get(&global, &"setTimeout".into()) + .and_then(|function| function.dyn_into::()) + else { + return waker.wake(); + }; + let wake = Closure::once_into_js(move || waker.wake()); + let _ = set_timeout.call2(&global, &wake, &(delay.as_secs_f64() * 1e3).into()); + } + + pub(crate) async fn wait(receiver: &Receiver<()>, timeout: Option) { + let deadline = timeout.map(|timeout| web_time::Instant::now() + timeout); + let mut armed = false; + std::future::poll_fn(|context| { + let Ok(mut bell) = receiver.0.lock() else { + return Poll::Ready(()); + }; + if std::mem::take(&mut bell.rung) + || deadline.is_some_and(|deadline| deadline <= web_time::Instant::now()) + { + return Poll::Ready(()); + } + bell.waiting = Some(context.waker().clone()); + if let Some(deadline) = deadline + && !std::mem::replace(&mut armed, true) + { + after( + deadline.saturating_duration_since(web_time::Instant::now()), + context.waker().clone(), + ); + } + Poll::Pending + }) + .await + } + + /// A task the browser's event loop runs. It cannot be waited for, and ends on its own. + pub(crate) struct JoinHandle(Arc>>); + + impl JoinHandle { + /// What the task ended with, if it has ended. + pub(crate) fn finished(self) -> Option { + self.0.lock().ok()?.take() + } + } + + pub(crate) fn spawn + 'static>( + _: &str, + start: impl FnOnce() -> F, + ) -> io::Result> { + let output = Arc::new(Mutex::new(None)); + let ended = Arc::clone(&output); + let work = start(); + wasm_bindgen_futures::spawn_local(async move { + let result = work.await; + if let Ok(mut ended) = ended.lock() { + *ended = Some(result); + } + }); + Ok(JoinHandle(output)) + } +} diff --git a/crates/notebook/src/worker.rs b/crates/notebook/src/worker.rs index be6537a1ab685878d74084c4888379d483db9363..e558ade6c555118ea434bba59d8e13435b0922a2 100644 --- a/crates/notebook/src/worker.rs +++ b/crates/notebook/src/worker.rs @@ -1,15 +1,14 @@ use super::*; +use crate::task::{self, JoinHandle}; use std::{ collections::hash_map::RandomState, hash::BuildHasher, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, - mpsc::{self, SyncSender}, }, - thread::{self, JoinHandle}, - time::Instant, }; +use web_time::Instant; pub(super) struct Signal { pub(crate) stopped: AtomicBool, @@ -22,7 +21,7 @@ pub(super) struct Signal { /// waits for that instead of checking the file on its own (`Background::hold`). pub(crate) watched: AtomicBool, pause: Mutex, - sender: SyncSender<()>, + sender: task::Sender<()>, } /// How local edits wait before they publish (`SyncWorker::set_pause`). @@ -37,8 +36,8 @@ struct Pause { const LONGEST: Duration = Duration::from_secs(30); impl Signal { - pub(crate) fn new() -> (Arc, mpsc::Receiver<()>) { - let (sender, receiver) = mpsc::sync_channel(1); + pub(crate) fn new() -> (Arc, task::Receiver<()>) { + let (sender, receiver) = task::channel(); let signal = Self { stopped: AtomicBool::new(false), offline: AtomicBool::new(false), @@ -123,9 +122,12 @@ impl SyncWorker { pub fn stop(mut self) -> Result<()> { self.signal.stopped.store(true, Ordering::Release); self.signal.wake(); - self.thread - .take() - .expect("Worker owns its thread") + let thread = self.thread.take().expect("Worker owns its thread"); + // In the browser no step is in flight outside the worker's own callback. + #[cfg(target_arch = "wasm32")] + return thread.finished().unwrap_or(Ok(())); + #[cfg(not(target_arch = "wasm32"))] + thread .join() .map_err(|_| io::Error::other("Synchronization worker panicked"))? } @@ -176,118 +178,115 @@ impl Replica { let (signal, receiver) = Signal::new(); let replica = Arc::clone(self); let worker_signal = Arc::clone(&signal); - let thread = thread::Builder::new() - .name("onestore-sync".into()) - .spawn(move || { - let mut remote: Option = None; - let jitter = RandomState::new(); - let mut contention = 0_u32; - // The first step, and the first after a failure, always runs, so `observe` - // hears that the remote is reachable and what state the queue is in. - let mut reported = false; - // With nothing to do: a watched worker waits for the watch, any other looks - // again after `interval`. - let rest = || { - if worker_signal.watched.load(Ordering::Acquire) { - let _ = receiver.recv(); - } else { - let _ = receiver.recv_timeout(interval); + let thread = task::spawn("onestore-sync", move || async move { + let mut remote: Option = None; + let jitter = RandomState::new(); + let mut contention = 0_u32; + // The first step, and the first after a failure, always runs, so `observe` + // hears that the remote is reachable and what state the queue is in. + let mut reported = false; + // With nothing to do: a watched worker waits for the watch, any other looks + // again after `interval`. + let rest = || { + task::wait( + &receiver, + (!worker_signal.watched.load(Ordering::Acquire)).then_some(interval), + ) + }; + while !worker_signal.stopped.load(Ordering::Acquire) { + if worker_signal.offline.load(Ordering::Acquire) + && !worker_signal.requested.load(Ordering::Acquire) + { + remote = None; + reported = false; + task::wait(&receiver, None).await; + continue; + } + if !worker_signal.requested.load(Ordering::Acquire) + && let Some(wait) = worker_signal.paused() + { + task::wait(&receiver, Some(wait)).await; + continue; + } + if let Ok(mut pause) = worker_signal.pause.lock() { + // The step publishes what waits; edits from now on wait anew. + pause.waiting = None; + } + let result = match remote.as_mut() { + Some(remote) => { + if reported && replica.settled(remote).unwrap_or(false) { + worker_signal.requested.store(false, Ordering::Release); + worker_signal.synced.store(crate::now(), Ordering::Release); + rest().await; + continue; + } + let result = replica.sync_once(remote); + reported = result.is_ok(); + result } + None => match connect() { + Ok(connected) => { + remote = Some(connected); + continue; + } + Err(error) => Err(Error::RemoteIo(error)), + }, }; - while !worker_signal.stopped.load(Ordering::Acquire) { - if worker_signal.offline.load(Ordering::Acquire) - && !worker_signal.requested.load(Ordering::Acquire) - { - remote = None; - reported = false; - let _ = receiver.recv(); + if result.is_ok() { + worker_signal.synced.store(crate::now(), Ordering::Release); + } + observe(&result); + let idle = matches!(result, Ok(Synced { edit: None, .. })); + match result { + Ok(Synced { + edit: Some((_, EditStatus::Published { .. })), + .. + }) => { + contention = 0; continue; } - if !worker_signal.requested.load(Ordering::Acquire) - && let Some(wait) = worker_signal.paused() + Ok(Synced { edit: None, .. }) => contention = 0, + Ok(_) => {} + Err(Error::Remote(onestore::CommitError { + state: onestore::CommitState::NotCommitted, + ref error, + })) if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy + ) => { - let _ = receiver.recv_timeout(wait); - continue; - } - if let Ok(mut pause) = worker_signal.pause.lock() { - // The step publishes what waits; edits from now on wait anew. - pause.waiting = None; - } - let result = match remote.as_mut() { - Some(remote) => { - if reported && replica.settled(remote).unwrap_or(false) { - worker_signal.requested.store(false, Ordering::Release); - worker_signal.synced.store(crate::now(), Ordering::Release); - rest(); - continue; - } - let result = replica.sync_once(remote); - reported = result.is_ok(); - result + contention = contention.saturating_add(1); + let ceiling = (50_u64 << contention.min(5)).min(1000); + let until = Instant::now() + + Duration::from_millis(jitter.hash_one(contention) % ceiling); + // Local wakes must not keep competing writers in the same retry phase. + while !worker_signal.stopped.load(Ordering::Acquire) { + let Some(remaining) = until.checked_duration_since(Instant::now()) + else { + break; + }; + task::wait(&receiver, Some(remaining)).await; } - None => match connect() { - Ok(connected) => { - remote = Some(connected); - continue; - } - Err(error) => Err(Error::RemoteIo(error)), - }, - }; - if result.is_ok() { - worker_signal.synced.store(crate::now(), Ordering::Release); + continue; } - observe(&result); - let idle = matches!(result, Ok(Synced { edit: None, .. })); - match result { - Ok(Synced { - edit: Some((_, EditStatus::Published { .. })), - .. - }) => { - contention = 0; - continue; - } - Ok(Synced { edit: None, .. }) => contention = 0, - Ok(_) => {} - Err(Error::Remote(onestore::CommitError { - state: onestore::CommitState::NotCommitted, - ref error, - })) if matches!( + Err(Error::RemoteIo(ref error)) + if matches!( error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy - ) => - { - contention = contention.saturating_add(1); - let ceiling = (50_u64 << contention.min(5)).min(1000); - let until = Instant::now() - + Duration::from_millis(jitter.hash_one(contention) % ceiling); - // Local wakes must not keep competing writers in the same retry phase. - while !worker_signal.stopped.load(Ordering::Acquire) { - let Some(remaining) = until.checked_duration_since(Instant::now()) - else { - break; - }; - let _ = receiver.recv_timeout(remaining); - } - continue; - } - Err(Error::RemoteIo(ref error)) - if matches!( - error.kind(), - io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy - ) => {} - Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, - Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} - Err(error) => return Err(error), - } - worker_signal.requested.store(false, Ordering::Release); - if idle { - rest(); - } else if !worker_signal.stopped.load(Ordering::Acquire) { - let _ = receiver.recv_timeout(interval); - } + ) => {} + Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, + Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => return Err(error), } - Ok(()) - })?; + worker_signal.requested.store(false, Ordering::Release); + if idle { + rest().await; + } else if !worker_signal.stopped.load(Ordering::Acquire) { + task::wait(&receiver, Some(interval)).await; + } + } + Ok(()) + })?; *owner = Arc::downgrade(&signal); Ok(SyncWorker { signal, diff --git a/crates/notebook/src/working.rs b/crates/notebook/src/working.rs index 0ccb3fbeea69fc76e86c6df393900e63e9f3259c..521a9c44c388a32036dc715472de2836d26ba29d 100644 --- a/crates/notebook/src/working.rs +++ b/crates/notebook/src/working.rs @@ -1,7 +1,8 @@ //! The section thread: the only owner of the parsed section, which is the cached base //! image with each sealed batch replayed and the open batch's edits applied. Edits apply //! here and are written in one SQLite transaction per burst; the sync thread asks it to seal -//! and, when the remote changed, to rebase the queue. +//! and, when the remote changed, to rebase the queue. In the browser, which has one thread, +//! the section is served as each request is sent, and a rebuild runs as it is asked for. use crate::{Result, base, lock, merge, queue, worker::Signal}; use onestore::{ @@ -15,7 +16,6 @@ use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, io, sync::{Arc, Mutex, Weak, mpsc}, - thread, }; pub(crate) type Reply = Box) + Send>; @@ -64,8 +64,10 @@ pub(crate) enum Request { reply: Reply<()>, }, /// From the thread that rebuilt the section: hand it the requests. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] Handover(mpsc::SyncSender), /// From the thread that rebuilt nothing: carry on with the section as it is. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] Resume, } @@ -107,7 +109,8 @@ pub(crate) struct Thread { pub(crate) worker: Mutex>, /// Taken when the replica drops, which ends every section thread. sender: Mutex>>, - threads: Mutex>>, + #[cfg(not(target_arch = "wasm32"))] + threads: Mutex>>, /// A password-protected section's key, under which its queue is sealed too. pub(crate) key: Option, } @@ -118,7 +121,10 @@ impl Thread { .lock() .ok() .and_then(|sender| sender.as_ref()?.send(request).ok()) - .ok_or_else(|| io::Error::other("The section thread stopped").into()) + .ok_or_else(|| io::Error::other("The section thread stopped"))?; + #[cfg(target_arch = "wasm32")] + serving::serve(self); + Ok(()) } /// Ends the section threads and waits for them, so the cache is released. @@ -126,6 +132,9 @@ impl Thread { if let Ok(mut sender) = self.sender.lock() { sender.take(); } + #[cfg(target_arch = "wasm32")] + serving::serve(self); + #[cfg(not(target_arch = "wasm32"))] while let Some(thread) = self .threads .lock() @@ -136,17 +145,81 @@ impl Thread { } } - fn start(self: &Arc, run: impl FnOnce(Arc) + Send + 'static) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + fn start>( + self: &Arc, + run: impl FnOnce(Arc) -> F + Send + 'static, + ) -> Result<()> { let shared = Arc::clone(self); - let thread = thread::Builder::new() + let thread = std::thread::Builder::new() .name("onestore-section".into()) - .spawn(move || run(shared))?; + .spawn(move || crate::task::complete(run(shared)))?; self.threads .lock() .map_err(|_| io::Error::other("The section thread panicked"))? .push(thread); Ok(()) } + + #[cfg(target_arch = "wasm32")] + fn start + 'static>( + self: &Arc, + run: impl FnOnce(Arc) -> F, + ) -> Result<()> { + serving::start(self, Box::pin(run(Arc::clone(self)))); + Ok(()) + } +} + +/// The browser's section threads: each replica's `run`, polled whenever a request is sent +/// until it waits for the next. +#[cfg(target_arch = "wasm32")] +mod serving { + use super::Thread; + use std::{cell::RefCell, collections::BTreeMap, future::Future, pin::Pin, task}; + + type Serving = Pin>>; + + thread_local! { + /// By the address of each replica's `Thread`, which its `run` keeps alive. + static SERVING: RefCell> = const { RefCell::new(BTreeMap::new()) }; + } + + pub(super) fn start(thread: &Thread, run: Serving) { + SERVING.with_borrow_mut(|serving| serving.insert(std::ptr::from_ref(thread).addr(), run)); + serve(thread); + } + + /// Serves what `thread` was sent. A request sent while it is being served, as from a + /// reply, waits for the serving already under way, which takes it next. + pub(super) fn serve(thread: &Thread) { + let id = std::ptr::from_ref(thread).addr(); + let Some(mut run) = SERVING.with_borrow_mut(|serving| serving.remove(&id)) else { + return; + }; + let mut context = task::Context::from_waker(task::Waker::noop()); + if run.as_mut().poll(&mut context).is_pending() { + SERVING.with_borrow_mut(|serving| serving.insert(id, run)); + } + } +} + +/// The next request, or none once the replica dropped. +#[cfg(not(target_arch = "wasm32"))] +async fn receive(requests: &mpsc::Receiver) -> Option { + requests.recv().ok() +} + +/// The next request, waiting for `serving::serve` where none is queued, or none once the +/// replica dropped. +#[cfg(target_arch = "wasm32")] +async fn receive(requests: &mpsc::Receiver) -> Option { + std::future::poll_fn(|_| match requests.try_recv() { + Ok(request) => std::task::Poll::Ready(Some(request)), + Err(mpsc::TryRecvError::Disconnected) => std::task::Poll::Ready(None), + Err(mpsc::TryRecvError::Empty) => std::task::Poll::Pending, + }) + .await } /// Starts the section thread once the queue opens. @@ -156,6 +229,7 @@ pub(crate) fn spawn(connection: Connection, key: Option) -> Result<(Arc, VecDeque), } -fn run( +async fn run( shared: Arc, requests: mpsc::Receiver, mut backlog: VecDeque, @@ -202,7 +276,7 @@ fn run( if let Some(ready) = ready.take() { let _ = ready.send(Ok(working.section.root())); } - match working.serve(&shared, &requests, &mut backlog) { + match working.serve(&shared, &requests, &mut backlog).await { Next::Stop => return, Next::Reopen => {} Next::Handover(to, held) => { @@ -222,6 +296,7 @@ fn fail(error: crate::Error, backlog: VecDeque, requests: mpsc::Receive } /// What a rebuilding thread does before it rereads the section. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] enum Job { Rebase { image: Option>, @@ -234,6 +309,7 @@ enum Job { /// Runs `job`, rereads the section and takes the requests over from the thread that /// started it; a failed rebase changed nothing that thread serves. +#[cfg(not(target_arch = "wasm32"))] fn build(shared: Arc, job: Job, signal: mpsc::Sender) { let answer: Box = match job { Job::Reopen { reply } => Box::new(move || reply(Ok(()))), @@ -263,15 +339,34 @@ fn build(shared: Arc, job: Job, signal: mpsc::Sender) { Ok(working) => working, Err(error) => return fail(error, backlog, requests), }; - match working.serve(&shared, &requests, &mut backlog) { + match crate::task::complete(working.serve(&shared, &requests, &mut backlog)) { Next::Stop => {} - Next::Reopen => run(shared, requests, backlog, None), + Next::Reopen => crate::task::complete(run(shared, requests, backlog, None)), Next::Handover(to, held) => { let _ = to.send((requests, held)); } } } +/// `build` where there is one thread: runs `job` before the section is reread, which the +/// serving thread does once true is returned; a failed rebase changed nothing it serves. +#[cfg(target_arch = "wasm32")] +fn inline(shared: &Thread, job: Job) -> bool { + match job { + Job::Reopen { reply } => reply(Ok(())), + Job::Rebase { image, reply } => { + match rebase(&shared.connection, image, shared.key.as_ref()) { + Ok(changed) => reply(Ok(changed)), + Err(error) => { + reply(Err(error)); + return false; + } + } + } + } + true +} + /// An edit applied to the section and not yet written, with who waits for it. struct Accepted { author: String, @@ -297,7 +392,7 @@ impl<'a> Working<'a> { }) } - fn serve( + async fn serve( mut self, shared: &Arc, requests: &mpsc::Receiver, @@ -310,9 +405,9 @@ impl<'a> Working<'a> { loop { let first = match backlog.pop_front() { Some(request) => request, - None => match requests.recv() { - Ok(request) => request, - Err(_) => return Next::Stop, + None => match receive(requests).await { + Some(request) => request, + None => return Next::Stop, }, }; let mut burst: VecDeque = VecDeque::from([first]); @@ -415,23 +510,35 @@ impl<'a> Working<'a> { } Request::Rebase { image, reply } => { if self.flush(shared, &mut accepted) { + #[cfg(target_arch = "wasm32")] + let reopen = inline(shared, Job::Rebase { image, reply }); + #[cfg(not(target_arch = "wasm32"))] + let reopen = { + held = self + .rebuild(shared, Job::Rebase { image, reply }) + .then(VecDeque::new); + false + }; + reopen + } else { + reply(Err( + io::Error::other("The queue could not be written").into() + )); + true + } + } + Request::Reopen { reply } => { + self.flush(shared, &mut accepted); + #[cfg(target_arch = "wasm32")] + let reopen = inline(shared, Job::Reopen { reply }); + #[cfg(not(target_arch = "wasm32"))] + let reopen = { held = self - .rebuild(shared, Job::Rebase { image, reply }) + .rebuild(shared, Job::Reopen { reply }) .then(VecDeque::new); false - } else { - reply(Err( - io::Error::other("The queue could not be written").into() - )); - true - } - } - Request::Reopen { reply } => { - self.flush(shared, &mut accepted); - held = self - .rebuild(shared, Job::Reopen { reply }) - .then(VecDeque::new); - false + }; + reopen } Request::Handover(_) | Request::Resume => false, }; @@ -448,6 +555,7 @@ impl<'a> Working<'a> { /// Starts a thread that runs `job` and rereads the section; false when the replica is /// stopping, which answers the job's request. + #[cfg(not(target_arch = "wasm32"))] fn rebuild(&self, shared: &Arc, job: Job) -> bool { let signal = shared .sender @@ -466,7 +574,7 @@ impl<'a> Working<'a> { return false; }; shared - .start(move |shared| build(shared, job, signal)) + .start(move |shared| async move { build(shared, job, signal) }) .is_ok() } diff --git a/crates/onestore/Cargo.toml b/crates/onestore/Cargo.toml index e0134c9a34ebc2834e8c1f6605f101f4c4131e45..57382db507a815990a9a092b240f1fc446490907 100644 --- a/crates/onestore/Cargo.toml +++ b/crates/onestore/Cargo.toml @@ -17,6 +17,8 @@ roxmltree = "0.21.1" sha1 = { version = "0.11.0", features = ["zeroize"] } subtle = "2.6.1" zeroize = "1.9.0" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.31.3", default-features = false, features = ["fs"] } diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index cb60c5d436039370866608c35da7b4ece4a9b992..bd7201ec69fcea5936704ddf5c8bd7ff0163691b 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -5,7 +5,7 @@ use crate::{ write::{append, append_list, fresh_guid, node}, }; use std::collections::BTreeMap; -use std::time::{SystemTime, UNIX_EPOCH}; +use web_time::{SystemTime, UNIX_EPOCH}; type Result = std::result::Result; diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 3c63bd1870901eb7d0376719601b8991d0bf202c..2375669bee25edbca798c2e373b7c022c8cbf200 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -8,6 +8,7 @@ mod create; pub mod document; mod edit; mod files; +#[cfg(any(unix, windows))] mod flush; mod formatting; mod identity; diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index c0e0913e189c7756b0afd5de8a56b2b29a96ed75..be6a38917856789c564d2a362d58359b27a94884 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -17,24 +17,39 @@ png = "0.18" # Encodes a video recording's pictures as JPEG, which OneNote 2010 plays. image = { version = "0.25", default-features = false, features = ["jpeg"] } onestore = { path = "../onestore" } -notebook = { path = "../notebook", features = ["smb"] } +notebook = { path = "../notebook" } serde = { version = "1", features = ["derive"] } serde_json = "1" pollster = "0.4" wgpu = { workspace = true, optional = true } parley.workspace = true winit = "0.30.13" -arboard = { version = "3.6.1", default-features = false } accesskit = "0.25.0" accesskit_consumer = "0.39.0" -accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } -# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. -ureq = { version = "3.4", default-features = false, features = ["rustls"] } -ring = "0.17" # Clears passwords typed into the password dialogs. zeroize = "1.9.0" # Names characters in More Symbols, Insert Symbol's dialog. unicode_names2 = "4.0.0" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +notebook = { path = "../notebook", features = ["smb"] } +arboard = { version = "3.6.1", default-features = false } +accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } +# Updates: fetched over HTTPS, verified with ed25519 and SHA-256. The browser updates by +# reloading. +ureq = { version = "3.4", default-features = false, features = ["rustls"] } +ring = "0.17" + +# The browser: see arc/platforms.md. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wgpu = { workspace = true, features = ["webgl"] } +getrandom = { version = "0.4", features = ["wasm_js"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Window"] } [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" @@ -43,7 +58,7 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } block2 = "0.5.1" -[target.'cfg(not(target_os = "macos"))'.dependencies] +[target.'cfg(not(any(target_os = "macos", target_arch = "wasm32")))'.dependencies] # Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself. arboard = { version = "3.6.1", default-features = false, features = ["image-data"] } diff --git a/crates/snowbound/src/attachment.rs b/crates/snowbound/src/attachment.rs index a6faeb23d99707d241c27c3606f026bb62ee6058..2f76105c5854f8567e25e0afed31e4e6c7a17cb9 100644 --- a/crates/snowbound/src/attachment.rs +++ b/crates/snowbound/src/attachment.rs @@ -21,7 +21,7 @@ impl State { } let (Some(name), Ok(bytes)) = ( path.file_name().and_then(|name| name.to_str()), - std::fs::read(path), + notebook::fs::read(path), ) else { platform::alert("Couldn't attach the file", "Choose a file you can open."); return Ok(()); @@ -68,14 +68,14 @@ impl State { return Ok(None); }; let root = std::env::temp_dir().join("Snowbound Attachments"); - std::fs::create_dir_all(&root)?; + notebook::fs::create_dir_all(&root)?; // A copy opened earlier may still be open, edited, in its application. let folder = (0..1000) .map(|index| root.join(index.to_string())) - .find(|folder| std::fs::create_dir(folder).is_ok()) + .find(|folder| notebook::fs::create_dir(folder).is_ok()) .ok_or("No folder is free for a copy of the file")?; let path = folder.join(&file.filename); - std::fs::write(&path, bytes)?; + notebook::fs::write(&path, bytes)?; Ok(Some(path)) } @@ -86,7 +86,7 @@ impl State { return Ok(()); }; if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) { - std::fs::write(path, bytes)?; + notebook::fs::write(path, bytes)?; } Ok(()) } diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 866b4f1bbed1e3ea12338fce32777fb8d60e6a6b..a9a5f8ba18e7591ff318c4b08a670547c707b041 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1562,7 +1562,7 @@ impl State { else { return Ok(()); }; - return self.insert_picture(std::fs::read(path)?, None); + return self.insert_picture(notebook::fs::read(path)?, None); } Id::Attachment => { let Some(path) = platform::pick_file("Attach File", &[]) else { diff --git a/crates/snowbound/src/guide.rs b/crates/snowbound/src/guide.rs index 9733a8c958b19f2a3a4d2692c43ae194689a0548..1e420fd5166851a9d7612b3e35102709fc9f4b72 100644 --- a/crates/snowbound/src/guide.rs +++ b/crates/snowbound/src/guide.rs @@ -35,8 +35,8 @@ impl State { /// Opens the guide in the user's documents, copying it there first unless it is there. pub(crate) fn open_guide(&mut self) -> Result<(), Box> { let documents = platform::documents_dir().ok_or("There is no Documents folder")?; - let folder = std::path::absolute(documents.join(NAME))?; - if !folder.exists() { + let folder = notebook::fs::absolute(documents.join(NAME))?; + if notebook::fs::metadata(&folder).is_err() { copy(&folder, &self.cache)?; } self.open_notebook(folder.to_string_lossy().into_owned(), None); @@ -48,12 +48,12 @@ impl State { /// short never passes for the guide. fn copy(folder: &Path, cache: &Path) -> Result<(), Box> { let partial = folder.with_file_name(format!("{NAME}.partial")); - if partial.exists() { - std::fs::remove_dir_all(&partial)?; + if notebook::fs::metadata(&partial).is_ok() { + notebook::fs::remove_dir_all(&partial)?; } - std::fs::create_dir_all(&partial)?; + notebook::fs::create_dir_all(&partial)?; for (name, bytes) in FILES { - std::fs::write(partial.join(name), bytes)?; + notebook::fs::write(partial.join(name), bytes)?; } // The snowflake tag's art, kept in the notebook as Customize Tags keeps it. Notebook::open(&partial, cache)?.map_tag_art( @@ -62,7 +62,7 @@ fn copy(folder: &Path, cache: &Path) -> Result<(), Box> { include_bytes!("../assets/tags/snowflake.svg"), "svg", )?; - std::fs::rename(&partial, folder)?; + notebook::fs::rename(&partial, folder)?; Ok(()) } @@ -75,7 +75,7 @@ mod tests { #[test] fn the_guide_copies_whole() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/guide-notebook"); - let mut committed: Vec = std::fs::read_dir(root.join(NAME)) + let mut committed: Vec = notebook::fs::read_dir(root.join(NAME)) .unwrap() .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) .filter(|name| !name.starts_with('.')) @@ -87,7 +87,7 @@ mod tests { let temporary = std::env::temp_dir().join(format!("snowbound-guide-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let folder = temporary.join(NAME); copy(&folder, &temporary.join("cache")).unwrap(); let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap(); @@ -99,8 +99,8 @@ mod tests { .collect::>(), [("Snowflake", 34)] ); - let committed = std::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json")); + let committed = notebook::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json")); assert!(committed.unwrap().contains(&art[0].art)); - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs index b18c231bc475a2bae92327f84083191e0e8cad7c..3c28e016a2403d97f90ace7cf1b59e2deb0b5db9 100644 --- a/crates/snowbound/src/history.rs +++ b/crates/snowbound/src/history.rs @@ -226,7 +226,7 @@ impl State { }; let library = Arc::clone(&session.library); let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { for path in sections(&library, &folder) { if path == open { continue; diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 95c278c47562f9b6463fcc404c78a404fb205120..93b80684ca654f9a54b9b3b14dccc4b764d05058 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -4,6 +4,7 @@ use canvas::{editor::NoteTag, gpu::TagArt}; use notebook::discover::{Folder, SectionState}; use notebook::session::{Background, Notebook, Section}; use notebook::sidecar::themes::Themes; +#[cfg(not(target_arch = "wasm32"))] use notebook::smb::{Client, Credentials}; use onestore::protected::Key; use std::{ @@ -14,8 +15,9 @@ use std::{ Arc, Mutex, OnceLock, atomic::{AtomicBool, Ordering}, }, - time::{Duration, Instant}, + time::Duration, }; +use web_time::Instant; /// OneNote's Work Offline, which like OneNote's holds for every notebook. static OFFLINE: AtomicBool = AtomicBool::new(false); @@ -63,7 +65,7 @@ pub struct Mount { /// the system reports mount points with symlinks (`/tmp`, `/var`) resolved. #[cfg(unix)] pub fn within_mount(path: &std::path::Path, point: &str) -> Option { - let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned()); + let path = notebook::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned()); Some( path.strip_prefix(point) .ok()? @@ -271,6 +273,7 @@ struct Server { login: Login, } +#[cfg(not(target_arch = "wasm32"))] impl Server { fn connect(&self) -> io::Result { Client::connect( @@ -398,8 +401,15 @@ impl Library { } } + /// No server is in reach of the browser until a relay carries SMB to it. + #[cfg(target_arch = "wasm32")] + pub fn on_share(_: &str, _: Mount, _: Login, _: &Path) -> Result { + Err(crate::platform::smb::Refusal::Unreachable.to_string()) + } + /// The notebook at `location`, which `mount` names on its server, opened through the /// embedded client signed in as `login`. + #[cfg(not(target_arch = "wasm32"))] pub fn on_share( location: &str, mount: Mount, @@ -443,8 +453,8 @@ impl Library { let mut names = read_display_names(&file); names.insert(self.location.clone(), name.to_owned()); let partial = file.with_extension("partial"); - std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; - std::fs::rename(partial, file) + notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; + notebook::fs::rename(partial, file) } /// The art the notebook's tags draw with. @@ -478,7 +488,7 @@ impl Library { } let extension = art.rsplit('.').next().unwrap_or_default().to_owned(); let (library, tag) = (Arc::clone(self), tag.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let kept = library.reopen().and_then(|notebook| { Ok(notebook.map_tag_art(&tag.label, tag.shape, &bytes, &extension)?) }); @@ -529,7 +539,7 @@ impl Library { .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), Arc::new(themes))); let library = Arc::clone(self); - std::thread::spawn(move || { + crate::spawn(move || { let kept = library .reopen() .and_then(|notebook| Ok(notebook.save_themes(change)?)); @@ -546,9 +556,9 @@ impl Library { return; } let library = Arc::clone(self); - std::thread::spawn(move || { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + crate::spawn(move || { + let now = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map_or(0, |unix| unix.as_secs()) // Time32 counts from 1980. .saturating_sub(315_532_800); @@ -567,9 +577,12 @@ impl Library { /// This notebook read again, the way it was opened, for changing its structure. pub fn reopen(&self) -> Result> { Ok(match &self.server { + #[cfg(not(target_arch = "wasm32"))] Some(server) => { Notebook::open_smb(Arc::new(server.connect()?), &server.mount.root, &self.cache)? } + #[cfg(target_arch = "wasm32")] + Some(_) => unreachable!("The browser opens no notebook on a server"), None => Notebook::open(&self.location, &self.cache)?, }) } @@ -665,13 +678,16 @@ impl Library { return Ok(kept); } let opened = match (&self.notebook, &self.server) { + #[cfg(target_arch = "wasm32")] + (Ok(Some(_)), Some(_)) => unreachable!("The browser opens no notebook on a server"), + #[cfg(not(target_arch = "wasm32"))] (Ok(Some(notebook)), Some(server)) => { let file = match server.mount.root.as_str() { "" => path.to_owned(), root => format!("{root}/{path}"), }; let cache = notebook.replica_path(path)?; - std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; + notebook::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || { Ok(server.connect()?.read_storage(&file, LIMIT)?) }); @@ -735,7 +751,7 @@ impl Library { Err(_) => vec![section], }; if !gone.is_empty() { - std::thread::spawn(move || close(gone)); + crate::spawn(move || close(gone)); } } @@ -984,7 +1000,7 @@ impl Library { if let Ok(mut kept) = self.kept.lock() { for path in &locked { if let Some(section) = kept.take(path) { - std::thread::spawn(move || close(vec![section])); + crate::spawn(move || close(vec![section])); } } } @@ -1013,7 +1029,7 @@ fn display_names(cache: &Path) -> PathBuf { } fn read_display_names(file: &Path) -> std::collections::BTreeMap { - std::fs::read(file) + notebook::fs::read(file) .ok() .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default() @@ -1119,7 +1135,7 @@ fn close(sections: Vec

) { } pub fn file_name(path: &Path) -> String { - path.canonicalize() + notebook::fs::canonicalize(path) .ok() .as_deref() .and_then(Path::file_name) @@ -1181,7 +1197,7 @@ pub enum Located { /// Whether `folder` holds a table of contents, as a notebook's or a section group's does. fn has_toc(folder: &Path) -> bool { - std::fs::read_dir(folder).is_ok_and(|entries| { + notebook::fs::read_dir(folder).is_ok_and(|entries| { entries.flatten().any(|entry| { entry .path() @@ -1199,10 +1215,12 @@ pub fn locate(path: &Path) -> Located { .extension() .map(|extension| extension.to_string_lossy().to_ascii_lowercase()); match extension.as_deref() { - _ if path.is_dir() => Located::Notebook { - root: path.to_owned(), - section: None, - }, + _ if notebook::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) => { + Located::Notebook { + root: path.to_owned(), + section: None, + } + } Some("onepkg") => Located::Package(path.to_owned()), Some("onetoc2") => match path.parent() { Some(root) => Located::Notebook { @@ -1245,14 +1263,14 @@ mod tests { #[test] fn kept_sections_open_at_once() { let root = std::env::temp_dir().join(format!("snowbound-kept-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); + let _ = notebook::fs::remove_dir_all(&root); let notebook = root.join("Personal"); - std::fs::create_dir_all(¬ebook).unwrap(); + notebook::fs::create_dir_all(¬ebook).unwrap(); let sample = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/sample-notebook/Personal"); - for entry in std::fs::read_dir(sample).unwrap() { + for entry in notebook::fs::read_dir(sample).unwrap() { let entry = entry.unwrap(); - std::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap(); + notebook::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap(); } let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache")); let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone()); @@ -1268,7 +1286,7 @@ mod tests { library.close_kept(); library.open(&first, || {}).unwrap().close().unwrap(); drop(library); - std::fs::remove_dir_all(&root).unwrap(); + notebook::fs::remove_dir_all(&root).unwrap(); } #[cfg(unix)] @@ -1276,13 +1294,13 @@ mod tests { fn within_mount_resolves_symlinks() { let base = std::env::temp_dir().join(format!("snowbound-within-{}", std::process::id())); let point = base.join("point"); - std::fs::create_dir_all(point.join("notes")).unwrap(); + notebook::fs::create_dir_all(point.join("notes")).unwrap(); std::os::unix::fs::symlink(&point, base.join("link")).unwrap(); - let point = std::fs::canonicalize(&point).unwrap(); + let point = notebook::fs::canonicalize(&point).unwrap(); let point = point.to_str().unwrap(); let found = within_mount(&base.join("link/notes"), point); let missing = within_mount(&base.join("link/gone"), point); - std::fs::remove_dir_all(&base).unwrap(); + notebook::fs::remove_dir_all(&base).unwrap(); assert_eq!(found.as_deref(), Some("notes")); assert_eq!(missing, None); } @@ -1310,8 +1328,8 @@ mod tests { fn a_notebook_on_a_share_opens_through_the_embedded_client() { let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); let root = std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap_or_else(|_| { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let nanos = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .unwrap() .subsec_nanos(); format!("snowbound-{}-{nanos}", std::process::id()) @@ -1400,7 +1418,7 @@ mod tests { } section.close().unwrap(); drop(library); - let _ = std::fs::remove_dir_all(&cache); + let _ = notebook::fs::remove_dir_all(&cache); if std::env::var_os("ONESTORE_SMB_LAB_KEEP").is_none() { remove_tree(&client, &root); assert!(client.read_dir(&root, 1).is_err(), "the folder is gone"); @@ -1488,18 +1506,18 @@ mod tests { #[test] fn chosen_paths_open_their_notebook_or_section() { let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); + let _ = notebook::fs::remove_dir_all(&root); let notebook = root.join("Notebook"); - std::fs::create_dir_all(notebook.join("Group")).unwrap(); + notebook::fs::create_dir_all(notebook.join("Group")).unwrap(); for file in [ "Open Notebook.onetoc2", "Group/Open Notebook.onetoc2", "Group/Inner.one", "Top.one", ] { - std::fs::write(notebook.join(file), b"").unwrap(); + notebook::fs::write(notebook.join(file), b"").unwrap(); } - std::fs::write(root.join("Loose.one"), b"").unwrap(); + notebook::fs::write(root.join("Loose.one"), b"").unwrap(); let at = |root: &Path, section: Option<&str>| Located::Notebook { root: root.to_owned(), section: section.map(str::to_owned), @@ -1526,6 +1544,6 @@ mod tests { Located::Package(root.join("Shared.onepkg")) ); assert_eq!(locate(&root.join("missing.txt")), Located::Nothing); - std::fs::remove_dir_all(&root).unwrap(); + notebook::fs::remove_dir_all(&root).unwrap(); } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 0325d758b262b6dc3fd7239b145f4058d2719a42..2c7f02854f5173a8b2e245145058f402c8639a25 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,4 +1,6 @@ #![cfg_attr(windows, windows_subsystem = "windows")] +// The browser drives `State` from `web.rs`; launch, replays and screenshots are the desktop's. +#![cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg(target_os = "macos")] mod aqua; mod art; @@ -18,6 +20,7 @@ mod history; #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] mod icloud; +#[cfg(not(target_arch = "wasm32"))] #[cfg_attr(target_os = "macos", allow(dead_code))] mod instance; mod keys; @@ -30,6 +33,7 @@ mod manage; #[cfg_attr(target_os = "linux", path = "media_linux.rs")] #[cfg_attr(target_os = "macos", path = "media_macos.rs")] #[cfg_attr(windows, path = "media_windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "media_web.rs")] mod media; mod meeting; #[cfg(target_os = "macos")] @@ -43,12 +47,14 @@ mod paste; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(windows, path = "windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "web.rs")] mod platform; mod prefetch; mod print; #[cfg_attr(target_os = "linux", path = "print_linux.rs")] #[cfg_attr(target_os = "macos", path = "print_macos.rs")] #[cfg_attr(windows, path = "print_windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "print_web.rs")] mod printer; mod properties; mod protection; @@ -64,6 +70,7 @@ mod sidebar; #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] #[cfg_attr(windows, path = "spell_windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "spell_web.rs")] mod spell; #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] #[cfg_attr(windows, path = "surface_windows.rs")] @@ -78,17 +85,17 @@ mod unpack; mod unread; mod update; mod video; +#[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")] mod watch; +#[cfg(not(target_arch = "wasm32"))] +use accesskit_winit::Adapter as AccessAdapter; +#[cfg(not(target_arch = "wasm32"))] +use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline}; use canvas::gpu::colorref; use canvas::gpu::page::PageScene; use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; -use canvas::{ - date::DateField, - document::TextDocument, - editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, - layout::TextEngine, -}; +use canvas::{date::DateField, document::TextDocument, editor::CanvasEditor, layout::TextEngine}; use draw::Renderer; use library::Library; use onestore::ExGuid; @@ -96,21 +103,29 @@ use onestore::document::Format; use onestore::page::Page; use onestore::page::ink::ShapeKind; use onestore::page::text::Paragraph; +#[cfg(target_arch = "wasm32")] +use platform::{AccessAdapter, ActiveEventLoop, EventLoopProxy, Window}; use std::{ collections::{HashMap, HashSet}, error::Error, path::{Path, PathBuf}, sync::{Arc, Mutex, mpsc}, - time::Instant, }; use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; +use web_time::Instant; +#[cfg(not(target_arch = "wasm32"))] use winit::{ application::ApplicationHandler, - dpi::{LogicalPosition, LogicalSize}, - event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, + event::{ElementState, MouseScrollDelta, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, - keyboard::{Key, ModifiersState, NamedKey}, - window::{CursorIcon, Window, WindowId}, + keyboard::ModifiersState, + window::{Window, WindowId}, +}; +use winit::{ + dpi::{LogicalPosition, LogicalSize}, + event::{Ime, MouseButton}, + keyboard::{Key, NamedKey}, + window::CursorIcon, }; /// Height of the toolbar's row, which is the title bar where the platform lets it: a @@ -207,6 +222,7 @@ enum UserEvent { /// A picture to insert at the caret, such as a screen clipping. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] Picture(Vec), + #[cfg(not(target_arch = "wasm32"))] Accessibility(accesskit_winit::Event), /// The section's synchronization thread reported an event. Sync, @@ -298,6 +314,7 @@ enum Replay { Quit, } +#[cfg(not(target_arch = "wasm32"))] impl From for UserEvent { fn from(event: accesskit_winit::Event) -> Self { Self::Accessibility(event) @@ -314,7 +331,7 @@ fn lap(phase: &str, start: Instant) { fn trace_input(event: &impl std::fmt::Debug) { if std::env::var_os("SNOWBOUND_TRACE_INPUT").is_some() { - eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now()); + eprintln!("Input {:?}: {event:?}", web_time::SystemTime::now()); } } @@ -334,6 +351,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon { } } +#[cfg(not(target_arch = "wasm32"))] struct App { proxy: EventLoopProxy, input: Option, @@ -373,7 +391,7 @@ impl Input { /// Lists the notebook folder `root` and shows it first, where the window shows notebooks. fn show(&mut self, root: &Path) -> std::io::Result<()> { if let Input::Notebooks { locations, current } = self { - let location = std::path::absolute(root)?.to_string_lossy().into_owned(); + let location = notebook::fs::absolute(root)?.to_string_lossy().into_owned(); if !locations.contains(&location) { locations.push(location.clone()); } @@ -816,7 +834,7 @@ struct State { min_width: f32, ime_allowed: bool, clipboard: Clipboard, - access_adapter: accesskit_winit::Adapter, + access_adapter: AccessAdapter, accessibility: accessibility::Accessibility, /// Assistive technology holds the page's tree, grafted into the interface's. page_grafted: bool, @@ -889,7 +907,7 @@ impl State { commands::Keymap::from_saved(&stored.keys).install(); platform::install_menu(); let access_adapter = - accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); + AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); window.set_visible(visible); let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?; let size = window.inner_size(); @@ -897,8 +915,9 @@ impl State { let fallbacks = platform::symbol_fonts(); draw::fall_back_to(&mut engine.fonts.collection, &fallbacks); for path in substitutes { - let target = engine - .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; + let target = engine.register_substitute(parley::fontique::Blob::new(Arc::new( + notebook::fs::read(path)?, + )))?; eprintln!("Using {} for {target}", path.display()); } let layouts = Arc::new(Mutex::new(engine.clone())); @@ -1028,7 +1047,6 @@ impl State { .unwrap_or([ui.theme.strip; 2]); // A window shown but never focused hears no focus event; a hidden one draws as focused. ui.window_focused = !visible || window.has_focus(); - platform::system_interface(&mut ui); ui.fall_back_to(&fallbacks); ui.set_focus(Some(page())); for family in FONTS { @@ -1036,6 +1054,7 @@ impl State { ui.preview_font(face.clone(), family); } } + platform::system_interface(&mut ui); let mut fonts: Vec = engine .fonts .collection @@ -3089,7 +3108,7 @@ impl State { let library = Arc::clone(&session.library); let path = session.tabs[tab].path.clone(); let (notify, author) = (notify(self.proxy.clone()), self.author.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let copied = library.open(&path, notify).and_then(|section| { section.import_page(&page, &author)?; Ok(section.close()?) @@ -3161,7 +3180,7 @@ impl State { let requested = Instant::now(); let scenes = Arc::clone(&self.prefetch.scenes); let open = self.session.as_ref().map(Session::key); - std::thread::spawn(move || { + crate::spawn(move || { let laid = read().and_then(|loaded| { lap("switch read", requested); let (shown, page) = match loaded { @@ -3283,7 +3302,7 @@ impl State { library.keep(&previous.tabs[previous.tab].path, previous.section); } else { let section = previous.section; - std::thread::spawn(move || { + crate::spawn(move || { if let Err(error) = section.close() { eprintln!("Synchronization stopped: {error}"); } @@ -3914,7 +3933,7 @@ impl State { } let mut out = String::new(); write(&tree.state().root(), 0, &mut out); - std::fs::write(path, out)?; + notebook::fs::write(path, out)?; Ok(()) } @@ -4000,7 +4019,7 @@ impl State { let pixels = self.capture()?; let size = self.surface.size; // Encoding off the frame keeps the frames at the pace they are drawn. - std::thread::spawn(move || { + crate::spawn(move || { if let Err(error) = write_png(&path, size, &pixels) { eprintln!("{error}"); } @@ -5436,14 +5455,143 @@ fn page_text(editor: &CanvasEditor) -> String { .join("\n\n") } +/// Runs `work` beside the frame: on a thread of its own, or in the browser, which gives the +/// page one thread, once the frame under way is done. +fn spawn(work: impl FnOnce() + Send + 'static) { + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(work); + #[cfg(target_arch = "wasm32")] + platform::defer(work); +} + /// FILETIME now: when an edit happened, which its modification times record. fn filetime() -> u64 { - let unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .unwrap_or_default(); (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) } +impl State { + /// Does what an event sent to the event loop asks of the open window. + fn user_event(&mut self, event: UserEvent) { + match event { + // The event loop quits; a page stays open. + UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} + UserEvent::Picture(bytes) => { + if let Err(error) = self.insert_picture(bytes, None) { + eprintln!("{error}"); + } + self.window.request_redraw(); + } + UserEvent::InsertText(text) => { + if self.ui.focused() == Some(page()) { + match self.view.insert_text(text) { + Ok(response) => self.respond(response), + Err(error) => eprintln!("{error}"), + } + self.window.request_redraw(); + } else { + self.input(ui::Event::Ime(Ime::Commit(text))); + } + } + UserEvent::Then(then) => { + if let Err(error) = then(self) { + eprintln!("{error}"); + } + self.window.request_redraw(); + } + UserEvent::ICloudFolder => self.list_icloud(), + UserEvent::ICloudAccount => { + self.icloud_account_changed(); + self.window.request_redraw(); + } + UserEvent::Open(paths) => { + for path in paths { + self.open_path(&path); + } + self.window.set_minimized(false); + self.window.focus_window(); + self.window.request_redraw(); + } + UserEvent::Update => { + self.updated(); + self.window.request_redraw(); + } + UserEvent::Sync => { + if let Err(error) = self.synced() { + eprintln!("{error}"); + } + } + UserEvent::Choose(choice) => self.choose(choice), + UserEvent::Appearance => { + self.follow_color_scheme(); + self.window.request_redraw(); + } + UserEvent::Redraw => self.window.request_redraw(), + UserEvent::Replay(replay) => { + match replay { + Replay::Input(event) => self.input(event), + Replay::Pinch(factor) => { + if let Err(error) = self.pinch(factor) { + eprintln!("{error}"); + } + } + Replay::Snapshot(path) => self.snapshot = Some(path), + Replay::Accessibility(path) => { + if let Err(error) = self.write_accessibility(&path) { + eprintln!("{error}"); + } + } + Replay::Tick | Replay::Quit => {} + Replay::Appearance(appearance) => { + self.window.set_theme(Some(appearance)); + self.set_appearance(appearance); + } + Replay::Resize([width, height]) => { + let _ = self + .window + .request_inner_size(LogicalSize::new(width, height)); + } + } + // A covered window gets no redraws, so each step draws its own frame. + if let Err(error) = self.frame() { + eprintln!("{error}"); + } + } + #[cfg(not(target_arch = "wasm32"))] + UserEvent::Accessibility(event) => self.access_event(event), + } + } + + /// Does what assistive technology asked of the window. + #[cfg(not(target_arch = "wasm32"))] + fn access_event(&mut self, event: accesskit_winit::Event) { + if event.window_id != self.window.id() { + return; + } + let was_marked = self.view.editor.marked_range().is_some(); + let result = match event.window_event { + accesskit_winit::WindowEvent::InitialTreeRequested => { + self.deactivate_accessibility(); + self.update_accessibility(true) + } + accesskit_winit::WindowEvent::ActionRequested(request) => self.access_action(request), + accesskit_winit::WindowEvent::AccessibilityDeactivated => { + self.deactivate_accessibility(); + Ok(()) + } + }; + if was_marked && self.view.editor.marked_range().is_none() { + platform::clear_marked_text(&self.window); + } + if let Err(error) = result { + eprintln!("{error}"); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] impl App { fn close(&self, event_loop: &ActiveEventLoop) { if self.state.as_ref().is_none_or(|state| { @@ -5480,8 +5628,10 @@ impl App { /// How long quitting waits for the open section's edits to reach its file; they stay in the /// replica and publish at the next launch otherwise. +#[cfg(not(target_arch = "wasm32"))] const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); +#[cfg(not(target_arch = "wasm32"))] impl ApplicationHandler for App { fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { let Some(state) = &mut self.state else { @@ -5493,114 +5643,9 @@ impl ApplicationHandler for App { } return; }; - let event = match event { - UserEvent::Quit | UserEvent::Replay(Replay::Quit) => return self.close(event_loop), - UserEvent::Picture(bytes) => { - if let Err(error) = state.insert_picture(bytes, None) { - eprintln!("{error}"); - } - return state.window.request_redraw(); - } - UserEvent::InsertText(text) => { - if state.ui.focused() == Some(page()) { - match state.view.insert_text(text) { - Ok(response) => state.respond(response), - Err(error) => eprintln!("{error}"), - } - state.window.request_redraw(); - } else { - state.input(ui::Event::Ime(Ime::Commit(text))); - } - return; - } - UserEvent::Then(then) => { - if let Err(error) = then(state) { - eprintln!("{error}"); - } - return state.window.request_redraw(); - } - UserEvent::ICloudFolder => return state.list_icloud(), - UserEvent::ICloudAccount => { - state.icloud_account_changed(); - return state.window.request_redraw(); - } - UserEvent::Open(paths) => { - for path in paths { - state.open_path(&path); - } - state.window.set_minimized(false); - state.window.focus_window(); - return state.window.request_redraw(); - } - UserEvent::Update => { - state.updated(); - return state.window.request_redraw(); - } - UserEvent::Sync => { - if let Err(error) = state.synced() { - eprintln!("{error}"); - } - return; - } - UserEvent::Choose(choice) => return state.choose(choice), - UserEvent::Appearance => { - state.follow_color_scheme(); - return state.window.request_redraw(); - } - UserEvent::Redraw => return state.window.request_redraw(), - UserEvent::Replay(replay) => { - match replay { - Replay::Input(event) => state.input(event), - Replay::Pinch(factor) => { - if let Err(error) = state.pinch(factor) { - eprintln!("{error}"); - } - } - Replay::Snapshot(path) => state.snapshot = Some(path), - Replay::Accessibility(path) => { - if let Err(error) = state.write_accessibility(&path) { - eprintln!("{error}"); - } - } - Replay::Tick | Replay::Quit => {} - Replay::Appearance(appearance) => { - state.window.set_theme(Some(appearance)); - state.set_appearance(appearance); - } - Replay::Resize([width, height]) => { - let _ = state - .window - .request_inner_size(LogicalSize::new(width, height)); - } - } - // A covered window gets no redraws, so each step draws its own frame. - if let Err(error) = state.frame() { - eprintln!("{error}"); - } - return; - } - UserEvent::Accessibility(event) => event, - }; - if event.window_id != state.window.id() { - return; - } - let was_marked = state.view.editor.marked_range().is_some(); - let result = match event.window_event { - accesskit_winit::WindowEvent::InitialTreeRequested => { - state.deactivate_accessibility(); - state.update_accessibility(true) - } - accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), - accesskit_winit::WindowEvent::AccessibilityDeactivated => { - state.deactivate_accessibility(); - Ok(()) - } - }; - if was_marked && state.view.editor.marked_range().is_none() { - platform::clear_marked_text(&state.window); - } - if let Err(error) = result { - eprintln!("{error}"); + match event { + UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop), + event => state.user_event(event), } } @@ -5810,8 +5855,8 @@ impl ApplicationHandler for App { fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box> { let partial = path.with_extension("partial"); - std::fs::write(&partial, paste::png(size, pixels)?)?; - std::fs::rename(partial, path)?; + notebook::fs::write(&partial, paste::png(size, pixels)?)?; + notebook::fs::rename(partial, path)?; Ok(()) } @@ -5820,6 +5865,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box) -> Result<(), Box> { let mut steps = Vec::new(); for line in script.lines().filter(|line| !line.trim().is_empty()) { @@ -5926,6 +5972,7 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box Result<(), Box> { #[cfg(target_os = "linux")] if std::env::args_os() @@ -5947,6 +5994,11 @@ fn main() -> Result<(), Box> { platform::with_pool(launch) } +/// The page starts the window through `web::start` once it has fetched the fonts. +#[cfg(target_arch = "wasm32")] +fn main() {} + +#[cfg(not(target_arch = "wasm32"))] fn launch() -> Result<(), Box> { let mut args = std::env::args_os().skip(1); if args.next().is_some_and(|arg| arg == update::FINISH) { @@ -6014,7 +6066,7 @@ fn launch() -> Result<(), Box> { let title = args .next() .ok_or("Provide a page title after the section file.")?; - let bytes = std::fs::read(path)?; + let bytes = notebook::fs::read(path)?; let store = onestore::Store::parse(&bytes)?; let index = onestore::RevisionIndex::parse(&store)?; reference = Some(Page::from_document( @@ -6046,7 +6098,7 @@ fn launch() -> Result<(), Box> { } let text = positional .first() - .map(std::fs::read_to_string) + .map(notebook::fs::read_to_string) .transpose()? .unwrap_or_default(); let width = positional @@ -6108,7 +6160,10 @@ fn launch() -> Result<(), Box> { instance.serve(event_loop.create_proxy()); } if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { - replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; + replay( + notebook::fs::read_to_string(script)?, + event_loop.create_proxy(), + )?; } let mut app = App { proxy: event_loop.create_proxy(), @@ -6164,10 +6219,10 @@ mod tests { fn recorded_ops_reach_the_section() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - std::fs::create_dir_all(directory.join("cache")).unwrap(); + let _ = notebook::fs::remove_dir_all(&directory); + notebook::fs::create_dir_all(directory.join("cache")).unwrap(); let file = directory.join("section.one"); - std::fs::copy( + notebook::fs::copy( root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), &file, ) @@ -6215,7 +6270,7 @@ mod tests { .any(|event| matches!(event, notebook::session::Event::Rejected { .. })) ); section.close().unwrap(); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } use notebook::session::{Event, Section}; @@ -6267,10 +6322,10 @@ mod tests { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let directory = std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - std::fs::create_dir_all(&directory).unwrap(); + let _ = notebook::fs::remove_dir_all(&directory); + notebook::fs::create_dir_all(&directory).unwrap(); let file = directory.join("section.one"); - std::fs::copy( + notebook::fs::copy( root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), &file, ) @@ -6345,7 +6400,7 @@ mod tests { ); ours.close().unwrap(); theirs.close().unwrap(); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } /// Times opening each section of a notebook and each of its pages, phase by phase, as a @@ -6356,7 +6411,7 @@ mod tests { #[ignore = "measures a notebook named by the environment"] fn switch_timings() { let cache = std::env::temp_dir().join(format!("snowbound-switch-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&cache); + let _ = notebook::fs::remove_dir_all(&cache); let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") { Ok(folder) => Library::notebook(&folder, &cache), Err(_) => { @@ -6421,6 +6476,6 @@ mod tests { session.section.close().unwrap(); } } - let _ = std::fs::remove_dir_all(&cache); + let _ = notebook::fs::remove_dir_all(&cache); } } diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 4aebad7a5cc7ef9215f3cdd2fa316f11c85e7d4c..ef3dfceb84a4c38cce1fea230638da59aa032450 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -211,7 +211,7 @@ impl State { }; let space = self.session.as_ref().ok_or("No section is open")?.space; let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { let art = template.pictures().map_err(|error| error.to_string()); let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { if state @@ -399,9 +399,9 @@ impl State { pub(crate) fn list_icloud(&mut self) { let proxy = self.proxy.clone(); // Listing a folder iCloud Drive has not brought down yet waits for it. - std::thread::spawn(move || { + crate::spawn(move || { let listed = crate::icloud::folder().and_then(|root| { - let mut listed: Vec = std::fs::read_dir(&root) + let mut listed: Vec = notebook::fs::read_dir(&root) .ok()? .flatten() .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) @@ -446,7 +446,7 @@ impl State { continue; } let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let library = Arc::new(Library::notebook(&location, &cache)); let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { state.icloud_reading.remove(&location); @@ -494,7 +494,7 @@ impl State { return; } let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { if missing.is_some() { std::thread::sleep(std::time::Duration::from_secs(2)); } @@ -544,11 +544,16 @@ impl State { } /// Creates a notebook in the new folder `root` and opens it. - fn create_notebook(&mut self, root: std::path::PathBuf) -> Result<(), Box> { + pub(crate) fn create_notebook( + &mut self, + root: std::path::PathBuf, + ) -> Result<(), Box> { let page = self.dated_page(None)?; let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); self.load(move || { - let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); + let location = notebook::fs::absolute(&root)? + .to_string_lossy() + .into_owned(); let notebook = Notebook::create(&location, &cache, Notebook::NEW_COLOR, &page)?; let library = Arc::new(Library::created(&location, notebook, &cache)); let path = library @@ -584,7 +589,7 @@ impl State { session.library.name, crate::filetime() )); - let exported = std::fs::create_dir_all(&folder) + let exported = notebook::fs::create_dir_all(&folder) .map_err(notebook::Error::from) .and_then(|()| session.section.export_recovery(&archive)); if let Err(error) = exported { @@ -936,13 +941,13 @@ mod tests { } fn copy(from: &Path, to: &Path) { - std::fs::create_dir_all(to).unwrap(); - for entry in std::fs::read_dir(from).unwrap().flatten() { + notebook::fs::create_dir_all(to).unwrap(); + for entry in notebook::fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy(&entry.path(), &target); } else { - std::fs::copy(entry.path(), target).unwrap(); + notebook::fs::copy(entry.path(), target).unwrap(); } } } @@ -956,8 +961,8 @@ mod tests { fn a_notebook_is_managed_as_onenote_manages_one() { let temporary = std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); - std::fs::create_dir_all(&temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(&temporary); + notebook::fs::create_dir_all(&temporary).unwrap(); let root = temporary.join("Managed"); let cache = temporary.join("cache"); let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); @@ -1182,7 +1187,7 @@ mod tests { if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") { copy(&root, Path::new(&directory)); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } /// Deleting a group's last section shows the notebook's other one; deleting that too @@ -1191,8 +1196,8 @@ mod tests { fn deleting_every_section_leaves_the_notebook_showing_none() { let temporary = std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); - std::fs::create_dir_all(&temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(&temporary); + notebook::fs::create_dir_all(&temporary).unwrap(); let root = temporary.join("Emptied"); let location = root.to_str().unwrap(); let cache = temporary.join("cache"); @@ -1213,7 +1218,7 @@ mod tests { let library = library.with(notebook); assert_eq!(shown(&library, Some("New Section 1.one".into())), None); assert!(library.tabs("").is_empty()); - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } #[test] @@ -1243,7 +1248,7 @@ mod tests { fn backgrounds_go_on_and_off_pages_that_have_content() { let temporary = std::env::temp_dir().join(format!("snowbound-background-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); copy( Path::new(concat!( env!("CARGO_MANIFEST_DIR"), @@ -1329,6 +1334,6 @@ mod tests { if let Some(directory) = std::env::var_os("SNOWBOUND_BACKGROUND_EXPORT") { copy(&temporary, Path::new(&directory)); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/media_web.rs b/crates/snowbound/src/media_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..abcff381d158864a3ad3e288d4adcb5243b1dcbc --- /dev/null +++ b/crates/snowbound/src/media_web.rs @@ -0,0 +1,61 @@ +//! Recording and playback in the browser, which come with `MediaRecorder` in a later phase; +//! until then recordings neither start nor play, and say why. + +use std::{convert::Infallible, path::Path}; + +/// Why the browser does not record yet. +const UNAVAILABLE: &str = "Recording isn't available in the browser yet."; + +pub struct Player(Infallible); + +impl Player { + pub fn open(_: &Path, _: bool) -> Option { + None + } + + pub fn play(&mut self) { + match self.0 {} + } + + pub fn pause(&mut self) { + match self.0 {} + } + + pub fn playing(&self) -> bool { + match self.0 {} + } + + pub fn position_ms(&self) -> u32 { + match self.0 {} + } + + pub fn duration_ms(&self) -> u32 { + match self.0 {} + } + + pub fn seek(&mut self, _: u32) { + match self.0 {} + } +} + +pub struct Recorder(Infallible); + +impl Recorder { + pub fn audio(_: &Path, _: u32) -> Result { + Err(UNAVAILABLE.into()) + } + + pub fn video(_: &Path) -> Result { + Err(UNAVAILABLE.into()) + } + + pub fn pause(&mut self, _: bool) { + match self.0 {} + } + + pub fn stop(self) -> Result Result<(), String> + Send, String> { + match self.0 {} + #[allow(unreachable_code)] + Ok(|| Ok(())) + } +} diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 47d44af69a5686045ae4ace76a985b5d9cfc728d..826a14a5e14f0824a827b437e6c179ed4f167db5 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -1046,10 +1046,10 @@ mod tests { fn sections_and_the_notebook_take_onenotes_colours() { let temporary = std::env::temp_dir().join(format!("snowbound-colors-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let root = temporary.join("Colors"); let cache = temporary.join("cache"); - std::fs::create_dir_all(&temporary).unwrap(); + notebook::fs::create_dir_all(&temporary).unwrap(); let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap(); let named: Vec<(Option, &str)> = (SECTION_COLORS.iter()) @@ -1080,14 +1080,14 @@ mod tests { assert_eq!(colors, expected); if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") { let directory = std::path::Path::new(&directory); - std::fs::create_dir_all(directory).unwrap(); - for entry in std::fs::read_dir(&root).unwrap() { + notebook::fs::create_dir_all(directory).unwrap(); + for entry in notebook::fs::read_dir(&root).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_file() { - std::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); + notebook::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); } } } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/pane.rs b/crates/snowbound/src/pane.rs index af0eebc0ed1b29aa2f6cac3cbd101de2d13c530d..6a9989eb51d90369388bcb7fffcf777a05329c83 100644 --- a/crates/snowbound/src/pane.rs +++ b/crates/snowbound/src/pane.rs @@ -169,7 +169,7 @@ fn local_day(time: u64) -> i64 { libc::localtime_r(&unix, &mut tm); tm.tm_gmtoff as i64 }; - #[cfg(windows)] + #[cfg(any(windows, target_arch = "wasm32"))] let offset = crate::platform::utc_offset(unix); (unix + offset).div_euclid(86_400) } diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs index 193e1ae69a47cfb82c40060b72febf017e43572f..db042b40d44493dab41b38abc4c9d5c8c7c2df69 100644 --- a/crates/snowbound/src/paste.rs +++ b/crates/snowbound/src/paste.rs @@ -5,7 +5,6 @@ use canvas::editor::Piece; use std::{ error::Error, path::{Path, PathBuf}, - time::Duration, }; /// What Paste takes from the clipboard. Files come first, as Finder also offers their names @@ -45,7 +44,7 @@ impl State { self.respond(response); for (place, (source, css)) in places.into_iter().zip(fetched) { let proxy = self.proxy.clone(); - std::thread::spawn(move || { + crate::spawn(move || { let Some(bytes) = fetch(&source) else { return; }; @@ -78,7 +77,7 @@ impl State { path: &Path, at: Option<[f32; 2]>, ) -> Result<(), Box> { - match std::fs::read(path) { + match notebook::fs::read(path) { Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { self.insert_picture(bytes, at) } @@ -315,15 +314,22 @@ fn load(source: &str) -> Option> { Some(drive) if cfg!(windows) => drive.to_owned(), _ => path, }; - return std::fs::read(path).ok(); + return notebook::fs::read(path).ok(); } None } +/// None in the browser, whose pages rarely let another site read their pictures. +#[cfg(target_arch = "wasm32")] +fn fetch(_: &str) -> Option> { + None +} + /// A web picture's bytes, on a thread of its own. +#[cfg(not(target_arch = "wasm32"))] fn fetch(source: &str) -> Option> { let agent: ureq::Agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) + .timeout_global(Some(std::time::Duration::from_secs(10))) .build() .into(); agent @@ -512,7 +518,7 @@ fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> { /// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes /// 96 dpi as OneNote 2010 gives a pasted bitmap. -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_arch = "wasm32")))] pub(crate) fn bitmap(image: arboard::ImageData) -> Option> { png( [image.width, image.height].map(|side| side as u32), @@ -667,12 +673,12 @@ mod tests { assert_eq!(load("relative/pic.png"), None); let directory = std::env::temp_dir().join(format!("snowbound-paste-{}", std::process::id())); - std::fs::create_dir_all(&directory).unwrap(); + notebook::fs::create_dir_all(&directory).unwrap(); let file = directory.join("a picture.png"); - std::fs::write(&file, b"bytes").unwrap(); + notebook::fs::write(&file, b"bytes").unwrap(); let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20")); assert_eq!(load(&address).unwrap(), b"bytes"); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } fn close(size: [f32; 2], expected: [f32; 2]) -> bool { diff --git a/crates/snowbound/src/prefetch.rs b/crates/snowbound/src/prefetch.rs index 73d5df0ff3a348272e78c300c31ec2464c11fda3..17c6764037d229aa0a756664785be0df8cbbff1e 100644 --- a/crates/snowbound/src/prefetch.rs +++ b/crates/snowbound/src/prefetch.rs @@ -109,6 +109,10 @@ impl Prefetch { let scenes = Scenes::new(Mutex::new(Recent::new(SCENES))); let (jobs, queue) = mpsc::channel(); let kept = Arc::clone(&scenes); + // With one thread the browser lays a page out as it opens rather than ahead of time. + #[cfg(target_arch = "wasm32")] + drop((queue, layouts, kept, redraw)); + #[cfg(not(target_arch = "wasm32"))] std::thread::Builder::new() .name("prefetch".into()) .spawn(move || { diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index c7dcf65a55e7129922ed7857df19861638baf7f9..68ba85bd48b28fa32887d4ee20197574d0c7de2f 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -343,7 +343,7 @@ impl State { footer: setup.footer, }; let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); - std::thread::spawn(move || { + crate::spawn(move || { let made = (|| -> Result, Box> { let mut open = Some(open); let this = spaces @@ -380,7 +380,9 @@ impl State { Ok(canvas::print::pdf(sections, &mut engine, &layout)?) })(); let done = made.and_then(|pdf| match &destination { - Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into), + Some(path) => notebook::fs::write(path, pdf) + .map(|()| None) + .map_err(Into::into), None => Ok(Some(pdf)), }); let failed = if export { @@ -468,9 +470,9 @@ pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result let folder = platform::cache_dir() .unwrap_or_else(std::env::temp_dir) .join("Printing"); - std::fs::create_dir_all(&folder)?; + notebook::fs::create_dir_all(&folder)?; let path = folder.join(format!("{}.pdf", file_name(title))); - std::fs::write(&path, pdf)?; + notebook::fs::write(&path, pdf)?; Ok(path) } @@ -502,8 +504,8 @@ mod tests { PageCreation, op::{Edit, Op, SectionOp}, }; - let _ = std::fs::remove_dir_all(temporary); - std::fs::create_dir_all(temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(temporary); + notebook::fs::create_dir_all(temporary).unwrap(); let root = temporary.join("Ordered"); let cache = temporary.join("cache"); let page = |title: &str| { @@ -558,7 +560,7 @@ mod tests { let tabs = notebook_tabs(&library); library.close_kept(); drop(library); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect(); assert_eq!(paths, ["A.one", "G/C.one", "B.one"]); } @@ -576,7 +578,7 @@ mod tests { .collect(); section.close().unwrap(); drop(library); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); assert_eq!(pages.len(), 3); // A page's title as typed, which its title object holds. let typed = |page: &Page| { @@ -607,8 +609,8 @@ mod tests { use notebook::session::Notebook; let temporary = std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); - std::fs::create_dir_all(&temporary).unwrap(); + let _ = notebook::fs::remove_dir_all(&temporary); + notebook::fs::create_dir_all(&temporary).unwrap(); let root = temporary.join("Printed"); let cache = temporary.join("cache"); let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); @@ -622,7 +624,7 @@ mod tests { .into_iter() .map(|(path, _)| path) .collect(); - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); assert_eq!(paths, ["New Section 1.one", "Group/Inner.one"]); } } diff --git a/crates/snowbound/src/print_web.rs b/crates/snowbound/src/print_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..54552fe5729a82f6e46c20b6d0a4f6782de98500 --- /dev/null +++ b/crates/snowbound/src/print_web.rs @@ -0,0 +1,18 @@ +//! Printing in the browser: the PDF Snowbound prints downloads, to print from the viewer. + +use crate::platform::{Window, download}; +use std::error::Error; + +/// Letter where the browser's language is American English, A4 elsewhere. +pub fn paper() -> [f32; 2] { + if crate::platform::input_language() == "en-US" { + canvas::print::LETTER + } else { + canvas::print::A4 + } +} + +pub fn print(_: &Window, pdf: &[u8], title: &str) -> Result<(), Box> { + download(&format!("{title}.pdf"), pdf, "application/pdf"); + Ok(()) +} diff --git a/crates/snowbound/src/recording.rs b/crates/snowbound/src/recording.rs index 49ab42f3d2fbb75a50d9bf7770c473e9f4006768..a20c608909ca9d25f957b9068e95ea50269cc414 100644 --- a/crates/snowbound/src/recording.rs +++ b/crates/snowbound/src/recording.rs @@ -73,7 +73,7 @@ impl State { _ => self.media = Media::Idle, } let folder = std::env::temp_dir().join("Snowbound Recordings"); - std::fs::create_dir_all(&folder)?; + notebook::fs::create_dir_all(&folder)?; let extension = if video { "avi" } else { "wav" }; let path = folder.join(format!("{}.{extension}", std::process::id())); let recorder = if video { @@ -131,7 +131,8 @@ impl State { let recorded = path.clone(); let job = std::thread::spawn(move || { finish()?; - let recorded = std::fs::read(&recorded).map_err(|error| error.to_string())?; + let recorded = + notebook::fs::read(&recorded).map_err(|error| error.to_string())?; Ok(if video { recorded } else { @@ -168,7 +169,7 @@ impl State { Wave::parse(&bytes).map(|wave| wave.duration_ms()) }; let preview = platform::file_icon(&path); - std::fs::remove_file(&path)?; + notebook::fs::remove_file(&path)?; let title = self.view.editor.page()?.title; let name: String = title .chars() @@ -1042,10 +1043,10 @@ pub(crate) mod tests { if let Some(directory) = std::env::var_os(export) { let directory = PathBuf::from(directory); - std::fs::create_dir_all(&directory).unwrap(); - std::fs::write(directory.join("Recorded.one"), &image).unwrap(); + notebook::fs::create_dir_all(&directory).unwrap(); + notebook::fs::write(directory.join("Recorded.one"), &image).unwrap(); let file_id = Store::parse(&image).unwrap().header.file_id; - std::fs::write( + notebook::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( "Open Notebook.onetoc2", diff --git a/crates/snowbound/src/recycle.rs b/crates/snowbound/src/recycle.rs index f1cf7d9dd74bd5c062d4e2cb95df0d1824d3c73b..b413aed4dba433d88726cc6c3a280fcd65cb12d7 100644 --- a/crates/snowbound/src/recycle.rs +++ b/crates/snowbound/src/recycle.rs @@ -261,7 +261,7 @@ mod tests { } fn titles(root: &std::path::Path, path: &str) -> Vec<(String, Option<[u8; 16]>)> { - let pages = stored_pages(&std::fs::read(root.join(path)).unwrap()).unwrap(); + let pages = stored_pages(¬ebook::fs::read(root.join(path)).unwrap()).unwrap(); (pages.into_iter()) .map(|page| (page.page.title, page.page.identity)) .collect() @@ -275,8 +275,8 @@ mod tests { #[test] fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() { let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&folder); - std::fs::create_dir_all(&folder).unwrap(); + let _ = notebook::fs::remove_dir_all(&folder); + notebook::fs::create_dir_all(&folder).unwrap(); let (root, cache) = (folder.join("Recycled"), folder.join("cache")); let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap(); let mut notebook = @@ -376,17 +376,17 @@ mod tests { if let Some(directory) = &export { copy(&root, &directory.join("after")); } - std::fs::remove_dir_all(&folder).unwrap(); + notebook::fs::remove_dir_all(&folder).unwrap(); } fn copy(from: &std::path::Path, to: &std::path::Path) { - std::fs::create_dir_all(to).unwrap(); - for entry in std::fs::read_dir(from).unwrap().flatten() { + notebook::fs::create_dir_all(to).unwrap(); + for entry in notebook::fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy(&entry.path(), &target); } else { - std::fs::copy(entry.path(), target).unwrap(); + notebook::fs::copy(entry.path(), target).unwrap(); } } } diff --git a/crates/snowbound/src/save_as.rs b/crates/snowbound/src/save_as.rs index e72867bfc7f68fc050757221c42df5ff8acaaadc..ace385c461fd631edf6002d4679c41ea464ebdc9 100644 --- a/crates/snowbound/src/save_as.rs +++ b/crates/snowbound/src/save_as.rs @@ -187,7 +187,7 @@ impl State { .into_iter()) .find(|tab| tab.path == section) .and_then(|tab| tab.color); - std::thread::spawn(move || { + crate::spawn(move || { let written = (|| -> Result<(), Box> { let image = |path: &str| { let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; @@ -212,7 +212,7 @@ impl State { package::pack(package::notebook_files(¬ebook, image)?)? } }; - std::fs::write(&path, bytes)?; + notebook::fs::write(&path, bytes)?; Ok(()) })(); let written = written.map_err(|error| error.to_string()); diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index 7c35021267de88f920737a5dec07179b7898123c..711e730efd8f1c4c65a0bcc615af76854bcc2c5d 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -17,9 +17,10 @@ use std::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc, }, - time::{Duration, Instant, SystemTime}, + time::{Duration, SystemTime}, }; use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, fit, px}; +use web_time::Instant; use winit::keyboard::NamedKey; /// Where a search looks, as OneNote's scope menu offers it. @@ -116,6 +117,10 @@ pub struct Search { version: Arc, pub(crate) busy: Arc, jobs: mpsc::Sender, + /// The index's own state, which the browser, having no thread for it, keeps here to run + /// the jobs sent after each frame. + #[cfg(target_arch = "wasm32")] + indexing: Arc>, pub(crate) found: Vec, found_for: Option<(Query, Scope, u64, String)>, pub(crate) selected: Option, @@ -136,10 +141,18 @@ impl Search { let index = Arc::new(Mutex::new(Index::default())); let version = Arc::new(AtomicU64::new(0)); let busy = Arc::new(AtomicBool::new(false)); - let shared = (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy)); + let indexing = Indexing { + jobs: receiver, + shared: (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy)), + redraw, + stamps: HashMap::new(), + }; + #[cfg(target_arch = "wasm32")] + let indexing = Arc::new(Mutex::new(indexing)); + #[cfg(not(target_arch = "wasm32"))] std::thread::Builder::new() .name("search-index".into()) - .spawn(move || run(receiver, shared, redraw)) + .spawn(move || indexing.run()) .expect("the index thread starts"); Self { query: String::new(), @@ -152,6 +165,8 @@ impl Search { version, busy, jobs, + #[cfg(target_arch = "wasm32")] + indexing, found: Vec::new(), found_for: None, selected: None, @@ -166,13 +181,28 @@ impl Search { /// Asks the index to read `spaces` of the open section again, as edits or another /// writer changed them, and its page list. pub fn changed(&self, key: String, replica: &Arc, spaces: Vec) { - let _ = self.jobs.send(Job::Pages { + self.send(Job::Pages { key, replica: Arc::downgrade(replica), spaces, }); } + fn send(&self, job: Job) { + let _ = self.jobs.send(job); + #[cfg(target_arch = "wasm32")] + { + let indexing = Arc::clone(&self.indexing); + crate::spawn(move || { + if let Ok(mut indexing) = indexing.lock() { + while let Ok(first) = indexing.jobs.try_recv() { + indexing.answer(first); + } + } + }); + } + } + /// Pages of section `key` among the results, which the page list marks. pub fn found_in(&self, key: &str) -> HashSet { if self.finding || self.query.trim().is_empty() { @@ -195,31 +225,45 @@ impl Search { type Stamp = Option<(u64, SystemTime)>; fn stamp(file: &Path) -> Stamp { - let metadata = std::fs::metadata(file).ok()?; + let metadata = notebook::fs::metadata(file).ok()?; Some((metadata.len(), metadata.modified().ok()?)) } /// Now in Time32, seconds since 1980, as page modification times are kept. pub(crate) fn now() -> u64 { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map_or(0, |since| since.as_secs().saturating_sub(315_532_800)) } -/// The index thread: answers jobs, the newest notebooks first, then page changes, and -/// wakes the window once the index changed. -fn run( +/// The index thread's jobs and what it keeps between them. +struct Indexing { jobs: mpsc::Receiver, - (index, version, busy): (Arc>, Arc, Arc), + shared: (Arc>, Arc, Arc), redraw: std::task::Waker, -) { - let mut stamps: HashMap = HashMap::new(); - while let Ok(first) = jobs.recv() { + stamps: HashMap, +} + +impl Indexing { + /// The index thread: answers jobs, gathering those typing sends a keystroke apart. + #[cfg(not(target_arch = "wasm32"))] + fn run(mut self) { + while let Ok(first) = self.jobs.recv() { + self.shared.2.store(true, Ordering::Relaxed); + // Typing sends a job a keystroke; a pause gathers them into one read. + if matches!(first, Job::Pages { .. }) { + std::thread::sleep(SETTLE); + } + self.answer(first); + } + } + + /// Answers `first` and the jobs waiting behind it, the newest notebooks first, then page + /// changes, and wakes the window once the index changed. + fn answer(&mut self, first: Job) { + let (index, version, busy) = &self.shared; + let (jobs, stamps) = (&self.jobs, &mut self.stamps); busy.store(true, Ordering::Relaxed); - // Typing sends a job a keystroke; a pause gathers them into one read. - if matches!(first, Job::Pages { .. }) { - std::thread::sleep(SETTLE); - } let mut notebooks = None; let mut changed = HashSet::new(); let mut pages: HashMap, HashSet)> = HashMap::new(); @@ -246,25 +290,18 @@ fn run( } let start = Instant::now(); if let Some((libraries, open)) = notebooks { - sync( - &index, - &version, - &mut stamps, - &libraries, - open.as_ref(), - &changed, - ); + sync(index, version, stamps, &libraries, open.as_ref(), &changed); lap("index notebooks", start); } for (key, (replica, spaces)) in pages { - if let Err(error) = reread(&index, &key, &replica, &spaces) { + if let Err(error) = reread(index, &key, &replica, &spaces) { eprintln!("Cannot index the open section: {error}"); } version.fetch_add(1, Ordering::Relaxed); } lap("index", start); busy.store(false, Ordering::Relaxed); - redraw.wake_by_ref(); + self.redraw.wake_by_ref(); } } @@ -329,7 +366,7 @@ fn sync( let read = || -> Result, Box> { let bytes = match notebook { Some(notebook) => notebook.read_section(&path)?, - None => onestore::read_file(&file)?, + None => notebook::fs::read_file(&file)?, }; let stored = match library.unlocked(&path) { Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?, @@ -494,7 +531,7 @@ impl State { return; } self.search.synced = identity; - let _ = self.search.jobs.send(Job::Notebooks { + self.search.send(Job::Notebooks { libraries: self.notebooks.clone(), open, changed, diff --git a/crates/snowbound/src/server.rs b/crates/snowbound/src/server.rs index 3b8deb5eac464120ea22e55909cb097fa6bbf7f5..520b44a6e231999ec867957af8a5129257b39b5c 100644 --- a/crates/snowbound/src/server.rs +++ b/crates/snowbound/src/server.rs @@ -3,9 +3,12 @@ //! where the system's file sharing can't reach the server. use crate::library::{Library, Login, Mount}; +#[cfg(target_arch = "wasm32")] +use crate::platform::smb::{self, Client, Credentials, DirectoryEntry, Refusal}; use crate::{State, art, platform}; use accesskit::Role; -use notebook::smb::{Client, Credentials, DirectoryEntry, Refusal}; +#[cfg(not(target_arch = "wasm32"))] +use notebook::smb::{self, Client, Credentials, DirectoryEntry, Refusal}; use std::{io, path::Path, sync::mpsc, time::Duration}; use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px}; use winit::keyboard::NamedKey; @@ -98,7 +101,7 @@ fn credentials(login: &Login) -> Credentials<'_> { impl Reach for Embedded { fn shares(&self, mount: &Mount, login: &Login) -> io::Result> { - notebook::smb::shares(&mount.endpoint(), credentials(login), TIMEOUT) + smb::shares(&mount.endpoint(), credentials(login), TIMEOUT) } fn list(&self, mount: &Mount, login: &Login) -> io::Result> { @@ -512,7 +515,7 @@ impl State { return; }; let (replies, redraw) = (connect.replies.0.clone(), self.redraw.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let _ = replies.send(request.run(&Embedded)); redraw.wake(); }); diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 081271a6535c540fce6772fe2c3585630688605a..569cd42bed2fe5b6812132af90cd4e9785c81d0f 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -126,7 +126,7 @@ impl ColorScheme { impl Settings { /// The settings at `path`; missing or unreadable ones are the defaults, reported. pub fn load(path: &Path) -> Self { - match std::fs::read(path) { + match notebook::fs::read(path) { Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| { eprintln!("Ignoring the settings in {}: {error}", path.display()); Self::default() @@ -142,11 +142,11 @@ impl Settings { /// Replaces the file at `path` whole, so a crash leaves the old settings or the new. pub fn save(&self, path: &Path) -> std::io::Result<()> { if let Some(folder) = path.parent() { - std::fs::create_dir_all(folder)?; + notebook::fs::create_dir_all(folder)?; } let partial = path.with_extension("partial"); - std::fs::write(&partial, serde_json::to_vec_pretty(self)?)?; - std::fs::rename(partial, path) + notebook::fs::write(&partial, serde_json::to_vec_pretty(self)?)?; + notebook::fs::rename(partial, path) } } @@ -277,7 +277,7 @@ mod tests { }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); - std::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap(); + notebook::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap(); assert_eq!( Settings::load(&path), Settings { @@ -286,10 +286,10 @@ mod tests { } ); // A picked "No color" stays picked; what was never picked keeps Office's default. - std::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap(); + notebook::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap(); let toolbar = Settings::load(&path).toolbar; assert_eq!(toolbar.highlight, None); assert_eq!(toolbar.font_color, Toolbar::default().font_color); - std::fs::remove_dir_all(&directory).unwrap(); + notebook::fs::remove_dir_all(&directory).unwrap(); } } diff --git a/crates/snowbound/src/spell_web.rs b/crates/snowbound/src/spell_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..d2cb30da44c38cb94516abb5682def02ebc90572 --- /dev/null +++ b/crates/snowbound/src/spell_web.rs @@ -0,0 +1,8 @@ +//! Spelling in the browser, which offers pages no checker to ask: words stay unmarked until +//! Hunspell dictionaries load in wasm. + +use canvas::spelling::Dictionary; + +pub fn dictionary() -> Option> { + None +} diff --git a/crates/snowbound/src/surface.rs b/crates/snowbound/src/surface.rs index 8ea91147a806353a77e589209d03fbb35eeff3cf..bf07c045b5b273c71ef0851c99da37794a54b570 100644 --- a/crates/snowbound/src/surface.rs +++ b/crates/snowbound/src/surface.rs @@ -1,9 +1,8 @@ //! Frames reach the window through wgpu. -use crate::{platform, trace_input}; +use crate::{Window, platform, trace_input}; use draw::Renderer; use std::{error::Error, sync::Arc}; -use winit::window::Window; pub struct Surface { /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. @@ -14,6 +13,9 @@ pub struct Surface { instance: wgpu::Instance, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, + /// The sRGB view of the surface's format, which the renderer blends in, where the surface + /// itself is not sRGB, as a browser's canvas is not. + view_format: wgpu::TextureFormat, /// Carries frames to the window where the system's backdrop shows through the strip. translucent: Option, } @@ -36,6 +38,13 @@ impl Surface { window: Arc, backdrop: bool, ) -> Result<(Self, Renderer), Box> { + #[cfg(target_arch = "wasm32")] + let instance = { + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); + descriptor.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL; + wgpu::util::new_instance_with_webgpu_detection(descriptor).await + }; + #[cfg(not(target_arch = "wasm32"))] #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))] let mut descriptor = wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())); @@ -61,8 +70,9 @@ impl Surface { wgpu::Dx12SwapchainKind::DxgiFromVisual; } } + #[cfg(not(target_arch = "wasm32"))] let instance = wgpu::Instance::new(descriptor); - let surface = instance.create_surface(window.clone())?; + let surface = instance.create_surface(target(&window))?; platform::configure_presentation(&surface); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { @@ -70,7 +80,18 @@ impl Surface { ..Default::default() }) .await?; - let (device, queue) = adapter.request_device(&Default::default()).await?; + // A browser without WebGPU draws through WebGL2, whose limits are lower. + #[cfg(target_arch = "wasm32")] + let required_limits = + wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()); + #[cfg(not(target_arch = "wasm32"))] + let required_limits = wgpu::Limits::default(); + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + required_limits, + ..Default::default() + }) + .await?; let size = window.inner_size(); let mut config = surface .get_default_config(&adapter, size.width, size.height) @@ -92,9 +113,16 @@ impl Surface { if platform::cuts_corners() && alpha.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { config.alpha_mode = wgpu::CompositeAlphaMode::PreMultiplied; } + #[cfg(target_arch = "wasm32")] + let view_format = config.format.add_srgb_suffix(); + #[cfg(not(target_arch = "wasm32"))] + let view_format = config.format; + if view_format != config.format { + config.view_formats.push(view_format); + } surface.configure(&device, &config); eprintln!("Canvas GPU: {:?}", adapter.get_info()); - let renderer = Renderer::new(device.clone(), queue.clone(), config.format); + let renderer = Renderer::new(device.clone(), queue.clone(), view_format); Ok(( Self { size: [config.width, config.height], @@ -104,6 +132,7 @@ impl Surface { instance, surface, config, + view_format, translucent, }, renderer, @@ -134,7 +163,7 @@ impl Surface { return Ok(None); } wgpu::CurrentSurfaceTexture::Lost => { - self.surface = self.instance.create_surface(self.window.clone())?; + self.surface = self.instance.create_surface(target(&self.window))?; platform::configure_presentation(&self.surface); self.configure(renderer); self.window.request_redraw(); @@ -154,7 +183,10 @@ impl Surface { }; let target = match &mut self.translucent { Some(translucent) => translucent.target(&self.device, self.size), - None => texture.texture.create_view(&Default::default()), + None => texture.texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(self.view_format), + ..Default::default() + }), }; let target = target_of(target); Ok(Some(Frame { @@ -195,7 +227,7 @@ impl Surface { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: self.config.format, + format: self.view_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); @@ -235,7 +267,7 @@ impl Surface { timeout: Some(std::time::Duration::from_secs(5)), })?; let bgra = matches!( - self.config.format, + self.view_format, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb ); let mut pixels = Vec::with_capacity(size[0] as usize * size[1] as usize * 4); @@ -251,6 +283,17 @@ impl Surface { } } +/// What the surface presents to: the window, or in the browser the page's canvas. +fn target(window: &Arc) -> wgpu::SurfaceTarget<'static> { + #[cfg(target_arch = "wasm32")] + return { + let _ = window; + wgpu::SurfaceTarget::Canvas(platform::canvas()) + }; + #[cfg(not(target_arch = "wasm32"))] + window.clone().into() +} + /// `view` as what the renderer draws into, which is the view itself unless `draw` also /// paints through OpenGL, as on Windows. #[cfg_attr(not(windows), expect(clippy::useless_conversion))] diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index ead43de22709b19b0129ea75e550977b44fb3123..32b77cc537aed680af39796a6b5e8621e5cb89a3 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -1285,7 +1285,7 @@ mod tests { .flat_map(|line| &line[..size[0] as usize * 4]) .copied() .collect(); - let file = std::fs::File::create(path).unwrap(); + let file = notebook::fs::File::create(path).unwrap(); let mut encoder = png::Encoder::new(file, size[0], height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); @@ -1458,7 +1458,7 @@ mod tests { fn popup_renders_each_state() { let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); let mut gpu = output.as_ref().map(|output| { - std::fs::create_dir_all(output).unwrap(); + notebook::fs::create_dir_all(output).unwrap(); Gpu::new() }); let states = states(); @@ -1479,7 +1479,7 @@ mod tests { continue; }; let frames = output.join(format!("frames-{theme}")); - std::fs::create_dir_all(&frames).unwrap(); + notebook::fs::create_dir_all(&frames).unwrap(); let mut ui = ui(appearance); let mut now = Instant::now(); settle( @@ -1511,7 +1511,7 @@ mod tests { } // Work offline on, an edit waiting, then off: the edit sends and nothing moves. let frames = output.join(format!("toggle-{theme}")); - std::fs::create_dir_all(&frames).unwrap(); + notebook::fs::create_dir_all(&frames).unwrap(); let mut ui = self::ui(appearance); let mut now = Instant::now(); settle( diff --git a/crates/snowbound/src/tags.rs b/crates/snowbound/src/tags.rs index fe2b3e2745f8fcaf0500ddf8d622db711c92a9bd..13807c71965bb4039f01b8f1dffadda83b0928ac 100644 --- a/crates/snowbound/src/tags.rs +++ b/crates/snowbound/src/tags.rs @@ -104,7 +104,7 @@ fn pictures() -> Option { pub(crate) fn art_bytes(art: &str) -> Option> { match extra_art().iter().position(|name| name == art) { Some(extra) => Some(EXTRAS[extra].2.as_bytes().to_vec()), - None => std::fs::read(pictures()?.join(art)).ok(), + None => notebook::fs::read(pictures()?.join(art)).ok(), } } @@ -595,7 +595,7 @@ impl State { let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else { return; }; - let kept = std::fs::read(&path) + let kept = notebook::fs::read(&path) .ok() .and_then(|bytes| canvas::gpu::import_tag_art(&bytes)); let Some((bytes, extension)) = kept else { @@ -608,8 +608,8 @@ impl State { let written = pictures() .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound)) .and_then(|folder| { - std::fs::create_dir_all(&folder)?; - match std::fs::File::create_new(folder.join(&art)) { + notebook::fs::create_dir_all(&folder)?; + match notebook::fs::File::create_new(folder.join(&art)) { Ok(mut file) => std::io::Write::write_all(&mut file, &bytes), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), Err(error) => Err(error), @@ -1185,16 +1185,16 @@ mod tests { let written = section.image(); let temporary = std::env::temp_dir().join(format!("snowbound-custom-art-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let root = temporary.join("Custom Art"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(root.join("Custom art.one"), &written).unwrap(); + notebook::fs::create_dir_all(&root).unwrap(); + notebook::fs::write(root.join("Custom art.one"), &written).unwrap(); let file_id = Store::parse(&written).unwrap().header.file_id; let toc = onestore::create_table_of_contents( "Open Notebook.onetoc2", &[("Custom art.one", file_id)], ); - std::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap(); + notebook::fs::write(root.join("Open Notebook.onetoc2"), toc.unwrap()).unwrap(); let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")); let notebook = notebook.unwrap(); for tag in &list[..3] { @@ -1242,7 +1242,7 @@ mod tests { .unwrap(); assert!(status.success()); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } /// The gallery offers each symbol OneNote 2010's does once: every one MS-ONE lists but @@ -1267,8 +1267,8 @@ mod tests { let sources = canvas::gpu::art_sources(&mapping.art, bytes).unwrap(); art.map(&mapping.name, mapping.shape, &mapping.art, sources); } - std::fs::remove_dir_all(&cache).unwrap(); - let bytes = std::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap(); + notebook::fs::remove_dir_all(&cache).unwrap(); + let bytes = notebook::fs::read(corpus.join("cold/notebook/Custom art.one")).unwrap(); let store = Store::parse(&bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); diff --git a/crates/snowbound/src/themes.rs b/crates/snowbound/src/themes.rs index 94d5d6d81592bd3ae8b26e6d3e78290748afc973..ace5be3ce0b8e68da5730c8ca194fc4e49750b79 100644 --- a/crates/snowbound/src/themes.rs +++ b/crates/snowbound/src/themes.rs @@ -775,9 +775,9 @@ mod tests { fn themed_pages_hold_their_theme_under_onenote_s_names() { let temporary = std::env::temp_dir().join(format!("snowbound-styles-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temporary); + let _ = notebook::fs::remove_dir_all(&temporary); let root = temporary.join("Styles"); - std::fs::create_dir_all(&root).unwrap(); + notebook::fs::create_dir_all(&root).unwrap(); let mut engine = TextEngine::default(); let mut sections = Vec::new(); for theme in built_in() { @@ -880,7 +880,7 @@ mod tests { } assert_eq!(definition, other[&name], "{} to modern {name}", theme.name); } - std::fs::write(root.join(&file), &written).unwrap(); + notebook::fs::write(root.join(&file), &written).unwrap(); let file_id = Store::parse(&written).unwrap().header.file_id; sections.push((file, file_id, theme.id)); } @@ -889,7 +889,7 @@ mod tests { .map(|(file, id, _)| (file.as_str(), *id)) .collect(); let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &listed).unwrap(); - std::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); + notebook::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap(); let themes = notebook .save_themes(Themes { @@ -916,6 +916,6 @@ mod tests { .unwrap(); assert!(status.success()); } - std::fs::remove_dir_all(&temporary).unwrap(); + notebook::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/undo.rs b/crates/snowbound/src/undo.rs index 8e6f22084509255f261e4135b5e16945b5c3e219..04b842ee04abd1ffe30633d26d369262932a03ed 100644 --- a/crates/snowbound/src/undo.rs +++ b/crates/snowbound/src/undo.rs @@ -1124,8 +1124,8 @@ mod tests { fn new(name: &str, titles: &[&str]) -> Self { let folder = std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&folder); - std::fs::create_dir_all(&folder).unwrap(); + let _ = notebook::fs::remove_dir_all(&folder); + notebook::fs::create_dir_all(&folder).unwrap(); let root = folder.join("Notebook"); let mut notebook = Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()) @@ -1211,7 +1211,7 @@ mod tests { let file = self .folder .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one"); - std::fs::read(file).map_or_else( + notebook::fs::read(file).map_or_else( |_| Vec::new(), |bytes| { notebook::session::stored_pages(&bytes) @@ -1235,7 +1235,7 @@ mod tests { if let Some(section) = self.section.take() { let _ = section.close(); } - let _ = std::fs::remove_dir_all(&self.folder); + let _ = notebook::fs::remove_dir_all(&self.folder); } } @@ -1434,8 +1434,8 @@ mod tests { use crate::manage::Structure; let folder = std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&folder); - std::fs::create_dir_all(&folder).unwrap(); + let _ = notebook::fs::remove_dir_all(&folder); + notebook::fs::create_dir_all(&folder).unwrap(); let root = folder.join("Notebook"); let mut notebook = Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap(); @@ -1495,7 +1495,7 @@ mod tests { carry(&mut notebook, structure); notebook.rename("Kitchen.one", "Pantry").unwrap(); assert!(restructuring(&library(notebook), &undo.change).is_none()); - let _ = std::fs::remove_dir_all(&folder); + let _ = notebook::fs::remove_dir_all(&folder); } /// Every kind of step taken back and redone in one notebook, written as the app writes @@ -1596,13 +1596,13 @@ mod tests { } fn copy(from: &std::path::Path, to: &std::path::Path) { - std::fs::create_dir_all(to).unwrap(); - for entry in std::fs::read_dir(from).unwrap().flatten() { + notebook::fs::create_dir_all(to).unwrap(); + for entry in notebook::fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy(&entry.path(), &target); } else { - std::fs::copy(entry.path(), target).unwrap(); + notebook::fs::copy(entry.path(), target).unwrap(); } } } diff --git a/crates/snowbound/src/unpack.rs b/crates/snowbound/src/unpack.rs index c33cb6f78f47122ce367ca5fdc7dee4fc6d42f14..35cc0e059efc48494e2cb06423550f4a1799c383 100644 --- a/crates/snowbound/src/unpack.rs +++ b/crates/snowbound/src/unpack.rs @@ -189,7 +189,7 @@ impl State { if create && !valid { return platform::alert("Couldn't unpack the notebook", "Give the notebook a name."); } - if create && root.exists() { + if create && notebook::fs::metadata(&root).is_ok() { return platform::alert( "Couldn't unpack the notebook", &format!( @@ -212,11 +212,13 @@ impl State { /// `color` where one was picked, and opens it. fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option) { let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let unpacked = (|| -> Result> { - let files = notebook::package::read(&std::fs::read(&package)?, LIMIT)?; + let files = notebook::package::read(¬ebook::fs::read(&package)?, LIMIT)?; notebook::package::unpack(files, &root)?; - let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); + let location = notebook::fs::absolute(&root)? + .to_string_lossy() + .into_owned(); if let Some(color) = color { Notebook::open(&location, &cache)?.set_color(color)?; } diff --git a/crates/snowbound/src/unread.rs b/crates/snowbound/src/unread.rs index db7006f2c2b72b89e53b6c5feb698443073e9304..2b1043c8e9928a745c671561dfc42604b5a96131 100644 --- a/crates/snowbound/src/unread.rs +++ b/crates/snowbound/src/unread.rs @@ -51,8 +51,8 @@ fn hex(identity: [u8; 16]) -> String { } fn now() -> u32 { - let unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map_or(0, |since| since.as_secs()); u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX) } @@ -61,7 +61,7 @@ impl Reads { /// What the cache folder `cache` keeps. pub fn load(cache: &Path) -> Self { let file = cache.join("read.json"); - let reads: Self = (std::fs::read(&file).ok()) + let reads: Self = (notebook::fs::read(&file).ok()) .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default(); Self { file, ..reads } @@ -71,8 +71,8 @@ impl Reads { let partial = self.file.with_extension("partial"); let saved = serde_json::to_vec(self) .map_err(std::io::Error::from) - .and_then(|bytes| std::fs::write(&partial, bytes)) - .and_then(|()| std::fs::rename(&partial, &self.file)); + .and_then(|bytes| notebook::fs::write(&partial, bytes)) + .and_then(|()| notebook::fs::rename(&partial, &self.file)); if let Err(error) = saved { eprintln!("Keeping what was read failed: {error}"); } @@ -254,7 +254,7 @@ impl State { /// pages are read on a thread of their own, those changed since unread. pub(crate) fn sections_changed(&self, library: &Arc, paths: Vec) { let (library, proxy) = (Arc::clone(library), self.proxy.clone()); - std::thread::spawn(move || { + crate::spawn(move || { let Ok(Some(notebook)) = &library.notebook else { return; }; @@ -387,7 +387,7 @@ mod tests { #[test] fn pages_changed_elsewhere_stay_unread_until_read() { let folder = std::env::temp_dir().join(format!("snowbound-unread-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); + notebook::fs::create_dir_all(&folder).unwrap(); let (notebook, section) = ("/notebook", [3; 16]); let mut reads = Reads::load(&folder); let known = reads.section_mut(notebook, section).read; @@ -426,6 +426,6 @@ mod tests { ); reads.notebooks.get_mut(notebook).unwrap().hidden = true; assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); - std::fs::remove_dir_all(&folder).unwrap(); + notebook::fs::remove_dir_all(&folder).unwrap(); } } diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index cc0ffd2f091a9e1bcb3964ffb31e289aa4817032..f49c497d0c162513642771dec651baa29edfc6a2 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -2,11 +2,14 @@ //! `build.json` the release key signs; `latest.json` names each platform's newest build. A //! thread checks on launch and daily, downloads a newer build for this platform, verifies //! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits. -//! `tools/RELEASE.md` describes the publishing side. +//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload, +//! so nothing is fetched or installed there. +#![cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg(target_os = "linux")] use crate::loader::executable; -use crate::{State, UserEvent, platform}; +use crate::{EventLoopProxy, State, UserEvent, platform}; +#[cfg(not(target_arch = "wasm32"))] use ring::signature::{ED25519, UnparsedPublicKey}; use serde::Deserialize; use std::collections::HashMap; @@ -16,8 +19,9 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -use winit::event_loop::EventLoopProxy; +use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; /// Where the builds are published. const BASE: &str = "https://file.paperclover.net/shr/snowbound/"; @@ -206,6 +210,16 @@ fn unhex(text: &str) -> Option> { .flatten() } +#[cfg(target_arch = "wasm32")] +fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { + Err(BROWSER.to_owned()) +} + +/// Why the browser installs no builds: it loads the newest each time the page opens. +#[cfg(target_arch = "wasm32")] +const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; + +#[cfg(not(target_arch = "wasm32"))] fn verify(key: &[u8], message: &[u8], signature: &str) -> Result<(), String> { let signature = unhex(signature).ok_or("The signature isn’t hex")?; UnparsedPublicKey::new(&ED25519, key) @@ -271,9 +285,12 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri archive.size )); } - let digest = ring::digest::digest(&ring::digest::SHA256, bytes); - if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) { - return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); + #[cfg(not(target_arch = "wasm32"))] + { + let digest = ring::digest::digest(&ring::digest::SHA256, bytes); + if unhex(&archive.sha256).as_deref() != Some(digest.as_ref()) { + return Err(format!("{}’s SHA-256 doesn’t match", archive.file)); + } } verify(key, bytes, &archive.signature) } @@ -448,7 +465,13 @@ fn check( result.unwrap_or_else(Status::Failed) } +#[cfg(target_arch = "wasm32")] +fn download(_: &str, _: u64) -> Result, String> { + Err(BROWSER.to_owned()) +} + /// The `Fetch` the app uses. +#[cfg(not(target_arch = "wasm32"))] fn download(path: &str, limit: u64) -> Result, String> { let agent: ureq::Agent = ureq::Agent::config_builder() .timeout_global(Some(Duration::from_secs(10 * 60))) @@ -498,6 +521,12 @@ impl Updates { ..Shared::default() })); let key = unhex(KEY).expect("release-key.pub holds a key in hex"); + #[cfg(target_arch = "wasm32")] + let thread = { + let _ = (key, proxy); + std::thread::current() + }; + #[cfg(not(target_arch = "wasm32"))] let thread = { let shared = Arc::clone(&shared); std::thread::Builder::new() @@ -570,8 +599,13 @@ impl Updates { /// Checks now, and shows what the check finds when it finishes. pub fn check_now(&self) { - self.shared.lock().unwrap().asked = true; - self.thread.unpark(); + #[cfg(target_arch = "wasm32")] + platform::inform("Snowbound is up to date", BROWSER); + #[cfg(not(target_arch = "wasm32"))] + { + self.shared.lock().unwrap().asked = true; + self.thread.unpark(); + } } /// The staged build to swap in, once Restart to Update has quit the app. @@ -715,6 +749,11 @@ fn quits(pid: u32, patience: Duration) -> bool { true } +#[cfg(target_arch = "wasm32")] +fn quits(_: u32, _: Duration) -> bool { + false +} + #[cfg(windows)] fn quits(pid: u32, patience: Duration) -> bool { use windows_sys::Win32::{ diff --git a/crates/snowbound/src/watch_web.rs b/crates/snowbound/src/watch_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..b499b2971e4953a5520aee7ebbc139e14813fd99 --- /dev/null +++ b/crates/snowbound/src/watch_web.rs @@ -0,0 +1,13 @@ +//! The browser's notebooks change only through this page, so nothing reports their changes. + +use std::path::Path; + +pub struct Watch; + +pub fn watch(_: &Path, _: impl Fn(Vec) + Send + Sync + 'static) -> Option { + None +} + +pub fn on_this_computer(_: &Path) -> bool { + true +} diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs new file mode 100644 index 0000000000000000000000000000000000000000..88ad9b1f7286c72fe830a018cb71dcd2ed02fd05 --- /dev/null +++ b/crates/snowbound/src/web.rs @@ -0,0 +1,1126 @@ +//! The browser: one canvas, its input through `web/glue.js`, and notebooks in the files +//! `notebook::fs` keeps, which the glue loads from IndexedDB and writes back. The window and +//! event loop are stand-ins with the calls `State` makes of winit's; menus are the kit's own, +//! as on Linux; the browser's chords stay the browser's. See arc/platforms.md. + +use crate::{State, UserEvent, commands, page, settings}; +use canvas::date::DateField; +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + error::Error, + marker::PhantomData, + path::{Path, PathBuf}, + time::Duration, +}; +use ui::Ui; +use wasm_bindgen::prelude::*; +use web_time::Instant; +use winit::{ + dpi::{PhysicalPosition, PhysicalSize, Size}, + event::{Ime, MouseButton}, + event_loop::EventLoopClosed, + keyboard::{Key, ModifiersState, NamedKey}, + window::{CursorIcon, ResizeDirection, Theme, WindowAttributes}, +}; + +#[wasm_bindgen(module = "/web/glue.js")] +extern "C" { + fn attach(module: JsValue); + /// The files IndexedDB kept: `[path, bytes or null for a folder, modified]`. + #[wasm_bindgen(js_name = loadFiles, catch)] + async fn load_files() -> Result; + #[wasm_bindgen(js_name = requestFrame)] + fn request_frame(); + #[wasm_bindgen(js_name = wakeIn)] + fn wake_in(ms: f64); + #[wasm_bindgen(js_name = setCursor)] + fn set_cursor(name: &str); + #[wasm_bindgen(js_name = setTitle)] + fn set_title(title: &str); + #[wasm_bindgen(js_name = placeInput)] + fn place_input(x: f32, y: f32, height: f32, text: bool); + #[wasm_bindgen(js_name = writeClipboard)] + fn write_clipboard(text: &str); + /// Hands `bytes` to the browser to save as `name`, of MIME type `kind`. + pub fn download(name: &str, bytes: &[u8], kind: &str); + #[wasm_bindgen(js_name = pickFiles)] + fn pick_files(purpose: &str, accept: &str); + #[wasm_bindgen(js_name = dateText)] + fn date_strings(ms: f64) -> Vec; + #[wasm_bindgen(js_name = shortDate)] + fn short_date_string(ms: f64) -> String; + #[wasm_bindgen(js_name = askConfirm)] + fn ask_confirm(message: &str) -> bool; + #[wasm_bindgen(js_name = askText)] + fn ask_text(message: &str, value: &str) -> Option; + #[wasm_bindgen(js_name = tell)] + fn tell(message: &str); + #[wasm_bindgen(js_name = openLink)] + fn open_link(url: &str); + /// Writes changed files to IndexedDB: `[path, bytes or null for a folder or undefined + /// for a removal, modified]` triples. + #[wasm_bindgen(js_name = storeFiles)] + fn store_files(changes: js_sys::Array); +} + +/// Where the browser keeps its notebooks, which a first visit makes one in. +const NOTEBOOKS: &str = "/Notebooks"; +const CACHE: &str = "/Cache"; +/// The metric-compatible faces `index.html` fetched, which only this page load holds. +const FONTS: &str = "/Fonts"; +const SETTINGS: &str = "/Settings"; +/// Files chosen to insert, open or paste, kept where the page links them from. +const CHOSEN: &str = "/Chosen"; +/// How long changed files wait to be written out, so a burst of edits writes once. +const STORE_AFTER: Duration = Duration::from_millis(500); + +/// What the page's input brought, waiting for the next frame. +enum Input { + Ui(ui::Event), + /// Zooms the page by the factor about a point in the window. + Pinch(f32, [f32; 2]), + /// The canvas's device pixels and device pixels per CSS pixel. + Resize([u32; 2], f32), + Focus(bool), +} + +/// The browser window's state as the canvas reports it. +struct Host { + size: PhysicalSize, + ratio: f64, + focused: bool, + title: String, + theme: Theme, + redraw: bool, +} + +thread_local! { + static HOST: RefCell = const { + RefCell::new(Host { + size: PhysicalSize::new(1, 1), + ratio: 1.0, + focused: true, + title: String::new(), + theme: Theme::Light, + redraw: true, + }) + }; + static STATE: RefCell> = const { RefCell::new(None) }; + static INPUT: RefCell> = const { RefCell::new(Vec::new()) }; + static EVENTS: RefCell> = const { RefCell::new(VecDeque::new()) }; + /// Work deferred to after the frame, as threads run it elsewhere. + static LATER: RefCell>> = const { RefCell::new(Vec::new()) }; + /// Input waits a frame for what a chord opened. + static HOLDING: Cell = const { Cell::new(false) }; + /// Modifier bits as the page's input last reported them. + static HELD: Cell = const { Cell::new(0) }; + /// Whether Command, not Control, takes the editing chords. + static MAC: Cell = const { Cell::new(false) }; + static PASTED: RefCell = RefCell::new(Pasted::default()); + /// When changed files are next written out. + static STORE_DUE: Cell> = const { Cell::new(None) }; + static LANGUAGE: RefCell = const { RefCell::new(String::new()) }; +} + +/// The page's canvas, which frames are drawn into. +pub fn canvas() -> web_sys::HtmlCanvasElement { + use wasm_bindgen::JsCast; + web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id("page")) + .and_then(|canvas| canvas.dyn_into().ok()) + .expect("index.html has a canvas called page") +} + +fn host(act: impl FnOnce(&mut Host) -> T) -> T { + HOST.with_borrow_mut(act) +} + +fn report(error: impl std::fmt::Display) { + web_sys::console::error_1(&error.to_string().into()); +} + +/// Runs `work` once the frame under way is done, as a thread would run it beside the frame. +pub fn defer(work: impl FnOnce() + 'static) { + LATER.with_borrow_mut(|later| later.push(Box::new(work))); + wake_in(0.0); +} + +/// The event loop `State` opens its window in. +pub struct ActiveEventLoop; + +impl ActiveEventLoop { + pub fn create_window(&self, attributes: WindowAttributes) -> Result> { + let window = Window(()); + window.set_title(&attributes.title); + Ok(window) + } +} + +/// The page's canvas as a window. +pub struct Window(()); + +impl Window { + pub fn request_redraw(&self) { + host(|host| host.redraw = true); + request_frame(); + } + + pub fn scale_factor(&self) -> f64 { + host(|host| host.ratio) + } + + pub fn inner_size(&self) -> PhysicalSize { + host(|host| host.size) + } + + pub fn request_inner_size(&self, _: impl Into) -> Option> { + None + } + + pub fn title(&self) -> String { + host(|host| host.title.clone()) + } + + pub fn set_title(&self, title: &str) { + set_title(title); + host(|host| host.title = title.to_owned()); + } + + /// The colour scheme Options chooses is `State`'s; the page follows the browser's. + pub fn set_theme(&self, _: Option) {} + + pub fn has_focus(&self) -> bool { + host(|host| host.focused) + } + + pub fn focus_window(&self) {} + + pub fn set_visible(&self, _: bool) {} + + pub fn set_minimized(&self, _: bool) {} + + /// The browser moves and sizes its own window. + pub fn drag_window(&self) -> Result<(), ()> { + Err(()) + } + + pub fn drag_resize_window(&self, _: ResizeDirection) -> Result<(), ()> { + Err(()) + } + + pub fn set_cursor(&self, cursor: impl Into) { + set_cursor(match cursor.into() { + winit::window::Cursor::Icon(icon) => icon.name(), + winit::window::Cursor::Custom(_) => "default", + }); + } + + pub fn set_ime_allowed(&self, _: bool) {} + + /// Puts the text area at the caret, so an input method's candidates show beside it. + pub fn set_ime_cursor_area( + &self, + position: impl Into, + size: impl Into, + ) { + let ratio = self.scale_factor(); + let position: PhysicalPosition = position.into().to_physical(ratio); + let size: PhysicalSize = size.into().to_physical(ratio); + place_input( + (position.x / ratio) as f32, + (position.y / ratio) as f32, + ((size.height / ratio) as f32).max(1.0), + true, + ); + } + + pub fn pre_present_notify(&self) {} +} + +/// Sends events to the page's loop, from wherever `State` hands it. +pub struct EventLoopProxy(PhantomData); + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + Self(PhantomData) + } +} + +impl EventLoopProxy { + pub fn send_event(&self, event: UserEvent) -> Result<(), EventLoopClosed> { + EVENTS.with_borrow_mut(|events| events.push_back(event)); + request_frame(); + Ok(()) + } +} + +/// Assistive technology's view of the window, which a DOM mirror takes in a later phase; +/// until then no tree is asked for. +pub struct AccessAdapter; + +impl AccessAdapter { + pub fn with_event_loop_proxy( + _: &ActiveEventLoop, + _: &Window, + _: EventLoopProxy, + ) -> Self { + Self + } + + pub fn update_if_active(&mut self, _: impl FnOnce() -> accesskit::TreeUpdate) {} +} + +/// The browser's clipboard: text out through `navigator.clipboard`, and in through the page's +/// paste event, which the glue keeps here before the paste runs. +pub struct Clipboard; + +#[derive(Default)] +struct Pasted { + text: Option, + html: Option, + files: Vec, +} + +impl Clipboard { + pub fn new(_: &Window) -> Result> { + Ok(Self) + } + + pub fn set_text(&mut self, text: String) -> Result<(), Box> { + write_clipboard(&text); + Ok(()) + } + + pub fn get_text(&mut self) -> Result> { + PASTED + .with_borrow(|pasted| pasted.text.clone()) + .ok_or_else(|| "Nothing is on the clipboard".into()) + } + + pub fn get_files(&mut self) -> Vec { + PASTED.with_borrow(|pasted| pasted.files.clone()) + } + + pub fn get_html(&mut self) -> Option { + PASTED.with_borrow(|pasted| pasted.html.clone()) + } + + pub fn get_picture(&mut self) -> Option> { + None + } +} + +pub const LEADING: f32 = 8.0; +pub const TRAILING: f32 = 8.0; + +pub fn window_attributes() -> WindowAttributes { + WindowAttributes::default() +} + +pub fn install_title_bar(_: &Window) {} + +pub fn install_text_input(_: &Window) {} + +pub fn install_backdrop(_: &Window) -> bool { + false +} + +pub fn install_menu() {} + +pub fn update_menu(_: impl FnOnce() -> Vec) {} + +pub fn update_tag_menu(_: &[canvas::editor::NoteTag]) {} + +/// The browser draws the tab's title; the toolbar's row is the page's top. +pub fn system_titlebar(_: &Window) -> bool { + true +} + +pub fn represent(_: &Window, _: Option<&Path>) {} + +pub fn titlebar(_: Theme) -> Option<[[f32; 4]; 2]> { + None +} + +pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { + theme.over_backdrop() +} + +/// The kit's own menus. +pub fn menu(_: Theme) -> Option { + None +} + +pub fn follow_appearance(_: &Window, _: Theme) {} + +pub fn appearance(_: &Window) -> Theme { + host(|host| host.theme) +} + +pub fn corner_radius(_: &Window) -> f32 { + 0.0 +} + +pub fn cuts_corners() -> bool { + false +} + +pub fn window_controls(_: &mut Ui, _: &Window) {} + +pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option { + None +} + +pub fn set_min_size(_: &Window, _: [f32; 2]) {} + +pub fn zoom(_: &Window) {} + +pub fn move_cursor() -> CursorIcon { + CursorIcon::Move +} + +pub fn cover_border_line(_: &mut Ui, _: f32) {} + +/// The interface in Carlito as Calibri, the page's default face, as the browser has no +/// system font to offer. +pub fn system_interface(ui: &mut Ui) { + ui.set_system_font("Calibri"); +} + +pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} + +pub fn clear_marked_text(_: &Window) {} + +pub fn configure_presentation(_: &wgpu::Surface<'_>) {} + +pub fn commit_presentation(_: &Window) {} + +pub fn double_click_interval() -> Duration { + Duration::from_millis(500) +} + +/// Pages fall back through the fonts the browser module carries. +pub fn symbol_fonts() -> Vec { + Vec::new() +} + +pub fn file_icon(_: &Path) -> Option> { + None +} + +/// The browser has no file manager to show a file in. +pub const SHOW_FILE: &str = "Download"; + +/// Downloads `file`, the nearest the browser comes to showing it. +pub fn show_file(file: &Path) { + open_file(file); +} + +/// Downloads a copy of the file at `path` from the browser's files. +pub fn open_file(path: &Path) { + match notebook::fs::read(path) { + Ok(bytes) => download( + &crate::library::file_name(path), + &bytes, + "application/octet-stream", + ), + Err(error) => alert("Couldn't open the file", &error.to_string()), + } +} + +/// Opens a link's URL in a new tab; a folder of the browser's files has nothing to show. +pub fn reveal(target: impl AsRef) { + let target = target.as_ref().to_string_lossy(); + if target.contains("://") || target.starts_with("mailto:") { + open_link(&target); + } +} + +pub fn cache_dir() -> Option { + Some(CACHE.into()) +} + +pub fn settings_dir() -> Option { + Some(SETTINGS.into()) +} + +pub fn documents_dir() -> Option { + Some(NOTEBOOKS.into()) +} + +/// The browser names no user; edits are stored under the name Options sets. +pub fn user_name() -> String { + String::new() +} + +pub fn input_language() -> String { + LANGUAGE.with_borrow(Clone::clone) +} + +/// Milliseconds since 1970 at FILETIME `filetime`. +fn unix_ms(filetime: u64) -> f64 { + (filetime / 10_000) as f64 - 11_644_473_600_000.0 +} + +/// Seconds east of UTC at `unix` seconds since 1970, as the browser's zone has it. +pub fn utc_offset(unix: i64) -> i64 { + -(js_sys::Date::new(&(unix as f64 * 1e3).into()).get_timezone_offset() as i64) * 60 +} + +pub fn short_date(filetime: u64) -> String { + short_date_string(unix_ms(filetime)) +} + +pub fn date_text(filetime: u64) -> [String; 2] { + let mut text = date_strings(unix_ms(filetime)).into_iter(); + [ + text.next().unwrap_or_default(), + text.next().unwrap_or_default(), + ] +} + +/// Asks for the page's date as `YYYY-MM-DD`, or its time as `HH:MM`. +pub fn edit_date( + timestamp: u64, + field: DateField, + title: &str, +) -> Result, &'static str> { + let ms = unix_ms(timestamp); + let date = js_sys::Date::new(&ms.into()); + let current = match field { + DateField::Date => format!( + "{:04}-{:02}-{:02}", + date.get_full_year(), + date.get_month() + 1, + date.get_date() + ), + DateField::Time => format!("{:02}:{:02}", date.get_hours(), date.get_minutes()), + }; + let Some(answer) = ask_text(title, ¤t) else { + return Ok(None); + }; + let numbers: Vec = answer + .split(|c: char| !c.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .collect(); + match (field, numbers.as_slice()) { + (DateField::Date, &[year, month, day]) + if (1..=12).contains(&month) && (1..=31).contains(&day) => + { + date.set_full_year_with_month_date(year, month as i32 - 1, day as i32); + } + (DateField::Time, &[hour, minute]) if hour < 24 && minute < 60 => { + date.set_hours(hour); + date.set_minutes(minute); + } + _ => return Err(crate::DATE_UNCHOSEN), + } + let seconds = (date.get_time() / 1e3).floor(); + let updated = ((seconds as i64 + 11_644_473_600) as u64) + .checked_mul(10_000_000) + .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000)) + .ok_or(crate::DATE_OUT_OF_RANGE)?; + Ok(Some((updated, date_text(updated)))) +} + +/// Asks for a file to insert; once chosen it goes to the caret, as a dropped file does. +pub fn pick_file(_: &str, types: &[&str]) -> Option { + let accept: Vec = types.iter().map(|kind| format!(".{kind}")).collect(); + pick_files("place", &accept.join(",")); + None +} + +/// Asks for notebooks, sections or packages to open; they open once read. +pub fn pick_notebook(_: &str) -> Option { + pick_files("open", ".one,.onetoc2,.onepkg"); + None +} + +/// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else +/// asked a place goes there too, then downloads. +pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>) -> Option { + let name = ask_text(title, name)?; + let name = name.trim().replace(['/', '\\'], " "); + let folder = Path::new(NOTEBOOKS); + let _ = notebook::fs::create_dir_all(folder); + let mut path = folder.join(&name); + let mut number = 2; + while notebook::fs::metadata(&path).is_ok() { + path = folder.join(format!("{name} {number}")); + number += 1; + } + Some(path) +} + +pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool { + ask_confirm(&format!("{message}\n\n{detail}")) +} + +pub fn alert(message: &str, detail: &str) { + tell(&format!("{message}\n\n{detail}")); +} + +pub fn inform(message: &str, detail: &str) { + alert(message, detail); +} + +/// Servers are reached through a relay in a later phase; none is mounted here. +pub fn smb_mount(_: &Path) -> Option { + None +} + +pub fn smb_login(_: &crate::library::Mount) -> Result { + Err("Servers aren't reachable from the browser yet.".into()) +} + +pub fn remember_label() -> Option<&'static str> { + None +} + +pub fn save_login(_: &crate::library::Mount, _: &crate::library::Login) -> Result<(), String> { + Err("The browser keeps no passwords.".into()) +} + +/// The embedded SMB client's face, until a WebSocket relay carries SMB to the browser: +/// every server is out of reach. +pub mod smb { + use std::{convert::Infallible, fmt, io, time::Duration}; + + fn unreachable() -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, Refusal::Unreachable) + } + + #[derive(Default)] + pub struct Credentials<'a> { + pub username: &'a str, + pub password: &'a str, + pub domain: &'a str, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct DirectoryEntry { + pub name: String, + pub size: u64, + pub modified: u64, + pub attributes: u32, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Refusal { + Unreachable, + Smb1, + SignIn, + NoShare, + Denied, + NoFolder, + Other, + } + + impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Servers aren't reachable from the browser yet") + } + } + + impl std::error::Error for Refusal {} + + impl Refusal { + pub fn of(_: &io::Error) -> Self { + Self::Unreachable + } + } + + pub struct Client(Infallible); + + impl Client { + pub fn connect(_: &str, _: &str, _: Credentials, _: Duration) -> io::Result { + Err(unreachable()) + } + + pub fn read_dir(&self, _: &str, _: usize) -> io::Result> { + match self.0 {} + } + } + + pub fn shares(_: &str, _: Credentials, _: Duration) -> io::Result> { + Err(unreachable()) + } +} + +/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts`. `module` is +/// the module's own exports, which the glue calls. +#[wasm_bindgen] +pub async fn start(module: JsValue, fonts: Vec) -> Result<(), JsValue> { + std::panic::set_hook(Box::new(|info| report(info))); + let window = web_sys::window().ok_or("No window")?; + let navigator = window.navigator(); + MAC.set(navigator.platform().is_ok_and(|platform| { + ["Mac", "iPhone", "iPad"] + .iter() + .any(|name| platform.contains(name)) + })); + LANGUAGE.with_borrow_mut(|language| *language = navigator.language().unwrap_or_default()); + let dark = window + .match_media("(prefers-color-scheme: dark)") + .ok() + .flatten() + .is_some_and(|query| query.matches()); + let ratio = window.device_pixel_ratio(); + let canvas = canvas(); + host(|host| { + host.theme = if dark { Theme::Dark } else { Theme::Light }; + host.size = PhysicalSize::new( + ((f64::from(canvas.client_width()) * ratio) as u32).max(1), + ((f64::from(canvas.client_height()) * ratio) as u32).max(1), + ); + host.ratio = ratio; + }); + canvas.set_width(host(|host| host.size.width)); + canvas.set_height(host(|host| host.size.height)); + restore(load_files().await?); + let state = open(fonts) + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + STATE.with_borrow_mut(|slot| *slot = Some(state)); + attach(module); + request_frame(); + Ok(()) +} + +/// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`. +fn restore(files: js_sys::Array) { + for entry in files.iter() { + let entry = js_sys::Array::from(&entry); + let Some(path) = entry.get(0).as_string() else { + continue; + }; + let bytes = entry.get(1); + let saved = if bytes.is_null() { + notebook::fs::Saved::Directory + } else { + notebook::fs::Saved::File( + js_sys::Uint8Array::new(&bytes).to_vec(), + entry.get(2).as_f64().unwrap_or(0.0), + ) + }; + notebook::fs::restore(path, saved); + } +} + +/// Writes the files changed since the last time out to IndexedDB. +fn store() { + STORE_DUE.set(None); + let changes = js_sys::Array::new(); + for (path, saved) in notebook::fs::changes() { + let path = JsValue::from_str(&path.to_string_lossy()); + let entry = match saved { + None => js_sys::Array::of1(&path), + Some(notebook::fs::Saved::Directory) => js_sys::Array::of2(&path, &JsValue::NULL), + Some(notebook::fs::Saved::File(bytes, modified)) => js_sys::Array::of3( + &path, + &js_sys::Uint8Array::from(bytes.as_slice()), + &modified.into(), + ), + }; + changes.push(&entry); + } + if changes.length() > 0 { + store_files(changes); + } +} + +/// The window, as `launch` opens it with the notebooks the settings list, making the +/// browser's first notebook on a first visit. +async fn open(fonts: Vec) -> Result> { + let cache = PathBuf::from(CACHE); + for folder in [NOTEBOOKS, CACHE, SETTINGS, CHOSEN] { + notebook::fs::create_dir_all(folder)?; + } + notebook::fs::restore(FONTS, notebook::fs::Saved::Directory); + let file = settings::default_path(); + let saved = file + .as_deref() + .map(settings::Settings::load) + .unwrap_or_default(); + let first = saved.notebooks.is_empty(); + let input = crate::Input::Notebooks { + locations: saved.notebooks.clone(), + current: saved.current.clone(), + }; + let launch = settings::Launch { file, saved, cache }; + // Where `State` reads them from, kept out of what IndexedDB stores. + let substitutes: Vec = fonts + .iter() + .enumerate() + .map(|(number, font)| { + let path = PathBuf::from(format!("{FONTS}/{number}.ttf")); + notebook::fs::restore(&path, notebook::fs::Saved::File(font.to_vec(), 0.0)); + path + }) + .collect(); + let mut state = State::new( + &ActiveEventLoop, + EventLoopProxy(PhantomData), + input, + &substitutes, + true, + launch, + ) + .await?; + // The first web build's sections, moved by the glue, or else a notebook of the browser's own. + if first { + let [moved, own] = + ["Web Notebook", "My Notebook"].map(|name| Path::new(NOTEBOOKS).join(name)); + match [&moved, &own] + .into_iter() + .find(|root| notebook::fs::metadata(root).is_ok()) + { + Some(root) => state.open_path(root), + None => state.create_notebook(own.clone())?, + } + } + Ok(state) +} + +fn queue(input: Input) { + INPUT.with_borrow_mut(|queue| queue.push(input)); + request_frame(); +} + +/// Whether the page takes the key `key` with modifier bits `held` (Shift, Control, Alt, Meta +/// from the lowest), rather than leaving it to the browser: the browser keeps its own tab +/// and window chords, and pastes through its paste event. +#[wasm_bindgen] +pub fn takes(key: &str, held: u8) -> bool { + let command = held & if MAC.get() { 8 } else { 2 } != 0; + if !command { + return true; + } + !matches!( + key.to_ascii_lowercase().as_str(), + "v" | "w" | "t" | "n" | "q" | "r" | "l" | "tab" + ) +} + +/// Reports modifier bits `held` where they changed. +fn follow_modifiers(held: u8) { + if HELD.replace(held) == held { + return; + } + let [shift, control, alt, meta] = [1, 2, 4, 8].map(|bit| held & bit != 0); + let mac = MAC.get(); + let mut state = ModifiersState::empty(); + state.set(ModifiersState::SHIFT, shift); + state.set(ModifiersState::ALT, alt); + // The command table's chords are the PC's here; on a Mac, Command is Control. + state.set(ModifiersState::CONTROL, if mac { meta } else { control }); + state.set(ModifiersState::SUPER, if mac { control } else { meta }); + queue(Input::Ui(ui::Event::Modifiers(state))); +} + +/// Pointer `kind` (moved, pressed, released, left) at `x`, `y` in CSS pixels. +#[wasm_bindgen] +pub fn pointer(kind: u8, x: f32, y: f32, button: i16, pressure: f32, held: u8) { + follow_modifiers(held); + queue(Input::Ui(ui::Event::Pressure( + (!pressure.is_nan()).then_some(pressure), + ))); + let button = match button { + 1 => MouseButton::Middle, + 2 => MouseButton::Right, + _ => MouseButton::Left, + }; + queue(Input::Ui(match kind { + 0 => ui::Event::PointerMoved([x, y]), + 1 | 2 => ui::Event::Button { + button, + pressed: kind == 1, + at: Instant::now(), + }, + _ => ui::Event::PointerLeft, + })); +} + +#[wasm_bindgen] +pub fn key(key: &str, text: Option, held: u8) { + follow_modifiers(held); + queue(Input::Ui(ui::Event::Key { + key: logical_key(key), + text, + })); +} + +#[wasm_bindgen] +pub fn modifiers(held: u8) { + follow_modifiers(held); +} + +#[wasm_bindgen] +pub fn compose(text: String) { + let end = text.len(); + queue(Input::Ui(ui::Event::Ime(Ime::Preedit( + text, + Some((end, end)), + )))); +} + +#[wasm_bindgen] +pub fn commit(text: String) { + queue(Input::Ui(ui::Event::Ime(Ime::Commit(text)))); +} + +/// The page's paste event: its text, HTML and files, kept for the paste it runs. +#[wasm_bindgen] +pub fn paste(text: Option, html: Option, files: js_sys::Array) { + let files = keep(files, CHOSEN); + PASTED.with_borrow_mut(|pasted| *pasted = Pasted { text, html, files }); + send(UserEvent::Choose(commands::Choice::Command( + commands::Id::Paste, + ))); +} + +#[wasm_bindgen] +pub fn wheel(x: f32, y: f32) { + queue(Input::Ui(ui::Event::Wheel([x, y]))); +} + +#[wasm_bindgen] +pub fn pinch(factor: f32, x: f32, y: f32) { + queue(Input::Pinch(factor, [x, y])); +} + +#[wasm_bindgen] +pub fn resize(width: u32, height: u32, ratio: f32) { + queue(Input::Resize([width, height], ratio)); +} + +#[wasm_bindgen] +pub fn focus(focused: bool) { + queue(Input::Focus(focused)); +} + +#[wasm_bindgen] +pub fn appearance_changed(dark: bool) { + host(|host| host.theme = if dark { Theme::Dark } else { Theme::Light }); + send(UserEvent::Appearance); +} + +/// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as +/// notebooks, sections or packages, `place` puts them at the caret. +#[wasm_bindgen] +pub fn files(purpose: &str, files: js_sys::Array) { + match purpose { + "open" => { + let paths = keep(files, NOTEBOOKS); + send(UserEvent::Open(paths)); + } + _ => { + for path in keep(files, CHOSEN) { + send(UserEvent::Then(Box::new(move |state| { + state.place_file(&path, None) + }))); + } + } + } +} + +/// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths. +fn keep(files: js_sys::Array, folder: &str) -> Vec { + files + .iter() + .filter_map(|pair| { + let pair = js_sys::Array::from(&pair); + let name = pair.get(0).as_string()?; + let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec(); + let name = Path::new(&name).file_name()?.to_owned(); + let mut path = Path::new(folder).join(&name); + let (stem, extension) = ( + Path::new(&name).file_stem()?.to_string_lossy().into_owned(), + Path::new(&name) + .extension() + .map(|extension| format!(".{}", extension.to_string_lossy())) + .unwrap_or_default(), + ); + let mut number = 2; + while notebook::fs::metadata(&path).is_ok() { + path = Path::new(folder).join(format!("{stem} {number}{extension}")); + number += 1; + } + notebook::fs::write(&path, bytes) + .inspect_err(|error| report(error)) + .ok()?; + Some(path) + }) + .collect() +} + +fn send(event: UserEvent) { + EVENTS.with_borrow_mut(|events| events.push_back(event)); + request_frame(); +} + +/// Writes every changed file out now, as the page goes away. +#[wasm_bindgen] +pub fn flush() { + STATE.with(|state| { + if let Ok(mut state) = state.try_borrow_mut() + && let Some(state) = state.as_mut() + && let Err(error) = state.persist() + { + report(error); + } + }); + store(); +} + +#[wasm_bindgen] +pub fn frame() { + STATE.with(|state| { + let Ok(mut state) = state.try_borrow_mut() else { + return request_frame(); + }; + let Some(state) = state.as_mut() else { + return; + }; + if let Err(error) = turn(state) { + report(error); + } + }); + for work in LATER.take() { + work(); + } +} + +/// One turn of the loop: the page's input, the events sent to the loop, a frame where one +/// was asked for, then when to come back. +fn turn(state: &mut State) -> Result<(), Box> { + // Input after a chord waits for the frame that runs it and the one that builds what it + // opened, as the palette takes the keys typed next. + let mut inputs = match HOLDING.take() { + true => Vec::new(), + false => INPUT.take(), + } + .into_iter(); + while state.commands.is_empty() + && let Some(input) = inputs.next() + { + match input { + Input::Ui(event) => state.input(event), + Input::Pinch(factor, point) => { + state.input(ui::Event::PointerMoved(point)); + state.pinch(factor)?; + } + Input::Resize(size, ratio) => { + let ratio = f64::from(ratio); + let changed = host(|host| { + let changed = host.ratio != ratio; + host.size = PhysicalSize::new(size[0].max(1), size[1].max(1)); + host.ratio = ratio; + changed + }); + if changed { + state.renderer.clear_glyph_cache(); + } + state.surface.size = size.map(|side| side.max(1)); + state.surface.configure(&state.renderer); + host(|host| host.redraw = true); + } + Input::Focus(focused) => { + state.ui.window_focused = focused; + host(|host| host.focused = focused); + if !focused { + state.publish_now(Duration::ZERO); + } + host(|host| host.redraw = true); + } + } + } + let waiting: Vec = inputs.collect(); + if !waiting.is_empty() { + INPUT.with_borrow_mut(|queue| queue.splice(0..0, waiting).for_each(drop)); + HOLDING.set(true); + } + if HOLDING.get() || !INPUT.with_borrow(Vec::is_empty) { + host(|host| host.redraw = true); + request_frame(); + } + while let Some(event) = EVENTS.with_borrow_mut(VecDeque::pop_front) { + state.user_event(event); + } + if host(|host| std::mem::take(&mut host.redraw)) { + state.frame()?; + } + park_input(state); + let now = Instant::now(); + let (repaint, blink) = state.view.blink(now); + let wake = state.ui.wake_at(); + if repaint || wake.is_some_and(|wake| wake <= now) { + state.window.request_redraw(); + } + if STORE_DUE.get().is_none() && notebook::fs::changed() { + STORE_DUE.set(Some(now + STORE_AFTER)); + } + if STORE_DUE.get().is_some_and(|due| due <= now) { + store(); + } + let next = [blink, wake.filter(|wake| *wake > now), STORE_DUE.get()] + .into_iter() + .flatten() + .min(); + if let Some(next) = next { + wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3); + } + Ok(()) +} + +/// Parks the text area while the page takes no text, keeping it writable for the +/// interface's fields. +fn park_input(state: &State) { + if state.ui.focused() != Some(page()) || !state.view.accepts_text() { + place_input(0.0, 0.0, 1.0, state.ui.focused().is_some()); + } +} + +/// The `KeyboardEvent.key` value `key` as winit names it. +fn logical_key(key: &str) -> Key { + let named = match key { + "Enter" => NamedKey::Enter, + "Tab" => NamedKey::Tab, + " " => NamedKey::Space, + "Backspace" => NamedKey::Backspace, + "Delete" => NamedKey::Delete, + "Escape" => NamedKey::Escape, + "ArrowLeft" => NamedKey::ArrowLeft, + "ArrowRight" => NamedKey::ArrowRight, + "ArrowUp" => NamedKey::ArrowUp, + "ArrowDown" => NamedKey::ArrowDown, + "Home" => NamedKey::Home, + "End" => NamedKey::End, + "PageUp" => NamedKey::PageUp, + "PageDown" => NamedKey::PageDown, + "Insert" => NamedKey::Insert, + "Shift" => NamedKey::Shift, + "Control" => NamedKey::Control, + "Alt" => NamedKey::Alt, + "AltGraph" => NamedKey::AltGraph, + "Meta" => NamedKey::Meta, + "ContextMenu" => NamedKey::ContextMenu, + "F1" => NamedKey::F1, + "F2" => NamedKey::F2, + "F3" => NamedKey::F3, + "F4" => NamedKey::F4, + "F5" => NamedKey::F5, + "F6" => NamedKey::F6, + "F7" => NamedKey::F7, + "F8" => NamedKey::F8, + "F9" => NamedKey::F9, + "F10" => NamedKey::F10, + "F11" => NamedKey::F11, + "F12" => NamedKey::F12, + _ => return Key::Character(key.into()), + }; + Key::Named(named) +} diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js new file mode 100644 index 0000000000000000000000000000000000000000..39922340f7521d04e2887f6bf4e1062f345b30ab --- /dev/null +++ b/crates/snowbound/web/glue.js @@ -0,0 +1,342 @@ +// The browser's half of Snowbound (src/web.rs): the page's input, its dialogs and files, and +// IndexedDB, which keeps the files `notebook::fs` holds between visits. + +let wasm; +let canvas; +let input; +let picker; +let framePending = false; +let wakeTimer; + +const DB = "snowbound"; +// `files` holds every path: `{path, bytes}`, `bytes` null for a folder. `sections` is the first +// web build's store, whose sections move into a notebook once. +const FILES = "files"; +const SECTIONS = "sections"; +const MIGRATED = "/Notebooks/Web Notebook"; + +function database() { + return new Promise((resolve, reject) => { + const open = indexedDB.open(DB, 2); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains(FILES)) db.createObjectStore(FILES, { keyPath: "path" }); + if (!db.objectStoreNames.contains(SECTIONS)) + db.createObjectStore(SECTIONS, { keyPath: "file" }); + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); +} + +function done(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + +/** Every file kept, as `[path, bytes or null, modified]`, the first web build's sections moved + * into a notebook of their own first. */ +export async function loadFiles() { + const db = await database(); + const read = db.transaction([FILES, SECTIONS], "readonly"); + const files = read.objectStore(FILES).getAll(); + const sections = read.objectStore(SECTIONS).getAll(); + await done(read); + let rows = files.result; + if (!rows.length && sections.result.length) { + const moved = [ + { path: "/Notebooks", bytes: null }, + { path: MIGRATED, bytes: null }, + ...sections.result + .sort((a, b) => a.order - b.order) + .map(({ file, bytes }) => ({ path: `${MIGRATED}/${file}`, bytes, modified: Date.now() })), + ]; + const write = db.transaction([FILES, SECTIONS], "readwrite"); + for (const row of moved) write.objectStore(FILES).put(row); + write.objectStore(SECTIONS).clear(); + await done(write); + rows = moved; + } + return rows.map(({ path, bytes, modified }) => [path, bytes, modified ?? 0]); +} + +/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, bytes, modified]` entries. */ +export function storeFiles(changes) { + database() + .then((db) => { + const transaction = db.transaction(FILES, "readwrite"); + const store = transaction.objectStore(FILES); + for (const [path, bytes, modified] of changes) { + if (bytes === undefined) store.delete(path); + else store.put({ path, bytes, modified }); + } + return done(transaction); + }) + .catch((error) => console.error("Keeping files", error)); +} + +export function requestFrame() { + if (!framePending) { + framePending = true; + requestAnimationFrame(() => { + framePending = false; + wasm.frame(); + }); + } +} + +export function wakeIn(ms) { + clearTimeout(wakeTimer); + wakeTimer = setTimeout(requestFrame, Math.max(0, ms)); +} + +export function setCursor(name) { + if (canvas.style.cursor !== name) canvas.style.cursor = name; +} + +export function setTitle(title) { + document.title = title; +} + +/** Puts the text area at the caret, so an input method's candidates show beside it. */ +export function placeInput(x, y, height, text) { + input.style.left = `${x}px`; + input.style.top = `${y}px`; + input.style.height = `${height}px`; + input.readOnly = !text; +} + +export function writeClipboard(text) { + navigator.clipboard.writeText(text).catch((error) => console.warn("Copy", error)); +} + +export function download(name, bytes, kind) { + const url = URL.createObjectURL(new Blob([bytes], { type: kind })); + const link = Object.assign(document.createElement("a"), { href: url, download: name }); + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} + +/** Asks for files for `purpose` (`open` or `place`), of the extensions `accept` lists. */ +export function pickFiles(purpose, accept) { + picker.dataset.purpose = purpose; + picker.accept = accept; + picker.multiple = purpose === "open"; + picker.click(); +} + +/** The long date and short time a new page's title shows, as OneNote dates one. */ +export function dateText(ms) { + const date = new Date(ms); + return [ + date.toLocaleDateString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }), + date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), + ]; +} + +export function shortDate(ms) { + return new Date(ms).toLocaleDateString(); +} + +export function askConfirm(message) { + return confirm(message); +} + +export function askText(message, value) { + return prompt(message, value) ?? undefined; +} + +export function tell(message) { + alert(message); +} + +export function openLink(url) { + open(url, "_blank", "noopener"); +} + +function modifiers(event) { + return ( + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 2 : 0) | + (event.altKey ? 4 : 0) | + (event.metaKey ? 8 : 0) + ); +} + +function read(list) { + return Promise.all( + [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]), + ); +} + +const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i; + +/** Wires the page's canvas, text area and file input to `module`'s exports. */ +export function attach(module) { + wasm = module; + canvas = document.getElementById("page"); + input = document.getElementById("input"); + picker = document.getElementById("files"); + const point = (event) => { + const rect = canvas.getBoundingClientRect(); + return [event.clientX - rect.left, event.clientY - rect.top]; + }; + const pressure = (event) => (event.pointerType === "pen" ? event.pressure : NaN); + + // A finger pans the page or, with a second, pinches it; one that doesn't move taps it. + const touches = new Map(); + let gesture = null; + const touchMoved = (event) => { + const last = touches.get(event.pointerId); + const now = point(event); + touches.set(event.pointerId, now); + if (touches.size === 1) { + if (!gesture.panning && Math.hypot(now[0] - gesture.start[0], now[1] - gesture.start[1]) > 8) + gesture.panning = true; + if (gesture.panning) { + wasm.pointer(0, ...now, -1, NaN, 0); + wasm.wheel(now[0] - last[0], now[1] - last[1]); + } + } else if (touches.size === 2) { + const [a, b] = [...touches.values()]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1]); + const centre = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + if (gesture.distance) { + wasm.pinch(distance / gesture.distance, centre[0], centre[1]); + wasm.wheel(centre[0] - gesture.centre[0], centre[1] - gesture.centre[1]); + } + Object.assign(gesture, { distance, centre, panning: true }); + } + }; + + canvas.addEventListener("pointerdown", (event) => { + event.preventDefault(); + input.focus({ preventScroll: true }); + canvas.setPointerCapture(event.pointerId); + if (event.pointerType === "touch") { + touches.set(event.pointerId, point(event)); + if (touches.size === 1) gesture = { start: point(event), panning: false }; + else gesture.distance = 0; + return; + } + wasm.pointer(0, ...point(event), -1, pressure(event), modifiers(event)); + wasm.pointer(1, ...point(event), event.button, pressure(event), modifiers(event)); + }); + canvas.addEventListener("pointermove", (event) => { + if (event.pointerType === "touch") { + if (touches.has(event.pointerId)) touchMoved(event); + return; + } + for (const each of event.getCoalescedEvents?.() ?? [event]) + wasm.pointer(0, ...point(each), -1, pressure(each), modifiers(event)); + }); + const up = (event) => { + if (event.pointerType === "touch") { + if (!touches.delete(event.pointerId)) return; + if (touches.size === 0 && !gesture.panning && event.type === "pointerup") { + wasm.pointer(0, ...point(event), -1, NaN, 0); + wasm.pointer(1, ...point(event), 0, NaN, 0); + wasm.pointer(2, ...point(event), 0, NaN, 0); + wasm.pointer(3, 0, 0, -1, NaN, 0); + } + return; + } + wasm.pointer(2, ...point(event), event.button, pressure(event), modifiers(event)); + }; + canvas.addEventListener("pointerup", up); + canvas.addEventListener("pointercancel", up); + canvas.addEventListener("pointerleave", (event) => { + if (event.pointerType !== "touch") wasm.pointer(3, 0, 0, -1, NaN, modifiers(event)); + }); + canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const unit = [1, 32, canvas.clientHeight][event.deltaMode]; + // The page scrolls whatever lies under the pointer, which a wheel alone doesn't move. + wasm.pointer(0, ...point(event), -1, NaN, modifiers(event)); + // Chrome and Safari report a trackpad pinch as a wheel with Control held. + if (event.ctrlKey) wasm.pinch(Math.exp(-event.deltaY * unit * 0.005), ...point(event)); + else wasm.wheel(-event.deltaX * unit, -event.deltaY * unit); + }, + { passive: false }, + ); + + input.addEventListener("keydown", (event) => { + if (event.isComposing || ["Process", "Dead", "Unidentified"].includes(event.key)) return; + const bits = modifiers(event); + if (!wasm.takes(event.key, bits)) return; + event.preventDefault(); + const shortcut = event.ctrlKey || event.metaKey; + const text = [...event.key].length === 1 && !shortcut ? event.key : undefined; + wasm.key(event.key, text, bits); + }); + input.addEventListener("keyup", (event) => wasm.modifiers(modifiers(event))); + input.addEventListener("compositionupdate", (event) => wasm.compose(event.data)); + input.addEventListener("compositionend", (event) => { + wasm.commit(event.data); + input.value = ""; + }); + // Text arriving outside keys and composition, as from a phone's keyboard or dictation. + input.addEventListener("input", (event) => { + if (event.isComposing) return; + if (event.inputType === "insertText" && event.data) wasm.commit(event.data); + input.value = ""; + }); + input.addEventListener("paste", async (event) => { + event.preventDefault(); + const data = event.clipboardData; + const text = data.getData("text/plain") || undefined; + const html = data.getData("text/html") || undefined; + wasm.paste(text, html, await read(data.files)); + }); + input.addEventListener("focus", () => wasm.focus(true)); + input.addEventListener("blur", () => wasm.focus(false)); + + picker.addEventListener("change", async () => { + const files = [...picker.files]; + const purpose = picker.dataset.purpose ?? (files.every((file) => NOTEBOOK_FILES.test(file.name)) ? "open" : "place"); + wasm.files(purpose, await read(files)); + picker.value = ""; + }); + document.addEventListener("dragover", (event) => event.preventDefault()); + document.addEventListener("drop", async (event) => { + event.preventDefault(); + const dropped = [...event.dataTransfer.files]; + const notebooks = dropped.filter((file) => NOTEBOOK_FILES.test(file.name)); + const others = dropped.filter((file) => !NOTEBOOK_FILES.test(file.name)); + if (notebooks.length) wasm.files("open", await read(notebooks)); + if (others.length) wasm.files("place", await read(others)); + }); + addEventListener("pagehide", () => wasm.flush()); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") wasm.flush(); + }); + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (query) => + wasm.appearance_changed(query.matches), + ); + + const resized = (width, height) => { + canvas.width = width; + canvas.height = height; + wasm.resize(width, height, devicePixelRatio); + }; + // Device pixels from the CSS size: emulated pixel ratios leave devicePixelContentBoxSize + // at the CSS size. + new ResizeObserver(([entry]) => + resized( + Math.round(entry.contentRect.width * devicePixelRatio), + Math.round(entry.contentRect.height * devicePixelRatio), + ), + ).observe(canvas); + input.focus({ preventScroll: true }); +} diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a19aaf2a480635aa64ced4243fdd9a95e2952424 --- /dev/null +++ b/crates/snowbound/web/index.html @@ -0,0 +1,77 @@ + + + + + +Snowbound + + + + + + +
Loading Snowbound…
+ + + diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 826f7dd315e021263108fe1c53c77cec815cb160..9d4029f2803aa961ea0cc600fce6241b2b39f625 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -10,6 +10,8 @@ draw = { path = "../draw", default-features = false, features = ["render"] } nucleo-matcher = "0.3.1" parley.workspace = true winit = "0.30.13" +# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. +web-time = "1.1" [dev-dependencies] accesskit_consumer = "0.39.0" diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index c2f8d4c6969b13f8ab528be0d2e3cd605bda9c81..64e9604ab0c933d64339386d0954cfcb76c055ff 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -29,9 +29,10 @@ use std::{ hash::{DefaultHasher, Hash, Hasher}, ops::BitOr, rc::Rc, - time::{Duration, Instant}, + time::Duration, }; use text::{Label, Texts}; +use web_time::Instant; use winit::{ event::{Ime, MouseButton}, keyboard::{Key, ModifiersState, NamedKey}, diff --git a/tools/TESTING.md b/tools/TESTING.md index eaa79291ae04552d6fb833611517dc1e60b065fd..3ee0b31210300802f28fe0e41e2b2e280ee44568 100644 --- a/tools/TESTING.md +++ b/tools/TESTING.md @@ -19,6 +19,7 @@ another, and the exit status is the result. | `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) | | `windows-aarch64`, `linux-*` | Clippy `-D warnings` on what ships (libraries and binaries but `mobile`), then the `snowbound` build; Linux through `platform/linux/cargo.sh`, which links with zig against glibc 2.17 | | `ios` | `xcodebuild` of the simulator app, unsigned | +| `web` | Clippy `-D warnings` on `snowbound` for `wasm32-unknown-unknown`, SQLite built by nixpkgs' clang; `release_web.py` links, optimizes and publishes the static folder | | `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` | ```sh diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 6fad891c252e1a44913a08e369820c25512fb06c..c7f389f0ea27c34782888313992311a7a4d8f3f3 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -90,7 +90,7 @@ A paragraph stored as an equation draws in two dimensions from the tree `onestor `TextEngine::default()` lays out Calibri, Arial, Times New Roman and Courier New in the bundled Carlito 1.104, Arimo 1.341, Tinos 1.340 and Cousine 1.241 (`crates/canvas/assets/fonts`, SIL Open Font License) wherever the system lacks them. The font box names such a family with its substitute, as "Carlito (Calibri)"; stored `Format.font` values keep the document's name. -The host and `layout-probe` also accept repeated `--substitute-font FONT_FILE` options for another Arimo, Carlito, Tinos or Cousine file. Each file is registered only in this process, as its family's counterpart; this explicitly replaces that family even when a system copy exists. No font files are installed. +The host and `layout-probe` also accept repeated `--substitute-font FONT_FILE` options for another Arimo, Carlito, Caladea, Tinos or Cousine file. Each file is registered only in this process, as its family's counterpart; this explicitly replaces that family even when a system copy exists. No font files are installed. ```sh "target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE diff --git a/tools/ci.py b/tools/ci.py index afec281160fd465df6e754763f0dbbb95c84108e..e19fcdbe2520313dbd2796631217d74a11e09ac1 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -75,6 +75,15 @@ def lanes(): commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')], environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')}, missing=None if shutil.which('zig') else 'needs zig')) + targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout + wasm = web_environment() + result.append(dict( + name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py'), + # Clippy builds it; the module itself is linked by release_web.py. + commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--', '-D', 'warnings')], + environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')}, + missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split() + else None if wasm else 'needs nix for a clang that builds for wasm32')) result.append(dict( name='ios', minutes=30, packages=['mobile'], paths=('apps/ios/',), # build-rust.sh builds the Rust half into target/ios. @@ -92,6 +101,17 @@ def lanes(): return result +def web_environment(): + """A clang and llvm-ar that build SQLite for wasm32, as release_web.py finds them; none + where it finds none.""" + import release_web + try: + environment = release_web.environment() + except SystemExit: + return None + return {key: environment[key] for key in ('CC_wasm32_unknown_unknown', 'AR_wasm32_unknown_unknown')} + + def jj(*args, cwd=ROOT): return subprocess.run(['jj', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout diff --git a/tools/release_web.py b/tools/release_web.py new file mode 100644 index 0000000000000000000000000000000000000000..8a71104bbc4b5ab948d12e361a5606baaf292c11 --- /dev/null +++ b/tools/release_web.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a +static folder and publishes it to the share's web/ folder. + + python3 tools/release_web.py --out /tmp/snowbound-web/dist # build only + python3 tools/release_web.py --publish # build, then replace web/ +""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +# The one target folder wasm builds share, apart from the native builds in target/. +TARGET = ROOT / 'target/wasm' +WEB = ROOT / 'crates/snowbound/web' +FONTS = ROOT / 'crates/canvas/assets/fonts' +PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') +# copyparty lists the folder itself, so the app is at its index.html. +URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' +# Size over speed where it costs little: the module is most of the first load. +PROFILE = ['--config', 'profile.release.lto=true', '--config', 'profile.release.codegen-units=1', + '--config', 'profile.release.opt-level="s"'] + + +def run(command, **kwargs): + print('+', ' '.join(map(str, command)), flush=True) + subprocess.run(command, cwd=ROOT, check=True, **kwargs) + + +def tool(name, nix=None, wasm=False): + """`name` from PATH or Cargo's bin folder, or else from nixpkgs package `nix`; with `wasm`, + from nixpkgs only, as the system's clang may not build for wasm32.""" + found = None if wasm else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin')) + if found: + return found + if nix and shutil.which('nix'): + store = subprocess.check_output(['nix', 'build', f'nixpkgs#{nix}', '--no-link', '--print-out-paths'], + text=True).split()[-1] + return str(Path(store) / 'bin' / name) + sys.exit(f'{name} is missing: cargo install wasm-bindgen-cli at the version Cargo.lock pins, ' + 'and binaryen for wasm-opt') + + +def environment(): + """Cargo's environment for wasm32: the wasm target folder, and a clang and llvm-ar that + build for wasm32, which SQLite (through sqlite-wasm-rs) needs and Apple's clang lacks.""" + tools = {} + for variable, package, name in (('CC_wasm32_unknown_unknown', 'llvmPackages.clang-unwrapped', 'clang'), + ('AR_wasm32_unknown_unknown', 'llvmPackages.llvm', 'llvm-ar')): + tools[variable] = os.environ.get(variable) or tool(name, package, wasm=True) + return {**os.environ, 'CARGO_TARGET_DIR': str(TARGET), **tools} + + +def build(out): + """Writes index.html, the module, its JavaScript and the fonts to `out`.""" + run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown', + *PROFILE], env=environment()) + if out.exists(): + shutil.rmtree(out) + out.mkdir(parents=True) + module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm' + run([tool('wasm-bindgen'), '--target', 'web', '--no-typescript', '--out-name', 'snowbound_web', + '--out-dir', out, module]) + bound = out / 'snowbound_web_bg.wasm' + run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) + shutil.copy(WEB / 'index.html', out) + (out / 'fonts').mkdir() + for font in sorted(FONTS.glob('*')): + if font.suffix in ('.ttf', '.txt'): + shutil.copy(font, out / 'fonts') + for path in sorted(out.rglob('*')): + if path.is_file(): + print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') + + +def publish(built): + """Replaces the share's web/ folder with `built`, leaving every other folder alone.""" + if not PUBLISHED.parent.is_dir(): + sys.exit(f'{PUBLISHED.parent} is not mounted') + staging = PUBLISHED.with_name('.web-staging') + if staging.exists(): + shutil.rmtree(staging) + shutil.copytree(built, staging) + if PUBLISHED.exists(): + shutil.rmtree(PUBLISHED) + staging.rename(PUBLISHED) + print(f'Published {URL}') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--out', type=Path, help='Build into this folder (default: a temporary one)') + parser.add_argument('--publish', action='store_true', help=f'Copy the build to {PUBLISHED}') + args = parser.parse_args() + if not args.out and not args.publish: + parser.error('give --out, --publish or both') + with tempfile.TemporaryDirectory() as scratch: + out = args.out or Path(scratch) / 'web' + build(out) + if args.publish: + publish(out) + + +if __name__ == '__main__': + main() -- 2.54.0