From ca77952a9d022bdcbce061677575baed37755b0d Mon Sep 17 00:00:00 2001 From: Nico Meister Date: Sun, 18 Oct 2026 17:46:42 +0200 Subject: [PATCH] v2.9.0: MBMNET Shop (eigene App, auf Wunsch installierbar) Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +- README.md | 4 +- release.sh | 11 +- shop/Makefile | 90 ++++++++ shop/assets/banner.png | Bin 0 -> 1658 bytes shop/assets/banner.wav | Bin 0 -> 37970 bytes shop/cia.rsf | 268 +++++++++++++++++++++++ shop/icon.png | Bin 0 -> 731 bytes shop/source/main.c | 477 +++++++++++++++++++++++++++++++++++++++++ shop/source/net.c | 73 +++++++ shop/source/net.h | 14 ++ shop/source/store.c | 476 ++++++++++++++++++++++++++++++++++++++++ shop/source/store.h | 43 ++++ source/main.c | 117 ++++++++-- source/shopctl.c | 131 +++++++++++ source/shopctl.h | 21 ++ 16 files changed, 1710 insertions(+), 19 deletions(-) create mode 100644 shop/Makefile create mode 100644 shop/assets/banner.png create mode 100644 shop/assets/banner.wav create mode 100644 shop/cia.rsf create mode 100644 shop/icon.png create mode 100644 shop/source/main.c create mode 100644 shop/source/net.c create mode 100644 shop/source/net.h create mode 100644 shop/source/store.c create mode 100644 shop/source/store.h create mode 100644 source/shopctl.c create mode 100644 source/shopctl.h diff --git a/Makefile b/Makefile index a910d90..9e17adb 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ SOURCES := source INCLUDES := source # Bei jedem Release erhöhen - das Auto-Update vergleicht diese Nummer -VERSION := 2.8.2 +VERSION := 2.9.0 APP_TITLE := MBMNET APP_DESCRIPTION := Dein eigenes 3DS-Netzwerk (v$(VERSION)) @@ -35,7 +35,7 @@ TITLE_MAJOR := $(shell echo $$(( $(word 1,$(VERSION_PARTS)) * 10 + $(word 2 VERSION_NUM := $(shell echo $$(( ($(TITLE_MAJOR) << 10) | $(word 3,$(VERSION_PARTS)) ))) ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft -CFLAGS := -g -Wall -O2 -mword-relocations -ffunction-sections $(ARCH) $(INCLUDE) -D__3DS__ -DAPP_VERSION=\"$(VERSION)\" +CFLAGS := -g -Wall -O2 -mword-relocations -ffunction-sections $(ARCH) $(INCLUDE) -D__3DS__ -DAPP_VERSION=\"$(VERSION)\" -DAPP_TITLE_VERSION=$(VERSION_NUM) ASFLAGS := -g $(ARCH) LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) LIBS := -lcitro2d -lcitro3d -lctru -lm diff --git a/README.md b/README.md index 38c0313..cc1135b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ docker build -t mbmnet-3ds-build tools docker run --rm -v "$PWD":/app -w /app mbmnet-3ds-build bash -c 'make && make cia' ``` -Ergebnis: `mbmnet.3dsx` und `mbmnet.cia`. Version steht in `Makefile` (`VERSION`). +Ergebnis: `mbmnet.3dsx` und `mbmnet.cia`. Den Shop baut `make -C shop && make -C shop cia`. Version steht in `Makefile` (`VERSION`). `release.sh` baut, legt die Dateien ins Repo `MBMNET` und erstellt dort ein Gitea-Release. @@ -32,6 +32,8 @@ Ergebnis: `mbmnet.3dsx` und `mbmnet.cia`. Version steht in `Makefile` (`VERSION` | `source/update.c` | Selbst-Update aus dem Repo `MBMNET` | | `source/admin.c` | Entwickler-Werkzeuge nur für Admins | | `ftpmodul/` | Kleiner FTP-Server für die Entwickler-Werkzeuge | +| `source/shopctl.c` | MBMNET Shop installieren, öffnen, entfernen | +| `shop/` | **MBMNET Shop**: eigene App mit Homebrew aus der Universal-DB (Katalog und Downloads über den MBMNET-Server) | ## Lizenz diff --git a/release.sh b/release.sh index d889b37..dd578c1 100755 --- a/release.sh +++ b/release.sh @@ -2,6 +2,7 @@ # Baut die App und lädt sie ins Repo "MBMNET" hoch. Danach aktualisiert sich jede # MBMNET-App beim nächsten Start von selbst. # Zusätzlich legt es auf Gitea ein Release "vX.Y.Z" mit .cia und .3dsx an (für Universal-Updater). +# Der MBMNET Shop (shop/) wird mitgebaut; die App installiert ihn auf Wunsch vom Server. # # Vorher in Makefile die VERSION erhöhen! # ./release.sh ["Release-Notizen"] bauen, hochladen, Release anlegen @@ -23,12 +24,13 @@ if [ "$NUR_RELEASE" = 0 ]; then docker image inspect mbmnet-3ds-build >/dev/null 2>&1 || docker build -t mbmnet-3ds-build tools echo "Baue MBMNET-App v$VERSION ..." - docker run --rm -v "$PWD":/app -w /app mbmnet-3ds-build bash -c 'make clean >/dev/null && make && make cia' + docker run --rm -v "$PWD":/app -w /app mbmnet-3ds-build bash -c \ + 'make clean >/dev/null && make && make cia && make -C shop clean >/dev/null && make -C shop && make -C shop cia' - cp mbmnet.3dsx mbmnet.cia "$RELEASE/" + cp mbmnet.3dsx mbmnet.cia shop/mbmnet-shop.3dsx shop/mbmnet-shop.cia "$RELEASE/" echo "$VERSION" > "$RELEASE/version.txt" - (cd "$RELEASE" && git add mbmnet.3dsx mbmnet.cia version.txt && git commit -m "MBMNET-App v$VERSION" && git push) + (cd "$RELEASE" && git add mbmnet.3dsx mbmnet.cia mbmnet-shop.3dsx mbmnet-shop.cia version.txt && git commit -m "MBMNET-App v$VERSION" && git push) fi # --- Gitea-Release ------------------------------------------------------------------- @@ -55,7 +57,8 @@ else fi # Vorhandene Dateien gleichen Namens löschen (bei erneutem Lauf), dann hochladen -for f in mbmnet.cia mbmnet.3dsx; do +for f in mbmnet.cia mbmnet.3dsx mbmnet-shop.cia mbmnet-shop.3dsx; do + [ -f "$RELEASE/$f" ] || continue for old in $(api GET "/releases/$ID/assets" | json "' '.join(str(a['id']) for a in d if a['name']=='$f')"); do api DELETE "/releases/$ID/assets/$old" >/dev/null done diff --git a/shop/Makefile b/shop/Makefile new file mode 100644 index 0000000..4c52861 --- /dev/null +++ b/shop/Makefile @@ -0,0 +1,90 @@ +#--------------------------------------------------------------------------------- +# MBMNET Shop - Homebrew-Shop für den 3DS (.3dsx / .cia) +# Wird von der MBMNET-App auf Wunsch installiert. Version = Version der MBMNET-App. +#--------------------------------------------------------------------------------- +.SUFFIXES: + +ifeq ($(strip $(DEVKITARM)),) +$(error "DEVKITARM ist nicht gesetzt. Bitte devkitPro installieren.") +endif + +TOPDIR ?= $(CURDIR) +include $(DEVKITARM)/3ds_rules + +TARGET := mbmnet-shop +BUILD := build +SOURCES := source +INCLUDES := source + +# Gleiche Version wie die MBMNET-App (die vergleicht damit, ob der Shop aktuell ist) +VERSION := $(shell sed -n 's/^VERSION *:= *//p' $(TOPDIR)/../Makefile) + +APP_TITLE := MBMNET Shop +APP_DESCRIPTION := Homebrew für deinen 3DS +APP_AUTHOR := MBMNET +ICON := icon.png + +# CIA: feste Titel-ID 000400000FB4E100 +APP_PRODUCT_CODE := CTR-H-MBMS +APP_UNIQUE_ID := 0xFB4E1 +VERSION_PARTS := $(subst ., ,$(VERSION)) +# Titelversion wie bei der MBMNET-App: 2.9.0 -> 29.0.0 +TITLE_MAJOR := $(shell echo $$(( $(word 1,$(VERSION_PARTS)) * 10 + $(word 2,$(VERSION_PARTS)) ))) +VERSION_NUM := $(shell echo $$(( ($(TITLE_MAJOR) << 10) | $(word 3,$(VERSION_PARTS)) ))) + +ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft +CFLAGS := -g -Wall -O2 -mword-relocations -ffunction-sections $(ARCH) $(INCLUDE) -D__3DS__ -DAPP_VERSION=\"$(VERSION)\" +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) +LIBS := -lcitro2d -lcitro3d -lctru -lm +LIBDIRS := $(CTRULIB) $(PORTLIBS) + +ifneq ($(BUILD),$(notdir $(CURDIR))) + +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) +export DEPSDIR := $(CURDIR)/$(BUILD) + +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +export LD := $(CC) +export OFILES := $(CFILES:.c=.o) +export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ + $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(CURDIR)/$(BUILD) +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) +export APP_ICON := $(TOPDIR)/$(ICON) +export _3DSXDEPS := $(OUTPUT).smdh +export _3DSXFLAGS += --smdh=$(OUTPUT).smdh + +.PHONY: all clean cia $(BUILD) + +all: $(BUILD) + +cia: $(BUILD) + @bannertool makebanner -i assets/banner.png -a assets/banner.wav -o $(BUILD)/banner.bnr + @bannertool makesmdh -s "$(APP_TITLE)" -l "$(APP_DESCRIPTION)" -p "$(APP_AUTHOR)" -i $(ICON) -o $(BUILD)/icon.icn + @makerom -f cia -o $(TARGET).cia -elf $(TARGET).elf -rsf cia.rsf -target t -exefslogo \ + -icon $(BUILD)/icon.icn -banner $(BUILD)/banner.bnr \ + -DAPP_TITLE="$(APP_TITLE)" -DAPP_PRODUCT_CODE="$(APP_PRODUCT_CODE)" -DAPP_UNIQUE_ID="$(APP_UNIQUE_ID)" \ + -ver $(VERSION_NUM) + @echo built ... $(TARGET).cia + +$(BUILD): + @mkdir -p $@ + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile + +clean: + @echo clean ... + @rm -fr $(BUILD) $(TARGET).3dsx $(TARGET).smdh $(TARGET).elf $(TARGET).cia + +else + +DEPENDS := $(OFILES:.o=.d) + +$(OUTPUT).3dsx : $(OUTPUT).elf $(_3DSXDEPS) +$(OUTPUT).elf : $(OFILES) + +-include $(DEPENDS) + +endif diff --git a/shop/assets/banner.png b/shop/assets/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..c9a4e54099863bee2ab0f478885efe5826e9a3a1 GIT binary patch literal 1658 zcmYjReOOav6hHTJx7}^6?)Y#z#=JluSVFo$NC_@$KtoVt7!wU9K@cJWMqo+1Xb^;a zls*NaLT1De6x0Qx#TdTB=ahFc9_Cs{Hxq{MGB~E`$)>V`I|^L4NY=a**DNj3ZXS#i zUfAyTQ!#ux$j-X{XH^mP+DH&Mm2)~5lJjU(dWEcsjQsR36O%8*p^?@$s>lJlYG-+z6&jE9au4Wr>wO$$Q)17AuQrW0SScRd#v0dxv&f;r@mz~ z)oFfD{DPx5m?4&rC>rbtz%@tELNzB@39okh95_QFk`m{8s>xwrEZwQHI1ipC>DzBn zYth2`qANm%9VTE7H6CK0cB73`V&j$ykUnAj5K{P**-b5RDPy{E8Zv8kzn1VyxaN;hts+unzTN%FEc9o#pgRW&9u;ZBBcN}}T(o4PJ0^jdig3s)xw2Ujomug{(dE%GqM%s=|r^Lojg+TWGF7#LcgQLn!uq*S}Dqn+bD0|$U-ljtt#ZpuVY z(;T}=x!v>0I6@G>(vMq zwXK15I$fOZEW97)Ve&SuGO2G4kTi8I;GCb_RKZJqSA0CmvSVkMEFL>qzpcKxA(+>n z%zXM2X`aU-SZQsK`C4}ko9`q%Nvs@;6IfN-6PGVSar5ujetVT_Jca6Rto6^{ArdOu zA4@RJ4u#s9zmbK&{w1v50|v>o*0N^C!EjW~`V5<_3UpQdfRz_)EM$T^ow&Vik?EI@ zieFEgi&|UxtQ`_{fI4E1cvyX#r$exo(3pAR7S*u>g~ z{P5p7Xfl1`*G-3e_7Xp4Z^?~oe-DMGIOOPE5iNE7|ByC&(PszQ%={je?pp(G366$L jxc&_G?-2iQUTXqo`C86pE;AsX4x&_PoMm4v@R@c)Kr zDf)tvso~UmY8SPP>P1CUpV21N8&yWZNFv|K5z>*o!%4WE)?Xc?Y?j^93F)mgQQoYS z(t=4vsuR$)epwz^?LRX&AX&y1jFQg2WnWJ32z7jhII(CVv8A%TLYHW<~7Df`a=FSYhmtFzfnu{i8LppaimsT z`6wFvk)DoDW6^=aeud-gS&kE)xuSqSG9&dJEG2_agkBHp7xpBiVNhvHhT)}9f(v5~ zQ5TT`4I?u!tEI_0afkQ4Gt~YeZ+^~_>s8SSG4V=ya>wP+X|VR;4pgfUx9%Hbdzl*7bIG81pkT7pE@!J?q!K z-?P#-<{WXvOEnnQd^_xX?5N^rl9nemEZ!xqZzK->U~aF!$(3eiQUlNw(i~4!?@D_A zbXU*9vN=oBb$^=vKKQ$T>iDd6g|&QlaZg?SpsSH};-?nxl1LZNj9VNvD|C?cwjqP3 zSOfhOrII{6M{BK=6O+7S9Aop(W<5=7{pWm2%hZyYiTPh#r{sL*z3Eu!`{>;Gy9t96 z;^K!zKMPB-RWv2&JnRL!7S#}qAT97J)ho^P*K{wiKhNEpxiPIo>gCi48Le}bA|ddC ztk!L|HH-+0eH%Zhm=dQ%r-wHP-en%GSGftyb!snqO)~K^ZJaVoyyETUd{QtchtJGQ z+nClc1Ld47{NZ8Lnd}PK4PeQG&5 z%kOgUD(aZOExRb=b^5Z5`q_W-OS$%nC($8Yy7hCIExK3iso0t^Jt7x{*0*t{hPo=8 zo1R3?LU&0j-k@z%o{3F;bzNqA#k_>eOcS{oJEIxWwji(mT6gV&xk?MOJaUR zACK%2HaMu6d7Qo?e~xieACVPxCGGHKb&%X4aL9Aa@vvZQZbEj`thnswxz+3u9$Fqo z9o1E|&I~ORX^k!)eLm6^HXwMCWw)W0FrQsY*P!~M3nT^a)~+aF(raI+yGhZL{IJ}e z*~hcT=Q;`$XRSbKT!;H^oELOAtb1hDsIif)!^?*(wmvkr)(zsyGe@b@2$S}t6Mm^~ zm#+redyhKf?IrWe=B~(@lN(#m!SUW(OTpB3-E>R2kU`Jm4Q+xf4X~wqgi22-nrcD+>80PqId2+A|g|`iN?9MI-%pkEfLki zlR~QpeKwCYv=Gj+H|b&2DpW*TksdfreI>_;Zto7)@S=wW*8Kf>$Mb91tGW94o2#?w z8M+5%d(f3oDeS+n=Akcxu2@DI8|jvC{h2S+7gQ1L0a;wtH07l9-hbRP$9dYmt6)#Q zFaKeo>X_pFDqSZ#Ioi0y`U>uPXjr+>3c(YsT};*WgZUt~2VH?0i_*Yb^uv-$DRaaP zzIpClj;8ka1?viq7A86J+}8t@wG_IY&Sbu0D-lvU^m9n>VA_^!E@o&dykm3e4b%k` zi?)$J_>opzbxUmnL%a)JYEdQo@WLO3hl_5w3Va1}Cv=8eZ`fj)9K?h)4e1y>-1gNh z8Ip86xaAB@Yp5IgN}7_P*rrueeuyRgJv_IaCyOT8yV++Hr8y^g?V_ZWVf_K@5TJ9KS-B7+NyNK>eZA50Yfu!LNS~Inl{3sCao9?dZJXT~X+E&!Z+167h z@Jo4*E^<+Z)8>k{IYHZk_Sl|VhM6AeD+$@G74Fz4)EK=XO~@ErS{tEMm#+FtdSAID zhqowDl<1u5{^%ppQ9PXaDV#E{wsf==*v8rzYmsS|!7J?LjxlBE1Zo<@h&6CiQ?-%m z9J!i!-#5_H-^DsNIx0HzUDv$B#B*w0>J)cC|JI~gY&Mh4X6Jy)F_bWedVs=>g}B2Jm$*qSOYG(4q4Ca5Rh@D*Lhd9)j%)vdh7fQPHC~&}c+tby3#P!2v@Ko}Z6Q3y8 z$SJ0y(8sXF^wV6%veDeoG}_QrSC{X~KB9L(ENct)y^IuKtesVfY>;HX&NtaJ&E3>3 zyZ3ub1p3Gv{zA>;F6gQlKbn@CyP5l#UK&z$-}plIZ$_qqz~6+STcDApc)TVl)#OHE z2mfyG5YJb4f~UQ=kH56EP)$HL8CJNZKWltoa+x}tOvdi|(ZV9`B{Q7vPn|(^Q8rmZ zoY;cjsST8I((1qq-w^Lw&s5J}-dDZ~VtHj9Hqm{#y1I#m@5T|P5~hmA%X+&I4Da!s zzCz`qp%4wPfpx9}|65vJBwrJ~{_(#4Ubm;UcdBo6z#x~KDF-dNG- z*Z0%ycROL-%OiSzwUeH*-My`6l&{4r9t!jkHAb?zCw ziNny>XfnncuIoaCLEIY#d;s+rb%IRfGHFJ3;JsQm^^IIwS|6z9Z|Mto`}!97`-`9C z%i4W(n;FTk(-{m;4bKfp23a>(Na5PREqx6s%}kVtev(B*#uab~c+ax(cClMl(ue6T@in;T%qn_4^%D(4k#N@+ z5k%_YzcjnDP!5(}1itwD`RDqZ1ulx`!fQg zVD13hjj2iZq;8@v5Qjd2MmW+IchQp6Px3Npptw3vHqbLrOq?V2QFdrNnna&u=kp(g zb-D|>*1Cy8JD%ZgGMnfFR1unn${;5>N7|CBI9>a!?o)cp<)m8T(ZHsFRg9M|%U4tx zccRuZb2x=}30-t$bjyX+d~YrW-ortaftcP5>Ck(yP9y1yCu%*^B*iP)#cE=1AVJI$ z|B}6m4mUy5>FI0~-&jZyoWfG!Du0BV#4gi*~82h`W#rrGE@sJ>n&MIg2_L4 zy|zjnr}U90Nbkh$;x}=Z^i`gtj>1#XG`cB!jl03u6&ea3cr$O|0!%I~QnjgbAk9dW z2dC&lUcfyop~a{sB|`2kc|}#6C(V|#lmps&vY6_@WU*HM5wGwM_)>gJt|?oa`J0|j z{X&!AhG6m<{6!?0h0ka=)vL;FxlkG`)shxS4dgM3PrZZpq6xH>?akHZ5A(bEiu`15 z8M}syTHB4MZi7OkRQSEJjx1=UT2RE0IcnIZt{mMa!RLw=zf*$UQWI z{>dQjAs5N>{7x>1HL}H-CiFroAI(R#kr8EpEP6vktcAyd2R^N2%iZK)xuSeQ-l=?7 zdtrtAOGPo=*?Qb*w&1#PR*>aehGFW^%PBuvjs8aA$V*<3HKaT_ zhKbfto37qdDk?wZ&vF^%tMWw+$8(7twW6moz1e)W7>C)dY=Ei5Or!5n$<$fY7nKCd zN`*Tzn0W9Yd_nW5b=2KTC52ZiE9aHP>TWF?H-}Svq2DtL*dJ^PyPXYXM=@vV0M#9; zs_7t28*;%Z){(!+6&!^}XcttYx>{+jv{Lpd&DC1kAbgsrs3!e4V`f*d=h@Y438<#h z=!*18ilA+%Jt_t<`49Mu5k$jN@Ov#uo2?cp8DyE}>N*+@(lj9loMHp1LmuHOcpX?q7d1mUpS=Yob_jnWJXL}Y zWgasX*w$=u_AXPE*+>^befS$K1iu-DG?GFtkbj6kmSd;ZQM<0zRr8fEicVduHqmP0 z&SWOqMs1{fFh7|H*2F$$+B3K4IM4@2?L$3KDex9WaEi^O5qXbW;Y(VKwoR?3a%#9b zRu#10S_<|PGi9Z-=!ML8rjWVM3}RB~RuIK1Quom~R1<}Qeg6gzKAr@Top4uYYi@Os z+CZ(Uj!;w7&Dspm>1wo&8c!EvHp27gnNf^HPXNE$lgdOZQETuw9C(};WGh60KX@Si zqO}F@H(%|oPEenyy|qLfM1oKdB~jPuM$9T^D>H&IfzQ;_izq!rkO7dhT9F8+xJ0Iq z7;*}i#fP=h+GBN%x?H`hR?&WG7x6)|6D^_I(|L3oW&+cP31?1&Mo&_esHfn08=z>g ztOB?rJ0OoQ#M5v|E@06O)-DXx;aq#}8R`$NvQN^77+XmMIM?Sa-4q?tlap?@edy$qgC zqo2}C>C#}C?ZMk@Lp{N727%|y2d}k<^nrM`A6Lh3KpIW8YVZk1wPgGpFC%S{iMmSF zqqoxc=o|D(x(@w~8VCBg0KTFwiUDuog;QK73rS7#3s1*E_?k9F>#7aVc4$~zi)#~z zxTrr>MD?V%&oV?yecDNFwl+!IqGf1( zu@4_6y^uidqr&MS^b&eD-Ik{4BUCdg5B-b!p{n3-803Ld+<;ir2=c~txGGN5j%jPP zf3;6q4SXB-Ap*LDI#BOHCj;q`bZgoSk{Ag0<1(6v8i6zoke~eDDGrg5B#u16W5H*9 z(ave7w6|IeUW;SN9Wn$_)Jn=tRi``9P3Ta_jb=~@@E*%iM^qLV4)woN+#;(;OURWD zF{-gcTSlk}pzzxVpG8#!}GWCwq!wt94Y1Cfu+yp*%Dp*AcxFHC+K^Aww z^K~LRas%F^0;aG><8ghw4I9Z8k_@t_O6{OhC<-K*PMxBLLU!~LZfQ60SmB^k2~P2r zoPc}w7sP>mxDPIcP1p(+G6$#Oek7YrKrYbgc`B81z~@|~rcmXnbhH<&yDo|cuSbX* z?C&1K65t`b1r!7EleS0GwV^IS zY3jkgU2uvAWINba64ZDH@pw2#D?9*ifm_-d&Q}|qMWNI%AadualhhijHx&!H=3X=k zcwHh$lLn8IN8Up$S^!$rlb3iaNVE?g3UBlhmmup24U+$ZYEVoP*Dp4`(=w?YI#+Ow8cjK7%}aQ`6y7hEokG6V#=< z(P-2ZC4)4%|9^_dWFMJ8>H;5ok5AyucoRN}f8r!Ehoq8b=m-+QenP-oZ$mk#9MuJ) zX)Um>n-EbOL$yw!EI30C@(go0Mk}dyP#((1b>WE>hlH?6d8vyUcRKEqp7Kxn9dtd=-Ug3 zvoQOpD{%5t#E&0pIm$oMLjP(H>l*8*?%40V>S-iC*Df;c^d56^(4OF4!8>fP%nJ>T zg<0%qI*e+L4wDDCk(Mjp4EQ{u&MSqB@=f{Y3T8Vh`f8{z8MCoO(CDxQ5&a_ChW!Y- zYTl?lA`@KEANB%;uNJbdvhb6}y zD*iC>MZ&n)!{O6y?+oqua!g|?2o(XPs3-e<%bknzpJaAVO-{*8aip8_cX`(1WBOeo z9b=3M%Zp3JnAmmUJ8dT8YQ8Tsl&XS?ljT~9oacY(;tNM-+tU{O`SGWF#<#pT?#3Dx zo&~Llni=0FA*tBLnDOCPY|V_%_;t)Ossn0Ierc1G7XjHl&93Im%J`Q0A@xG9vvJ0&34jQPjE7?sO9Kis75BJJ)}5aw9}D4A$v~7+w@PabDp5HR( zd)A%olKEMVi2*N}sB>8lgh`R_qWVM@3;SrRZ!+l4vwdj?+@%^MOS`G86PI}{&Q}HV zbAM%z$r+J<*73xji<5}T>)auRYc=dW;7^gFeDuCm!3yf0jdNQjsndfN7nX^_sy9ifw`x@a!+1oo?WQV)Ma zcg3Pl`6;>Ia!myj9VdJy?E#xJRx+wZI)?;t|s@Ku1|FYO&r9GwpcD3 zXzXe0a2Nc{+mbi7V3ebVua0_xIc6AT>lvCH{w?f5$XMG_(`jI=MRX5p7`jS6;l7$p zu?MPq`#S~u=lst3Ukk=LK6+;>hv*slYSwxo^};HJ^$TfkTWxx!Tg3%46R8=%;3#qi z81rE<+Bd`%QuLxAq@ZQte8)oXEBOr7Q>TZ%Mc2@!q2Ayq+Zt1z?gCeXSwpQsnGkDz znxM9mJidPJ2*<6$PX+f1cRT8PE6Pt%Ga=6u9yBiGTgZywTTbVh_c~HMiBfseihF7>SWg7a4L%$+%{s+YULV8nV}1e2iABrE z5nNHDlu9Dy%X5u$gcUU_s_1;^xgbu#1KFo~i{+*5O;ClPn${tvy862OeI`JCgDA8Q zB)UMGt9%h(`JT92IX@P?DH`v*=;Y_NIg|UU@KkIGlU`vFlvwjs{jBQ1SP`!aN zHHCi1HFdRI5Gd`t=qAo-&fcy&p2>j(pq~}_QHB}jNNas-sKsyWqTkQgV~5ZssgY1u z3?y^0Mbjy(#WY}r7hRm|lIw$~ap07)0S)0c>(83TTJBgLnco{b=`Zm;*m-mvV6F<} z7H4pOkW8Yq(O=#>$i3BNb2~jzfyRoP9AF>o@{Etn^(+(2CyZ_MkN8RK4!S+H6!Nr9 zkewaVa+PyZ?Z6+e$y35T(H-hl{2B59lFwkFj4{@H(;RJ{Yiz84&#!`R&R}4pVZi{Po@dw!#KsgQoQ@sun zO~%L6^YS>cvcJ2xk*BU_g7=5Nh1?78q*wCS^`DK`Oe0M(#$iMqIGw(_vAW4*$}t4%^ZA#oOz)%a0@?XS`jWN4 zLe?vbq@jT|zHVNVH`V*y|3R9gjidhJ`s;QWE*sAo&lyx*5%5YIa|x(YQ^*gdK+d^A z^D2}K(cS;cJI!0nm*GDm#cDZd3wuK#`fA2b#;Jx3c&?Bw$vlO8s~yzps~|snuXR#q z$n(T={z1M}?;xMYKVN#Hjzk(0EsWA%F}Mx24NrB2yu#LDen1`56RA+WaKsFf$(1S4 zMTW19@2F277%a_DJ!CAikpHTyW!Pj;^e1$=yuh_*iYN)_7mv;YC25KSYA0o`bT=@> zf7MsyOAfS^s;bLK7H#KR>Tc<)8gA)V>eBciZXknb9rQe`(8+27jJ>gTN%2Va#XtUH z{#w8v>q}mxI2lF{GDFS>`Bzg%|)_%NHv#NvT>tc_<3jY%S zs6aL8uJRu)puVv+glD>m`kKHjU-HGcg-kM7_9D>yS2Bq_$Ny==pxaYHdKGZ`-}^TP zsz~dVad;-xgT2gm)&=R^x+vWpz67^{sXG5)Q#iwy=o}B>VGZ;71vXyaixD+qm4aABqq_WCR ztqtaX@9#WVxK=YAR z^hb6*zfJfqj26c8#kt$eRC+vh18M{o{RfolD!#8RSIa4Xq~Bs+ajE!^#46>q!z7s= z#wPQ%g-t>op$8w!{l_e#=TiSc{cZ$iIhp)`%Kx3ZSZN^FmQIOx#Kn>%Yicj@m{OU$ zTngV>&&NIbUinRg`W^PWh7h35QcP8HugH-{Oz( zEKhSEn4?hJW%Zh5P^3qL_W>3`WT+!THipT+sv zkIZ@c0_B8iHW})_%cLDSfuCrX)J@6;Aeo0GB8k9bhv7MBG2Mt=$W`F$^N+b)_7iiR zzC$T!CMpN<>^>PnUSnDF0T2Eomy}ba5;CQXRBPZGs4|_)RN`_tJ%5<{2H$s|eopb! zLR1B6y%%H}DFVs-1vNoA(0-KMS~e>^RU>evG-?Zz!XDs0af`TT>^oqMAE86D5-J-8 zNcsxMg1Q0i9IZ}KF3VlzNphUhR)zeAY@?bm>)C$XKCVA^m3_s$qEmo!Zi1+!N56pI zlp^!+A?=iUQxWBL^1pILGO_YY}5v>C;Q737U`N~$~!nk$pedZJG1Zs2;rXqqt6BkK0paL9D z(i*6<6-sf+y_EXuY9N5G(O~*Kvy~;RgPp-%XTJWAZJz>u7y+FBF4TNkK>m7bbKs

