From 2ef7c90f3da02576be5ec3dfcb088255259ebc93 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 18 Sep 2026 08:52:00 -0700 Subject: [PATCH] give aesel for iphone the desktop notebook, slab's palette, and app store braincells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone app now draws what the desktop draws: one scrolling ruled sheet with the piece title in Prox lettering on the first row (Comic Relief Bold, the SIL OFL twin of Comic Sans, bundled since iOS has neither), the little piece in its tinted 178×119 frame at the top-right, the desktop's own running donkey and activity caption after the latest prose while a turn is live, the draft written inline on the last rows, and the version label parked bottom-right. The notebook page stops scrolling itself and reports its height to the sheet. It opens straight onto the notebook. Colours follow Slab's theme-by-status from a bundled snapshot of the palettes Slab publishes for the desktop (Resources/slab-theme.json), with the desktop's readable-accent and user-ink mixes ported, and "complete" clearing back to blank once the draft is touched. Settings is the desktop's centred card rather than a bottom sheet: the provider, the automatic model as served, remote inference, auto-publish, the balance, and one large green button that buys 1,000,000 braincells as an App Store consumable (computer.aesthetic.easel.braincells.1m). StoreKit 2 hands the signed transaction to /api/easel-iap, which verifies it with Apple's server library and root certificates, credits the same wallet the Stripe checkout fills under the Apple transaction id, records the purchase, and takes the pack back on an Apple refund notification. Sandbox transactions credit only with AC_IAP_ALLOW_SANDBOX=true. Co-Authored-By: Claude Fable 5.1 --- apple/aesel/Aesel.xcodeproj/project.pbxproj | 16 + apple/aesel/Info.plist | 2 + .../braincell.imageset/Contents.json | 5 + .../braincell.imageset/braincell.svg | 1 + .../aesel/Resources/COMIC-RELIEF-OFL-1.1.txt | 93 ++++ apple/aesel/Resources/ComicRelief-Bold.ttf | Bin 0 -> 94684 bytes apple/aesel/Resources/slab-theme.json | 1 + apple/aesel/Sources/AeselApp.swift | 10 + apple/aesel/Sources/AeselHomeView.swift | 23 +- apple/aesel/Sources/AeselModelPicker.swift | 3 +- apple/aesel/Sources/AeselNotebook.swift | 18 +- apple/aesel/Sources/AeselPieceView.swift | 5 +- apple/aesel/Sources/AeselScene.swift | 294 ++++++++++-- apple/aesel/Sources/Braincells.swift | 103 +++++ apple/aesel/Sources/ContentView.swift | 437 +++++++++++++----- apple/aesel/Sources/SessionHost.swift | 12 + apple/aesel/bundle-session.sh | 2 +- easel/phone/credits.mjs | 45 +- easel/phone/notebook.css | 20 +- easel/phone/notebook.html | 1 + easel/phone/notebook.js | 32 ++ easel/phone/session.mjs | 2 + system/backend/easel-paid-credits.mjs | 23 +- system/netlify/functions/easel-iap.mjs | 105 +++++ system/package-lock.json | 248 ++++++---- system/package.json | 1 + system/tests/easel-iap.test.mjs | 84 ++++ 27 files changed, 1316 insertions(+), 270 deletions(-) create mode 100644 apple/aesel/Resources/Assets.xcassets/braincell.imageset/Contents.json create mode 100644 apple/aesel/Resources/Assets.xcassets/braincell.imageset/braincell.svg create mode 100644 apple/aesel/Resources/COMIC-RELIEF-OFL-1.1.txt create mode 100644 apple/aesel/Resources/ComicRelief-Bold.ttf create mode 100644 apple/aesel/Resources/slab-theme.json create mode 100644 apple/aesel/Sources/Braincells.swift create mode 100644 system/netlify/functions/easel-iap.mjs create mode 100644 system/tests/easel-iap.test.mjs diff --git a/apple/aesel/Aesel.xcodeproj/project.pbxproj b/apple/aesel/Aesel.xcodeproj/project.pbxproj index ea838e0b3d..5eba832531 100644 --- a/apple/aesel/Aesel.xcodeproj/project.pbxproj +++ b/apple/aesel/Aesel.xcodeproj/project.pbxproj @@ -11,10 +11,14 @@ 1F53641C03321161A7665F3B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E152C400C5CE69463B39F /* ContentView.swift */; }; 2DB3100BC43BD21115A1BA37 /* AeselApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7CE6904A010A2BEC894D13C /* AeselApp.swift */; }; 3661DCFA19E2594688511C86 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B505C3279E0F8DB5292B86 /* Session.swift */; }; + 44709587196D5718C5B25542 /* COMIC-RELIEF-OFL-1.1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */; }; 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */; }; 7AE3B1696F71BFDE82E8B72E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 228E709932A29DC953A73B71 /* Assets.xcassets */; }; A2CDE2052DDB891D3A4659D6 /* AeselNotebook.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */; }; + AC7B6241A0E7F2791E06F1CA /* ComicRelief-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */; }; + B7D0EF78D26BBA38AB801393 /* slab-theme.json in Resources */ = {isa = PBXBuildFile; fileRef = AACC6D25723505FB156B46F0 /* slab-theme.json */; }; B8236CE8844912F069905928 /* AeselScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */; }; + C27DB5FE92281B28965A6535 /* Braincells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */; }; C48716DE150CDF3FE383298E /* Session in Resources */ = {isa = PBXBuildFile; fileRef = 47AE0766266534A8EE665542 /* Session */; }; C5323C1BB9D8D59F14A1FF74 /* AeselModelPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26176753E43E445E2CF551D /* AeselModelPicker.swift */; }; FBC8984584385405A83CB639 /* AeselHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2D2E4941E446673BAF4F97 /* AeselHomeView.swift */; }; @@ -25,9 +29,13 @@ 22509229E839210D9200B4AA /* Aesel.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Aesel.app; sourceTree = BUILT_PRODUCTS_DIR; }; 228E709932A29DC953A73B71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 47AE0766266534A8EE665542 /* Session */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Session; path = Resources/Session; sourceTree = SOURCE_ROOT; }; + 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "COMIC-RELIEF-OFL-1.1.txt"; sourceTree = ""; }; 505E152C400C5CE69463B39F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Braincells.swift; sourceTree = ""; }; 72A337044339282DB88573A0 /* AeselPieceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselPieceView.swift; sourceTree = ""; }; 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselScene.swift; sourceTree = ""; }; + 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ComicRelief-Bold.ttf"; sourceTree = ""; }; + AACC6D25723505FB156B46F0 /* slab-theme.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "slab-theme.json"; sourceTree = ""; }; B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHost.swift; sourceTree = ""; }; B7CE6904A010A2BEC894D13C /* AeselApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselApp.swift; sourceTree = ""; }; BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselNotebook.swift; sourceTree = ""; }; @@ -45,6 +53,7 @@ BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */, 72A337044339282DB88573A0 /* AeselPieceView.swift */, 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */, + 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */, 505E152C400C5CE69463B39F /* ContentView.swift */, 13B505C3279E0F8DB5292B86 /* Session.swift */, B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */, @@ -74,6 +83,9 @@ isa = PBXGroup; children = ( 228E709932A29DC953A73B71 /* Assets.xcassets */, + 4A54F121629B5E78C6F46F6A /* COMIC-RELIEF-OFL-1.1.txt */, + 99FE2FC14157DB3F88708A6B /* ComicRelief-Bold.ttf */, + AACC6D25723505FB156B46F0 /* slab-theme.json */, ); path = Resources; sourceTree = ""; @@ -139,7 +151,10 @@ buildActionMask = 2147483647; files = ( 7AE3B1696F71BFDE82E8B72E /* Assets.xcassets in Resources */, + 44709587196D5718C5B25542 /* COMIC-RELIEF-OFL-1.1.txt in Resources */, + AC7B6241A0E7F2791E06F1CA /* ComicRelief-Bold.ttf in Resources */, C48716DE150CDF3FE383298E /* Session in Resources */, + B7D0EF78D26BBA38AB801393 /* slab-theme.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -156,6 +171,7 @@ A2CDE2052DDB891D3A4659D6 /* AeselNotebook.swift in Sources */, 06DAB9B425E19C4E02F25A24 /* AeselPieceView.swift in Sources */, B8236CE8844912F069905928 /* AeselScene.swift in Sources */, + C27DB5FE92281B28965A6535 /* Braincells.swift in Sources */, 1F53641C03321161A7665F3B /* ContentView.swift in Sources */, 3661DCFA19E2594688511C86 /* Session.swift in Sources */, 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */, diff --git a/apple/aesel/Info.plist b/apple/aesel/Info.plist index 5693974238..5973b07869 100644 --- a/apple/aesel/Info.plist +++ b/apple/aesel/Info.plist @@ -18,6 +18,8 @@ 0.7.5 CFBundleVersion 2 + UIAppFonts + ComicRelief-Bold.ttf UILaunchScreen UIColorName diff --git a/apple/aesel/Resources/Assets.xcassets/braincell.imageset/Contents.json b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/Contents.json new file mode 100644 index 0000000000..11401b7556 --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [ { "filename" : "braincell.svg", "idiom" : "universal" } ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/apple/aesel/Resources/Assets.xcassets/braincell.imageset/braincell.svg b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/braincell.svg new file mode 100644 index 0000000000..7d06c76b6f --- /dev/null +++ b/apple/aesel/Resources/Assets.xcassets/braincell.imageset/braincell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apple/aesel/Resources/COMIC-RELIEF-OFL-1.1.txt b/apple/aesel/Resources/COMIC-RELIEF-OFL-1.1.txt new file mode 100644 index 0000000000..be3ee64f6b --- /dev/null +++ b/apple/aesel/Resources/COMIC-RELIEF-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright 2013 The Comic Relief Project Authors (https://github.com/loudifier/Comic-Relief) + +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: +https://openfontlicense.org + + +----------------------------------------------------------- +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/apple/aesel/Resources/ComicRelief-Bold.ttf b/apple/aesel/Resources/ComicRelief-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..14f8bf9dc98cc6c572864860c14b817ec4a34aff GIT binary patch literal 94684 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

oY}zu)sw&1(|K@NqDazT~sj0CtN;Atz zsWHhZ+uZB<=b^^QE+eb#Vaj-XxptVbrYH*w7oX(6EsVnUO7bksl9HxuY=4$IsmL=h zGVuI=%KV2lm_d#~+(AT=pOu-x6V%x#V`N~EW0Ye86*o$tQlAlAw}F<7fd-ktPG(dC z^}QIsdT47ivi-XzCCqPXWMVA({HuhirI8hnpzObOjEtJ9tidvJ2LJB=x!|s^#>36S zZOK&kx6hu3mrp>;(t}y1uisD>dWW*hzZ~W-Eb0s<3@HxWe2h$t#`;XG;Nv7g2YShX zI=bNe06x!14Sc7p4ru+Z7HCV96ay0jD+3cNxIe6cA_;EH8X0J7DnPELHeob@G-l1r zOd-v7b~aIAKF~0~x{11)Dd>7ga3p|^0TzRd1`CUc8<~qScN>EiKPV`h+i2)8N$48{ zh3e|@F*7l$t6AA8C^E7z@-pe@xa!NvGjX~f2y<2SRaY}+PPA3iVr10Nb^LqDKwN@} zNkb!2NI*i8vBOnYn~~Ab(Cwd_*WY>KMj_C8?En86Bq4W3GO;m4fa`68 ze=*D_*sK}+8RE9OF)}fO+H#=cLLM{%lE}cy3eF+mg9J1{Mu2mT7O1Nui&(t?x!Vp! zKGK2L*4orqTT?+^Lz#_3LYoy_Yl50I;-ESc9NXXvgkbG{kO*i23aFW<25u07CrLm$ zKp1=*31rfWsau|n-Kxb;7gVllsGGm4a8^|2QEW835atrV#T}H^9Hg(q%&es3)@fxd zEW*qoB&Zi~-5%7X($KWsRh4F!t|V`woTew+$j6^)lwa@4B_*yVrV1-$b!?rTH`_?6 z3Fw%X6vq^CvdPPt+iGg?N-{I^@QCUwE6EsevuJ5K+bJqAvCBx>rnOgAd0D7w?pVHj zs*9!`xE&2%>&zm}#0EO=f`O4i1nGf%Q1uM zAkfkrSTo67*qE6ObQV0c5CsiOsF@3c>K#y}tE>o`3l|qV#u3cP>%}IoXm6*j#pZcT zpOcA+kzFZ0!c&=tn~T?!b{IM@{x z&6sxoGxB4Rm6j0-H8R!~QCC$r)E2N}e4(i5?4qQYtFEi7ZOy>+|Ifcz=C3Tg3{ng_ z41QbrK@BJ$?+u_cRK-9e3EA`FZypp?Y~IspnM9O=L>Bdw+)ts|o& zB_Slh3aT*JL`6ZDr-Ek0Ks5#=lL&y)ggE4cCvXyiEN5BW%&5*HC28)YsmCUoTJ)uA zDr0*3tKa`a!R@T506$PWi#c-BWnp%XzfrFG2CQrv8lH?CmfhPgGxANGz{K<$)Ux8? z*<|Hz@7UW`=MJhf82>;27sLFD#h$^EK^SzI4&+i~Ek-{LWzbX(_CyS74na~eD4xJc z7aUXI<`Z}jo2glbh1sUSM_W{sPe5JW>{^wRk^-mr^0M#Y7zyqm_Op&ah#0?rIhi!Gx z0Ug%nASfmZT7#<~C#oi4N(hd@!g%#jYHE;t;htbbMO$9u{BP?cY464hFL3@KuOu^%#rY4}HXFzR4aWQ)p ztHpdwnp&RjTI!4fs~j~nn6{x;euDlsxLyQ=>6a5Xey)Yo-n=49Hbs%pT-<|r$} zsI1}w+DOQt`~MINFN->ZI?N4V>%nES4zr(!xws{yJsjU^yBw`I3JYh31aQOTGe0doo^OT8<9H9QN#J^%z9Trgr2?jR? zQ3oM=6D1}Va5qqk!Oz-~5mfK9u?yRA7#Yd2i;IDmpn*1Yse$qiIIpm=vkA*FfjS$Y zG5|DB4({(Wt1<7Esu5&lWdY5Ov$C+Tu(EJ+aB^^SahkCtCnN^j$;q(^B`W#U21b~e z8EOa%_2`HsC+}loX3|ji_0!R2Vx5_pWLj2j!RYO&&bU%pMn*t@n~Q~ok%@zYmsd(k z=`@q19y_~*R!~swzfvWyl^2^Flv!D{L}YLNJJA~*qn4EDV_?Y0sITXA#%kxQ^!a}O*mR8KKOe4byELvKQ z|AfBgm{~Kk^!BnaTbpNpWqjwVuF1mu|NsA)|Iaa9X9b;?0N&TlkooWF|BsNd!v7yP zm}-N@vBBj4YYc3Rjv@2*4XBD6AQg!A2Y9RxvbQPq0%)v|2{O|6|39QGVdH|C!I*j+ z^(;GZ{ID@FsWC1Gt@&e2W!T9e4;l$%+yLH|3vvf|E|pyoc0L9}CPO3RT!>2;=Yn0r z#K7_I5!-Io2MkIK4h(7z%Ai#~42(YT0#qE-1C?j=bFeixHPqMEU||>2W(2K30(Tcd zB^W3|p^4W_SO^rQ>T0Zv_;#HzZRE6_IH^9_T`lOW+5|&6c{WyAIXyiSQ>)im^$r?| zaZ67ATlqH&?>-dfZ~yjkYHBK(s^-4__uRx;Tuv4=8zm^n$|4@i$i-;;XKq%iqDCa+ zA>12O!13Vx|1V2AhZB<;<33P0GL$lEqOPlFU}P}-|CnVh8wZ0WXpWwNnE|v0JRLl- zz`(#D!Qf|Nq@$&-DlQ5R9O&dKBp}$?n8Dps$V4otO$aUmAbCR-vJzO0ofT4wDj0-Y zXc=l5u`;V_*{#3-azvv2jSt*l8G2~Ul7?KUqUyg7+y2e@cWu{Uc6LV)E#KvF>4VP=+vk74H&X93*Nha7C)tUeQK=z|B z?SSt`(fhxb)rR#E6B~mLXnocH&;MVre`fv2;K1O^&<(jcSrK%z6lju5+?Ro!9o(st z^koBO=|oWTR03SgLi#tX4D5`o?3oA+sLC8P88B2ZfOZjp%>gazW`Y?R8R@_+C@&Qz zE~aP9!7iuGXr>0bU7Q`%o(0vyd@Rap;2WsHE9=bR$A=gj8H2~ugoV`gm_R#<)WHYT zg7(2G3Y)R=Fd3VK{>}2%(`RIqV&dZB7Z4N@bJ5gdW;QhN5fEAu62dCPxL<}zgi~JK zoRf{6(I|PI$75ceTh2{QXF)3-81*95)L2;+6&+mvz2f}C8ttm7p{(M_#V!12rK^E~ zloU5Nql$`ytFj6+Ba=vL>rU3xd3NkO*#>LJ0H%`jb zG&0E0NJ^UVEDxx@*ZBX2eLWi&gEfN(gS~@|0wXg!qYoP+BM0beS(Z#D&`Nn3P&_av zfhK|^Kx=zF+}&KwOf}VHq(y~!xY$@3tQoDjK$#vqTBHarw!us`F;E){lyZd?h1l7| zA>ANzaLWoLY6dFh6~Rdybm9T{UP?t_6&4peRaGXgzbn}p)zq9FRaBT{m{nDM4dj)0 z_!zmELKyj(I2px6)n(=Q|Ic0ER$^-+wzWNh*^G-TA*_x+s_Z*9%QsGzK&Ys>iSKf`|`btV-V8D2g{4nBT$$AY{m-MKj) zeh2pY85sWADrI326JlZ_EyMVTi-C!O=l@x@1l9)(Dhy5x5!)r1KnEFu`rC#95f0#) zTJVfKxJZ=q1&voqfY&B~5*)G&s89g)u;iKjA|3dh>@CfW4Ry6tl*B~XKmo}PE6j zS64vL!rVzigOO2P-JYe0pI=wkf#sZoI%xb@+wt$|zsDF^GF_nS*TLuE!PbN^Ffla$ z|I9L-^)rJ8gDFF=_Xf3qh|Qo?A<|nx*FS?>qo9VT90MZ*D;pzgCTy96IH-_=oF)lM zh+vHl(r{I5EQ}0nkbOv?MHS+peMkxjSq26cHU?y~SXd;$eLyY?LG>_WDMfa6S#4G^ zP$dPfxAd4;m6QclK#OWXr6DW$NKWv8rLh_K&_XeG(8#Mgqk0%4-~P-*jc7LZY{$9t z{{3cWXS8HgcT`trV$#rb_^ZdoW2vaf%&4ejAy#?2v{c+FOe`#XvVjXLhcK^}TJYwD z3tRk^Knj?dRaG3ocWN_gX*m3rvQ$!GWID_zD`&H^P)0K%GrUgTDFH)Upa zNo`d#=;}K&(9PwL)pv4C;zo>F+AMZV6Ks`1*ZW!jc`0nIpulXytf*-2q@wP`;>N|O zsby}Usl~|4q^0fj+rnB!l@U}vTm0L@`jz!3gC&DK!+OXu)x1V}I?OCg8Zu1GEKEMq z4gyAu987vTOe`E=A!g8E0BB%T9();f82EA%(Ap>labHdjCMG791O{+CN_%gB94o~H zRtUProgopXkdcD}uFgS@laYggg_(mTg8@@5yI*9agRqsAmA#d{t&OF*il~;jq5>DY zj5Z?+s~V{3$Id1$#0;tt#YI6~Lr|SyYN{v>x@8=aztq&tO!b)5g+S8@>`IJ6%&JVx zEHD4f`nO6oemzrg+xB5C8Fk(hs)(*cccx8KNAx zlS3`_1)14c!87Qfb2Y?3i%(b?8CV;bnV7&yij`G@)lV8cWC)sEW^G_#Vv+%`*%Akp zSQ4zDIv_LM)5Foh)L27JP6kv5sG6CAMj=2L)Z`qGjqsI*vzLiShOeko>SH_uy7L+RygqQ zXN0D#oUEvbkRUe~i?*U751+KOvASh(SeS#DvA!8IqmGWHi<+9RlCr2nSXl8XE=Cp} zMMZ5EE-oQKQ88IrS@p<&zYZt}3%Obt7%21?6~?KmxLHVQIx(fP98dl?&qiEQOv=i} zg>l~&%Lsc@6M1!kUq9@nXZj%bYYcA^lS2|7%!gvc6~V z1+~SsWSE#d!4&~0yMnHr5@+=D_H?v2He?1>wcraM#lf@k?2tA$sE7xrO>r?*J0^2b ze+Aq`QC9`a=V5xVS(0B8W}MrtFzQkpT1OH zSI5LbP*8TszYP%)=0faRJmLaeTr3*$a@@R@nVo+CARFIJp5@2OvFksRHHGdeD zp%u0;sG0_Ea1#gZ_yQL{prxXq0X=gwQ)Ux2HFYJH!#sZ(dHFoGv{@NB*m-&R`Q_cT zwV4>1I1Kb%PbMTVvM>p=YHGT;Dyy)lsW}R;zGY-mQFHp!;bLGY2|8MvNm<2#hu2D4 znu)cg^#jxM-{%4h4MEdh{w#+<4FO18qpuZ4S=$XevxfCKWS#8)|FCtkOl*wx;65tK zy3_yv8Ng@YvI#J;f$n`mU!Tdq$N+AOvN1DgF~~Vc3xIY6`lzrnF?fLj7<6j0Jfok6 zCKJ1awlEuLF$^dffJQs@ZUHo5{&%CZA)T zWT>sJsv<0>pyc&8pJ|NW@Q3ra~2lxLVgL*UIHx*6(w++SdUQ;R1KlDiN)Y;Vspr9KXrJUSXf07 z-X@llvyoI};bGKJw=q$W=VD}LZ*EitgHn)H`im!_c zv&#nZv6Mhs#hT8)I~ZAtz-?ksYxn;nmM%7K23^ov5OqdoMn)gdWsfY3%$cCUC>hY4 zuLO&qs+PEh8Y{arBrAgkS=iY?H^xAUcTN8JV;c{Qk|ZD>N}? zVzRMqVchDZtIf!!tLyqt{Z(}}qxZiOD^)c}on-a@HQPHjCI(#wEeCZL(54;+8DCJ3 z8WHrG8Y)V1vJzsvTnxI5x}Y4168NBF70nOAR21aF3Cf1ihLas9zkf3LDsLc9*zPx}8g>vh(53_J`*ppq4|HH?>$1++s8I@-;^ zz{1SHQpdo|44w-XhbBZ7H8VL&P(ow`kAyOaA$qPpvHI|w4;eZn+0@ZCKE&D|JN+jS>G_oGw6V7Pz6S2&~bmvOg;<@ zjLeMS+m)FZnVIU4I!#vUYO?gA- z#pBr63Y`}(`o_k=%&4nnZ=(xNVaha}W zS!;>?OLx=NWntCQbYt8sujYWV1l4VqRt?C3^=ob!Oq#U|(i7Jv$yrP5}>ReLI5L8#w*HtrBH&s&+QCAV-1a%+T*cCxZ2h@oMuc!sJy3K_ZMTNj? zxrBrT)YVi~&~{=m|D9p~&y1HzOUJ=hRh1ER{IIQ)jy4l7<8sD{=NXe2qprPSoHh5~ z&VLPz9VmOUSiUW}#3W&-qQ<16Z24<}y@oa;qlSh(lPL4Qk8|#QXB1>yz-Tsa5i`rb zPiQ-{7#NuTzy800IsL%Rc)I3p{wA|nF}=OljQ%*yi24&48aWCWR6d3$73n>H}sW@2Xi z`^QL5hLKTP&XjefmXrh|3!k{r|CW{s?P@mvCMX)}YKuyUvON3eA=aDk8=$1X#tv?` zfbWQ4@nm9S0BsUuVvzZNoW+Vw44kHCZslWS1>Z6R>YJ#-LJpkZ#C<^rhJ(8)l8~`3 z1_lOM@L86i1rN&5wT@X}O*oV~C^Im#Ft@^#VpkOz=^y}0qbh1DYRVk!(%Qmupl&&M zRiQfgdJa%9g9f$P*x30Xo7 zWi3|I_*Xu|ML^K-2~TdIxFVN=v>+oRCks29sF=34vHdJI78XGver9`C0X`8KF)=v_ z9&XMfdlYs5tTav&3o9$XtROol9~&DB zJ3AK_2e;TGMo>Q*bV_RC|BK95S!5Zc7$hCUKyyx@TSz5AtMB9){LEA|Kx2i%d`#jX z&w&z+I#Ttqi)BvjZsU@(q2}Ek&%N9R2KdC_mgECizb5wgDpdb1E-OWupkRF8)%jSv#v|xEzzQ9E-S^I2&Xv4_p_53=k6s z?dddPW>I4@^O?!RYowuPBq}1n!cwtJRZN3dQb2%1LrRLBg+n+%NG!grT9ehm#nE-pridLt2}K4vCXYwIp)K_Slf zCeqTi#{~*VPkYX66?X)Q?_PE4X%_ zi9G1~Q3gf^+yC>KJz3-#JQ*|{)KtKCrNY*iFfcH*fSTKh3`|Ut%zn;}hWbjnETCiv zSzanG1{u;rZ>oVq+KvTGf$xih+$RSQO-S36jd8!4swxXxsfDQ+6O)3jNvM6Qva7rZ z6QisR$I%Cc4h|f=cJhj%9PFH&ta9uedKoJxSDAp8@JdOki%O|+@Ehu=nrcgOvq*_E zRdTDU8U}E)s`A+xh{-ZCYdSJ9nJ8LGN|~{?+R8aOMlVV-Qjp^06-f}J5;80ZHx6`*@isMeHSkEDIcUiNfr&{DRK>xhA|1G$ z9jz?1wd7?%`4!Y>gZUAfTtL~)3_Jo48i@x@@Ibr{?kcdGo2aQNfyV;HK;0a3GgHQ} zJTb|O*6Jp<>SE$dOk8~Oxl>d`C4_m!`T2M`*qMa|B)PfSSvW*Ogv3i7ye(LyWQ-J* znAFvcj6h466qF3jl~oyc2n&nEZF0L#y0~4By|f1L^y!Oz@;-p40P%rSPWJpOM7ns&7XqBkko+6&5#UU}Vtx zf09Lq#g9Rq!51>4D8|6bz{JSPln$ON2d!IRWRwNXprcAKCW5BcWf>v&)+sB=$%uk> zd2ukPGpd6U2xzzpygLY7oC<@-GQczFpk5rfBxYs>G56T3sxopYs+ij98Zfdko?>QH zS2MQJ)?(yi)X+Aw(9~gM{%6j{q^HB;%c!bq@u$^NMV*OBS;d~wiu2zwThJu2va%&} zy$z^&rmA7{?*bd6p`C^rXueeL|5=uWEYb}84Dt-yAPGX0fsqlsz!n^dpuQ55Jh<}< zN*QoTaI7Oqf|3Ya5?v=Kt-vMGb%GKNToP5M16&+M6F3=dQ&R)A6r{8n!IM$2-HGOA zs^Eok;B9+CpdK%?Rv;@|cxXjbU~-Mk{N%q!=XcEf#^^t@iIK6U`rq?^*O*>s)o*lh zatf}QQftG+WV`I;`?hI}Omkb`Fp5l>0$w4`u<8G0mOU)o46>lTNuce7@t_+gCBd_j z@}PlWXw=Fw%7EMJd`zOCBy9vbu8$dfOeH9tgSW}BGn!Z{t1vMtsajZQXs|H;25oyW zw*d`0DXE#Wa5Af@S^T+cr>d!`X~DGkZ>)u;rly89v$mCzDigTx=l=I7iw=to6B|Pi zm=C^#jzx`$4Ro&_10#dv{{ze$Sriy#8Em{afUcz$Vqs=tWCV}3fx{PEV+k-YfrJ^D z7}FtLz(@xkFhkl*MTM1JQk#)k1UdvP3>wz~^~H=8!3%6ad6KD`OU%xhF*<342d@o+8Z z+#VCbt*oqRrlP_Z61B5^At%p-%KI_VjH;@xX15BWoERD19rGi68J+)e=R3MFIz<xI&s;uxIz5v%M9p%xXHIT3uYiK$)4PyzlJ4PpkTQ*d;HXcT!Vh4d#q> zspyRgBR>MEidpaED_(9p6VX!HZL#79U_6*9;S8afA!Nh*Q@22@g+vOM5X zGGbI#vjSCH|J*b*E$vm+7^RflnAS5h>8sgWsjB!I3t8|o9k5f=1XWos|2F*VWn|jx zqzU4x+x%roS2OmuG|&L`TR=k@42%p)|Iaf2XK`WBVsPB1E(C3}seorvL8D@zz01(H zh&-s!6=pz)f(JH0edw*~YWko)Bcqx+G-}ykeMj&q6rkK_4k}#L!J7m?DVi}ypM{fI zQ_aR6w8x3Htov|FF%u)Bww9HpvN9tFqr8%Zou)1$6JvO9I+KxB`eRo;Z8kQ2eTRQ9 zwngVMGwBu=SgNb3s+lsU+NoFm@Vxy$Q!XuQc zs%~$qqs92|frgg7J&19OQA5+lQC)peyo#;2FL=^RQ^Wb2l8!Au(_Tn=QnUVR$#f9r zpMUNDHZvW7`v}yxfA#+jt1OEx0}q2CL*zC|&}t-5gBMg=$uTgoFt9K&G%&ERfOocl z%SOf|&=w!?AdL)M0W$*&6Eh2_oP|g5r!3d>0A0U8~NHC{L6iL-|VV z@L_T>ap;7_^2KgCBEn2e+Pdk6es$L-KM#y7(akO1A*IaDq^WJk$?-4V0@SEgQZ(;b zD#)a$Vj{+PRX|!mM%KtwR+f#C@iDiE!rawI0~M4&^9vSADvXRQj7rKT=8B4pOiX{} z4ds+%*cliZ*dYhfvLa#2CrI1yaYwl!j&}gI^?QbTKHv#aUHD&`e3qOi7iSU0$1!O`J_!%-EcX z89W>SV#5ZBl$C{qL0KMD7Jx2kz=$KzrW}w4q;eiq(r;5>WR#ONEM#U(GggoXF&R%8 zfv$9rmp6)KWQ;Kcvz7J!*&6@lzrNsIKuDT;Wxy>NHFhR#4NGHPT}B~BS$RF-MGM`H zghiQ{^bCS6VwgXwn3yXlF#db^@41SKsR3y0O5V^+MTJqFQCMEi%tBG+_akl*x%o@> zdYgiy*HldzG{R%_XQiRM6619NX+ddNI$&U8i1`18`5lWDgEWH%=#*g@9wrt>&{QuI z3lk#?q^1O|Ye<4FuhkJ()er!c^5AACI6JaKQ!4gQcuK6tIEi*NXkLlI=Klremn_;0 zrl9c(7SOgzW^maK9yS8a#DHhm^>ox!c)1x&8BIYAD|7IK6FcbC0&pCf3V@;%)Q^U& zwgA^mpwbaMPyyK;qpo&ZO+itBjfGEH*}3-Qney4;87H<+o~$Xr&#z%W!LP$tU6%cg zx1Js&qn@6}M*-Pzuet69f0+b@)cFK$w6ukkd3aM=-ZJv_ESC_Gm(S8vRTYvmkd_fJ z*HmU8=aUg)U}n(%f0~_}C7D4KG#byu=e+@R(7hG|0|z5B3uKCk z3AApINgmW_VNYaWXO{%cfINLtDJ(iZmR}tg{wW@_7lXReF2dFLpXF_2@eHf;zjQ`5H znA9|EtkqPRq!|@eZEQ6(m^fMd3G{6kGZ=XoGcDBB)l{wj)Z40SgW6e^jKYk~g!(*8 z|9}0z&b*67hQW}*ilNhiOP-OHS(A|wJVysg(Xwoe@JTubMg|tfbOvT-N$^PxkWmBB zdUHIAKntuL@W{)9%6SKVBO@a#BMTE_bu|qkc6KRkP!0zTqL`bRtBM($iG%o{&7JC? z(E?@U**Z|84pa=lXX`@wBt=9-#6(48_!*@vESOn1m{e75tz_jH#Tk{9Ohp8JJO0hd zHV~BxHnO@YAtAudEGo*>$@=fJm4d0!TvtU_CPro!PA(oHo;Qq)j8`?l=lf_ePg4{T zW#$&vx|&eTCi3spL5;#M#-%aj!p6B6X)V&dQs5EK!W;oy)6Hc*sj zX4ciUyIU5e%EidSl$3P2Hit!mQQlHXK|#Uh-vuT{O)WMrMio_CGjm1-1v4v98|V)^ zs6EZ7q2b1ODBIA0nURNAR@TVS<6o7%p01=MH#Z{_FT0|=xudEYhkT633+E8Vp?hs(S2O1+~D9w2XSqb_79V`wQ)c&7mKEa~H zV9X!^8h`+GFBm{8AS9Ul)K%3~nAs(?RY83tP%2c{V}jl$0-2`(HFMb^0}$Z)2_(

orlEsLL4Rjyb|Njg<|IaZ#0pHX0=^q35bl^5tDTuFGr9i&^|NsB=|L2&l zvV!gzg6+Y447uNjX$S0%-v5jKE@wK$dLMN6s|eT}h8$K&s5zi3WI^ri2mi0KyRuj@ z@G$r=6d~H%pi*6lfr*2G1JvH;;9yQbKIz-*0*z&p}P>`WS3wgS9=ABalWDJU{avM@8Vuz*(8 z`{>BX$jZnHbFs_&*`I3-_F)rZ^cMi7Vll=`$W8YL+#<@eHtcXU0k`cyO?PG%eM5)8 zlf;DuIXFOO$q93?b8t%Y35NzaaIFm_<5h8#q} zCCSLZYzaOFTiSt3l983!l97Xf3AD9AFCfA}mX(p2gAp_tl*z`(!UFE}GbM7eL!0=Z z34I0z4rYc-237_RMph1Nsv;fu9PC8YRYX)Z1T6%hfh`7^HezSP((PemXJcmv4`703 z=^(|8xtX~zi#Z#p{bp>$e55&6T@us@(u*((&Oh4s!8bHrr>1TpxD%vf=d7a2$o6j; zJCmk{m5HVnqcD@4g1e2nCL<@Kg9I}hqpGT-DB~#(VRltmWB3NQh|=t}2ZI#BA#16m z%*dpwYWa_ukqMNC7*$n`{;V{RQ)FhAma$|^VPu}S2NI@?|Cjy0!s5^7#o)oG&coJgFwq$4@M7Ic`JfZ-hu+a4tzcasNNEV>@5HlB&Nck zGYmkbthpT%==20uP{Wpy*G67}ku}FeL4lcBUfwiUoKarc!ChO6iCf;?-P=iAQHYzD zQ&q)+ozq4^fr%+O{`~q{mTMC%RFoN&l`a3xW@1v)fE1`IhywNZS9pQCDbLuLiHVy} zM#xsz=U=0Xwl*W1zMehf(MRPajGmi7<)?xvsQe6ZQ2~{o;Q1rAS|&Bdr;zf~|2L!r zfZXH)8h?V`%>}ye8+MpKKOLs`|*PE&{RpOJ;U z8c3@!Ye5EAw4DChFf#3P(AEUaJ5Krkm*oK4I|fCDDO-8LQ%4YQiZd`VGcy%{hSPE4G?kj{Hd(I50w8X^>k&Zu)bkWgawqt6sQ&V9U zXHiwPbkZ*@0PEnOR-khQ&#WSyRJNOo)-kN>P!KQBlKsx)_s&ru}aX zb7jzZbPNgquQ1IkKu-F&*b;6c^JKmu6J7 zRa9bSRoAeY!Oy5~VE^a5nG$Gw80c=||NA(+Io#lP)HyNPFdl@YPR4_fx{v{KM_n$H z8UyH#I(7yphAaPHK-Bzy0aXLJ>&^jgPAM}l_?}bH9e}WVP9f&B!p$jVT*bf)IZuEY zWDKZH2sVdZ5^hc=<0*_i;9zq=XCHvw!^V(ViFWn@10w^+|1+#^tdAJH84|$>ZL147 z(MfN0VPs(f?{@?>*~PYqyxroJqU%*>FJZR|lQNDef@#l*r48ZH2z5pD`vu>n^Q z>A$udeYYBIVl33U<^)tfKHtsv>- zl%=ogATKT?Y3p31p^_JoW1%T4y=3(aZ`K2HEYhMXQVPt!xy{v#3{>nGSt6Mj=lk0` zC@3l_SLB-51S-oa@#`v>swwEnDsVG1sp_WtRA=^7&saM zjO?iA6~N{z*g$ClRHlK_L>FqB0Bz@C{K1^TEXE+npx{8@J|bbz#5ZUm3h3BO6VTP7 zt+vX_3JO+Cp)OjQI@)f`A{rVFw(1({YR=ATptQ@t$RNe|jqMh*5LxCcpY}I2)X{Ng zt@PH{GcxjL=2KB|by87PRdsb$0j+-h{~u;P6B`p~KNHjcO@CLhhW&rRz|6qTpas5d zLmpIiL)HT`Gc$#QRt3j1Ff%dx3krfRw3gBaUA4oiD5xl^D5%I9_A7>U2?$I4`=s>m z6G$(EIg1cuAzJ{0Faz6WVJ=1^ZP21F(6vzD=9d|Guzsr*XqzIVseq!Yowc|GryRSw zCYu+lvWf*4%fH|LYC1eTCKm2r?c^09W9!Z=!iCvWPOXvjsu#>`@Ts z0^iw#H4MQ!`Y=M4sTEt${#%1hl+6be;9rpf+)PV@lMzjj%~T5%3rq~r|E-t}z;OTy zF9lKXeL~pc0h<_OHz;n{Sppz&qp6ai&cVT`4-#Z!4TT7*tL3V5Kq8EbeJ&)zxb<2M z%}td-k;uUb730!x)wcviA|r#;e|?rcZ1Wh@L3d+-&J|;303H4XS_a6-l+MP;zyRuv zGcX4-GAJubONt8fgDxBaP5N_+X+y4d16>*bo>x?bEKdVLxXLeimL6uZA!{6o|>AB zf8?A%CxdBfxH3OyQc-mU9lY=4sIJDq$RO}vpJg-KJO*0^7Y9c!Mg~S!hDc7((G{TG ziRtW&%xsJ-tjsLwT#O71py^Tu)IPW4)KY&n6WA| zcw-l6_6mA?FvwHppoWGXld`ff+l}=lF!sMYEMPCIs@m>^`uJa~GRW&1|8$(RLFYqixH8^kQdMzvQ&(fGfd;`w zM|E{j`NNpO#K`Q){FFhO!EUQCc#DHK`2H;(@NQSojHm^uIuydZ<}K2Jn~{N!n?agU z3Ut#OBeVhpH!s08pqV*nv8I~3nle*}lck}8g0h0Ki7f}{4ofXIDFqvIC+4Rr%4X(v z&K4#LicFHCMiy?&g4QMu%HZ{T%na@SC$q9J>}QZ>FlPv6uyQcxU}0wh?UZI@h=ej3 z7!yI0hYXB?mKL%yj0|qh7QvQ5TIw?9vStvsadL`jtKxAR=>7uO0EIZ3yYa~}3aKl} z3JZw{%gL*-s>sTSNiy>bt0<^5&c`9X3%mG!F)>9Ybxjoo5fMgyUU_A0CT>*)4NzVn zMHsyIX2Jj2oK9@X;5+U78L}B#85S~ZW~g=GZeZl#^buoXU<55205zO788{fYnK-yJ z*%+BvLA_+gOa@L)u0#edE^}}ToH-G6YqlA)pR|KI0~Z4)2N!21rXJ8h9$aOlgV5B; zo$b|?dRjVKAgsv4DX*;x-4ktU0xGwS!OJB;Z6S6x@Y!#mQ?tQsA!x{hT0ti2YK(N$ z1unuN$IzOanKPQHtFao(D=A1xh)MDbD#&SQDJl!_GO|gFCirI*gony;i)+LNCQ3-! znpi2?^D!%`Gij2f{KrpeIcafGVKH8QX=!B@IXOOVMpkj*5ZCBLA8#ox5#>OS5K%Ex zJtJ9j9%fl3CMA76J&C^>Qo=&!W(IoFOh-ip1=jL1GV<}sDC#Q8D2j@TimFJ}hvYkH-Kig`PS{#!`89;U-QjEp=ylCm1Ik}|?VLc;PA z(wv;iQX-6Tx=c*A)^V!p7RFH~x-9HcGAd5tBEnAA-cC|HJchq~OpFaBB!q>`SvTwH zXfZG{7_b;JcCk4y2*TPVjEMRad?uZ`8l#|{ma3pBpS-k6%J z>zV(syRmsOs4;*JpkrrXX50ZP1OKP9=CEq8HGtZY;8vX|1KRN% zX2wjMkUQK!wUaTZjjG2CzVc@Vqn4%%OOv<09t(?sfzR&=E?U~GUmR3aRFs{aRg_gh zeZK!6{vTz_V_VH2%wP`bi!d-S*nqpZTmcbV85kJs!5kh42P6X(jEs!jti{MFt_=<> zM)0s4IE{iDT5M*sD^_JVeB7+RVeu=?vTGwZXkHEZzjnEgu9w=mT+U}7?1-DY5J zstekE$1smsfw_gvnL(2w#(|5Qk%du~5wgQbBp|{;6trlh6*OT-~l?Y zLacpq-}RCZ-y@ z9uue%WCsNsxa}!y#{@c)NK{ZnT#T8?T1|$LnK3wcUV;~wf`XQTyaJ;TldP<EoIJCVx~w^)`K<6TMn*+tAt7mLoxd9l zZh@u%*p-yk)Il6m6E!tuR`6yhb2D){Hn6urg$ii+4?GVK>gE`Wi6gNgd*z{5U_h2p zfd|WBJAptuRzM}98B_LE7fnrVZMVioPWBfN=D+`pj2z)X(+^q;adGnV@$(7lX=q3r zCincC`(oV;Hd|X|B?Wn_baoCXtBAExj8}|>g`Y=EOh!gdRzX0}S-WIbUdPGaTs}5= z1siiUH7-^T4pvrXjq03~X^HZT(^S>Ly)I34HFakfRaHiIw#?uFLGd(setuyQE^ZcH zQBmE1_5;E^r#CLu)UdZz*U-|?0MY8pN`!@_l~t5v1q7Ly*;x75IF*^$Tn$;+OCopM zXlk;v$;g^1$;k5XGHbCgX*+T`{r}Hk{{JZFTW~%#VgTKf9L8J-uHQo0TtIu0{!jUT zlI0|u0D~ZdGD9$^e-9c>muFyPV`N}uWJqTR#U5xd$pSQN21=+8JQTF znbP4(8CY3i@{tbWB0@|I-k#Q$vQk3IBFfwx41!F89Gqg>!m7~g@RV7hHzNvzZbSlg z+ugu45GGh{`tE{TV0_Cn3QkQ38RW}UNQBs!+RaE)+anHZW|Mu@Z$N@UNPt(lP z*a&obpS~#vYgGNE2UR&{HVh0b3<3XVv)QsGFc>m8GsH4fG8j7;M1il{0xhO#1+7eA zWM;}_U_>n!ajC=cp*n#3U%>U~Z?Pq@@>){l2t*byn zP>`F8k&$0kUtf=n=@gc_R*t0Foh8tQjl)z+!9Yz*T*O3{pPz+QUR7IL!@*cXK{-Et zMt*uP7h8gesHlXvq?EWGCzl)x;|DBN=C69vt838u<;4GcnE$agG3YW_gKq27Q<36> zoWEP9&d4UNtqK|11fQ?YY-Vl-I-LfTSRo}4C=G*8?66~EGz0HZ059njV}8Z0scFi? zXC|*CEX-@blO@#{Cr}+`zGq@S_m5( zG54p`sL06h@G>&-3d@7)LKcR!|JOKk*cccD8H^d68G;#RJ83tAZi)1Qu|p<}K}9}0Gou22_7<32s)xqU5y#Mh8vU_APr~M@1W6SRePpFPhA}*J$;YAv+U1m zGRZS(XgFD`sW2)3l~?t(R#9P6VpdkRbJ5gfl3}{5?&QnV{W>PKw0P^$?9%jzIz=WX zMFsN!IR$0WAlEChmVwF|YU*xIN{USP^kP{q*{iCmC|Uh!WnQPp$f%@j^_$Z{LxYh? zP0g9v^G~%tGYiX7riPw57s^YSS(^HfpUjT_cVEpvQCEwZRYTL=*;XCYU1VTnFlG$l zY+}95Pzvf-7pqAyvoJGdK$lK3F}8xk8+7ds=+J`(24)5p=1Axf;mj<7j0~yC;i2ZH zB0>zMjHPU#;={}wvK_*XNnH&*A`V`o$_!fH016>+Xo8n*sq4YFAw$X%(9k(}bViOz zTn==R6lhZ|cnTU+w1CH~%*??%n87Eu3k!>}C6`;Dtr5`IbvBomXJcY{D5$1trKaet za$_U6xS%jUpO~zfs;ar>*Fk&%=Z7UAULkl^8AV>OgvVUbV`^D)rjmtTFgNq3u@6rX**^UaGZgof*l-Rpq3CQUx0>6 zKx2o-#-O<=aIL^>3_4FmOpIB^RM$Wx#hzPT*)Tvc#hzDN!;FJVNl1{9b1JW*vYxuU z5^stVx3ZGHy1a}4mxP+d-w;zdaFfvBDT}hQxtW3@(_=Y_MUwpd%ta#N5{94OY0F5l zas+&OXCN)f$HdGlDIq7T$aqadRFsKHL`?quOLcJ&OGs8yKuTW0P+LakU$&vF3^S9w zya8hm$Gk>rK}_qE1M`I6AS2=92RC4 zCguhP1_m}(2DVHFR(3`fCRUbo2GFDwDAj`&XfrbgGBC(UNr*FuF^DM(sws1@Nog~h zKqd=7H_wQ(n5#q1!536j6f`q76K7G+>HKwO!ouktw;AIq3(Zr)%gYWjmHh4LU%O%g zli2ZjC;Aus`~II{dOIW2NhVuOm2Sp(Pe#t!jC20~XK?yg&a#GWAA=f06$1l<8Uy1Q z1_m~!xQC$m7uSE~EbH0MFsU)cLFCReFt9O{FfcH%GC2P$XIaa(oxzSFj3JGoj9~)9 zX9s>EMi$2G^wh*aS2j*~QEbHk@;+#>B?~JPO98Z*kj~4<#lX$R#huQ>$i~3I$;Oe% zz{$YQ$({-_K9PZy)ehVd0(D0$8CV!u8Ch6CdM(%ppw2(o9KBosUyS8`2GflpUbttgOUt4lC-} zK@CK9$YyjUvDfYM@pjsAt3ux;0ac8FXc) z9FwRR8yjfbKS&Q~M95gt$d1+2L|uVt$d>qUyx`rB&GO}7WVqA%zo3AOtb(EjOMtMFn4Fw!B0F1b{-QXmAR!}J852v;%{5|TYRvy08ft2?GBPqT z^N6T%bE}D}nMunsGOKE^bBS^NZFh96j&%1gZgq8KX7O-uDGRjE&b7Aw_mE3eiJO;^ zi$_|BpP!rK-(5jbQE_2$aTzTkVPRqB<#KX1R%L~Y7OEoi|MqbOr8 zEXgD-ZEPeh{m({M&p=Y@Ul&y)hKa%J|4HVnY>W)@4EhY>4kDlhIG|ai%Xv9p01O5m&n+LdH(_Ct%E-7G9FO!43RP+K99 z{D$7%-t)Yi|5DYJlvGufmE}1Z=W=t}Mk(lO+iJ_n3rm_hvR*MZGgr4O&GyQ9zOcSC zA)kXYp`URkqYL8=9eH_CQASCQKhNakw=cF95$5BPle2Iy1QqfB|1&`EF=t|9EC!Dy zLGM$BpA!eYkC=&#u@RKK7%~|Duw*c^fX0;Y&V90pX@e3BG~DW>QhHwNh45QgU=ugv{~%|NDOf%RRPG1|c19L929`_)HdaQqNCs9$MsQAJOk`qYWMvFwWH2?>)>K!O zl@=G}=jCQ+kYtqP1hxM7m_Tg=aWi8x@aZ1l)*HJTqZ#PtHBk{!P>T-K;8s&pHvw(Q zGdE-0y)ZnSi8(B6DHqqn?dO(lPl#t@OHJExarS=3q{3uxIXMS z2XmQUa>T|h+mM*Z#K?T;A_qrY+{*27acpc0cl_(Gl#y{}XZMhmV`Q3G&%z`rX~)9q zAuk8o2Fbw4Ap0+kHJoiPgE@3{f#m&Fnty?Qm{QO2W;^7b+^Y@E9CLP9Jo!ip9a z4jVXGqoS8>`g{9d-Wxv&2{tZC3Efa5BX)KjT^C-yE%6DUexnb=O_t{@#~8F3!Wn!W zybKuGSs8smJ0ii&7dCbdb~cU%1`duE1{Ma;kCT3$K{wI6((q|3$hzQ9kkXER3U<6SIo=|9430q5FwZ#xc>>= z>I^=!mW_?EQdM3~ic3I1T1p{tM`uhn8;iVxld-ajkg7qDvx<$aUwL7kx{#oNp}D?3 zQ>?0-JV>#WG)%FAypyqtGDNYh4MeekAV@K@tD~|qJ2MNbl$1_8BcpG0g}c5U6O)|0 zaT*hIwhd^+F+8-~Ku1SJOh|ASSS>3HLam-Y6O+6grdmNE24;p`{|~Zgu)2ZAtV|gU z9rQqRCybz_1FVdo6;R=z6_cO^OiaxFsw%=ldfF*m32Ye?k+9{mO7TXr_2 zMggeUP*YPz5dr6OX63&R#Q6F34YagHnFK*6ZkT8){1dcMR#8;6UjQ)%aGE!XZ41$aTENqaeIZ($J z(pv_#c^TQ*MZ`cW$w6H*H8pirRq%#Ro{`W|qiAht{$Vi8=UP)Mp3DqmJ8GZB^8BG;bR239tU%gc`G11jx zVpd@^m6n!P01cBeF#g}i_?q<%(?l-B&_-j%aM0lfp!H0Q4E~_8R0d{2W;Stc zW@c4EduBF0rg#7TGJ~!pV2b!VnK_c(iGdkZQ-kzEDrqD=jDm`SOc9KX>`vbTIoE?u z_Y3|zne`3R2L@Kq`I?|be{Ia5llGay!B;=WgKnM$S<1@5Dk#XpCZWwJtSGE1$P^5c zW}3wG;n!l8a3)3;Q2qJ;z~9NtQDC#=9Hc>05N!;g8R>A4BqZPk1qGPdB()h81zCj^ znWO%sF->M-WMca8?+?@Ol^|tI!Hln1->`cyurff+08MW&K!$%HLpTUCRGAf-f|+Oj z&13rakMR{F3lj_YZp8ol7~e35gUwKJPz3p@l>u~MIA{ooDIVlKCR8smD>AbRGKVw& z`^WS*kMRv_%+qS1_KkL2ZICiC8jeZyF-;(P*IR^ALG1#No7o3OdmN4}&Ffs7`Z)82g zmct;;AP>6zSzb;Sdi%2wXxNbzG_uGDo!nIi?G|STb)!J}M4XeEDapgd+Mh{>pNE&1 zOYrX%4`-{uzgGmgd3gBwn6@#l_!sdnLN=0>C5dUBh?KOfoV0`})0zl2*5tn-q7pK) za#FH_OnHBQ{Qb(r4T^Wh00tlC158I4WI-+gWm0haQcoMyAOcwnX|F>LSpknafXAXi z-EAf-KO-wiaV^6TM^90)ICl?m4i0`-2M3r~1w#itHt!Nics09srKnl}UWD;PlwVRW_SW%+p- zq#30_g$Af)3t1xwYTtskoPf%B$j~3?jA)Q3IIy5c!@yk4)a;>cE+NXqsjgzKq$VnC zr=}^wp(d-R{oFg#$HURskyqN$DMm@z+$6%3ONnF&+IUKYP=llQlEMlz94Dt*%4wgcU zOpH?CxkE`tW-riXz>G|wqX|I`gF493N{o!)K|Sd3hKh=am@+Gyw6;2|E;nU16BCnX zg0u(N*#$vkqD*X_9<_&36uE>Mb4*NF889cTNR<^qL+#+^5p*uZnh7*AZ)R?0%LE!! z1`U;|v9qznu&}VIXnGjC|9h2`%bVq{qYGM}?UFCdsH*1XtFOl>z}O?etgY?sqO8ow z$Ec*}#LfBlHM6*wiE%=J1qVB$q^yymIx`a&hl!dhFAF0JhpL>GsF;|j2I~%HW@9CF zZVo1{y54{9R8^gw)YSg`2Q8dp1TCCm`J3;erO#-n=fU*PO~-(VF~;3VMw)|No|To0 zOHWNlOpuY0T}jbUK#GY`L|9u%o`;o*4V)JlDiQ zY;5M>t#QnW9E{A&;IKDi^-~s9R}t1wQBmgPl-Cv(5fg)j9B7{a=+0A96VS|&vNC97 zg&91E0vcKeW zj!{*`Rz&FEh1LJ|3syU6YBG1*si^$<4)(H!rYp0sl9B@(o0X#6WKTb?-$u5Ypwm1x z?O7un)zw(}Ewle6Dl3E9J)sOt%&XY4NG(f+6`6$<1sU}imoxnfz0SfM$U5`43G1w% zv)M90bp<2C9mZ8GOiVL~=|3Vh`^?QibJ5C-{wDGYagv`^(-aj=jTDs> z6f7*{;5xk64ke6m+16_iSC~%C8L4^$no0@`dF$NV(%4+x3 zW#ssI#U=HW)R~w$IV_Zw`B)iQI3Wp&Igd+9N=05#S6xR;h>?+zT~XddK#Gx3L|8{j zjt8`|fRW)Y%S@&Ow&e`63~ZalSs9JA8Nt)v;HI7$=wv(S+&7!J7-OJ>l%$NbjEs~r z4-Y4&q=!8F3wFTTKlPP8k_n@4$Kn zMn-v-xl9w;RzT_lJ|4(v>}8VTjG)e$nK@_xjvd@j1|7Ny>WCQ|*)cOIn<$Hm2$-7r zI4di&u$l@8OK@=Va4JhHNQ;Y0O0YaLt`G9Em67J)(9pDV))tpmQdE?emJ;FQ5ENiw zWK?9C!8C>&SvCWYjfz}-J}P>xpvb%6Mo87G)1ONa`XnIi1rx23@Kh-R#qu4J{~SEZb4B- zaXC42b2&LvBV{E;B@=0BMy3{b#x^ltHWon^76Ae2f4$POvT|~g+#Kx8%#6&85vIy2 zD$1r25)wMP($X_$NQj7m#;|YwTgcSJ(#D|9Ai$u)e1MUSfsaR*L2x4j;|}Hn8%(t~ zGBR$^1qA~GGo#zTa^_czPZ`7+)EUe{_rvRGs3{4tfzNFO`JaiIfhiMxv>r58Aur3z z%^=Pw&I()kN#c+n(<(B?@ED(xH6#Zb$!9zV>anvhgW77~Ry-pMD+3b)7Xu$yoQ;8r zfsH`|%w}g`V&Gtq1G70mcb0)yOfj;6mVs|$FanEnGB7eoFo4#=gT$E_I6$j>89;1i z1~Wzus2UaqB}O|ao0TDiF#*bEV^Cn+0%dbB2r*uPvN;(<7@tAeT#Ocszo2Yxh9G8` zy*x;4UPdM67N{CN1~ZmrU^eKUH9nR*U^X`c6N3=TD=?b}1c}eqc7}1Qik17%*EJ6rQZ0 z*$ze)S&&`OFq8+2SAo^2fWng%;$~g2_ymwR2a=mPkj&>n66b-r-;W`cA)TR!A(f$o zA(5ekp_GxAA&=oDLmERWLkfc%SiFR}gLyCW5$65Odzm*fI5Xrk6fjgW6ftBnq%&kN zlrShT7%>yvLAFAV7fqIf!&ud6=4jiV1H#YfqkjYR67E6Wtv4jDn4;1sD^ilv$Riz9$42cXyXz3@IA(0^u zoHl$Jf*JG}6d2s0dO;?DQXq9xivmL$I5jFT=R64j$Y>ICH(P>d)rxPem% zC`Sb|q8O8cnx=z+r@QpzC5F>$(JWgDpUNMr!{I|H2h^T94G zWpHOGVn}2tW^iM8$&ka4&G3z(jNv6i5nLP;KA;?$4-TOc1`lxhEd!U0B@6)!`QVZh zR4PGocO?U|jrlC{EOso`EY2)uEEX&dEVeA(ES3x*@DkP=WE!I(Lmq=Sxcn_($Y#iA z0Oeg!nU=@^T4xGcBQT#~J*XLvj+sECPM}kuSr}Lu*cjLuI2bq?xEQz@co=vY_!#&Z zKx-O>7=#%_7(^Mw7{nPQ7$g~_7^E3w7-Sjb7~~lg7!(0ER$@AckOu5Qb2OFotl32!=?8D28Z;7=~DeIEHwJ z1cpS0H4KXxS{Pax_A*Rkc)`%d(8titFqdH^!xBbjhAu`HhGvGz44)W2GxRdDGITH; zX1K@jkzpRgDu%BNUl_hIEMi#8aFXFT!#ai}hGd2b3@Hq!8B!TeFq~pI&v1s}EWedy^$h6@9~dSwTw}PxaFrp0;WfishE9eohD?TRhFpdmhCGG^4EYR&3L%)JBD41Y>e!T9E_ZdT#Vcd ze;NKU@-Xr;@-gx={AXlf6krr&1oc>i8ATXH8O0dI86_Ac8KoGd8D$t{8RZy$F#Kec zXH;NRWK?2QW>jHRWmIESXVhTSWYl8RX4GNSWz=K%#c-WbpV5HPkkN?In9+pMl+ldQ zoY8{OlF^FMn$d>Qmf;b@V@5khdqxLFM@A<`XGRxBS4KBRcSa9JPew0BZ$=-64GbF@ zeHr~2{TTxo0~v!DgBe2@Lm9&uelz@Gc*5|MF`O}iF_JNgF`6-kF_tloF`hAjF_AHe zF_|%iF_kfmF`Y4kF_STiF`F@mVK-wgV;*BZV*$f6hUbih49ggc7>gN87)u$;7|R(e z7%Lg87^@j;7;Z2;WVpp}o8b<_U4{n?HyQ3T)-u*H)-yIRHZnFbHZ!&`wlcOcwlj7x zb~1J`b~E-c_A>S{_A^dkoX9wdaWdl+#;J_c7^gGNV4TS~i*YvN9LBkf^BCtdE?`{9 zxQKBv;}XWDjLR68Gp=A<$+(JfHRBq_wG8ta*D&oG{4JjZyR z@dD#T#!HNs8Lu#2WxU3Co$&_aO~zY{w;As+-etVUc%ShB<3q+rjE@;M`SB$S2-!Q&qe8>2n@dM*W#!rl&8NV=oW&FnYo$&|bPsU%2zZw59{$>2f_#brW zA`=r6GZPCFD-#wr8TK*kXR=|kW!S@H z$MBuWp2>m9k;#e4naPF8m7#~pjme$KgUOT0i^-c|2Ez)5Sqw87-ZLy^n8YxLVL3xT z!xV<8Og>D$OnyxMOaV-ROhHV+Od$+AnL?Sun8FzjFdSrxV2WglVv1&pVTxsnV~S@= zU`k|5fPFyhXrEXYXB%gxLyE#}NGOD!r+&M!)3&Mh@$cg#&pF3QhiPlV8Hj!8wS zWvOh5V2a%_KRrJ$HJd#VLUTJOXBH)w=BDMOR&XaH*euQ|`6VpLDfuOAF3E{S`FU(9 z5Xz-EC$Tt#-4$Xudn$xxa|N5umI|g=TtS*sQ}RpLT_M)6r$T5pSBNR8V2axvVIg-q zg3aO%(x0A^U&7&@oSKrElat7i4rOqAAk=YZAlNJ(Nr^=)8A*vnY@Q{VIVq`ZnP7_D z6XHVlObE^933eh|CYWOPgt&`66GAh2dNE~YaeE=m<<3H|IlQ5c;K+tDc(T)rQd9GC z67y0rli7R{lS@ld*>b=XhYwT@M-G(1<^%RMTMn3F@d0@<2Nc47U=3_}V2Z;Js+=Pa z%3$*YYhcR*Q`~up1^LA#Mfn98sm!i<>CCBl>FoXx&#~u2Xf}Va@7VIe6sLbm62wiM z`7jn+5ZE%dA~3}f1htBz2+Cj!0$avb1fhb#%Gip*6n8KpNVtm;Y>p79W{wglgDnK2 zy#!3Lgn*)`1QbP~;J9Hc1yk&y5LdI8LTI*7u(R1p!4zv~QD$B`Ybl82428L$vlPZ+ z4~0Y>dnttG4oA3%yBvufiNvl%u-T%(o?@#4Q!G)SSgA_MFJaBe&r2`nas=56mf}i; zGua(o!9whbsbHGhADk;7?0f{9CpbAjH#adcIXN}2gr^vp6N1bsLE^BN=4Bcf8Jlya z7bTXZCgKB{Q`swK%gF%;R#*Dac3!GdUCU@=H>4QZo~ITnmabbMo`R!W^z8Pb3sNXb8+A>2Pz5XaDg?0nVgAfnVE(L21dp(mZ>3}Wdvs-WKG~=rf`-SoMjGYS-@GAFqWAC zoCUYT3}%O+3EVtGxG9EkQw-sz7{N_3f}3IlGsVCN?mi>9c}8&e8Np39f}3mvH`y3& zvN7CbW4Ou2@Gvlj+hGj1!x(OdG29MgxE;oDJ51nqn858Yf!kpMx5ET(2O?Zd;C7h6 z?J$AcVFI_q4DKp3xDGS84l}q8Gq?_OxU0!vb!H1>6oxxE+>oJ1pULSi@YNd*rn`lFC+`nw+1P!kJo;oRgTFl9a<)l$es4oS2hZ!I@WFT98_l znO_7k!@|M2plQ;~&A%%@8uhfyEdZ7#IW?Bp5&=(=rSy42(I6C3y@+3`{xs z$vF&iU{VFDQU^+Ffz1UO#t0T+@(J-!U{DG2_f=p3sZn8E%fQ5-!ob1^?E{17wLmt3 U=5s)LKzJQ!cQUyDt<0zb0Dti+cmMzZ literal 0 HcmV?d00001 diff --git a/apple/aesel/Resources/slab-theme.json b/apple/aesel/Resources/slab-theme.json new file mode 100644 index 0000000000..91a4782ac8 --- /dev/null +++ b/apple/aesel/Resources/slab-theme.json @@ -0,0 +1 @@ +{"dark":true,"enabled":true,"palettes":{"awaiting":{"background":[64,41,14],"bold":[219,255,232],"cursor":[219,179,54],"foreground":[219,226,179]},"blank":{"background":[0,0,10],"bold":[219,255,255],"cursor":[167,195,232],"foreground":[201,233,255]},"complete":{"background":[66,7,12],"bold":[255,214,206],"cursor":[255,43,39],"foreground":[255,175,167]},"interrupted":{"background":[28,11,70],"bold":[201,210,255],"cursor":[134,86,255],"foreground":[181,179,255]},"rendering":{"background":[30,7,36],"bold":[219,218,255],"cursor":[194,70,188],"foreground":[207,171,250]},"stale":{"background":[57,5,22],"bold":[219,214,254],"cursor":[219,78,99],"foreground":[219,163,196]},"working":{"background":[2,47,24],"bold":[181,255,255],"cursor":[47,241,143],"foreground":[134,249,219]}},"version":1} \ No newline at end of file diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 4b91a80f93..e985142975 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -38,6 +38,16 @@ struct AeselApp: App { session.purchasedDollars = 1.41 session.append(.you, "Make a little orange circle.") session.append(.ac, "The **orange** circle follows your pointer.\n\nTry a radius of `24` or a blue background.\n\n```js\nconst radius = 24;\nwipe(\"blue\");\n```\n\n[Open Aesthetic Computer](https://aesthetic.computer)") + if ProcessInfo.processInfo.environment["AESEL_PREVIEW_PIECE"] == "1" { + session.previewURL = URL(string: "https://aesthetic.computer/blank") + session.source = "wipe(\"orange\");" + } + if ProcessInfo.processInfo.environment["AESEL_PREVIEW_BUSY"] == "1" { + session.append(.you, "Now make it bounce.") + session.busy = true + session.status = "writing" + session.health = .working + } return } #endif diff --git a/apple/aesel/Sources/AeselHomeView.swift b/apple/aesel/Sources/AeselHomeView.swift index 9a1dc62ba3..ca2d3a8fab 100644 --- a/apple/aesel/Sources/AeselHomeView.swift +++ b/apple/aesel/Sources/AeselHomeView.swift @@ -6,6 +6,7 @@ struct AeselHomeView: View { let host: SessionHost let resume: () -> Void @State private var tab = 0 + @Environment(\.paint) private var paint private let media: [(id: String, name: String)] = [ ("piece", "Piece"), ("picture", "Picture"), ("sound", "Sound"), @@ -16,13 +17,13 @@ struct AeselHomeView: View { VStack(spacing: 0) { HStack { if !session.route.isEmpty { - Button("/resume", action: resume).foregroundStyle(Paint.dim) + Button("/resume", action: resume).foregroundStyle(paint.dim) } Spacer() if session.signedIn { AeselHandle(handle: session.handle, colors: session.handleColors) } else { - Button("/login") { host.signIn() }.foregroundStyle(Paint.dim) + Button("/login") { host.signIn() }.foregroundStyle(paint.dim) } } .font(Paint.font(20)) @@ -48,12 +49,12 @@ struct AeselHomeView: View { Text(medium.name) Spacer(minLength: 0) if medium.id != "piece" { - Text("desktop").font(Paint.font(17)).foregroundStyle(Paint.dim) + Text("desktop").font(Paint.font(17)).foregroundStyle(paint.dim) } } .padding(.horizontal, 12) .frame(height: 48) - .foregroundStyle(medium.id == "piece" ? Paint.ink : Paint.dim) + .foregroundStyle(medium.id == "piece" ? paint.ink : paint.dim) .background(medium.id == "piece" ? Color(rgb: 0xc81e64) : .clear) } .disabled(medium.id != "piece" || session.busy) @@ -64,7 +65,7 @@ struct AeselHomeView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 2) { if session.history.isEmpty { - Text("No saved threads").foregroundStyle(Paint.dim).padding(.vertical, 12) + Text("No saved threads").foregroundStyle(paint.dim).padding(.vertical, 12) } ForEach(session.history) { thread in Button { @@ -74,17 +75,17 @@ struct AeselHomeView: View { VStack(alignment: .leading, spacing: 5) { Text("› " + thread.title) .lineLimit(2) - .foregroundStyle(Paint.ink) + .foregroundStyle(paint.ink) Text(thread.medium + " · " + date(thread.updatedAt)) .font(Paint.font(17)) - .foregroundStyle(Paint.dim) + .foregroundStyle(paint.dim) if !thread.route.isEmpty { - Text(thread.route).font(Paint.font(17)).foregroundStyle(Paint.dim).lineLimit(1) + Text(thread.route).font(Paint.font(17)).foregroundStyle(paint.dim).lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) - .background(thread.id == session.currentSessionID ? Paint.deep.opacity(0.65) : .clear) + .background(thread.id == session.currentSessionID ? paint.deep.opacity(0.65) : .clear) } .disabled(session.busy) } @@ -103,7 +104,7 @@ struct AeselHomeView: View { } .buttonStyle(.plain) - .foregroundStyle(Paint.ink) + .foregroundStyle(paint.ink) .background { AeselCloth().ignoresSafeArea() } } @@ -111,7 +112,7 @@ struct AeselHomeView: View { Button { tab = index } label: { Text(label) .padding(.horizontal, 8).padding(.vertical, 7) - .foregroundStyle(tab == index ? Paint.ink : Paint.dim) + .foregroundStyle(tab == index ? paint.ink : paint.dim) .background(tab == index ? Color(rgb: 0xc81e64) : .clear) } .accessibilityAddTraits(tab == index ? [.isSelected] : []) diff --git a/apple/aesel/Sources/AeselModelPicker.swift b/apple/aesel/Sources/AeselModelPicker.swift index 176ee96c00..48dee447c4 100644 --- a/apple/aesel/Sources/AeselModelPicker.swift +++ b/apple/aesel/Sources/AeselModelPicker.swift @@ -4,9 +4,10 @@ import SwiftUI struct AeselModelPicker: View { let session: Session let host: SessionHost + @Environment(\.paint) private var paint var body: some View { Text("Braincells · automatic model") - .font(Paint.font(16)).foregroundStyle(Paint.dim) + .font(Paint.font(16)).foregroundStyle(paint.dim) } } diff --git a/apple/aesel/Sources/AeselNotebook.swift b/apple/aesel/Sources/AeselNotebook.swift index bc69fb41af..789ce02af7 100644 --- a/apple/aesel/Sources/AeselNotebook.swift +++ b/apple/aesel/Sources/AeselNotebook.swift @@ -4,6 +4,8 @@ import WebKit /// Desktop rich replies, packaged locally; the agent host remains separate. struct AeselNotebook: UIViewRepresentable { let session: Session + var paint = Paint.base + @Binding var height: CGFloat var openLink: (URL) -> Void final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { @@ -12,6 +14,7 @@ struct AeselNotebook: UIViewRepresentable { var payload = "{}" var lastPayload = "" var openLink: (URL) -> Void + var reportHeight: (CGFloat) -> Void = { _ in } init(openLink: @escaping (URL) -> Void) { self.openLink = openLink } func render(_ view: WKWebView) { guard ready, payload != lastPayload else { return } @@ -26,7 +29,12 @@ struct AeselNotebook: UIViewRepresentable { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.frameInfo.isMainFrame, message.webView?.url?.scheme == "aesel-bundle", - let body = message.body as? [String: Any], let text = body["url"] as? String, + let body = message.body as? [String: Any] else { return } + if let height = body["height"] as? Double, height.isFinite, height >= 0, height < 200_000 { + reportHeight(CGFloat(height)) + return + } + guard let text = body["url"] as? String, let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { return } openLink(url) } @@ -47,6 +55,7 @@ struct AeselNotebook: UIViewRepresentable { view.backgroundColor = .clear view.scrollView.backgroundColor = .clear view.scrollView.isScrollEnabled = false + view.scrollView.contentInsetAdjustmentBehavior = .never view.load(URLRequest(url: URL(string: "aesel-bundle://app/easel/phone/notebook.html")!)) return view } @@ -62,11 +71,16 @@ struct AeselNotebook: UIViewRepresentable { guard value.count == 7, let rgb = UInt32(value.dropFirst(), radix: 16) else { return nil } return [Int((rgb >> 16) & 255), Int((rgb >> 8) & 255), Int(rgb & 255)] } - let payload: [String: Any] = ["entries": entries, "handle": session.handle, "colors": colors] + let payload: [String: Any] = ["entries": entries, "handle": session.handle, "colors": colors, "theme": paint.css, + "busy": session.busy, "activity": session.busy ? session.status : ""] if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), let json = String(data: data, encoding: .utf8) { context.coordinator.payload = json context.coordinator.openLink = openLink + let height = $height + context.coordinator.reportHeight = { value in + DispatchQueue.main.async { if height.wrappedValue != value { height.wrappedValue = value } } + } context.coordinator.render(view) } } diff --git a/apple/aesel/Sources/AeselPieceView.swift b/apple/aesel/Sources/AeselPieceView.swift index 792ed29a4a..f94eea2188 100644 --- a/apple/aesel/Sources/AeselPieceView.swift +++ b/apple/aesel/Sources/AeselPieceView.swift @@ -8,6 +8,7 @@ struct PieceView: View { let source: String @State private var failure: String? @State private var attempt = 0 + @Environment(\.paint) private var paint var body: some View { ZStack { PieceWebView(url: url, source: source, failure: $failure).id(attempt) @@ -18,9 +19,9 @@ struct PieceView: View { Text("Preview could not load").font(Paint.font(20)) Text(failure).font(Paint.font(16)).multilineTextAlignment(.center) Button("/retry") { self.failure = nil; attempt += 1 } - .font(Paint.font(18)).foregroundStyle(Paint.you).buttonStyle(.plain) + .font(Paint.font(18)).foregroundStyle(paint.you).buttonStyle(.plain) }.padding().frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Paint.deep) + .background(paint.deep) } } } diff --git a/apple/aesel/Sources/AeselScene.swift b/apple/aesel/Sources/AeselScene.swift index 31cabb8e5d..fd647eff08 100644 --- a/apple/aesel/Sources/AeselScene.swift +++ b/apple/aesel/Sources/AeselScene.swift @@ -2,27 +2,190 @@ import SwiftUI import UIKit // The desktop's palette, typefaces and mascot, shared without redraws. -// Title lettering is the desktop's Comic Sans MS Bold; Chalkboard SE Bold is -// the fallback its own CSS names on Apple platforms, so nothing ships. -enum Paint { - static let bg = Color(rgb: 0x463264) - static let deep = Color(rgb: 0x241d35) - static let ink = Color.white - static let dim = Color(rgb: 0xb394ce) - static let rule = Color(rgb: 0x9b72b8) - static let you = Color(rgb: 0xff0088) - static let ac = Color(rgb: 0xff64ff) - static let edit = Color(rgb: 0x77ff55) - static let bad = Color(rgb: 0xff7777) - static let accent = Color(rgb: 0xff78b2) +// Title lettering is the desktop's Comic Sans MS Bold. iOS has no Comic Sans, +// so the app bundles Comic Relief Bold, its metric-compatible SIL OFL twin +// (Resources/COMIC-RELIEF-OFL-1.1.txt); Chalkboard SE Bold is the last resort. +// +// Colours follow Slab's theme-by-status: the desktop reads the palettes Slab +// publishes to ~/.local/share/slab/state/theme.json, and the phone carries a +// snapshot of that file (Resources/slab-theme.json) so both wear the same +// cloth for the same status without Slab having to be nearby. +struct Paint: Equatable { + var bg = Color(rgb: 0x463264) + var deep = Color(rgb: 0x241d35) + var ink = Color.white + var dim = Color(rgb: 0xb394ce) + var rule = Color.white.opacity(0.12) + var you = Color(rgb: 0xff0088) + var ac = Color(rgb: 0xff64ff) + var edit = Color(rgb: 0x77ff55) + var bad = Color(rgb: 0xff7777) + var accent = Color(rgb: 0xff78b2) // The preview frame: --aesel-background 75% + accent, edged 65% toward white. - static let frame = Color(rgb: 0x744478) - static let frameEdge = Color(rgb: 0xa485a7) + var frame = Color(rgb: 0x744478) + var frameEdge = Color(rgb: 0xa485a7) + // The desktop's --user-ink: foreground 65% toward #c12b87. + var userInk = Color(rgb: 0xe9b5d5) + // What the notebook web view is told, as CSS hex. + var css: [String: String] = ["background": "#463264", "foreground": "#ffffff", "userInk": "#e9b5d5"] + + static let base = Paint() static func font(_ size: CGFloat = 17) -> Font { .custom("Helvetica", size: size, relativeTo: .body) } - static func title(_ size: CGFloat = 28) -> Font { - .custom("ChalkboardSE-Bold", size: size, relativeTo: .title) + static func title(_ size: CGFloat = 20) -> Font { + Font(Rock.font(size)) + } + + // MARK: Slab theme-by-status + + private typealias RGB = [Double] + private struct Palette { var background, foreground, bold, cursor: RGB } + private static let palettes: [String: Palette] = { + guard let url = Bundle.main.url(forResource: "slab-theme", withExtension: "json"), + let data = try? Data(contentsOf: url), + let document = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + document["version"] as? Int == 1, document["enabled"] as? Bool == true, + let entries = document["palettes"] as? [String: [String: [Double]]] else { return [:] } + var result: [String: Palette] = [:] + for (status, value) in entries { + guard let background = value["background"], let foreground = value["foreground"], + let bold = value["bold"], let cursor = value["cursor"], + [background, foreground, bold, cursor].allSatisfy({ $0.count == 3 }) else { continue } + result[status] = Palette(background: background, foreground: foreground, bold: bold, cursor: cursor) + } + return result + }() + + /// Slab's state names for the phone's session words; the desktop gets + /// these from Slab directly, the phone derives them from its own turn. + static func slabStatus(_ status: String, busy: Bool, failed: Bool) -> String { + if failed { return "stale" } + if busy { return status == "publishing" || status == "rendering" ? "rendering" : "working" } + switch status { + case "ready", "live", "complete", "done": return "complete" + case "stopped", "interrupted", "cancelled": return "interrupted" + case "failed", "stale", "error", "not published": return "stale" + case "awaiting", "waiting": return "awaiting" + default: return "blank" + } + } + + static func slab(status: String, busy: Bool, failed: Bool) -> Paint { + let name = slabStatus(status, busy: busy, failed: failed) + guard let palette = palettes[name] ?? palettes["blank"] else { return base } + var paint = base + let bg = palette.background, fg = palette.foreground + paint.bg = color(bg) + paint.deep = color(mix(bg, [0, 0, 0], 0.45)) + paint.ink = color(fg) + paint.rule = color(fg).opacity(0.12) + paint.accent = color(palette.cursor) + paint.you = color(palette.cursor) + paint.dim = color(readable([170, 150, 205], on: bg)) + paint.ac = color(readable([255, 100, 255], on: bg)) + paint.edit = color(readable([0, 255, 0], on: bg)) + paint.bad = color(readable([255, 90, 90], on: bg)) + let frame = mix(bg, palette.cursor, 0.25) + paint.frame = color(frame) + paint.frameEdge = color(mix(frame, [255, 255, 255], 0.65)) + let userInk = mix(fg, [193, 43, 135], 0.35) + paint.userInk = color(userInk) + paint.css = ["background": hex(bg), "foreground": hex(fg), "userInk": hex(userInk)] + return paint + } + + private static func color(_ rgb: RGB) -> Color { + Color(red: rgb[0] / 255, green: rgb[1] / 255, blue: rgb[2] / 255) + } + private static func hex(_ rgb: RGB) -> String { + "#" + rgb.map { String(format: "%02x", Int($0.rounded())) }.joined() + } + private static func mix(_ a: RGB, _ b: RGB, _ amount: Double) -> RGB { + zip(a, b).map { $0 + ($1 - $0) * amount } + } + private static func luminance(_ rgb: RGB) -> Double { + let linear = rgb.map { channel -> Double in + let c = channel / 255 + return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722 + } + private static func contrast(_ a: RGB, _ b: RGB) -> Double { + let x = luminance(a), y = luminance(b) + return (max(x, y) + 0.05) / (min(x, y) + 0.05) + } + // The desktop's `readable`: keep the hue, move only as far toward black or + // white as WCAG AA (4.5:1) against the background needs. + private static func readable(_ color: RGB, on background: RGB) -> RGB { + if contrast(color, background) >= 4.5 { return color } + let target: Double = contrast([0, 0, 0], background) > contrast([255, 255, 255], background) ? 0 : 255 + for step in 1...100 { + let candidate = color.map { ($0 + (target - $0) * Double(step) / 100).rounded() } + if contrast(candidate, background) >= 4.5 { return candidate } + } + return [target, target, target] + } +} + +private struct PaintKey: EnvironmentKey { static let defaultValue = Paint.base } +extension EnvironmentValues { + var paint: Paint { + get { self[PaintKey.self] } + set { self[PaintKey.self] = newValue } + } +} + +/// The desktop's native Prox lettering (easel/desktop/native/credit-label.swift), +/// drawn with UIKit: cyan, purple and pink echoes under a thin-outlined face +/// with a hard pink shadow, one cached image per letter so the notebook can +/// tilt and sway each one on its own. +enum Rock { + static let inset: CGFloat = 9 + private static var cache: [String: (UIImage, CGSize)] = [:] + + static func font(_ size: CGFloat) -> UIFont { + for name in ["ComicRelief-Bold", "ComicSansMS-Bold", "ChalkboardSE-Bold"] { + if let font = UIFont(name: name, size: size) { return font } + } + return .systemFont(ofSize: size, weight: .heavy) + } + + /// A letter's image plus its advance box; the image overhangs the box by + /// `inset` on every side so the echoes and shadow are not clipped. + static func glyph(_ text: String, size: CGFloat, face: UIColor = .white) -> (image: UIImage, advance: CGSize) { + let key = "\(text)|\(size)|\(face)" + if let hit = cache[key] { return hit } + let font = font(size) + let advance = (text as NSString).size(withAttributes: [.font: font]) + let bounds = CGSize(width: ceil(advance.width + inset * 2), height: ceil(advance.height + inset * 2)) + let format = UIGraphicsImageRendererFormat.default() + format.opaque = false + let image = UIGraphicsImageRenderer(size: bounds, format: format).image { _ in + let echoes: [(UIColor, CGFloat, CGFloat)] = [ + (.systemCyan.withAlphaComponent(0.28), 4.5, 3), + (.systemPurple.withAlphaComponent(0.40), 3, 2), + (.systemPink.withAlphaComponent(0.65), 1.5, 1.5), + ] + for (color, x, y) in echoes { + NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color]) + .draw(at: CGPoint(x: inset + x, y: inset + y)) + } + let shadow = NSShadow() + shadow.shadowColor = UIColor.systemPink.withAlphaComponent(0.5) + shadow.shadowBlurRadius = 0 + shadow.shadowOffset = CGSize(width: 1.5, height: 1.5) + NSAttributedString(string: text, attributes: [ + .font: font, + .foregroundColor: face.withAlphaComponent(0.94), + .strokeColor: UIColor(white: 0.08, alpha: 1), + .strokeWidth: -3.5, + .shadow: shadow, + ]).draw(at: CGPoint(x: inset, y: inset)) + } + if cache.count > 256 { cache.removeAll() } + cache[key] = (image, advance) + return (image, advance) } } @@ -39,39 +202,65 @@ extension Color { } } +extension UIColor { + // "#rrggbb" from the account palette, or nil. + convenience init?(hex: String) { + guard hex.count == 7, let rgb = UInt32(hex.dropFirst(), radix: 16) else { return nil } + self.init(red: CGFloat((rgb >> 16) & 255) / 255, green: CGFloat((rgb >> 8) & 255) / 255, blue: CGFloat(rgb & 255) / 255, alpha: 1) + } +} + struct AeselCloth: View { - var body: some View { Paint.bg.accessibilityHidden(true) } + @Environment(\.paint) private var paint + var body: some View { paint.bg.accessibilityHidden(true) } +} + +/// The desktop's ruled sheet: one faint line per 24px row, from 10px in to +/// the trailing edge, drawn behind everything so title, prose and the draft +/// all sit on the same paper. +struct AeselRuling: View { + var spacing: CGFloat = 24 + @Environment(\.paint) private var paint + var body: some View { + Canvas { context, size in + var y = spacing - 0.5 + var path = Path() + while y < size.height { + path.move(to: CGPoint(x: 10, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + y += spacing + } + context.stroke(path, with: .color(paint.rule), lineWidth: 1) + } + .accessibilityHidden(true) + } } struct AeselWordmark: View { var text = "aesel" - private let colors: [Color] = [.orange, Paint.ac, Paint.edit, Paint.dim, Paint.you] + @Environment(\.paint) private var paint var body: some View { + let colors: [Color] = [.orange, paint.ac, paint.edit, paint.dim, paint.you] HStack(spacing: 0) { ForEach(Array(text.enumerated()), id: \.offset) { index, letter in Text(String(letter)).foregroundStyle(colors[index % colors.count]) } } - .font(Paint.title()) + .font(Paint.title(28)) .accessibilityElement(children: .ignore) .accessibilityLabel("aesel") } } -/// The desktop's piece title: handle letters in the account palette, a -/// status-coloured shadow, and every letter leaning by the FNV hash Slab uses, -/// so the phone and the rock agree on each letter's tilt. +/// The desktop's piece title: Prox lettering with handle letters tinted by the +/// account palette, every letter leaning by the FNV hash Slab uses (so the +/// phone and the rock agree on each letter's tilt) and swaying on its own +/// 1.8s beat like the desktop's qr-name-wiggle. struct AeselTitle: View { let text: String var colors: [String] = [] - var status = "" - var shadow: Color? - var size: CGFloat = 28 - private static let shadows: [String: UInt32] = [ - "working": 0x36f175, "thinking": 0x36f175, "rendering": 0xe1469c, "awaiting": 0xffb327, - "complete": 0x5d9cf9, "ready": 0x5d9cf9, "interrupted": 0x9c56e9, "stopped": 0x9c56e9, - "stale": 0xff4e4e, "failed": 0xff4e4e, - ] + var size: CGFloat = 20 + private static func fnv(_ text: String) -> UInt32 { var hash: UInt32 = 2166136261 for byte in text.utf8 { hash = (hash ^ UInt32(byte)) &* 16777619 } @@ -79,29 +268,55 @@ struct AeselTitle: View { } // "@handle" letters wear the account palette; the piece name stays white. - private func ink(_ index: Int, handle: Int) -> Color { - guard index < handle, colors.indices.contains(index), let color = Color(hex: colors[index]) else { return Paint.ink } + private func face(_ index: Int, handle: Int) -> UIColor { + guard index < handle, colors.indices.contains(index), let color = UIColor(hex: colors[index]) else { return .white } return color } var body: some View { let handle = text.hasPrefix("@") ? text.prefix(while: { $0 != "/" }).count : 0 - HStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { ForEach(Array(text.enumerated()), id: \.offset) { index, letter in let hash = Self.fnv("rock\(index)\(text)") - Text(String(letter)) - .foregroundStyle(ink(index, handle: handle)) + let face = face(index, handle: handle) + RockLetter(text: String(letter), size: size, face: face, beat: Double(index) * 0.12) + // A new letter is a new view, so the sway never crossfades old ink into new. + .id("\(index)|\(letter)|\(face)") .rotationEffect(.degrees(-Double(Int((hash >> 8) % 9) - 4) * 0.9)) .offset(y: -(CGFloat(hash % 5) / 2 - 1)) } } - .font(Paint.title(size)) - .shadow(color: shadow ?? Color(rgb: Self.shadows[status] ?? 0x808080), radius: 0, x: size * 0.07, y: size * 0.07) .accessibilityElement(children: .ignore) .accessibilityLabel(text) } } +/// One swaying letter: its image overhangs the advance box by `Rock.inset` +/// so the echoes and shadow stay unclipped, and it rocks on its own beat. +private struct RockLetter: View { + let text: String + let size: CGFloat + let face: UIColor + let beat: Double + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var swaying = false + + var body: some View { + let glyph = Rock.glyph(text, size: size, face: face) + Color.clear + .frame(width: glyph.advance.width, height: glyph.advance.height) + .overlay(alignment: .topLeading) { + Image(uiImage: glyph.image).offset(x: -Rock.inset, y: -Rock.inset) + } + .rotationEffect(.degrees(swaying ? 0.8 : -1.2)) + .offset(y: swaying ? 0.8 : -1.2) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true).delay(beat)) { swaying = true } + } + } +} + /// The desktop's pencil companion: eight 512px cells on a 4×2 sheet, copied in /// by bundle-session.sh, drawn smooth and running only while a turn is live. struct AeselDonkey: View { @@ -135,12 +350,13 @@ struct AeselDonkey: View { struct AeselHandle: View { let handle: String let colors: [String] + @Environment(\.paint) private var paint private var label: AttributedString { var result = AttributedString() for (index, character) in ("@" + handle).enumerated() { var letter = AttributedString(String(character)) - letter.foregroundColor = colors.indices.contains(index) ? Color(hex: colors[index]) ?? Paint.ac : Paint.ac + letter.foregroundColor = colors.indices.contains(index) ? Color(hex: colors[index]) ?? paint.ac : paint.ac result += letter } return result diff --git a/apple/aesel/Sources/Braincells.swift b/apple/aesel/Sources/Braincells.swift new file mode 100644 index 0000000000..f15ae9363b --- /dev/null +++ b/apple/aesel/Sources/Braincells.swift @@ -0,0 +1,103 @@ +import Foundation +import Observation +import StoreKit + +/// Braincells bought through the App Store. StoreKit 2 signs each purchase; +/// the app hands that signed transaction to AC (`/api/easel-iap`), which +/// verifies it with Apple and credits the account, and only then is the +/// transaction finished. Anything unfinished is retried on the next launch. +@Observable +@MainActor +final class Braincells { + static let productID = "computer.aesthetic.easel.braincells.1m" + static let redeemURL = URL(string: "https://aesthetic.computer/api/easel-iap")! + + var product: Product? + var busy = false + var notice = "" + var storeStatus = "Loading the App Store…" + + private var token: () -> String? = { nil } + private var credited: () -> Void = {} + private var listener: Task? + + func start(token: @escaping () -> String?, credited: @escaping () -> Void) { + self.token = token + self.credited = credited + guard listener == nil else { return } + listener = Task { [weak self] in + for await result in Transaction.updates { + await self?.settle(result) + } + } + Task { await load() } + } + + var price: String { product?.displayPrice ?? "$4.99" } + + func load() async { + do { + let products = try await Product.products(for: [Self.productID]) + product = products.first + storeStatus = product == nil ? "Braincells are not for sale here yet." : "" + } catch { + storeStatus = "App Store unavailable: \(error.localizedDescription)" + } + for await result in Transaction.unfinished { await settle(result) } + } + + func buy() async { + guard !busy else { return } + guard let product else { await load(); return } + guard token() != nil else { notice = "Sign in to AC before adding braincells."; return } + busy = true + notice = "" + defer { busy = false } + do { + switch try await product.purchase() { + case .success(let result): await settle(result) + case .userCancelled: break + case .pending: notice = "Purchase pending approval." + @unknown default: break + } + } catch { + notice = error.localizedDescription + } + } + + /// Redeem a verified transaction with AC, then finish it. + private func settle(_ result: VerificationResult) async { + guard case .verified(let transaction) = result else { return } + guard transaction.productID == Self.productID, transaction.revocationDate == nil else { + await transaction.finish() + return + } + guard let token = token() else { + notice = "Sign in to AC to add the braincells you bought." + return + } + var request = URLRequest(url: Self.redeemURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["jws": result.jwsRepresentation]) + do { + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + switch status { + case 200: + await transaction.finish() + notice = body?["credited"] as? Bool == true ? "1,000,000 braincells added." : "Braincells already added." + credited() + case 400, 401: + // Apple would not verify it for this account; keep it for a retry after sign-in. + notice = body?["error"] as? String ?? "Could not add braincells." + default: + notice = body?["error"] as? String ?? "AC is busy; the purchase will be retried." + } + } catch { + notice = "Could not reach AC; the purchase will be retried." + } + } +} diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 550b6ef86a..d552ef3216 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -5,35 +5,63 @@ struct ContentView: View { let session: Session let host: SessionHost @State private var draft = "" - @State private var showHome = true + // Straight onto the notebook; /new or Settings opens the chooser. + @State private var showHome = false @State private var showHelp = false @State private var showSettings = false - private enum SettingsDestination { case signIn, help } - @State private var settingsDestination: SettingsDestination? + @State private var braincells = Braincells() @State private var expandedPreview = false @State private var previewHidden = false + @State private var notebookHeight: CGFloat = 24 + /// Slab's "complete" is an attention colour that clears once you return to + /// the window; here, once you touch the draft again. + @State private var attended = true @FocusState private var writing: Bool @Environment(\.openURL) private var openURL + /// One ruled row of the sheet, as the desktop's --notebook-line-height. + private let row: CGFloat = 24 + /// The desktop's #artifact-shell: a 178×119.33 piece in a 4pt frame, 14pt + /// in from the top-right corner. The prose starts on the first ruled row + /// clear of it (the desktop reserves the full width when the page is this + /// narrow). + private let previewSize = CGSize(width: 178, height: 119.33) + private let previewInset: CGFloat = 14 + private var previewVisible: Bool { session.previewURL != nil && !previewHidden } + private var previewBlockHeight: CGFloat { previewVisible ? 168 : row } + + /// Slab's palette for what the session is doing right now. + private var paint: Paint { + let status = attended && session.status == "ready" ? "blank" : session.status + return Paint.slab(status: status, busy: session.busy, failed: session.health == .failed || session.fatal != nil) + } + var body: some View { - VStack(spacing: 0) { - if !expandedPreview { title } - if session.previewURL != nil && !previewHidden { preview } - if !expandedPreview { - transcript - shelf - } + Group { + if expandedPreview { expandedPiece } else { sheet } } + .blur(radius: showSettings ? 4 : 0) .overlay { if showHome { AeselHomeView(session: session, host: host) { showHome = false } } } + .overlay(alignment: .bottomTrailing) { + if !expandedPreview && !showHome && !showSettings { versionLabel } + } + .overlay { if showSettings { settingsPane } } .font(Paint.font()) - .foregroundStyle(Paint.ink) + .foregroundStyle(paint.ink) .background { AeselCloth().ignoresSafeArea() } .background(HostCarrier(host: host).frame(width: 0, height: 0)) - .onChange(of: session.currentSessionID) { draft = ""; previewHidden = false; expandedPreview = false } + .environment(\.paint, paint) + .animation(.easeInOut(duration: 0.5), value: paint) + .onChange(of: session.currentSessionID) { draft = ""; previewHidden = false; expandedPreview = false; attended = true } + .onChange(of: session.busy) { if session.busy { attended = false } } + .onChange(of: writing) { if writing { attended = true } } + .onChange(of: draft) { attended = true } + .onChange(of: session.signedIn) { if session.signedIn { Task { await braincells.load() } } } + .task { braincells.start(token: { host.accessToken() }, credited: { host.refreshCredits() }) } .onAppear { #if DEBUG if ProcessInfo.processInfo.environment["AESEL_NOTEBOOK_PREVIEW"] == "1" { @@ -43,172 +71,336 @@ struct ContentView: View { #endif } .sheet(isPresented: $showHelp) { help } - .sheet(isPresented: $showSettings, onDismiss: { - let destination = settingsDestination - settingsDestination = nil - switch destination { - case .signIn: host.signIn() - case .help: showHelp = true - case nil: break - } - }) { settings } .sheet(isPresented: Binding(get: { session.showSignIn }, set: { session.showSignIn = $0 })) { VStack(spacing: 0) { HStack { AeselWordmark(text: "Aesel") Spacer() Button("/close") { session.showSignIn = false } - .font(Paint.font()).foregroundStyle(Paint.dim) - }.padding(16).background(Paint.bg) + .font(Paint.font()).foregroundStyle(paint.dim) + }.padding(16).background(paint.bg) ZStack { SignInCarrier(host: host) if session.signInLoading { ProgressView("Opening AC sign-in…") - .font(Paint.font()).tint(Paint.ac) + .font(Paint.font()).tint(paint.ac) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Paint.bg) + .background(paint.bg) } if let error = session.signInError { VStack(spacing: 18) { Text(error).multilineTextAlignment(.center) - Button("/retry") { host.signIn() }.foregroundStyle(Paint.you) + Button("/retry") { host.signIn() }.foregroundStyle(paint.you) } .font(Paint.font()).padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Paint.bg) + .background(paint.bg) } } } } + } + + // MARK: - The sheet + /// The desktop's notebook: one scrolling ruled sheet holding the preview, + /// the prose and the draft, with the title fixed on the first row and the + /// version label parked at the bottom-right corner. + private var sheet: some View { + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topTrailing) { + Color.clear.frame(height: previewBlockHeight) + if previewVisible { previewBox } + } + AeselNotebook(session: session, paint: paint, height: $notebookHeight) { openURL($0) } + .frame(height: max(row, notebookHeight)) + if session.fatal != nil { + Button("/retry") { host.restore() } + .foregroundStyle(paint.you).frame(height: row).padding(.horizontal, 10) + } + Spacer(minLength: 0) + prompt.id("prompt") + } + .frame(minHeight: geometry.size.height, alignment: .top) + .background(alignment: .top) { AeselRuling(spacing: row) } + } + .scrollDismissesKeyboard(.interactively) + .overlay(alignment: .topLeading) { + let clear = previewVisible ? previewSize.width + 8 + 2 + previewInset + 16 : 0 + title.frame(maxWidth: max(60, geometry.size.width - 10 - clear), alignment: .leading).clipped() + } + .onChange(of: notebookHeight) { if session.busy || writing { proxy.scrollTo("prompt", anchor: .bottom) } } + .onChange(of: writing) { if writing { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } + .onChange(of: session.busy) { if session.busy { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } + } + } } private var title: some View { - HStack(spacing: 12) { - Button { - if let url = session.shareURL { openURL(url) } - } label: { - AeselTitle(text: session.route.isEmpty ? "new piece" : session.route, - colors: session.handleColors, status: session.status) - .lineLimit(1).minimumScaleFactor(0.6) - } - .buttonStyle(.plain) - .disabled(session.shareURL == nil) - .accessibilityLabel("Open piece in browser") - Spacer(minLength: 0) + Button { + if let url = session.shareURL { openURL(url) } + } label: { + AeselTitle(text: session.route.isEmpty ? "new piece" : session.route, colors: session.handleColors) } - .padding(.horizontal, 16).padding(.vertical, 8) + .buttonStyle(.plain) + .disabled(session.shareURL == nil) + .accessibilityLabel("Open piece in browser") + .frame(height: row) + .padding(.leading, 10) } - private var preview: some View { - ZStack(alignment: .bottomTrailing) { + /// The little piece at the top-right, in the desktop's tinted frame with + /// its accent shadows and the 24pt fullscreen control in its corner. + private var previewBox: some View { + ZStack(alignment: .topTrailing) { if let url = session.previewURL { PieceView(url: url, source: session.source) - .frame(maxWidth: .infinity, maxHeight: expandedPreview ? .infinity : writing ? 140 : 240) + .frame(width: previewSize.width, height: previewSize.height) .clipped() } - Button { expandedPreview.toggle(); writing = false } label: { - Image(systemName: expandedPreview ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") - .font(.system(size: 14)).padding(10).background(Paint.deep.opacity(0.85)) + Button { expandedPreview = true; writing = false } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 12)).frame(width: 24, height: 24) + .background(Color(rgb: 0x1c1728).opacity(0.9)) + .overlay { RoundedRectangle(cornerRadius: 4).stroke(Color.white.opacity(0.4), lineWidth: 1) } + .clipShape(RoundedRectangle(cornerRadius: 4)) } - .accessibilityLabel(expandedPreview ? "Return to aesel" : "Expand piece") + .buttonStyle(.plain).foregroundStyle(.white).padding(5) + .accessibilityLabel("Expand piece") } - .padding(expandedPreview ? 0 : 5) - .background(Paint.frame) - .overlay { Rectangle().stroke(Paint.frameEdge, lineWidth: 1) } - .shadow(color: Paint.accent, radius: 0, x: 3, y: 3) - .shadow(color: Paint.accent.opacity(0.45), radius: 0, x: 5, y: 5) - .padding(.horizontal, expandedPreview ? 0 : 16).padding(.top, expandedPreview ? 0 : 12) + .padding(4) + .background(paint.frame) + .overlay { Rectangle().stroke(paint.frameEdge, lineWidth: 1) } + .shadow(color: Color(rgb: 0x282130), radius: 0, x: 1, y: 1) + .shadow(color: paint.accent, radius: 0, x: 3, y: 3) + .shadow(color: paint.accent.opacity(0.45), radius: 0, x: 5, y: 5) + .padding(.top, previewInset).padding(.trailing, previewInset) } - private var transcript: some View { - VStack(spacing: 0) { - AeselNotebook(session: session) { openURL($0) } - if session.fatal != nil { - Button("/retry") { host.restore() }.padding(8) + private var expandedPiece: some View { + ZStack(alignment: .topTrailing) { + if let url = session.previewURL { + PieceView(url: url, source: session.source) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + Button { expandedPreview = false } label: { + Image(systemName: "arrow.down.right.and.arrow.up.left") + .font(.system(size: 14)).padding(10).background(paint.deep.opacity(0.85)) + } + .buttonStyle(.plain).padding(5) + .accessibilityLabel("Return to aesel") } - .frame(maxHeight: .infinity) } private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" } - private var shelf: some View { - HStack(alignment: .bottom, spacing: 8) { + /// The draft, written on the sheet's last rows in the user's ink like the + /// desktop's prose prompt; the trailing 100pt stays clear for the version. + private var prompt: some View { + HStack(alignment: .top, spacing: 8) { TextField("make something…", text: $draft, axis: .vertical) - .font(Paint.font()).lineLimit(1...4).textFieldStyle(.plain) - .tint(Paint.you).focused($writing).submitLabel(.send) + .font(Paint.font(16)).foregroundStyle(paint.userInk).tint(paint.userInk) + .lineLimit(1...6).textFieldStyle(.plain) + .focused($writing).submitLabel(.send) .onSubmit { send() }.autocorrectionDisabled() - .padding(.vertical, 10) - Button { session.busy ? host.stop() : send() } label: { - Text(session.busy ? "■" : "↵") - .font(Paint.font(28)).frame(width: 44, height: 44) - .foregroundStyle(Paint.you) - } - .disabled(!session.busy && draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .accessibilityLabel(session.busy ? "Stop" : "Send") - Button { writing = false; showSettings = true; host.refreshCredits() } label: { - AeselTitle(text: "v\(appVersion)", shadow: Paint.accent, size: 16) - .frame(minHeight: 44) + .frame(minHeight: row) + if session.busy { + Button { host.stop() } label: { + Text("■").font(Paint.font(16)).foregroundStyle(paint.you).frame(width: row, height: row) + } + .accessibilityLabel("Stop") + } else if !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button { send() } label: { + Text("↵").font(Paint.font(20)).foregroundStyle(paint.you).frame(width: row, height: row) + } + .accessibilityLabel("Send") } - .accessibilityLabel("Version \(appVersion). Settings") } - .buttonStyle(.plain).padding(.leading, 16).padding(.trailing, 8) + .buttonStyle(.plain) + .padding(.leading, 10).padding(.trailing, 100).padding(.bottom, 8) + } + + /// The desktop's #credit-label: the version in Prox lettering, 10pt from + /// the right and 8pt up, and the way into settings. + private var versionLabel: some View { + Button { openSettings() } label: { + AeselTitle(text: "v\(appVersion)", size: 16).padding(9) + } + .buttonStyle(.plain) + .padding(.trailing, 1) + .accessibilityLabel("Version \(appVersion). Settings") + } + + // MARK: - Settings + + private func openSettings() { + writing = false + braincells.notice = "" + host.refreshCredits() + withAnimation(.easeOut(duration: 0.18)) { showSettings = true } + } + + private func closeSettings(then action: (() -> Void)? = nil) { + withAnimation(.easeIn(duration: 0.15)) { showSettings = false } + if let action { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: action) } + } + + private var modelName: String { + let served = session.reportedModel.isEmpty ? session.model : session.reportedModel + return served.isEmpty ? "choosing…" : served } - private var settings: some View { - NavigationStack { - List { - Section { + private func usd(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } + + /// The desktop's #provider-menu: a centred card over the blurred sheet, + /// 15pt Helvetica, hairline sections, with the account, the automatic + /// model, braincells and the way to buy more. + private var settingsPane: some View { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + .onTapGesture { closeSettings() } + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + if session.signedIn { + AeselHandle(handle: session.handle, colors: session.handleColors).font(Paint.title(20)) + } else { + AeselWordmark(text: "aesel") + } + Spacer() + Button { closeSettings() } label: { + Text("×").font(Paint.font(24)).frame(width: 30, height: 30) + } + .accessibilityLabel("Close settings") + } + .padding(.bottom, 6) + + settingsField("Provider") { + HStack(spacing: 9) { + Image("braincell").resizable().scaledToFit().frame(width: 25, height: 25) + Text("Braincells") + } + } + settingsField("Automatic model") { + Text(modelName).lineLimit(1).truncationMode(.middle) + } + .accessibilityLabel("Automatic model, \(modelName)") + + Text(["Remote inference", session.status].joined(separator: " · ")) + .padding(.top, 14) + if let url = session.shareURL { + Text("Auto-publish on · every save goes to \(url.absoluteString)") + .padding(.top, 10) + } + if session.signedIn { - AeselHandle(handle: session.handle, colors: session.handleColors) - Button { host.refreshCredits() } label: { - VStack(alignment: .leading, spacing: 4) { - if let dollars = session.braincellDollars { - Text(dollars, format: .currency(code: "USD")) - .font(.system(size: 28, weight: .medium, design: .rounded)) - } - if let balance = session.braincells { - Text("\(balance.formatted(.number.precision(.fractionLength(0)))) braincells") - } else { Text(session.creditsStatus) } - if let free = session.freeDollars, let paid = session.purchasedDollars { - Text("\(free.formatted(.currency(code: "USD"))) daily · \(paid.formatted(.currency(code: "USD"))) purchased") - .font(.footnote).foregroundStyle(Paint.dim) - } + HStack(spacing: 9) { + Image("braincell").resizable().scaledToFit().frame(width: 25, height: 25) + if let dollars = session.braincellDollars, let balance = session.braincells { + Text("\(usd(dollars)) · \(balance.formatted(.number.precision(.fractionLength(0)))) braincells") + } else if let balance = session.braincells { + Text("\(balance.formatted(.number.precision(.fractionLength(0)))) braincells") + } else { + Text(session.creditsStatus) } } - .buttonStyle(.plain).accessibilityHint("Refresh balance") + .padding(.top, 14) + .onTapGesture { host.refreshCredits() } + .accessibilityHint("Refresh balance") + if let free = session.freeDollars, let paid = session.purchasedDollars { + Text("\(usd(free)) daily · \(usd(paid)) purchased") + .font(Paint.font(13)).foregroundStyle(paint.dim).padding(.top, 4) + } + buyButton.padding(.top, 14) + if !braincells.notice.isEmpty { + Text(braincells.notice).font(Paint.font(13)).foregroundStyle(paint.dim).padding(.top, 8) + } else if !braincells.storeStatus.isEmpty { + Text(braincells.storeStatus).font(Paint.font(13)).foregroundStyle(paint.dim).padding(.top, 8) + } } else { - Button("Sign in") { settingsDestination = .signIn; showSettings = false } - } - Text("Braincells · automatic model") - Text(session.status).foregroundStyle(Paint.dim) - } footer: { - Text("Dollar equivalent at $5 per million braincells. Daily allowance resets at midnight UTC.") - } - Section { - Button("New piece / threads") { showSettings = false; showHome = true } - Button("Publish") { host.publish() } - .disabled(!session.signedIn || session.busy) - Button("Open in browser") { if let url = session.shareURL { openURL(url) } } - .disabled(session.shareURL == nil) - if session.previewURL != nil { - Toggle("Show preview", isOn: Binding(get: { !previewHidden }, set: { previewHidden = !$0 })) + settingsItem("Sign in to AC") { closeSettings { host.signIn() } } + .padding(.top, 14) } - Button("Help") { settingsDestination = .help; showSettings = false } - if session.signedIn { - Button("Sign out") { showSettings = false; host.signOut() } + + settingsDivider + HStack { + Text("Aesel").fontWeight(.semibold) + Spacer() + Text("v\(appVersion)").foregroundStyle(paint.dim) } + .font(Paint.font(13)) + } + .padding(EdgeInsets(top: 20, leading: 24, bottom: 20, trailing: 24)) + } + .frame(maxWidth: 560) + .frame(maxHeight: UIScreen.main.bounds.height * 0.85) + .fixedSize(horizontal: false, vertical: true) + .background(paint.bg) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { RoundedRectangle(cornerRadius: 16).stroke(paint.ink.opacity(0.18), lineWidth: 1) } + .shadow(color: .black.opacity(0.2), radius: 30, y: 20) + .padding(24) + } + .font(Paint.font(15)) + .buttonStyle(.plain) + .transition(.opacity) + } + + /// The App Store purchase: one big green button with Apple's price. + private var buyButton: some View { + Button { + Task { await braincells.buy() } + } label: { + HStack(spacing: 10) { + Image("braincell").resizable().scaledToFit().frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(braincells.busy ? "Opening the App Store…" : "Add 1,000,000 braincells") + .font(Paint.font(18)).fontWeight(.semibold) + Text(braincells.busy ? "" : "\(braincells.price) · App Store").font(Paint.font(13)).opacity(0.85) } + Spacer() } - .scrollContentBackground(.hidden) - .background { AeselCloth().ignoresSafeArea() } - .navigationTitle("Settings").navigationBarTitleDisplayMode(.inline) - .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showSettings = false } } } + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) + .frame(maxWidth: .infinity, minHeight: 64) + .foregroundStyle(.white) + .background(Color(rgb: 0x1f9d55)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.25), lineWidth: 1) } + } + .disabled(braincells.busy || braincells.product == nil) + .opacity(braincells.product == nil ? 0.55 : 1) + .accessibilityLabel("Add one million braincells for \(braincells.price) through the App Store") + } + + private func settingsField(_ label: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(label).font(Paint.font(13)) + content() + .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) + .padding(.horizontal, 8) + .overlay { RoundedRectangle(cornerRadius: 6).stroke(paint.ink.opacity(0.25), lineWidth: 1) } + } + .padding(.top, 14) + } + + private func settingsItem(_ label: String, enabled: Bool = true, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label).frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: 9, leading: 10, bottom: 9, trailing: 10)) + .contentShape(Rectangle()) } - .tint(Paint.you).presentationDetents([.medium, .large]) + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + } + + private var settingsDivider: some View { + Rectangle().fill(paint.ink.opacity(0.18)).frame(height: 1) + .padding(.top, 16).padding(.bottom, 12) } private var help: some View { @@ -216,8 +408,8 @@ struct ContentView: View { AeselWordmark() Text("describe a picture, a sound, or a little world. aesel writes it while you watch.") Text("/new start a piece\n/publish share your piece\n/open open in your browser\n/login your AC account") - .foregroundStyle(Paint.dim) - Button("/close") { showHelp = false }.foregroundStyle(Paint.you) + .foregroundStyle(paint.dim) + Button("/close") { showHelp = false }.foregroundStyle(paint.you) Spacer() } .font(Paint.font(22)).padding(24) @@ -236,13 +428,12 @@ struct ContentView: View { case "/login": host.signIn() case "/logout": host.signOut() case "/open": if let url = session.shareURL { openURL(url) } - case "/settings": writing = false; showSettings = true; host.refreshCredits() + case "/settings": openSettings() + case "/buy": openSettings(); Task { await braincells.buy() } case "/help": showHelp = true default: host.ask(text) } } - - } /// Puts the hidden session webview in the hierarchy without drawing it. diff --git a/apple/aesel/Sources/SessionHost.swift b/apple/aesel/Sources/SessionHost.swift index c03a2f1a89..48ef078a40 100644 --- a/apple/aesel/Sources/SessionHost.swift +++ b/apple/aesel/Sources/SessionHost.swift @@ -97,6 +97,7 @@ final class SessionHost: NSObject { func adopt(token: String) { call("void aesel.adoptToken(\(quote(token)));") } func restore() { call("void aesel.restore();") } func refreshCredits() { call("void aesel.refreshCredits();") } + func accessToken() -> String? { store.token() } var signInView: WKWebView { if let loginWebView { return loginWebView } @@ -348,6 +349,17 @@ final class SessionStore { func clearToken() { SecItemDelete(tokenQuery as CFDictionary) } + /// The signed-in AC access token, for the app's own calls to AC. + func token() -> String? { + var query = tokenQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, let token = String(data: data, encoding: .utf8), !token.isEmpty else { return nil } + return token + } + private func saveToken(_ token: String) { clearToken() guard !token.isEmpty else { return } diff --git a/apple/aesel/bundle-session.sh b/apple/aesel/bundle-session.sh index 478e8d41e5..da43cca500 100755 --- a/apple/aesel/bundle-session.sh +++ b/apple/aesel/bundle-session.sh @@ -8,7 +8,7 @@ done # Use the desktop's sanitized rich-text renderer on iPhone as well. mkdir -p Resources/Session/easel/desktop/vendor rsync -a --delete ../../easel/desktop/vendor/rich/ Resources/Session/easel/desktop/vendor/rich/ -cp ../../easel/desktop/{transcript,reply-details}.js Resources/Session/easel/desktop/ +cp ../../easel/desktop/{transcript,reply-details,notebook-donkey}.js Resources/Session/easel/desktop/ cp ../../easel/desktop/rich.css Resources/Session/easel/desktop/ # The desktop's pencil companion sheet, drawn natively by AeselDonkey. mkdir -p Resources/Session/easel/desktop/assets diff --git a/easel/phone/credits.mjs b/easel/phone/credits.mjs index 1df4c2c835..b81836cc83 100644 --- a/easel/phone/credits.mjs +++ b/easel/phone/credits.mjs @@ -31,5 +31,48 @@ export function createCredits({ token, emit, site, fetch: request = (...args) => } } finally { clearTimeout(timer); } } - return { refresh, clear }; + // The desktop's checkout: one Stripe session per request id, reused until it + // is paid or expires, and credited only once the server has seen the payment. + let pending = null, opening = false; + async function call(body) { + const credential = token(); + if (!credential) throw new Error("Sign in to AC before buying braincells."); + const response = await request(`${site}/api/easel-checkout`, { + method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${credential}` }, + body: JSON.stringify(body), + }); + const value = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(value.error || "Checkout unavailable"); + return value; + } + async function buy() { + if (opening) return; + opening = true; + try { + if (pending?.sessionId) { + const status = await call({ sessionId: pending.sessionId }); + if (status.paid || status.status === "expired") pending = null; + } + if (!pending) pending = { requestId: crypto.randomUUID() }; + const checkout = await call({ pack: "luna-1m-v1", requestId: pending.requestId }); + const url = new URL(checkout.url); + if (url.protocol !== "https:" || !["checkout.stripe.com", "pay.aesthetic.computer"].includes(url.hostname)) throw new Error("Invalid checkout address"); + pending.sessionId = checkout.sessionId; + emit({ type: "checkout", url: url.href, sessionId: checkout.sessionId }); + } catch (error) { + emit({ type: "checkout", error: error.message || "Checkout unavailable" }); + } finally { opening = false; } + } + async function check() { + if (!pending?.sessionId) return; + try { + const status = await call({ sessionId: pending.sessionId }); + if (status.paid || status.status === "expired") { + pending = null; + await refresh(); + emit({ type: "checkout", paid: !!status.paid, done: true }); + } + } catch {} + } + return { refresh, clear: () => { pending = null; clear(); }, buy, check }; } diff --git a/easel/phone/notebook.css b/easel/phone/notebook.css index 83f0dcd494..a012932006 100644 --- a/easel/phone/notebook.css +++ b/easel/phone/notebook.css @@ -1,9 +1,11 @@ -/* The desktop notebook's metrics: 16px Helvetica prose on a 24px ruling. */ +/* The desktop notebook's metrics: 16px Helvetica prose on a 24px ruling. + The page is one pane of the app's own scrolling sheet, which draws the + ruling and owns the scroll; this document only grows to fit its prose. */ :root{color-scheme:dark;--aesel-background:#463264;--aesel-foreground:#fff;--user-ink:#e9b5d5;--number-ink:#ffd17c;--notebook-line-height:24px;--prose-font-size:16px} *{box-sizing:border-box} -html,body{margin:0;height:100%;overflow:hidden;background:transparent;color:var(--aesel-foreground);font:var(--prose-font-size)/var(--notebook-line-height) Arial,Helvetica,sans-serif} -#conversation{position:absolute;inset:0;overflow:auto;padding:0 14px;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;line-height:var(--notebook-line-height)} -#notebook-page{background:repeating-linear-gradient(to bottom,transparent 0,transparent 23px,#ffffff1f 23px,#ffffff1f 24px);padding-bottom:24px;overflow-wrap:anywhere} +html,body{margin:0;overflow:hidden;background:transparent;color:var(--aesel-foreground);font:var(--prose-font-size)/var(--notebook-line-height) Arial,Helvetica,sans-serif} +#conversation{padding:0 10px;line-height:var(--notebook-line-height)} +#notebook-page{overflow-wrap:anywhere} article{margin:0 0 24px;white-space:pre-wrap} article[data-kind=user]{color:var(--user-ink)} article[data-kind=error]{color:#ff9898} @@ -12,4 +14,12 @@ pre{max-width:100%;overflow:auto;white-space:pre;margin:24px 0;font:14px/24px Me code{font:.88em Menlo,monospace} a{color:inherit;text-decoration:underline;text-underline-offset:3px} .user-handle{font:inherit;opacity:.5} -@media(prefers-contrast:more){#notebook-page{background:none}} +/* The desktop's pencil companion: two rows tall beside the latest user prose, behind the ink. */ +#notebook-activity{white-space:nowrap} +#notebook-activity[hidden]{display:none} +#prompt-feedback{position:relative;display:inline-block;width:calc(clamp(36px,var(--prose-font-size,16px)*2.8,64px) + 12px);height:calc(var(--notebook-line-height,24px)*2);vertical-align:middle} +#prompt-feedback[hidden]{display:none} +#activity-caption{display:inline;font:italic .75em/1.3 Helvetica,Arial,sans-serif;opacity:.62;overflow-wrap:anywhere;white-space:normal;pointer-events:none} +#activity-caption[hidden]{display:none} +#notebook-thinking-donkey{position:fixed;z-index:3;pointer-events:none;opacity:.3;filter:saturate(.8)} +#notebook-thinking-donkey[hidden]{display:none} diff --git a/easel/phone/notebook.html b/easel/phone/notebook.html index 3893633e48..7aaeaf4873 100644 --- a/easel/phone/notebook.html +++ b/easel/phone/notebook.html @@ -10,5 +10,6 @@ + diff --git a/easel/phone/notebook.js b/easel/phone/notebook.js index 44da6bd33b..426f8a004f 100644 --- a/easel/phone/notebook.js +++ b/easel/phone/notebook.js @@ -3,6 +3,38 @@ window.aesel = {openLink(url) { if (/^https?:\/\//i.test(url)) window.webkit?.messageHandlers?.notebook?.postMessage({url}); }}; window.updatePhoneNotebook = value => { + for (const [name, property] of [['background','--aesel-background'],['foreground','--aesel-foreground'],['userInk','--user-ink']]) { + const color = value.theme?.[name]; + if (/^#[0-9a-f]{6}$/i.test(color || '')) document.documentElement.style.setProperty(property, color); + } window.setNotebookHandle(value.handle || '', value.colors || []); window.updateConversation({entries:value.entries}); + placeActivity(!!value.busy, value.activity || ''); + reportHeight(); }; +// The desktop's pencil companion and activity caption, following the latest +// user prose while a turn runs (renderer.js placeNotebookActivity). +const feedback = document.createElement('span'); feedback.id = 'prompt-feedback'; feedback.hidden = true; feedback.setAttribute('role', 'img'); +const caption = document.createElement('span'); caption.id = 'activity-caption'; caption.hidden = true; +const activity = document.createElement('span'); activity.id = 'notebook-activity'; activity.hidden = true; activity.append(feedback, caption); +document.body.append(activity); +window.installNotebookDonkey?.(feedback); +function placeActivity(busy, text) { + const last = Array.from(document.querySelectorAll('#notebook-page article[data-kind="user"]')).at(-1); + const target = last?.lastElementChild || last; + if (target && activity.parentElement !== target) target.append(activity); + activity.hidden = feedback.hidden = !busy || !target; + feedback.setAttribute('aria-label', busy ? text || 'working' : 'Idle'); + caption.hidden = activity.hidden || !text; + caption.textContent = caption.hidden ? '' : text; +} +// The app sizes this pane to its prose, so the sheet scrolls as one page. +let reported = 0; +const reportHeight = () => { + const height = Math.ceil(document.documentElement.scrollHeight); + if (height === reported) return; + reported = height; + window.webkit?.messageHandlers?.notebook?.postMessage({height}); +}; +new ResizeObserver(reportHeight).observe(document.documentElement); +document.fonts?.ready.then(reportHeight); diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs index 5c5083e637..d987af3175 100644 --- a/easel/phone/session.mjs +++ b/easel/phone/session.mjs @@ -472,6 +472,8 @@ export function createSession({ storage = memoryStore(), emit = () => {} } = {}) setModel, history, refreshCredits: credits.refresh, + buyCredits: credits.buy, + checkCheckout: credits.check, route, pieceUrl, }; diff --git a/system/backend/easel-paid-credits.mjs b/system/backend/easel-paid-credits.mjs index 8cf30d71e3..860671f227 100644 --- a/system/backend/easel-paid-credits.mjs +++ b/system/backend/easel-paid-credits.mjs @@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto'; import { connect } from './database.mjs'; export const CREDIT_PACK = Object.freeze({ id:'braincells-1m-v1', amount:500, currency:'usd', credits:1_000_000, unit:'braincells' }); +// The same pack sold through Apple in-app purchase; Apple keeps the price. +export const IAP_PRODUCTS = Object.freeze({ 'computer.aesthetic.easel.braincells.1m': Object.freeze({ credits:1_000_000, unit:'braincells' }) }); const COLLECTION='ac-credit-wallets'; export async function withWallets(fn) { const connection=await connect(); @@ -23,14 +25,27 @@ export function paidCheckout(session, {live}={}) { if(![CREDIT_PACK.id,'luna-1m-v1'].includes(m.pack)||!m.userSub||session.client_reference_id!==m.userSub||session.amount_total!==CREDIT_PACK.amount||session.currency!==CREDIT_PACK.currency||typeof session.id!=='string'||!session.id.startsWith('cs_')||(live!==undefined&&session.livemode!==live))throw new Error('Credit checkout does not match the server offer'); return {user:m.userSub,id:session.id,credits:CREDIT_PACK.credits}; } -export async function fulfillCheckout(session,wallets,options) { - const grant=paidCheckout(session,options); if(!grant)return false; +// A single-document conditional increment makes webhook retries and a +// simultaneous checkout-return reconciliation safe across server processes. +export async function fulfillGrant(grant,wallets) { + if(!grant?.user||typeof grant.id!=='string'||!Number.isSafeInteger(grant.credits)||grant.credits<1)return false; await ensureWallet(grant.user,wallets); - // A single-document conditional increment makes webhook retries and a - // simultaneous checkout-return reconciliation safe across server processes. const result=await wallets.updateOne({_id:grant.user,grants:{$ne:grant.id}},{$inc:{balance:grant.credits},$addToSet:{grants:grant.id},$set:{updatedAt:new Date()}}); return result.modifiedCount===1; } +export async function fulfillCheckout(session,wallets,options) { + const grant=paidCheckout(session,options); if(!grant)return false; + return fulfillGrant(grant,wallets); +} +// Take back a granted pack (an Apple refund). Cumulative per grant, so a +// redelivered notification converges; spent credit becomes debt. +export async function revokeGrant(grant,wallets) { + if(!grant?.user||typeof grant.id!=='string'||!Number.isSafeInteger(grant.credits)||grant.credits<1)return false; + await fulfillGrant(grant,wallets); + const path=`refunds.${grant.id}`; + const result=await wallets.updateOne({_id:grant.user},[{$set:{balance:{$subtract:['$balance',{$max:[0,{$subtract:[grant.credits,{$ifNull:[`$${path}`,0]}]}]}]},[path]:{$max:[grant.credits,{$ifNull:[`$${path}`,0]}]},updatedAt:'$$NOW'}}]); + return result.modifiedCount===1; +} export function reservationSize(body,maxTokens,{validateMedia=true}={}) { // UTF-8 bytes upper-bound text tokens, including JSON tool definitions. // Unsupported media is rejected below; it cannot hide unbounded token cost. diff --git a/system/netlify/functions/easel-iap.mjs b/system/netlify/functions/easel-iap.mjs new file mode 100644 index 0000000000..e8e96cf6b6 --- /dev/null +++ b/system/netlify/functions/easel-iap.mjs @@ -0,0 +1,105 @@ +// Apple in-app purchases of braincells for Aesel on iPhone. +// +// The app sends the StoreKit 2 signed transaction (`jwsRepresentation`); we +// verify Apple's signature and certificate chain here, never the client's +// word, and credit the same wallet the Stripe checkout fills. The grant id is +// the Apple transaction id, so a retried redemption or a duplicate +// notification cannot credit twice. App Store Server Notifications (REFUND) +// arrive at the same address with a `signedPayload` and take the pack back. +import { readFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SignedDataVerifier, Environment } from '@apple/app-store-server-library'; +import { authorize } from '../../backend/authorization.mjs'; +import { IAP_PRODUCTS, withWallets, fulfillGrant, revokeGrant } from '../../backend/easel-paid-credits.mjs'; + +export const BUNDLE_ID='computer.aesthetic.easel'; +export const APP_APPLE_ID=6812823093; +const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'POST, OPTIONS'}; +const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); + +function appleRoots(){ + const dir=join(dirname(fileURLToPath(import.meta.url)),'../../backend/apple-roots'); + return readdirSync(dir).filter(name=>name.endsWith('.cer')).map(name=>readFileSync(join(dir,name))); +} +export function makeVerifiers({roots=appleRoots(),sandbox=process.env.AC_IAP_ALLOW_SANDBOX==='true',online=true}={}){ + const list=[new SignedDataVerifier(roots,online,Environment.PRODUCTION,BUNDLE_ID,APP_APPLE_ID)]; + if(sandbox)list.push(new SignedDataVerifier(roots,online,Environment.SANDBOX,BUNDLE_ID,APP_APPLE_ID)); + return list; +} +async function decodeWith(verifiers,method,jws){ + let failure; + for(const verifier of verifiers){ + try{return {payload:await verifier[method](jws),environment:verifier.environment};} + catch(error){failure=error;} + } + throw failure||new Error('Unverifiable'); +} +// What a verified transaction is worth, or why it is worth nothing. +export function grantFor(transaction,user){ + const product=IAP_PRODUCTS[transaction?.productId]; + if(!product)return {error:'Unknown product'}; + if(transaction.bundleId!==BUNDLE_ID)return {error:'Wrong app'}; + if(transaction.type!=='Consumable')return {error:'Not a consumable'}; + if(transaction.revocationDate)return {error:'Refunded'}; + const quantity=Number.isSafeInteger(transaction.quantity)&&transaction.quantity>0?transaction.quantity:1; + if(quantity>10)return {error:'Quantity too large'}; + if(typeof transaction.transactionId!=='string'||!/^\d{1,30}$/.test(transaction.transactionId))return {error:'Invalid transaction id'}; + return {user,id:`apple:${transaction.transactionId}`,credits:product.credits*quantity,quantity,transactionId:transaction.transactionId}; +} + +export function createHandler({verifiers=makeVerifiers(),verifyUser=authorize,wallets=fn=>withWallets(fn),purchases=null,now=()=>new Date()}={}){ + const record=async doc=>{ if(purchases)await purchases(doc); }; + return async event=>{ + if(event.httpMethod==='OPTIONS')return reply(204,null); + if(event.httpMethod!=='POST')return reply(405,{error:'POST only'}); + let body;try{body=JSON.parse(event.body||'{}');}catch{return reply(400,{error:'Invalid request'});} + + // App Store Server Notifications V2: unauthenticated, signed by Apple. + if(typeof body.signedPayload==='string'){ + let note;try{note=(await decodeWith(verifiers,'verifyAndDecodeNotification',body.signedPayload));}catch{return reply(401,{error:'Invalid notification'});} + const {payload,environment}=note; + try{ + if(['REFUND','REVOKE'].includes(payload.notificationType)&&payload.data?.signedTransactionInfo){ + const {payload:transaction}=await decodeWith(verifiers,'verifyAndDecodeTransaction',payload.data.signedTransactionInfo); + const owner=purchases?await purchases({find:`apple:${transaction.transactionId}`}):null; + const grant=grantFor({...transaction,revocationDate:undefined},owner?.user); + if(grant.error)return reply(200,{received:true,ignored:grant.error}); + if(!owner?.user)return reply(200,{received:true,ignored:'Unknown purchase'}); + await wallets(w=>revokeGrant(grant,w)); + await record({id:grant.id,user:grant.user,refundedAt:now(),environment,notificationUUID:payload.notificationUUID}); + } + return reply(200,{received:true}); + }catch(error){console.error('IAP notification failed:',error.message);return reply(500,{error:'Notification pending'});} + } + + // A purchase the app just made, redeemed for the signed-in account. + let user;try{user=await verifyUser(event.headers||{});}catch{return reply(401,{error:'Sign in to add braincells'});} + if(!user?.sub)return reply(401,{error:'Sign in to add braincells'}); + if(typeof body.jws!=='string'||body.jws.length>16384)return reply(400,{error:'Missing transaction'}); + let verified;try{verified=await decodeWith(verifiers,'verifyAndDecodeTransaction',body.jws);}catch{return reply(401,{error:'Apple could not verify this purchase'});} + const grant=grantFor(verified.payload,user.sub); + if(grant.error)return reply(400,{error:grant.error}); + try{ + const credited=await wallets(w=>fulfillGrant(grant,w)); + await record({id:grant.id,user:user.sub,environment:verified.environment,productId:verified.payload.productId,quantity:grant.quantity,credits:grant.credits, + originalTransactionId:verified.payload.originalTransactionId,purchaseDate:verified.payload.purchaseDate,appAccountToken:verified.payload.appAccountToken,redeemedAt:now()}); + return reply(200,{credited,transactionId:grant.transactionId,credits:grant.credits,environment:verified.environment}); + }catch(error){console.error('IAP fulfillment failed:',error.message);return reply(503,{error:'Could not add braincells yet. The purchase is kept and will be retried.'});} + }; +} + +// Purchases are remembered by grant id so a refund can find its owner. +async function purchaseLedger(doc){ + const { connect } = await import('../../backend/database.mjs'); + const connection=await connect(); + try{ + const purchases=connection.db.collection('ac-credit-purchases'); + if(doc.find)return purchases.findOne({_id:doc.find}); + const {id,...rest}=doc; + await purchases.updateOne({_id:id},{$set:rest,$setOnInsert:{createdAt:new Date()}},{upsert:true}); + }finally{await connection.disconnect();} +} +export async function handler(event){ + return createHandler({purchases:purchaseLedger})(event); +} diff --git a/system/package-lock.json b/system/package-lock.json index 3a7beaf45b..ff871fbf86 100644 --- a/system/package-lock.json +++ b/system/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@apple/app-store-server-library": "^3.1.0", "@atproto/api": "^0.20.28", "@aws-sdk/client-s3": "^3.1020.0", "@aws-sdk/s3-request-presigner": "^3.1020.0", @@ -71,6 +72,9 @@ "three": "^0.182.0", "typescript": "^5.9.3", "web3": "^4.16.0" + }, + "engines": { + "node": ">=24.18.1 <25" } }, "node_modules/@adraffy/ens-normalize": { @@ -80,6 +84,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@apple/app-store-server-library": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apple/app-store-server-library/-/app-store-server-library-3.1.0.tgz", + "integrity": "sha512-d26SICRz8BwCV2qPR0BSXBMxmw0NEvJLwsdREcBmCrus8NmyHw2XgOOP0fiFjcctPn/JXtmSDuGVnaHe+dqO+A==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.5", + "@types/jsrsasign": "^10.5.12", + "@types/node": "^25.4.0", + "@types/node-fetch": "^2.6.13", + "base64url": "^3.0.1", + "jsonwebtoken": "^9.0.2", + "jsrsasign": "^11.0.0", + "node-fetch": "^2.7.0" + } + }, + "node_modules/@apple/app-store-server-library/node_modules/@types/node": { + "version": "25.9.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz", + "integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@apple/app-store-server-library/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/@atproto/api": { "version": "0.20.28", "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.28.tgz", @@ -10228,6 +10263,28 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/jsrsasign": { + "version": "10.5.15", + "resolved": "https://registry.npmjs.org/@types/jsrsasign/-/jsrsasign-10.5.15.tgz", + "integrity": "sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.2.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", @@ -10237,6 +10294,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -11655,6 +11722,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -11929,6 +12002,15 @@ ], "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", @@ -12321,7 +12403,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12723,6 +12804,18 @@ "node": ">=12.20" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -13222,6 +13315,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -13560,7 +13662,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -13678,18 +13779,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -13771,7 +13860,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13781,7 +13869,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13798,7 +13885,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -13807,6 +13893,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -14802,6 +14903,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/form-data-encoder": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", @@ -14811,6 +14928,27 @@ "node": ">= 18" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -14849,7 +14987,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14964,7 +15101,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -15019,7 +15155,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -15340,7 +15475,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15450,7 +15584,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15463,7 +15596,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -15487,10 +15619,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -15722,7 +15853,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -15898,17 +16029,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -17725,7 +17845,6 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dev": true, "license": "MIT", "dependencies": { "jws": "^4.0.1", @@ -17744,6 +17863,13 @@ "npm": ">=6" } }, + "node_modules/jsrsasign": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.5.tgz", + "integrity": "sha512-i6mAjey0/4UQmlOaNS2vROl8EkWV8Ei436reJAby2OPQ3lF6uXSs1VYpnM8hndmmCMFIzNc04Uw2GF+0JVLFbg==", + "deprecated": "This package is no longer maintained.", + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -18134,14 +18260,12 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isequal": { @@ -18156,35 +18280,30 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, "license": "MIT" }, "node_modules/log-process-errors": { @@ -18445,7 +18564,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -22990,7 +23108,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -23018,21 +23135,18 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, "license": "MIT" }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -25303,7 +25417,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": { @@ -25644,34 +25758,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/sonic-boom": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", diff --git a/system/package.json b/system/package.json index 364132b5b3..8432530eb2 100644 --- a/system/package.json +++ b/system/package.json @@ -53,6 +53,7 @@ "homepage": "https://github.com/whistlegraph/system#readme", "comment:dependencies": "Only packages imported by server code (backend/, netlify/functions/, and the lith + oven runtimes). Production installs with `npm install --omit=dev`, so anything the browser gets from a vendored copy in public/aesthetic.computer/dep/ belongs in devDependencies, not here.", "dependencies": { + "@apple/app-store-server-library": "^3.1.0", "@atproto/api": "^0.20.28", "@aws-sdk/client-s3": "^3.1020.0", "@aws-sdk/s3-request-presigner": "^3.1020.0", diff --git a/system/tests/easel-iap.test.mjs b/system/tests/easel-iap.test.mjs new file mode 100644 index 0000000000..5f5a8b3b12 --- /dev/null +++ b/system/tests/easel-iap.test.mjs @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHandler, grantFor } from "../netlify/functions/easel-iap.mjs"; + +const transaction = { transactionId: "2000000123", originalTransactionId: "2000000123", bundleId: "computer.aesthetic.easel", + productId: "computer.aesthetic.easel.braincells.1m", type: "Consumable", quantity: 1, purchaseDate: 1789750000000 }; +const verifier = (environment, payloads) => ({ environment, + verifyAndDecodeTransaction: async jws => { if (!payloads[jws]) throw new Error("bad"); return payloads[jws]; }, + verifyAndDecodeNotification: async jws => { if (!payloads[jws]) throw new Error("bad"); return payloads[jws]; } }); +function wallet() { + const doc = { balance: 0, grants: [], refunds: {} }; + const wallets = { + async updateOne(filter, update) { + if (Array.isArray(update)) { // revoke pipeline + const id = Object.keys(update[0].$set).find(k => k.startsWith("refunds."))?.slice(8); + const credits = update[0].$set["refunds." + id].$max[0]; + const already = doc.refunds[id] || 0; + doc.balance -= Math.max(0, credits - already); doc.refunds[id] = Math.max(credits, already); + return { modifiedCount: 1 }; + } + if (update.$setOnInsert) return { matchedCount: 1 }; + if (filter.grants?.$ne && doc.grants.includes(filter.grants.$ne)) return { modifiedCount: 0 }; + doc.balance += update.$inc.balance; doc.grants.push(update.$addToSet.grants); + return { modifiedCount: 1 }; + }, + }; + return { doc, wallets }; +} +const post = (body, auth = "Bearer t") => ({ httpMethod: "POST", headers: auth ? { authorization: auth } : {}, body: JSON.stringify(body) }); + +test("grantFor accepts only this app's consumable", () => { + assert.equal(grantFor(transaction, "u").id, "apple:2000000123"); + assert.equal(grantFor({ ...transaction, productId: "other" }, "u").error, "Unknown product"); + assert.equal(grantFor({ ...transaction, bundleId: "x" }, "u").error, "Wrong app"); + assert.equal(grantFor({ ...transaction, revocationDate: 1 }, "u").error, "Refunded"); + assert.equal(grantFor({ ...transaction, quantity: 3 }, "u").credits, 3_000_000); +}); + +test("a verified purchase credits once, and a retry reports it already credited", async () => { + const { doc, wallets } = wallet(); + const ledger = []; + const handler = createHandler({ verifiers: [verifier("Production", { good: transaction })], + verifyUser: async () => ({ sub: "user" }), wallets: fn => fn(wallets), purchases: async d => { ledger.push(d); } }); + let result = await handler(post({ jws: "good" })); + assert.equal(result.statusCode, 200); + assert.deepEqual(JSON.parse(result.body), { credited: true, transactionId: "2000000123", credits: 1_000_000, environment: "Production" }); + assert.equal(doc.balance, 1_000_000); + result = await handler(post({ jws: "good" })); + assert.equal(JSON.parse(result.body).credited, false); + assert.equal(doc.balance, 1_000_000); + assert.equal(ledger[0].user, "user"); +}); + +test("an unverifiable or unsigned-in purchase grants nothing", async () => { + const { doc, wallets } = wallet(); + const handler = createHandler({ verifiers: [verifier("Production", { good: transaction })], + verifyUser: async h => { if (!h.authorization) throw new Error("no"); return { sub: "user" }; }, wallets: fn => fn(wallets) }); + assert.equal((await handler(post({ jws: "forged" }))).statusCode, 401); + assert.equal((await handler(post({ jws: "good" }, null))).statusCode, 401); + assert.equal(doc.balance, 0); +}); + +test("sandbox transactions only count when a sandbox verifier is configured", async () => { + const { doc, wallets } = wallet(); + const handler = createHandler({ verifiers: [verifier("Production", {})], verifyUser: async () => ({ sub: "user" }), wallets: fn => fn(wallets) }); + assert.equal((await handler(post({ jws: "sandbox" }))).statusCode, 401); + assert.equal(doc.balance, 0); +}); + +test("an Apple refund notification takes the pack back from its owner", async () => { + const { doc, wallets } = wallet(); + const owners = { "apple:2000000123": { user: "user" } }; + const handler = createHandler({ + verifiers: [verifier("Production", { good: transaction, refund: { notificationType: "REFUND", notificationUUID: "n1", data: { signedTransactionInfo: "good" } } })], + verifyUser: async () => ({ sub: "user" }), wallets: fn => fn(wallets), + purchases: async d => d.find ? owners[d.find] : undefined }); + await handler(post({ jws: "good" })); + assert.equal(doc.balance, 1_000_000); + const result = await handler({ httpMethod: "POST", headers: {}, body: JSON.stringify({ signedPayload: "refund" }) }); + assert.equal(result.statusCode, 200); + assert.equal(doc.balance, 0); + await handler({ httpMethod: "POST", headers: {}, body: JSON.stringify({ signedPayload: "refund" }) }); + assert.equal(doc.balance, 0, "a redelivered refund converges"); +}); -- 2.51.2