diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f3465a2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Patch files must reach `git am` byte for byte. Line ending conversion on a +# Windows checkout would corrupt them, binary hunks in particular. +*.patch -text + +# The shell scripts run under bash, including on Windows runners, where CRLF +# line endings break them. +*.sh text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cc46674 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,120 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + upstream-ref: + description: Upstream tag to build instead of the one in upstream.txt + type: string + required: false + workflow_call: + inputs: + upstream-ref: + description: Upstream tag to build instead of the one in upstream.txt + type: string + required: false + outputs: + version: + description: Version the app was built as + value: ${{ jobs.build.outputs.version }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.upstream-ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + UPSTREAM_REF: ${{ inputs.upstream-ref }} + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + outputs: + version: ${{ steps.version.outputs.version }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-latest + - name: Windows + os: windows-latest + - name: macOS + os: macos-latest + + steps: + - name: Check out Modrinth Enhanced + uses: actions/checkout@v4 + + # Everything below works on the patched tree in build/upstream, so this + # has to come first: a patch that no longer applies fails the build here, + # which is exactly the signal we want when upstream has moved. + - name: Apply patches + shell: bash + run: scripts/prepare.sh + + - name: Resolve version + id: version + shell: bash + run: | + . scripts/common.sh + echo "version=$(app_version)" >> "$GITHUB_OUTPUT" + echo "upstream=$UPSTREAM_REF" >> "$GITHUB_OUTPUT" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: build/upstream/.nvmrc + + - name: Enable Corepack + shell: bash + run: corepack enable + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Set up Rust + shell: bash + working-directory: build/upstream + run: | + rustup show active-toolchain || rustup toolchain install + if [ "$RUNNER_OS" = macOS ]; then + rustup target add x86_64-apple-darwin aarch64-apple-darwin + fi + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: build/upstream + key: ${{ steps.version.outputs.upstream }} + + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + + - name: Check the patched sources + shell: bash + run: scripts/check.sh + + - name: Build + shell: bash + run: scripts/build.sh + + - name: Check the build output + shell: bash + run: scripts/check.sh + + - name: Upload installers + uses: actions/upload-artifact@v4 + with: + name: modrinth-enhanced-${{ matrix.name }} + path: build/artifacts/* + if-no-files-found: error diff --git a/.github/workflows/upstream-release.yml b/.github/workflows/upstream-release.yml new file mode 100644 index 0000000..e8dd1e3 --- /dev/null +++ b/.github/workflows/upstream-release.yml @@ -0,0 +1,124 @@ +name: Upstream release + +# Watches Modrinth for a new Modrinth App release, rebuilds Modrinth Enhanced +# on top of it, and publishes a release of our own if everything still works. +# +# Nothing is published unless the patches applied, the checks passed and all +# three platforms built, so an upstream change that breaks a patch stops here +# and reports a failure instead of shipping a broken build. + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + inputs: + upstream-ref: + description: Build this upstream tag instead of the newest one + type: string + required: false + force: + description: Release even if this version was already released + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: upstream-release + cancel-in-progress: false + +jobs: + detect: + name: Detect + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.check.outputs.tag }} + proceed: ${{ steps.check.outputs.proceed }} + steps: + - uses: actions/checkout@v4 + + - name: Find the newest upstream release + id: check + env: + GH_TOKEN: ${{ github.token }} + REQUESTED: ${{ inputs.upstream-ref }} + FORCE: ${{ inputs.force }} + run: | + set -euo pipefail + tag="${REQUESTED:-$(scripts/latest-upstream.sh)}" + current="$(tr -d '[:space:]' < upstream.txt)" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "Newest upstream release: $tag (we are on $current)" + + if gh release view "$tag" >/dev/null 2>&1 && [ "$FORCE" != 'true' ]; then + echo "$tag has already been released; nothing to do." + echo "proceed=false" >> "$GITHUB_OUTPUT" + else + echo "proceed=true" >> "$GITHUB_OUTPUT" + fi + + build: + name: Build + needs: detect + if: needs.detect.outputs.proceed == 'true' + uses: ./.github/workflows/build.yml + with: + upstream-ref: ${{ needs.detect.outputs.tag }} + + release: + name: Release + needs: [detect, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Record the upstream release we build against + env: + TAG: ${{ needs.detect.outputs.tag }} + run: | + set -euo pipefail + printf '%s\n' "$TAG" > upstream.txt + if git diff --quiet -- upstream.txt; then + echo "upstream.txt already points at $TAG" + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m "Track upstream $TAG" -- upstream.txt + git push origin HEAD:${{ github.event.repository.default_branch }} + + - name: Download installers + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.detect.outputs.tag }} + run: | + set -euo pipefail + ls -la artifacts + notes="$(cat <` — so worlds keep the same player data when they are opened from another +launcher. Offline accounts can play singleplayer and join servers running in offline mode. Servers +in online mode reject them, as they do in every other launcher. + +Microsoft sign-in is untouched and still the default. + +### What is *not* removed + +Download attribution still happens. It is a header on downloads you already asked for, and it is +what credits project authors for them. Removing it would take money out of creators' pockets +without making anyone more private. + +`posthog-js` and `@sentry/vue` remain listed in `package.json`. Nothing imports them any more, so +neither ends up in a build; removing the entries would mean carrying a patch against the lockfile +for no practical gain. + +## Relationship to the official app + +Modrinth Enhanced keeps the upstream bundle identifier, which means it uses **the same data +directory as the official Modrinth App**. Instances, settings and accounts carry over in both +directions, and it can be installed as a drop-in replacement. + +The flip side: do not run both at once, and on Windows the two installers share an uninstall entry. +If you would rather have them fully separated, change `identifier` in +`apps/app/tauri.conf.json` — it is one line in `0001-Rename-the-app-to-Modrinth-Enhanced.patch`. + +## Building it yourself + +You need git, Node (the version in the upstream `.nvmrc`), pnpm via Corepack, a Rust toolchain, +JDK 17, and on Linux `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev` and `librsvg2-dev`. + +```bash +scripts/prepare.sh # check out the pinned upstream tag and apply every patch +scripts/check.sh # assert the patches still do what they claim +scripts/build.sh # build installers into build/artifacts +``` + +`build/` is scratch space and is never committed. + +## Working on the patches + +The patched checkout is an ordinary git repository with one commit per patch, so patches are +maintained as commits rather than as diffs by hand: + +```bash +scripts/prepare.sh # build/upstream, branch `enhanced` +cd build/upstream +# ...edit, then either commit a new change or amend an existing one +cd ../.. +scripts/export-patches.sh # rewrite patches/ from those commits +``` + +Patches are applied with `git am --3way`, so small upstream movements around a hunk resolve by +themselves. When one genuinely conflicts, `scripts/prepare.sh` stops and leaves the conflict staged +in `build/upstream` to be resolved with `git am --continue`, after which `scripts/export-patches.sh` +writes the fixed series back. + +To move to a newer upstream release: + +```bash +scripts/latest-upstream.sh --write # update upstream.txt +scripts/prepare.sh +``` + +## Automation + +- **Build** (`.github/workflows/build.yml`) runs on every push and pull request, and is also the + reusable workflow the release job calls. It applies the patches, checks them, and builds on + Linux, Windows and macOS. +- **Upstream release** (`.github/workflows/upstream-release.yml`) runs daily. If Modrinth has + published a newer release than `upstream.txt`, it rebuilds against it and — only if every + platform built and every check passed — commits the bump, tags it with the upstream version and + publishes a release with the installers. + +`scripts/check.sh` is what makes the automation trustworthy. A patch can apply cleanly and still +stop doing its job if upstream moves the thing it was holding down, so the checks assert the +outcome instead of the diff: the app is named correctly, offline accounts are wired up end to end, +no telemetry endpoint survives into the built frontend, and the installers are named after this +fork. + +## Licence + +The Modrinth App is GPL-3.0, and so is everything here. Modrinth Enhanced is not affiliated with or +endorsed by Rinth, Inc. diff --git a/patches/0001-Rename-the-app-to-Modrinth-Enhanced.patch b/patches/0001-Rename-the-app-to-Modrinth-Enhanced.patch new file mode 100644 index 0000000..3e95dbe --- /dev/null +++ b/patches/0001-Rename-the-app-to-Modrinth-Enhanced.patch @@ -0,0 +1,125 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Mon, 14 Sep 2026 09:56:18 +0200 +Subject: [PATCH] Rename the app to Modrinth Enhanced + +Changes the product name, the binary name, the window title and the +version label shown in settings. A small "Enhanced" pill is added next +to the wordmark in the title bar so the fork is recognisable at a +glance. + +The bundle identifier is deliberately left as `ModrinthApp` so that +Modrinth Enhanced keeps using the same data directory as the official +app and stays a drop-in replacement for it. +--- + apps/app-frontend/index.html | 2 +- + apps/app-frontend/src/App.vue | 5 +++++ + .../src/components/ui/modal/AppSettingsModal.vue | 2 +- + apps/app-frontend/src/locales/en-US/index.json | 2 +- + apps/app/tauri.conf.json | 6 +++--- + apps/app/tauri.linux.conf.json | 2 +- + apps/app/tauri.macos.conf.json | 2 +- + 7 files changed, 13 insertions(+), 8 deletions(-) + +diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html +index 50867a4..6367788 100644 +--- a/apps/app-frontend/index.html ++++ b/apps/app-frontend/index.html +@@ -4,7 +4,7 @@ + + + +- Modrinth App ++ Modrinth Enhanced + + + +diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue +index a2ae0e6..1d1a9ce 100644 +--- a/apps/app-frontend/src/App.vue ++++ b/apps/app-frontend/src/App.vue +@@ -2374,6 +2374,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload) +
+
+ ++ Enhanced +
+ +Date: Mon, 14 Sep 2026 10:17:50 +0200 +Subject: [PATCH] Use the Modrinth Enhanced icon + +Replaces the bundled application icons with the Modrinth mark plus a +sparkle badge that marks the build as the enhanced fork. The vector +source the whole set is rendered from is checked in next to the +generated files as `modrinth-enhanced.svg`. + +`icons/apple.icon` is left untouched: it is an Icon Composer source +that nothing in the build references. +--- + apps/app/icons/128x128.png | Bin 11506 -> 10962 bytes + apps/app/icons/128x128@2x.png | Bin 21890 -> 23098 bytes + apps/app/icons/Square107x107Logo.png | Bin 9957 -> 9149 bytes + apps/app/icons/Square142x142Logo.png | Bin 14194 -> 12369 bytes + apps/app/icons/Square150x150Logo.png | Bin 15023 -> 12971 bytes + apps/app/icons/Square284x284Logo.png | Bin 30114 -> 25912 bytes + apps/app/icons/Square30x30Logo.png | Bin 1955 -> 2241 bytes + apps/app/icons/Square310x310Logo.png | Bin 33107 -> 28222 bytes + apps/app/icons/Square44x44Logo.png | Bin 3321 -> 3556 bytes + apps/app/icons/Square71x71Logo.png | Bin 6099 -> 5943 bytes + apps/app/icons/Square89x89Logo.png | Bin 7851 -> 7601 bytes + apps/app/icons/StoreLogo.png | Bin 3968 -> 4064 bytes + apps/app/icons/favicon.ico | Bin 24838 -> 10450 bytes + apps/app/icons/icon.icns | Bin 265353 -> 275090 bytes + apps/app/icons/icon.ico | Bin 53242 -> 37449 bytes + apps/app/icons/icon.png | Bin 60231 -> 48948 bytes + apps/app/icons/modrinth-enhanced.svg | 29 +++++++++++++++++++++++++++ + 17 files changed, 29 insertions(+) + create mode 100644 apps/app/icons/modrinth-enhanced.svg + +diff --git a/apps/app/icons/128x128.png b/apps/app/icons/128x128.png +index 092c8fcbfcbd6eb46e2eae2c504fad3daa4a0939..b9d95680334c48e7002e35dda76d5aab26491976 100644 +GIT binary patch +literal 10962 +zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4mJh`hQoG=rx_R+*pj^6T^Rm@;DWu&Co?c8 +zFnGE+hE&XXTgzTE`|8zy#qplg);!HUc{?(N-APP9p=Sak%OSlf)5LCViqeqR-F*Le +z7I*4)2kxc!uU~R%4DxDpU}|A8Qb;inauV7klV8d2IW5QY`+w`}8`pmH+9l;D^Q*qD +zQ}?y(_o?r8KA%^8uJ~N)k7tcHbDl0{_`ihVhQ_f9)dQy_0}eFWFjVpw6eut^ynJA) +z$M&V@0P}*WjB6RPA8}kMIJe5;@o$ru&4(YZxX0h{Sem6xS*CL7Qq`{h^Pv*6Pg|xj +z&R{xVM3tnu^LJImMPXRcg%T(C#kk3maYn^$lBdlA-%GnQhE7OxEKFX^4T +zS+p#n_3D!U3Fqh8#>qHZD6u4bdU8@yS67zXB3Yc-LcBp#S$Hl(wQa)z`zWcm@0p^1 +z85}ZP#JXU?f|5Cl4u;D7sW#dAxPz(O;8po! +zOuusT%!w6e+V&|)?_v_(VrIOha^{5@MhAGJGS2*(@R@mC+>g7p92R>&nC`xK{fSfj +zWZ}K+Ru698=Bs!2|0LhB`76)A`xY`^PrJ<$w4B7!ASyOh&z0dnb6c{|&Vb2tIWM0{ +zl30FgqUBP7nZlnI&Ny+xU;dA*Z9#s1TY9zdop`GU|F-ber7x12S9{9hU8zOZJqgVw +z10|LP2jX@`Fur?yfM@3CnTIA=p5=SJa&e;FI+0(6m5=s1bH3wV@Z6hyj&#<=tyS_i +zyQ7w@Pd$)!?uPy>sjPdF*V?C4PdnWH=h35&vl>2eybu3cIMMQI+Z)$SKKXRdd0-WrE3|>&NZt3*SzaZP~Ylcb{4Lp3C12&aE%A +zcop_=wg$tK11~@Rc=znUc}9(k=T2*EUG!kp%oBFiHZ$~IWx9(n+16&%{`B7R=jrB( +z)tr2N>04(V@csJrYoY414#9>)hc0CXd?^3)(#6_6*>u5&S2IuSt$1a=?D1j2g6|7| +z{@=FXX?}b|b!|suF4q)q{?`8qH&_p(Y44t5{4Xcqf$A4Vk+wVC%NBh2b<>ICpYVn2 +z7kZDmU*leE$W~?XW9!-8((}B}H)phczQ?+4ll#FR&P#4y^)u)>?R+%3dt&j!6pddq +zwmmTClIoU=qszNylKH=)+P#}7=Kbw;fF^v%6RJN~fc+^G4!Hn#rp1M$^n +zs{$wd|C=0G@pVmN==UdgKZlhb*u0*1pPPD(tlW>I|K$qmUwl8wBb8Cyd*PYn!m}q` +z+&Wo*ZTxvc!|ZZV?u~g9=Xmz=uXFq@ZM}H@k&m+sgR3J$Lq%IxU+&sczp1H-$vF7S +z`liIjTZOR`S(om&V_x>sH8XcQyM;~s_5*u&@|`S? +zy)uon$JqPGr4X^bSLeR?w?pdPdFczg_cQPPdY9wNm;Q>QGa7r8xQ*OWoiDz=@bzBa +zkJwlDT=~{~`z|$Y+t#-)4|#|-96A(erm=53W8Iotg`YpBYE&mMnnb>w`8u=r?%sC{ +zR`>5RKmW!2p?vkKOIw;UMI_XoaO3g_QY0}jN +zx6C%M?s@Oie)!^)FYFzA(yJ1-hljB}sOCR#<3yMQ*ScAahqBW<^$kLI*kwEXw%@+^ +z_u*R4ACE6{9BU~zs{Uw^GT+uQqwU?-s~I%~x_j8I+E)6$3ENTTn4BCMT9r4Q?Z(@0 +zO$=MI8GHIO99A#*@onA&b^W=w>mk+a)6@(tF%+j_t +z;@Nzr?dZx27lI;dv~}0Uo@dsYJ3rOosqu4#VkgHS^JRSN_m`W-ME7OvOAV1Q;$8IF +z)^_FER}uwT;oG09GKgR5`ZU3+iO;HXT~P4v#q;dgXHMpt9Q0kc{;!Pb_I=&=X8F|w +zC!XBowD{_Q*^3xB415e`u4S9PIXkszx;o?RwO@?ReQ-K*wnQq#uF}k>*h_d{dVI3^ +zs``I?Rog5-___y1ZoI(IyOsClF4goobDtMn*<<%6n1Qv-g8An+=YtYn?`>^Yo-3~l +z>78}^e)E3gw|(mzx##X+dD5b^Zh_ftg?qs~f93D*T*)_AyX)90qMRJo7mxG)KKWd7&&*#sAdWF@&J+$=TSdkfAAe|U +zFgE>D`}*bLzzSZ~rze_!Gq3pnls%(9m;DlB(ebdOq-C +zIDhA_MU5v{(1FY98t>}DSNxvMy#2{-@9Eo3uGMK2%eziHe0=7ok{uU4+~RLKcMEa# +z2?#EFb^LVDtC}u_XK^;H?t3eDa_B6*^t0AiFZ0~~hbw9XZ+NmA`_E<0b`}1_ZuGjt;N_>Bn +zsu_B0 +z;N7A{i@K(;9yo8>A0%P_!}gbw*LzFdg(u=3C(AGV`&D8|yT!5VH^TK=Q}gbepKM?= +ziBon;jnj+l5;dFW=Pi@<2RDbGV=(DC +zE1e>_@?eIM)9i)KHSD3=Ismj +zF7@f(yW4O}HZS?wpBdLZ=SU8eJ0+to|Y%jX==JNRP%9Q!qM&;Mrn +zaEj}|jypHLb8)@9@Sb5u#H*hV`)~eKJTBnSaCz#U|8ZaZi*`m-eCABh+T*ouL-piK +z3`&8U_djyu$g!WPyeDIm$%|LBr@N=_aA~f~(bKB0=PPMl +z{KPLjei3o-liww(*V6w$H48>bBVZ-?^i`&gaca1*t0Ay%n$8eg}Ro4Lxzff7@qH$;D3dTek$i +zEAQ*GnV8skH_psEioqf{kFWl`ft~!c$y!V1+I}rx?{#9ofA+6a_H!neJP+RFdN=lc +z^7BK_A5;`E|9Z81qTY|))fYc}sCpCiL8eCcWO`1Z`~TMq_w!{j1pPi)W~W$OwS-yc +z_ao0$OKvkfFZbYNn3@_GEY5o0KJ3 +zG8ePFOB@w7nwA?mZrlG5$vvCUe&hhc3>#$b{p}ujw6geMR?r5N=J4c*t(N%Yx$mXmqOORcJ|x$ +zefRvIrO?_C^z5S9MSbT5Tb=Da7;eq$K4G5{)2z*SN79*@Z-*ouDLIJ~#c(p%m0{NYZCI+5-Azi!5;Fim>(YWB3nFJJaL3G}&G +ze7m+Vv;0%A=dl+u5q|QvH$G-f3;V_@d{u9&xQL}+zZQdxmy6!~)VQT9)~PZJs!xvz +zzbxa%C~NxMRcF7`Ro;A6S$_%5jP==W?t5c+oOriI`>|Z!FyrQ;woJ2>p3X1VjLzu? +ziZUeqZg@E71NWx$Q=-35?%wkF>v{$5RevUO)b{l3{cHcnJZAoqw3eeq*0cL`-TsLl +zR}p*S#Z+s%_rtEep6CA_yLq%Qxb@|Z(6imAZU!y-%>T#I;?IgdTHLD-tTcSRWP07( +z^|h*VXS*-n_u-di(DSZzzJkOzUvvDUz2+!vl2|U@T~PLH36rC2b6J8-!NC(B2+_jxq9Io8s-97Q=5w8tA +zJ2^zJ9-8R-@2NX?(J~#G{`!`6Cmh;MGAcd4GWQ1OD4o7;a8j+sTEgZ{=*3+1u7JWt +zC+8Y{Si0d``=JZrcU6tL4j(+M;t-zJSXX1WCsEM<+>J#Xj1L%G|2ZjG|EexC@m~MC +zv(uYt0!Q+UPmGfpStq?Qmy@4vaVRv7cf;IovH!eog-dm&u}n->|IU6n(XP~_BxJqA +z+j-6QPH_h;w(B@CFu2wJHLhC9GbM)I{X~nSR+~kE(TkXmTfelQw9KCrvFyZw`)^(E +z-d#N@X!;Qut-nwI%$R$geZQ7y^sL}cVP!A*D3z9n1&Y59c}n~}_w>ZiZ(CZd=O@?& +zn(7w^)z!IPT)>d=`$lJ0T_so3i8JTtYJXFhSh`o8^97diMm44%3i-4-TzZn(kz;7H(_tK~J=I-M@pc +zKP+8%siS0X@<R65o6} +zeQ?b})mP4fug-z|Nayl`17Ir)IUB~r%vzBR?}qESbT{8f}ixu +zlq*6jj?ChjBb~LxQ~b)$sps`XTg8I|f8Kd5+;fZh`X0vhDT_Q7eR!AWoZn%qX#OfW +z^5@6a^cB557bn)9I-s?+=VR9gBSuNpqG}_-gF#$92Ng1d1?v50*?l`>DVUkLDa`Jt +zM2vLSgP3B63dzM6_g?0_J}WCF=HC1(lP;}TKC5GQ^u^6f!Ae$Mk* +zj}~v)vNGw%(-6(c`xca>UY&G5)~r6@*oq}fm%R96@M`jv70VCt?2qjJ7QTN{f6Ow@ +zce&%HEm%ie>Z61l#na8&>hLn^&yWlZarA^c8g8 +z{O;b>NA#$<|$yT1?vOAfsy-D&6`U;F5bGoN0q%5CukNItoOVBDgx7){5Ufsz2 +zb+_&CwVx~#`!C#L?md;I5%bi#ZYSHh?~mCJv@rCX>#|*1;Bc}}rOQa6zlP!D$2r0c +z*Yc*VTdm!^?)!Jn+=(4i?|-@7f3%IaT{>>+f|;`xs!QvCG_`(QW+$7T^|Z2bKLgM2 +zcivGaZynh3aT3potoJp~vop2#ufN$k?fIpsKGjT?rZ9_%VgCeD9`iGAYtU-i$KI=1 +zH1mu^j|#)#eTw};mzm92_IuA$z5MZ+p5}4BMzcN>xw-#(-FY8>o>>1@CuQ#PQ~QHk +z*WXosdLvM$=wTjaVDxt8B-2z!7hWz2-fPT3FMlLNh|9Bn +zf37*nT7>QJ51SeO>snGjMx;zh=V$A+@>Ea_6K#yEzY=KAuyB*A_77JJpV0m6a!1Oz +z4xYHROj(dop=94i?pAXd@#UX?_eY5LIX-rNy~IjWLL^A--J+jc0u9cu4hcPbi%EG6 +z_pt@9b322I**DicuKcp&joS=nA%V0HZU04em-Zh?x?!sjWAxZmuNaYb +zw$F$P|84d3jp8ki=)0aWzr;mncI^1JfPtqvt5+x5;?7QC{**heo9Y(1U-vmaebI|^ +zlb15ZR))(;Cx@)wuDF%6L)vz5;&*a@Y6tFqPA%5P{SGvlj`MWFi)^H!*uF_@yO1ZySB*t9&)*By< +z$STDUlSwH(o~Ap@uPl1Heg2V3rUM2$9aeqr=o>}6SfN$bJy${B1Q-c47ox-czsQ$g-Z?v*cBU;D{&`Qy_? +zeE$EwFaN%|L;2aGD{i%4iiL8N4_y4v9r|MG8kt#!X6%xJiG`(%pKG+#86<^2sqAb2 +zXI!JjbU^a{dWKmYQ{vv&S@JpJUkXu!;S7PPI_0KQOYMo>J)qK6jicgxo|K7Zp +zmHqwC@qY7b+n+5G6Q_R+EHF->v*@M(HRWm@ox(?1tX +zTz1mzfW>N+r#Uk3*NK_f?}*d6D8x9?LG!dl7&CKj#Ws@;p${h&9=vKUzM1ziK02c* +z^P|L!^D^wQsbW?3!G>Cr(f<2?6*JeKs&cbCs8CU6(miQ~k#+55J6j_rhwN+ns?`ku~PV6#E&-umdpe%4>@tJzb}&*QH3klghnx1io(g2mj`s~-F+*q`uJjqB9< +z6Z?!7J;?f?++ySW+`BV0toNw!o0`1UKi0246V`jQV1C$-L+qvH3-?aZFjKraJ9U3# +z#~P0F(JC3jt#_aQw|!M;^lH=j*;nE-td=>PEUk<0mOk5`@1UM4;Cbfglc(DMKUcE9 +ze&s6vb_m0*e1>x3Bti^V_mxw=@@u +zI!R13meXRmEB|h~5W}a_lMPpFo6D8@{zksN=ae+h$fxWUR@3Txm$ln@tw=D|ZOmF9 +z+xE$RKDX+f(pMi3?%l|DSNr?Hp9kw6-tlDrls7~7=Fh+NTkU_}T&PpMz2?Eu1q=oo +zo-*)nWZrfCjrd}vxVZ|yIJR^KZ(z7^C2@Bm-=^t)LQ)E$Q~l>1`L#Q2^%U!g%ufxk +z-P^*HdR|BHXU~?l@^!cPuOHjh66y3Y!LnFplODHr%fek+Z~u2ct9@SdQ%QPdQ{g@( +zCx$g@f7xUG_+1s)%%3dOKecmTRLGr1Q2>&cEFBX{Fr1 +z1M6;|JhXW&=kN2iflKOFPtv^1@c83WjUC!O#R9#{%LQ`%^Q=l1&tI%;Z(qIk@WuQm +z{Csb>Z8O_{a*pelYZtiu{x#f~?f*B|*1I^;dbG#92so?VG<+=&h#H`u@@| +zdy&0t(~R8?{|(fwS$i`4&9bD)L8rS_5^NTVd~-j?adk#u$Ulad)vGUje6#R$cmhxT +z{r&atvzhDLFJv-t{89eXw2uE9_xS_^CwEi+n|D0lNa;CV@;Nr$Uux-_nVS=*_?X&H +zG0>Oe{=JyHt)h1opPt;t=l-vo~~fx-@dte|H+)c +zioVIt{wEinHRat^bJfc3$J&avxGCD9=eVjwOBh?HbWZZ+c~r5ptewecwZivbktgHop=O$HOQ*=sQGYx!sQOxS;EvWJUGvE{l>6JR^tD(w0!f5C3? +ze-<`;=YHnjT^GB^;R%D%nd; +zI44rZC2vCaZEp9|m6H!t|9Wuu;-r$FS6v*7YtDV|Z+v@wIq%gs{fST8_dmXSuV>lq +zaL@ibJs&Gi>@8jQDy!?I=>`8&?LX%%PKYyMo26s>>sxulcb5qcJDJXYk6m|e!jG-1 +zB6BxS-*>dpxcUC#S-Td;@6pKBnt$!%+4Fghzq@ZAF>#){R{LK~oPBQ8qR-9!mkO0^ +zw(S3-Y8C%eYw6s*j}KOz3p}y2Y+1o}J}0NApA*=i-l%QY +z^=n!GZTO(iUcGteMzuu}J6ic?`=A#JgMd~XX**7>Ny(1?vb09h+XczBmK<5-tNR&ot2+B +zr%2taID7kp<&&8|Qocm$%}}`8_Ic_02XRXe1t+{|&r1*AI)!0JObSoh#4RmHs`ggj +zo>=@(Wuu(`vd^04{Z{)v^M2i1o3U8^eE8YFw=W;Lvrwf{D)Te!4 +z9;wa66nk$=23v03vK{qGTJ;+~uaEEB$I@iG>ywVipJycsDjSWfq?de)&Wm5V?4NyR +z-kQJiSI-NtEj(7JpZ=qKhxxkKb8R=cot+l=UZQA^iS0i_t4=f7^m%2qG0n4HPh0ro +z<%QRUf^idk%A8Ie+iVDD5rNgU(o_Os-zk +z&|CTTl0jQO&(!;`kKg+o=TjkV{&z>r6q{`pua0?KJm4I6Lg(18i(l8Tzwl*sX$SYQ +zfRE?4H@~>C@%D$j(gTI3e}7QlaX03i|MLUC-f9Nr3jE;{f2Chzs>Ix}L+)(v*5XCA +zUv5}C8j3A6`z5ve;-~%5N^-5oH_UijK5xzizsD>}r7}DIga*o|A6Tt)^{36pbLuJQ +zrfDa;OPw)}kTWlnKDWJm@xxnBIm;V=yRk4Za8-M{IEL)Yyf|aUT=`cm(oVmHXNO$g +zFLExw`|HJIx5}7v>-MH?o@(OIaqj)iYb>vuHeN^*KFb;OGGK{e%p}zZY!31(s(;F{ +z{9-yWCr;(t$v|8EC#}DPj~c6qu9mx-|8|;@jA%BSPohY^+`EU>FOs+XPwXyOs?PMc +z#YV)&cY1)g8U_;Ec?>Cq!a|i}bFpynV4} +zjq2m%JXPsG2SQk9|M)C2!INXo;^&*$c3i*eV82p9LtJk~Zvbb|7d{qa1mM$Hj6-K+V}+Sl^i7%}g*UXc3y +zOG#hEnFv261ci+_0Ladk5A`R)|r_#XA95Us#@_sxp@-H +zZnU_0uKldMTx6B)lPJmgQ#_UT+<$pV{;kRc?`>cI^bcBn&*bx^K)2p6SsUS +zf0a>R!Mta7XiM)#>!?@R_j|9e3wb+jPR{G9dWPvO;Udax>nh_|w^SBpedFD8P~wbw +zILEEcB~vd>kS?y+X1@6Id~27LqPO_nPfoZguJCEat-{=Sg0koHCkIMj2uZJ3?-`|!*QQ`^FVyT421b*b2GwB&kxyexIYdHbx}M*KIfUVUmj +zTj%yciMLu-OFlElY{=YnPEP;O)~v;LOkBc$nBu+p_gFvr%yHG>b*0xlh4u6IU+wML +zr1Ox&>PFU{)r+OqRO#Ny=JSxuy>qjDo^NSJZG!Edcr*30`WL1}B-FQA*_#|xyLZ=v +z+5G+wxgF+*whKP!iG5P`Zpx$S-uZ3L=M)zoopre3ant?p>Y@K-7I{nSRXKl0WPun>=?N$`M%^hC-yT<#__UTK% +zKEOWJz8ar_rg+52C{7dUynj?sHR*$zFZ&;~}#h*RCE2zpu;UGCNm* +zQF!J#{-K%;`CTr{nNt#8x;^KJl6nz8&USbaeT{ +z%eP;>5{k-Jcip6|qh-r4v%W3f-*5h=<>!Q?{ZzAB1k3M!Ty0vg&$=f5-@~?Jd2R>w +zpF7HjYzexRwy??W^tT0X_ibIpEqCl@Q2XL%y)E-OJr6v6b1Ty*T8BY6x}W#Z`HOqA +z?0YtQI~`uS@cgPgX`$!&|1u^z`Ia?$IXphW`nvy>s7-}c$}^ity$}KJ2pEc +zm+#mO=DuUI?yG;!@Oz5K&OzPm4SME*Q8iO`!R;UqRS^e4;tT~a3Zw?$3eckW<(R@R!o8>birI%-8H +z6>a~#;<|5|_|7h8wYsQTCHpsr`>;lBbl*_JsQ$}w>-#gyL%Is|-cI(uxJ8+F)*YWG +zcPykU%g?l~J2Gj?DfKTk5xvti=JF?mPP6>HXxp}JORlD7xm4fcu(H3u=%Q3uae~_q +z!>GQgHoFY+7O2kCpFhbZ>eV`21VVt%g4F +zZCyJKEWGzlRjy?5ro^2YlO8-X+?$n}mpRq{#jnp{C3bsNZ?#qnUO1bZb!)znu9Ys6 +z!-m`1+RSIZ=lxK-!hB+Hk=3X5^F=2aPF|xwzv%C-`!_n*rS4vOdL=Kr$Yj2mpS2R= +ztmN8WGOrWd$r1NK^5A`D-}!I(Yk00l2i6Gk>^A){|Kf!U0{gX|E)-#K^7QnKn?GOI +zdB*kvhG&zt683+evhc2U5XXH!8TJ3xp;uo-hV#B(w=L0s<0Y|K2{U4O9 +zazj*8|mTYSCBj_$w>r>J{`^ +zRuMaQ#i`{Lb;lRaKkzlp=lkdVJ#25cuU@t4N%158n7s5ZJ7fXGLv8a^Fs#6RyZ)ZHL$-rb5{juiUjn535 +zUGIke*~58acil9vOUZxw_z(-a)ki-zZwzewHtE#e +zoBqDEa)XYny~_uW?o}H+mH#< +zzrKEZdprMsv1iII3@o2MpY9UXt!Fq;o^tNGzplt^i$`NgBO*r?Eb_a6%7J-_!>_e0&ohVtlOqa{;&Wp~$^s1}cI~poyCWP%>P_I3ubag13yRm4)vNuviM}yWf +zX>EU$UC?#wKuDo-Teq21)+mp&(+>D;s?#hUk11sU14F#owfM^oS2^vWLp +zZHjIdeIkq;oF=B`<>#9p#j|X1Wl)oS=6;LsgJ?*dsl9#r*@qFQ{82|tP + +literal 11506 +zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4mJh`hQoG=rx_Sn)@Fu8ltlRYSS9D@>LsS+ +zC#C9D0Vi&D~Tl`=|73as??%gf94%8m8%i_-NCEiEne +z4UF`SjC6}q(sYX}^GXscbn}XpK}JB#a7isrF3Kz@$;{7F0GXMXlwVq6tE9xGpr8OX +zJhdVdVR~LM)ceW#x%wa(eM3D1ePrb&nQ1tbgJdw27i8p@;mZj#ED1$=+6fRZ) +znH8xy5iXgzsd>ej`FXZVhDH`>vXQuCo%3_@i-HRhlT&S#48fW}VQ1x;o0y&&o|#gT +zfiMjr;ggz|jv)hbT}~+|dPDOvOKg>l5T->S%?vHg%yflFkfa`*S_t7mxH*|1)d~s<;Fz{bOa=v#tx`czeoAR_Y6{qLxNLELT1k0gQL1Bl +zYF>%0l4Dw0W?p7VrGl}ZfgU7C;Y!OhQuFY6N+03~8+}lAgSp8@AH&rU1xWc5lQd{;l%)?{_bK8n!bw@X@jL-2a&Jre-hg +zm=beZwXiwve0G-iw>jsE?VeZ9d;W6H^9Iv7VMbMV1yVLu>OT2y^-P(&uPt4tIU@Wqa%Wk-w7*PXZA)%;;r%~jPm +zZ<@RxMNi+yXs{#wte8f-90LObXMsm#F#`j)FbFd;%$g&?z@Wh3>EaktG3RY<<&0R@ +zncvsH(kb7+{c^Nb_472v5}{3|oRJ(8!UPKsvd%p3?@}l&@b`zCNF&3hjxMeh7R`$m +zA9(Si;W1B_viIf|m6j!&1uxz7DF5cSeDb?}wcj^~)Yi`_@zDPsGWYg&>x2FGKYy95 +z{{7m@;O|$eSKWL1eIM(d?(&a%_m1wa5P$#ZzxxDZ+lGlvQS$vvD;S@fGOS_@X<97A +z;7}lQppfCX!-+r!_qEHW%`-N;A$a8eRE8B_KN=PMi!0dIVo)b1(JwqZTyQq)!5!Q0 +zdvQMJI$O|M(lANP@1(RsiY`axy{xR4a?9o$JgRW#ZitfSZ~DBt^XxhmvDyAU)gnI( +z?oVNe3Es8C%e=7Fj?-!0hFMWYhik4U9=7&lxbo|V$c;C0Nt?b0U0%l`|4vf8LBPP* +z@|@`}8>iWuR)ia*0{cQ98VY<|mf@i_^A4u&zn3WYZC2E*^Isy)tzcO3wNTcq +z+UMk~aNgH){tQCb9F?s+T-bYGDI}i#`IU{?Tr$D1;4quN)Q0}F1I5=i@Uk;FaBgS5 +zCBNW8_L^m4$rbrw66rn+S6C;nUugL{{m2&QrEjKB`@PVw&vB+qYm{_zlys|+9?yy^ +z!S*^6*{x?a-ksN0tHD})oHN4O`e1RHW;^#f&CF-v>o=QivkSb`r)u~Jy7@3%`8hd%q2%MZLp!!_{b8b!eE7ru{bKvtIu``) +zopM;(C?cKjXK4F`zs1})Ha@aFJV)qQ)33GHmR);4y`X~C@D0cVnj7Ct&o~wnmm_~` +zjuy-MIec&TtlE4eudwm##QLWKM*eTxY7HVw%+lb)sS=UexsyeZ(xO*z7A>aL8YIgiE87wz++?;} +z=+~dD2@}k|d}MrJ$-Q!ZuIs<2&G*(_o?^nCae2+MY1=08u&9bKY_*eHYPor;MA^+s`Y^cb5BYoE6ok^HNoi +zA@|%8U6br{6Z7oXOI@6OUO&t8XZ`u*x_@t)j$ +zUGe`X#|^@V)1<1Kk0!o-yY-rBHo(1w^{}$_ai#Rh-*N35JwKHTF?Pn>&+n}#{w?NC;9c#$<;j9jMwh%*?P1{$-#T?Ke7^6iftk$ItaG8W +zk{xTEHuf!Aw4Gt8(WaSF+onuEzr939>HZ4#6@Q-0j<%W*vGZ8v>zg;dnDuz(ZJbbl +z(eU$@nWCSX{5ijHA3JcThG$Lip0k3-LIq+D-V){)H$JzB#bIvz?+KF(^`~d9KD)}L +zvLT{x%;Y`KKF2FwbFcbr_m{gc5N|g*wn5! +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 +z<33s2@9Tlp3t65gf_RMVOnz-K?Qk}QM +zyqAVg6?!wn;}R^Lpwq&u0<5PcvWA29xCpuZ_hf_ +zX?}d~hwx{M{%rIX{L?ez&cP}k#r(z}s$mU1E_IDPcDM8Ue&3lnd0{y3WTwUsZwuw- +zScsf1=KCHmQp2eFA=X0frhT>ccJDu2Uw$rLs4vI-_s=XlN&A1_eVv|_Ti^Mf-pX+{ +zcBQVvvZ$^zpOp#B`49J_4aN*V^kypDRcBzaGh%(Z|1X2mcAb{@ +zk2skx?e%bI-G9(Fwl_oQ{ge#TmOESutWLi&mi}d3u;WF0c!bZ2D;K&NwlE*PbC2`) +ziXHnJtk(Ki&AqcdiODH`hOSKW|HFmfAB7uD)($9}>-I=3C|BdW09ZzpazaJL6qHtST1RkA^p<02z&K1V11}sfF +z-{sWuCN?!GL_%QF#^c=oG9FC`a-Kh*HR|QvqR=d-j=Q@>I`j8(&D*~4TByVP$5VQO +zAE#bgnpSbn>HOZ;F(>6$v1l$0VM_3xGV`g|eakTR8qWv2rf*pM=&9FS9WjR0riwah +zh7vo2B;_4{oPO@PF+cYAJORg>hqdQ41-Z@(p77_Pn>zc&w{}8DL>`JcaAfpWa3`p< +zFgdX8dy=)Et$OFGh8eBjgLlU|-JJB``)QT#jLljXwI>|a`L5(qd5%lr^KDs!xbo$D +zCobU1EnB)lKRQ4)W+h*PhsNYLJASV^70Sx+>&C&qycc3)mabzupfX8Rq;rziwRw5R +z^GX#BHInCeMV-1)aPjot0)_q0IE?BG_&XRI_GR=GJi765%?6jAGoS9?o-V!P^P?}4 +z74?i3M@)}8UDJ6nb3z;=OEl;C$PH1|EqC_5xa-|kqTswT@$GBJR||cE)TaB)+O8;g +zVUi%jysa(meg(dUj9)y{-e}M3J=^nBBz!&B>oc697DAVn+zU(IyF=;Q1hx7O_nmw% +z^fDDH-&?Jmd^EE(pli8;GK10Awl%h~hwh)QIaOz3_I;x$`x<|r@Vn>NJ}o%4mSX{@ +zk1NAGe&0#0wHB>M-e%9$SrpW0In#dA%o7J!pA&qoCDy$2=-iJi3^VV)ce?(y_k`Wf +z-4BfN-wI7Gn6WPJ-T#{h1cNs9a_@;ZYMokp`e^B&H-|M7XA0{-sFZ$reAdOkSH%&twd86@_!7ChY@c~|Uf3KYEOcD$!35Wo(`Rq7Pxi3C*K;m=zU!lFS5MgP +z-|t^@K!k(gx@f~B#*?DWZ@;dd8Q=QI>DL57hT2444;OJYk0>dnf{wao^We7u?Ii!JUmx!UC<9bnf5#J +z+{M4X{=E7j{@ub2j}nqCCprCkp1(tG^Q3=Y%Pth{4=D|tXCkxcMRu-%x%}snCDIH( +z^rZAdbuQn!sp*@h$&z>D#Qv&?RUWLLSE@B!|5a~SFiCp$=A5HDvmH0=S+MN1WzSk! +zC*AA&lMXFla#+p8QJc7J`K_PKZ3#C{{7V$yRA1#ClkzboGEC|-?<E4aue8vxoOU +z=fU*4-ORuKWVP(8xpzUkj>%R23VU>^-`qg%lmI-E?zP# +z3t(>$XaBu!0>hg0I^BbsofDl{g>>5ftaAPk|8Ac5tNa-psYxjwr)%qfg!wRZzVxWe +z-}^j;Z`ujwr75i1EE8voJIz)<{`dU;A`h+GD?=VkY52L7rGdA(*8Ydpom&S5)^`cd +z)?c9KCEzXfVgLGTr3@-d9H*XO{J@o(Bk}7_mc;8@dt`ZHoi42Cdyy;d5$eq-6TA5N +zyV3@S^p~-L8LkX(B9^U~?CE#c_zg36M*rfCKlii$-uJ;xX8o&YTz}_G(#tuv@tjP} +z>Eq3APx~`8RWn=meD-pnQH}t~$CPMXS`)Z_D@Z2kskO{CxOfTko6w@10&X +z_A+0ZRe7!H<`usCp9+IIg_k$GmHZIvcm44A@`Cg7yiu|YI`3QzyJI9O%H(>Jc0Oh?>far%zuArTfa%jn6SMzE*3LaM`EQ@>75z{l +z%LzF+Yjqs?NHC|Lc^D9*TXUmjteLsnl +zqc&-o9aw(=&L +z%rlqX&3ai@B*y+bFP?u_e$C4(6F(X)NVRjxk5-WtKWTGO=ZkX0IhkArgB9=h+dp}C +z=N99U)qGA1=d1M`K5L!v)`Ic=QQ;+9%4_{Z-yGJw{-pokhTE4;*lw3MS->S6vZukV +zTBUi|%6$&vjD%f7Vrbf1jcFvZ23XicsOH8>h5t +zZPFJ%_bZ>%AxSYKO^T~-b|-r6X2Et!GGqprT$Im>j +z*7ly%Eo`y4XFA(kbjCaWc_s;KvYp?pU+Xs`Jb2FH92+iX*{AN`{NHJ-pO4@RUeUoY +zB}mxm%etfU)a<|isY|KbetB!J?4?BazYH6Mde7E>HBr;=Wn}ocikHFe;Ge9ileThI +zb36?B`AJtw`R(=V&q5lH)$r|I&C$+#-a7BWpVRCUYnO?B+cz)xo`cl2mX-I8EI$x& +zo$XraFQuSgf3l9ei+lC*gyrX6K9%|lPhL4an{{dTm-h@GBDa)POgi)E+Ud4ksSI(7 +z%Y46no3i@l@*d8GZw_|b9_2b?-mm=ifm*ow#B_n@JoZMX*rhHxIL#71^UzOIYMRl( +z%W7|wS1!_O*?rgiL+4A5*x2VUGjF#~I=6ChUZ9^-LJwbaHWzb&pw#Seal<`I7X!+r +z8>**nVcdGTcF~q%ZGpYj8`Y=losabRpCnzR|HghXyE8BAmNV@iz7{V%7h)%?GtvFg +zvd}||#KS5#Tz|HoxA*LKQR8EeIx9b}GG&-n5Ia-Ji1+44oi*nEps`(nlSlj(fo2^X +z<_579-!5F4GiNg!SL6Rr7K^s)eTvyO4>St++cfdoM%j;F-~B$%xPx!4tm|GiXXANN +zuU0YUFdDd7Pi&KJ7bxh`cX=Q6=%FO7HxC^ZCs6_ICoZ7jx9)%zEw=vUfxOvWvyPqaJQL|3~Z0 +zDFcTC3~L;cKWn?*{h-noC$?^Dvizgaw7vplb +z2D^HbKksL<+&I(OUcKe8H<#0b`SHyk!k;O8*%=x1G=Z(TR-yCA+SRRkdF!+dRv+41 +z)3|=Y#-A%eGi6>6?5#W7cI{@kP$ +z)C=zHJ#%%UIHn2TJpHe7eZibF%dNKUGyd7nutTo*tm@CwrNRsh87qPr{;)UnHx_58 +z8x=ej_;P4_j?5wRGrFAXH;BJ+;d^$@FT&b7QQoJi^yz_JpZww+6pwQi?rA*L!f~Zj +zP~)+3fSTI^zrLv@Io~$&OuBzxp>!P+cShf#=Q_XZSqem@W_vGVm>R~wuwdai{s)>2 +z)0rD?&1{t3x<`D=o&F2r>E5ANC4?;4y(Rn-T$d{BVpP+~s*NdDE123zJgJBaS=$ +z5Nvo|Z66d=xuSE<^ANqIYEPyl@2vPU!H+{<>dwQLlG*&q|D$Ga@OaD+z@+`GUH0AK +zsXrteW_zuDx%=UnjEHm7K5#RnGC1V$sZN|~B+lMo@wIvDOwkLkv=*FOQ*guKFvITv +zp=UG(rHP1SRI7#Q~UEnd&GL->H^I+KkOQ$plS +za(5K*pLZ+pI^bmWX2bgbN4_)c;6C8ret6?!(e!qPFN+qg<2k^~kjL`jTi^qk1(w2I +zw;0aOOxJhqvDjB_y~4Ux^0(bxVFo#t55^{D)7RO&5-2R=bkLW2b%Fvh<`^%jRH)s)sXr=eI4^ +z(3AdP%f2GB@rg`g0ZY=1<1RA&vY%Hzs1x-G=C(b0wV>w5f3rzxbHnG{Wh$_edbR9; +zVB2a&AIUAe&lSX&4JA8Ln`iJ`*)(?-TU;rJ*BqPbP{!*TKUUVSjArfM%OKZo{8IN` +zUPao6bAk}#{gJIUgkh?X42UczNT&T_VzJ~Ed +zf18!8gUkWTzyDS}U|Fohz$N%IsDR1PlR>uN+;z*p>sfoxs+Rf9iTv}Ivw=tIl}3Yr +rpPTwapNd`o3$@-qiv4r<@qfnmHv3HzZ%*h3Emih(^>bP0l+XkKDvcjx + +diff --git a/apps/app/icons/128x128@2x.png b/apps/app/icons/128x128@2x.png +index bc9847e1ba9cb421519789e87fea7d5c27547b67..836c373178318edf70bd0517e7c32555672ecd44 100644 +GIT binary patch +literal 23098 +zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%YuY)RhkE)4%caKYZ?lNlHk +z7(87ZLn`LHnaf@ief8_{`tZxwK5Toc*#Bk%C#QCHmdnIH|4DT{eMoj^~VWo)L+_p%EjBM$-TF= +z?X7;d^ZC5b);y1UGTxL`@4CN!<^%TxrpHp!$u|5BcFgCWI_T|Cn{N1VZ)0D&gW2EP +zzc=svZj{GXQC)xk(r5kmX%4~?*-0|KbM~=IS*G_U?1|&{UFs)p6A&Bw*7MP2d3C>t +zJ>nATf@iMZ&N-LKFrS%sw&;_Cojhxp9*8n*R{d`KfUiOJ!Goz%%PbodwlUs#Rv_|* +z&Fsgd>iowpNp~2QJm|S6n{eEyuQlmF@fo(mJYUW|Q0CCLuwzzkKCrE*ZL6DZy!|`F +zC1Q`+6i$kW_-rt|wzt9fY>mM-jyd{l6WT6z&S#81@Imo;O|#$O`>WTkwVm>2w$$k! +z>4qbVPwVSh#|iW3F&C(@KkNPYJ<;CcUK+=~ysfjs-rU{2-TJ1v$ux=6@_Sk)1_vcw +zXEk|V{6L1Su4?~-+ZN0(84g=B#LHdS^xrHr{CdxRhgU{<3?Ykl?65d5pYpsZa~-32 +zbKK%<$-O~M58foR{Yz3f5SNp)W|i+Z-d?w3ybVtr-*acJPk9i|uwd(-9}A{CPhegx +zd4szu_e1NfSyHzq$+$B;W^-VwbWQzidhkJS!VWfvT|L=-!qf8DzDZR)kZfQ4ZSK4) +zt%XUccNmU%)YZ8vZuyrjSzz06(r`cX<0FUT80x=gH`QmXTf0`OUUPB!9R?TAy26i! +zItzaCNt`%)UAgr_+EVA~)A^duGR3ar+%=~p!-s8yiR_j%TN5#5lgucC#d=SE-jQ~= +z@YZ?5W$yi}cR#P*F5dVp?}v=+2lJ9c;rIIzrps%#@yq;wWc9DRZPu)_C-=>qcw`4- +zfJa^7%_NrQa-#=(QugNgtZv+Qf+@Muj7NA=qR#o2jY`sN)odk|Y6g4F6u%2*d_VO5 +zO~dwmviF#EUszqZ_4)ljF_x&wb0p8)Z0Bj5Zz;2NeZM}xl5rkGoBQ#@{mcS4 +zD>$29ni%+%)Vh|XN9s!rqzr-&8D9_gl6<4xO(V|TUR;cIMiNTtW-^Yqv#&{RsMvX>I+AAp2utn7hWfB +z7S(?&b+-1wjMcBW*I!kr-NyTjRds`oPHSeL?$h-0NS2t`g7vE%kKf8FS+ln3d%)~h +z{U;BW8$C!&uRU9=bUZ=k;10$Ys{2pWnLYj?*(_R>!xCv{su&_g +zMI(>t{r_%RAkDD&&FjljcCY`uJP~CkdaLJR)ScyecFU#L=D%?}eR}@E=%uHO4jz}7 +z_oYm-?$V`A@qbG_xQxVj88(aFT>o5#KS*f)8SdA)Vkxl}vNumJI~*`g^Yf)->2nX` +z;=8AXmvfzWXtiNcXa8O2Tzhr?@mtp(&M;zNWnDUX!9#y$3+HF0zV70R-(Ojtu*h9> +zW1qD9U740?;daiKKSy7^5f>wTg(W{@&Dy)YhKC{y8LVcXtrJeKa0t`X<1LZ;urq!Z +z_vJfIlGA0>xiri!d_3rA@;7<)ji@Od><5&8?wra0?#4#v7b<;eH)d#Das0r)Y1>zm +zmsL+<423JN%P4Ppf6UWvO2L&6%XB)83s7jEQyn{9bm +z+33rcj0frQM$A92pKaEwuX(Us>2CGxiyx-@W>hC{Uvrn)zJYzt*Q?)quc<5w%w*{5 +z?mqp;@$7wuIWq6Jnxruo?KhCFynaUZ{&soAlDBy+k5`sv)UOYk=KjI(@hp8ynf7nV +zQw(46IMgMcd@8kJdFA?lwKg-WBE@A{Z*LcKnr=OBgR#)ZH#rV}7t0^m>)1780ymGj +z?`D%IV{YVv~Z^tBK3*s4i&*wTmmcOQa +zL9l%Pq_rHIyyu)-RV(qZr-FGxe1?5EvyFKFX@4e3R&%-TJ^J#@%jEALmp>bjnsj$@ +z_JwcHo_~xx#mc+;T4eNB`JM!~qMwqd^|wrRV4qrfOlF$-d2P3!!p{{Sf9XuPC792D +zyp!Q-Qp!9_&yb|Dyj9K{l%K7wQ?^*QCBo>^UDZV{0cVtS6W>2i-l+XrUmb_+7CaJ%gjI3@%zd1r&Gl< +zOB??eCDg6CzF}Szww_JMPGC!6m8;%`&la>j`&vn1H|6tz!4^=b# +zxHC*IOvqc{$er63Sd(dVX~AjF=6h+frjJw>zPJ~hQR^RD7-TbnOmBbz-+K3ZB&(0b9m_R&}0`SbL* +zaIZ8|lHA4qH2@S?597jMrlSzonLD8TUSlH_FINIdf|BL^ECS +zd$Y=}-1|PyV1wbMbCT;aR;-Te6Fksiz+g7}uhuz-rL!*9uaa>7S9*NGgJrGC%YypZ +z_WZt_75K!-Q)kJJ)w2xRxGub&V`l1~ArmEbtM!~BubHWW^NXmR{wKOi^VW1$cl&3X +zD&#KX{USTx5Q^?tWy_+4-+Z-eH76VZ|dJ!PBOgF|f8Sz0dR`KS}b>v6Ge>zmHlbt(~>1qRjNb)<4BN!ej+@ +zRV24=S-JCsMY535`B^%X)=zO_^tr^8@#nPKLNODj2R$Ee%a)q%ck7z8iETl7|Nn;( +z=g!(xb9ue9^EKUWEcc?>dzIPR^>2mP64oTonbN!__40(;hb1qHoR$|C#0&WTztPlY +zpVO0`JJ07`;kEGl-6ATgybk-;KCwG?qkpk&f2Ksv_IVw9vise%y24HN?_iddmy-#6 +z{nO5XX#9h`lLuNmfsotJ0ITkZ=GVgHu|`vI?Eixn!ADFe|3Tw +zraAroQ{QcN%iq^nyKcA6OU=Dk&K&yrhHcBfZN)VY`WLq_Jbv>kcpe86viwtWUsPv>olpEq~PLI0JzwtinPRsP-G!fLOd +z4r9<+hJuB;3)Qzi^wxZAIkA84hl?Qr5k>mUc^ziudmf$SUSKFGB==->bqcS{{$Cu9 +z{a@OqJXV=u6K(f}&DJVLbHRMkX`Er@B8k+iz2QyqIZ#E2#e-`XBt>^1p*>yWay{Asv!1VJ^ +z&2JGtv8!?W_Z`ql`&Zzql-22FG5?py)cb$ld#tpOc2}IHqrK*)sBSsY{kt7joK@kH`f#;;AM5vJRy}SWOWRBSZ&5oecvOy$#cCJ7 +zef>Mxy+3(aUAKkYV&dtXu>Jan>D}Gk{vIx$C$>m1T-?Y$L)14~@AqMESKst3BMl&$)6WP2Wl3 +zD6fNlF_YsrmWK67OQai`lzvY?usHjAXvOc&4SIet<@2UZFI(_!;ZF?|_N3c!zL}-D-brJj+X{d8T=Yq-{ODoG@E{?6<4vz{3z^XWG8@?xmhadxh4o{F +z$j^b!{HwboyI1slMQHgthu^cjmS~xr +zo}4r7>lChqyH=~sv;Cy}IOT-3orTE!d;3%_omCU@*ASfj=B1dhjjZy5?dr~94cVFn +zby9D=`Zbsnw6*>!KL2wiL+RgA`{+p%?$v~>umAAuy6f*3A%_#rJA7+(SA6{^^MbgV +z9)nhNVSCdK*K!@EeY+z(=F9G?wXE5CGCJvuO<9?LkF1RQ+1ahVtFI@v6a?)%`|mEB +zd#TT-3Qt1@PWLbC8lcPzwL!r<5^^qr1496A)Stjw=pVil^TfJ~w+2l9T +zfyukBW?Iy%N%8(~%9mmP{3mGzujc!A|4*IPH2#q}O~t(O)$8jA{ym5^(BGiG)<5z3 +z^CDgbR@L7HYwt+Mzu#+d@l{Dg!8IkrAD+*u4_nY-mTS4qXf&5;v|pPtF^ +z^KKVgqIx3e9K(&SH`kdhVqbo|DK5XXY7@U_q5O`ISD!rnVyreR?m~~halO&g(`}2h +z^Cy2%^}AmjdU$t$*GCrXeSiK+xAXklw_g9xgKUjC0h6};etqw9%bw`I_JtvpwM@)@ +zs*HE17=gPZa~?Y`J%0IH*u(NGvCXWvc2#jzy_y{S%QNe!=zeSYZ@+gYEsQ60_2!+E*zfuB?w;?FwyQ)-jrJ0&t +zxn$1$s2N4C{}hzG&7P*0dT)P6_a{9e$q&(0SAYIE`uhBWilE3N2hUf3X8LrB`{Uo~ +z@&`S`^*l2YE`(qHeyiZg-8}}YmNGO%Yrc?sZ+ql#&Q`~>`cvQid|h-oaraY}6`h|u +zw-)T(E#Im3`p?T5U;aGxn7=OX)ViEqp_~kIkxVK|3?9G#sL!pps{45B@{X4a-}Aiw +z#xM8h;Jy17EyL}<=+?ZS>Td9U&ygpbEO)kiy){{2iRb0TAKaI_6z_5rK5%~WEY6J0 +zqVrES7Eh6jueja0itW&?d-vMg>~md|7CsLyIxl&`B3a2zX%ln97M4Z#>S|f;+FdRB +zoqlG1tMIfrJ*?hhyf>crmwotLcYNx(c^&I*>a3$=&ffU-%_=8)quA8h-xl1~-LL)e +z;q_(anisY#V3G7zJG?b%{UPlQ++1B=)2&{wTCv{k5HG`Hsk4h~&DXn}p0D&M%dztB +zRr@OIg}o}C>v~j9{jPuDE_ErmK|O_E!<(N-gX}o+v%e6OBXCHovlWexX@jhm?F1LDItytgwUp_yW>ny%qT~L;;D#dSg +zA?R|$wqI!#QPvMKiw{43#qwC@?z}vf#Tf$1JO>u^Xa9Sv-r;vCWX-aUc@KVfDW|D8 +zdN=$zH0iXawdcu=Um7)j9n@U4bZ3y*?S=36Xg1Y%9@@2nM|l438W;Y$(4dCjJO6JK +z;Q7L*AT{R^H^--h^9P>DFv5b=&5xatx2QeznReU2xg#%Z9c0baY}iNgUU; +z4P5g5l+2&AUwd!t`xN}9qS}@(?~lwqzn|U=lhV0e&rN@}=9{7VD(0i +zuHz{TTwPx4n9|)fW0l2g{zj}|{rWUJzLbHp(NN^xK90)kDsCHGa<9C|Gr7UMfWOj& +z%|TOcJNxWwHCp@T?fqUj@pk>c-tdG&b2JNi|2ap0e{?rJ{_r=^*8(3_Ys(3pV&I5( +zTJCsIR(;X0Xa6<#Uh$c|_RFV;S-r=S7`8m{`6!oiFZ`Kj`1+U4o6fwd|0*cN_)=ub +zoocpC=X$H3yOuV|$?wvRez-Qr_4~pw#jj7_&wcxMogPcI^5;$54Nj5_vlwc$-k-SS +z@b|;l?;kGy59|ICcj|-n9^nNAAIf)FeYkbF{_rmGdv8lWUUknlL%SPhJ?*D(}%WC#;-v4)aug0vD +zaFZW%rjFn8CR_=0Pp!R?Ayc^Hx( +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 +zz7a3&b=JJ^F4w)qQA=3Zp1hK%&coWA4<%gX +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 +z!dj%5#z4!=HA<`L@Gsl +zuVT`F{vsm2`;4)=?9ZzgnnmJXpVIYjrN50jrgZA$}wO5Bm{olyD?)ot0A +zm5xvD7V*bEceL-Tei0Mid@R16$@Ju&nzS>4|2FK3te&;xnTEiz8EX4JG|&I=E^LqQ +z&8s2Nzbotc8;lr5JTI5zO@6&=X5ZBNr%pXDSzO%EeR8tVkH@JWOskmEI*Wg%N*PEw +zl-`floRzSmb)k~?iHZDmUuMi_e*bg%=IPpcYMgVoZ_>N3WAbC_9Qo7A-m%+QEa!aR$G`s4i;|vz)rPMtCoetwtM>k- +z+G^EshHw10w(j5MUX#hKyJ3#CF)mkmRHsnz8yciV&9+pEA~CQpYiwf +zv=f=1{?5zzZnpf>(@=#ECk2=%WSXrD7UEb^>V4gAr{%?K0yE2uGAe%>i^l#duy;K# +zQ2u}G-5d1}?Js;<|2F9WzvaH${i~vB@0Z`~E9YPE^JZ^=^x-2q5&AQ< +z&OWvGnQ&OwafV7%&F|=opJw|9*50#!xF?tOPX1f=_xfKXA35K4J^1LRC%j*JK +z+vL|eUA?q_@yB;pKdiB1k(2o4v)f#o_uOaym23;%FLz|h|KqGW{lyQLcU5y2^ljU; +z@Rh^$=<5DIO0LYcPu^L}|74gN&R_Ccb0HT{of?HDf8$1yp-4)mpH3?H;uk! +zw^+=-^Y?oB;)fgklk~dZ-Mig$>-Mz6|DG5ve7O9>#HBm`i12?}v)=jms=o#M<26HH +zS_-KwpZEXpf1>vPM34BEf0^AsjQq~G7OwNH^kwC5GiT6w@q5DN +z6Sf~SK1>$eFeiX3a>;oOfzu3&lX7XFD@B7(y{uX%>9JE)W^PF$# +zil6tqrr7LKDOsI)^1Skmx9-nxN$m)i7m=#-GJTOUx%hM8g{dx@daE-yPh9-=C_U(? +z{gp-4;gf#^air_~@Qh%7P+&2KwYAJkXvWGENrlS{UtV4}|6}*>-iMIG@dc-yQs$cL +zK258A6?@^wm*p8t8Rj2ZBJur|mxbIvhE{t!%QuUUf0ogBc46Z)g*&VH5B`0qxN(n{ +zT4r(1i9I%_jwvo=s&#(wXlLo4@VzBZmmJ)%b>F(W15t`o`2R0cV*XGl`XP7a_VzW! +z%Z{ZooM1io`<2a;`0_IkpL(9+shfCaGdu78e-W#=g6eO@{p0z?WL%U#=l=hR?6u2f +z>TB6=h06+TuDO2f*92>!lMP$Ma=*{NrvFl%sf&4k%%A;#m&PXb*KIjibWWhr%bt9wK~?|#^d+*ueBEj@m)&4{h(9vZp4n?_v3kzE6Yyp{Bw7zZo$7d%sYfP +zEX%2R6`Wyoc|vV!P9cAMskPO$kgmV^=f&Ke7X1i3DDmmNrR&bu>n}Wg-<3qiEUQ&GMDk<#a#GO)UP%bHyc*fp7Qk)5|nVJ_?z? +z4z6DPY=54ka;2%(vjhCg9$w^Mc-p^R)o0s-1daI%YM<6^S$~5&KR(6M_2jQt7Y*h| +z>0N#L-v4&-?MY&n10=(f_r6t5c%UM3sa;D$r&2UQ++|nNFCA$$+wz%5s#%!k+P$%R +zeO}O%N#n-U+NU}<{^cv0WV$m=UupCs(>Lz_0jVEzib_SMI(aMnp8UM?oSpMKeF2f% +zr6w;jm#J@^J+a{>i#3;gwA4=9$SHdD^|pVV#s6w6-k$FHAT_=A>(b_W$uxQY%`y)J +zc^lH(gIBP=RyE;$Tdi%Pwl~A&)+eE(O3ulaJDFVXN8j1}XTqEt!ukgt1g`%7Zok8R +zrIXRZxwB^7p0@anzxY7|Mehpbcl@g_Y+NNYH=D2PPV>Z;k1K3$$OT?_YT-3~zrAk# +zJ)L(7}7FT?K`lBv$1;^8cd#C>^KJjZZTg}6A +z@xxVK`IiE^cK)dixUlBg?);TezodMhOFq70Q?hPt-=aSzTQA&RtMT)2>xE~n*DmxQ +zfBL@p$;nlEb%jUvxe0Qx{f(+letb|dGELFg-9|5b_st|*?gYW$H5qCrcdC4|d*^(g +zk1w{&YS%W)mSgqvchCQP^rl8_iRPshiOY{f+JDP#v7i6y(n{UZ->bYV^IT;DOrpPR +z_MXU5x8=nrAC0{GyfHc3;0c|Ihr@0blMORr;(wwfN`n|7MTpXlm?vJw@VHX1{U8hu5c# +zRCoM&`2SVicZNIfPv(Zs_cAzkxbNwPor}|g&nYhByyRDZ#NU9kDxxoZo`}hv1Md$9 +zOk(`}C+SJNeSFEim-P`^=YB1$^RDIZ5siFNvA@VAW$IF)EhUPGqlNv)Rru +zyY6_d_M~|?mMAeE`+I%&-FFiv-rLZ;d*Q-O^O&=z*f^c8FW0=r5n%L#wddfT19eXX +z=Kb1Sw}O46-prOs?$f&-JhM0@oi^pq!_qCZ`1{}p(iMsAoB +z(%8q(*L#Y`_-1si>wZi2TXRZY2L3x(l3Tp!<;4Dw&dW2V`?(xEyj=P7s+$HrCmFx9 +z3M^PA%lVB(GM#_t-F3c(k_~HoUNK#M*n5;r8C;O0T^WcWHae{CUK2?qb39 +z-!B%0q>FBdvyq+lxy?*{p_)Zzaqfn=GLbXsz9Ofc>U+8O1g~A|{;j6i_v+`vvVtoc +zWWtKwxFvR*JxVTgNWLJ)$9By6wR_>Q4v9a^vlhxk>-^%H`rhItbGk%DZk9JI$LwEvI77F}r}nc|#+v5cPy1}A$Dg#FFv