B=2b{l(;ZN(me=XkmebqnT55+I(v1MV6}7T~*@L#?772UdAhX{~nE zPT^9(OS9?k%wTplTbbPj=QPq?s7L=}*Mxi{yGV7AOrcf+bn#o+t2|V?s4cbCSR_NK zWAq-TBHNXXVAnFAX&c=KXu)u((rMtM#~=&(4+vHdZND0%zEDz?{%S*QJbp>aQ~%I| z8HFj&YRm%W1MrR^(8nJGQHh5*afu8d>9`?Y4&>}_pv*)Wqt@2C1Hltf6}k-boZ;AX zW+L;3j)acI4ZW9GmOTndyi z6z=@?z9g0=0v6va|NDT_&;*q6rzL*?B*ot zNiuL(d{e8SJyrLqkJSdie`pem{-R3KKk3FyS0FdQbhKHq#U>A4^1_0@PVLnyJa?nL~685N?{<4t>^m&|ogu=qwTjb*O;nYhhZd z>QvilUhOmfMpDpAYBBAlqZmKEla8X-P&#T4(5~XZ6YL<-mEiF&;c`GP>uN|7;Cr3g zBYcxwL;HdEy#;QWPOqcG>2;JDZc;bkQvyW#w`3b+C2yg>@Dey-f))?V(+=!*57~sK zQI+VEbP5p5#dI*t2n18d!Lmz(=Di@%gU~6;1O8H^Erz~Ab#1&>2>pm9K%+WP0kR4-KFti8_yxV4VNe^a20D2ZzOfxpT01b-F{A@3L!|*@Jxm{>M+0fz z1T$F|Av^d#(yWj)h)h#~wj6`|`&65&4FkgJ)YjqdqzdAw+f*ZZIlNsznt|uyscUEi zM9(1LEZN{k7n2I)ArQbEV32dQT_De8xG4!m8E6Lu_nw{s3{eAeRh+sFH=;UN3<59n zjBEtS{De2;@eOU8c0!Z11-J@P$RjkB(x`SoKpWG3Y6DdQxcmgj)53wt6v3_ELplRl zS_%EDFWOn{rbe{cIFY0Shwe)Krm6tJ{tJ2v>%kNLho*oJjRNoM28kXAkFO_(abxV( zo@g(D5>CgVz{i)PYSbMdt~KcrbT&|_(!kYb027M=&HF&27a=Q&CwFi^Y{n@--mPGF zI-t>GP#BO}nJP|)fxlh_-OB&t)lGq72|&%$$SvS#RiMu{9f+b2v=E7>VvG-w&On|P zQhz8T&C#D>I<73l+y$sP_%uB*(JY{6YoRtMz#HJCCg>_9!uNp>C3Vm*Gz{pUAO3-@ z0(<*_mOyrp2ytEoa`ytVnXaJw6Sy}_)&&6zodLvb5lKQ1Q7a&ysZ=)g04QWRpk*r{ z4^0M(!Nd+s?l2ijLZLf43D<@vYvEZq2TvvzbR1QrHos)dIE8O4!nFc9);Hf z2W&~6laA;O=za#Z6`maecJ>=>hmKqour%&}bowr?;Z$qqQ_T6nuX@LX+>RylMC>40HRfq&ho zVpIxH%faBYqoB&thyxi9?`= zzeF6V3?rAA@xpigdc#5e2EoF;hDxyyWZ^O7h}KX^7InU_?wRhX-h1LP%ya7uB`uAu zl9@4%5R%w0)H=v%eq*2dLOS7_?wVHg&EDOazNk@ttGu#>Dej)?J>F|g4(}a#B0M?xA5*l@n6Xjm&{fV*`UN_=l>8dm z?=rnPXC1HQ(n6i!Pf@pHarE1;FzYQ{5Id0akO_FMGRQx{Va@HGK0ft&M$bZ8Y9`bQ zZ5>Av+80|9^)+ak!NcC9^k^V1r>yinu=mP3lTzl_tUqbFnmPip1d`wecZ*+ z)}~x8o-T=cVY~dn+qm#sdZnM}TZ5l%v+jD2@Vz5eCdHTeSSq3T;fR_RH@}W9cLgMtO&(>gF1?DJ9!&yp4ziR(G^XO0N>y~d7((XC7Q&Aze6GBUmDV?5} z5!Kcu&5n+9CWbQpi)&zo0Hl`KeWBo?Pao1 z1+q+?5;)+fkUjc$nQxE2)k}SAH4Y}PyeGU z+^K#wH;FBg+_}{7q_Hsx!Crk2wiUIJbkkI^(B;StO||@J|2_FnwSs??wWj!(UL~ZG z(~};@Yzppa*ujpW&XG;pWa+T`V_qQj`j2(rQ&L9f|1CE+iczbQc9vY26ctN{+%f!Q zH&Cz03oTRX=DC>vBW=Xb&)@f_oXRUHF^2b%R}!n1T#__0c4A19F@d{68PikH1se z3K@01r3O6lw?Z!5%)7}jX9maNUubQ3{#f}+BtJ_=BKXz#igvy`pbQo zx@8+5dnPe8k&mw(?lD&t(&)n|k(^QWqTsrf+c?emJ0vAGtA}eD+Gd>-6PkD~v3&fN z@LrZBLNId=H6?C!nRv^!HjhnT^Sf-ywag{Xab%KZdGxg6(-Ygq7lhxnd=;88KhYpk zRnx@L?!Ng?(}Po5ru50Y;pmGynb${sNT`)KBEDfnlC_*}785}8$T)4h6zVBmusUOR z%D5DLrdZTbt76(3SvJ8_d~W>uh-KCVy7NpZwTm3neoEIpu7VDk<^JqWxu4OnsI?ku z+!Zmm*zw|P;(tZtS-vEJTBk@b7RU&i++zAZjE zvbC+ezBt>7dIR+3i0txCw|~v*oNE5lDkHbBi=3|87Zw(eivJrwI`X7#iM}s8k;;b2 zEk;@9t6sD#yCAjWpO}o+g@dFw!hz7Ll~92Qi~@Rhwxg`ti3yt3ba*b$qXoSysVdwQvYO@Z^= z!QdY;UyCh`FCKL-Xsscf`%0AuKSGp}f$q*zJtsmTia-Ve6RSWQR9Le7E1}g zo7Cds8CUmwbEcSjDgA0*knaF}#JV7AUVPp7^HC3jw;NNqR`diEOFpYR#LjM8!Ntse zX;;(R=C$@7p^jNDMxywL_%Bf*Ax>iwKapO6>X0x^FL~V03VLNdO?!}@pS#*~2Awpg zMa+#WiW8#-hqN?};{T=hq3)!kHceXZnOjI@S5NT?r4qW zTi#TA=bX3gM&*pF zIp3X(_C&ubw0TTWT*v4Ip$E;Yh0Y8`-5_yTl0W%sIS%KBW%kXOm($Q$QvIO25OOy9 zRc!C*`=NgG7hy3IOT8s6aR;ThKh@DLZ)@iIjJ7#v9PO1f;Y)CE^rF}S(Q0TbOL5&5 zrY4mI4E=!e)4#;&%Zto%IW zCYoAITIQ)}f0k1*>p^yU;a&d>`kc8$_<-op=;-h$TY(;NPMBn`L27Bsq%iMWdym{D zSr4<96bAYCQlCtd!n{%bsCwZ8Z8Z&bxnQunj%2)+C++u+E6UFOll45iQsHRdGb9`D zg)WWy7}Y2Iux+tnI#+{Uf<}@O+H~PDf1*&$hia zT;}@FJJ5XcNjoT?_8oEP^7m#JWbZ7f;w?j389ImTid+!2I=p01w9&&YqR+s5H-kmF zyuX2SXFij|=JYJs>luhA>vsjmM|OzX9X=#zsIdxvf_{Wfl8U&sa@_yPS*~DAPKlgi z1;L(wwY|F3pnVZhQ76Oq1f4ZbEv{;9qaN(9A4 z{E56A{wc_Byuho_EBgRlflOsfKytM&Y?eDY=YGD~ouDfGNZa=C{gF4q^}!8HZoV|c z?o42_H$DXS&CU*yg3y1`3Le+gZgc*+lT-FS7P80;Bu zFP--#XHI^i>xK-yE6bv=@W`v-qk~_VrU;9`LIuc2&Z$4de?7#$C(oKYH2wkqF~LwV{;YU0W2A-!`{%ekbP$=>s#_JUDcA#G&vz!5z(_ zkjjjq%7Bd|Yx|{x-WbQ~{I$8w@;#0xqJ_C)dJ|GAVpDi}@J4eT$OM;Dbx<|ZN7JN4 z-!TVU@G-Yx{w_yZu{|vsYlXZGpBK)Cyfe?yHDwP|ZBSdXUh5(6^;LDwFNn=+nBU5= zC9sjIYuq0^C45+TVu;0ZS2vA)2zEDw+|y3W5&m0FrC?Ov-}zb*6ZnSa8@S-G@K)jV zLt0sg?gX1kjYBhtT_f@ue^b}=!W((*^RE}p@F${2`q4ob!%ByD3t41otWRMPJs+(l z5xBiV{Lfw9LQ{VK{Fy~=-$bI9RL6xnmU`r4E9yT+?VR@);#Wkf5p^IcVPE)=I-nxtIxAWKLn~OwmB;F~kwN4E^ z9kw(i(MlN>aKq`VK#^DCWVMOd-ZQ2svfxyHs(rC{qL#rITKLe}Ve3LVTN@d!ajWR( zklCNY!_{NrD^Fh0w1T(!H|(L_w`wK6wPkZi^RO);3#{`DMcg_1H*^$V;KQm;YT+I3 z_*&r4-(f%HsiMx|j+x^_%wbzYPFrsoO7Wj*H`FRQ_>Ve5y64Ss)Gkacm}76@*`d5* zS@Y50ccGg@K3M~X0bt7lMI(kpXb+_tz5&iXg`EqA+4J0t63-4Yl?&bwx-LYqRyFS6 zOE7U%5X73cT9kak_ruB9mlbriZ*)(RCo^}9XM@^?E(wXZ{bPK`w_~bN31B1hwQ+K^ zzpHDs{Z2tEdjog2^n#8yRu1Aqr-wAO9X9HP=}a4{5^6)vYWL)o{@1P#cB!C=-RT-2 zm7u2^&e?8;3=ipR`(bP%9E3@M`e-2eshJh8znQyUQO&|e_7kpO;sWZEzPxQrNav90 zu)Hu^c+JeE+QN<%hkHU@bK8BiXhz}R_TjGnVk)YtKW2>ysS~o^*3@)KkeMA&e=R2! z@IK{Hpo%BRai_4Yy^Jd>&;)JJ6}LVKjt@C(n`$Z)O0k!y(P$^>0(ZWoc*HZu!PdyrJax-goBNci8`NR`C~Up}c158MHR|o2{(*nJ$ejL!W@^?<4-MZkGo8D!A$t zU9q=xzVi*zW^xlve%rv{6kBIA)&+4b>6^ex9oVWBz|PVR*P5cQ_Oi|?zHjOuHp{rt zRyjD;HpBdvzA-n7egzysN1AB$c=%?+Dd6)hWcZPPs^ifUNrTZ?Uz<2jL zN5`T<$2@OWC6;Mu=x$vT^vU+i{8Imsdq=ZW6!?+XTB0&I(8QDDm|v9UsP6qKuc9yM z^DG^Lp4%j|s%Ig`h=N&!0VJS}QXT|Oc^Ww{6#a0xJ;P*~Dyg4su?F3;g;>fPs`HUd zMXD+S&V)}Z#l#Trzs~HUZ;neIk2HqbsWVt!+fLcaS$Z3W^9`7$R0Fga`gujL=Qh*p zcg8z@IA(a3NomNe+hJaA+iq)QS#H?Q4`q6TzuN-dp}hJ*{NwHH>gh;zH1)(uz0e{d z+1${!z}Cfb(eR941v3Rb(IK)7k5o%bO?~HFTOIk1V9z!26AADaOg`%{+YrkygUDZG zR>IW6Md-~RQ0GWTd?xp2N5GNg?k;vB?y<3_wX|)vrJS*cK(n`~1+csK8ymG)dAR?HyO%TGxyyYZ&<*$F z+8cQ*XIpIPU|c4Y`yX}qK?1nGHd6l2pXAx=tm&NXZX5WiePzEGE?WLrS6C()uLxaP z7tq^mVkKj=3vxnWfhWt^**Vay`iE+r*?xw;maEoPmMz9~VJ>T?PXixJCOb7ro*nq@ zso+1%G+TXw@A9TfPmPrVjZ}tFL?v)b>tyZE@CgkMu84+cN*? zPni2!ms{Q$>+0UKExhy>&N%6!r3wYbxq@$M|&38fwEwK+`z%S;Pr+NewBme57O7AKLKSW`ZTt$lsm zMpuOUq_3&+o+83V(*w&mORVXpt|NB{CKF1dMWh#GcuCS>-wAguSEzfkFHP=2EfXr6 zR#*mFs+iJsv$#ieF!dMONM_=R>Lf|^iSCiEFn1&0LOBJQg}26bmX4OcO?v%dE|X3M zyW0=m;TUX+cJ;UL>~cl9ZN8H7NVJt7W3*c8S-P4k>ECc1Q=4jq&cJ-@PgO6U^l$gP zaTRxGdEZEW636R|Z_TAFgH2uZ0j?C&p6ZHj0v|VPo#EEycvyFN_g(KyX$d*X9W<;p zTP)*D)AYspR?HA;AnbhQ!nW)Vxoe=Nx1PI>`(JNqDU4L+>Kj^_J?0sv9s1V%7}#hX z1KAKFqqSf1(ZF)=RCinVZ0}F;1ir<7(KF`H<^`tfFu}2&Sr6Un-_R-9s>Lf};H&q% zd!T!ucazua9oFVljrSZ9jho(uUe++Yl)oeq^LlaHyw9fEA_{dJDC74@137Y~T;F)&>B-l1%y?gvRWfJ<2E7X@YwKDBC zqzfXunKnS2y$X|OjkMQtMxcT3rpMr&?!P5lP(N;)-fOI3I$@x7vD|q&2Bsk&5HJ2m zGbt6s#lBomh_|bMqI{b8*@pUC#zfO)LxQd$_Xc)4JEB*_0Ge;5OcS5^BD}@C)&1q< zwqzrlqg!Y+n(i3t>xOUzbR(G6`2xExx3wk89ntJ><*fi^N3P_+<=FMQW=6O1KSM9w zGENWG*D&Z2Rwe1$U4@l8`)7L_cq#ueX)}Jx)X)jWug2$wDZ1mZb3K9@54%9k2#-!AVR-xIN()|h_FQ-*HFXNGsWiu_^bHuz8j#F{1ef!a|% z5m@EB=DqCODi*5g)KvbCzLN2gAye0mf5d#GPJ$QP2$h9XT_+a?9{Ij_&-zA+`_!dW z3_ne;Gu|@zbz}H+CLg*-(Xf$w6US*UWkgfGSmaElKzk3qJf7wDVn9} z$I#n20l7m-Cc%`!bGi+a!H)z zukSnUa|fO&(~+6mt6Of^30r#q@r__(J`FlocfmXCfSq}dc*Z}(ci#6duvAGvm)Wkm zo`zM13i?!j0BnOhVZQqr^hNG!=aj#sTtB?M?_!{t@{06j^}5oAnTDErnV-iFr4ewH z_b^$Pu4TgfSW@7U?}2YyK&Q+kEPGe*>xUT{>O+M+Fo|V@Oz;Op5Ee(M=cNIG-@aGA zxdGtY~4YH9gPpt`@n*EKLuUX2m+o$sMfFtpS65(4Zin0grjk#00h zt{hi~$id}&mi+*c5jc|PTc4Z_SXn# zl3(jVH{f%0dHQzxMM7nc23wvD8P-zRl%cfG@(S^RzqG$x;GJ|%V`(RMM)y+RM!!X9 z%f&LYV46&TX)@@d!u@B8=pq%S>$mFSZ9J(7R|& zV45p2(8nJem?xFe22&|sUdX)zE&;qxJE@G9mIk)?BLg+0W9nmclf5N0(O1^L6RvVA zm=`dakOVWbCh|!8r5u;82QI>AhD-mbGtgLeju4|y*8dXTa)+2d)IG4f$KV~ZG_#s2 z6$C!QIVG`*S^*`n^#qSDQlBm4z`n2#CdR73)Mgd9^YzpSIUe?769Xxtq-2p#jLN^z z8T1a=zWmM@=YU0p0M5$gv23Y-%+D&0vvW)?q1m#b5SGQ63sN&BF4o&}o$1Mx%k8q8!33^WYv z76&Rp#6{QQo9jO5kgh&oifusJp%xf7ZbD6fn79w(!gB4ra!o2NZVB`k*UKNZJ;Gsf0O-652Y4EkMbf+*}VjP#>jKT zhk{Zz8 zwF@`+9F}L6!}LKcRNiG^N_3bySNMnbpA1>taYq)nzRjAW?K!^JP%m!Z5!qowCkaR+v zD$P&^;XTw6b{XFhzI_*$#WV+79soV&lMolaY7Jo~x4Lvw94GZq%Hsi48@4x0*Jbg? zIGO1Uet86RYcIhjun&H};Hun9dM%EWYAdpqLqNek4uDKW@tc7hv@kTCg3XJtDvPrl3yGm(aJ9EENK2C z{1VO={yA5j-A1>AN+|-pfN7i7_?KEkc_x{qAyS?^Mw^0$GDo=g{2TrQ*MdDs_k~(M z7NSyT_{9jM4g7!YocW*5RsYA&IiJsGtTndC)>x(#?n-Imj+pEzvSjS(wl~I}yEG9p zMHrGIDx+kZZrze(DO1M2P9ma|Y}tiepU+vo&++~K3Ev;D9uL3tIFDwRAD<(%_+ zy`l@TZfcnER%AOaxm^J6#ZNj?KhfsF(KKQQ67A%s0Ijto0xA_QIiP2AV#GO?1+X z(l^83V+&#f!^Zjt`>Cwq?+W^}GT(3E9p{z*Nlt&coGGQ9tM7_LxGdH?Owx<(aCzBV z8gxwfE78C>z-f=ipo9C|w#c^WN4O}M+6;knq;;o9h?$r1N?w+ALT8HD}<*t1n& z`IbeO)5A@$&CH=_L1;VypUwIQnxDeXZ#4aMQq(Ez z9`=k<&1zR424rTiKByR+_NJ*m@?AReIkb3Z?Gf0rO{4U%PuMQ1Zr*Uds_6F#Rt8mr z>)rx26jj3muvk8D5$v3$`uS*LI0%NMGCkY@dC6-QEDDkW<$ng3b{X7`Yw#&cx>9z# zo)FCsM}!TcllnooSZ?<24(0^a$@q=1XIG)dj=}V(N)11&7s2z)3?GQL>mqK9eBVn5 zvRGNs->u$<3-vf?TEm(030us3AMFX>2x~pLS8&R@# zN6&O3Iup(gt4HtZuWTjR+}rB+52^+C`{&hKx&QyT!fkjjYGr)wML8(e#_3u1f~e?y z>UZY1Kk93>mAc%XS!@R_I@qzFKOVNl|=zD*5$+fny80I$65J+4s25~R3-U-P(0}3*C#)U$-&_6IMv}N zd%)!AY0*`_XA-R03Zl8v-bLRFdibecX;qGiwK6l#408UW`BpEB{HRJ)N^donTqX4r zY}d%|Z25iHb9qoiAdK>HMfdR6X+aGPBKHmaDx@{#C!9-`%?(SCa8ef*0r@ zC0#We>EEI{(LK@M(R7pJw#b>@QvZxU&>!G6QVmgK)#9YK%vB+i@5F1UY4nfid^FO$ z;y#c=VE6vw5B5iRZB+}I4Og$wo1+e=^!la}ajGAkh`O0BZnSLez33PC>3$}e)De#Q zgP>_GT&e!{8Iz*ZqsGzCQB(7fdrH>ydieSN5WF1*;^DFoglgE0t=ubii0KGMTSf<> z`%I!smKGNDT0S$|8?Dm8azoIR4^wO=lhaWBUeqDl8@W9U@aMuHe+eu< zPEXuPU-$&4wW<0)QO{^cc3vQuDB2pG z*SU73$X4gQxBLPAQgUtzc2jYrRqbx zaoTYodjvkzQJA_%^|fdcE4S-mwx4LJR(XT{-u{=~YPAB@PiLmKUr|+Dus$k>k}$#Z zqjkC?XsWK>@zVTm{(3mvYhlQC19vBwFO98Y%YgfwXiJofl4)K&|7m|SyyE?^SqE^QJqL%b3TN+@IBMiY zyQ0}z*+XtMJ{nd17XCKxHyDC~d#S2;B;>lfc|AIH%0M6FSCQJW;2f}z=VCJ|XGE`T+`Z#i2#{r@|vu!fmxOLnnWZ7A8 zZ*N3LqkroS=0(>={;ZC9wf#Mwg|}G^{`$S3X%iE{`%J=XbWuKam|gCT%cBZ52Q98u#Js;579<>Z6AmV>gMOYCPQO-QHcy zY)?^_8#37+bmLITB-je33*WSt?qz1M}`_>J-z;4{c4@p$Vr3JPYkLbLAMy6%Nm0-1 zs4|$BTQRkrV0K7ky6$CW>$PsR zJ#{i{)FknZl-_^6^*DVDP?O|#v>1iV`lVF+4EvVZs9VCVu4wvMDL#~^)k65#k9))6 z4i`{u?_kDRNzP9o>k4#d-3Oc9UhY=e-b(n_FROVdiF(2}yg~i>qRD?Fs1P?8mh1B(l*=Gl$ollH`i;0PsP`uK7+GL3>NP|xQaK;J!Y03 ztuJY7c;}MOs}^3i*V=nueGlqK)4Mf(10$*7CGBHW@v-_(eZh2dr^)1cUKT5J)E=A` z#^M*`Tmfgw)3zEFZxy~9C-qU&)NL2FRaGyOl}mADCnJ7WFozZeBOkiz?B^o$xBgTg)gOb< zG_g@`S5JFQani8p$v=R9T%5^eF}(Wmc+eHao8_o}&+N2~#3K2HYT`A5g?e3L z+(uW*<@`4jpN&dp11qPQHMY9QBI_RV8lWdhME|ftv;cP(s17Y%jy-1T!kqs_Pc(~c z5j-%mR86lwE*y7qhw}yVKp85|*W~;vdj-#oAM|nkGQ94K?iu_`?(pvScB^XikM&gB za-8PBW#_-6iarB3{)8T3GVPBp1%7*icdxfk)l!4xcQAP?(7*E8`Tb}i;Ir$~da!xH zZg7<)jBQl|{lf!l1RgtGsf>TaOCCWc-$#ao`W(8BZn#`XSzDb_mAxNSiW&>*doeSH zu;s_{{`VMtsff8u#<#K4!AKePqblniP>sl>Bk*ODK+_Mf&JC>kd9e6z=&t6Ub`0v2 zNN!doSlNv8$M0eg2z9WOr{Nv-oLzyZjYb_($M$hwh!ezKv9gVN2W?#j2o+%D9nSN^ znJ^zRp6P(Q$&>DVu}kKtnEFAr&RDvwvz34SOL{d$0)6SH(kGKh9waiwR zRDtS*s;N+ARF^7tf|Er(Hwz_3ds7A%n7~$dgYnUM75|xis;l};ddwJwPDH2BLNsPS zufp1|Y}&$xzs+?JljRHQ2nvCo_|ueNUVa$don>}t4d>UhLs77_HJ40i8;XouDukVQ zer%LwQTnEUyYn!uJHhKNXJ18M(bk+aXKW48MK)L8Sn{@NJ>1zkMsi16LzPg2@vs~SLOn3Dk{jVo_Ff|TlF2ns+ve^$F-YF0CJ-eb4bU+Z7WY!c z&Z3a%!hSx=d_5j#muB{1*H(0v6;WfPtD-1vPograL-#z-?C=~jR!cbKZ?N*8Xem-e zQ(2V1JqX>@IA)=LqY^3fRoXS1vGa@f$<)Co#|BDQdFD*cD@oi z%cA%{tup29VSCX1EWRY7T~q~}g>L7@qY0HUhAQN3GI@b5Pu71)9`CZ7-8S*DtgAXu zL389CpuQQEF>*)P`3K!{^buRkT63G-WWR8$#oMx)YKvNE37PZ}lS6sVbjR@bd5r#9 z4~_2zQw$A2j>|#uQBJjBx3T zR)FP7py>~EJ?*KtPuWu@-*_k|UURSDN)z&%Dyr4=r2kTvtFrPGeW4rq*^PR?(?~pA zMo@8{k{8J3JDGt~cz@{%LKPVK8rQKu?EtIIK2$W*?DMXRcnoDqZKAY+oZHBZQG+UW zo|8p7`#Fm9(jjBbB-_a~M^98BYoK`g7A@cR%oxew?jq`y(acz{*)nt{!z|R?J5M1BlIX;-M5+Q-h<#2LCiEncyAMaG4Eehh%r^s>uO>0u zmIh6~aGTJU+l;=pEGX?{FWEfz5&L;aMslxACzIX;%Vj{*Z%hQ8dEXeygj)ig%^!A^ z%NFhBZocVWIShsObS8&#py>~kO1(G(jirhwf|Q@>v*XC*@8lI$j>6mKZLnM(jGU$~ z44~dlw)gY*pR@&bjvFfKq4GJ$%706rX}uK)6<}l|{!AVn>{04dYrEaP>3WMAsEJO> zJ!pK2GvTxap%RSbajLtWTuNm}TiQG-V>?k+E(A}zQ5_|6yWNg1?}3pm%=U#^vnOp0 zJjK4YLtPW$$#>*WxPXx-%=H~Xs1HVVFyYl;mpY@NYGGG_&@AI}dx~CR4ml|44oMe}F$Zcq>?&37towIxqFjBzIr=SHIWFKbbr*!$! z;-DB#oVMVrRFnMZjaOGuFmjNbZ^HZINc&HI(L&qAd19;RE7yVNsj?P#IDM(M#i=-l z+4%Ru8n=q)^=yya@0%9 z(V$J24|3a+PPHuwnvS#c-BG$_E2|DtNtp!uRP`lxks2EB79sDF^ksL{TyB9o1s zXS*;HW!nl&+tb<4xn$D}rqF$${$UVm&_aF1`(F{%uf5P+O|!RC)5nQq)N-r%*9bqs zm%wtNTiO{Wf{|#ZK0~9`358ccT^UX$XQAoJ#&hWiTDOMuZilbhCc1YeDqaSdf5RHP z1h2s2GE**PV$ZX zUq785e*x`OOAuP90o;!xUlVp|I(t9S@@DAjvh$tMxV=TaxgusWFINF~*O(pVv!7>B zd}Xlms2%2##P(Ynv#GMZye{UVpSz2x?K;&V2WQbM=(R?&@{sNC%7_h2PFY~71KGKN zyWncz?r*BY5>#lBU4kNT5;@n?1?adQk>gq0i94|zrjZ)p?k3eCms=O#t>o9gZui)Z zE_91TZIo)0WM?|!Vk&qt_b4%{!zx_dZgXq-#L0GtZR0MwIU-4pK$-R|o^Q+X)v5(T zBdWt!+{~3{dYHldW()&k4imUSW)QA)s>7d)QR2?LajT8PVII2 z=MlRA4c<4PJ`az%zeGpsQdiD%>p^`zdbdN5xQ!a#09EkEDB#xP*R{r_-SR#g#>zW* zaUBYlOM|9M{Iyxs+jIDOy<_yR3oI8v^fZ*5fR-G9Vhb7yq|{K54ho8G79kK~wGu)g zgp7YFiM+<~E3uQTr4ib^{D1b$c$`4UBDV?Ij+rIQGG;WeWqIGRi|~k9+JfjRgO8xJ>Fww2vhsX3aKJacByhZ?KCug*crb-xA02qzXO_#E*0AMB!X&A7`RN^97xP{5} z#kocSs#a*e!PR6lh8U6!MAh|0oHYs{8zr9rx&_l30Ci9(0F#o%8UThhAOwKU|D6GJ zanKZi&i|bO@Zb;tCIzDt03Pfcz_es{0)U|dp8zKQUkgCBX8;_;V&M`CKpYoqD%tfW z{$C3~7Y9D;pGE-0{Q+MlG+|!KX_X7LwL^0T5EnBadjr)zpca7WKE4_TNeAHs5OQOq ziw7rwkQzf$M#FKFH-II;BiXdh0Ps&)Wm>GPb;@(ZneJX_ zE&*=Hx-~%6=Emlgi|QS$?p|mv0WQhF`Xinym`$IddIPAt7n)0ebF!S=lZ{5o37Z+H zs;P8u{-70_d4TwWtXjK~Sg_d?Vnt~##u0w&0vKAMdG0F>JCI(Hfps7knFOO|Q^)WS zmTksbL@^t8 zEM1MsR&dei6Mb`|t`{%3Q_9`gwxyMBqXCJLu9#&lh|co9 N002ovPDHLkV1fpGJhuP< literal 0 HcmV?d00001 diff --git a/shop/source/main.c b/shop/source/main.c new file mode 100644 index 0000000..5adace7 --- /dev/null +++ b/shop/source/main.c @@ -0,0 +1,477 @@ +// MBMNET Shop - Homebrew für den 3DS, aus der Universal-DB (über den MBMNET-Server). +// +// Oben: die gewählte App (Symbol, Name, Beschreibung). Unten: Reiter (Kategorien) und die Liste. +// A installiert / aktualisiert / startet, Y entfernt, L/R wechselt die Kategorie, X die Sortierung. +#include <3ds.h> +#include +#include +#include +#include + +#include "store.h" + +#define TOP_W 400 +#define BOT_W 320 +#define SCR_H 240 + +#define COL_RED C2D_Color32(0xE4, 0x00, 0x2B, 0xFF) +#define COL_RED_DARK C2D_Color32(0x9E, 0x00, 0x1E, 0xFF) +#define COL_BG C2D_Color32(0x16, 0x19, 0x1F, 0xFF) +#define COL_BG2 C2D_Color32(0x22, 0x26, 0x2E, 0xFF) +#define COL_CARD C2D_Color32(0x2A, 0x2F, 0x39, 0xFF) +#define COL_CARD_HI C2D_Color32(0x3A, 0x41, 0x4E, 0xFF) +#define COL_WHITE C2D_Color32(0xFF, 0xFF, 0xFF, 0xFF) +#define COL_MUTED C2D_Color32(0x9A, 0xA3, 0xAF, 0xFF) +#define COL_GREEN C2D_Color32(0x22, 0xC5, 0x5E, 0xFF) +#define COL_AMBER C2D_Color32(0xF5, 0x9E, 0x0B, 0xFF) + +#define TAB_H 26 +#define ROW_H 40 +#define LIST_Y (TAB_H + 2) +#define VISIBLE_ROWS ((SCR_H - LIST_Y) / ROW_H + 1) + +// Kategorien (Universal-DB) und ihre Reiter +typedef struct { const char *label, *match; } Tab; +static const Tab TABS[] = { + { "Alle", NULL }, { "Spiele", "game" }, { "Tools", "utility" }, { "Apps", "app" }, + { "Emus", "emulator" }, { "Medien", "media" }, { "Meine", "*installed" }, +}; +#define TAB_COUNT (int)(sizeof(TABS) / sizeof(TABS[0])) +static const char *SORT_NAMES[] = { "Name", "Beliebt", "Neu" }; + +static C2D_TextBuf textBuf, measureBuf; // measureBuf nur zum Messen (wird jedes Mal geleert) +static C2D_SpriteSheet sheets[8]; + +// --- Zeichenhilfen ------------------------------------------------------------ + +static void text(const char *s, float x, float y, float size, u32 color, u32 flags) +{ + C2D_Text t; + C2D_TextParse(&t, textBuf, s); + C2D_TextOptimize(&t); + C2D_DrawText(&t, C2D_WithColor | flags, x, y, 0.5f, size, size, color); +} + +static void text_wrap(const char *s, float x, float y, float size, u32 color, float width) +{ + C2D_Text t; + C2D_TextParse(&t, textBuf, s); + C2D_TextOptimize(&t); + C2D_DrawText(&t, C2D_WithColor | C2D_WordWrap, x, y, 0.5f, size, size, color, width); +} + +static float text_width(const char *s, float size) +{ + C2D_Text t; + float w = 0; + C2D_TextBufClear(measureBuf); + C2D_TextParse(&t, measureBuf, s); + C2D_TextGetDimensions(&t, size, size, &w, NULL); + return w; +} + +// Text auf eine Breite kürzen ("...") +static void fit(char *out, size_t len, const char *s, float size, float width) +{ + snprintf(out, len, "%s", s); + if (text_width(out, size) <= width) return; + size_t n = strlen(out); + while (n > 1) { + n--; + while (n > 0 && (out[n] & 0xC0) == 0x80) n--; // nicht mitten in einem UTF-8-Zeichen + snprintf(out + n, len - n, "..."); + if (text_width(out, size) <= width) return; + } +} + +static void round_rect(float x, float y, float w, float h, float r, u32 color) +{ + C2D_DrawRectSolid(x + r, y, 0.5f, w - 2 * r, h, color); + C2D_DrawRectSolid(x, y + r, 0.5f, w, h - 2 * r, color); + C2D_DrawCircleSolid(x + r, y + r, 0.5f, r, color); + C2D_DrawCircleSolid(x + w - r, y + r, 0.5f, r, color); + C2D_DrawCircleSolid(x + r, y + h - r, 0.5f, r, color); + C2D_DrawCircleSolid(x + w - r, y + h - r, 0.5f, r, color); +} + +// Einkaufswagen (wie das Symbol der App) +static void draw_cart(float x, float y, float size, u32 bg) +{ + float s = size / 100.0f; + round_rect(x, y, size, size, 22 * s, COL_RED_DARK); + round_rect(x, y, size, size - 4 * s, 22 * s, COL_RED); + float t = 8 * s; + C2D_DrawLine(x + 13 * s, y + 23 * s, COL_WHITE, x + 23 * s, y + 23 * s, COL_WHITE, t, 0.5f); + C2D_DrawLine(x + 23 * s, y + 23 * s, COL_WHITE, x + 33 * s, y + 61 * s, COL_WHITE, t, 0.5f); + C2D_DrawLine(x + 33 * s, y + 61 * s, COL_WHITE, x + 30 * s, y + 70 * s, COL_WHITE, t, 0.5f); + C2D_DrawLine(x + 30 * s, y + 70 * s, COL_WHITE, x + 78 * s, y + 70 * s, COL_WHITE, t, 0.5f); + C2D_DrawTriangle(x + 27 * s, y + 33 * s, COL_WHITE, x + 84 * s, y + 33 * s, COL_WHITE, x + 77 * s, y + 61 * s, COL_WHITE, 0.5f); + C2D_DrawTriangle(x + 27 * s, y + 33 * s, COL_WHITE, x + 77 * s, y + 61 * s, COL_WHITE, x + 33 * s, y + 61 * s, COL_WHITE, 0.5f); + C2D_DrawCircleSolid(x + 37 * s, y + 80 * s, 0.5f, 7 * s, COL_WHITE); + C2D_DrawCircleSolid(x + 72 * s, y + 80 * s, 0.5f, 7 * s, COL_WHITE); + (void)bg; +} + +static void draw_icon(const App *a, float x, float y, float size) +{ + C2D_SpriteSheet sh = a->sheet >= 0 && a->sheet < 8 ? sheets[a->sheet] : NULL; + if (sh && a->icon >= 0 && (size_t)a->icon < C2D_SpriteSheetCount(sh)) { + C2D_Image img = C2D_SpriteSheetGetImage(sh, a->icon); + float sc = size / (img.subtex->width ? img.subtex->width : 48); + C2D_DrawImageAt(img, x, y, 0.5f, NULL, sc, sc); + return; + } + // kein Symbol: Anfangsbuchstabe auf grauem Feld + round_rect(x, y, size, size, size / 6, COL_CARD_HI); + char first[8] = { 0 }; + int n = 1; + while (n < 4 && (a->title[n] & 0xC0) == 0x80) n++; + memcpy(first, a->title, n); + text(first, x + size / 2, y + size * 0.2f, size / 48.0f, COL_WHITE, C2D_AlignCenter); +} + +static void size_text(char *out, size_t len, u32 bytes) +{ + if (bytes >= 1024 * 1024) snprintf(out, len, "%.1f MB", bytes / (1024.0 * 1024.0)); + else if (bytes) snprintf(out, len, "%lu KB", (unsigned long)((bytes + 1023) / 1024)); + else snprintf(out, len, "?"); +} + +// --- Liste (gefiltert und sortiert) --------------------------------------------- + +static int *view; // Indizes in den Katalog +static int viewCount; +static int sortMode; + +static int cmp_name(const void *a, const void *b) +{ + return strcasecmp(catalog_app(*(const int *)a)->title, catalog_app(*(const int *)b)->title); +} +static int cmp_stars(const void *a, const void *b) +{ + const App *x = catalog_app(*(const int *)a), *y = catalog_app(*(const int *)b); + if (x->stars != y->stars) return x->stars > y->stars ? -1 : 1; + return strcasecmp(x->title, y->title); +} +static int cmp_new(const void *a, const void *b) +{ + const App *x = catalog_app(*(const int *)a), *y = catalog_app(*(const int *)b); + int c = strcmp(y->updated, x->updated); + return c ? c : strcasecmp(x->title, y->title); +} + +static bool in_tab(const App *a, int tab) +{ + const char *m = TABS[tab].match; + if (!m) return true; + if (strcmp(m, "*installed") == 0) return a->installed; + if (strcmp(m, "utility") == 0) + return !strcmp(a->category, "utility") || !strcmp(a->category, "save-tool") || !strcmp(a->category, "plugin"); + return strcmp(a->category, m) == 0; +} + +static void rebuild_view(int tab) +{ + int n = catalog_count(); + free(view); + view = malloc(sizeof(int) * (n ? n : 1)); + viewCount = 0; + for (int i = 0; i < n; i++) + if (in_tab(catalog_app(i), tab)) view[viewCount++] = i; + qsort(view, viewCount, sizeof(int), sortMode == 1 ? cmp_stars : sortMode == 2 ? cmp_new : cmp_name); +} + +// --- Bildschirme --------------------------------------------------------------- + +static void draw_status_badge(const App *a, float right, float y) +{ + AppStatus s = app_status(a); + if (s == APP_NEW) return; + const char *label = s == APP_UPDATE ? "Update" : "Installiert"; + u32 col = s == APP_UPDATE ? COL_AMBER : COL_GREEN; + float w = text_width(label, 0.38f) + 10; + round_rect(right - w, y, w, 15, 7, col); + text(label, right - w / 2, y + 1.5f, 0.38f, COL_BG, C2D_AlignCenter); +} + +static void draw_top(const App *a, int tab, const char *msg, bool armed) +{ + C2D_DrawRectangle(0, 0, 0, TOP_W, SCR_H, COL_BG2, COL_BG2, COL_BG, COL_BG); + // Kopfzeile + draw_cart(10, 6, 22, COL_BG2); + text("MBMNET Shop", 38, 8, 0.55f, COL_WHITE, 0); + char info[64]; + if (catalog_state() == CAT_READY) + snprintf(info, sizeof(info), "%s · %d Apps · %s%s", TABS[tab].label, viewCount, SORT_NAMES[sortMode], + catalog_offline() ? " · offline" : ""); + else snprintf(info, sizeof(info), "v" APP_VERSION); + text(info, TOP_W - 10, 10, 0.42f, COL_MUTED, C2D_AlignRight); + C2D_DrawRectSolid(0, 32, 0.5f, TOP_W, 2, COL_RED); + + if (!a) { + draw_cart(TOP_W / 2 - 36, 60, 72, COL_BG2); + const char *m = catalog_state() == CAT_READY ? (tab == TAB_COUNT - 1 ? "Noch nichts aus dem Shop installiert." + : "Hier gibt es nichts.") + : catalog_message(); + text_wrap(m, 40, 150, 0.5f, COL_MUTED, TOP_W - 80); + return; + } + + // App-Kopf: Symbol, Name, Autor, Version, Größe + round_rect(10, 42, 56, 56, 8, COL_CARD); + draw_icon(a, 14, 46, 48); + char line[96], size[16]; + fit(line, sizeof(line), a->title, 0.7f, TOP_W - 90); + text(line, 76, 42, 0.7f, COL_WHITE, 0); + fit(line, sizeof(line), a->author, 0.45f, TOP_W - 90); + text(line, 76, 64, 0.45f, COL_MUTED, 0); + size_text(size, sizeof(size), a->size); + snprintf(line, sizeof(line), "%s · %s · %s", a->version[0] ? a->version : "ohne Version", + size, strcmp(a->kind, "cia") == 0 ? "CIA" : "3DSX"); + text(line, 76, 82, 0.42f, COL_MUTED, 0); + draw_status_badge(a, TOP_W - 10, 82); + + // Beschreibung oder Meldung + round_rect(10, 106, TOP_W - 20, 104, 8, COL_CARD); + if (msg[0]) { + text_wrap(msg, 20, 114, 0.48f, armed ? COL_AMBER : COL_WHITE, TOP_W - 40); + if (job_state() == JOB_RUNNING) { + float p = job_progress(); + round_rect(20, 184, TOP_W - 40, 14, 7, COL_BG); + if (p >= 0) round_rect(20, 184, 14 + (TOP_W - 54) * (p > 1 ? 1 : p), 14, 7, COL_RED); + } + } else { + char desc[440]; + snprintf(desc, sizeof(desc), "%s", a->description ? a->description : ""); + if (strlen(desc) >= sizeof(desc) - 1) { + char *cut = strrchr(desc, ' '); + if (cut) snprintf(cut, sizeof(desc) - (cut - desc), " ..."); + } + text_wrap(desc, 20, 114, 0.42f, COL_WHITE, TOP_W - 40); + } + + // Tastenhilfe + AppStatus s = app_status(a); + const char *aLabel = s == APP_NEW ? "A: Installieren" : s == APP_UPDATE ? "A: Aktualisieren" + : strcmp(a->kind, "cia") == 0 ? "A: Starten" : "A: Neu laden"; + snprintf(line, sizeof(line), "%s%s L/R: Kategorie X: Sortierung START: Ende", aLabel, + s != APP_NEW ? " Y: Entfernen" : ""); + text(line, TOP_W / 2, 220, 0.38f, COL_MUTED, C2D_AlignCenter); +} + +static void draw_bottom(int tab, int selected, int scroll) +{ + C2D_DrawRectSolid(0, 0, 0, BOT_W, SCR_H, COL_BG); + // Reiter + float w = (float)BOT_W / TAB_COUNT; + for (int i = 0; i < TAB_COUNT; i++) { + bool on = i == tab; + C2D_DrawRectSolid(i * w, 0, 0.5f, w, TAB_H, on ? COL_RED : COL_BG2); + text(TABS[i].label, i * w + w / 2, 6, 0.42f, on ? COL_WHITE : COL_MUTED, C2D_AlignCenter); + } + if (catalog_state() != CAT_READY) { + text_wrap(catalog_state() == CAT_ERROR ? "Kein Katalog. A: nochmal versuchen" : "Einen Moment ...", + 20, 110, 0.5f, COL_MUTED, BOT_W - 40); + return; + } + // Liste + for (int r = 0; r < VISIBLE_ROWS; r++) { + int i = scroll + r; + if (i >= viewCount) break; + const App *a = catalog_app(view[i]); + float y = LIST_Y + r * ROW_H; + if (i == selected) { + C2D_DrawRectSolid(0, y, 0.5f, BOT_W, ROW_H, COL_CARD_HI); + C2D_DrawRectSolid(0, y + 6, 0.5f, 4, ROW_H - 12, COL_RED); + } else if (i % 2) { + C2D_DrawRectSolid(0, y, 0.5f, BOT_W, ROW_H, COL_BG2); + } + draw_icon(a, 8, y + 4, 32); + char line[96]; + AppStatus s = app_status(a); + float right = s == APP_NEW ? BOT_W - 8 : BOT_W - 78; + fit(line, sizeof(line), a->title, 0.5f, right - 48); + text(line, 48, y + 4, 0.5f, COL_WHITE, 0); + fit(line, sizeof(line), a->author, 0.38f, right - 48); + text(line, 48, y + 22, 0.38f, COL_MUTED, 0); + draw_status_badge(a, BOT_W - 8, y + 12); + } + // Rollbalken + if (viewCount > VISIBLE_ROWS - 1) { + float h = SCR_H - LIST_Y, bar = h * (VISIBLE_ROWS - 1) / viewCount; + if (bar < 12) bar = 12; + float y = LIST_Y + (h - bar) * scroll / (viewCount - (VISIBLE_ROWS - 1)); + C2D_DrawRectSolid(BOT_W - 3, y, 0.5f, 3, bar, COL_MUTED); + } +} + +// --- Aktionen -------------------------------------------------------------------- + +// Installierte CIA starten (Sprung in die App, der Shop beendet sich) +static bool launch(const App *a, char *msg, size_t len) +{ + if (!a->titleId) { snprintf(msg, len, "Starte %s bitte im HOME-Menü.", a->title); return false; } + Result rc = APT_PrepareToDoApplicationJump(0, a->titleId, MEDIATYPE_SD); + if (R_SUCCEEDED(rc)) { + u8 param[0x300] = { 0 }, hmac[0x20] = { 0 }; + rc = APT_DoApplicationJump(param, sizeof(param), hmac); + } + if (R_FAILED(rc)) { snprintf(msg, len, "Starte %s bitte im HOME-Menü (%08lX).", a->title, (unsigned long)rc); return false; } + return true; +} + +static void load_sheets(void) +{ + for (int n = 0; n < catalog_sheet_count() && n < 8; n++) { + const char *p = catalog_sheet_path(n); + if (p[0] && !sheets[n]) sheets[n] = C2D_SpriteSheetLoad(p); + } +} + +int main(int argc, char **argv) +{ + (void)argc; (void)argv; + gfxInitDefault(); + httpcInit(0); + amInit(); + C3D_Init(C3D_DEFAULT_CMDBUF_SIZE); + C2D_Init(C2D_DEFAULT_MAX_OBJECTS); + C2D_Prepare(); + C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT); + C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT); + textBuf = C2D_TextBufNew(8192); + measureBuf = C2D_TextBufNew(512); + hidSetRepeatParameters(18, 4); + + catalog_start(); + int tab = 0, selected = 0, scroll = 0; + bool ready = false; + char msg[192] = ""; + int armed = -1; // Sicherheitsabfrage: 0 = installieren, 1 = entfernen + bool quit = false; + int touchStartY = -1, touchScroll = 0, touchRow = -1; + bool dragged = false; + + while (!quit && aptMainLoop()) { + hidScanInput(); + u32 down = hidKeysDown(), rep = hidKeysDownRepeat(), held = hidKeysHeld(); + bool busy = job_state() == JOB_RUNNING; + if (down & KEY_START && !busy) break; + + // Katalog fertig: Symbole laden und Liste aufbauen + if (!ready && catalog_state() == CAT_READY) { + load_sheets(); + rebuild_view(tab); + ready = true; + } + if (catalog_state() == CAT_ERROR && down & KEY_A) catalog_start(); + + // Ergebnis einer Installation: anzeigen, Liste auffrischen (z. B. Reiter "Meine") + JobState js = job_state(); + if (js == JOB_DONE || js == JOB_FAILED) { + snprintf(msg, sizeof(msg), "%s", job_message()); + job_acknowledge(); + const App *cur = viewCount ? catalog_app(view[selected]) : NULL; + rebuild_view(tab); + selected = 0; + for (int i = 0; cur && i < viewCount; i++) if (catalog_app(view[i]) == cur) selected = i; + } + + if (ready && !busy) { + int oldSel = selected, oldTab = tab; + if (down & KEY_R) tab = (tab + 1) % TAB_COUNT; + if (down & KEY_L) tab = (tab + TAB_COUNT - 1) % TAB_COUNT; + if (down & KEY_X) { sortMode = (sortMode + 1) % 3; rebuild_view(tab); selected = scroll = 0; } + if (rep & (KEY_DOWN | KEY_CPAD_DOWN)) selected++; + if (rep & (KEY_UP | KEY_CPAD_UP)) selected--; + if (rep & (KEY_RIGHT | KEY_CPAD_RIGHT)) selected += VISIBLE_ROWS - 1; + if (rep & (KEY_LEFT | KEY_CPAD_LEFT)) selected -= VISIBLE_ROWS - 1; + + // Touch: Reiter antippen, Liste ziehen, Zeile antippen (zweimal = Aktion) + touchPosition tp; + if (down & KEY_TOUCH) { + hidTouchRead(&tp); + if (tp.py < TAB_H) tab = tp.px * TAB_COUNT / BOT_W; + else { touchStartY = tp.py; touchScroll = scroll; dragged = false; touchRow = scroll + (tp.py - LIST_Y) / ROW_H; } + } else if (held & KEY_TOUCH && touchStartY >= 0) { + hidTouchRead(&tp); + int dy = touchStartY - tp.py; + if (abs(dy) > 6) dragged = true; + if (dragged) { + scroll = touchScroll + dy / ROW_H; + int maxScroll = viewCount - (VISIBLE_ROWS - 1); + if (scroll > maxScroll) scroll = maxScroll; + if (scroll < 0) scroll = 0; + } + } else if (touchStartY >= 0) { + if (!dragged && touchRow >= 0 && touchRow < viewCount) { + if (touchRow == selected) down |= KEY_A; + else selected = touchRow; + } + touchStartY = -1; + } + + if (tab != oldTab) { rebuild_view(tab); selected = scroll = 0; } + if (selected >= viewCount) selected = viewCount - 1; + if (selected < 0) selected = 0; + if (selected != oldSel || tab != oldTab) { msg[0] = 0; armed = -1; } + if (selected < scroll) scroll = selected; + if (selected >= scroll + VISIBLE_ROWS - 1) scroll = selected - (VISIBLE_ROWS - 2); + + App *a = viewCount ? catalog_app(view[selected]) : NULL; + if (a && down & KEY_A) { + AppStatus s = app_status(a); + if (s == APP_INSTALLED && strcmp(a->kind, "cia") == 0) { + armed = -1; + if (launch(a, msg, sizeof(msg))) quit = true; + } else if (armed == 0) { + armed = -1; + char size[16]; + size_text(size, sizeof(size), a->size); + snprintf(msg, sizeof(msg), "Lade %s (%s) ...", a->title, size); + job_install(a); + } else if (catalog_offline()) { + snprintf(msg, sizeof(msg), "Keine Verbindung zum MBMNET-Server.\nInstallieren geht gerade nicht."); + } else { + armed = 0; + snprintf(msg, sizeof(msg), "%s %s?\nZum Bestätigen nochmal A drücken (oder nochmal antippen).", + a->title, s == APP_UPDATE ? "aktualisieren" : "installieren"); + } + } + if (a && down & KEY_Y && app_status(a) != APP_NEW) { + if (armed == 1) { + armed = -1; + snprintf(msg, sizeof(msg), "Entferne %s ...", a->title); + job_remove(a); + } else { + armed = 1; + snprintf(msg, sizeof(msg), "%s entfernen?\nZum Bestätigen nochmal Y drücken.", a->title); + } + } + if (down & KEY_B) { msg[0] = 0; armed = -1; } + } + + const App *cur = ready && viewCount ? catalog_app(view[selected]) : NULL; + C2D_TextBufClear(textBuf); + C3D_FrameBegin(C3D_FRAME_SYNCDRAW); + C2D_TargetClear(top, COL_BG); + C2D_SceneBegin(top); + draw_top(cur, tab, msg, armed >= 0); + C2D_TargetClear(bot, COL_BG); + C2D_SceneBegin(bot); + draw_bottom(tab, selected, scroll); + C3D_FrameEnd(0); + } + + store_exit(); + for (int n = 0; n < 8; n++) if (sheets[n]) C2D_SpriteSheetFree(sheets[n]); + free(view); + C2D_TextBufDelete(textBuf); + C2D_TextBufDelete(measureBuf); + C2D_Fini(); + C3D_Fini(); + amExit(); + httpcExit(); + gfxExit(); + return 0; +} diff --git a/shop/source/net.c b/shop/source/net.c new file mode 100644 index 0000000..8d252d3 --- /dev/null +++ b/shop/source/net.c @@ -0,0 +1,73 @@ +// HTTP über den Systemdienst http:C. Alles läuft über den MBMNET-Server (einfaches HTTP), +// der Katalog, Symbole und Downloads von Universal-DB und GitHub holt. +#include <3ds.h> +#include +#include + +#include "net.h" + +#define CHUNK (128 * 1024) + +static Result open_request(httpcContext *ctx, const char *url, u32 *status) +{ + Result rc = httpcOpenContext(ctx, HTTPC_METHOD_GET, url, 1); + if (R_FAILED(rc)) return rc; + httpcAddRequestHeaderField(ctx, "User-Agent", "MBMNET-Shop/" APP_VERSION); + httpcSetKeepAlive(ctx, HTTPC_KEEPALIVE_DISABLED); + if (R_SUCCEEDED(rc = httpcBeginRequest(ctx))) + rc = httpcGetResponseStatusCodeTimeout(ctx, status, 30ULL * 1000 * 1000 * 1000); + if (R_FAILED(rc)) httpcCloseContext(ctx); + return rc; +} + +u32 net_stream(const char *url, NetSink sink, void *ctx, Result *rcOut) +{ + httpcContext http; + u32 status = 0, total = 0, got; + Result rc = open_request(&http, url, &status); + if (rcOut) *rcOut = rc; + if (R_FAILED(rc)) return 0; + if (status != 200) { httpcCloseContext(&http); return status; } + + httpcGetDownloadSizeState(&http, NULL, &total); + u8 *buf = malloc(CHUNK); + if (!buf) { httpcCloseContext(&http); if (rcOut) *rcOut = -2; return 0; } + do { + got = 0; + rc = httpcDownloadData(&http, buf, CHUNK, &got); + if (got && !sink(ctx, buf, got, total)) { rc = -3; break; } // -3 = Empfänger hat abgebrochen + } while (rc == (Result)HTTPC_RESULTCODE_DOWNLOADPENDING); + free(buf); + httpcCloseContext(&http); + if (rcOut) *rcOut = rc; + return R_SUCCEEDED(rc) ? 200 : 0; +} + +typedef struct { u8 *data; u32 size, cap, max; } MemSink; + +static bool to_memory(void *p, const u8 *data, u32 len, u32 total) +{ + MemSink *m = p; + if (m->size + len > m->max) return false; + if (m->size + len > m->cap) { + u32 cap = m->cap ? m->cap : (total && total <= m->max ? total : 64 * 1024); + while (cap < m->size + len) cap *= 2; + u8 *bigger = realloc(m->data, cap); + if (!bigger) return false; + m->data = bigger; + m->cap = cap; + } + memcpy(m->data + m->size, data, len); + m->size += len; + return true; +} + +u32 net_get(const char *url, u8 **out, u32 *size, u32 max, Result *rc) +{ + MemSink m = { NULL, 0, 0, max }; + u32 status = net_stream(url, to_memory, &m, rc); + if (status != 200) { free(m.data); m.data = NULL; m.size = 0; } + *out = m.data; + *size = m.size; + return status; +} diff --git a/shop/source/net.h b/shop/source/net.h new file mode 100644 index 0000000..edf9b81 --- /dev/null +++ b/shop/source/net.h @@ -0,0 +1,14 @@ +#pragma once +#include <3ds/types.h> +#include + +// Empfänger für net_stream: bekommt die Daten stückweise. total = Gesamtgröße (0, wenn unbekannt). +// false = abbrechen. +typedef bool (*NetSink)(void *ctx, const u8 *data, u32 len, u32 total); + +// Lädt eine URL komplett in den Speicher (höchstens max Bytes). Gibt den HTTP-Status zurück, +// 0 bei Netzwerkfehler (Code in *rc). *out mit free() freigeben. +u32 net_get(const char *url, u8 **out, u32 *size, u32 max, Result *rc); + +// Lädt eine URL und reicht die Daten an sink weiter. Gibt den HTTP-Status zurück (0 bei Fehler). +u32 net_stream(const char *url, NetSink sink, void *ctx, Result *rc); diff --git a/shop/source/store.c b/shop/source/store.c new file mode 100644 index 0000000..ebb64b3 --- /dev/null +++ b/shop/source/store.c @@ -0,0 +1,476 @@ +// Katalog, Symbole und installierte Apps des MBMNET Shops. +// +// Der Katalog kommt vom MBMNET-Server (/shop/list, Quelle Universal-DB) und wird auf der SD +// zwischengespeichert, damit der Shop auch ohne Verbindung die installierten Apps zeigt. +// Was der Shop installiert hat, steht in SHOP_DIR/installed.txt (nur dann kann er es wieder entfernen). +#include <3ds.h> +#include +#include +#include +#include +#include + +#include "net.h" +#include "store.h" + +#define LIST_FILE SHOP_DIR "/list.txt" +#define INSTALLED_FILE SHOP_DIR "/installed.txt" +#define MAX_SHEETS 8 + +// --- Katalog --------------------------------------------------------------------- + +static volatile CatalogState catState = CAT_IDLE; +static char catMsg[128]; +static bool offline; +static App *apps; +static int appCount; +static int sheetCount; +static char sheetPath[MAX_SHEETS][64]; +static char *extraInstalled; // Einträge in installed.txt zu Apps, die es im Katalog nicht (mehr) gibt +static Thread catThread, jobThread; + +CatalogState catalog_state(void) { return catState; } +const char *catalog_message(void) { return catMsg; } +bool catalog_offline(void) { return offline; } +int catalog_count(void) { return catState == CAT_READY ? appCount : 0; } +App *catalog_app(int i) { return &apps[i]; } +int catalog_sheet_count(void) { return sheetCount; } +const char *catalog_sheet_path(int n) { return n >= 0 && n < sheetCount ? sheetPath[n] : ""; } + +AppStatus app_status(const App *a) +{ + if (!a->installed) return APP_NEW; + if (a->version[0] && strcmp(a->version, a->installedVersion) != 0) return APP_UPDATE; + return APP_INSTALLED; +} + +static bool file_exists(const char *path) +{ + struct stat st; + return stat(path, &st) == 0; +} + +static bool write_file(const char *path, const void *data, u32 size) +{ + char tmp[96]; + snprintf(tmp, sizeof(tmp), "%s.neu", path); + FILE *f = fopen(tmp, "wb"); + bool ok = f && fwrite(data, 1, size, f) == size; + if (f) fclose(f); + if (ok) { remove(path); ok = rename(tmp, path) == 0; } + if (!ok) remove(tmp); + return ok; +} + +static char *read_file(const char *path, u32 *size) +{ + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + fseek(f, 0, SEEK_END); + long n = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = n >= 0 ? malloc(n + 1) : NULL; + if (buf && fread(buf, 1, n, f) != (size_t)n) { free(buf); buf = NULL; } + fclose(f); + if (buf) { buf[n] = 0; if (size) *size = n; } + return buf; +} + +// Zerlegt eine Zeile in Felder (Tab-getrennt). Gibt die Anzahl zurück. +static int split(char *line, char **fields, int max) +{ + int n = 0; + while (n < max) { + fields[n++] = line; + char *tab = strchr(line, '\t'); + if (!tab) break; + *tab = 0; + line = tab + 1; + } + return n; +} + +#define COPY(dst, src) snprintf(dst, sizeof(dst), "%s", src) + +static bool parse_list(char *text, int *revision) +{ + char *save = NULL, *line = strtok_r(text, "\n", &save); + char *f[16]; + if (!line || split(line, f, 16) < 5 || strcmp(f[0], "MBMNETSHOP") != 0) return false; + *revision = atoi(f[2]); + int count = atoi(f[3]); + sheetCount = atoi(f[4]); + if (sheetCount > MAX_SHEETS) sheetCount = MAX_SHEETS; + if (count <= 0 || count > 5000) return false; + apps = calloc(count, sizeof(App)); + if (!apps) return false; + + appCount = 0; + while ((line = strtok_r(NULL, "\n", &save)) && appCount < count) { + if (split(line, f, 16) < 13) continue; + App *a = &apps[appCount++]; + COPY(a->slug, f[0]); + COPY(a->title, f[1]); + COPY(a->author, f[2]); + COPY(a->version, f[3]); + COPY(a->category, f[4]); + a->sheet = atoi(f[5]); + a->icon = atoi(f[6]); + COPY(a->kind, f[7]); + COPY(a->file, f[8]); + a->size = strtoul(f[9], NULL, 10); + a->stars = strtoul(f[10], NULL, 10); + COPY(a->updated, f[11]); + a->description = strdup(f[12]); + } + return appCount > 0; +} + +static App *find_app(const char *slug) +{ + for (int i = 0; i < appCount; i++) + if (strcmp(apps[i].slug, slug) == 0) return &apps[i]; + return NULL; +} + +// installed.txt: slug \t Version \t Art \t Titel-ID (hex) \t Dateiname +static void load_installed(void) +{ + char *text = read_file(INSTALLED_FILE, NULL); + if (!text) return; + size_t extraLen = 0; + char *save = NULL, *line, *f[8]; + for (line = strtok_r(text, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) { + char copy[256]; + COPY(copy, line); + if (split(line, f, 8) < 5) continue; + App *a = find_app(f[0]); + if (!a) { + // unbekannte App merken, damit sie beim Speichern nicht verloren geht + size_t len = strlen(copy); + char *bigger = realloc(extraInstalled, extraLen + len + 2); + if (!bigger) continue; + extraInstalled = bigger; + memcpy(extraInstalled + extraLen, copy, len); + extraLen += len; + extraInstalled[extraLen++] = '\n'; + extraInstalled[extraLen] = 0; + continue; + } + a->installed = true; + COPY(a->installedVersion, f[1]); + a->titleId = strtoull(f[3], NULL, 16); + if (f[4][0]) COPY(a->file, f[4]); + } + free(text); +} + +static void save_installed(void) +{ + FILE *f = fopen(INSTALLED_FILE, "w"); + if (!f) return; + for (int i = 0; i < appCount; i++) { + App *a = &apps[i]; + if (a->installed) + fprintf(f, "%s\t%s\t%s\t%016llX\t%s\n", a->slug, a->installedVersion, a->kind, a->titleId, a->file); + } + if (extraInstalled) fputs(extraInstalled, f); + fclose(f); +} + +// Alte Symbol-Blätter (andere Revision) löschen +static void remove_old_sheets(int revision) +{ + char keep[32]; + snprintf(keep, sizeof(keep), "icons-%d-", revision); + DIR *d = opendir(SHOP_DIR); + if (!d) return; + struct dirent *e; + char path[320]; + while ((e = readdir(d))) { + if (strncmp(e->d_name, "icons-", 6) == 0 && strncmp(e->d_name, keep, strlen(keep)) != 0) { + snprintf(path, sizeof(path), SHOP_DIR "/%s", e->d_name); + remove(path); + } + } + closedir(d); +} + +static void catalog_worker(void *arg) +{ + (void)arg; + char url[160]; + u8 *data = NULL; + u32 size = 0; + Result rc; + mkdir("sdmc:/3ds", 0777); + mkdir(SHOP_DIR, 0777); + + snprintf(catMsg, sizeof(catMsg), "Lade den Katalog ..."); + snprintf(url, sizeof(url), "http://%s/shop/list", SHOP_SERVER); + u32 status = net_get(url, &data, &size, 4 * 1024 * 1024, &rc); + char *text = NULL; + if (status == 200 && size > 10) { + write_file(LIST_FILE, data, size); + text = realloc(data, size + 1); + if (text) text[size] = 0; else free(data); + } else { + free(data); + text = read_file(LIST_FILE, &size); + offline = true; + if (!text) { + if (status) snprintf(catMsg, sizeof(catMsg), "Der Shop-Server antwortet nicht (HTTP %lu).", (unsigned long)status); + else snprintf(catMsg, sizeof(catMsg), "Keine Verbindung zum MBMNET-Server (%08lX).\nIst das WLAN an?", (unsigned long)rc); + catState = CAT_ERROR; + return; + } + } + int revision = 0; + bool ok = parse_list(text, &revision); + free(text); + if (!ok) { + snprintf(catMsg, sizeof(catMsg), "Der Katalog ist beschädigt."); + catState = CAT_ERROR; + return; + } + load_installed(); + + // Symbol-Blätter (einmal pro Katalog-Revision laden) + for (int n = 0; n < sheetCount; n++) { + snprintf(sheetPath[n], sizeof(sheetPath[n]), SHOP_DIR "/icons-%d-%d.t3x", revision, n); + if (file_exists(sheetPath[n])) continue; + if (offline) { sheetPath[n][0] = 0; continue; } + snprintf(catMsg, sizeof(catMsg), "Lade die Symbole (%d von %d) ...", n + 1, sheetCount); + snprintf(url, sizeof(url), "http://%s/shop/sheet/%d", SHOP_SERVER, n); + if (net_get(url, &data, &size, 16 * 1024 * 1024, &rc) != 200 || !write_file(sheetPath[n], data, size)) + sheetPath[n][0] = 0; + free(data); + } + if (!offline) remove_old_sheets(revision); + catMsg[0] = 0; + catState = CAT_READY; +} + +static void start_thread(Thread *t, ThreadFunc fn) +{ + if (*t) { threadJoin(*t, U64_MAX); threadFree(*t); *t = NULL; } + s32 prio = 0x30; + svcGetThreadPriority(&prio, CUR_THREAD_HANDLE); + *t = threadCreate(fn, NULL, 32 * 1024, prio + 1, -2, false); +} + +void catalog_start(void) +{ + if (catState == CAT_LOADING || catState == CAT_READY) return; + catState = CAT_LOADING; + start_thread(&catThread, catalog_worker); + if (!catThread) { snprintf(catMsg, sizeof(catMsg), "Konnte nicht starten."); catState = CAT_ERROR; } +} + +// --- Installieren / Entfernen ------------------------------------------------------ + +static volatile JobState jobState = JOB_IDLE; +static volatile u32 jobDone, jobTotal; +static char jobMsg[160]; +static App *jobApp; +static bool jobRemove; + +JobState job_state(void) { return jobState; } +float job_progress(void) { return jobTotal ? (float)jobDone / jobTotal : -1.0f; } +const char *job_message(void) { return jobMsg; } +void job_acknowledge(void) { if (jobState != JOB_RUNNING) jobState = JOB_IDLE; } + +static inline u32 le32(const u8 *p) { return p[0] | p[1] << 8 | p[2] << 16 | (u32)p[3] << 24; } +static inline u32 be32(const u8 *p) { return (u32)p[0] << 24 | p[1] << 16 | p[2] << 8 | p[3]; } +static inline u32 align64(u32 x) { return (x + 63) & ~63u; } + +// Titel-ID aus dem Kopf einer CIA (Kopf, Zertifikate, Ticket, dann die TMD mit der Titel-ID) +static u64 cia_title_id(const u8 *d, u32 n) +{ + if (n < 0x20) return 0; + u32 off = align64(le32(d)) + align64(le32(d + 8)) + align64(le32(d + 12)); + if (off + 4 > n) return 0; + u32 sigType = be32(d + off), sig; + switch (sigType) { + case 0x10003: sig = 0x200 + 0x3C; break; + case 0x10004: sig = 0x100 + 0x3C; break; + case 0x10005: sig = 0x3C + 0x40; break; + default: return 0; + } + u32 hdr = off + 4 + sig; + if (hdr + 0x54 > n) return 0; + return (u64)be32(d + hdr + 0x4C) << 32 | be32(d + hdr + 0x50); +} + +#define HEAD_SIZE (64 * 1024) + +typedef struct { + bool cia, started; + Handle cia_h; + FILE *file; + u32 offset; + u8 *head; // Anfang der Datei (für Prüfung und Titel-ID) + u32 headLen; + Result rc; + const char *error; +} Install; + +static bool write_part(Install *in, const u8 *data, u32 len) +{ + if (!len) return true; + if (in->cia) { + u32 w = 0; + in->rc = FSFILE_Write(in->cia_h, &w, in->offset, data, len, 0); + if (R_FAILED(in->rc) || w != len) { in->error = "Schreiben fehlgeschlagen"; return false; } + } else if (fwrite(data, 1, len, in->file) != len) { + in->error = "SD-Karte voll?"; + return false; + } + in->offset += len; + jobDone = in->offset; + return true; +} + +static bool install_sink(void *p, const u8 *data, u32 len, u32 total) +{ + Install *in = p; + jobTotal = total; + // Den Anfang der Datei mitschreiben (Prüfung, Titel-ID) + u32 copied = 0; + if (in->headLen < HEAD_SIZE && len) { + copied = len < HEAD_SIZE - in->headLen ? len : HEAD_SIZE - in->headLen; + memcpy(in->head + in->headLen, data, copied); + in->headLen += copied; + } + if (!in->started) { + if (in->headLen < 8) return true; + if (in->cia ? le32(in->head) != 0x2020 : memcmp(in->head, "3DSX", 4) != 0) { + in->error = "Die Datei ist keine gültige App."; + return false; + } + if (in->cia) { + in->rc = AM_StartCiaInstall(MEDIATYPE_SD, &in->cia_h); + if (R_FAILED(in->rc)) { in->error = "Installation nicht möglich"; return false; } + } else if (!(in->file = fopen(SHOP_DIR "/download.tmp", "wb"))) { + in->error = "Kann nicht auf die SD-Karte schreiben."; + return false; + } + in->started = true; + // Alles bisher Gesammelte schreiben, vom aktuellen Stück dann nur noch den Rest + if (!write_part(in, in->head, in->headLen)) return false; + data += copied; + len -= copied; + } + return write_part(in, data, len); +} + +static void install_worker(App *a) +{ + char url[192]; + Install in = { .cia = strcmp(a->kind, "cia") == 0 }; + in.head = malloc(HEAD_SIZE); + if (!in.head) { snprintf(jobMsg, sizeof(jobMsg), "Zu wenig Speicher."); jobState = JOB_FAILED; return; } + snprintf(url, sizeof(url), "http://%s/shop/get/%s", SHOP_SERVER, a->slug); + Result rc = 0; + u32 status = net_stream(url, install_sink, &in, &rc); + if (status == 200 && !in.started && !in.error) in.error = "Die Datei ist keine gültige App."; + bool ok = status == 200 && in.started && !in.error; + + if (in.cia) { + if (in.started) { + if (ok) { + in.rc = AM_FinishCiaInstall(in.cia_h); + if (R_FAILED(in.rc)) { ok = false; in.error = "Installation abgelehnt"; } + } else { + AM_CancelCIAInstall(in.cia_h); + } + } + } else if (in.file) { + fclose(in.file); + char dest[128]; + snprintf(dest, sizeof(dest), "sdmc:/3ds/%s", a->file); + if (ok) { remove(dest); ok = rename(SHOP_DIR "/download.tmp", dest) == 0; if (!ok) in.error = "Umbenennen fehlgeschlagen"; } + else remove(SHOP_DIR "/download.tmp"); + } + + if (ok) { + bool update = a->installed; + a->installed = true; + COPY(a->installedVersion, a->version); + if (in.cia) a->titleId = cia_title_id(in.head, in.headLen); + save_installed(); + snprintf(jobMsg, sizeof(jobMsg), in.cia ? "%s %s!\nDu findest die App jetzt im HOME-Menü." + : "%s %s!\nStarte sie über den Homebrew Launcher (/3ds/%s).", + a->title, update ? "aktualisiert" : "installiert", a->file); + jobState = JOB_DONE; + } else { + if (in.error && in.rc) snprintf(jobMsg, sizeof(jobMsg), "%s nicht installiert:\n%s (%08lX).", a->title, in.error, (unsigned long)in.rc); + else if (in.error) snprintf(jobMsg, sizeof(jobMsg), "%s nicht installiert:\n%s", a->title, in.error); + else if (status && status != 200) snprintf(jobMsg, sizeof(jobMsg), "%s nicht installiert:\nDer Server meldet HTTP %lu.", a->title, (unsigned long)status); + else snprintf(jobMsg, sizeof(jobMsg), "%s nicht installiert:\nDownload abgebrochen (%08lX).", a->title, (unsigned long)rc); + jobState = JOB_FAILED; + } + free(in.head); +} + +static void remove_worker(App *a) +{ + bool ok = true; + Result rc = 0; + if (strcmp(a->kind, "cia") == 0) { + if (a->titleId) { + rc = AM_DeleteTitle(MEDIATYPE_SD, a->titleId); + // schon von Hand gelöscht? Dann ist es auch gut. + AM_TitleEntry info; + u64 id = a->titleId; + if (R_FAILED(rc) && R_FAILED(AM_GetTitleInfo(MEDIATYPE_SD, 1, &id, &info))) rc = 0; + ok = R_SUCCEEDED(rc); + } + } else { + char path[128]; + snprintf(path, sizeof(path), "sdmc:/3ds/%s", a->file); + remove(path); + } + if (ok) { + a->installed = false; + a->installedVersion[0] = 0; + a->titleId = 0; + save_installed(); + snprintf(jobMsg, sizeof(jobMsg), "%s entfernt.", a->title); + jobState = JOB_DONE; + } else { + snprintf(jobMsg, sizeof(jobMsg), "%s ließ sich nicht entfernen (%08lX).", a->title, (unsigned long)rc); + jobState = JOB_FAILED; + } +} + +static void job_worker(void *arg) +{ + (void)arg; + if (jobRemove) remove_worker(jobApp); + else install_worker(jobApp); +} + +static void job_start(App *a, bool remove) +{ + if (jobState == JOB_RUNNING) return; + jobApp = a; + jobRemove = remove; + jobDone = jobTotal = 0; + jobMsg[0] = 0; + jobState = JOB_RUNNING; + start_thread(&jobThread, job_worker); + if (!jobThread) { snprintf(jobMsg, sizeof(jobMsg), "Konnte nicht starten."); jobState = JOB_FAILED; } +} + +void job_install(App *a) { job_start(a, false); } +void job_remove(App *a) { job_start(a, true); } + +void store_exit(void) +{ + if (catThread) { threadJoin(catThread, U64_MAX); threadFree(catThread); } + if (jobThread) { threadJoin(jobThread, U64_MAX); threadFree(jobThread); } + for (int i = 0; i < appCount; i++) free(apps[i].description); + free(apps); + free(extraInstalled); +} diff --git a/shop/source/store.h b/shop/source/store.h new file mode 100644 index 0000000..0ceac7d --- /dev/null +++ b/shop/source/store.h @@ -0,0 +1,43 @@ +#pragma once +#include <3ds/types.h> +#include +#include + +#define SHOP_SERVER "mbmnetapi.mbm.wtf:81" +#define SHOP_DIR "sdmc:/3ds/mbmnet-shop" + +typedef enum { APP_NEW, APP_INSTALLED, APP_UPDATE } AppStatus; + +typedef struct { + char slug[72], title[64], author[44], version[28], category[16], kind[6], file[72], updated[12]; + int sheet, icon; // Symbol: Blatt und Nummer darin (-1 = keins) + u32 size, stars; + char *description; + // Installiert (aus SHOP_DIR/installed.txt) + bool installed; + char installedVersion[28]; + u64 titleId; // bei CIAs, 0 sonst +} App; + +// --- Katalog (läuft im Hintergrund) --- +typedef enum { CAT_IDLE, CAT_LOADING, CAT_READY, CAT_ERROR } CatalogState; +void catalog_start(void); +CatalogState catalog_state(void); +const char *catalog_message(void); // Fortschritt bzw. Fehler +bool catalog_offline(void); // aus dem Zwischenspeicher auf der SD (Server nicht erreichbar) +int catalog_count(void); +App *catalog_app(int i); +int catalog_sheet_count(void); +const char *catalog_sheet_path(int n); // Symbol-Blatt auf der SD (leer, wenn nicht da) +AppStatus app_status(const App *a); + +// --- Installieren / Entfernen (läuft im Hintergrund) --- +typedef enum { JOB_IDLE, JOB_RUNNING, JOB_DONE, JOB_FAILED } JobState; +void job_install(App *a); +void job_remove(App *a); +JobState job_state(void); +float job_progress(void); // 0..1, < 0 wenn unbekannt +const char *job_message(void); +void job_acknowledge(void); // Ergebnis gesehen, zurück auf JOB_IDLE + +void store_exit(void); diff --git a/source/main.c b/source/main.c index 3003c0a..91781e7 100644 --- a/source/main.c +++ b/source/main.c @@ -15,6 +15,7 @@ #include "admin.h" #include "ftpctl.h" #include "spotpass.h" +#include "shopctl.h" #define TOP_W 400 #define BOT_W 320 @@ -33,18 +34,31 @@ #define COL_AMBER C2D_Color32(0xF5, 0x9E, 0x0B, 0xFF) #define COL_NINTENDO C2D_Color32(0xE6, 0x00, 0x12, 0xFF) -// Bedienelemente auf dem unteren Bildschirm. Die Werkzeuge gibt es nur für Admins. -enum { BTN_ENABLE, BTN_DISABLE, BTN_DIAG, BTN_FTP, BTN_UNLINK, BTN_SPOTPASS, BTN_REBOOT, BTN_COUNT }; +// Bedienelemente auf dem unteren Bildschirm. Die Werkzeuge gibt es nur für Admins (mit SELECT), +// die Shop-Leiste nur ohne Werkzeuge. +enum { BTN_ENABLE, BTN_DISABLE, BTN_DIAG, BTN_FTP, BTN_UNLINK, BTN_SPOTPASS, BTN_REBOOT, BTN_SHOP, BTN_SHOP_DEL, BTN_COUNT }; typedef struct { float x, y, w, h; } Box; #define TILE_Y 14 #define TILE_W 142 -#define TILE_H_FULL 210 +#define TILE_H_FULL 166 #define TILE_H_ADMIN 160 #define TOOL_COUNT 5 static const Box toolBoxes[TOOL_COUNT] = { { 12, 184, 56, 42 }, { 72, 184, 56, 42 }, { 132, 184, 56, 42 }, { 192, 184, 56, 42 }, { 252, 184, 56, 42 } }; +static const Box shopBox = { 12, 188, 216, 40 }, shopDelBox = { 232, 188, 76, 40 }; +static int shopVersion = -1; // installierte Version des MBMNET Shops (-1 = nicht installiert) + +// Welche Knöpfe sind gerade da? +static bool visible(int btn, bool admin) +{ + if (btn <= BTN_DISABLE) return true; + if (btn <= BTN_REBOOT) return admin; + if (btn == BTN_SHOP) return !admin; + return !admin && shopVersion >= 0; // Entfernen nur, wenn installiert +} + static Box box_of(int btn, bool admin) { float h = admin ? TILE_H_ADMIN : TILE_H_FULL; @@ -55,6 +69,8 @@ static Box box_of(int btn, bool admin) case BTN_FTP: return toolBoxes[1]; case BTN_UNLINK: return toolBoxes[2]; case BTN_SPOTPASS: return toolBoxes[3]; + case BTN_SHOP: return shopBox; + case BTN_SHOP_DEL: return shopDelBox; default: return toolBoxes[4]; } } @@ -189,12 +205,47 @@ static void draw_tile(int btn, bool sel, bool active, bool admin) } } +// Kleiner Einkaufswagen (wie das Symbol des MBMNET Shops) +static void draw_cart(float x, float y, float size) +{ + float s = size / 100.0f, t = 9 * s; + round_rect(x, y, size, size, 22 * s, COL_RED); + C2D_DrawLine(x + 13 * s, y + 23 * s, COL_WHITE, x + 23 * s, y + 23 * s, COL_WHITE, t, 0.5f); + C2D_DrawLine(x + 23 * s, y + 23 * s, COL_WHITE, x + 33 * s, y + 61 * s, COL_WHITE, t, 0.5f); + C2D_DrawLine(x + 33 * s, y + 61 * s, COL_WHITE, x + 30 * s, y + 70 * s, COL_WHITE, t, 0.5f); + C2D_DrawLine(x + 30 * s, y + 70 * s, COL_WHITE, x + 78 * s, y + 70 * s, COL_WHITE, t, 0.5f); + C2D_DrawTriangle(x + 27 * s, y + 33 * s, COL_WHITE, x + 84 * s, y + 33 * s, COL_WHITE, x + 77 * s, y + 61 * s, COL_WHITE, 0.5f); + C2D_DrawTriangle(x + 27 * s, y + 33 * s, COL_WHITE, x + 77 * s, y + 61 * s, COL_WHITE, x + 33 * s, y + 61 * s, COL_WHITE, 0.5f); + C2D_DrawCircleSolid(x + 37 * s, y + 80 * s, 0.5f, 7 * s, COL_WHITE); + C2D_DrawCircleSolid(x + 72 * s, y + 80 * s, 0.5f, 7 * s, COL_WHITE); +} + +static void draw_shop_bar(int selected) +{ + bool busy = shopctl_job() == SHOPJOB_RUNNING; + bool outdated = shopVersion >= 0 && shopVersion < APP_TITLE_VERSION; + Box b = shopBox; + bool sel = selected == BTN_SHOP; + round_rect(b.x, b.y, b.w, b.h, 10, sel ? COL_CARD_HI : COL_CARD); + if (sel) C2D_DrawRectSolid(b.x, b.y + 10, 0.5f, 4, b.h - 20, COL_RED); + draw_cart(b.x + 10, b.y + 7, 26); + const char *label = busy ? "Shop wird installiert ..." : shopVersion < 0 ? "MBMNET Shop installieren" + : outdated ? "Shop aktualisieren" : "MBMNET Shop öffnen"; + text(label, b.x + 44, b.y + 11, 0.5f, busy ? COL_AMBER : COL_WHITE, 0); + if (shopVersion < 0) return; + b = shopDelBox; + sel = selected == BTN_SHOP_DEL; + round_rect(b.x, b.y, b.w, b.h, 10, sel ? COL_CARD_HI : COL_CARD); + if (sel) C2D_DrawRectSolid(b.x, b.y + 10, 0.5f, 4, b.h - 20, COL_RED); + text("Entfernen", b.x + b.w / 2, b.y + 12, 0.42f, COL_MUTED, C2D_AlignCenter); +} + static void draw_bottom(int selected, PatchState st, bool admin) { C2D_DrawRectSolid(0, 0, 0, BOT_W, SCR_H, COL_BG); draw_tile(BTN_ENABLE, selected == BTN_ENABLE, st == STATE_MBMNET, admin); draw_tile(BTN_DISABLE, selected == BTN_DISABLE, st == STATE_ORIGINAL, admin); - if (!admin) return; + if (!admin) { draw_shop_bar(selected); return; } const char *labels[TOOL_COUNT] = { "Diagnose", ftpOn ? "FTP aus" : "FTP an", "ID lösen", "SpotPass", "Neustart" }; for (int i = 0; i < TOOL_COUNT; i++) { @@ -219,6 +270,7 @@ static void draw_bottom(int selected, PatchState st, bool admin) static bool old_mbmnet_link(void) { FILE *f = fopen(UNLINKED_MARKER, "r"); if (f) fclose(f); return f != NULL; } static int armed = -1; // Sicherheitsabfrage (zweimal antippen) +static bool launchShop; // Sprung in den MBMNET Shop vorbereitet: App beenden // Name der MBMNET-ID, gemerkt beim Wechsel zu Nintendo. Der 3DS erlaubt dieselbe ID nicht in zwei Konto-Plätzen // (022-2571): Heißt die Pretendo-ID genauso, muss sie beim Wechsel zurück zu MBMNET dort gelöst werden. @@ -354,7 +406,7 @@ static bool run(int action, char *msg, size_t msglen) bool confirmed = armed == action; armed = -1; // Während ein Update lädt oder installiert wird, nicht umschalten (der 3DS würde mittendrin neu starten) - if (action != BTN_DIAG && action != BTN_FTP && action != BTN_SPOTPASS && update_pending()) { + if (action != BTN_DIAG && action != BTN_FTP && action != BTN_SPOTPASS && action < BTN_SHOP && update_pending()) { snprintf(msg, msglen, "Moment: Das Update auf v%s wird gerade installiert.\nDanach startet die App neu.", update_new_version()); return false; } @@ -386,6 +438,30 @@ static bool run(int action, char *msg, size_t msglen) if (spotpass_test_start(MBMNET_SERVER, msg, msglen)) spotpassTestFrames = 0; return false; case BTN_REBOOT: return true; + case BTN_SHOP: + if (shopctl_job() == SHOPJOB_RUNNING) { snprintf(msg, msglen, "Der Shop wird gerade installiert ..."); return false; } + if (shopVersion >= 0 && shopVersion >= APP_TITLE_VERSION) { + if (shopctl_launch(msg, msglen)) launchShop = true; + return false; + } + if (shopVersion < 0 && !confirmed) { + armed = BTN_SHOP; + snprintf(msg, msglen, "MBMNET Shop installieren?\nEine eigene App mit Homebrew aus der Universal-DB\n" + "(Spiele, Tools, Emulatoren). Zum Bestätigen nochmal antippen."); + return false; + } + shopctl_install(MBMNET_SERVER); + snprintf(msg, msglen, shopVersion < 0 ? "Installiere den MBMNET Shop ..." : "Aktualisiere den MBMNET Shop ..."); + return false; + case BTN_SHOP_DEL: + if (shopVersion < 0) return false; + if (!confirmed) { + armed = BTN_SHOP_DEL; + snprintf(msg, msglen, "MBMNET Shop entfernen?\nApps, die du darüber installiert hast, bleiben.\nZum Bestätigen nochmal antippen."); + return false; + } + if (shopctl_remove(msg, msglen)) shopVersion = -1; + return false; } return false; } @@ -396,8 +472,8 @@ int main(int argc, char **argv) { gfxInitDefault(); httpcInit(0); - // Als installierte CIA: am:net zum Installieren von Updates - bool am = !envIsHomebrew() && R_SUCCEEDED(amInit()); + // am:net: Updates (als CIA) und den MBMNET Shop installieren. Unter Luma auch als .3dsx erlaubt. + bool am = R_SUCCEEDED(amInit()); C3D_Init(C3D_DEFAULT_CMDBUF_SIZE); C2D_Init(C2D_DEFAULT_MAX_OBJECTS); C2D_Prepare(); @@ -444,6 +520,9 @@ int main(int argc, char **argv) } // Das Hintergrund-Modul ist abgeschaltet (Kernel-Absturz): Reste wegräumen, nie wieder starten ftpctl_cleanup_module(); + // MBMNET Shop: installiert? Ältere Version gleich im Hintergrund aktualisieren. + shopVersion = am ? shopctl_version() : -1; + if (shopVersion >= 0 && shopVersion < APP_TITLE_VERSION) shopctl_install(server); ftpOn = ftpctl_running(); int ftpCheckIn = 120; @@ -459,22 +538,26 @@ int main(int argc, char **argv) if (down & KEY_SELECT) toolsToggled = !toolsToggled; // Werkzeuge nur mit SELECT, beim Öffnen immer aus bool admin = toolsToggled; - if (!admin && selected >= BTN_DIAG) selected = BTN_ENABLE; + if (!visible(selected, admin)) selected = admin || selected <= BTN_REBOOT ? BTN_ENABLE : BTN_SHOP; int action = -1; // Oben die beiden Kacheln, unten (nur mit Werkzeugen) Diagnose / FTP / ID lösen / SpotPass / Neustart bool left = down & (KEY_LEFT | KEY_CPAD_LEFT), right = down & (KEY_RIGHT | KEY_CPAD_RIGHT); + bool up = down & (KEY_UP | KEY_CPAD_UP), dn = down & (KEY_DOWN | KEY_CPAD_DOWN); if (selected < BTN_DIAG) { if (left || right) selected ^= 1; } + else if (selected >= BTN_SHOP) { if ((left || right) && shopVersion >= 0) selected = selected == BTN_SHOP ? BTN_SHOP_DEL : BTN_SHOP; } else if (left) selected = selected == BTN_DIAG ? BTN_REBOOT : selected - 1; else if (right) selected = selected == BTN_REBOOT ? BTN_DIAG : selected + 1; - if (admin && down & (KEY_DOWN | KEY_CPAD_DOWN) && selected < BTN_DIAG) selected = selected == BTN_ENABLE ? BTN_DIAG : BTN_REBOOT; - if (down & (KEY_UP | KEY_CPAD_UP) && selected >= BTN_DIAG) selected = selected == BTN_REBOOT ? BTN_DISABLE : BTN_ENABLE; + if (dn && selected < BTN_DIAG) selected = admin ? (selected == BTN_ENABLE ? BTN_DIAG : BTN_REBOOT) + : (selected == BTN_DISABLE && shopVersion >= 0 ? BTN_SHOP_DEL : BTN_SHOP); + else if (up && selected >= BTN_SHOP) selected = selected == BTN_SHOP_DEL ? BTN_DISABLE : BTN_ENABLE; + else if (up && selected >= BTN_DIAG) selected = selected == BTN_REBOOT ? BTN_DISABLE : BTN_ENABLE; if (down & KEY_A) action = selected; if (down & KEY_TOUCH) { touchPosition tp; hidTouchRead(&tp); - for (int i = 0; i < (admin ? BTN_COUNT : BTN_DIAG); i++) - if (inside(box_of(i, admin), tp)) selected = action = i; + for (int i = 0; i < BTN_COUNT; i++) + if (visible(i, admin) && inside(box_of(i, admin), tp)) selected = action = i; } if (queued >= 0) { int a = queued; @@ -506,6 +589,15 @@ int main(int argc, char **argv) } lastUpd = upd; + // MBMNET Shop fertig installiert (oder fehlgeschlagen) + ShopJob sj = shopctl_job(); + if (sj == SHOPJOB_DONE || sj == SHOPJOB_FAILED) { + snprintf(msg, sizeof(msg), "%s", shopctl_message()); + shopctl_acknowledge(); + shopVersion = shopctl_version(); + } + if (launchShop) break; + // SpotPass-Test: jede Sekunde nachsehen if (spotpassTestFrames >= 0 && ++spotpassTestFrames % 60 == 0 && !spotpass_test_poll(spotpassTestFrames / 60, msg, sizeof(msg))) @@ -533,6 +625,7 @@ int main(int argc, char **argv) } ftpctl_stop(NULL, 0); + shopctl_exit(); admin_finish(); update_finish(); frdswitch_exit(); diff --git a/source/shopctl.c b/source/shopctl.c new file mode 100644 index 0000000..6c57d13 --- /dev/null +++ b/source/shopctl.c @@ -0,0 +1,131 @@ +// MBMNET Shop installieren, starten und entfernen (der Shop selbst steckt in shop/). +// Die CIA kommt vom MBMNET-Server (/app/mbmnet-shop.cia, aus dem Repo MBMNET wie die App-Updates). +#include <3ds.h> +#include +#include +#include + +#include "shopctl.h" + +static volatile ShopJob job = SHOPJOB_IDLE; +static char jobMsg[160]; +static char server[64]; +static Thread thread; + +ShopJob shopctl_job(void) { return job; } +const char *shopctl_message(void) { return jobMsg; } +void shopctl_acknowledge(void) { if (job != SHOPJOB_RUNNING) job = SHOPJOB_IDLE; } + +int shopctl_version(void) +{ + u64 id = SHOP_TITLE_ID; + AM_TitleEntry info; + if (R_FAILED(AM_GetTitleInfo(MEDIATYPE_SD, 1, &id, &info))) return -1; + return info.version; +} + +static void fail(const char *what, Result rc) +{ + snprintf(jobMsg, sizeof(jobMsg), "MBMNET Shop: %s (%08lX).", what, (unsigned long)rc); + job = SHOPJOB_FAILED; +} + +static void install_worker(void *arg) +{ + (void)arg; + char url[128]; + snprintf(url, sizeof(url), "http://%s/app/mbmnet-shop.cia", server); + httpcContext ctx; + u32 status = 0; + Result rc = httpcOpenContext(&ctx, HTTPC_METHOD_GET, url, 1); + if (R_FAILED(rc)) return fail("keine Verbindung", rc); + httpcAddRequestHeaderField(&ctx, "User-Agent", "MBMNET-Switcher/" APP_VERSION); + httpcSetKeepAlive(&ctx, HTTPC_KEEPALIVE_DISABLED); + if (R_FAILED(rc = httpcBeginRequest(&ctx)) || + R_FAILED(rc = httpcGetResponseStatusCodeTimeout(&ctx, &status, 15ULL * 1000 * 1000 * 1000))) { + httpcCloseContext(&ctx); + return fail("keine Verbindung", rc); + } + if (status != 200) { + httpcCloseContext(&ctx); + snprintf(jobMsg, sizeof(jobMsg), "MBMNET Shop: Der Server hat ihn gerade nicht (HTTP %lu).", (unsigned long)status); + job = SHOPJOB_FAILED; + return; + } + + // Direkt in die Installation schreiben (wie FBI) + Handle cia; + bool started = false; + u32 offset = 0, got; + u8 *buf = malloc(64 * 1024); + if (!buf) { httpcCloseContext(&ctx); return fail("zu wenig Speicher", -1); } + do { + got = 0; + rc = httpcDownloadData(&ctx, buf, 64 * 1024, &got); + if (!got) continue; + if (!started) { + // Eine CIA beginnt mit der Kopfgröße 0x2020 + if (got < 4 || buf[0] != 0x20 || buf[1] != 0x20 || buf[2] || buf[3]) { rc = -1; break; } + Result arc = AM_StartCiaInstall(MEDIATYPE_SD, &cia); + if (R_FAILED(arc)) { rc = arc; break; } + started = true; + } + u32 written = 0; + Result wrc = FSFILE_Write(cia, &written, offset, buf, got, 0); + if (R_FAILED(wrc) || written != got) { rc = R_FAILED(wrc) ? wrc : -1; break; } + offset += got; + } while (rc == (Result)HTTPC_RESULTCODE_DOWNLOADPENDING); + free(buf); + httpcCloseContext(&ctx); + + if (R_FAILED(rc) || !started) { + if (started) AM_CancelCIAInstall(cia); + return fail(started ? "Installation abgebrochen" : "Download fehlgeschlagen", rc); + } + if (R_FAILED(rc = AM_FinishCiaInstall(cia))) return fail("Installation abgelehnt", rc); + snprintf(jobMsg, sizeof(jobMsg), "MBMNET Shop installiert!\nTippe unten auf \"Shop öffnen\" oder starte ihn im HOME-Menü."); + job = SHOPJOB_DONE; +} + +void shopctl_install(const char *srv) +{ + if (job == SHOPJOB_RUNNING) return; + snprintf(server, sizeof(server), "%s", srv); + if (thread) { threadJoin(thread, U64_MAX); threadFree(thread); thread = NULL; } + job = SHOPJOB_RUNNING; + jobMsg[0] = 0; + s32 prio = 0x30; + svcGetThreadPriority(&prio, CUR_THREAD_HANDLE); + thread = threadCreate(install_worker, NULL, 32 * 1024, prio + 1, -2, false); + if (!thread) fail("konnte nicht starten", -1); +} + +bool shopctl_remove(char *msg, size_t len) +{ + Result rc = AM_DeleteTitle(MEDIATYPE_SD, SHOP_TITLE_ID); + if (R_FAILED(rc) && shopctl_version() >= 0) { + snprintf(msg, len, "Der Shop ließ sich nicht entfernen (%08lX).", (unsigned long)rc); + return false; + } + snprintf(msg, len, "MBMNET Shop entfernt.\nApps, die du darüber installiert hast, bleiben auf dem 3DS."); + return true; +} + +bool shopctl_launch(char *msg, size_t len) +{ + Result rc = APT_PrepareToDoApplicationJump(0, SHOP_TITLE_ID, MEDIATYPE_SD); + if (R_SUCCEEDED(rc)) { + static u8 param[0x300], hmac[0x20]; + rc = APT_DoApplicationJump(param, sizeof(param), hmac); + } + if (R_FAILED(rc)) { + snprintf(msg, len, "Starte den MBMNET Shop bitte im HOME-Menü (%08lX).", (unsigned long)rc); + return false; + } + return true; +} + +void shopctl_exit(void) +{ + if (thread) { threadJoin(thread, U64_MAX); threadFree(thread); thread = NULL; } +} diff --git a/source/shopctl.h b/source/shopctl.h new file mode 100644 index 0000000..71e9856 --- /dev/null +++ b/source/shopctl.h @@ -0,0 +1,21 @@ +#pragma once +#include <3ds/types.h> +#include +#include + +// Der MBMNET Shop ist eine eigene App (shop/). Die MBMNET-App installiert ihn nur, wenn man das will. +#define SHOP_TITLE_ID 0x000400000FB4E100ULL + +typedef enum { SHOPJOB_IDLE, SHOPJOB_RUNNING, SHOPJOB_DONE, SHOPJOB_FAILED } ShopJob; + +// Installierte Version (Titelversion) oder -1, wenn der Shop nicht installiert ist +int shopctl_version(void); +// Lädt den Shop vom MBMNET-Server und installiert ihn (im Hintergrund, auch zum Aktualisieren) +void shopctl_install(const char *server); +bool shopctl_remove(char *msg, size_t len); +// Startet den Shop (die MBMNET-App beendet sich danach). false = ging nicht, Grund in msg. +bool shopctl_launch(char *msg, size_t len); +ShopJob shopctl_job(void); +const char *shopctl_message(void); +void shopctl_acknowledge(void); +void shopctl_exit(void);