Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25fa10a368 | ||
|
|
b4bd102b17 | ||
|
|
7cad7a32dd | ||
|
|
d408edea98 | ||
|
|
5b2210a8a7 | ||
|
|
8c079e43c9 | ||
|
|
84a86743b2 | ||
|
|
ad33779356 | ||
|
|
f41e1e178f | ||
|
|
0c722c21d4 | ||
|
|
e112c1dc1e | ||
|
|
10a85e6bc5 | ||
|
|
cafa40decf |
No files matched your search
@@ -1,88 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
version=$1
|
|
||||||
here=$(pwd)
|
|
||||||
root=${PACKAGES_ROOT:-/var/www/packages.arasaka.software}
|
|
||||||
keep=${FDROID_KEEP:-3}
|
|
||||||
url=https://packages.arasaka.software/fdroid/repo
|
|
||||||
fingerprint=5F52CFF4F52F4E0B6DA6CD044BFCEAF78D1D42DB24F2470602DB7F19CBFDF846
|
|
||||||
id=software.arasaka.meweb
|
|
||||||
apk=$here/releases/$version/MeWeb-$version-android.apk
|
|
||||||
test -f "$apk"
|
|
||||||
if [ -z "${MEWEB_KEYSTORE_B64:-}" ] || [ -z "${MEWEB_KEYSTORE_PASS:-}" ]; then
|
|
||||||
echo "::error::the repository secrets MEWEB_KEYSTORE_B64 and MEWEB_KEYSTORE_PASS are not set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
major=$(echo "$version" | cut -d. -f1)
|
|
||||||
minor=$(echo "$version" | cut -d. -f2)
|
|
||||||
patch=$(echo "$version" | cut -d. -f3)
|
|
||||||
code=$((major * 10000 + minor * 100 + patch))
|
|
||||||
|
|
||||||
tools/setup.sh --group fdroid
|
|
||||||
tools=$(cd "$here/tools/fdroid/linux-x64" && pwd -P)
|
|
||||||
pinned=$(sed -n 's/^fdroidserver==\([^ ]*\).*/\1/p' packaging/fdroid/requirements.txt)
|
|
||||||
venv=$tools/fdroidserver-$pinned
|
|
||||||
if ! "$venv/bin/fdroid" --version > /dev/null 2>&1; then
|
|
||||||
rm -rf "$venv"
|
|
||||||
UV_CACHE_DIR=$tools/cache UV_PYTHON_INSTALL_DIR=$tools/python "$tools/uv-x86_64-unknown-linux-gnu/uv" venv -q --python 3.12 "$venv"
|
|
||||||
UV_CACHE_DIR=$tools/cache "$tools/uv-x86_64-unknown-linux-gnu/uv" pip install -q --python "$venv/bin/python" --require-hashes -r packaging/fdroid/requirements.txt
|
|
||||||
fi
|
|
||||||
JAVA_HOME=$(ls -d "$here"/tools/jdk/linux-x64/*/ | head -n 1)
|
|
||||||
ANDROID_HOME=$here/tools/android-sdk
|
|
||||||
PATH=$JAVA_HOME/bin:$PATH
|
|
||||||
export JAVA_HOME ANDROID_HOME PATH
|
|
||||||
|
|
||||||
exec 9>>"$root/.lock"
|
|
||||||
flock -w 2700 9
|
|
||||||
|
|
||||||
keys=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$keys"' EXIT
|
|
||||||
(umask 077 && printf '%s' "$MEWEB_KEYSTORE_B64" | base64 -d > "$keys/meweb.keystore")
|
|
||||||
MEWEB_KEYSTORE=$keys/meweb.keystore
|
|
||||||
export MEWEB_KEYSTORE MEWEB_KEYSTORE_PASS
|
|
||||||
unset MEWEB_KEYSTORE_B64
|
|
||||||
held=$(keytool -J-Duser.language=en -list -v -keystore "$MEWEB_KEYSTORE" -storepass:env MEWEB_KEYSTORE_PASS -alias meweb | sed -n 's/^.*SHA256: *//p' | tr -d ':')
|
|
||||||
test "$held" = "$fingerprint"
|
|
||||||
|
|
||||||
state=$root/.fdroid
|
|
||||||
snap=$state/$(date -u +%Y%m%d%H%M%S)-$version
|
|
||||||
mkdir -p "$snap/repo" "$snap/metadata" "$snap/tmp"
|
|
||||||
if [ -e "$root/fdroid/repo" ]; then
|
|
||||||
prior=$(readlink -f "$root/fdroid/repo")/..
|
|
||||||
for file in "$prior"/repo/*.apk; do
|
|
||||||
[ -e "$file" ] && ln "$file" "$snap/repo/"
|
|
||||||
done
|
|
||||||
cp -a "$prior/metadata/." "$snap/metadata/"
|
|
||||||
cp "$prior/repo/index-v2.json" "$snap/repo/"
|
|
||||||
if [ -f "$prior/tmp/apkcache.json" ]; then cp "$prior/tmp/apkcache.json" "$snap/tmp/"; fi
|
|
||||||
fi
|
|
||||||
cp packaging/fdroid/config.yml "$snap/config.yml"
|
|
||||||
chmod 600 "$snap/config.yml"
|
|
||||||
cp packaging/fdroid/icon.png "$snap/icon.png"
|
|
||||||
cp -r packaging/fdroid/metadata/. "$snap/metadata/"
|
|
||||||
rm -f "$snap/repo/${id}_$code.apk"
|
|
||||||
cp "$apk" "$snap/repo/${id}_$code.apk"
|
|
||||||
(cd "$snap/repo" && ls "$id"_*.apk | sed 's/.*_\([0-9]*\)\.apk$/\1/' | sort -n | head -n "-$keep" | while read -r old; do
|
|
||||||
rm -f "${id}_$old.apk"
|
|
||||||
done)
|
|
||||||
|
|
||||||
(cd "$snap" && "$venv/bin/fdroid" update)
|
|
||||||
rm -rf "$snap/repo/status"
|
|
||||||
chmod -R u=rwX,go=rX "$snap/repo"
|
|
||||||
|
|
||||||
mkdir -p "$root/fdroid"
|
|
||||||
ln -sfn "../.fdroid/$(basename "$snap")/repo" "$root/fdroid/repo.new"
|
|
||||||
mv -T -f "$root/fdroid/repo.new" "$root/fdroid/repo"
|
|
||||||
ls -1d "$state"/*/ | sort | head -n -3 | while read -r old; do
|
|
||||||
find "$old" -maxdepth 0 -mmin +1440 -exec rm -rf {} +
|
|
||||||
done
|
|
||||||
echo "published ${id}_$code.apk in $root/fdroid/repo"
|
|
||||||
|
|
||||||
if [ "$root" = /var/www/packages.arasaka.software ]; then
|
|
||||||
curl -fsS "$url/entry.jar" -o "$keys/entry.jar"
|
|
||||||
signed=$(keytool -J-Duser.language=en -printcert -jarfile "$keys/entry.jar" | sed -n 's/^.*SHA256: *//p' | head -n 1 | tr -d ':')
|
|
||||||
test "$signed" = "$fingerprint"
|
|
||||||
curl -fsS "$url/index-v2.json" | grep -Eq "\"versionCode\": ?$code\b"
|
|
||||||
curl -fsSI "$url/${id}_$code.apk" > /dev/null
|
|
||||||
echo "live at $url: $id $version ($code)"
|
|
||||||
fi
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
version=$1
|
|
||||||
releases=$(pwd)/releases/$version
|
|
||||||
root=${PACKAGES_ROOT:-/var/www/packages.arasaka.software}
|
|
||||||
keep=${PACKAGES_KEEP:-5}
|
|
||||||
url=https://packages.arasaka.software
|
|
||||||
fingerprint=F92C166E9B1C13EDD1629E76888DCF969D133FB2
|
|
||||||
deb=$releases/MeWeb-$version-linux-x64.deb
|
|
||||||
rpm=$releases/MeWeb-$version-linux-x64.rpm
|
|
||||||
test -f "$deb"
|
|
||||||
test -f "$rpm"
|
|
||||||
|
|
||||||
exec 9>>"$root/.lock"
|
|
||||||
flock -w 2700 9
|
|
||||||
|
|
||||||
keys=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$keys"' EXIT
|
|
||||||
export GNUPGHOME="$keys"
|
|
||||||
printf '%s\n' "$PACKAGES_SIGNING_KEY" | gpg --batch --quiet --import
|
|
||||||
gpg --batch --with-colons --list-secret-keys | grep -q "^fpr:::::::::$fingerprint:$"
|
|
||||||
gpg --batch --armor --export "$fingerprint" > "$keys/public.asc"
|
|
||||||
|
|
||||||
rpmsign --define "__gpg /usr/bin/gpg" --define "_gpg_name $fingerprint" --define "_gpg_path $keys" --addsign "$rpm" >/dev/null
|
|
||||||
rpmkeys --dbpath "$keys/rpmdb" --import "$keys/public.asc"
|
|
||||||
rpmkeys --dbpath "$keys/rpmdb" -K "$rpm" | grep -q 'digests signatures OK'
|
|
||||||
|
|
||||||
full=$(dpkg-deb -f "$deb" Version)
|
|
||||||
test "$(dpkg-deb -f "$deb" Package)" = meweb
|
|
||||||
test "${full%-*}" = "$version"
|
|
||||||
debname=meweb_${full}_$(dpkg-deb -f "$deb" Architecture).deb
|
|
||||||
rpmname=$(rpm -qp --nosignature --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}.rpm' "$rpm")
|
|
||||||
test "$(rpm -qp --nosignature --qf '%{NAME} %{VERSION}' "$rpm")" = "meweb $version"
|
|
||||||
|
|
||||||
snap=$root/.snap/$(date -u +%Y%m%d%H%M%S)-$version
|
|
||||||
mkdir -p "$snap/apt/pool/main" "$snap/rpm"
|
|
||||||
if [ -d "$root/apt/pool/main" ]; then cp -al "$root/apt/pool/main/." "$snap/apt/pool/main/"; fi
|
|
||||||
if [ -d "$root/rpm" ]; then
|
|
||||||
for folder in "$root"/rpm/*/; do
|
|
||||||
[ "$(basename "$folder")" = repodata ] || cp -al "$folder" "$snap/rpm/"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
mkdir -p "$snap/apt/pool/main/m/meweb" "$snap/rpm/meweb"
|
|
||||||
rm -f "$snap/apt/pool/main/m/meweb/$debname" "$snap/rpm/meweb/$rpmname"
|
|
||||||
cp "$deb" "$snap/apt/pool/main/m/meweb/$debname"
|
|
||||||
cp "$rpm" "$snap/rpm/meweb/$rpmname"
|
|
||||||
for folder in "$snap/apt/pool/main/m/meweb" "$snap/rpm/meweb"; do
|
|
||||||
ls "$folder" | sed -E 's/^meweb[_-]([0-9.]+)-.*/\1/' | sort -uV | head -n "-$keep" | while read -r old; do
|
|
||||||
rm -f "$folder/meweb_$old-"* "$folder/meweb-$old-"*
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
binary=$snap/apt/dists/stable/main/binary-amd64
|
|
||||||
mkdir -p "$binary"
|
|
||||||
(cd "$snap/apt" && apt-ftparchive packages pool) > "$binary/Packages"
|
|
||||||
gzip -9nc "$binary/Packages" > "$binary/Packages.gz"
|
|
||||||
(cd "$snap/apt/dists/stable" && apt-ftparchive \
|
|
||||||
-o APT::FTPArchive::Release::Origin=Arasaka -o APT::FTPArchive::Release::Label=Arasaka \
|
|
||||||
-o APT::FTPArchive::Release::Suite=stable -o APT::FTPArchive::Release::Codename=stable \
|
|
||||||
-o APT::FTPArchive::Release::Architectures=amd64 -o APT::FTPArchive::Release::Components=main \
|
|
||||||
-o APT::FTPArchive::Release::NotAutomatic=yes -o APT::FTPArchive::Release::ButAutomaticUpgrades=yes \
|
|
||||||
-o "APT::FTPArchive::Release::Description=Arasaka packages" release .) > "$keys/Release"
|
|
||||||
mv "$keys/Release" "$snap/apt/dists/stable/Release"
|
|
||||||
gpg --batch --yes --local-user "$fingerprint" --clearsign -o "$snap/apt/dists/stable/InRelease" "$snap/apt/dists/stable/Release"
|
|
||||||
gpg --batch --yes --local-user "$fingerprint" --armor --detach-sign -o "$snap/apt/dists/stable/Release.gpg" "$snap/apt/dists/stable/Release"
|
|
||||||
|
|
||||||
createrepo_c --quiet "$snap/rpm"
|
|
||||||
repodata=$snap/rpm/repodata
|
|
||||||
gpg --batch --yes --local-user "$fingerprint" --armor --detach-sign -o "$repodata/repomd.xml.asc" "$repodata/repomd.xml"
|
|
||||||
cp "$keys/public.asc" "$repodata/repomd.xml.key"
|
|
||||||
if [ -f "$root/rpm/repodata/repomd.xml" ]; then
|
|
||||||
grep -o 'repodata/[^"]*' "$root/rpm/repodata/repomd.xml" | while read -r href; do
|
|
||||||
[ -e "$snap/rpm/$href" ] || cp "$root/rpm/$href" "$snap/rpm/$href"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
chmod -R u=rwX,go=rX "$snap"
|
|
||||||
|
|
||||||
for part in apt rpm; do
|
|
||||||
ln -sfn ".snap/$(basename "$snap")/$part" "$root/$part.new"
|
|
||||||
mv -T -f "$root/$part.new" "$root/$part"
|
|
||||||
done
|
|
||||||
ls -1d "$root"/.snap/* | sort | head -n -3 | while read -r old; do
|
|
||||||
find "$old" -maxdepth 0 -mmin +1440 -exec rm -rf {} +
|
|
||||||
done
|
|
||||||
echo "published $debname and $rpmname in $root"
|
|
||||||
|
|
||||||
(cd "$releases" && for file in $(ls | LC_ALL=C sort); do [ "$file" = SHA256SUMS.txt ] || sha256sum "$file"; done) > "$keys/sums"
|
|
||||||
mv "$keys/sums" "$releases/SHA256SUMS.txt"
|
|
||||||
|
|
||||||
if [ "$root" = /var/www/packages.arasaka.software ]; then
|
|
||||||
curl -fsS "$url/apt/dists/stable/main/binary-amd64/Packages" | grep -qx "Version: $full"
|
|
||||||
curl -fsS "$url/apt/dists/stable/InRelease" > "$keys/InRelease"
|
|
||||||
gpg --batch --quiet --verify "$keys/InRelease"
|
|
||||||
curl -fsS "$url/rpm/repodata/repomd.xml" > "$keys/repomd.xml"
|
|
||||||
curl -fsS "$url/rpm/repodata/repomd.xml.asc" > "$keys/repomd.xml.asc"
|
|
||||||
gpg --batch --quiet --verify "$keys/repomd.xml.asc" "$keys/repomd.xml"
|
|
||||||
curl -fsSI "$url/rpm/meweb/$rpmname" > /dev/null
|
|
||||||
echo "live at $url: meweb $version"
|
|
||||||
fi
|
|
||||||
@@ -8,7 +8,7 @@ fi
|
|||||||
echo "VERSION=$version" >> "$GITHUB_ENV"
|
echo "VERSION=$version" >> "$GITHUB_ENV"
|
||||||
echo "COMMIT=$(echo "$GITHUB_SHA" | cut -c1-8)" >> "$GITHUB_ENV"
|
echo "COMMIT=$(echo "$GITHUB_SHA" | cut -c1-8)" >> "$GITHUB_ENV"
|
||||||
keep="$HOME/.cache/meweb"
|
keep="$HOME/.cache/meweb"
|
||||||
for dir in tools/cache tools/jdk tools/packaging tools/android-sdk tools/fdroid; do
|
for dir in tools/cache tools/jdk tools/packaging tools/android-sdk; do
|
||||||
mkdir -p "$keep/$dir"
|
mkdir -p "$keep/$dir"
|
||||||
rm -rf "$dir"
|
rm -rf "$dir"
|
||||||
ln -s "$keep/$dir" "$dir"
|
ln -s "$keep/$dir" "$dir"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
version=$1
|
||||||
|
releases=$(pwd)/releases/$version
|
||||||
|
rpm=$releases/MeWeb-$version-linux-x64.rpm
|
||||||
|
fingerprint=F92C166E9B1C13EDD1629E76888DCF969D133FB2
|
||||||
|
test -f "$rpm"
|
||||||
|
keys=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$keys"' EXIT
|
||||||
|
export GNUPGHOME="$keys"
|
||||||
|
printf '%s\n' "$PACKAGES_SIGNING_KEY" | gpg --batch --quiet --import
|
||||||
|
gpg --batch --with-colons --list-secret-keys | grep -q "^fpr:::::::::$fingerprint:$"
|
||||||
|
gpg --batch --armor --export "$fingerprint" > "$keys/public.asc"
|
||||||
|
rpmsign --define "__gpg /usr/bin/gpg" --define "_gpg_name $fingerprint" --define "_gpg_path $keys" --addsign "$rpm" > /dev/null
|
||||||
|
rpmkeys --dbpath "$keys/rpmdb" --import "$keys/public.asc"
|
||||||
|
rpmkeys --dbpath "$keys/rpmdb" -K "$rpm" | grep -q 'digests signatures OK'
|
||||||
|
(cd "$releases" && for file in $(ls | LC_ALL=C sort); do [ "$file" = SHA256SUMS.txt ] || sha256sum "$file"; done) > "$keys/sums"
|
||||||
|
mv "$keys/sums" "$releases/SHA256SUMS.txt"
|
||||||
|
echo "signed $(basename "$rpm") with $fingerprint"
|
||||||
@@ -22,6 +22,15 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: .gitea/scripts/prepare.sh
|
run: .gitea/scripts/prepare.sh
|
||||||
|
|
||||||
|
- name: Release notes from the commit messages since the previous tag
|
||||||
|
uses: https://git.thepra.dev/Arasaka/app-stores/changelog@v1
|
||||||
|
with:
|
||||||
|
prefix: v
|
||||||
|
name: me-web?
|
||||||
|
strip: '^["“]?me-web\?["”]? [\d.]+:\s*'
|
||||||
|
packager: Arasaka di Hvozdikov Yevheniy <https://arasaka.software/contact>
|
||||||
|
output: artifacts/notes
|
||||||
|
|
||||||
- name: The server
|
- name: The server
|
||||||
run: tools/release.sh server --commit "$COMMIT"
|
run: tools/release.sh server --commit "$COMMIT"
|
||||||
|
|
||||||
@@ -43,21 +52,17 @@ jobs:
|
|||||||
MEWEB_KEYSTORE_PASS: ${{ secrets.MEWEB_KEYSTORE_PASS }}
|
MEWEB_KEYSTORE_PASS: ${{ secrets.MEWEB_KEYSTORE_PASS }}
|
||||||
run: .gitea/scripts/android.sh
|
run: .gitea/scripts/android.sh
|
||||||
|
|
||||||
- name: The deb and rpm on packages.arasaka.software, the rpm signed
|
- name: The changelog beside the files
|
||||||
|
run: cp artifacts/notes/CHANGELOG.md "releases/$VERSION/"
|
||||||
|
|
||||||
|
- name: The rpm signed with the Arasaka packages key
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
env:
|
env:
|
||||||
PACKAGES_SIGNING_KEY: ${{ secrets.PACKAGES_SIGNING_KEY }}
|
PACKAGES_SIGNING_KEY: ${{ secrets.PACKAGES_SIGNING_KEY }}
|
||||||
run: .gitea/scripts/packages.sh "$VERSION"
|
run: .gitea/scripts/sign-rpm.sh "$VERSION"
|
||||||
|
|
||||||
- name: Publish the release
|
- name: Publish the release
|
||||||
if: ${{ github.ref_type == 'tag' || inputs.publish == 'true' }}
|
if: ${{ github.ref_type == 'tag' || inputs.publish == 'true' }}
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: .gitea/scripts/release.sh "v$VERSION" releases/"$VERSION"/*
|
run: .gitea/scripts/release.sh "v$VERSION" releases/"$VERSION"/*
|
||||||
|
|
||||||
- name: The APK in the F-Droid repository on packages.arasaka.software
|
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
|
||||||
env:
|
|
||||||
MEWEB_KEYSTORE_B64: ${{ secrets.MEWEB_KEYSTORE_B64 }}
|
|
||||||
MEWEB_KEYSTORE_PASS: ${{ secrets.MEWEB_KEYSTORE_PASS }}
|
|
||||||
run: .gitea/scripts/fdroid.sh "$VERSION"
|
|
||||||
@@ -17,7 +17,6 @@ appsettings.*.local.json
|
|||||||
/tools/packaging/
|
/tools/packaging/
|
||||||
/tools/android-sdk/
|
/tools/android-sdk/
|
||||||
/tools/cache/
|
/tools/cache/
|
||||||
/tools/fdroid/
|
|
||||||
/tools/logs/
|
/tools/logs/
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
/artifacts/
|
/artifacts/
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Read this first. **`PATTERNS.md` is canonical for how code is written** (cite "PATTERNS #n"); **`CLAUDE.md` is the
|
Read this first. **`PATTERNS.md` is canonical for how code is written** (cite "PATTERNS #n"); **`CLAUDE.md` is the
|
||||||
quick reference** (commands, map, pitfalls). When a tool in the repo enforces a rule (the compiler, `.editorconfig`,
|
quick reference** (commands, map, pitfalls). When a tool in the repo enforces a rule (the compiler, `.editorconfig`,
|
||||||
`tools/lint.cs`), the tool is the source of truth.
|
the house-rules analyzer), the tool is the source of truth.
|
||||||
|
|
||||||
## What MeWeb is
|
## What MeWeb is
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Free software under AGPL-3.0, published by Arasaka di Hvozdikov Yevheniy.
|
|||||||
- **No comments in source** — none, of any kind (#42).
|
- **No comments in source** — none, of any kind (#42).
|
||||||
- **No `private`**, no `_` prefixes, **`Nullable` disabled**, **2-space indent**, **`var` everywhere** (#92).
|
- **No `private`**, no `_` prefixes, **`Nullable` disabled**, **2-space indent**, **`var` everywhere** (#92).
|
||||||
- **Primary constructors**, `IOptionsMonitor<T>`, `CancellationToken` last and forwarded (#1, #87, #88).
|
- **Primary constructors**, `IOptionsMonitor<T>`, `CancellationToken` last and forwarded (#1, #87, #88).
|
||||||
- **Routes only in `src/MeWeb.Shared/Endpoints.cs`** (#9); DTOs are classes named `View*`, `Upsert*`, `*Form` (#34).
|
- **Routes only in `src/MeWeb.Shared/Endpoints.cs`** (#9); DTOs are classes named `View*`, `*Request`, `*Form` (#34).
|
||||||
- **UI text:** resx keys only, sentence case, every language present, drafts labelled (#39, #50, #125).
|
- **UI text:** resx keys only, sentence case, every language present, drafts labelled (#39, #50, #125).
|
||||||
- **Every outbound host is declared** in the app's "What this app contacts" screen (#120).
|
- **Every outbound host is declared** in the app's "What this app contacts" screen (#120).
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dotnet run tools/setup.cs -- --group packaging # pinned nfpm into to
|
|||||||
dotnet build src/MeWeb.Android/MeWeb.Android.csproj \
|
dotnet build src/MeWeb.Android/MeWeb.Android.csproj \
|
||||||
-p:JavaSdkDirectory=$PWD/tools/jdk/linux-x64/jdk-21.0.12+8 -p:AndroidSdkDirectory=$HOME/Android/Sdk
|
-p:JavaSdkDirectory=$PWD/tools/jdk/linux-x64/jdk-21.0.12+8 -p:AndroidSdkDirectory=$HOME/Android/Sdk
|
||||||
dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...]
|
dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...]
|
||||||
|
dotnet run --project tools/Screenshots -- --store # store set: fastlane/metadata/android/{en-US,it-IT}/images/phoneScreenshots
|
||||||
tools/release.sh notes # artifacts/release-notes.md alone, nothing built
|
tools/release.sh notes # artifacts/release-notes.md alone, nothing built
|
||||||
dotnet run tools/strings.cs -- check # every language against English
|
dotnet run tools/strings.cs -- check # every language against English
|
||||||
dotnet run tools/strings.cs -- set Key values.json # one key, every language at once
|
dotnet run tools/strings.cs -- set Key values.json # one key, every language at once
|
||||||
@@ -44,7 +45,8 @@ is root-owned, so CI uses the runner user's own `~/.dotnet` (passolo's `tools/re
|
|||||||
| `MEWEB_KEYSTORE` | release step | a temporary file in `$RUNNER_TEMP` decoded by `.gitea/scripts/android.sh`, removed on exit |
|
| `MEWEB_KEYSTORE` | release step | a temporary file in `$RUNNER_TEMP` decoded by `.gitea/scripts/android.sh`, removed on exit |
|
||||||
|
|
||||||
`build.yml` runs on every push to `master`; `release.yml` on a `v*` tag (publishes the Gitea release) or by hand
|
`build.yml` runs on every push to `master`; `release.yml` on a `v*` tag (publishes the Gitea release) or by hand
|
||||||
(`publish: false` builds every platform and deploys the server only). Both run on the Arasaka org runner `nuvola`,
|
(`publish: false` builds every platform and deploys the server only). Both run on `nuvola`, the instance-wide runner
|
||||||
|
on the box (since 1 Nov 2026 it also takes the jobs labelled `build`),
|
||||||
user `gitea-runner`, home `/var/lib/gitea-runner`: `tools/sdk.sh` installs the SDK from `global.json` into its
|
user `gitea-runner`, home `/var/lib/gitea-runner`: `tools/sdk.sh` installs the SDK from `global.json` into its
|
||||||
`~/.dotnet` (the system SDK in `/opt/dotnet` is older and root-owned), and the JDK, nfpm and the Android SDK are
|
`~/.dotnet` (the system SDK in `/opt/dotnet` is older and root-owned), and the JDK, nfpm and the Android SDK are
|
||||||
cached in `~/.cache/meweb`. There is deliberately no `pull_request` trigger: the runner is the production box.
|
cached in `~/.cache/meweb`. There is deliberately no `pull_request` trigger: the runner is the production box.
|
||||||
@@ -75,7 +77,7 @@ The Android release key: alias `meweb`, certificate SHA-256
|
|||||||
| `src/MeWeb.Server/KnowledgeFeeder.cs` | refreshes the open network data under `/var/lib/meweb/knowledge` every six hours (iptoasn daily, DB-IP monthly, eight RouteViews RIBs weekly through `bzcat` (one or two collectors per continent, `Collectors`; each counts once per network in `UpstreamTally`, and a path's first AS, the collector's own neighbour, is never taken for an upstream), the AOSP carrier list weekly, the address registries' block files weekly (`Registries`: RIPE's and APNIC's `inetnum` and `inet6num` split files, AFRINIC's whole dump, LACNIC's `lacnic.db`)), builds `ip2asn.bin`, `networks.bin`, `carriers.json`, `registry.bin` (every block of the four, `RegistryBuilder`) and `SOURCES.txt` with `MeWeb.Core/Knowledge/Sources.cs`, and serves them at `/api/knowledge` |
|
| `src/MeWeb.Server/KnowledgeFeeder.cs` | refreshes the open network data under `/var/lib/meweb/knowledge` every six hours (iptoasn daily, DB-IP monthly, eight RouteViews RIBs weekly through `bzcat` (one or two collectors per continent, `Collectors`; each counts once per network in `UpstreamTally`, and a path's first AS, the collector's own neighbour, is never taken for an upstream), the AOSP carrier list weekly, the address registries' block files weekly (`Registries`: RIPE's and APNIC's `inetnum` and `inet6num` split files, AFRINIC's whole dump, LACNIC's `lacnic.db`)), builds `ip2asn.bin`, `networks.bin`, `carriers.json`, `registry.bin` (every block of the four, `RegistryBuilder`) and `SOURCES.txt` with `MeWeb.Core/Knowledge/Sources.cs`, and serves them at `/api/knowledge` |
|
||||||
| `tools/knowledge.cs` | takes the server's knowledge files (`MEWEB_KNOWLEDGE_URL`, default the public server; the release sets `http://127.0.0.1:5480` after deploying the server first) into `artifacts/knowledge/`, checking each SHA-256, and falls back to building `ip2asn.bin` alone from iptoasn (`--local` forces that); `MeWeb.Core` embeds whatever is there; `tools/release.cs` runs it before every app build |
|
| `tools/knowledge.cs` | takes the server's knowledge files (`MEWEB_KNOWLEDGE_URL`, default the public server; the release sets `http://127.0.0.1:5480` after deploying the server first) into `artifacts/knowledge/`, checking each SHA-256, and falls back to building `ip2asn.bin` alone from iptoasn (`--local` forces that); `MeWeb.Core` embeds whatever is there; `tools/release.cs` runs it before every app build |
|
||||||
| `tools/setup.cs` + `setup.json` | pinned build tools |
|
| `tools/setup.cs` + `setup.json` | pinned build tools |
|
||||||
| `tools/Screenshots` | headless renders of every view in every language |
|
| `tools/Screenshots` | headless renders of every view in every language; it runs a real check, in a throwaway app folder (`AppServices.Folder`), so the developer's own history and settings stay untouched. `--store` runs no check: it replays the invented run in `StoreRun.cs` (a phone on home Wi-Fi behind CGNAT with its own IPv6, documentation addresses, "Example Net" on AS64500, findings computed by `Verdicts`) on a fake `android` platform and writes the six 1080×1920 store screenshots per language, laid out at 405×720 and rendered at 1080/405. Headless rounds a window's logical size up to whole units before scaling, so a store size must be a whole number of units (411.4×731.4 at 2.625 came out 1082×1922) |
|
||||||
| `branding/` | the icon (SVG source, PNG, ICO) |
|
| `branding/` | the icon (SVG source, PNG, ICO) |
|
||||||
|
|
||||||
## Pitfalls
|
## Pitfalls
|
||||||
@@ -159,37 +161,29 @@ The Android release key: alias `meweb`, certificate SHA-256
|
|||||||
the network and the block without the publisher's server. A change to any knowledge file's format bumps
|
the network and the block without the publisher's server. A change to any knowledge file's format bumps
|
||||||
`KnowledgeFeeder.Format` and the `"format":N` that `wait-knowledge.sh` waits for: the feeder rebuilds only when a
|
`KnowledgeFeeder.Format` and the `"format":N` that `wait-knowledge.sh` waits for: the feeder rebuilds only when a
|
||||||
source changes or the format differs, and a release must not embed the previous build.
|
source changes or the format differs, and a release must not embed the previous build.
|
||||||
- **The deb and rpm reach packages.arasaka.software from this repository's own release job.** On tags,
|
- **packages.arasaka.software is fed from outside this repository** (since 1 Nov 2026). The private repository
|
||||||
`.gitea/scripts/packages.sh` runs on the `nuvola` runner (host mode, user `gitea-runner`, which owns
|
`thepra/packages.arasaka.software` lists this app in `apps/meweb.yml`, reads every `v*` release from the Releases
|
||||||
`/var/www/packages.arasaka.software`). It imports the org secret `PACKAGES_SIGNING_KEY` into a throwaway
|
tab every 15 minutes, and publishes the newest five debs and rpms, the newest three APKs in the F-Droid repository
|
||||||
`GNUPGHOME`, refuses any key but `F92C166E…3FB2`, signs the rpm in place, hard-links the current pool into a new
|
and the newest Windows and macOS files under `/download/meweb/`, all signed with keys that live as `thepra`'s own
|
||||||
`.snap/` folder, adds this version, keeps the newest five, rebuilds and signs the apt (`InRelease`, `Release.gpg`)
|
secrets. Its README is the reference. What this repository still owes it:
|
||||||
and rpm (`repomd.xml.asc`) metadata, carries over the files the previous `repomd.xml` named so a client mid-refresh
|
- **The release must carry every file named in that entry and list each in `SHA256SUMS.txt`**, or the publisher
|
||||||
still finds them, swaps the `apt` and `rpm` links, rewrites `SHA256SUMS.txt` for the signed rpm, and checks the live
|
leaves the version out and keeps serving the previous one. Renaming a release file means changing `apps/meweb.yml`
|
||||||
site. Never copy a package over a pool file: the pools are hard links into older snapshots, so it is `rm` then `cp`.
|
in the same hour.
|
||||||
`PACKAGES_ROOT` and `PACKAGES_KEEP` exist for a test against a copy. YoloCICD, which used to do this, was erased on
|
- **The rpm is signed here, before the release is published**, by `.gitea/scripts/sign-rpm.sh` with the Arasaka org
|
||||||
23 Oct 2026. `packaging/arasaka-repository.sh` is written into both maintainer scripts by `tools/release.cs`; it is
|
secret `PACKAGES_SIGNING_KEY` (it refuses any key but `F92C166E…3FB2`), which then rewrites `SHA256SUMS.txt`, so
|
||||||
now maintained here by hand, and any change to a file it writes needs the old content's SHA-256 kept in
|
the README's promise holds for the file on the release page too; the publisher sees the signature and keeps it.
|
||||||
|
- **The store listing is `fastlane/metadata/android/`** (`en-US` and `it-IT`: name, short and full description,
|
||||||
|
icon, banner, the six screenshots from `tools/Screenshots --store`), in Google Play's layout. The publisher reads
|
||||||
|
it from `master`, not from a release tag, so F-Droid shows a change within a quarter of an hour. The banner is
|
||||||
|
drawn by `Arasaka/app-stores`' `listing/banner.py meweb`. The F-Droid repository's own settings and icon live in
|
||||||
|
the publisher's repository.
|
||||||
|
- **The APK's signing key also signs the F-Droid index**, so `MEWEB_KEYSTORE_B64` exists twice: here, to sign the
|
||||||
|
APK, and as `thepra`'s `FDROID_KEYSTORE_B64`. The publisher refuses an APK signed by anything but `5f52cff4…f846`.
|
||||||
|
- `packaging/arasaka-repository.sh` is written into both maintainer scripts by `tools/release.cs`; it is
|
||||||
|
maintained here by hand, and any change to a file it writes needs the old content's SHA-256 kept in
|
||||||
`arasaka_repository_known`, or installed copies are treated as edited by hand and left alone. The package owns
|
`arasaka_repository_known`, or installed copies are treated as edited by hand and left alone. The package owns
|
||||||
`/usr/share/arasaka-repository/meweb`, the marker that keeps the repository configured while any Arasaka package
|
`/usr/share/arasaka-repository/meweb`, the marker that keeps the repository configured while any Arasaka package
|
||||||
is installed. The signing key's only copies are that secret and the owner's Nextcloud backup.
|
is installed. The portal app (`arasaka.software`, `arasaka-portal`) carries a byte-identical copy; change both.
|
||||||
- **The APK reaches an F-Droid repository on packages.arasaka.software from the same job** (31 Oct 2026), after the
|
|
||||||
release is published, so the repository can lag the release page but never lead it. `.gitea/scripts/fdroid.sh`
|
|
||||||
installs fdroidserver into `tools/fdroid` (cached like the JDK) with uv from `setup.json` and the hash-locked
|
|
||||||
`packaging/fdroid/requirements.txt`, because the box has no `ensurepip` and the apt package would bring 242 others.
|
|
||||||
The environment is made at the cache's real path (`pwd -P`) and rebuilt whenever `fdroid --version` fails: its
|
|
||||||
scripts carry the absolute path they were made at, `tools/fdroid` is a symlink from a workspace that can move, and
|
|
||||||
0.15.1's step failed with `fdroid: not found` on exactly that.
|
|
||||||
It signs the repository with the APK's own key (`MEWEB_KEYSTORE_B64` and `MEWEB_KEYSTORE_PASS`, alias `meweb`), so
|
|
||||||
the F-Droid fingerprint is the certificate's `5F52CFF4…FDF846` and there is no second key to lose; it refuses any
|
|
||||||
other. It builds a new snapshot in
|
|
||||||
`/var/www/packages.arasaka.software/.fdroid/` from the live one (the APKs as hard links, so `rm` then `cp` as above;
|
|
||||||
the metadata, `apkcache.json` and the previous `index-v2.json`, which is where fdroidserver reads each APK's first
|
|
||||||
date), adds this version, keeps the newest three, runs `fdroid update`, deletes `repo/status/` (it names the
|
|
||||||
build machine's paths and `/etc/issue.net`), swaps the `fdroid/repo` link and checks the live signature. The key
|
|
||||||
was already never to be regenerated; now everyone who added the repository trusts only its fingerprint too. `packaging/fdroid/` holds the
|
|
||||||
repository's settings, its icon (the Arasaka emblem) and the app's listing in English and Italian; the download
|
|
||||||
page links to it and to Obtainium (`Endpoints.FDroid`, source `FDroidRepo`).
|
|
||||||
- **The server cannot ping**: `NoNewPrivileges` drops `ping`'s file capability and `ping_group_range` is closed, so
|
- **The server cannot ping**: `NoNewPrivileges` drops `ping`'s file capability and `ping_group_range` is closed, so
|
||||||
the trace back to a caller uses `tracepath` (UDP, unprivileged) and keeps only public hops outside `Probe:Refuse`,
|
the trace back to a caller uses `tracepath` (UDP, unprivileged) and keeps only public hops outside `Probe:Refuse`,
|
||||||
so the box's own network never reaches an app.
|
so the box's own network never reaches an app.
|
||||||
@@ -209,6 +203,9 @@ The Android release key: alias `meweb`, certificate SHA-256
|
|||||||
phone with several): `AndroidPlatform` reads `ActivityManager.MemoryInfo.TotalMem`, Linux `/proc/meminfo`.
|
phone with several): `AndroidPlatform` reads `ActivityManager.MemoryInfo.TotalMem`, Linux `/proc/meminfo`.
|
||||||
- **`DriveInfo` on Unix takes any path** (it calls `statvfs`): pass the app's folder, not `/`, or Android reads the
|
- **`DriveInfo` on Unix takes any path** (it calls `statvfs`): pass the app's folder, not `/`, or Android reads the
|
||||||
read-only root and reports no free disk at all.
|
read-only root and reports no free disk at all.
|
||||||
|
- **A wireless mouse is a battery to Linux.** `/sys/class/power_supply` lists the batteries of mice, keyboards, headsets
|
||||||
|
and game controllers too (`type` Battery, `scope` Device); only one without `scope` Device makes a laptop. Until
|
||||||
|
0.15.4 the owner's desktop read as a laptop because of an MX Master 4 (MW-VRST-EK5A).
|
||||||
- **An IPv6 address only wraps at a colon if it is allowed to**: the path's detail lines put U+200B after each colon;
|
- **An IPv6 address only wraps at a colon if it is allowed to**: the path's detail lines put U+200B after each colon;
|
||||||
they are plain `TextBlock`s for that reason, so a copy never carries the invisible character (the cards' values,
|
they are plain `TextBlock`s for that reason, so a copy never carries the invisible character (the cards' values,
|
||||||
which are selectable, stay untouched).
|
which are selectable, stay untouched).
|
||||||
@@ -225,10 +222,13 @@ The Android release key: alias `meweb`, certificate SHA-256
|
|||||||
themed icons), and the splash uses the same foreground. Both vectors hold `branding/meweb.svg`'s drawing scaled into
|
themed icons), and the splash uses the same foreground. Both vectors hold `branding/meweb.svg`'s drawing scaled into
|
||||||
the central 66 of 108 units around its smallest enclosing circle; a change to the drawing goes into all three files.
|
the central 66 of 108 units around its smallest enclosing circle; a change to the drawing goes into all three files.
|
||||||
The plain `mipmap-xxxhdpi/icon.png` is only for Android 7.x.
|
The plain `mipmap-xxxhdpi/icon.png` is only for Android 7.x.
|
||||||
- **Release notes are the commits since the previous tag.** `tools/release.cs` writes "What's new" from each
|
- **Release notes are the commits since the previous tag** (since 1 November 2026 written by the shared
|
||||||
commit's subject (without the `me-web? x.y:` prefix, quoted or not) and body, dropping trailers and any line that mentions Claude;
|
`Arasaka/app-stores` `changelog@v1` action in `release.yml`, into `artifacts/notes/`; `tools/release.cs` reads its
|
||||||
the release step needs `fetch-depth: 0` for the tags. So a commit body is written for the people who download the
|
`notes.md` for the release text and hands its `changelog.yml` to nfpm). Each commit's subject (without the
|
||||||
release: plain words, one bullet per change they would notice.
|
`me-web? x.y:` prefix, quoted or not) and its whole body, with the attribution trailers dropped, go to the Releases tab,
|
||||||
|
`CHANGELOG.md` beside the files, the deb's and rpm's own changelog, and on packages.arasaka.software to the page,
|
||||||
|
F-Droid's What's new, `apt changelog` and dnf's advisories. So a commit body is written for the people who download
|
||||||
|
the release: plain words, one bullet per change they would notice.
|
||||||
- **The name is quoted inside a sentence, in each language's own marks, and bare where it stands alone.** “me-web?”
|
- **The name is quoted inside a sentence, in each language's own marks, and bare where it stands alone.** “me-web?”
|
||||||
in English, «me-web?» in Italian, „me-web?“ in German, « me-web? » (no-break spaces) in French, 「me-web?」 in
|
in English, «me-web?» in Italian, „me-web?“ in German, « me-web? » (no-break spaces) in French, 「me-web?」 in
|
||||||
Japanese: unquoted in a sentence, the question mark reads as its end ("What me-web? found"). Alone, as a title or a
|
Japanese: unquoted in a sentence, the question mark reads as its end ("What me-web? found"). Alone, as a title or a
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.15.3</Version>
|
<Version>0.16.0</Version>
|
||||||
<Product>me-web?</Product>
|
<Product>me-web?</Product>
|
||||||
<Company>Arasaka di Hvozdikov Yevheniy</Company>
|
<Company>Arasaka di Hvozdikov Yevheniy</Company>
|
||||||
<Copyright>© 2026 Arasaka di Hvozdikov Yevheniy</Copyright>
|
<Copyright>© 2026 Arasaka di Hvozdikov Yevheniy</Copyright>
|
||||||
@@ -13,5 +13,8 @@
|
|||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
<InvariantGlobalization>false</InvariantGlobalization>
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
<HouseRulesDtoNamespaces>MeWeb.Shared</HouseRulesDtoNamespaces>
|
||||||
|
<HouseRulesFormatHelpers>NumbersExtensions,Texts</HouseRulesFormatHelpers>
|
||||||
|
<WarningsAsErrors>$(WarningsAsErrors);HR0001;HR0002;HR0003;HR0004;HR0005;HR0006;HR0007;HR0008;HR0009;HR0010;HR0011;HR0012;HR0013;HR0101;HR0102;HR0103;HR0104;HR0105;HR0106;HR0107;HR0108;HR0201</WarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AvaloniaVersion>12.1.4</AvaloniaVersion>
|
<AvaloniaVersion>12.1.6</AvaloniaVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersion)" />
|
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersion)" />
|
||||||
@@ -14,9 +14,16 @@
|
|||||||
<PackageVersion Include="Avalonia.HarfBuzz" Version="$(AvaloniaVersion)" />
|
<PackageVersion Include="Avalonia.HarfBuzz" Version="$(AvaloniaVersion)" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
<PackageVersion Include="QRCoder" Version="1.8.1" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.13" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.14" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" />
|
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
|
||||||
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.LiveData.Core" Version="2.11.0.1" />
|
||||||
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
|
||||||
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.ViewModelSavedState" Version="2.11.0.1" />
|
||||||
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.ViewModelSavedState.Android" Version="2.11.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<GlobalPackageReference Include="Arasaka.HouseRules.Analyzers" Version="1.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# PATTERNS.md — MeWeb house rulings
|
# PATTERNS.md — MeWeb house rulings
|
||||||
|
|
||||||
The canonical standards doc. **On any conflict about how to write code, a ruling here wins**; cite it in review as
|
The canonical standards doc. **On any conflict about how to write code, a ruling here wins**, except over the owner's
|
||||||
"PATTERNS #n". `AGENT.md` is the operational contract, `CLAUDE.md` the quick reference and pitfalls.
|
house rules (next section), which win over everything; cite a ruling in review as "PATTERNS #n". `AGENT.md` is the operational contract, `CLAUDE.md` the quick reference and pitfalls.
|
||||||
|
|
||||||
Numbering is shared with the house lineage (SimpleSchool, passolo, odysseus, zigzag.academy): **#42, #51, #86–#93
|
Numbering is shared with the house lineage (SimpleSchool, passolo, odysseus, zigzag.academy): **#42, #51, #86–#93
|
||||||
mean the same thing in every repo**, and the text below follows passolo's wording wherever the subject is the same.
|
mean the same thing in every repo**, and the text below follows passolo's wording wherever the subject is the same.
|
||||||
@@ -10,6 +10,54 @@ only MeWeb needs start at **#120**.
|
|||||||
|
|
||||||
Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third-party trees, the licence text.
|
Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third-party trees, the licence text.
|
||||||
|
|
||||||
|
## House rules
|
||||||
|
|
||||||
|
The owner's rules for every .NET project (`~/RiderProjects/HouseRules/RULES.md`, repo `Arasaka/HouseRules`) apply
|
||||||
|
here. `Arasaka.HouseRules.Analyzers` (a `GlobalPackageReference` in `Directory.Packages.props`) reports the checkable
|
||||||
|
ones as `HRxxxx` warnings, they are listed in `WarningsAsErrors`, and `tools/ci.sh` builds with `-warnaserror` too.
|
||||||
|
MeWeb builds with none and carries no suppression. Applied to MeWeb:
|
||||||
|
|
||||||
|
- **Coding style:**
|
||||||
|
- `var` for every local, `var x = default(T)` when there is no value yet (HR0001, #92).
|
||||||
|
- `IOptionsMonitor<T>.CurrentValue` (HR0002, #87).
|
||||||
|
- Primary constructors, using the parameters directly (HR0004, #1).
|
||||||
|
- Work a constructor used to do becomes a field initializer through a static helper (`RegistryWriter.Reserve`).
|
||||||
|
- A collection built from instance methods is a `field ??=` property (`MainViewModel.Cards`, `Shared`).
|
||||||
|
- A `CancellationToken` last on every method that returns a task, passed down (HR0003, #88).
|
||||||
|
- Relay commands take one.
|
||||||
|
- Where an API takes none, the wait takes it (`SetTextAsync(link).WaitAsync(ct)`).
|
||||||
|
- Best-effort and fire-and-forget work passes `CancellationToken.None` on purpose.
|
||||||
|
- **DTOs:**
|
||||||
|
- `View*` responses, `*Request` create/update requests, `*Form` other input (HR0010 over `MeWeb.Shared`, #34).
|
||||||
|
- `Show*` booleans and `*Formatted` strings for display (#44).
|
||||||
|
- Lists are `= []` (HR0006, #75), and strings have no `= string.Empty` default (HR0005, #89).
|
||||||
|
- **Server:**
|
||||||
|
- Routes live only in `Endpoints` (#9).
|
||||||
|
- A service that can refuse returns `WebResult<T>` (`MeWeb.Shared`). `Invalidate(reason)` answers 400; 403 and
|
||||||
|
404 are passed explicitly. The endpoint answers through `Answer(context, result, type)`: the data on success,
|
||||||
|
otherwise a `ViewRefusal` with its `Reason`, plus `Retry-After` when `RetryAfterSeconds` is set.
|
||||||
|
- Services take the caller's address from the endpoint, never `IHttpContextAccessor` (HR0008, #86).
|
||||||
|
- **Status codes** (owner, 2026-11-10):
|
||||||
|
- 400 for every refusal: bad ports, shape, size, busy, too many.
|
||||||
|
- 403 for a caller the probe will not serve (`not-public`, `other-address`) and a delete token that does not match.
|
||||||
|
- 404 for an unknown ticket or submission.
|
||||||
|
- Nothing answers 409, 413, 429 or any 5XX (HR0007), and MeWeb has no exception.
|
||||||
|
- Clients read `ViewRefusal.Reason`, never the status. Installed apps up to 0.15.4 read 429 on the speed download,
|
||||||
|
so until they update a limited speed test shows as "couldn't measure" rather than "limited".
|
||||||
|
- **Client:** `ServerProbe` returns the same `WebResult<T>` and never throws (#27). A transport failure is the refusal
|
||||||
|
`unreachable`, and a failure without a body is `http-<status>`.
|
||||||
|
- **Numbers:** formatted only by `NumbersExtensions` (`MeWeb.Shared`) and `Texts` (`MeWeb.App`) (HR0011).
|
||||||
|
- `NumbersExtensions` holds the invariant values that fill a localized template: `ToOneDecimal`, `ToWholeMegabytes`.
|
||||||
|
- `Texts` holds what the App formats in the reader's culture: money and dates.
|
||||||
|
- **JSON:** one source-generated context per assembly (#84).
|
||||||
|
- **Error codes** (HR0012, HR0013) are not turned on: a refusal already carries a stable machine code,
|
||||||
|
`ViewRefusal.Reason` (`too-many`, `busy`, `bad-ports`…), which installed apps read and translate. A numbered prefix
|
||||||
|
would change what they receive.
|
||||||
|
- **Spreadsheets** (HR0319): none; findings are shown in the app and shared as the submission JSON the person reads.
|
||||||
|
- **Not applicable:** passwords (HR0311), since there are no accounts.
|
||||||
|
- **Not applicable:** the `.razor` rules (HR0101–HR0108, HR0201), since the App is Avalonia. Their counterparts are
|
||||||
|
#27, #43, #44 and #93. `Localizer.Instance[key]` already returns a string (#91).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Dependency injection and services
|
## A. Dependency injection and services
|
||||||
@@ -26,7 +74,8 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third
|
|||||||
|
|
||||||
## B. Web boundary
|
## B. Web boundary
|
||||||
|
|
||||||
5. **`WebResult` envelope + early return** on every server endpoint that can refuse; the happy path stays flat.
|
5. **`WebResult<T>` + early return** in every server service that can refuse, answered through `Answer`; the happy
|
||||||
|
path stays flat (see House rules).
|
||||||
6. **Pure or internal code may return tuples** (`(value, error)`), never across the web boundary.
|
6. **Pure or internal code may return tuples** (`(value, error)`), never across the web boundary.
|
||||||
7. **`try/catch` placement: few at the top, few at the bottom, none in the middle.** Top: host startup, background
|
7. **`try/catch` placement: few at the top, few at the bottom, none in the middle.** Top: host startup, background
|
||||||
loops. Bottom: sockets, DNS, UPnP/PCP, file IO, platform APIs — the IO edges where a check meets the network.
|
loops. Bottom: sockets, DNS, UPnP/PCP, file IO, platform APIs — the IO edges where a check meets the network.
|
||||||
@@ -53,7 +102,7 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third
|
|||||||
55. n/a — no model output.
|
55. n/a — no model output.
|
||||||
63. **An inventory that lives in a check rots; keep one map and assert it against reality.**
|
63. **An inventory that lives in a check rots; keep one map and assert it against reality.**
|
||||||
64. **A sweep asserts what must hold and records what is a judgement call.**
|
64. **A sweep asserts what must hold and records what is a judgement call.**
|
||||||
65. **Prefer a source lint to a runtime check when the bug is visible in the source** (`tools/lint.cs`).
|
65. **Prefer a source lint to a runtime check when the bug is visible in the source** (the house-rules analyzer).
|
||||||
66. **A check that has always failed is a decision nobody made** — fix it or delete it.
|
66. **A check that has always failed is a decision nobody made** — fix it or delete it.
|
||||||
67. **Two paths that resolve the same thing must degrade the same way** (the v4 and v6 echo, the system resolver and
|
67. **Two paths that resolve the same thing must degrade the same way** (the v4 and v6 echo, the system resolver and
|
||||||
the server-side blocklist fallback).
|
the server-side blocklist fallback).
|
||||||
@@ -117,7 +166,7 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third
|
|||||||
32. **Subscription discipline:** every `+=` has its `-=` in the matching unload/dispose; timers are `PeriodicTimer`
|
32. **Subscription discipline:** every `+=` has its `-=` in the matching unload/dispose; timers are `PeriodicTimer`
|
||||||
with a cancellation source cancelled on leave.
|
with a cancellation source cancelled on leave.
|
||||||
33. n/a.
|
33. n/a.
|
||||||
34. **DTO naming by direction:** `View*` (response), `Upsert*` (create/update), `*Form` (any other input). Contracts
|
34. **DTO naming by direction:** `View*` (response), `*Request` (create/update), `*Form` (any other input). Contracts
|
||||||
grouped by area in `MeWeb.Shared/<Area>Contracts.cs`.
|
grouped by area in `MeWeb.Shared/<Area>Contracts.cs`.
|
||||||
35. n/a. 36. **Path safety** for anything served from disk by name. 37. **Layered gating: the UI hides; the server
|
35. n/a. 36. **Path safety** for anything served from disk by name. 37. **Layered gating: the UI hides; the server
|
||||||
refuses.** 57. n/a.
|
refuses.** 57. n/a.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Your photos, your files, a website or a game server, kept at home instead of on someone else's computer. Whether that can work depends on your connection, and “me-web?” tells you, in plain words.
|
||||||
|
|
||||||
|
It checks your connection and your device one step at a time, from this phone to your router, your provider and the internet, and then says what you could host at home, what stands in the way, and what would fix it:
|
||||||
|
|
||||||
|
• a website, reaching home from outside, family photos and files, a game or voice server, a fediverse or blog server, a mail server: possible, possible with a workaround (and which one), or can't tell;
|
||||||
|
• whether your provider shares your public address with other customers (CGNAT), or a second router is in the way;
|
||||||
|
• whether your router opens ports for the apps that ask it to (UPnP, NAT-PMP, PCP);
|
||||||
|
• on mobile data, the operator and the type of network (5G, 4G);
|
||||||
|
• only if you agree: whether the outside world can really reach this device, with ports opened for ten minutes and closed afterwards;
|
||||||
|
• whether this connection could send mail directly;
|
||||||
|
• how your public address has changed across your earlier checks.
|
||||||
|
|
||||||
|
What it knows about providers is inside the app, not fetched from a server. Your findings stay on your phone unless you choose to share them, after reading exactly what is sent, and you can delete them at any time.
|
||||||
|
|
||||||
|
Free software (AGPL-3.0), no ads, no telemetry, no account.
|
||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 235 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
Le sue foto, i suoi file, un sito o un server per giocare, tenuti a casa invece che sul computer di qualcun altro. Se si può dipende dalla sua connessione, e «me-web?» glielo dice in parole semplici.
|
||||||
|
|
||||||
|
Controlla la connessione e il dispositivo un passo alla volta, da questo telefono al router, al suo operatore e a internet, e poi le dice che cosa potrebbe ospitare a casa, che cosa lo impedisce e che cosa lo risolverebbe:
|
||||||
|
|
||||||
|
• un sito, raggiungere casa da fuori, foto e file di famiglia, un server per giochi o per chiamate vocali, un server del fediverso o un blog, un server di posta: si può, si può con un accorgimento (e quale), oppure non si può dire;
|
||||||
|
• se l'operatore condivide il suo indirizzo pubblico con altri clienti (CGNAT), o se c'è un secondo router di mezzo;
|
||||||
|
• se il router apre le porte alle app che glielo chiedono (UPnP, NAT-PMP, PCP);
|
||||||
|
• con i dati mobili, l'operatore e il tipo di rete (5G, 4G);
|
||||||
|
• solo se lei è d'accordo: se dall'esterno si raggiunge davvero questo dispositivo, con le porte aperte per dieci minuti e richiuse subito dopo;
|
||||||
|
• se questa connessione può inviare posta direttamente;
|
||||||
|
• come è cambiato il suo indirizzo pubblico nei controlli precedenti.
|
||||||
|
|
||||||
|
Quello che sa degli operatori è dentro l'app, non scaricato da un server. I risultati restano sul suo telefono, a meno che non scelga di condividerli dopo aver letto esattamente che cosa viene inviato, e può cancellarli quando vuole.
|
||||||
|
|
||||||
|
Software libero (AGPL-3.0), niente pubblicità, niente telemetria, nessun account.
|
||||||
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 229 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"sdk": {
|
"sdk": {
|
||||||
"version": "10.0.401",
|
"version": "10.0.403",
|
||||||
"rollForward": "latestFeature"
|
"rollForward": "latestFeature"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
<add key="nget" value="https://nget.thepra.dev/v3/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nget">
|
||||||
|
<package pattern="Arasaka.*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
arasaka_repository_url='https://packages.arasaka.software'
|
arasaka_repository_url='https://packages.arasaka.software'
|
||||||
arasaka_repository_known=' 39c1037ea150a946870135d45c7e0a150b6cf07612961ab7d4ebf7356e3d6206 8e98c23290cd1afa016eed46b6bc6643a44f07d1d398b4c74221891a3dee0ef1 f8a29b044c4b538af072041b201e31cbcc6328f709c3b960fbfccef819cb2572 fe762297efbcb217cbfa51ac28dce057695205bd46f59bc69369d92765edebfc '
|
arasaka_repository_known=' 311daabcddefad7fe495155f328d1c5e14ef1abf0d3d65312468ddb2bc3b33a2 39c1037ea150a946870135d45c7e0a150b6cf07612961ab7d4ebf7356e3d6206 8e98c23290cd1afa016eed46b6bc6643a44f07d1d398b4c74221891a3dee0ef1 f8a29b044c4b538af072041b201e31cbcc6328f709c3b960fbfccef819cb2572 fe762297efbcb217cbfa51ac28dce057695205bd46f59bc69369d92765edebfc '
|
||||||
arasaka_repository_sources='Types: deb
|
arasaka_repository_sources='Types: deb
|
||||||
URIs: https://packages.arasaka.software/apt
|
URIs: https://packages.arasaka.software/apt
|
||||||
Suites: stable
|
Suites: stable
|
||||||
@@ -75,7 +75,7 @@ gpgcheck=1
|
|||||||
repo_gpgcheck=1
|
repo_gpgcheck=1
|
||||||
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-arasaka
|
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-arasaka
|
||||||
priority=150
|
priority=150
|
||||||
metadata_expire=6h
|
metadata_expire=15m
|
||||||
'
|
'
|
||||||
arasaka_repository_zypp='[arasaka]
|
arasaka_repository_zypp='[arasaka]
|
||||||
name=Arasaka
|
name=Arasaka
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
repo_url: https://packages.arasaka.software/fdroid/repo
|
|
||||||
repo_name: Arasaka
|
|
||||||
repo_description: >-
|
|
||||||
Apps published by Arasaka di Hvozdikov Yevheniy, Busto Arsizio, Italy, such as “me-web?”.
|
|
||||||
Each app here is the same signed file as on its release page.
|
|
||||||
repo_icon: icon.png
|
|
||||||
archive_older: 0
|
|
||||||
repo_keyalias: meweb
|
|
||||||
keystore: {env: MEWEB_KEYSTORE}
|
|
||||||
keystorepass: {env: MEWEB_KEYSTORE_PASS}
|
|
||||||
keypass: {env: MEWEB_KEYSTORE_PASS}
|
|
||||||
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,10 +0,0 @@
|
|||||||
Categories:
|
|
||||||
- Connectivity
|
|
||||||
- Internet
|
|
||||||
License: AGPL-3.0-only
|
|
||||||
AuthorName: Arasaka di Hvozdikov Yevheniy
|
|
||||||
WebSite: https://meweb.arasaka.software
|
|
||||||
SourceCode: https://git.thepra.dev/Arasaka/MeWeb
|
|
||||||
IssueTracker: https://git.thepra.dev/Arasaka/MeWeb/issues
|
|
||||||
Changelog: https://git.thepra.dev/Arasaka/MeWeb/releases
|
|
||||||
AutoName: me-web?
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
“me-web?” checks your connection and your device and tells you, in plain words, what you could host at home, what stands in the way, and what would fix it. Free software, no telemetry, no account.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
«me-web?» controlla la sua connessione e il suo dispositivo e le dice, in parole semplici, che cosa potrebbe ospitare a casa, che cosa lo impedisce e che cosa lo risolverebbe. Software libero, niente telemetria, nessun account.
|
|
||||||
@@ -118,7 +118,7 @@ public class AndroidPlatform : SystemPlatform
|
|||||||
if (activity is null) return false;
|
if (activity is null) return false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await activity.Ask(permission).WaitAsync(TimeSpan.FromMinutes(2), ct);
|
return await activity.Ask(permission, ct).WaitAsync(TimeSpan.FromMinutes(2), ct);
|
||||||
}
|
}
|
||||||
catch (TimeoutException)
|
catch (TimeoutException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,23 +32,23 @@ public class MainActivity : AvaloniaMainActivity
|
|||||||
base.OnDestroy();
|
base.OnDestroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> Ask(string permission)
|
public Task<bool> Ask(string permission, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var answer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var answer = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
int code;
|
var code = default(int);
|
||||||
lock (Waiting)
|
lock (Waiting)
|
||||||
{
|
{
|
||||||
code = nextRequest++;
|
code = nextRequest++;
|
||||||
Waiting[code] = answer;
|
Waiting[code] = answer;
|
||||||
}
|
}
|
||||||
RunOnUiThread(() => RequestPermissions([permission], code));
|
RunOnUiThread(() => RequestPermissions([permission], code));
|
||||||
return answer.Task;
|
return answer.Task.WaitAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Permission[] grantResults)
|
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Permission[] grantResults)
|
||||||
{
|
{
|
||||||
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
|
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
TaskCompletionSource<bool> answer;
|
var answer = default(TaskCompletionSource<bool>);
|
||||||
lock (Waiting)
|
lock (Waiting)
|
||||||
{
|
{
|
||||||
if (!Waiting.Remove(requestCode, out answer)) return;
|
if (!Waiting.Remove(requestCode, out answer)) return;
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia.Android" />
|
<PackageReference Include="Avalonia.Android" />
|
||||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData.Core" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Process" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Lifecycle.ViewModelSavedState" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Lifecycle.ViewModelSavedState.Android" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public static class AppServices
|
|||||||
public const string Secure = "secure";
|
public const string Secure = "secure";
|
||||||
public const string Plain = "plain";
|
public const string Plain = "plain";
|
||||||
|
|
||||||
|
public static string Folder { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeWeb");
|
||||||
|
|
||||||
public static IServiceCollection AddMeWebApp(this IServiceCollection services, IPlatform platform)
|
public static IServiceCollection AddMeWebApp(this IServiceCollection services, IPlatform platform)
|
||||||
{
|
{
|
||||||
var agent = $"me-web/{BuildInfo.Version}";
|
var agent = $"me-web/{BuildInfo.Version}";
|
||||||
@@ -28,7 +30,7 @@ public static class AppServices
|
|||||||
return new ServerProbe(factory.CreateClient(Secure), factory.CreateClient(Plain));
|
return new ServerProbe(factory.CreateClient(Secure), factory.CreateClient(Plain));
|
||||||
});
|
});
|
||||||
services.AddSingleton<KnowledgeBase>();
|
services.AddSingleton<KnowledgeBase>();
|
||||||
var folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeWeb");
|
var folder = Folder;
|
||||||
services.AddSingleton(new HistoryStore(folder));
|
services.AddSingleton(new HistoryStore(folder));
|
||||||
services.AddSingleton(new Leftovers(folder));
|
services.AddSingleton(new Leftovers(folder));
|
||||||
services.AddSingleton(new Submissions(folder));
|
services.AddSingleton(new Submissions(folder));
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ public class StoredSettings
|
|||||||
|
|
||||||
public static class AppSettings
|
public static class AppSettings
|
||||||
{
|
{
|
||||||
static readonly string Folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeWeb");
|
static string FilePath => Path.Combine(AppServices.Folder, "settings.json");
|
||||||
static readonly string FilePath = Path.Combine(Folder, "settings.json");
|
|
||||||
|
|
||||||
public static StoredSettings Read()
|
public static StoredSettings Read()
|
||||||
{
|
{
|
||||||
@@ -32,7 +31,7 @@ public static class AppSettings
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Folder);
|
Directory.CreateDirectory(AppServices.Folder);
|
||||||
var temporary = FilePath + ".tmp";
|
var temporary = FilePath + ".tmp";
|
||||||
using (var stream = File.Create(temporary))
|
using (var stream = File.Create(temporary))
|
||||||
JsonSerializer.Serialize(stream, settings, AppJsonContext.Default.StoredSettings);
|
JsonSerializer.Serialize(stream, settings, AppJsonContext.Default.StoredSettings);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace MeWeb.App.Services;
|
|||||||
public static class ErrorLog
|
public static class ErrorLog
|
||||||
{
|
{
|
||||||
const long MaxBytes = 256 * 1024;
|
const long MaxBytes = 256 * 1024;
|
||||||
static readonly string FilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeWeb", "errors.log");
|
static string FilePath => Path.Combine(AppServices.Folder, "errors.log");
|
||||||
static readonly Lock Gate = new();
|
static readonly Lock Gate = new();
|
||||||
|
|
||||||
public static void Watch()
|
public static void Watch()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public enum Reachability
|
|||||||
Offline,
|
Offline,
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class MainViewModel : ObservableObject
|
public partial class MainViewModel(ServerProbe server, CheckRunner runner, KnowledgeBase knowledge, HistoryStore history, IPlatform platform, Reach reach, Leftovers leftovers, Submissions submissions) : ObservableObject
|
||||||
{
|
{
|
||||||
public const string CorrectionsUrl = "https://git.thepra.dev/Arasaka/MeWeb/issues";
|
public const string CorrectionsUrl = "https://git.thepra.dev/Arasaka/MeWeb/issues";
|
||||||
public const string SourceUrl = "https://git.thepra.dev/Arasaka/MeWeb";
|
public const string SourceUrl = "https://git.thepra.dev/Arasaka/MeWeb";
|
||||||
@@ -38,14 +38,6 @@ public partial class MainViewModel : ObservableObject
|
|||||||
CheckId.Speed, CheckId.Online, CheckId.Addresses, CheckId.Reach, CheckId.History,
|
CheckId.Speed, CheckId.Online, CheckId.Addresses, CheckId.Reach, CheckId.History,
|
||||||
];
|
];
|
||||||
|
|
||||||
readonly ServerProbe server;
|
|
||||||
readonly CheckRunner runner;
|
|
||||||
readonly KnowledgeBase knowledge;
|
|
||||||
readonly HistoryStore history;
|
|
||||||
readonly IPlatform platform;
|
|
||||||
readonly Reach reach;
|
|
||||||
readonly Leftovers leftovers;
|
|
||||||
readonly Submissions submissions;
|
|
||||||
RunReport lastRun;
|
RunReport lastRun;
|
||||||
int checks;
|
int checks;
|
||||||
bool sent;
|
bool sent;
|
||||||
@@ -53,21 +45,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
bool instant;
|
bool instant;
|
||||||
bool storySeen = AppSettings.Read().StorySeen;
|
bool storySeen = AppSettings.Read().StorySeen;
|
||||||
|
|
||||||
public MainViewModel(ServerProbe server, CheckRunner runner, KnowledgeBase knowledge, HistoryStore history, IPlatform platform, Reach reach, Leftovers leftovers, Submissions submissions)
|
public ObservableCollection<SubmissionViewModel> Shared => field ??= new(submissions.Read().Select(s => new SubmissionViewModel(s, Unshare)));
|
||||||
{
|
|
||||||
this.server = server;
|
|
||||||
this.runner = runner;
|
|
||||||
this.knowledge = knowledge;
|
|
||||||
this.history = history;
|
|
||||||
this.platform = platform;
|
|
||||||
this.reach = reach;
|
|
||||||
this.leftovers = leftovers;
|
|
||||||
this.submissions = submissions;
|
|
||||||
Cards = new ObservableCollection<CardViewModel>(CardOrder.Select(id => new CardViewModel(id, OpenLink)));
|
|
||||||
Shared = new ObservableCollection<SubmissionViewModel>(submissions.Read().Select(s => new SubmissionViewModel(s, Unshare)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public ObservableCollection<SubmissionViewModel> Shared { get; }
|
|
||||||
|
|
||||||
public ObservableCollection<UseViewModel> Uses { get; } = [];
|
public ObservableCollection<UseViewModel> Uses { get; } = [];
|
||||||
|
|
||||||
@@ -123,7 +101,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
public IReadOnlyList<Language> Languages { get; } = Services.Languages.All;
|
public IReadOnlyList<Language> Languages { get; } = Services.Languages.All;
|
||||||
|
|
||||||
public ObservableCollection<CardViewModel> Cards { get; }
|
public ObservableCollection<CardViewModel> Cards => field ??= new(CardOrder.Select(id => new CardViewModel(id, OpenLink)));
|
||||||
|
|
||||||
public ObservableCollection<PathNodeViewModel> Path { get; } = [];
|
public ObservableCollection<PathNodeViewModel> Path { get; } = [];
|
||||||
|
|
||||||
@@ -210,10 +188,10 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
async Task Leave()
|
async Task Leave(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Leaving = true;
|
Leaving = true;
|
||||||
await Task.Delay(2200);
|
await Task.Delay(2200, ct);
|
||||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) desktop.Shutdown();
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) desktop.Shutdown();
|
||||||
else platform.Quit();
|
else platform.Quit();
|
||||||
}
|
}
|
||||||
@@ -341,12 +319,13 @@ public partial class MainViewModel : ObservableObject
|
|||||||
IsSending = true;
|
IsSending = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (shared, refusal) = await server.Share(SharePreview, ct);
|
var result = await server.Share(SharePreview, ct);
|
||||||
if (shared is null)
|
if (result.Data is null)
|
||||||
{
|
{
|
||||||
ShareResult = Localizer.Instance[refusal?.Reason == "too-many" ? "ShareTooMany" : "ShareFailed"];
|
ShareResult = Localizer.Instance[result.Refusal?.Reason == "too-many" ? "ShareTooMany" : "ShareFailed"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var shared = result.Data;
|
||||||
var submission = new Submission { Id = shared.Id, Token = shared.Token, KeepUntil = shared.KeepUntil, SentAt = DateTimeOffset.UtcNow };
|
var submission = new Submission { Id = shared.Id, Token = shared.Token, KeepUntil = shared.KeepUntil, SentAt = DateTimeOffset.UtcNow };
|
||||||
submissions.Add(submission);
|
submissions.Add(submission);
|
||||||
Shared.Add(new SubmissionViewModel(submission, Unshare));
|
Shared.Add(new SubmissionViewModel(submission, Unshare));
|
||||||
@@ -362,9 +341,9 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Unshare(SubmissionViewModel item)
|
async Task Unshare(SubmissionViewModel item, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!await server.Unshare(item.Id, item.Token, CancellationToken.None))
|
if (!await server.Unshare(item.Id, item.Token, ct))
|
||||||
{
|
{
|
||||||
item.Failed = true;
|
item.Failed = true;
|
||||||
return;
|
return;
|
||||||
@@ -375,16 +354,16 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
async Task CopyLink()
|
async Task CopyLink(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var link = Endpoints.Base + Endpoints.Downloads.GET_Page;
|
var link = Endpoints.Base + Endpoints.Downloads.GET_Page;
|
||||||
if (platform.ShareLink(link, Localizer.Instance["AppName"])) return;
|
if (platform.ShareLink(link, Localizer.Instance["AppName"])) return;
|
||||||
if (Clipboard is null) return;
|
if (Clipboard is null) return;
|
||||||
await Clipboard.SetTextAsync(link);
|
await Clipboard.SetTextAsync(link).WaitAsync(ct);
|
||||||
LinkCopied = true;
|
LinkCopied = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CleanLeftovers() => await leftovers.Clean(CancellationToken.None);
|
public async Task CleanLeftovers(CancellationToken ct) => await leftovers.Clean(ct);
|
||||||
|
|
||||||
public void Replay(RunReport run)
|
public void Replay(RunReport run)
|
||||||
{
|
{
|
||||||
@@ -617,10 +596,10 @@ public partial class MainViewModel : ObservableObject
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pending[node] = outcome;
|
pending[node] = outcome;
|
||||||
_ = Reveal();
|
_ = Reveal(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Reveal()
|
async Task Reveal(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (revealing) return;
|
if (revealing) return;
|
||||||
revealing = true;
|
revealing = true;
|
||||||
@@ -630,7 +609,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var next = Path.FirstOrDefault(n => !n.IsLit);
|
var next = Path.FirstOrDefault(n => !n.IsLit);
|
||||||
if (next is null || !pending.TryGetValue(next, out var outcome)) break;
|
if (next is null || !pending.TryGetValue(next, out var outcome)) break;
|
||||||
await Task.Delay(320);
|
await Task.Delay(320, ct);
|
||||||
next.State = outcome;
|
next.State = outcome;
|
||||||
pending.Remove(next);
|
pending.Remove(next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using MeWeb.Core.History;
|
|||||||
|
|
||||||
namespace MeWeb.App.ViewModels;
|
namespace MeWeb.App.ViewModels;
|
||||||
|
|
||||||
public partial class SubmissionViewModel(Submission submission, Func<SubmissionViewModel, Task> delete) : ObservableObject
|
public partial class SubmissionViewModel(Submission submission, Func<SubmissionViewModel, CancellationToken, Task> delete) : ObservableObject
|
||||||
{
|
{
|
||||||
public string Id => submission.Id;
|
public string Id => submission.Id;
|
||||||
public string Token => submission.Token;
|
public string Token => submission.Token;
|
||||||
@@ -18,7 +18,7 @@ public partial class SubmissionViewModel(Submission submission, Func<SubmissionV
|
|||||||
public partial bool Failed { get; set; }
|
public partial bool Failed { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
Task Delete() => delete(this);
|
Task Delete(CancellationToken ct) => delete(this, ct);
|
||||||
|
|
||||||
public void Refresh() => OnPropertyChanged(string.Empty);
|
public void Refresh() => OnPropertyChanged(string.Empty);
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ public partial class MainView : UserControl
|
|||||||
model.Launcher = top?.Launcher;
|
model.Launcher = top?.Launcher;
|
||||||
model.Clipboard = top?.Clipboard;
|
model.Clipboard = top?.Clipboard;
|
||||||
if (top is not null) top.BackRequested += OnBack;
|
if (top is not null) top.BackRequested += OnBack;
|
||||||
_ = model.CleanLeftovers();
|
_ = model.CleanLeftovers(CancellationToken.None);
|
||||||
model.AvailableWidth = Bounds.Width;
|
model.AvailableWidth = Bounds.Width;
|
||||||
model.StartCommand.Execute(null);
|
model.StartCommand.Execute(null);
|
||||||
if (model.StorySeen) Dispatcher.UIThread.Post(ShowCheck, DispatcherPriority.Background);
|
if (model.StorySeen) Dispatcher.UIThread.Post(ShowCheck, DispatcherPriority.Background);
|
||||||
|
|||||||
@@ -49,14 +49,14 @@ public class Reach(ServerProbe server, Leftovers leftovers)
|
|||||||
var report = new ReachReport { Choice = choice };
|
var report = new ReachReport { Choice = choice };
|
||||||
if (choice == ReachChoice.Skip) return report;
|
if (choice == ReachChoice.Skip) return report;
|
||||||
|
|
||||||
var ticket4 = run.PublicV4 is null ? (null, null) : await server.Ticket(false, ct);
|
var ticket4 = run.PublicV4 is null ? new WebResult<ViewProbeTicket>() : await server.Ticket(false, ct);
|
||||||
var ticket6 = run.PublicV6 is null ? (null, null) : await server.Ticket(true, ct);
|
var ticket6 = run.PublicV6 is null ? new WebResult<ViewProbeTicket>() : await server.Ticket(true, ct);
|
||||||
report.V4Refusal = ticket4.Refusal?.Reason;
|
report.V4Refusal = ticket4.Refusal?.Reason;
|
||||||
report.V6Refusal = ticket6.Refusal?.Reason;
|
report.V6Refusal = ticket6.Refusal?.Reason;
|
||||||
report.RetryAfterSeconds = Math.Max(ticket4.Refusal?.RetryAfterSeconds ?? 0, ticket6.Refusal?.RetryAfterSeconds ?? 0);
|
report.RetryAfterSeconds = Math.Max(ticket4.Refusal?.RetryAfterSeconds ?? 0, ticket6.Refusal?.RetryAfterSeconds ?? 0);
|
||||||
if (ticket4.Ticket is null && ticket6.Ticket is null) return report;
|
if (ticket4.Data is null && ticket6.Data is null) return report;
|
||||||
|
|
||||||
Answerer started;
|
var started = default(Answerer);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
started = Answerer.Start();
|
started = Answerer.Start();
|
||||||
@@ -71,13 +71,13 @@ public class Reach(ServerProbe server, Leftovers leftovers)
|
|||||||
}
|
}
|
||||||
await using var answerer = started;
|
await using var answerer = started;
|
||||||
report.ListenPort = answerer.Port;
|
report.ListenPort = answerer.Port;
|
||||||
if (ticket4.Ticket is not null) answerer.Accept(ticket4.Ticket.Nonce, Convert.FromBase64String(ticket4.Ticket.Key));
|
if (ticket4.Data is not null) answerer.Accept(ticket4.Data.Nonce, Convert.FromBase64String(ticket4.Data.Key));
|
||||||
if (ticket6.Ticket is not null) answerer.Accept(ticket6.Ticket.Nonce, Convert.FromBase64String(ticket6.Ticket.Key));
|
if (ticket6.Data is not null) answerer.Accept(ticket6.Data.Nonce, Convert.FromBase64String(ticket6.Data.Key));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var local = run.Local?.LocalV4;
|
var local = run.Local?.LocalV4;
|
||||||
if (choice == ReachChoice.OpenPorts && ticket4.Ticket is not null && run.Router?.AnyAnswer == true && local is not null)
|
if (choice == ReachChoice.OpenPorts && ticket4.Data is not null && run.Router?.AnyAnswer == true && local is not null)
|
||||||
{
|
{
|
||||||
foreach (var external in Wellknown.Append(answerer.Port))
|
foreach (var external in Wellknown.Append(answerer.Port))
|
||||||
{
|
{
|
||||||
@@ -92,18 +92,18 @@ public class Reach(ServerProbe server, Leftovers leftovers)
|
|||||||
: [.. Wellknown, answerer.Port];
|
: [.. Wellknown, answerer.Port];
|
||||||
ports4 = ports4.Where(p => p is 80 or 443 or (>= 49152 and <= 65535)).Distinct().Take(3).ToList();
|
ports4 = ports4.Where(p => p is 80 or 443 or (>= 49152 and <= 65535)).Distinct().Take(3).ToList();
|
||||||
|
|
||||||
var knock4 = ticket4.Ticket is null ? Task.FromResult<(ViewProbe, ViewRefusal)>((null, null)) : server.Knock(false, ticket4.Ticket.Id, ports4, ct);
|
var knock4 = ticket4.Data is null ? Task.FromResult(new WebResult<ViewProbe>()) : server.Knock(false, ticket4.Data.Id, ports4, ct);
|
||||||
var knock6 = ticket6.Ticket is null ? Task.FromResult<(ViewProbe, ViewRefusal)>((null, null)) : server.Knock(true, ticket6.Ticket.Id, [answerer.Port], ct);
|
var knock6 = ticket6.Data is null ? Task.FromResult(new WebResult<ViewProbe>()) : server.Knock(true, ticket6.Data.Id, [answerer.Port], ct);
|
||||||
var (probe4, refusal4) = await knock4;
|
var probe4 = await knock4;
|
||||||
var (probe6, refusal6) = await knock6;
|
var probe6 = await knock6;
|
||||||
report.V4 = probe4;
|
report.V4 = probe4.Data;
|
||||||
report.V6 = probe6;
|
report.V6 = probe6.Data;
|
||||||
report.V4Refusal ??= refusal4?.Reason;
|
report.V4Refusal ??= probe4.Refusal?.Reason;
|
||||||
report.V6Refusal ??= refusal6?.Reason;
|
report.V6Refusal ??= probe6.Refusal?.Reason;
|
||||||
|
|
||||||
var own = report.Mappings.FirstOrDefault(m => m.Mapping?.InternalPort == answerer.Port && m.ExternalPort == answerer.Port)?.Mapping;
|
var own = report.Mappings.FirstOrDefault(m => m.Mapping?.InternalPort == answerer.Port && m.ExternalPort == answerer.Port)?.Mapping;
|
||||||
if (own is not null && ticket4.Ticket is not null && Addresses.Parse(run.PublicV4) is { } publicV4)
|
if (own is not null && ticket4.Data is not null && Addresses.Parse(run.PublicV4) is { } publicV4)
|
||||||
report.Hairpin = await Knock(publicV4, own.ExternalPort, ticket4.Ticket, ct);
|
report.Hairpin = await Knock(publicV4, own.ExternalPort, ticket4.Data, ct);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,23 +55,23 @@ public class ServerProbe(HttpClient https, HttpClient plain)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(ViewProbeTicket Ticket, ViewRefusal Refusal)> Ticket(bool v6, CancellationToken ct)
|
public async Task<WebResult<ViewProbeTicket>> Ticket(bool v6, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
||||||
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_Ticket, null, MeWebJsonContext.Default.ViewProbeTicket, ct);
|
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_Ticket, null, MeWebJsonContext.Default.ViewProbeTicket, Patience, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(ViewProbe Probe, ViewRefusal Refusal)> Knock(bool v6, string id, List<int> ports, CancellationToken ct)
|
public async Task<WebResult<ViewProbe>> Knock(bool v6, string id, List<int> ports, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
||||||
var form = JsonContent.Create(new ProbeForm { Ports = ports }, MeWebJsonContext.Default.ProbeForm);
|
var form = JsonContent.Create(new ProbeForm { Ports = ports }, MeWebJsonContext.Default.ProbeForm);
|
||||||
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_M_Run(id), form, MeWebJsonContext.Default.ViewProbe, ct, TimeSpan.FromSeconds(30));
|
return await Send(HttpMethod.Post, origin + Endpoints.Probe.POST_M_Run(id), form, MeWebJsonContext.Default.ViewProbe, TimeSpan.FromSeconds(30), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(ViewShared Shared, ViewRefusal Refusal)> Share(string json, CancellationToken ct)
|
public async Task<WebResult<ViewShared>> Share(string json, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||||
return await Send(HttpMethod.Post, Endpoints.Base + Endpoints.Share.POST_Create, content, MeWebJsonContext.Default.ViewShared, ct);
|
return await Send(HttpMethod.Post, Endpoints.Base + Endpoints.Share.POST_Create, content, MeWebJsonContext.Default.ViewShared, Patience, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> Unshare(string id, string token, CancellationToken ct)
|
public async Task<bool> Unshare(string id, string token, CancellationToken ct)
|
||||||
@@ -93,23 +93,28 @@ public class ServerProbe(HttpClient https, HttpClient plain)
|
|||||||
|
|
||||||
public const string DeleteTokenHeader = "X-Delete-Token";
|
public const string DeleteTokenHeader = "X-Delete-Token";
|
||||||
|
|
||||||
async Task<(T Value, ViewRefusal Refusal)> Send<T>(HttpMethod method, string url, HttpContent content, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> type, CancellationToken ct, TimeSpan? patience = null) where T : class
|
async Task<WebResult<T>> Send<T>(HttpMethod method, string url, HttpContent content, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> type, TimeSpan patience, CancellationToken ct) where T : class
|
||||||
{
|
{
|
||||||
|
var result = new WebResult<T>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(method, url) { Content = content };
|
using var request = new HttpRequestMessage(method, url) { Content = content };
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
timeout.CancelAfter(patience ?? Patience);
|
timeout.CancelAfter(patience);
|
||||||
using var response = await https.SendAsync(request, timeout.Token);
|
using var response = await https.SendAsync(request, timeout.Token);
|
||||||
if (response.IsSuccessStatusCode) return (await response.Content.ReadFromJsonAsync(type, timeout.Token), null);
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
result.Data = await response.Content.ReadFromJsonAsync(type, timeout.Token);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
var refusal = response.Content.Headers.ContentType?.MediaType == "application/json"
|
var refusal = response.Content.Headers.ContentType?.MediaType == "application/json"
|
||||||
? await response.Content.ReadFromJsonAsync(MeWebJsonContext.Default.ViewRefusal, timeout.Token)
|
? await response.Content.ReadFromJsonAsync(MeWebJsonContext.Default.ViewRefusal, timeout.Token)
|
||||||
: null;
|
: null;
|
||||||
return (null, refusal ?? new ViewRefusal { Reason = $"http-{(int)response.StatusCode}" });
|
return result.Invalidate(refusal ?? new ViewRefusal { Reason = $"http-{(int)response.StatusCode}" }, (int)response.StatusCode);
|
||||||
}
|
}
|
||||||
catch (Exception) when (!ct.IsCancellationRequested)
|
catch (Exception) when (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return (null, new ViewRefusal { Reason = "unreachable" });
|
return result.Invalidate("unreachable");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,14 +337,14 @@ public static class Verdicts
|
|||||||
{ Port25: Core.Mail.PortState.Blocked } => (Outcome.Bad, "MailBlocked"),
|
{ Port25: Core.Mail.PortState.Blocked } => (Outcome.Bad, "MailBlocked"),
|
||||||
_ when listed.Count > 0 => (Outcome.Bad, "MailListed"),
|
_ when listed.Count > 0 => (Outcome.Bad, "MailListed"),
|
||||||
_ when mail.Listings.Any(l => l.Residential) => (Outcome.Caution, "MailResidential"),
|
_ when mail.Listings.Any(l => l.Residential) => (Outcome.Caution, "MailResidential"),
|
||||||
_ when wide.Count > 0 => (Outcome.Caution, "MailNetworkListed"),
|
|
||||||
{ ReverseName: null } => (Outcome.Caution, "MailNoName"),
|
{ ReverseName: null } => (Outcome.Caution, "MailNoName"),
|
||||||
{ ReverseLooksDynamic: true } => (Outcome.Caution, "MailDynamicName"),
|
{ ReverseLooksDynamic: true } => (Outcome.Caution, "MailDynamicName"),
|
||||||
|
_ when wide.Count > 0 => (Outcome.Caution, "MailNetworkListed"),
|
||||||
{ Port25: Core.Mail.PortState.Open } => (Outcome.Good, "MailClean"),
|
{ Port25: Core.Mail.PortState.Open } => (Outcome.Good, "MailClean"),
|
||||||
_ => (Outcome.Unknown, "MailUnknown"),
|
_ => (Outcome.Unknown, "MailUnknown"),
|
||||||
};
|
};
|
||||||
if (listed.Count > 0) finding.Args = [string.Join(", ", listed.Select(l => l.List))];
|
if (finding.Summary == "MailListed") finding.Args = [string.Join(", ", listed.Select(l => l.List))];
|
||||||
else if (wide.Count > 0) finding.Args = [string.Join(", ", wide.Select(l => l.List))];
|
else if (finding.Summary == "MailNetworkListed") finding.Args = [string.Join(", ", wide.Select(l => l.List))];
|
||||||
finding.WithKey("FactPort25", mail.Port25 switch
|
finding.WithKey("FactPort25", mail.Port25 switch
|
||||||
{
|
{
|
||||||
Core.Mail.PortState.Open => "ValuePort25Open",
|
Core.Mail.PortState.Open => "ValuePort25Open",
|
||||||
@@ -409,9 +409,9 @@ public static class Verdicts
|
|||||||
}
|
}
|
||||||
finding.Outcome = Outcome.Info;
|
finding.Outcome = Outcome.Info;
|
||||||
finding.Summary = "SpeedResult";
|
finding.Summary = "SpeedResult";
|
||||||
finding.Args = [speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture), speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture)];
|
finding.Args = [speed.DownMbps.ToOneDecimal(), speed.UpMbps.ToOneDecimal()];
|
||||||
finding.WithKey("FactDownload", "ValueMbpsDecimal", speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture));
|
finding.WithKey("FactDownload", "ValueMbpsDecimal", speed.DownMbps.ToOneDecimal());
|
||||||
finding.WithKey("FactUpload", "ValueMbpsDecimal", speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture));
|
finding.WithKey("FactUpload", "ValueMbpsDecimal", speed.UpMbps.ToOneDecimal());
|
||||||
finding.WithKey("FactSample", "ValueSample", (speed.DownBytes / 1048576).ToString(CultureInfo.InvariantCulture), (speed.UpBytes / 1048576).ToString(CultureInfo.InvariantCulture));
|
finding.WithKey("FactSample", "ValueSample", (speed.DownBytes / 1048576).ToString(CultureInfo.InvariantCulture), (speed.UpBytes / 1048576).ToString(CultureInfo.InvariantCulture));
|
||||||
if (speed.RoundTripMs > 0) finding.WithKey("FactRoundTrip", "ValueMilliseconds", speed.RoundTripMs.ToString(CultureInfo.InvariantCulture));
|
if (speed.RoundTripMs > 0) finding.WithKey("FactRoundTrip", "ValueMilliseconds", speed.RoundTripMs.ToString(CultureInfo.InvariantCulture));
|
||||||
return finding;
|
return finding;
|
||||||
@@ -430,7 +430,7 @@ public static class Verdicts
|
|||||||
};
|
};
|
||||||
finding.With("FactSystem", device.System);
|
finding.With("FactSystem", device.System);
|
||||||
finding.WithKey("FactCores", "ValueCount", device.Cores.ToString(CultureInfo.InvariantCulture));
|
finding.WithKey("FactCores", "ValueCount", device.Cores.ToString(CultureInfo.InvariantCulture));
|
||||||
finding.WithKey("FactMemory", "ValueGb", Math.Round(device.MemoryMb / 1024.0, 1).ToString("0.#", CultureInfo.InvariantCulture));
|
finding.WithKey("FactMemory", "ValueGb", Math.Round(device.MemoryMb / 1024.0, 1).ToOneDecimal());
|
||||||
if (device.FreeDiskGb > 0) finding.WithKey("FactDisk", "ValueGbFree", device.FreeDiskGb.ToString(CultureInfo.InvariantCulture));
|
if (device.FreeDiskGb > 0) finding.WithKey("FactDisk", "ValueGbFree", device.FreeDiskGb.ToString(CultureInfo.InvariantCulture));
|
||||||
if (device.OnBattery is not null) finding.WithKey("FactBattery", device.OnBattery == true ? "ValueYes" : "ValueNo");
|
if (device.OnBattery is not null) finding.WithKey("FactBattery", device.OnBattery == true ? "ValueYes" : "ValueNo");
|
||||||
return finding;
|
return finding;
|
||||||
|
|||||||
@@ -179,22 +179,21 @@ public sealed class RegistryTable : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RegistryWriter : IDisposable
|
public sealed class RegistryWriter(Stream output, DateTimeOffset date) : IDisposable
|
||||||
{
|
{
|
||||||
const int ChunkEntries = 16384;
|
const int ChunkEntries = 16384;
|
||||||
|
|
||||||
readonly Stream output;
|
readonly long header = Reserve(output);
|
||||||
readonly DateTimeOffset date;
|
|
||||||
readonly List<(UInt128 Start, RegistryBlock Block)> pending = [];
|
readonly List<(UInt128 Start, RegistryBlock Block)> pending = [];
|
||||||
readonly List<(UInt128 Start, long Length)>[] index = [[], []];
|
readonly List<(UInt128 Start, long Length)>[] index = [[], []];
|
||||||
int family;
|
int family;
|
||||||
int count;
|
int count;
|
||||||
|
|
||||||
public RegistryWriter(Stream output, DateTimeOffset date)
|
static long Reserve(Stream output)
|
||||||
{
|
{
|
||||||
this.output = output;
|
var at = output.Position;
|
||||||
this.date = date;
|
|
||||||
output.Write(new byte[RegistryTable.HeaderBytes]);
|
output.Write(new byte[RegistryTable.HeaderBytes]);
|
||||||
|
return at;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Add(bool v6, UInt128 start, RegistryBlock block)
|
public void Add(bool v6, UInt128 start, RegistryBlock block)
|
||||||
@@ -225,7 +224,7 @@ public sealed class RegistryWriter : IDisposable
|
|||||||
}
|
}
|
||||||
writer.Flush();
|
writer.Flush();
|
||||||
var end = output.Position;
|
var end = output.Position;
|
||||||
output.Position = 0;
|
output.Position = header;
|
||||||
writer.Write(RegistryTable.Magic);
|
writer.Write(RegistryTable.Magic);
|
||||||
writer.Write(date.ToUnixTimeSeconds());
|
writer.Write(date.ToUnixTimeSeconds());
|
||||||
writer.Write(count);
|
writer.Write(count);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public static partial class Sources
|
|||||||
{
|
{
|
||||||
var names = new Dictionary<string, string>(StringComparer.Ordinal);
|
var names = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress));
|
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress));
|
||||||
string line;
|
var line = default(string);
|
||||||
while ((line = reader.ReadLine()) is not null)
|
while ((line = reader.ReadLine()) is not null)
|
||||||
{
|
{
|
||||||
var parts = line.Split('\t');
|
var parts = line.Split('\t');
|
||||||
@@ -36,7 +36,7 @@ public static partial class Sources
|
|||||||
{
|
{
|
||||||
var seen = new Dictionary<uint, Dictionary<string, int>>();
|
var seen = new Dictionary<uint, Dictionary<string, int>>();
|
||||||
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress));
|
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress));
|
||||||
string line;
|
var line = default(string);
|
||||||
while ((line = reader.ReadLine()) is not null)
|
while ((line = reader.ReadLine()) is not null)
|
||||||
{
|
{
|
||||||
var first = line.IndexOf(',');
|
var first = line.IndexOf(',');
|
||||||
@@ -62,7 +62,7 @@ public static partial class Sources
|
|||||||
{
|
{
|
||||||
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress), Encoding.Latin1);
|
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress), Encoding.Latin1);
|
||||||
RegistryRange range = null;
|
RegistryRange range = null;
|
||||||
string line;
|
var line = default(string);
|
||||||
while ((line = reader.ReadLine()) is not null)
|
while ((line = reader.ReadLine()) is not null)
|
||||||
{
|
{
|
||||||
if (line.Length == 0)
|
if (line.Length == 0)
|
||||||
@@ -90,7 +90,7 @@ public static partial class Sources
|
|||||||
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress), Encoding.Latin1);
|
using var reader = new StreamReader(new GZipStream(gzip, CompressionMode.Decompress), Encoding.Latin1);
|
||||||
RegistryRange range = null;
|
RegistryRange range = null;
|
||||||
string status = null;
|
string status = null;
|
||||||
string line;
|
var line = default(string);
|
||||||
while ((line = reader.ReadLine()) is not null)
|
while ((line = reader.ReadLine()) is not null)
|
||||||
{
|
{
|
||||||
if (line.Length == 0)
|
if (line.Length == 0)
|
||||||
@@ -145,7 +145,7 @@ public static partial class Sources
|
|||||||
string name = null;
|
string name = null;
|
||||||
string tuple = null;
|
string tuple = null;
|
||||||
var plain = false;
|
var plain = false;
|
||||||
string line;
|
var line = default(string);
|
||||||
while ((line = textpb.ReadLine()) is not null)
|
while ((line = textpb.ReadLine()) is not null)
|
||||||
{
|
{
|
||||||
var text = line.Trim();
|
var text = line.Trim();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed class Answerer : IAsyncDisposable
|
|||||||
Answerer(TcpListener listener)
|
Answerer(TcpListener listener)
|
||||||
{
|
{
|
||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
loop = Task.Run(Accept);
|
loop = Task.Run(() => Accept(stop.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Accept(string nonce, byte[] key) => keys[nonce] = key;
|
public void Accept(string nonce, byte[] key) => keys[nonce] = key;
|
||||||
@@ -46,30 +46,30 @@ public sealed class Answerer : IAsyncDisposable
|
|||||||
throw new InvalidOperationException("no free port for the answerer");
|
throw new InvalidOperationException("no free port for the answerer");
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Accept()
|
async Task Accept(CancellationToken ct)
|
||||||
{
|
{
|
||||||
while (!stop.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
TcpClient client;
|
var client = default(TcpClient);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
client = await listener.AcceptTcpClientAsync(stop.Token);
|
client = await listener.AcceptTcpClientAsync(ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException)
|
catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_ = Task.Run(() => Answer(client));
|
_ = Task.Run(() => Answer(client, ct));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Answer(TcpClient client)
|
async Task Answer(TcpClient client, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using (client)
|
using (client)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stop.Token);
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(4));
|
timeout.CancelAfter(TimeSpan.FromSeconds(4));
|
||||||
var stream = client.GetStream();
|
var stream = client.GetStream();
|
||||||
var buffer = new byte[128];
|
var buffer = new byte[128];
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ public class SystemPlatform : IPlatform
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Directory.Exists("/sys/class/power_supply") && Directory.EnumerateDirectories("/sys/class/power_supply")
|
return Directory.Exists("/sys/class/power_supply") && Directory.EnumerateDirectories("/sys/class/power_supply")
|
||||||
.Any(d => File.Exists(Path.Combine(d, "type")) && File.ReadAllText(Path.Combine(d, "type")).Trim() == "Battery");
|
.Any(d => Attribute(d, "type") == "Battery" && Attribute(d, "scope") != "Device");
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
@@ -183,6 +183,12 @@ public class SystemPlatform : IPlatform
|
|||||||
return product.StartsWith(maker, StringComparison.OrdinalIgnoreCase) ? product : $"{maker} {product}";
|
return product.StartsWith(maker, StringComparison.OrdinalIgnoreCase) ? product : $"{maker} {product}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string Attribute(string folder, string name)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(folder, name);
|
||||||
|
return File.Exists(path) ? File.ReadAllText(path).Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
static string Dmi(string name)
|
static string Dmi(string name)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -199,7 +205,7 @@ public class SystemPlatform : IPlatform
|
|||||||
public virtual LocalSnapshot ReadNetwork()
|
public virtual LocalSnapshot ReadNetwork()
|
||||||
{
|
{
|
||||||
var snapshot = new LocalSnapshot();
|
var snapshot = new LocalSnapshot();
|
||||||
NetworkInterface[] adapters;
|
var adapters = default(NetworkInterface[]);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
adapters = NetworkInterface.GetAllNetworkInterfaces();
|
adapters = NetworkInterface.GetAllNetworkInterfaces();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
|
||||||
using MeWeb.Shared;
|
using MeWeb.Shared;
|
||||||
@@ -30,19 +31,19 @@ public static class SpeedCheck
|
|||||||
var clock = Stopwatch.StartNew();
|
var clock = Stopwatch.StartNew();
|
||||||
using (var response = await https.GetAsync(Endpoints.Base + Endpoints.Speed.GET_M_Down(report.DownBytes), HttpCompletionOption.ResponseHeadersRead, timeout.Token))
|
using (var response = await https.GetAsync(Endpoints.Base + Endpoints.Speed.GET_M_Down(report.DownBytes), HttpCompletionOption.ResponseHeadersRead, timeout.Token))
|
||||||
{
|
{
|
||||||
if ((int)response.StatusCode == 429)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
report.Limited = true;
|
report.Limited = response.Content.Headers.ContentType?.MediaType == "application/json"
|
||||||
|
&& (await response.Content.ReadFromJsonAsync(MeWebJsonContext.Default.ViewRefusal, timeout.Token))?.Reason is "too-many" or "busy";
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
await using var body = await response.Content.ReadAsStreamAsync(timeout.Token);
|
await using var body = await response.Content.ReadAsStreamAsync(timeout.Token);
|
||||||
var buffer = new byte[64 * 1024];
|
var buffer = new byte[64 * 1024];
|
||||||
var warm = report.DownBytes / 4;
|
var warm = report.DownBytes / 4;
|
||||||
long total = 0;
|
long total = 0;
|
||||||
long warmBytes = 0;
|
long warmBytes = 0;
|
||||||
var warmAt = TimeSpan.Zero;
|
var warmAt = TimeSpan.Zero;
|
||||||
int read;
|
var read = default(int);
|
||||||
while ((read = await body.ReadAsync(buffer, timeout.Token)) > 0)
|
while ((read = await body.ReadAsync(buffer, timeout.Token)) > 0)
|
||||||
{
|
{
|
||||||
total += read;
|
total += read;
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public static class Gateway
|
|||||||
window.CancelAfter(TimeSpan.FromSeconds(2.5));
|
window.CancelAfter(TimeSpan.FromSeconds(2.5));
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
UdpReceiveResult received;
|
var received = default(UdpReceiveResult);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
received = await udp.ReceiveAsync(window.Token);
|
received = await udp.ReceiveAsync(window.Token);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class Leftovers(string folder)
|
|||||||
|
|
||||||
public async Task<int> Clean(CancellationToken ct)
|
public async Task<int> Clean(CancellationToken ct)
|
||||||
{
|
{
|
||||||
List<Mapping> list;
|
var list = default(List<Mapping>);
|
||||||
lock (gate) list = Read();
|
lock (gate) list = Read();
|
||||||
foreach (var mapping in list.Where(m => m.Until > DateTimeOffset.UtcNow.AddMinutes(-1)))
|
foreach (var mapping in list.Where(m => m.Until > DateTimeOffset.UtcNow.AddMinutes(-1)))
|
||||||
await PortMapper.Remove(mapping, ct);
|
await PortMapper.Remove(mapping, ct);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ public static class DownloadPage
|
|||||||
{
|
{
|
||||||
var asset = ReleaseFeed.Asset(release, platform);
|
var asset = ReleaseFeed.Asset(release, platform);
|
||||||
if (asset is null) continue;
|
if (asset is null) continue;
|
||||||
html.Append($"<a class=\"button\" href=\"{Endpoints.Downloads.GET_M_Platform(platform)}\">{E(label)} · {asset.Size / 1048576.0:0} MB</a>");
|
html.Append($"<a class=\"button\" href=\"{Endpoints.Downloads.GET_M_Platform(platform)}\">{E(label)} · {asset.Size.ToWholeMegabytes()} MB</a>");
|
||||||
}
|
}
|
||||||
html.Append($"<p class=\"soft\">{note}</p>{more}</div>");
|
html.Append($"<p class=\"soft\">{note}</p>{more}</div>");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public partial class ObservedNotes(IOptionsMonitor<ShareOptions> shares, IOption
|
|||||||
var latest = new Dictionary<(uint, string, string), SharedLine>();
|
var latest = new Dictionary<(uint, string, string), SharedLine>();
|
||||||
foreach (var file in Directory.EnumerateFiles(settings.Folder, "MW-*.json"))
|
foreach (var file in Directory.EnumerateFiles(settings.Folder, "MW-*.json"))
|
||||||
{
|
{
|
||||||
SharedLine line;
|
var line = default(SharedLine);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
line = Read(File.ReadAllBytes(file));
|
line = Read(File.ReadAllBytes(file));
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ public partial class ProbeService(IOptionsMonitor<ProbeOptions> options, ILogger
|
|||||||
readonly SemaphoreSlim outbound = new(32, 32);
|
readonly SemaphoreSlim outbound = new(32, 32);
|
||||||
readonly SemaphoreSlim tracing = new(4, 4);
|
readonly SemaphoreSlim tracing = new(4, 4);
|
||||||
|
|
||||||
public (ViewProbeTicket Ticket, ViewRefusal Refusal, int Status) Issue(IPAddress caller)
|
public WebResult<ViewProbeTicket> Issue(IPAddress caller)
|
||||||
{
|
{
|
||||||
|
var result = new WebResult<ViewProbeTicket>();
|
||||||
var refusal = Refuse(caller);
|
var refusal = Refuse(caller);
|
||||||
if (refusal is not null) return (null, new ViewRefusal { Reason = refusal }, StatusCodes.Status403Forbidden);
|
if (refusal is not null) return result.Invalidate(refusal, StatusCodes.Status403Forbidden);
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
foreach (var (id, old) in tickets)
|
foreach (var (id, old) in tickets)
|
||||||
if (old.Expires < now) tickets.TryRemove(id, out _);
|
if (old.Expires < now) tickets.TryRemove(id, out _);
|
||||||
@@ -47,7 +48,7 @@ public partial class ProbeService(IOptionsMonitor<ProbeOptions> options, ILogger
|
|||||||
if (lastHour >= options.CurrentValue.PerHour || times.Count >= options.CurrentValue.PerDay)
|
if (lastHour >= options.CurrentValue.PerHour || times.Count >= options.CurrentValue.PerDay)
|
||||||
{
|
{
|
||||||
var retry = lastHour >= options.CurrentValue.PerHour ? times.Where(t => now - t < TimeSpan.FromHours(1)).Min().AddHours(1) : times.Min().AddDays(1);
|
var retry = lastHour >= options.CurrentValue.PerHour ? times.Where(t => now - t < TimeSpan.FromHours(1)).Min().AddHours(1) : times.Min().AddDays(1);
|
||||||
return (null, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = (int)Math.Ceiling((retry - now).TotalSeconds) }, StatusCodes.Status429TooManyRequests);
|
return result.Invalidate("too-many", retryAfterSeconds: (int)Math.Ceiling((retry - now).TotalSeconds));
|
||||||
}
|
}
|
||||||
times.Add(now);
|
times.Add(now);
|
||||||
}
|
}
|
||||||
@@ -61,27 +62,30 @@ public partial class ProbeService(IOptionsMonitor<ProbeOptions> options, ILogger
|
|||||||
Expires = now + TicketLife,
|
Expires = now + TicketLife,
|
||||||
};
|
};
|
||||||
tickets[ticket.Id] = ticket;
|
tickets[ticket.Id] = ticket;
|
||||||
return (new ViewProbeTicket { Id = ticket.Id, Nonce = ticket.Nonce, Key = Convert.ToBase64String(ticket.Key), Address = caller.ToString(), Seconds = (int)TicketLife.TotalSeconds }, null, StatusCodes.Status200OK);
|
result.Data = new ViewProbeTicket { Id = ticket.Id, Nonce = ticket.Nonce, Key = Convert.ToBase64String(ticket.Key), Address = caller.ToString(), Seconds = (int)TicketLife.TotalSeconds };
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(ViewProbe Probe, ViewRefusal Refusal, int Status)> Run(string id, IPAddress caller, ProbeForm form, CancellationToken ct)
|
public async Task<WebResult<ViewProbe>> Run(string id, IPAddress caller, ProbeForm form, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var result = new WebResult<ViewProbe>();
|
||||||
if (id is null || !tickets.TryRemove(id, out var ticket) || ticket.Expires < DateTimeOffset.UtcNow)
|
if (id is null || !tickets.TryRemove(id, out var ticket) || ticket.Expires < DateTimeOffset.UtcNow)
|
||||||
return (null, new ViewRefusal { Reason = "unknown-ticket" }, StatusCodes.Status404NotFound);
|
return result.Invalidate("unknown-ticket", StatusCodes.Status404NotFound);
|
||||||
if (!ticket.Address.Equals(caller)) return (null, new ViewRefusal { Reason = "other-address" }, StatusCodes.Status403Forbidden);
|
if (!ticket.Address.Equals(caller)) return result.Invalidate("other-address", StatusCodes.Status403Forbidden);
|
||||||
var ports = form?.Ports.Distinct().ToList() ?? [];
|
var ports = form?.Ports.Distinct().ToList() ?? [];
|
||||||
if (ports.Count is 0 or > 3 || ports.Any(p => p is not (80 or 443) and not (>= 49152 and <= 65535)))
|
if (ports.Count is 0 or > 3 || ports.Any(p => p is not (80 or 443) and not (>= 49152 and <= 65535)))
|
||||||
return (null, new ViewRefusal { Reason = "bad-ports" }, StatusCodes.Status400BadRequest);
|
return result.Invalidate("bad-ports");
|
||||||
var network = NetworkOf(caller);
|
var network = NetworkOf(caller);
|
||||||
if (!running.TryAdd(network, 0)) return (null, new ViewRefusal { Reason = "busy" }, StatusCodes.Status409Conflict);
|
if (!running.TryAdd(network, 0)) return result.Invalidate("busy");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = new ViewProbe { Address = caller.ToString() };
|
var probe = new ViewProbe { Address = caller.ToString() };
|
||||||
var route = Trace(caller, ct);
|
var route = Trace(caller, ct);
|
||||||
foreach (var port in ports) result.Ports.Add(new ViewProbePort { Port = port, Outcome = await Knock(ticket, port, ct) });
|
foreach (var port in ports) probe.Ports.Add(new ViewProbePort { Port = port, Outcome = await Knock(ticket, port, ct) });
|
||||||
result.Route = await route;
|
probe.Route = await route;
|
||||||
log.LogInformation("{Service} ran {Count} port(s)", nameof(ProbeService), ports.Count);
|
log.LogInformation("{Service} ran {Count} port(s)", nameof(ProbeService), ports.Count);
|
||||||
return (result, null, StatusCodes.Status200OK);
|
result.Data = probe;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -245,7 +249,7 @@ public partial class ProbeService(IOptionsMonitor<ProbeOptions> options, ILogger
|
|||||||
{
|
{
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
public string Nonce { get; set; }
|
public string Nonce { get; set; }
|
||||||
public byte[] Key { get; set; }
|
public byte[] Key { get; set; } = [];
|
||||||
public IPAddress Address { get; set; }
|
public IPAddress Address { get; set; }
|
||||||
public DateTimeOffset Expires { get; set; }
|
public DateTimeOffset Expires { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
|
||||||
using MeWeb.Core.Knowledge;
|
using MeWeb.Core.Knowledge;
|
||||||
using MeWeb.Shared;
|
using MeWeb.Shared;
|
||||||
@@ -46,8 +47,8 @@ public static class ServerApi
|
|||||||
{
|
{
|
||||||
var caller = Caller(context);
|
var caller = Caller(context);
|
||||||
if (caller is null) return Results.BadRequest();
|
if (caller is null) return Results.BadRequest();
|
||||||
if (!speed.Allow(caller)) return Refused(context, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = 3600 }, StatusCodes.Status429TooManyRequests);
|
if (!speed.Allow(caller)) return Refused(context, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = 3600 }, StatusCodes.Status400BadRequest);
|
||||||
if (!speed.Enter()) return Refused(context, new ViewRefusal { Reason = "busy", RetryAfterSeconds = 60 }, StatusCodes.Status429TooManyRequests);
|
if (!speed.Enter()) return Refused(context, new ViewRefusal { Reason = "busy", RetryAfterSeconds = 60 }, StatusCodes.Status400BadRequest);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var size = Math.Clamp(bytes ?? 1024 * 1024, 1024, Endpoints.Speed.MaxDownBytes);
|
var size = Math.Clamp(bytes ?? 1024 * 1024, 1024, Endpoints.Speed.MaxDownBytes);
|
||||||
@@ -63,7 +64,7 @@ public static class ServerApi
|
|||||||
static async Task<IResult> Up(HttpContext context, CancellationToken ct)
|
static async Task<IResult> Up(HttpContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var received = await SpeedService.Drain(context.Request.Body, ct);
|
var received = await SpeedService.Drain(context.Request.Body, ct);
|
||||||
return received < 0 ? Results.StatusCode(StatusCodes.Status413PayloadTooLarge) : Results.NoContent();
|
return received < 0 ? Refused(context, new ViewRefusal { Reason = "size" }, StatusCodes.Status400BadRequest) : Results.NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public const string DeleteTokenHeader = "X-Delete-Token";
|
public const string DeleteTokenHeader = "X-Delete-Token";
|
||||||
@@ -72,16 +73,14 @@ public static class ServerApi
|
|||||||
{
|
{
|
||||||
var caller = Caller(context);
|
var caller = Caller(context);
|
||||||
if (caller is null) return Results.BadRequest();
|
if (caller is null) return Results.BadRequest();
|
||||||
var (ticket, refusal, status) = probe.Issue(caller);
|
return Answer(context, probe.Issue(caller), MeWebJsonContext.Default.ViewProbeTicket);
|
||||||
return ticket is not null ? Results.Json(ticket, MeWebJsonContext.Default.ViewProbeTicket) : Refused(context, refusal, status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task<IResult> Run(string id, ProbeForm form, HttpContext context, ProbeService probe, CancellationToken ct)
|
static async Task<IResult> Run(string id, ProbeForm form, HttpContext context, ProbeService probe, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var caller = Caller(context);
|
var caller = Caller(context);
|
||||||
if (caller is null) return Results.BadRequest();
|
if (caller is null) return Results.BadRequest();
|
||||||
var (result, refusal, status) = await probe.Run(id, caller, form, ct);
|
return Answer(context, await probe.Run(id, caller, form, ct), MeWebJsonContext.Default.ViewProbe);
|
||||||
return result is not null ? Results.Json(result, MeWebJsonContext.Default.ViewProbe) : Refused(context, refusal, status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task<IResult> Share(HttpContext context, ShareService shares, CancellationToken ct)
|
static async Task<IResult> Share(HttpContext context, ShareService shares, CancellationToken ct)
|
||||||
@@ -90,19 +89,21 @@ public static class ServerApi
|
|||||||
if (caller is null) return Results.BadRequest();
|
if (caller is null) return Results.BadRequest();
|
||||||
using var buffer = new MemoryStream();
|
using var buffer = new MemoryStream();
|
||||||
var chunk = new byte[8192];
|
var chunk = new byte[8192];
|
||||||
int read;
|
var read = default(int);
|
||||||
while ((read = await context.Request.Body.ReadAsync(chunk, ct)) > 0)
|
while ((read = await context.Request.Body.ReadAsync(chunk, ct)) > 0)
|
||||||
{
|
{
|
||||||
buffer.Write(chunk, 0, read);
|
buffer.Write(chunk, 0, read);
|
||||||
if (buffer.Length > ShareService.MaxBytes) return Refused(context, new ViewRefusal { Reason = "size" }, StatusCodes.Status413PayloadTooLarge);
|
if (buffer.Length > ShareService.MaxBytes) return Refused(context, new ViewRefusal { Reason = "size" }, StatusCodes.Status400BadRequest);
|
||||||
}
|
}
|
||||||
var (shared, refusal, status) = shares.Create(caller, buffer.ToArray());
|
return Answer(context, shares.Create(caller, buffer.ToArray()), MeWebJsonContext.Default.ViewShared);
|
||||||
return shared is not null ? Results.Json(shared, MeWebJsonContext.Default.ViewShared) : Refused(context, refusal, status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static IResult Unshare(string id, HttpContext context, ShareService shares) =>
|
static IResult Unshare(string id, HttpContext context, ShareService shares) =>
|
||||||
Results.StatusCode(shares.Delete(id, context.Request.Headers[DeleteTokenHeader].ToString()));
|
Results.StatusCode(shares.Delete(id, context.Request.Headers[DeleteTokenHeader].ToString()));
|
||||||
|
|
||||||
|
static IResult Answer<T>(HttpContext context, WebResult<T> result, JsonTypeInfo<T> type) where T : class =>
|
||||||
|
result.IsInvalid ? Refused(context, result.Refusal, result.StatusCode) : Results.Json(result.Data, type);
|
||||||
|
|
||||||
static IResult Refused(HttpContext context, ViewRefusal refusal, int status)
|
static IResult Refused(HttpContext context, ViewRefusal refusal, int status)
|
||||||
{
|
{
|
||||||
if (refusal.RetryAfterSeconds > 0) context.Response.Headers.RetryAfter = refusal.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
if (refusal.RetryAfterSeconds > 0) context.Response.Headers.RetryAfter = refusal.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ public partial class ShareService(IOptionsMonitor<ShareOptions> options, ILogger
|
|||||||
readonly ConcurrentDictionary<string, int> today = new();
|
readonly ConcurrentDictionary<string, int> today = new();
|
||||||
string day = "";
|
string day = "";
|
||||||
|
|
||||||
public (ViewShared Shared, ViewRefusal Refusal, int Status) Create(IPAddress caller, byte[] body)
|
public WebResult<ViewShared> Create(IPAddress caller, byte[] body)
|
||||||
{
|
{
|
||||||
if (body.Length is 0 or > MaxBytes) return (null, new ViewRefusal { Reason = "size" }, StatusCodes.Status413PayloadTooLarge);
|
var result = new WebResult<ViewShared>();
|
||||||
if (!Valid(body)) return (null, new ViewRefusal { Reason = "shape" }, StatusCodes.Status400BadRequest);
|
if (body.Length is 0 or > MaxBytes) return result.Invalidate("size");
|
||||||
|
if (!Valid(body)) return result.Invalidate("shape");
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var date = now.ToString("yyyy-MM-dd");
|
var date = now.ToString("yyyy-MM-dd");
|
||||||
@@ -45,7 +46,7 @@ public partial class ShareService(IOptionsMonitor<ShareOptions> options, ILogger
|
|||||||
if (count > options.CurrentValue.PerDay)
|
if (count > options.CurrentValue.PerDay)
|
||||||
{
|
{
|
||||||
var midnight = new DateTimeOffset(now.UtcDateTime.Date.AddDays(1), TimeSpan.Zero);
|
var midnight = new DateTimeOffset(now.UtcDateTime.Date.AddDays(1), TimeSpan.Zero);
|
||||||
return (null, new ViewRefusal { Reason = "too-many", RetryAfterSeconds = (int)Math.Ceiling((midnight - now).TotalSeconds) }, StatusCodes.Status429TooManyRequests);
|
return result.Invalidate("too-many", retryAfterSeconds: (int)Math.Ceiling((midnight - now).TotalSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
var id = $"MW-{Code(4)}-{Code(4)}";
|
var id = $"MW-{Code(4)}-{Code(4)}";
|
||||||
@@ -55,7 +56,8 @@ public partial class ShareService(IOptionsMonitor<ShareOptions> options, ILogger
|
|||||||
Directory.CreateDirectory(options.CurrentValue.Folder);
|
Directory.CreateDirectory(options.CurrentValue.Folder);
|
||||||
File.WriteAllText(PathOf(id), record);
|
File.WriteAllText(PathOf(id), record);
|
||||||
log.LogInformation("{Service} stored a submission", nameof(ShareService));
|
log.LogInformation("{Service} stored a submission", nameof(ShareService));
|
||||||
return (new ViewShared { Id = id, Token = token, KeepUntil = keepUntil.ToString("yyyy-MM-dd") }, null, StatusCodes.Status200OK);
|
result.Data = new ViewShared { Id = id, Token = token, KeepUntil = keepUntil.ToString("yyyy-MM-dd") };
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Delete(string id, string token)
|
public int Delete(string id, string token)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class SpeedService
|
|||||||
{
|
{
|
||||||
var buffer = new byte[64 * 1024];
|
var buffer = new byte[64 * 1024];
|
||||||
long total = 0;
|
long total = 0;
|
||||||
int read;
|
var read = default(int);
|
||||||
while ((read = await body.ReadAsync(buffer, ct)) > 0)
|
while ((read = await body.ReadAsync(buffer, ct)) > 0)
|
||||||
{
|
{
|
||||||
total += read;
|
total += read;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MeWeb.Shared;
|
||||||
|
|
||||||
|
public static class NumbersExtensions
|
||||||
|
{
|
||||||
|
public static string ToOneDecimal(this double value) => value.ToString("0.#", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
public static string ToWholeMegabytes(this long bytes) => (bytes / 1048576.0).ToString("0", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace MeWeb.Shared;
|
||||||
|
|
||||||
|
public class WebResult<T> where T : class
|
||||||
|
{
|
||||||
|
public T Data { get; set; }
|
||||||
|
public ViewRefusal Refusal { get; private set; }
|
||||||
|
public int StatusCode { get; private set; } = (int)HttpStatusCode.OK;
|
||||||
|
public bool IsInvalid => Refusal is not null;
|
||||||
|
public bool IsValid => Refusal is null;
|
||||||
|
|
||||||
|
public WebResult<T> Invalidate(string reason, int statusCode = (int)HttpStatusCode.BadRequest, int retryAfterSeconds = 0) =>
|
||||||
|
Invalidate(new ViewRefusal { Reason = reason, RetryAfterSeconds = retryAfterSeconds }, statusCode);
|
||||||
|
|
||||||
|
public WebResult<T> Invalidate(ViewRefusal refusal, int statusCode = (int)HttpStatusCode.BadRequest)
|
||||||
|
{
|
||||||
|
Data = null;
|
||||||
|
Refusal = refusal;
|
||||||
|
StatusCode = statusCode;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Headless;
|
using Avalonia.Headless;
|
||||||
@@ -10,17 +12,57 @@ using MeWeb.App.Services;
|
|||||||
using MeWeb.App.ViewModels;
|
using MeWeb.App.ViewModels;
|
||||||
using MeWeb.App.Views;
|
using MeWeb.App.Views;
|
||||||
using MeWeb.Core.Checks;
|
using MeWeb.Core.Checks;
|
||||||
|
using MeWeb.Core.Knowledge;
|
||||||
|
using MeWeb.Screenshots;
|
||||||
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
var output = args.Length > 0 ? args[0] : Path.Combine("dist", "screenshots");
|
var store = args.Length > 0 && args[0] == "--store";
|
||||||
var codes = args.Length > 1 ? args[1].Split(',', StringSplitOptions.RemoveEmptyEntries) : Languages.All.Select(l => l.Code).ToArray();
|
var output = store ? args.Length > 1 ? args[1] : Path.Combine("fastlane", "metadata", "android") : args.Length > 0 ? args[0] : Path.Combine("dist", "screenshots");
|
||||||
|
var codes = !store && args.Length > 1 ? args[1].Split(',', StringSplitOptions.RemoveEmptyEntries) : Languages.All.Select(l => l.Code).ToArray();
|
||||||
Directory.CreateDirectory(output);
|
Directory.CreateDirectory(output);
|
||||||
|
var data = Directory.CreateTempSubdirectory("meweb-screenshots-");
|
||||||
|
AppServices.Folder = data.FullName;
|
||||||
|
if (store) App.Platform = new StorePhone();
|
||||||
|
|
||||||
var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry));
|
var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry));
|
||||||
await session.Dispatch(async () =>
|
await session.Dispatch(async () =>
|
||||||
{
|
{
|
||||||
var saved = AppSettings.Read();
|
if (store)
|
||||||
|
{
|
||||||
|
var invented = StoreRun.Finished(App.Services.GetRequiredService<KnowledgeBase>());
|
||||||
|
var early = StoreRun.Early(invented);
|
||||||
|
foreach (var (code, locale) in new[] { ("en", "en-US"), ("it", "it-IT") })
|
||||||
|
{
|
||||||
|
Localizer.Instance.SetLanguage(Languages.Find(code));
|
||||||
|
var folder = Path.Combine(locale, "images", "phoneScreenshots");
|
||||||
|
Phone(Path.Combine(folder, "2"), model => model.Replay(invented),
|
||||||
|
view => view.GetVisualDescendants().OfType<TextBlock>().First(t => t.Text == Localizer.Instance["UsesTitle"]));
|
||||||
|
Phone(Path.Combine(folder, "3"), model => model.Replay(invented));
|
||||||
|
Phone(Path.Combine(folder, "1"), model => model.Status = Reachability.Online);
|
||||||
|
Phone(Path.Combine(folder, "4"), model =>
|
||||||
|
{
|
||||||
|
model.Replay(invented);
|
||||||
|
model.Cards.First(c => c.Id == CheckId.Nat).IsExpanded = true;
|
||||||
|
}, view => view.GetVisualDescendants().OfType<Border>().First(b => b.Classes.Contains("card") && b.DataContext is CardViewModel { Id: CheckId.Nat }));
|
||||||
|
Phone(Path.Combine(folder, "5"), model =>
|
||||||
|
{
|
||||||
|
model.Replay(early);
|
||||||
|
model.Status = Reachability.Online;
|
||||||
|
model.IsChecking = true;
|
||||||
|
model.AskReach = false;
|
||||||
|
model.OfferShare = false;
|
||||||
|
model.Uses.Clear();
|
||||||
|
foreach (var id in Enum.GetValues<UseId>()) model.Uses.Add(new UseViewModel(id, null));
|
||||||
|
});
|
||||||
|
Phone(Path.Combine(folder, "6"), model =>
|
||||||
|
{
|
||||||
|
model.Replay(invented);
|
||||||
|
model.OpenShareCommand.Execute(null);
|
||||||
|
}, view => view.GetVisualDescendants().OfType<TextBlock>().First(t => t.Text == Localizer.Instance["ShareExact"]));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
var run = await App.Services.GetRequiredService<CheckRunner>().Run((_, _) => { }, CancellationToken.None);
|
var run = await App.Services.GetRequiredService<CheckRunner>().Run((_, _) => { }, CancellationToken.None);
|
||||||
Console.WriteLine($"run: {string.Join(", ", run.Findings.Select(f => $"{f.Id}={f.Outcome}"))}");
|
Console.WriteLine($"run: {string.Join(", ", run.Findings.Select(f => $"{f.Id}={f.Outcome}"))}");
|
||||||
foreach (var (width, height, label) in new[] { (1100, 1500, "desktop"), (400, 8000, "phone") })
|
foreach (var (width, height, label) in new[] { (1100, 1500, "desktop"), (400, 8000, "phone") })
|
||||||
@@ -48,12 +90,20 @@ await session.Dispatch(async () =>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppSettings.Write(saved);
|
|
||||||
return 0;
|
return 0;
|
||||||
}, CancellationToken.None);
|
}, CancellationToken.None);
|
||||||
|
data.Delete(true);
|
||||||
Environment.Exit(0);
|
Environment.Exit(0);
|
||||||
|
|
||||||
void Shoot(int width, int height, string name, Action<MainViewModel> prepare, Action<MainView> arrange = null)
|
void Phone(string name, Action<MainViewModel> prepare, Func<MainView, Control> top = null) => Shoot(405, 720, name, prepare, top is null ? null : view =>
|
||||||
|
{
|
||||||
|
var target = top(view);
|
||||||
|
var scroller = target.FindAncestorOfType<ScrollViewer>();
|
||||||
|
var at = target.TranslatePoint(default, (Visual)scroller.Content);
|
||||||
|
scroller.Offset = new Vector(0, Math.Max(0, (at?.Y ?? 0) - 8));
|
||||||
|
}, 1080 / 405.0);
|
||||||
|
|
||||||
|
void Shoot(int width, int height, string name, Action<MainViewModel> prepare, Action<MainView> arrange = null, double scale = 1)
|
||||||
{
|
{
|
||||||
var fresh = AppSettings.Read();
|
var fresh = AppSettings.Read();
|
||||||
fresh.StorySeen = false;
|
fresh.StorySeen = false;
|
||||||
@@ -62,7 +112,19 @@ void Shoot(int width, int height, string name, Action<MainViewModel> prepare, Ac
|
|||||||
var view = new MainView { DataContext = model };
|
var view = new MainView { DataContext = model };
|
||||||
var window = new Window { Width = width, Height = height, Content = view };
|
var window = new Window { Width = width, Height = height, Content = view };
|
||||||
window.Show();
|
window.Show();
|
||||||
|
window.SetRenderScaling(scale);
|
||||||
model.AvailableWidth = width;
|
model.AvailableWidth = width;
|
||||||
|
if (store)
|
||||||
|
{
|
||||||
|
var patience = Stopwatch.StartNew();
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Thread.Sleep(20);
|
||||||
|
}
|
||||||
|
while (model.Status == Reachability.Unknown && patience.Elapsed < TimeSpan.FromSeconds(20));
|
||||||
|
model.LatestVersion = null;
|
||||||
|
}
|
||||||
prepare(model);
|
prepare(model);
|
||||||
for (var i = 0; i < 6; i++) Dispatcher.UIThread.RunJobs();
|
for (var i = 0; i < 6; i++) Dispatcher.UIThread.RunJobs();
|
||||||
if (arrange is not null)
|
if (arrange is not null)
|
||||||
@@ -74,6 +136,7 @@ void Shoot(int width, int height, string name, Action<MainViewModel> prepare, Ac
|
|||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
var frame = window.CaptureRenderedFrame();
|
var frame = window.CaptureRenderedFrame();
|
||||||
var path = Path.Combine(output, $"{name}.png");
|
var path = Path.Combine(output, $"{name}.png");
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||||
if (frame is not null)
|
if (frame is not null)
|
||||||
{
|
{
|
||||||
using var file = File.Create(path);
|
using var file = File.Create(path);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using MeWeb.Core.Checks;
|
||||||
|
using MeWeb.Core.History;
|
||||||
|
using MeWeb.Core.Knowledge;
|
||||||
|
using MeWeb.Core.Mail;
|
||||||
|
using MeWeb.Core.Net;
|
||||||
|
using MeWeb.Core.Router;
|
||||||
|
using MeWeb.Shared;
|
||||||
|
|
||||||
|
namespace MeWeb.Screenshots;
|
||||||
|
|
||||||
|
public class StorePhone : SystemPlatform
|
||||||
|
{
|
||||||
|
public override DeviceKind Device => DeviceKind.Phone;
|
||||||
|
|
||||||
|
public override string Name => "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StoreRun
|
||||||
|
{
|
||||||
|
const string PhoneAddress = "192.168.1.23";
|
||||||
|
const string PhoneAddressV6 = "2001:db8:4d2:1a00:5c3e:9bff:fe12:3456";
|
||||||
|
const string RouterInside = "192.168.1.1";
|
||||||
|
const string RouterOutside = "100.64.12.34";
|
||||||
|
const string SharedExit = "203.0.113.45";
|
||||||
|
const uint ExampleAsn = 64500;
|
||||||
|
const string ExampleProvider = "Example Net";
|
||||||
|
|
||||||
|
public static RunReport Finished(KnowledgeBase knowledge)
|
||||||
|
{
|
||||||
|
var run = new RunReport
|
||||||
|
{
|
||||||
|
StartedAt = new DateTimeOffset(2026, 11, 2, 18, 30, 0, TimeSpan.Zero),
|
||||||
|
Device = DeviceKind.Phone,
|
||||||
|
Online = new OnlineReport { State = OnlineState.Online },
|
||||||
|
Local = new LocalSnapshot
|
||||||
|
{
|
||||||
|
Interfaces = [new LocalInterface { Name = "wlan0", Kind = LinkKind.Wifi, SpeedMbps = 866, Addresses = [PhoneAddress, PhoneAddressV6], Gateways = [RouterInside, "fe80::1"] }],
|
||||||
|
Dns = [RouterInside],
|
||||||
|
},
|
||||||
|
PublicV4 = SharedExit,
|
||||||
|
PublicV6 = PhoneAddressV6,
|
||||||
|
NetworkV4 = new AsnEntry { Number = ExampleAsn, Name = ExampleProvider },
|
||||||
|
NetworkV6 = new AsnEntry { Number = ExampleAsn, Name = ExampleProvider },
|
||||||
|
BlockV4 = new RegistryBlock { Name = "EXAMPLENET-HOME", Status = BlockStatus.Assigned },
|
||||||
|
BlockV6 = new RegistryBlock { Name = "EXAMPLENET-HOME", Status = BlockStatus.Assigned },
|
||||||
|
Router = new RouterReport { Gateway = RouterInside, Upnp = Answer.Answered, UpnpVersion = 2, WanAddress = RouterOutside },
|
||||||
|
Hops =
|
||||||
|
[
|
||||||
|
new Hop { Distance = 1, Address = RouterInside, Kind = AddressKind.Private },
|
||||||
|
new Hop { Distance = 2, Address = "100.64.0.1", Kind = AddressKind.Shared },
|
||||||
|
new Hop { Distance = 3, Address = "198.51.100.17", Kind = AddressKind.Public },
|
||||||
|
new Hop { Distance = 4, Address = "198.51.100.90", Kind = AddressKind.Public },
|
||||||
|
new Hop { Distance = 5, Address = "192.0.2.80", Kind = AddressKind.Public, IsDestination = true },
|
||||||
|
],
|
||||||
|
Stun = new StunReport
|
||||||
|
{
|
||||||
|
LocalPort = 49731,
|
||||||
|
FirstServer = $"{Endpoints.SecondStunHost}:{Endpoints.SecondStunPort}",
|
||||||
|
FirstMapped = $"{SharedExit}:40071",
|
||||||
|
SecondServer = $"{Endpoints.StunHost}:{Endpoints.StunPort}",
|
||||||
|
SecondMapped = $"{SharedExit}:40071",
|
||||||
|
Behaviour = NatBehaviour.Easy,
|
||||||
|
Ports = new PortReport { Pattern = PortPattern.Block, Low = 40071, High = 40086, BlockLow = 38912, BlockSize = 2048, Hairpin = false, LifetimeDone = true, KeptAtLeast = 120 },
|
||||||
|
},
|
||||||
|
Mail = new MailReport
|
||||||
|
{
|
||||||
|
Port25 = PortState.Blocked,
|
||||||
|
ReverseName = "203-0-113-45.home.example.net",
|
||||||
|
ReverseLooksDynamic = true,
|
||||||
|
ReverseConfirmed = true,
|
||||||
|
Listings =
|
||||||
|
[
|
||||||
|
new Listing { List = "Spamhaus ZEN", State = ListingState.Listed, Residential = true, Code = "127.0.0.10" },
|
||||||
|
new Listing { List = "SpamCop", State = ListingState.Clean },
|
||||||
|
new Listing { List = "Barracuda", State = ListingState.Clean },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Dns = new DnsReport { Resolvers = [RouterInside], Validates = true, Hijacks = false, Ipv6Names = true },
|
||||||
|
DeviceReport = new DeviceReport { Kind = DeviceKind.Phone, System = "Android 15 (API 35, Arm64)", Cores = 8, MemoryMb = 7680, FreeDiskGb = 64, OnBattery = true },
|
||||||
|
Speed = new SpeedReport { DownMbps = 312.4, UpMbps = 48.7, DownBytes = 12 * 1024 * 1024, UpBytes = 4 * 1024 * 1024, RoundTripMs = 21 },
|
||||||
|
History = new HistorySummary { Checks = 3, Since = new DateTimeOffset(2026, 10, 19, 9, 0, 0, TimeSpan.Zero), AddressesSeen = 1, Changes = 0 },
|
||||||
|
};
|
||||||
|
var (nat, kind) = Verdicts.Nat(run.Local, run.Router, run.PublicV4, run.PublicV6, run.Hops);
|
||||||
|
run.Nat = kind;
|
||||||
|
run.Findings =
|
||||||
|
[
|
||||||
|
Verdicts.Online(run.Online, run.Local),
|
||||||
|
Verdicts.Connection(run.Local, null, knowledge),
|
||||||
|
Verdicts.Addresses(run.PublicV4, run.PublicV6, run.Local, run.Router),
|
||||||
|
Verdicts.Provider(knowledge, run.NetworkV4, run.NetworkV6, run.BlockV4, run.BlockV6, true, false, run),
|
||||||
|
Verdicts.Router(run.Router, run.Local),
|
||||||
|
nat,
|
||||||
|
Verdicts.Behaviour(run.Stun, run.Nat, false),
|
||||||
|
Verdicts.Mail(run.Mail, true),
|
||||||
|
Verdicts.Dns(run.Dns),
|
||||||
|
Verdicts.Speed(run.Speed),
|
||||||
|
Verdicts.Device(run.DeviceReport),
|
||||||
|
Verdicts.History(run.History),
|
||||||
|
];
|
||||||
|
run.Uses = Uses.Decide(run);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RunReport Early(RunReport finished) => new()
|
||||||
|
{
|
||||||
|
StartedAt = finished.StartedAt,
|
||||||
|
Device = finished.Device,
|
||||||
|
Local = finished.Local,
|
||||||
|
DeviceReport = finished.DeviceReport,
|
||||||
|
Findings = [.. finished.Findings.Where(f => f.Id is CheckId.Connection or CheckId.Device)],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ using (var input = File.OpenRead(download))
|
|||||||
using (var file = File.Create(Path.Combine(output, "ip2asn.bin")))
|
using (var file = File.Create(Path.Combine(output, "ip2asn.bin")))
|
||||||
AsnTable.Write(file, sourceDate, Sources.IpToAsn(input));
|
AsnTable.Write(file, sourceDate, Sources.IpToAsn(input));
|
||||||
foreach (var stale in new[] { "networks.bin", "carriers.json", "registry.bin" }) File.Delete(Path.Combine(output, stale));
|
foreach (var stale in new[] { "networks.bin", "carriers.json", "registry.bin" }) File.Delete(Path.Combine(output, stale));
|
||||||
string sha;
|
var sha = default(string);
|
||||||
using (var input = File.OpenRead(download)) sha = Convert.ToHexStringLower(SHA256.HashData(input));
|
using (var input = File.OpenRead(download)) sha = Convert.ToHexStringLower(SHA256.HashData(input));
|
||||||
File.WriteAllText(Path.Combine(output, "SOURCE.txt"), $"iptoasn.com, Public Domain Dedication and License (PDDL) 1.0\n{Fallback}\nlast-modified {sourceDate:O}\nsha256 {sha}\n");
|
File.WriteAllText(Path.Combine(output, "SOURCE.txt"), $"iptoasn.com, Public Domain Dedication and License (PDDL) 1.0\n{Fallback}\nlast-modified {sourceDate:O}\nsha256 {sha}\n");
|
||||||
Report();
|
Report();
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ return failed ? 1 : 0;
|
|||||||
void WriteNotes()
|
void WriteNotes()
|
||||||
{
|
{
|
||||||
var source = Path.Combine(root, "artifacts", "knowledge", "SOURCE.txt");
|
var source = Path.Combine(root, "artifacts", "knowledge", "SOURCE.txt");
|
||||||
var lines = Changes();
|
var notes = Path.Combine(root, "artifacts", "notes", "notes.md");
|
||||||
|
var lines = File.Exists(notes) ? [.. File.ReadAllLines(notes), ""] : new List<string>();
|
||||||
lines.AddRange(
|
lines.AddRange(
|
||||||
[
|
[
|
||||||
$"me-web? {version}, commit {commit}, built {builtAt}.",
|
$"me-web? {version}, commit {commit}, built {builtAt}.",
|
||||||
@@ -92,26 +93,6 @@ void WriteNotes()
|
|||||||
File.WriteAllLines(Path.Combine(root, "artifacts", "release-notes.md"), lines);
|
File.WriteAllLines(Path.Combine(root, "artifacts", "release-notes.md"), lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<string> Changes()
|
|
||||||
{
|
|
||||||
var previous = Capture("git", "-C", root, "describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^").Trim();
|
|
||||||
var log = Capture("git", "-C", root, "log", "--no-merges", "--reverse", "--format=%x1e%s%x1f%b", previous.Length > 0 ? $"{previous}..HEAD" : "HEAD");
|
|
||||||
var lines = new List<string>();
|
|
||||||
foreach (var entry in log.Split('\x1e', StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
{
|
|
||||||
var parts = entry.Split('\x1f');
|
|
||||||
var subject = Regex.Replace(parts[0].Trim(), @"^[""“]?me-web\?[""”]? [\d.]+:\s*", "");
|
|
||||||
if (subject.Length == 0) continue;
|
|
||||||
var body = (parts.Length > 1 ? parts[1] : "").Split('\n').Select(l => l.TrimEnd())
|
|
||||||
.Where(l => !Regex.IsMatch(l, @"^[A-Za-z-]+-(By|Session|Off-By):", RegexOptions.IgnoreCase))
|
|
||||||
.Where(l => !l.Contains("claude", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.SkipWhile(l => l.Length == 0).Reverse().SkipWhile(l => l.Length == 0).Reverse().ToList();
|
|
||||||
lines.AddRange([$"### {char.ToUpperInvariant(subject[0])}{subject[1..]}", ""]);
|
|
||||||
if (body.Count > 0) lines.AddRange([.. body, ""]);
|
|
||||||
}
|
|
||||||
return lines.Count == 0 ? [] : [$"## What's new in {version}", "", .. lines];
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] Stamp() => [$"-p:Version={version}", $"-p:BuildCommit={commit}", $"-p:BuildTimeUtc={builtAt}"];
|
string[] Stamp() => [$"-p:Version={version}", $"-p:BuildCommit={commit}", $"-p:BuildTimeUtc={builtAt}"];
|
||||||
|
|
||||||
string Linux()
|
string Linux()
|
||||||
@@ -151,6 +132,8 @@ string Linux()
|
|||||||
var png = Path.Combine(branding, "meweb-512.png");
|
var png = Path.Combine(branding, "meweb-512.png");
|
||||||
var svg = Path.Combine(branding, "meweb.svg");
|
var svg = Path.Combine(branding, "meweb.svg");
|
||||||
var yaml = Path.Combine(stage, "nfpm.yaml");
|
var yaml = Path.Combine(stage, "nfpm.yaml");
|
||||||
|
var history = Path.Combine(root, "artifacts", "notes", "changelog.yml");
|
||||||
|
var changelog = File.Exists(history) ? $"changelog: {history}" : "";
|
||||||
File.WriteAllText(yaml, $"""
|
File.WriteAllText(yaml, $"""
|
||||||
name: meweb
|
name: meweb
|
||||||
arch: amd64
|
arch: amd64
|
||||||
@@ -163,6 +146,7 @@ string Linux()
|
|||||||
vendor: Arasaka di Hvozdikov Yevheniy
|
vendor: Arasaka di Hvozdikov Yevheniy
|
||||||
homepage: https://git.thepra.dev/Arasaka/MeWeb
|
homepage: https://git.thepra.dev/Arasaka/MeWeb
|
||||||
license: AGPL-3.0-only
|
license: AGPL-3.0-only
|
||||||
|
{changelog}
|
||||||
description: |
|
description: |
|
||||||
Can you run your own server from here? “me-web?” checks your connection and device and
|
Can you run your own server from here? “me-web?” checks your connection and device and
|
||||||
explains, in plain words, what you could host at home and what stands in the way.
|
explains, in plain words, what you could host at home and what stands in the way.
|
||||||
@@ -381,7 +365,7 @@ string Reset(string folder)
|
|||||||
return folder;
|
return folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
string Size(string file) => $"{new FileInfo(file).Length / 1024.0 / 1024.0:0.0} MB";
|
string Size(string file) => $"{new FileInfo(file).Length.ToMegabytes()} MB";
|
||||||
|
|
||||||
string Option(string key)
|
string Option(string key)
|
||||||
{
|
{
|
||||||
@@ -420,3 +404,8 @@ static string FindRoot()
|
|||||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent;
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent;
|
||||||
return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)");
|
return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static class NumbersExtensions
|
||||||
|
{
|
||||||
|
public static string ToMegabytes(this long bytes) => (bytes / 1048576.0).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
@@ -47,18 +47,6 @@
|
|||||||
"kind": "tar",
|
"kind": "tar",
|
||||||
"dest": "tools/jdk/osx-arm64",
|
"dest": "tools/jdk/osx-arm64",
|
||||||
"check": "tools/jdk/osx-arm64/jdk-21.0.12+8/Contents/Home/bin/java"
|
"check": "tools/jdk/osx-arm64/jdk-21.0.12+8/Contents/Home/bin/java"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "uv",
|
|
||||||
"group": "fdroid",
|
|
||||||
"version": "0.13.1",
|
|
||||||
"license": "MIT or Apache-2.0 (build tool, not distributed)",
|
|
||||||
"rid": "linux-x64",
|
|
||||||
"url": "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-x86_64-unknown-linux-gnu.tar.gz",
|
|
||||||
"sha256": "eadd9d27c134ab5ac1ffd4473b97a741407f3ea238288f03121fc4deddf90220",
|
|
||||||
"kind": "tar",
|
|
||||||
"dest": "tools/fdroid/linux-x64",
|
|
||||||
"check": "tools/fdroid/linux-x64/uv-x86_64-unknown-linux-gnu/uv"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||