+- +
+ +
+diff --git a/apps/app-frontend/src/helpers/analytics.ts b/apps/app-frontend/src/helpers/analytics.ts +index b3d2c0a..c9f4846 100644 +--- a/apps/app-frontend/src/helpers/analytics.ts ++++ b/apps/app-frontend/src/helpers/analytics.ts +@@ -1,4 +1,16 @@ +-import type { PostHog } from 'posthog-js' ++/** ++ * Modrinth Enhanced does not collect analytics. ++ * ++ * Upstream this module lazily loads `posthog-js` on the first interaction and ++ * sends every event below to `posthog.modrinth.com`. Here the whole module is ++ * inert: nothing is loaded, nothing is queued and nothing is sent, which also ++ * keeps the PostHog SDK out of the bundle entirely rather than merely leaving ++ * it switched off. ++ * ++ * The event map and the exported functions are kept exactly as upstream ++ * declares them so that every `trackEvent` call site still type checks and ++ * needs no patch of its own. ++ */ + + interface InstanceProperties { + loader: string +@@ -43,72 +55,13 @@ type AnalyticsEventMap = { + + export type AnalyticsEvent = keyof AnalyticsEventMap + +-let analytics: PostHog | undefined +-let pending: Promise | undefined +-let enabled = false +-let activated = false +-let debug = false +-let explicitlyOptedIn = false +-const events: Array<{ name: AnalyticsEvent; properties: Record | undefined }> = [] +-const allowed = import.meta.env.PROD || import.meta.env.VITE_ENABLE_ANALYTICS === 'true' ++export const initAnalytics = () => {} + +-function removeActivationListeners() { +- window.removeEventListener('pointerdown', activate) +- window.removeEventListener('keydown', activate) +-} +- +-function activate() { +- activated = true +- removeActivationListeners() +- if (!enabled || pending || analytics) return +- pending = import('posthog-js') +- .then(({ posthog }) => { +- if (!enabled) return +- posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', { +- persistence: 'localStorage', +- api_host: 'https://posthog.modrinth.com', +- }) +- analytics = posthog +- if (explicitlyOptedIn) posthog.opt_in_capturing() +- if (debug) posthog.debug() +- for (const event of events.splice(0)) posthog.capture(event.name, event.properties) +- }) +- .catch(() => { +- events.length = 0 +- }) +- .finally(() => { +- pending = undefined +- }) +-} ++export const debugAnalytics = () => {} + +-export const initAnalytics = () => { +- if (!allowed || enabled) return +- enabled = true +- if (activated) activate() +- else { +- window.addEventListener('pointerdown', activate, { once: true, passive: true }) +- window.addEventListener('keydown', activate, { once: true }) +- } +-} ++export const optOutAnalytics = () => {} + +-export const debugAnalytics = () => { +- debug = true +- analytics?.debug() +-} +- +-export const optOutAnalytics = () => { +- explicitlyOptedIn = false +- enabled = false +- events.length = 0 +- removeActivationListeners() +- analytics?.opt_out_capturing() +-} +- +-export const optInAnalytics = () => { +- explicitlyOptedIn = true +- initAnalytics() +- analytics?.opt_in_capturing() +-} ++export const optInAnalytics = () => {} + + type OptionalArgs = Record extends T ? [properties?: T] : [properties: T] + +@@ -116,10 +69,6 @@ export const trackEvent = ( + eventName: E, + ...args: OptionalArgs + ) => { +- if (!enabled) return +- if (analytics) analytics.capture(eventName, args[0]) +- else { +- if (events.length >= 100) events.shift() +- events.push({ name: eventName, properties: args[0] }) +- } ++ void eventName ++ void args + } +diff --git a/apps/app-frontend/src/helpers/error-reporting.ts b/apps/app-frontend/src/helpers/error-reporting.ts +index bb20aa9..46a9749 100644 +--- a/apps/app-frontend/src/helpers/error-reporting.ts ++++ b/apps/app-frontend/src/helpers/error-reporting.ts +@@ -1,66 +1,16 @@ + import type { App } from 'vue' + import type { Router } from 'vue-router' + ++/** ++ * Modrinth Enhanced does not send crash reports. ++ * ++ * Upstream this installs a Vue error handler plus `error` and ++ * `unhandledrejection` listeners that lazily load `@sentry/vue` and ship ++ * exceptions — including breadcrumbs and route traces — to Sentry. This ++ * build keeps the local behaviour of the upstream development path instead: ++ * errors are logged to the console and go nowhere else. ++ */ + export function setupErrorReporting(app: App, router: Router): void { +- if (!import.meta.env.PROD) return +- +- const previousHandler = app.config.errorHandler +- let pending: Promise | undefined +- let queuedErrors = 0 +- +- function removeListeners() { +- window.removeEventListener('pointerdown', activate) +- window.removeEventListener('keydown', activate) +- window.removeEventListener('error', onError) +- window.removeEventListener('unhandledrejection', onRejection) +- } +- +- function load() { +- pending ??= import('@sentry/vue').then((sentry) => { +- app.config.errorHandler = previousHandler +- sentry.init({ +- app, +- dsn: 'https://9508775ee5034536bc70433f5f531dd4@o485889.ingest.us.sentry.io/4504579615227904', +- integrations: [sentry.browserTracingIntegration({ router })], +- tracesSampleRate: 0.1, +- }) +- removeListeners() +- return sentry +- }) +- return pending +- } +- +- function capture(error: unknown) { +- if (queuedErrors >= 20) return +- queuedErrors++ +- void load() +- .then((sentry) => sentry.captureException(error)) +- .catch(() => {}) +- .finally(() => { +- queuedErrors-- +- }) +- } +- +- function onError(event: ErrorEvent) { +- capture(event.error ?? event.message) +- } +- +- function onRejection(event: PromiseRejectionEvent) { +- capture(event.reason) +- } +- +- function activate() { +- void load().catch(() => {}) +- } +- +- app.config.errorHandler = (error, instance, info) => { +- if (previousHandler) previousHandler(error, instance, info) +- else console.error(error) +- capture(error) +- } +- window.addEventListener('error', onError) +- window.addEventListener('unhandledrejection', onRejection) +- window.addEventListener('pointerdown', activate, { once: true, passive: true }) +- window.addEventListener('keydown', activate, { once: true }) +- app.onUnmount(removeListeners) ++ void app ++ void router + } +diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json +index faab078..233cac2 100644 +--- a/apps/app-frontend/src/locales/en-US/index.json ++++ b/apps/app-frontend/src/locales/en-US/index.json +@@ -2667,7 +2667,7 @@ + "message": "Discord Rich Presence" + }, + "app.settings.privacy.telemetry.description": { +- "message": "Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected." ++ "message": "Modrinth Enhanced collects no analytics or usage data and sends no crash reports, so there is nothing here to turn off." + }, + "app.settings.privacy.telemetry.title": { + "message": "Telemetry" +diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json +index e69e6b7..e5bbabe 100644 +--- a/apps/app/tauri.conf.json ++++ b/apps/app/tauri.conf.json +@@ -102,12 +102,12 @@ + "capabilities": ["ads", "core", "plugins"], + "csp": { + "default-src": "'self' customprotocol: asset:", +- "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com https://app.getsentry.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:", ++ "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:", + "font-src": ["https://cdn.modrinth.com/fonts/", "https://js.intercomcdn.com"], + "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", + "style-src": "'unsafe-inline' 'self'", +- "script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://tally.so/widgets/embed.js 'self'", +- "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'", ++ "script-src": "https://js.stripe.com https://widget.intercom.io https://js.intercomcdn.com 'self'", ++ "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'", + "media-src": "https://*.githubusercontent.com" + } + } +diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs +index 25626c1..d638128 100644 +--- a/packages/app-lib/src/api/instance/run.rs ++++ b/packages/app-lib/src/api/instance/run.rs +@@ -4,13 +4,10 @@ use crate::state::{ + Credentials, InstanceLink, ProcessMetadata, Settings, State, + game_options_sync_is_enabled, load_game_option_preferences, + }; +-use crate::util::fetch; + use crate::util::io::IOError; + use serde_json::json; + use std::collections::HashMap; +-use std::time::Duration; + use tokio::process::Command; +-use tracing::{info, warn}; + + #[derive(Debug, Clone)] + pub enum QuickPlayType { +@@ -232,54 +229,10 @@ async fn run_credentials( + mc_set_options.push(("fullscreen".to_string(), "true".to_string())); + } + +- if let Some(project_id) = server_play_project_id(&context.link) +- && !project_id.trim().is_empty() +- { +- let server_id = uuid::Uuid::new_v4().to_string(); +- let join_result = fetch::INSECURE_REQWEST_CLIENT +- .post("https://sessionserver.mojang.com/session/minecraft/join") +- .json(&json!({ +- "accessToken": &credentials.access_token, +- "selectedProfile": credentials.offline_profile.id.simple().to_string(), +- "serverId": &server_id, +- })) +- .timeout(Duration::from_secs(5)) +- .send() +- .await; +- +- match join_result { +- Ok(resp) if resp.status().is_success() => { +- let result = fetch::post_json( +- concat!( +- env!("MODRINTH_API_BASE_URL"), +- "analytics/minecraft-server-play" +- ), +- json!({ +- "project_id": project_id, +- "username": &credentials.offline_profile.name, +- "server_id": &server_id, +- }), +- &state.api_semaphore, +- &state.pool, +- ) +- .await; +- +- match result { +- Ok(()) => { +- info!( +- "Tracked server play for '{project_id}' in analytics" +- ) +- } +- Err(err) => warn!("Failed to report server play: {err:?}"), +- } +- } +- Ok(resp) => warn!( +- "Failed to join Mojang session server: HTTP {}", +- resp.status() +- ), +- Err(err) => warn!("Failed to join Mojang session server: {err:?}"), +- } +- } ++ // Modrinth Enhanced does not report server plays. Upstream authenticates ++ // that report by handshaking with Mojang's session server and then sends ++ // the player's Minecraft username to Modrinth's analytics endpoint; ++ // neither request is made here. + + crate::minecraft_skins::flush_pending_skin_change().await?; + crate::launcher::launch_minecraft( +@@ -297,21 +250,6 @@ async fn run_credentials( + .await + } + +-fn server_play_project_id(link: &InstanceLink) -> Option<&String> { +- match link { +- InstanceLink::ServerProject { project_id } +- | InstanceLink::ServerProjectModpack { +- server_project_id: project_id, +- .. +- } => Some(project_id), +- InstanceLink::Unmanaged +- | InstanceLink::ModrinthModpack { .. } +- | InstanceLink::ModrinthHosting { .. } +- | InstanceLink::ImportedModpack { .. } +- | InstanceLink::SharedInstance { .. } => None, +- } +-} +- + pub async fn kill(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let processes = +@@ -373,13 +311,12 @@ pub async fn try_update_playtime_by_instance_id( + } + } + +- fetch::post_json( +- concat!(env!("MODRINTH_API_BASE_URL"), "analytics/playtime"), +- serde_json::to_value(hashmap)?, +- &state.api_semaphore, +- &state.pool, +- ) +- .await ++ // Modrinth Enhanced does not report playtime to Modrinth's analytics ++ // endpoint. The payload is still assembled and then dropped so that ++ // the bookkeeping below, which clears the instance's recent playtime ++ // counter, keeps behaving exactly like it does upstream. ++ drop(hashmap); ++ Ok(()) + } else { + Ok(()) + }; diff --git a/patches/0005-Add-offline-accounts.patch b/patches/0005-Add-offline-accounts.patch new file mode 100644 index 0000000..194f915 --- /dev/null +++ b/patches/0005-Add-offline-accounts.patch @@ -0,0 +1,487 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Mon, 14 Sep 2026 10:27:34 +0200 +Subject: [PATCH] Add offline accounts + +Adds a second way to add a Minecraft account that never talks to +Microsoft or Mojang, for playing singleplayer worlds and servers running +in offline mode. + +An offline account is an ordinary row in `minecraft_users`, marked by a +sentinel in the refresh token column, so no database migration is +needed. `Credentials::refresh` and the online profile lookup both return +early for such an account, which keeps every existing code path - +launching, account switching, serialisation to the frontend - working +without further changes. + +The player UUID is derived the way Minecraft itself derives it, as an +MD5 name UUID over `OfflinePlayer:`. That is what vanilla servers +in offline mode and other launchers use, so worlds keep the same player +data when they are opened elsewhere. + +Usernames are validated the way Mojang validates them: 3 to 16 +characters of letters, numbers and underscores. +--- + Cargo.toml | 1 + + .../src/components/ui/AccountsCard.vue | 25 ++++ + .../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++ + apps/app-frontend/src/helpers/auth.js | 13 ++ + apps/app/src/api/auth.rs | 7 + + packages/app-lib/Cargo.toml | 1 + + packages/app-lib/src/api/minecraft_auth.rs | 39 +++++ + packages/app-lib/src/state/minecraft_auth.rs | 68 +++++++++ + 8 files changed, 290 insertions(+) + create mode 100644 apps/app-frontend/src/components/ui/OfflineAccountModal.vue + +diff --git a/Cargo.toml b/Cargo.toml +index a4a779c..a85f576 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -132,6 +132,7 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [ + "std", + ] } + maxminddb = "0.26.0" ++md-5 = "0.10.6" + modrinth-content-management = { path = "packages/modrinth-content-management" } + modrinth-log = { path = "packages/modrinth-log" } + modrinth-util = { path = "packages/modrinth-util" } +diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue +index 70cc46e..e695a6d 100644 +--- a/apps/app-frontend/src/components/ui/AccountsCard.vue ++++ b/apps/app-frontend/src/components/ui/AccountsCard.vue +@@ -9,6 +9,10 @@ + + {{ formatMessage(messages.signInToMinecraft) }} + ++ +
+ + {{ formatMessage(messages.addAccount) }} + ++ +
+ + ++ + + + +diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js +index 94bd13e..cb7319a 100644 +--- a/apps/app-frontend/src/helpers/auth.js ++++ b/apps/app-frontend/src/helpers/auth.js +@@ -33,6 +33,19 @@ export async function login() { + return await invoke('plugin:auth|login') + } + ++/** ++ * Adds an offline account with the given username and makes it the active one. ++ * ++ * Offline accounts never contact Microsoft or Mojang. They can play ++ * singleplayer worlds and join servers running in offline mode. ++ * ++ * @param {string} username ++ * @returns {Promise} ++ */ ++export async function login_offline(username) { ++ return await invoke('plugin:auth|login_offline', { username }) ++} ++ + /** + * Retrieves the default user + * @return {Promise} +diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs +index 8227d94..f4eded6 100644 +--- a/apps/app/src/api/auth.rs ++++ b/apps/app/src/api/auth.rs +@@ -9,6 +9,7 @@ pub fn init() -> TauriPlugin { + .invoke_handler(tauri::generate_handler![ + check_reachable, + login, ++ login_offline, + remove_user, + get_default_user, + set_default_user, +@@ -86,6 +87,12 @@ pub async fn login( + Ok(None) + } + ++/// Adds an offline account with the given username and makes it active. ++#[tauri::command] ++pub async fn login_offline(username: String) -> Result { ++ Ok(minecraft_auth::login_offline(&username).await?) ++} ++ + #[tauri::command] + pub async fn remove_user(user: uuid::Uuid) -> Result<()> { + Ok(minecraft_auth::remove_user(user).await?) +diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml +index 69545c2..7da46c9 100644 +--- a/packages/app-lib/Cargo.toml ++++ b/packages/app-lib/Cargo.toml +@@ -48,6 +48,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] } + indicatif = { workspace = true, optional = true } + itertools = { workspace = true } + json5 = { workspace = true } ++md-5 = { workspace = true } + modrinth-content-management = { workspace = true } + notify = { workspace = true } + notify-debouncer-mini = { workspace = true } +diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs +index e7195c6..a7fac4a 100644 +--- a/packages/app-lib/src/api/minecraft_auth.rs ++++ b/packages/app-lib/src/api/minecraft_auth.rs +@@ -47,6 +47,45 @@ pub async fn finish_login( + Ok(credentials) + } + ++/// Creates an offline account for `username`, or reuses the existing one, and ++/// makes it the active account. ++/// ++/// Offline accounts never contact Microsoft or Mojang. They are enough to play ++/// singleplayer worlds and to join servers running in offline mode, and they ++/// are refused by servers in online mode, exactly like offline accounts in ++/// other launchers. ++#[tracing::instrument] ++pub async fn login_offline(username: &str) -> crate::Result { ++ let username = username.trim(); ++ ++ if !(3..=16).contains(&username.len()) ++ || !username ++ .bytes() ++ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') ++ { ++ return Err(crate::ErrorKind::InputError( ++ "An offline username must be 3 to 16 characters long and may only \ ++ contain letters, numbers and underscores" ++ .to_string(), ++ ) ++ .into()); ++ } ++ ++ let state = State::get().await?; ++ let credentials = Credentials::offline(username); ++ credentials.upsert(&state.pool).await?; ++ ++ if let Err(error) = ++ crate::onboarding_checklist::mark_logged_into_minecraft().await ++ { ++ tracing::warn!( ++ "Failed to mark Minecraft login in onboarding checklist: {error}" ++ ); ++ } ++ ++ Ok(credentials) ++} ++ + #[tracing::instrument] + pub async fn get_default_user() -> crate::Result> { + let state = State::get().await?; +diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs +index b835ad4..d97d233 100644 +--- a/packages/app-lib/src/state/minecraft_auth.rs ++++ b/packages/app-lib/src/state/minecraft_auth.rs +@@ -6,6 +6,7 @@ use chrono::{DateTime, Duration, TimeZone, Utc}; + use dashmap::DashMap; + use futures::TryStreamExt; + use heck::ToTitleCase; ++use md5::Md5; + use p256::ecdsa::signature::Signer; + use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; + use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; +@@ -212,6 +213,34 @@ pub struct Credentials { + pub active: bool, + } + ++/// Access token handed to Minecraft for offline accounts. ++/// ++/// The game only uses this token to talk to Mojang's session server, which an ++/// offline account never does, so the value just has to be non-empty. `0` is ++/// what launchers have traditionally used for offline play. ++const OFFLINE_ACCESS_TOKEN: &str = "0"; ++ ++/// Marker stored in an offline account's refresh token column. ++/// ++/// Microsoft refresh tokens are opaque base64, so this value cannot collide ++/// with a real one, and reusing an existing column means offline accounts need ++/// no database migration. ++const OFFLINE_REFRESH_TOKEN: &str = "modrinth-enhanced:offline-account"; ++ ++/// Computes the player UUID Minecraft itself derives for an offline player. ++/// ++/// This mirrors Java's `UUID.nameUUIDFromBytes("OfflinePlayer:")`, which ++/// is what vanilla servers in offline mode and other launchers use. Matching it ++/// means a world played here keeps the same player data when it is opened from ++/// somewhere else. ++pub fn offline_uuid(username: &str) -> Uuid { ++ let mut bytes: [u8; 16] = ++ Md5::digest(format!("OfflinePlayer:{username}").as_bytes()).into(); ++ bytes[6] = (bytes[6] & 0x0f) | 0x30; // Version 3 ++ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant ++ Uuid::from_bytes(bytes) ++} ++ + /// An entry in the player profile cache, keyed by player UUID. + pub(super) enum ProfileCacheEntry { + /// A cached profile that is valid, even though it may be stale. +@@ -265,12 +294,45 @@ impl OnlineProfileCacheIntent { + } + + impl Credentials { ++ /// Builds credentials for an offline account with the given username. ++ /// ++ /// Offline accounts hold no Microsoft tokens and have no Mojang profile ++ /// behind them; they exist so the launcher can start the game for ++ /// singleplayer worlds and offline-mode servers without signing in. ++ pub fn offline(username: &str) -> Self { ++ Self { ++ offline_profile: MinecraftProfile { ++ id: offline_uuid(username), ++ name: username.to_owned(), ++ ..MinecraftProfile::default() ++ }, ++ access_token: OFFLINE_ACCESS_TOKEN.to_owned(), ++ refresh_token: OFFLINE_REFRESH_TOKEN.to_owned(), ++ // There is nothing to expire. `refresh` returns early for offline ++ // accounts, and a far future date keeps every other expiry check ++ // from doing anything surprising. ++ expires: Utc::now() + Duration::days(365 * 100), ++ active: true, ++ } ++ } ++ ++ /// Whether these credentials belong to an offline account. ++ pub fn is_offline(&self) -> bool { ++ self.refresh_token == OFFLINE_REFRESH_TOKEN ++ } ++ + /// Refreshes the authentication tokens for this user if they are expired, or + /// very close to expiration. + async fn refresh( + &mut self, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, + ) -> crate::Result<()> { ++ // Offline accounts have no tokens to refresh and nothing to ask ++ // Microsoft about. ++ if self.is_offline() { ++ return Ok(()); ++ } ++ + // Use a margin of 5 minutes to give e.g. Minecraft and potentially + // other operations that depend on a fresh token 5 minutes to complete + // from now, and deal with some classes of clock skew +@@ -351,6 +413,12 @@ impl Credentials { + &self, + cache_intent: OnlineProfileCacheIntent, + ) -> Option> { ++ // Offline accounts have no Mojang profile, so skip the request that ++ // would only ever fail and fall back to the offline profile. ++ if self.is_offline() { ++ return None; ++ } ++ + let max_age = cache_intent.max_age(); + let stale_profile = { + let mut profile_cache = PROFILE_CACHE.lock().await; diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..81b992a --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Build Modrinth Enhanced from the patched checkout and collect the installers. +# +# Expects scripts/prepare.sh to have run. Finished bundles are copied to +# build/artifacts. + +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +require_worktree + +ARTIFACTS="${ARTIFACTS:-$REPO_ROOT/build/artifacts}" + +# Upstream keeps the API endpoints out of the sources and picks one at build +# time; production is the only sensible choice for a release build. +log "Selecting the production environment" +cp "$WORKTREE/packages/app-lib/.env.prod" "$WORKTREE/packages/app-lib/.env" + +"$REPO_ROOT/scripts/set-version.sh" + +log "Installing JavaScript dependencies" +(cd "$WORKTREE" && pnpm install --frozen-lockfile) + +tauri_args=() +case "$(uname -s)" in +MINGW* | MSYS* | CYGWIN* | Windows_NT) + platform=windows + tauri_args+=(--bundles nsis) + ;; +Darwin) + platform=macos + tauri_args+=(--target universal-apple-darwin) + ;; +*) + platform=linux + ;; +esac + +log "Building for $platform" +(cd "$WORKTREE" && pnpm --filter=@modrinth/app run tauri build "${tauri_args[@]}") + +log "Collecting bundles into $ARTIFACTS" +rm -rf "$ARTIFACTS" +mkdir -p "$ARTIFACTS" + +if [ "$platform" = macos ]; then + bundle_dir="$WORKTREE/target/universal-apple-darwin/release/bundle" +else + bundle_dir="$WORKTREE/target/release/bundle" +fi + +found=0 +while IFS= read -r -d '' artifact; do + cp "$artifact" "$ARTIFACTS/" + found=1 +done < <(find "$bundle_dir" -maxdepth 2 -type f \ + \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ + -o -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*-setup.exe' \) -print0) + +[ "$found" = 1 ] || die "No bundles were produced under $bundle_dir" + +ls -la "$ARTIFACTS" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..ae21dd4 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Verify that the patched checkout still is Modrinth Enhanced. +# +# A patch can apply cleanly and still stop doing its job - upstream may move +# the thing it was holding down. These checks assert the outcome rather than +# the diff: the app is named correctly, offline accounts exist, and no +# telemetry endpoint survives into the built frontend. +# +# Checks that need the built frontend are skipped when it is not there yet, so +# this is useful both before and after scripts/build.sh. + +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +require_worktree + +failures=0 + +check() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then + printf ' \033[1;32mok\033[0m %s\n' "$description" + else + printf ' \033[1;31mFAIL\033[0m %s\n' "$description" + failures=$((failures + 1)) + fi +} + +contains() { + grep -qF "$2" "$1" +} + +missing() { + ! grep -qrF "$2" "$1" +} + +log "Branding" +check "tauri.conf.json is named Modrinth Enhanced" \ + contains "$WORKTREE/apps/app/tauri.conf.json" '"productName": "Modrinth Enhanced"' +check "the window is titled Modrinth Enhanced" \ + contains "$WORKTREE/apps/app/tauri.conf.json" '"title": "Modrinth Enhanced"' +check "the icon set was replaced" \ + test -f "$WORKTREE/apps/app/icons/modrinth-enhanced.svg" + +log "Offline accounts" +check "app-lib exposes login_offline" \ + contains "$WORKTREE/packages/app-lib/src/api/minecraft_auth.rs" 'pub async fn login_offline' +check "the Tauri command is registered" \ + contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_offline,' +check "the frontend can reach it" \ + contains "$WORKTREE/apps/app-frontend/src/helpers/auth.js" "plugin:auth|login_offline" + +log "No telemetry in the sources" +# Quoted, so that the module names being mentioned in a comment explaining why +# they are gone does not count as importing them. +check "nothing imports posthog-js" \ + missing "$WORKTREE/apps/app-frontend/src" "'posthog-js'" +check "nothing imports @sentry/vue" \ + missing "$WORKTREE/apps/app-frontend/src" "'@sentry/vue'" +check "the Tally embed is gone" \ + missing "$WORKTREE/apps/app-frontend/index.html" "tally.so" +check "playtime is not reported" \ + missing "$WORKTREE/packages/app-lib/src/api/instance/run.rs" "analytics/playtime" +check "server plays are not reported" \ + missing "$WORKTREE/packages/app-lib/src/api/instance/run.rs" "analytics/minecraft-server-play" +check "the CSP does not allow PostHog" \ + missing "$WORKTREE/apps/app/tauri.conf.json" "posthog" +check "the CSP does not allow Sentry" \ + missing "$WORKTREE/apps/app/tauri.conf.json" "sentry.io" + +dist="$WORKTREE/apps/app-frontend/dist" +if [ -d "$dist" ]; then + log "No telemetry in the built frontend" + check "no PostHog endpoint in the bundle" missing "$dist" "posthog.modrinth.com" + check "no Sentry endpoint in the bundle" missing "$dist" "ingest.us.sentry.io" + check "no Tally embed in the bundle" missing "$dist" "tally.so" +else + warn "Skipping bundle checks: $dist does not exist yet" +fi + +artifacts="${ARTIFACTS:-$REPO_ROOT/build/artifacts}" +if [ -d "$artifacts" ] && [ -n "$(ls -A "$artifacts" 2>/dev/null)" ]; then + log "Installers" + for artifact in "$artifacts"/*; do + name="$(basename "$artifact")" + case "$name" in + "Modrinth Enhanced"* | ModrinthEnhanced* | "Modrinth_Enhanced"*) + printf ' \033[1;32mok\033[0m %s\n' "$name" + ;; + *) + printf ' \033[1;31mFAIL\033[0m %s is not named after Modrinth Enhanced\n' "$name" + failures=$((failures + 1)) + ;; + esac + done +fi + +if [ "$failures" -gt 0 ]; then + die "$failures check(s) failed" +fi + +log "All checks passed" diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..d85a6c3 --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Shared settings for every script in this repository. +# +# Source this, do not run it. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Where the Modrinth App sources come from, and which release of them the +# patches in this repository are written against. +UPSTREAM_REPO="${UPSTREAM_REPO:-https://github.com/modrinth/code.git}" +UPSTREAM_REF="${UPSTREAM_REF:-$(tr -d '[:space:]' <"$REPO_ROOT/upstream.txt")}" + +# The patched checkout. Everything in here is disposable: it is recreated from +# the upstream tag plus patches/ and is never committed to this repository. +WORKTREE="${WORKTREE:-$REPO_ROOT/build/upstream}" + +# Branch the patches are applied on top of the upstream tag as. +PATCH_BRANCH=enhanced + +PATCH_DIR="$REPO_ROOT/patches" + +log() { + printf '\033[1;32m==>\033[0m %s\n' "$*" +} + +warn() { + printf '\033[1;33m==>\033[0m %s\n' "$*" >&2 +} + +die() { + printf '\033[1;31m==>\033[0m %s\n' "$*" >&2 + exit 1 +} + +# Version the built app reports, derived from the upstream tag it is built +# from: `v0.20.5` becomes `0.20.5`. +app_version() { + printf '%s\n' "${UPSTREAM_REF#v}" +} + +require_worktree() { + [ -d "$WORKTREE/.git" ] || die "No patched checkout at $WORKTREE. Run scripts/prepare.sh first." +} diff --git a/scripts/export-patches.sh b/scripts/export-patches.sh new file mode 100755 index 0000000..ac8d29a --- /dev/null +++ b/scripts/export-patches.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Regenerate patches/ from the commits in build/upstream. +# +# Run this after changing anything in the patched checkout: every commit on top +# of the upstream tag becomes one patch file, and patch files that no longer +# have a commit behind them are deleted. + +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +require_worktree + +if [ -n "$(git -C "$WORKTREE" status --porcelain)" ]; then + die "$WORKTREE has uncommitted changes. Commit them first: each commit becomes one patch." +fi + +count="$(git -C "$WORKTREE" rev-list --count "$UPSTREAM_REF..HEAD")" +[ "$count" -gt 0 ] || die "No commits on top of $UPSTREAM_REF to export." + +log "Exporting $count patches" +rm -f "$PATCH_DIR"/*.patch +git -C "$WORKTREE" format-patch \ + --binary \ + --zero-commit \ + --no-signature \ + --no-numbered \ + --output-directory "$PATCH_DIR" \ + "$UPSTREAM_REF..HEAD" + +log "patches/ now contains:" +ls -1 "$PATCH_DIR" diff --git a/scripts/latest-upstream.sh b/scripts/latest-upstream.sh new file mode 100755 index 0000000..5bc99d0 --- /dev/null +++ b/scripts/latest-upstream.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Print the newest Modrinth App release tag upstream has published. +# +# With --write, also store it in upstream.txt. Exits with status 0 either way; +# compare the output with upstream.txt to find out whether anything moved. + +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +latest="$( + git ls-remote --tags --refs "$UPSTREAM_REPO" 'v*' | + sed 's#.*refs/tags/##' | + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | + sort -V | + tail -1 +)" + +[ -n "$latest" ] || die "Could not determine the latest upstream release tag" + +if [ "${1:-}" = --write ]; then + printf '%s\n' "$latest" >"$REPO_ROOT/upstream.txt" +fi + +printf '%s\n' "$latest" diff --git a/scripts/prepare.sh b/scripts/prepare.sh new file mode 100755 index 0000000..6ed1ee1 --- /dev/null +++ b/scripts/prepare.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Check out the pinned Modrinth App release and apply every patch on top of it. +# +# The result lands in build/upstream on the `enhanced` branch and is what all +# other scripts build from. Running this again always starts from a clean +# upstream tree, so it is safe to repeat. + +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +log "Upstream $UPSTREAM_REF from $UPSTREAM_REPO" + +if [ ! -d "$WORKTREE/.git" ]; then + log "Cloning into $WORKTREE" + mkdir -p "$(dirname "$WORKTREE")" + git clone --depth 1 --branch "$UPSTREAM_REF" "$UPSTREAM_REPO" "$WORKTREE" +else + log "Fetching $UPSTREAM_REF into the existing checkout" + git -C "$WORKTREE" remote set-url origin "$UPSTREAM_REPO" + git -C "$WORKTREE" fetch --depth 1 --force origin "refs/tags/$UPSTREAM_REF:refs/tags/$UPSTREAM_REF" +fi + +# `git am` refuses to run with a rebase or merge in progress, and a previous +# run may have stopped on a conflict. +git -C "$WORKTREE" am --abort 2>/dev/null || true + +log "Resetting to $UPSTREAM_REF" +git -C "$WORKTREE" checkout --detach --force "$UPSTREAM_REF" +git -C "$WORKTREE" branch -f "$PATCH_BRANCH" "$UPSTREAM_REF" +git -C "$WORKTREE" checkout --force "$PATCH_BRANCH" +git -C "$WORKTREE" reset --hard "$UPSTREAM_REF" +git -C "$WORKTREE" clean -fdx -e node_modules -e target + +# `git am` needs an identity for the commits it creates. +git -C "$WORKTREE" config user.name "Modrinth Enhanced" +git -C "$WORKTREE" config user.email "patches@modrinth-enhanced.invalid" +git -C "$WORKTREE" config commit.gpgsign false + +shopt -s nullglob +patches=("$PATCH_DIR"/*.patch) +shopt -u nullglob + +[ ${#patches[@]} -gt 0 ] || die "No patches found in $PATCH_DIR" + +log "Applying ${#patches[@]} patches" +if ! git -C "$WORKTREE" am --3way --whitespace=nowarn "${patches[@]}"; then + cat >&2 <