Compare commits

..

99 commits

Author SHA1 Message Date
3b4cc241a7
badges display
All checks were successful
Dotnet build and test / build (push) Successful in 6m9s
2026-08-29 04:17:15 +01:00
243c0f2780
Disposing the test fixtures
All checks were successful
Dotnet build and test / build (push) Successful in 6m49s
2026-08-29 04:00:10 +01:00
5b6d2e82db
Release 1.0.8-rc6
Some checks failed
Forgejo Release / release (push) Successful in 7m29s
Dotnet build and test / build (push) Failing after 5m43s
2026-08-29 02:43:03 +01:00
5d1649df80
dont boot twice the app 2026-08-29 02:41:59 +01:00
4a68494162
updated
All checks were successful
Dotnet build and test / build (push) Successful in 3m20s
2026-08-29 01:52:11 +01:00
ab8ab00ae8
Release 1.0.8-rc5
All checks were successful
Dotnet build and test / build (push) Successful in 3m15s
Forgejo Release / release (push) Successful in 5m58s
2026-08-29 01:42:55 +01:00
ecca6c07b4
Simpler is better
All checks were successful
Dotnet build and test / build (push) Successful in 5m29s
Forgejo Release / release (push) Successful in 6m45s
2026-08-29 01:21:21 +01:00
e08c61ba4c
restore a GitHub badge
All checks were successful
Dotnet build and test / build (push) Successful in 2m8s
Forgejo Release / release (push) Successful in 7m32s
2026-08-29 01:12:44 +01:00
4988253fdf
detail the release action 2026-08-29 01:06:24 +01:00
8226f54074
-MsBuild.GitVersion
All checks were successful
Dotnet build and test / build (push) Successful in 3m50s
Forgejo Release / release (push) Successful in 9m24s
2026-08-29 00:52:29 +01:00
74cb8795d2
x64 !
Some checks failed
Forgejo Release / release (push) Failing after 6m9s
Dotnet build and test / build (push) Successful in 4m37s
2026-08-29 00:39:20 +01:00
589e2ba6c3
release it
Some checks failed
Dotnet build and test / build (push) Successful in 4m14s
Forgejo Release / release (push) Has been cancelled
2026-08-29 00:27:39 +01:00
d738002677
build the x64 and arm64 together, in the same apk
Some checks failed
Dotnet build and test / build (push) Successful in 4m58s
Forgejo Release / release (push) Failing after 4m18s
2026-08-29 00:04:59 +01:00
06b33f18e4
keep the CodeQL Advanced badge
Some checks failed
Dotnet build and test / build (push) Successful in 8m40s
Forgejo Release / release (push) Failing after 18s
2026-08-28 23:43:44 +01:00
1cdb9619a0
reference my upstream
All checks were successful
Dotnet build and test / build (push) Successful in 8m8s
2026-08-28 23:35:06 +01:00
dc05266bdb
Release the android x86 package
Some checks failed
Dotnet build and test / build (push) Failing after 7m6s
Forgejo Release / release (push) Failing after 30s
2026-08-28 23:17:58 +01:00
4747942f93 Merge pull request 'release/1.0.8-rc4' (#44) from release/1.0.8-rc4 into main
All checks were successful
Dotnet build and test / build (push) Successful in 5m49s
Forgejo Release / release (push) Successful in 10m17s
Reviewed-on: #44
2026-08-28 22:52:44 +01:00
e689dbfe35
fixes the tests
All checks were successful
Dotnet build and test / build (pull_request) Successful in 7m35s
2026-08-28 22:41:05 +01:00
1c2e1760b0
refacto + test fixes
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m11s
2026-08-28 21:33:11 +01:00
e56bdd4329
dead code cleanup
Some checks failed
Dotnet build and test / build (pull_request) Failing after 7m37s
2026-08-28 20:58:10 +01:00
05db88f345
fixes the 404 on the Details button 2026-08-28 20:57:52 +01:00
e62adedc17
Fixes ACL backend support 2026-08-28 20:10:32 +01:00
0034a311f2
chore(release): bump version via gitversion for 1.0.8-rc4 2026-08-26 23:08:23 +01:00
6b161b0fb5 Merge pull request 'release/1.0.8-rc3' (#43) from release/1.0.8-rc3 into main
Some checks failed
Dotnet build and test / build (push) Failing after 15m24s
Reviewed-on: #43
2026-08-26 22:49:50 +01:00
671afebc12
Build image version bump
All checks were successful
Dotnet build and test / build (pull_request) Successful in 7m2s
Forgejo Release / release (push) Successful in 6m11s
2026-08-26 22:26:14 +01:00
ad2285b8c5
was breakingthe format
Some checks failed
Dotnet build and test / build (pull_request) Successful in 4m24s
Forgejo Release / release (push) Failing after 1m46s
2026-08-26 20:19:51 +01:00
09c8d79714
debian13!
Some checks failed
Dotnet build and test / build (pull_request) Successful in 9m7s
Forgejo Release / release (push) Failing after 22s
2026-08-26 20:04:39 +01:00
912dd88a02
gives this release a title
Some checks failed
Dotnet build and test / build (pull_request) Failing after 6m4s
Forgejo Release / release (push) Failing after 1s
2026-08-26 19:52:41 +01:00
400db94953
hit the runner
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 19:49:42 +01:00
5d81ab3094
Release 1.0.8-rc3
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 19:43:47 +01:00
45299d14c1
no apk to install
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m27s
2026-08-26 19:27:43 +01:00
bfcb7cd2ef
disable the Xamarin.UITest
Some checks failed
Dotnet build and test / build (pull_request) Failing after 25s
2026-08-26 19:23:27 +01:00
366cd0eed9
try with vulkan enabled
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 17:22:03 +01:00
29d842a8f9
-cores 1
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 17:18:31 +01:00
98cab22e60
RE_EMULATOR_DISABLE_CPUID_CHECK
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 17:16:47 +01:00
d63f065e03
try host cpu
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 17:15:11 +01:00
99c500d177
start qemu
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 17:13:20 +01:00
b0e70eb198
give a try to the v7
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10s
2026-08-26 17:06:14 +01:00
39400baadb
giving up
Some checks failed
Dotnet build and test / build (pull_request) Failing after 21s
2026-08-26 09:25:23 +01:00
d6b45024dc
kvm-ok is to install (or not?)
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 09:21:44 +01:00
e266c1de6e
syntax
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5s
2026-08-26 09:20:36 +01:00
f285a168bd
settings
Some checks failed
buildAndTest.yml / settings (push) Failing after 0s
buildAndTest.yml / settings (pull_request) Failing after 0s
2026-08-26 09:18:38 +01:00
1e3239498c
-gpu software
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 08:58:46 +01:00
696117ac5a
another try
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 08:56:14 +01:00
eb0cf11703
-cpu host
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 08:50:35 +01:00
1ba114ee33
gpu swiftshader
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 08:45:57 +01:00
f7956b0459
appel direct
Some checks are pending
Dotnet build and test / build (pull_request) Has started running
2026-08-26 08:28:19 +01:00
8003239159
docker image version bump
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 08:08:13 +01:00
8f9c55bf2d
apt-get update && apt-get install
Some checks failed
Dotnet build and test / build (pull_request) Failing after 20m29s
2026-08-26 07:09:10 +01:00
c4f6913fdb
LD_LIBRARY_PATH 2
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 07:05:00 +01:00
7e3b0a72ad
LD_LIBRARY_PATH
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 07:03:03 +01:00
7ccb23ba28
try the direct call
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:58:47 +01:00
955d925f33
-no-accel-check
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:53:28 +01:00
bbd869ab88
export USER=root
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:48:18 +01:00
c338fb33ed
Injecte la ligne parfaite
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:44:38 +01:00
016d16fda4
Injecter le groupe KVM
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:41:57 +01:00
8978225539
ne rien forcer
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:39:22 +01:00
e337e25aa4
Forcer l'appropriation et les droits KVM
Some checks failed
Dotnet build and test / build (pull_request) Failing after 14s
2026-08-26 06:27:04 +01:00
955725d739
Diagnostic système absolu du Runner
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:23:22 +01:00
7d1e5763ff
cleanup
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 06:19:42 +01:00
41a1febb24
pour test
Some checks failed
Dotnet build and test / build (pull_request) Failing after 15s
2026-08-26 06:09:16 +01:00
4f9acb17ff
pour test
Some checks failed
Dotnet build and test / build (pull_request) Failing after 19s
2026-08-26 06:07:50 +01:00
af1ca7b1bb
for reading
Some checks failed
Dotnet build and test / build (pull_request) Failing after 32s
2026-08-26 05:44:46 +01:00
bc6861dd26
no kvm
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 05:38:07 +01:00
801839465c
donne l'accès
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 05:25:18 +01:00
1a79ee93eb
avd name changed
Some checks are pending
Dotnet build and test / build (pull_request) Has started running
2026-08-26 05:15:13 +01:00
851920c79e
specify the docker image
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 05:09:24 +01:00
a9c18d6708
emulator
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 04:52:57 +01:00
70ad5100be
yatry
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 03:55:21 +01:00
dab56e508f
hit the AVD
All checks were successful
Dotnet build and test / build (pull_request) Successful in 1h29m2s
2026-08-26 02:24:03 +01:00
9d5667c459
to be clear
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 01:49:57 +01:00
36d58b6870
simpler is better
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-26 01:42:59 +01:00
553bd415a9
comment cleanup
Some checks failed
Dotnet build and test / build (pull_request) Failing after 7m15s
2026-08-26 01:24:19 +01:00
0bb293c6ee
postit_test_avd 2026-08-26 00:20:17 +01:00
a4ae0fde72
should work, Xamarin.UITest installs the app
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10m18s
2026-08-25 23:39:12 +01:00
863c549d51
reinstall when yet installed
Some checks failed
Dotnet build and test / build (pull_request) Failing after 9m12s
2026-08-25 23:17:17 +01:00
14b0cdb6a4
undated 2026-08-25 23:14:50 +01:00
43dd526e4f
fixes the step title 2026-08-25 23:07:19 +01:00
b18f86ed23
fixes the script
Some checks failed
Dotnet build and test / build (pull_request) Failing after 9m22s
2026-08-25 23:06:25 +01:00
d56ad25e6b
fixes the tests
Some checks failed
buildAndTest.yml / fixes the tests (push) Failing after 0s
buildAndTest.yml / fixes the tests (pull_request) Failing after 0s
2026-08-25 23:01:00 +01:00
50a07102cb
Do not fail at attaching developer tools
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10m48s
2026-08-25 21:15:04 +01:00
6d6c4f2a8a
Package renamed impact
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-25 20:30:39 +01:00
c707b5f29e
Release 1.0.8-rc3 - unstable
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10m56s
Forgejo Release / release (push) Failing after 8m43s
2026-08-25 19:49:12 +01:00
75b298b0f8
Fixes the Android Login process 2026-08-25 19:20:03 +01:00
dd5904f0a7
refacto Makefiles and MainView setup 2026-08-25 14:53:25 +01:00
beeac58628
test are still ok! 2026-08-25 14:16:49 +01:00
a450be6f24
simpler is better 2026-08-24 16:26:42 +01:00
a079c56c08
code format 2026-08-24 16:26:23 +01:00
ce070453b2
It was too much for qemu 2026-08-24 16:25:45 +01:00
7fabee1bd1
restore the TextEditor 2026-08-24 01:43:47 +01:00
f62a020c82
don't wait from App Nav push ok on loggin succeded 2026-08-24 01:23:15 +01:00
88e1499e4b
show the window 2026-08-24 00:30:29 +01:00
246111dee1
Simpler is better 2026-08-24 00:18:07 +01:00
11aaf36789
updated 2026-08-24 00:17:43 +01:00
404bba56ab
restores the legacy behavior 2026-08-24 00:03:14 +01:00
01bc4782a0
changes 2026-08-23 23:30:37 +01:00
bcd72e17d8
Merge branch 'feat/ui-testing' into release/1.0.8-rc3 2026-08-23 23:26:59 +01:00
50de5f412f
chore(release): bump version via gitversion for 1.0.8-rc3 2026-08-23 23:19:48 +01:00
5274d7bdf5 Merge pull request 'release/1.0.8-rc1' (#42) from release/1.0.8-rc1 into main
All checks were successful
Dotnet build and test / build (push) Successful in 7m4s
Reviewed-on: #42
2026-08-23 23:19:06 +01:00
84 changed files with 960 additions and 1150 deletions

View file

@ -25,9 +25,9 @@ on:
jobs:
build:
runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps:
- name: Clone yavsc
run: |
@ -39,10 +39,9 @@ jobs:
git checkout FETCH_HEAD
fi
git submodule update --init --recursive
echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Restore dependencies
run: cd /src/_src && dotnet restore
- name: Build
run: cd /src/_src && dotnet build --no-restore
echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Test
run: cd /src/_src && dotnet test --no-build --verbosity normal
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test --verbosity normal && echo "✅ Success !" || echo "❌ Fail ($?)!"

View file

@ -51,6 +51,8 @@ jobs:
# via l'API REST Forgejo (pas d'actions tierces Node).
release:
runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps:
- name: Clone du repo au tag demandé
env:
@ -66,10 +68,8 @@ jobs:
# WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile).
cd /src
# Clone unshallow pour que GitVersion.MsBuild ait l'historique
# et les tags (sinon MSB3073 sur la cible Android cf. PR #21).
if [[ ! -d _src/.git ]]; then
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src
fi
cd _src
@ -171,37 +171,22 @@ jobs:
echo "EOF" >> "$GITHUB_ENV"
echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV"
- name: Build des projets .NET (sans docker)
# L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le
# workload Android, mais PAS le binaire `docker` ni de daemon
# Docker. On exécute donc les commandes dotnet directement
# au lieu de passer par `docker build`.
# Equivalent des stages build-env du Dockerfile (lignes
# restore + build Yavsc.Org + build Yavsc.Api + build
# Yavsc.Blogs + build PostIt.Android -r android-arm64).
- name: Restore
run: |
cd /src/_src
dotnet restore
dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
-c Release --no-restore -clp:ErrorsOnly -r android-arm64
- name: Copier l'APK signé vers un emplacement connu
# Le build Android avec -r android-arm64 produit l'APK dans
# bin/Release/net10.0-android/android-arm64/. On le copie à
# la racine du checkout pour que l'étape d'upload le trouve.
- name: Build de PostIt.Android ARM64
run: |
cd /src/_src
APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.PostIt-Signed.apk
if [[ ! -f "$APK" ]]; then
echo "::error::APK not found at $APK"
ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true
exit 1
fi
cp "$APK" /src/_src/PostIt.Android.apk
ls -la /src/_src/PostIt.Android.apk
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
-c Release -r android-arm64 --no-restore -clp:ErrorsOnly
- name: Build de PostIt.Android x64
run: |
cd /src/_src
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
-c Release -r android-x64 --no-restore -clp:ErrorsOnly
- name: Publier la release Forgejo via l'API REST
# Pas d'action tierce (pas de Node dans l'image runner).
@ -310,21 +295,22 @@ jobs:
# sinon curl l'interprète comme un second fichier d'input
# (un fichier nommé '?name=PostIt.Android.apk') et l'API
# Forgejo renvoie 400 "Missing 'name' parameter".
echo "::group::Upload APK asset"
HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
-H "Accept: application/json" \
--data-binary "@/src/_src/PostIt.Android.apk" \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk")
echo "POST asset -> HTTP $HTTP"
echo "::group::Upload PostIt APK assets"
for MARCH in arm64 x64; do
HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
-H "Accept: application/json" \
--data-binary "@/src/_src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-$MARCH/fr.pschneider.postit-Signed.apk" \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android-$MARCH.apk")
echo "POST asset -> HTTP $HTTP"
if [[ "$HTTP" != "201" ]]; then
echo "::error::Asset upload failed (HTTP $HTTP):"
cat /tmp/asset.json
exit 1
fi
done
echo "::endgroup::"
if [[ "$HTTP" != "201" ]]; then
echo "::error::Asset upload failed (HTTP $HTTP):"
cat /tmp/asset.json
exit 1
fi
echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG"
echo "✅ Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG"

View file

@ -1,183 +0,0 @@
name: Build and Push Yavsc Apk
on:
push:
branches:
- main
tags:
- '*'
workflow_dispatch:
inputs:
force_unstable:
description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.'
required: false
type: boolean
default: false
# softprops/action-gh-release a besoin de contents: write
# pour publier une release + uploader un asset.
permissions:
contents: write
jobs:
apk-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout du code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
# 1. Votre étape de build actuelle (on nomme l'image "postit-android")
# --target build-env : on ne veut que le stage de build (qui
# contient les artefacts .apk). Sans --target, Docker ciblerait
# le DERNIER stage du Dockerfile (blogs-runtime, qui est une
# image ASP.NET runtime sans aucun APK à extraire).
- name: Build de l'image Docker
run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android .
# 2. EXTRACTION : Créer un conteneur éphémère pour copier l'APK vers l'hôte GitHub
- name: Extraire l'APK du conteneur Docker
run: |
docker create --name extractor postit-android
docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.PostIt-Signed.apk ./PostIt.Android.apk
docker rm extractor
- name: Téléverser l'APK en tant qu'Artéfact GitHub
uses: actions/upload-artifact@v7
with:
name: application-apk-release
path: ./PostIt.Android.apk
retention-days: 7
# Job de validation : parse le tag, vérifie le format, applique la règle
# de parité du patch (pair=stable / impair=preview / suffixe=instable),
# et s'assure que CHANGELOG.md contient une section cohérente.
# Sans ce job, le job publish-release peut être bypassé (un attaquant
# qui contrôle un tag ne peut pas publier de release sans une section
# changelog cohérente).
validate-release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout du code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Valider le tag et la section CHANGELOG
env:
FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }}
run: |
TAG="${GITHUB_REF_NAME}"
# Parse semver : MAJOR.MINOR.PATCH[-SUFFIX]
if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then
echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format."
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
SUFFIX="${BASH_REMATCH[4]}"
# Classification du canal par parité du patch.
# Patch pair + pas de suffixe -> stable.
# Patch impair + pas de suffixe -> preview.
# Suffixe présent -> instable.
if [[ -n "$SUFFIX" ]]; then
CHANNEL="unstable"
elif (( PATCH % 2 == 0 )); then
CHANNEL="stable"
else
CHANNEL="preview"
fi
echo "Tag $TAG classifié comme channel=$CHANNEL"
# Fail-fast sur instable sauf opt-in explicite via workflow_dispatch.
if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then
echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish."
echo "Set force_unstable=true via workflow_dispatch to override."
exit 1
fi
# Lecture du CHANGELOG.md (doit exister à la racine du repo).
if [[ ! -f CHANGELOG.md ]]; then
echo "::error::CHANGELOG.md not found at repo root."
exit 1
fi
# Extraction de la section [TAG]. On cherche la première ligne
# commençant par '## [' qui contient '[TAG]' (entre '## [' et
# la prochaine ligne '## [' ou fin de fichier). awk en mode
# paragraphe suffit et reste POSIX.
BODY=$(awk -v tag="[$TAG]" '
/^## \[/ {
if (in_section) exit
if (index($0, tag) > 0) in_section=1
next
}
in_section { print }
' CHANGELOG.md)
if [[ -z "$BODY" ]]; then
echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md."
echo "Add a '## [$TAG] - $CHANNEL' section before tagging."
exit 1
fi
# Vérification cohérence du canal déclaré dans le titre de section.
# Format attendu : "## [TAG] - stable" / "- preview" / "- unstable".
HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md)
if [[ "$HEADER" != *" - $CHANNEL"* ]]; then
echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity."
echo "Current section header: $HEADER"
exit 1
fi
echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL"
# Exposition aux étapes suivantes via $GITHUB_ENV.
# heredoc <<EOF pour le body multi-lignes (pattern GitHub Actions).
{
echo "RELEASE_BODY<<EOF"
echo "$BODY"
echo "EOF"
echo "RELEASE_CHANNEL=$CHANNEL"
if [[ "$CHANNEL" == "stable" ]]; then
echo "IS_PRERELEASE=false"
else
echo "IS_PRERELEASE=true"
fi
} >> "$GITHUB_ENV"
publish-release:
# Déclenché uniquement par un push de tag. Le job apk-deploy produit
# l'artefact ; validate-release garantit la cohérence du tag et du
# changelog avant publication.
if: startsWith(github.ref, 'refs/tags/')
needs: [apk-deploy, validate-release]
runs-on: ubuntu-latest
steps:
- name: Récupérer l'APK depuis l'artefact
uses: actions/download-artifact@v7
with:
name: application-apk-release
path: ./
- name: Publier la release GitHub et uploader l'APK
uses: softprops/action-gh-release@v2
with:
# Le nom de fichier final dans la release. C'est ce qui
# apparaîtra dans l'asset et donc dans le permalink :
# https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk
files: ./PostIt.Android.apk
# Le body est extrait de la section CHANGELOG.md correspondant
# au tag, exposée par validate-release via $GITHUB_ENV.
body: ${{ env.RELEASE_BODY }}
# stable -> false (marque comme Latest).
# preview / unstable -> true (visible mais pas Latest).
prerelease: ${{ env.IS_PRERELEASE }}

92
.vscode/launch.json vendored
View file

@ -1,59 +1,57 @@
{
// Utilisez IntelliSense pour en savoir plus sur les attributs possibles.
// Pointez pour afficher la description des attributs existants.
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug - Android",
// Utilisez IntelliSense pour en savoir plus sur les attributs possibles.
// Pointez pour afficher la description des attributs existants.
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Android Debug",
"type": "mono",
"preLaunchTask": "run-debug-android",
"request": "attach",
"address": "localhost",
"port": 10000
"port": 55555
},
{
"name": "Attach - Android",
"name": "Android Attach - Debug",
"type": "mono",
"request": "attach",
"address": "localhost",
"port": 10000
"port": 55555
},
{
"name": "API",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Api/Api.csproj"
},
{
"name": "Yavsc.Org",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
},
{
"name": "Yavsc.Blogs",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
},
{
"name": "PostIt",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
},
{
"name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
"args": [
],
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",
"console": "integratedTerminal",
"stopAtEntry": false
}
]
{
"name": "API",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Api/Api.csproj"
},
{
"name": "Yavsc Org",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
},
{
"name": "Yavsc Blogs",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
},
{
"name": "PostIt Desktop",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
},
{
"name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
"args": [],
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",
"console": "integratedTerminal",
"stopAtEntry": false
}
]
}

49
.vscode/settings.json vendored
View file

@ -2,27 +2,31 @@
"dotnet-test-explorer.testProjectPath": "test/**/*Tests.csproj",
"cSpell.words": [
"appsettings",
"asciidoctor",
"ASPNETCORE",
"Configurabilité",
"Cratie",
"DESTDIR",
"dotnet",
"DOTNET",
"ecdsa",
"envsubst",
"Hsts",
"Newtonsoft",
"Npgsql",
"PKCE",
"postit",
"pschneider",
"SLNDIR",
"validable",
"www-data",
"yavsc",
"Yavsc"
"appsettings",
"asciidoctor",
"ASPNETCORE",
"Avalonia",
"blogspot",
"Configurabilité",
"Cratie",
"DESTDIR",
"dotnet",
"DOTNET",
"ecdsa",
"envsubst",
"Forgejo",
"Hsts",
"Newtonsoft",
"Npgsql",
"Oidc",
"PKCE",
"postit",
"pschneider",
"SLNDIR",
"validable",
"www-data",
"yavsc",
"Yavsc"
],
"cSpell.reportUnknownWords": true,
"cSpell.language": "fr,en",
@ -40,5 +44,6 @@
"copilotcli/gpt-5.3-codex"
]
}
}
},
"dotnet.defaultSolution": "yavsc.sln"
}

82
.vscode/tasks.json vendored
View file

@ -1,5 +1,21 @@
{
"version": "2.0.0",
"isRoot": true,
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s+(.*)\\((\\d+):(\\d+)\\):\\s+(error|warning)\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
}
],
"tasks": [
{
"label": "run-debug-android",
@ -10,19 +26,17 @@
"env": {
"DOTNET_HOST_PATH": "/usr/share/dotnet",
"ANDROID_HOME": "/opt/android-sdk",
"JAVA_HOME": "/usr/lib/jvm/java-1.21.0-openjdk-amd64"
"JAVA_HOME": "/usr/lib/jvm/java-1.25.0-openjdk-amd64"
}
},
"args": [
"build"
"-t:run",
"run",
"-p:TargetFramework=net10.0-android",
"-p:Configuration=Debug",
"-p:AndroidAttachDebugger=true",
"-p:AndroidSdbHostPort=10000",
"-p:AndroidSdbTargetPort=10000"
],
"problemMatcher": "$msCompile"
"-p:AndroidSdbHostPort=55555",
"-p:AndroidSdbTargetPort=55555"
]
},
{
"label": "build",
@ -32,7 +46,6 @@
"group": "build",
"isBuildCommand": true,
"isTestCommand": false,
"problemMatcher": ["$msCompile"],
"isBackground": true
},
{
@ -62,59 +75,6 @@
"kind": "build"
},
"isBackground": true
},
{
"label": "test blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["test"],
"runOptions": {
"instanceLimit": 1
},
"options": {
"cwd": "src/Yavsc.Blogs",
"env": {
"DOTNET_CLI_UI_LANGUAGE": "en-US",
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"group": {
"kind": "test"
},
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
}
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": ["watch", "--project",
"src/Yavsc.Org/Yavsc.Org.csproj"
],
"problemMatcher": "$msCompile",
"runOptions": {
}
}
]
}

View file

@ -16,6 +16,26 @@ Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`.
## [1.0.8-rc6] - unstable
### Added
* a code cleanup,
* a first Xamarin.UITest is successful, but disabled, because breaking the actual CI process,
* Android app starts, the login process succeeds
### Changed
L'identifiant de l'application client Android a changé, il passe en minuscules :
`fr.pschneider.postit`
### Fixed
a bug posting and retrieving ACL from the backend,
the ACL now comes along with the article,
[TODO][PostIt] keep ACL along with the article
## [1.0.8-rc1] - unstable
### Added
@ -169,10 +189,10 @@ pour la production des paquets `.deb`.
migration, reverted in this release. The publish toggle covers
the same user-visible switch without a schema change.
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD
[1.0.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6
## [1.0.6] - stable
@ -206,4 +226,4 @@ pour la production des paquets `.deb`.
actual release id. Switched to `jq` for both body construction and
field extraction.
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6

View file

@ -11,7 +11,7 @@
## Premier build
```bash
git clone https://github.com/pazof/yavsc.git
git clone https://forgejo.pschneider.fr/notazof/yavsc.git
cd yavsc
dotnet restore
dotnet build

View file

@ -1,16 +1,6 @@
<Project>
<PropertyGroup>
<RootNamespace>Yavsc</RootNamespace>
<!--
GitVersion.MsBuild is referenced as a build-time package from
every csproj under src/. Setting UseProjectNamespaceForGitVersionInformation
here (in Directory.Build.props) means each assembly exposes a
GitVersionInformation type under its own namespace, e.g.
PostIt.GitVersionInformation, Yavsc.GitVersionInformation, so
the assembly metadata reflects the source tree it was built
from without conflicting names.
-->
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
<NoWarn>NU1701, NU1901, NU1902</NoWarn>
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn>
</PropertyGroup>
</Project>

View file

@ -4,7 +4,6 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.8.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />

166
Makefile
View file

@ -121,168 +121,4 @@ release:
git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin."
# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu.
#
# Usage typique :
# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe
# make qemu-install # (re)build l'APK et l'installe (AVD doit tourner)
# make qemu-build # build l'APK seul (sans install)
# make qemu-run # démarre l'AVD en background
# make qemu-stop # arrête l'émulateur
# make qemu-wait-boot # attend que l'AVD ait fini de booter
#
# Variables surchargeables (make VAR=valeur) :
# AVD_NAME default: postit_test_avd
# (l'AVD doit être listé par `avdmanager list avd`)
# ADB_SERIAL default: emulator-5554
# (port standard du premier émulateur lancé)
# ANDROID_HOME default: /opt/android-sdk
# (le SDK Android local; doit contenir
# emulator/emulator et platform-tools/adb)
# POSTIT_RID default: android-x64
# (doit matcher l'ABI de l'AVD; `avdmanager list avd`
# affiche la ligne Tag/ABI)
# EMU_HEADLESS default: 0
# (1 = lancer l'émulateur sans fenêtre, pour scripter)
# CONFIG surcharge la variable CONFIG globale (Debug par
# défaut dans ce Makefile). Passer à Release pour
# un APK optimisé et signé release.
# LOGCAT_LINES default: 200
# (nombre de lignes dumpées par `make qemu-logcat`)
# LOGCAT_FOLLOW default: 0
# (1 = stream live via `make qemu-logcat`,
# sinon dump one-shot des N dernières lignes)
# LOGCAT_BOOT_WAIT default: 30
# (secondes d'attente entre le clear du buffer,
# le `am start`, et le dump final dans
# `make qemu-logcat-boot`)
AVD_NAME ?= postit_test_avd
ADB_SERIAL ?= emulator-5554
ANDROID_HOME ?= /opt/android-sdk
POSTIT_RID ?= android-x64
EMU_HEADLESS ?= 0
LOGCAT_LINES ?= 600
LOGCAT_FOLLOW ?= 0
LOGCAT_BOOT_WAIT ?= 20
ANDROID_PACKAGE_NAME = fr.pschneider.PostIt
POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj
POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID)
POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk
qemu-run:
@echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..."
@mkdir -p /tmp/yavsc-emu
@EMU_ARGS=""; \
if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \
$(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \
>/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \
echo " emulator PID: $$!"
qemu-stop:
adb -s $(ADB_SERIAL) emu kill
qemu-wait-boot:
@echo " Waiting for $(ADB_SERIAL) to finish booting..."
adb -s $(ADB_SERIAL) wait-for-device
@for i in $$(seq 1 180); do \
BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \
if [ "$$BOOTED" = "1" ]; then \
echo " ✓ booted in $${i}s"; \
exit 0; \
fi; \
sleep 1; \
done; \
echo " ERROR: device did not boot within 180s." >&2; \
echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \
exit 1
qemu-build:
# EmbedAssembliesIntoApk=true: without this, the Debug APK ships
# without the managed assemblies in it (they are pushed at runtime
# via `adb push`, "Fast Deployment"). On the qemu emulator, the
# runtime cannot find them in `files/.__override__/<rid>/` and
# aborts at startup with "No assemblies found in '.__override__'"
# (monodroid-glue.cc:757, SIGABRT). Forcing this property on
# packages the .dlls into the APK as `assemblies/<rid>/` so the
# runtime reads them directly.
#
# The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`,
# not `AndroidEnableFastDeployment` (which exists in older
# templates but is a no-op in the .NET 10 SDK).
dotnet build $(POSTIT_ANDROID_CSPROJ) \
-c $(CONFIG) \
-p:RuntimeIdentifier=$(POSTIT_RID) \
-p:EmbedAssembliesIntoApk=true \
--nologo
@if [ ! -f "$(POSTIT_APK)" ]; then \
echo " APK not found at $(POSTIT_APK)." >&2; \
echo " Files in $(POSTIT_APK_DIR):" >&2; \
ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \
exit 1; \
fi
qemu-install: qemu-build
@echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..."
adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r
qemu-uninstall:
adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME)
# Dump recent logcat output for the running PostIt.Android process.
# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with
# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead.
# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag,
# because Mono/Xamarin can emit logs under several tags
# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering
# would miss the ones not matching. PID-based filtering is exact.
# If the app is not running, pidof returns empty and logcat exits
# silently with no output; that is the expected behaviour for
# "no logs yet".
qemu-logcat:
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
if [ -z "$$PID" ]; then \
echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \
echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \
exit 1; \
fi; \
echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \
if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \
adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \
else \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \
fi
# Clear logcat, launch PostIt.Android, then dump everything that was
# emitted during the startup window. Targets the "démarrage KO" case
# where the process starts but Avalonia never renders a frame — the
# logcat trace from process start to first frame is what diagnoses it.
#
# Override LOGCAT_BOOT_WAIT to extend the post-launch wait
# (default 15s; raise to 30+ if the device is slow to boot Avalonia).
LOGCAT_BOOT_WAIT ?= 15
qemu-logcat-boot:
@echo " Clearing logcat buffer..."
adb -s $(ADB_SERIAL) logcat -c
@echo " Launching $(ANDROID_PACKAGE_NAME)..."
adb -s $(ADB_SERIAL) shell am start \
-n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity
@echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..."
@sleep $(LOGCAT_BOOT_WAIT)
@echo " Dumping logcat (PostIt PID + system buffer):"
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
if [ -n "$$PID" ]; then \
echo " ✅ (PID $$PID at dump time)"; \
adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \
else \
echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \
exit 1; \
fi
qemu: qemu-run qemu-wait-boot qemu-install
@echo " ✓ PostIt.Android installed on $(ADB_SERIAL)"
.PHONY: test release qemu qemu-run qemu-stop qemu-wait-boot qemu-build qemu-install qemu-logcat qemu-logcat-boot
.PHONY: test release

View file

@ -16,11 +16,10 @@ https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml
# Statut actuel des actions GitHub
* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)
* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml)
* [![Build and Push Yavsc Production Image](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml)
* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml)
# Documentation

View file

@ -5,33 +5,35 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia packages -->
<!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Browser" Version="12.1.1" />
<PackageVersion Include="Avalonia.Android" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="12.1.1" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.100" />
</ItemGroup>
</Project>

178
src/PostIt/Makefile Normal file
View file

@ -0,0 +1,178 @@
# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu.
#
# Usage typique :
# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe
# make android-install # (re)build l'APK et l'installe (AVD doit tourner)
# make android-build # build l'APK seul (sans install)
# make qemu-run # démarre l'AVD en background
# make qemu-stop # arrête l'émulateur
# make qemu-wait-boot # attend que l'AVD ait fini de booter
#
# Variables surchargeables (make VAR=valeur) :
# AVD_NAME default: postit_test_avd
# (l'AVD doit être listé par `avdmanager list avd`)
# ADB_SERIAL default: emulator-5554
# (port standard du premier émulateur lancé)
# ANDROID_HOME default: /opt/android-sdk
# (le SDK Android local; doit contenir
# emulator/emulator et platform-tools/adb)
# POSTIT_RID default: android-x64
# (doit matcher l'ABI de l'AVD; `avdmanager list avd`
# affiche la ligne Tag/ABI)
# EMU_HEADLESS default: 0
# (1 = lancer l'émulateur sans fenêtre, pour scripter)
# CONFIG surcharge la variable CONFIG globale (Debug par
# défaut dans ce Makefile). Passer à Release pour
# un APK optimisé et signé release.
# LOGCAT_LINES default: 200
# (nombre de lignes dumpées par `make qemu-logcat`)
# LOGCAT_FOLLOW default: 0
# (1 = stream live via `make logcat`,
# sinon dump one-shot des N dernières lignes)
# LOGCAT_BOOT_WAIT default: 30
# (secondes d'attente entre le clear du buffer,
# le `am start`, et le dump final dans
# `make qemu-logcat-boot`)
AVD_NAME ?= postit_test_avd
ADB_SERIAL ?= emulator-5554
ANDROID_HOME ?= /opt/android-sdk
POSTIT_RID ?= android-x64
EMU_HEADLESS ?= 0
LOGCAT_LINES ?= 600
LOGCAT_FOLLOW ?= 0
LOGCAT_BOOT_WAIT ?= 30
ANDROID_PACKAGE_NAME = fr.pschneider.postit
POSTIT_ANDROID_CSPROJ := PostIt.Android/PostIt.Android.csproj
POSTIT_APK_DIR := PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID)
POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk
clean: clean-PostIt clean-PostIt.Android clean-PostIt.Desktop
clean-%:
rm -rf $*/obj $*/bin
qemu-run:
@echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..."
@mkdir -p /tmp/yavsc-emu
@EMU_ARGS=""; \
if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \
$(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \
>/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \
echo " ✅ Started emulator PID: $$!"
qemu-stop:
adb -s $(ADB_SERIAL) emu kill
echo " ✅ Stopped emulator"
qemu-wait-boot:
@echo " Waiting for $(ADB_SERIAL) to finish booting..."
adb -s $(ADB_SERIAL) wait-for-device
@for i in $$(seq 1 180); do \
BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \
if [ "$$BOOTED" = "1" ]; then \
echo " ✓ booted in $${i}s"; \
exit 0; \
fi; \
sleep 1; \
done; \
echo " 👿 ERROR: device did not boot within 180s." >&2; \
echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \
exit 1
android-build:
# EmbedAssembliesIntoApk=true: without this, the Debug APK ships
# without the managed assemblies in it (they are pushed at runtime
# via `adb push`, "Fast Deployment"). On the qemu emulator, the
# runtime cannot find them in `files/.__override__/<rid>/` and
# aborts at startup with "No assemblies found in '.__override__'"
# (monodroid-glue.cc:757, SIGABRT). Forcing this property on
# packages the .dlls into the APK as `assemblies/<rid>/` so the
# runtime reads them directly.
#
# The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`,
# not `AndroidEnableFastDeployment` (which exists in older
# templates but is a no-op in the .NET 10 SDK).
dotnet build $(POSTIT_ANDROID_CSPROJ) \
-c $(CONFIG) \
-p:RuntimeIdentifier=$(POSTIT_RID) \
-p:EmbedAssembliesIntoApk=true \
--nologo
@if [ ! -f "$(POSTIT_APK)" ]; then \
echo " APK not found at $(POSTIT_APK)." >&2; \
echo " Files in $(POSTIT_APK_DIR):" >&2; \
ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \
exit 1; \
fi
android-install: android-build
@echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..."
adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r
@echo " ✅ PostIt.Android installed on $(ADB_SERIAL)"
qemu-uninstall:
adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME)
# Dump recent logcat output for the running PostIt.Android process.
# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with
# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead.
# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag,
# because Mono/Xamarin can emit logs under several tags
# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering
# would miss the ones not matching. PID-based filtering is exact.
# If the app is not running, pidof returns empty and logcat exits
# silently with no output; that is the expected behaviour for
# "no logs yet".
logcat:
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
if [ -z "$$PID" ]; then \
echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \
echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \
exit 1; \
fi; \
echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \
if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \
adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \
else \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \
fi
# Clear logcat, launch PostIt.Android, then dump everything that was
# emitted during the startup window. Targets the "démarrage KO" case
# where the process starts but Avalonia never renders a frame — the
# logcat trace from process start to first frame is what diagnoses it.
#
# Override LOGCAT_BOOT_WAIT to extend the post-launch wait
# (default 15s; raise to 30+ if the device is slow to boot Avalonia).
LOGCAT_BOOT_WAIT ?= 15
android-start:
@echo " Clearing logcat buffer..."
adb -s $(ADB_SERIAL) logcat -c
@echo " Launching $(ANDROID_PACKAGE_NAME)..."
adb -s $(ADB_SERIAL) shell am start \
-n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity
@echo "$(ANDROID_PACKAGE_NAME) started on $(ADB_SERIAL)"
qemu-logcat-boot: android-start
@echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..."
@sleep $(LOGCAT_BOOT_WAIT)
@echo " Dumping logcat (PostIt PID + system buffer):"
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
if [ -n "$$PID" ]; then \
echo " ✅ (PID $$PID at dump time)"; \
sleep 10; \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID; \
else \
echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \
exit 1; \
fi
qemu: qemu-run qemu-wait-boot android-install
.PHONY: clean qemu qemu-run qemu-stop qemu-wait-boot android-build android-install logcat qemu-logcat-boot

View file

@ -1,8 +1,11 @@
using Android.App;

using Android.App;
using Android.Content;
using Android.Content.PM;
using Avalonia;
using AndroidX.Core.Provider;
using AndroidX.Emoji2.Text;
using Avalonia.Android;
using PostIt.Droid.Services;
namespace PostIt.Android;
@ -15,18 +18,22 @@ namespace PostIt.Android;
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity
{
/// <summary>
/// Strongly-typed handle to the current MainActivity instance, set in
/// <see cref="OnCreate"/> and consumed by platform services such as
/// <see cref="Services.AndroidSystemBrowser"/> which need to launch
/// Chrome Custom Tabs.
/// <summary>
/// The current MainActivity instance.
/// </summary>
public static MainActivity? Current { get; private set; }
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
{
FontRequest fontRequest = new FontRequest(
"com.google.android.gms.fonts",
"com.google.android.gms",
"Noto Color Emoji Compat",
Yavsc.Resource.Array.com_google_android_gms_fonts_certs); //com_google_android_gms_fonts_certs
EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest);
EmojiCompat.Init(config);
PlatformBootstrap.InitPlatform();
base.OnCreate(savedInstanceState);
PlatformBootstrap.EnsureInitialized();
Current = this;
}
/// <summary>
@ -41,7 +48,13 @@ public class MainActivity : AvaloniaMainActivity
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
if (intent is not null) AndroidOidcCallbackSink.Handle(intent);
var url = intent?.DataString;
if (!string.IsNullOrEmpty(url) && url.StartsWith("postit://callback"))
{
OidcCallbackManager.SetResult(url);
}
}
internal static class AndroidOidcCallbackSink

View file

@ -12,14 +12,9 @@ namespace PostIt.Android;
/// </summary>
internal static class PlatformBootstrap
{
private static int _initialized;
internal static void EnsureInitialized()
internal static void InitPlatform()
{
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
return;
Platform.DefaultRedirectUri = ViewModels.Settings.AndroidRedirectUri;
Platform.CreateBrowser = () =>
{
var activity = MainActivity.Current;

View file

@ -4,29 +4,26 @@
<TargetFramework>net10.0-android</TargetFramework>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ApplicationId>fr.pschneider.PostIt</ApplicationId>
<ApplicationId>fr.pschneider.postit</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AndroidLinkMode>SdkOnly</AndroidLinkMode>
<TrimMode>partial</TrimMode>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">
<Link>Resources\drawable\Icon.png</Link>
</AndroidResource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
<PackageReference Include="Xamarin.AndroidX.Browser" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Runtime" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Common" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>

View file

@ -2,31 +2,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="PostIt" android:icon="@drawable/Icon">
<!--
Deep-link receiver for the OIDC Authorization Code + PKCE flow.
After the user authenticates in the system browser, the OP
redirects to android://postit-signin?... and Android forwards
the Intent to the MainActivity (configured SingleTask so the
existing instance receives OnNewIntent rather than spawning a
new one).
The host value (postit-signin) MUST match the
AndroidRedirectUri constant in PostIt/Settings/Settings.cs and
the corresponding RedirectUri registered for the 'postit'
client in IdentityServer (Yavsc.Org ConfigurationDb).
-->
<activity-alias
android:name="PostIt.Android.OidcCallbackActivity"
android:targetActivity="PostIt.Android.PostItMainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="android" android:host="postit-signin" />
</intent-filter>
</activity-alias>
</application>
</manifest>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>MIIEqDCCA5CgAwIBAgIJAN5gc16AJfAsMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHR29vZ2xlMRAwDgYDVQQLEwdBbmRyb2lkMRAwDgYDVQQDEwdBbmRyb2lkMSEwHwYJKoZIhvcNAQkBFhJhbmRyb2lkQGFuZHJvaWQuY29tMCAXDTA4MDQxNTIyNDA0M1YYDzQyMDgxMzA0MjI0MDQzWjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDURvdW50YWluIFZpZXcxEDAOBgNVBAoTB0dvb2dsZTEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEhMB8GCSqGSIb3DQEJARYSYW5kcm9pZEBhbmRyb2lkLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALBi1vF0K1vOEHG7AxneTjOHUka46MIidBqvFcO164A49iU2DkYPhUaM4H8JCdzh6N1GzM6h9o6E2V6z8+gEtdI6nqqs0EGA0G0H701bFjLp9+K/1DkMIFeD4P8J7X1/M8t4+X09X/7bQyV3w0v7q+Qh38sY8W/7K29B3f2O2sLw+uX9U8a8Tf4Xv8A==</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZgXpYOMA0GCSqGSIb3DQEBBQUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKKvSkUIXm+t9M8rXj2V</item>
</string-array>
</resources>

View file

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Android.App;
using AndroidX.Browser.CustomTabs;
using IdentityModel.OidcClient.Browser;
using PostIt.Droid.Services;
namespace PostIt.Android.Services;
@ -35,9 +36,14 @@ public sealed class AndroidSystemBrowser : IBrowser
};
}
var uri = global::Android.Net.Uri.Parse(options.StartUrl)!;
// 1. Enregistrez la tâche avant de lancer le Custom Tab
var callbackTask = OidcCallbackManager.RegisterCallback(cancellationToken);
var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync();
// 2. LANCEZ VOTRE CUSTOM TAB ICI (via AndroidX.Browser.CustomTabs)
// ... code pour ouvrir l'URL d'authentification ...
var uri = global::Android.Net.Uri.Parse(options.StartUrl)!;
var tabsIntent = new CustomTabsIntent.Builder()
.SetShowTitle(true)!

View file

@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Droid.Services;
public static class OidcCallbackManager
{
private static TaskCompletionSource<string>? _tcs;
public static Task<string> RegisterCallback(CancellationToken cancellationToken)
{
_tcs = new TaskCompletionSource<string>();
cancellationToken.Register(() => _tcs.TrySetCanceled());
return _tcs.Task;
}
public static void SetResult(string url)
{
_tcs?.TrySetResult(url);
}
}

View file

@ -0,0 +1,35 @@
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using PostIt.Droid.Services;
namespace PostIt.Android;
[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter(new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "postit", // Remplacez par votre schéma personnalisé (ex: yavsc ou postit)
DataHost = "callback")] // Correspond à postit://callback
public class WebAuthenticationCallbackActivity : Activity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
// Capturer l'URL de redirection OIDC
var url = Intent?.DataString;
if (!string.IsNullOrEmpty(url))
{
// Transmettre l'URL au gestionnaire partagé pour compléter la Task
OidcCallbackManager.SetResult(url);
}
// Fermer cette activité transparente et ramener l'application au premier plan
var intent = new Intent(this, typeof(MainActivity));
intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
StartActivity(intent);
Finish();
}
}

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>

View file

@ -1,29 +0,0 @@
using PostIt.Services;
namespace PostIt.Desktop;
/// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared OIDC login path sees a working <c>IBrowser</c> — the
/// custom-scheme browser that hands the OIDC callback off to the
/// running instance through the named pipe. Desktop builds do NOT use
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered
/// with the OS at install time and the browser is whatever the user
/// has configured to open it.
/// </summary>
internal static class PlatformBootstrap
{
private static int _initialized;
internal static void EnsureInitialized()
{
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
return;
// Use the custom-scheme redirect on Desktop. Loopback is only
// a fallback for platforms that cannot register postit://
// (see Settings.DefaultLoopbackRedirectUri for that path).
Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
Platform.CustomScheme = "postit";
}
}

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<PropertyGroup>

View file

@ -12,8 +12,6 @@ sealed class Program
[STAThread]
public static void Main(string[] args)
{
PlatformBootstrap.EnsureInitialized();
// Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes

View file

@ -73,7 +73,7 @@ public class AddCircleMemberDialogTests
return context;
}
/// <summary>
/// Mount a real <see cref="MainWindow"/>, build a minimal
/// Mount a real <see cref="MainView"/>, build a minimal
/// DI graph, push <see cref="CirclesPage"/> then the
/// <see cref="AddCircleMemberDialog"/> on top of it.
/// Returns the stack size so the test can pin the delta.
@ -98,12 +98,9 @@ public class AddCircleMemberDialogTests
services.AddTransient<AddCircleMemberDialogViewModel>();
var sp = services.BuildServiceProvider();
context.Window = new MainWindow();
context.Window = new MainView();
context.App = (PostIt.App)Application.Current!;
context.App.DataTemplates.Clear();
context.App.DataTemplates.Add(new ViewLocator(sp));
context.App.AttachMainWindow(context.Window);
context.Window.Show();
context.page = sp.GetRequiredService<CirclesPage>();
context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult();

View file

@ -14,7 +14,7 @@ namespace PostIt.Tests;
/// </summary>
public class AndroidAppLaunchTests
{
private const string PackageName = "fr.pschneider.PostIt";
private const string PackageName = "fr.pschneider.postit";
private readonly ITestOutputHelper _output;
@ -23,7 +23,8 @@ public class AndroidAppLaunchTests
_output = output;
}
// FIXME [Fact]
// https://twosixtech.com/blog/integrating-docker-and-adb/
// FIXME ala hosted shared resource adb server - [Fact]
public void PostIt_starts_and_draws_a_first_frame_on_the_emulator()
{
if (!IsPackageInstalledOnAnyDevice())

View file

@ -99,7 +99,7 @@ public class MainPageButtonsTests
}
/// <summary>
/// Mount a real <see cref="MainWindow"/> (as
/// Mount a real <see cref="MainView"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
@ -109,18 +109,12 @@ public class MainPageButtonsTests
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// </summary>
private static (MainWindow window, MainPage page) MountMainPage(MainViewModel vm)
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm)
{
var window = new MainWindow();
var window = new MainView();
var page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
if (vm.Services is not null)
{
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(vm.Services));
}
app.AttachMainWindow(window);
window.Show();
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page);
}
@ -130,7 +124,7 @@ public class MainPageButtonsTests
/// supported headless pattern (cf. CalculatorTests in the
/// Avalonia.Samples repo). Returns the nav-stack count
/// before the click so the caller can assert on the delta.
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
/// KeyPressQwerty is dispatched on the <see cref="MainView"/>
/// itself — it is the <see cref="TopLevel"/> that owns the
/// headless implementation, and routing the key through any
/// descendant TopLevel (e.g. one obtained via
@ -139,7 +133,7 @@ public class MainPageButtonsTests
/// because the descendant does not carry the
/// <c>PlatformHandle</c> the harness expects.
/// </summary>
private static int ClickAndCapture(MainWindow window, Button button)
private static int ClickAndCapture(MainView window, Button button)
{
var stackBefore = window.NavRoot.NavigationStack.Count;
button.Command?.Execute(button.CommandParameter);

View file

@ -117,7 +117,7 @@ public class PostAclDialogTests
/// rebinding the global DI mid-test would trample the
/// Settings singleton the rest of the harness depends on.
/// </summary>
private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
private static (MainView window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
{
var handler = new CountingHttpHandler();
var settings = new Settings();
@ -138,12 +138,9 @@ public class PostAclDialogTests
// CountingHttpHandler.
GC.KeepAlive(sp);
var window = new MainWindow();
var window = new MainView();
var app = (App)Application.Current!;
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(sp));
app.AttachMainWindow(window);
window.Show();
return (window, aclClient, circleClient, handler);
}

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -16,9 +16,7 @@
<PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
@ -28,6 +26,5 @@
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
</ItemGroup>
<ItemGroup></ItemGroup>
</Project>

View file

@ -9,7 +9,7 @@ namespace PostIt.Tests;
/// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// a real <see cref="MainWindow"/> via the headless Avalonia
/// a real <see cref="MainView"/> via the headless Avalonia
/// platform declared in <c>TestApp.cs</c>.
///
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
@ -34,11 +34,10 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
MainWindow window = new MainWindow();
window.Show();
var buttons = window.SessionBanner.GetVisualDescendants()
var buttons = window.GetVisualDescendants()
.OfType<Button>()
.ToList();
@ -46,7 +45,7 @@ public class SessionStatusBannerTests
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.Equal(3, buttons.Count);
Assert.Equal(4, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
@ -55,13 +54,10 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out()
{
var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default
window.SessionBanner.DataContext = vm;
MainWindow window = new MainWindow();
window.Show();
var login = window.SessionBanner.GetVisualDescendants()
var login = window.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se connecter");
@ -73,13 +69,13 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out()
{
var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
SessionStatusBanner banner = CreateBanner();
var logout = window.SessionBanner.GetVisualDescendants()
Assert.False((banner.DataContext as SessionStatusViewModel)!
.IsLoggedIn); // VM default
var logout = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter");
@ -89,11 +85,9 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
SessionStatusBanner banner = CreateBanner();
var settings = window.SessionBanner.GetVisualDescendants()
var settings = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Paramètres");
@ -103,14 +97,21 @@ public class SessionStatusBannerTests
Assert.True(settings.IsVisible);
}
private static SessionStatusBanner CreateBanner()
{
MainWindow window = new MainWindow();
window.Show();
var banner = window.MainView.SessionBanner;
var status = new SessionStatusViewModel();
banner.DataContext = status;
return banner;
}
[AvaloniaFact]
public void Banner_session_label_reflects_DataContext()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var label = window.SessionBanner.GetVisualDescendants()
SessionStatusBanner banner = CreateBanner();
var label = banner.GetVisualDescendants()
.OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");

View file

@ -85,7 +85,7 @@ public class SettingsLoadTests
bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip;
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DefaultDesktopRedirectUri;
global::AuthenticationSettings.DesktopRedirectUri;
settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/"

View file

@ -4,7 +4,7 @@ namespace PostIt.Tests;
internal class TestAppContext
{
public MainWindow? Window {get; set; }
public MainView? Window {get; set; }
public CirclesPage? page {get; set; }
public AddCircleMemberDialog? dialog { get; set; }
public App? App { get; internal set; }

View file

@ -8,8 +8,7 @@ public class MainPageTests
[AvaloniaFact]
public void MainPage_Should_Load()
{
var window = new MainWindow();
window.Show();
var window = new MainView();
Assert.NotNull(window);
}
}

View file

@ -11,6 +11,5 @@
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles>
</Application>

View file

@ -1,5 +1,5 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
@ -16,6 +16,8 @@ namespace PostIt;
public partial class App : Application
{
private int _bootStarted;
/// <summary>
/// DI container the platform entry points hand to ViewModels so
/// they can resolve the canonical <see cref="Settings"/> singleton
@ -28,14 +30,11 @@ public partial class App : Application
/// </summary>
public IServiceProvider? ServiceProvider { get; private set; }
public MainWindow? Window { get; private set; }
public MainView? View { get; private set; }
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
#if DEBUG
this.AttachDeveloperTools();
#endif
}
public override void OnFrameworkInitializationCompleted()
@ -47,7 +46,11 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = CreateMainWindow();
var window = ServiceProvider.GetRequiredService<MainWindow>();
desktop.MainWindow = window;
View = window.MainView;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
@ -55,53 +58,62 @@ public partial class App : Application
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
Window = CreateMainWindow();
View = ServiceProvider.GetRequiredService<MainView>();
this.ConfigureRootView(View);
ApplyDarkMode(settings);
return Window;
return View;
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = CreateMainWindow();
singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService<MainView>();
ConfigureRootView(View);
ApplyDarkMode(settings);
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureRootView(MainView rootView)
{
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
private MainWindow CreateMainWindow()
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
Window = new MainWindow();
// Remplacer Window.NavRoot par rootView.NavRoot
rootView.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += async () =>
{
await PushMainPageAsync();
};
rootView.SessionBanner.DataContext = sessionStatus;
}
private async Task BootOnceAsync()
{
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
{
return;
}
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
Window.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += () =>
{
PushMainPageAsync().Wait();
};
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
this.PushPageAsync(homeVm).Wait();
Window.SessionBanner.DataContext = sessionStatus;
return Window;
await BootAsync(this.ServiceProvider!, api);
}
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// Test-only hook: bind a concrete <see cref="MainView"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
internal void AttachMainWindow(MainView mainView)
{
Window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
View = mainView ?? throw new ArgumentNullException(nameof(mainView));
}
private static void ApplyDarkMode(Settings settings)
@ -125,6 +137,9 @@ public partial class App : Application
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh();
var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!;
await app.PushPageAsync(homePage);
if (!refreshed) return;
await PushMainPageAsync().ConfigureAwait(true);
@ -179,6 +194,6 @@ public partial class App : Application
internal async Task GoBackAsync()
{
await Window!.NavRoot.PopAsync();
await View!.NavRoot.PopAsync();
}
}

View file

@ -23,11 +23,13 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
// Vues
services.AddTransient<MainPage>();
services.AddSingleton<MainView>();
services.AddSingleton<MainPage>();
services.AddSingleton<MainWindow>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
@ -58,7 +60,6 @@ public static class ServiceCollectionHelpers
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();

View file

@ -10,7 +10,8 @@ public static class ViewModelBaseHelpers
{
public static async Task PushPageAsync(this App app, ViewModelBase vm)
{
if (app.Window is null)
var window = app.View;
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
@ -39,12 +40,12 @@ public static class ViewModelBaseHelpers
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = app.Window.NavRoot.NavigationStack;
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
}
await app.Window.NavRoot.PushAsync(page);
await window.NavRoot.PushAsync(page);
}
}

View file

@ -1,14 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<Content Include="postit-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -28,11 +30,9 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />

View file

@ -1,36 +0,0 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Desktop stub for <see cref="IContactService"/>.
///
/// <para>The desktop has no equivalent of the mobile address
/// book (no <c>Contacts.Default</c>, no CardDAV out of the
/// box). Rather than synthesise a list from a different
/// source, this provider returns an empty list and lets the
/// UI render an honest "no local contacts on this platform"
/// message.</para>
///
/// <para>If desktop users want to invite people who aren't
/// Yavsc members, that flow goes through a separate path
/// (manual email entry + invitation endpoint) — not through
/// <see cref="IContactService"/>. Finding existing Yavsc
/// members is <see cref="IUserDirectory"/>'s job, not this
/// one's.</para>
///
/// <para>Future CardDAV / Google Contacts / Exchange
/// providers can plug in here as additional
/// <see cref="IContactService"/> implementations selected
/// from DI by configuration.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
}
#endif

View file

@ -21,14 +21,14 @@ public static class Platform
/// override this property at startup (e.g. PostIt.Android sets
/// it to <c>android://postit-signin</c>).
/// </summary>
public static string DefaultRedirectUri { get; set; } = "postit://callback";
public const string RedirectUri = "postit://callback";
/// <summary>
/// Scheme prefix the <see cref="CustomSchemeBrowser"/> matches
/// against <c>BrowserOptions.EndUrl</c>. Overridable for apps
/// that want to register their own scheme.
/// </summary>
public static string CustomScheme { get; set; } = "postit";
public const string CustomScheme = "postit";
/// <summary>
/// Constructs a fresh <see cref="IBrowser"/> for the running platform.

View file

@ -1,72 +0,0 @@
using System;
using System.Threading.Tasks;
using Avalonia.Threading;
namespace PostIt.Services;
/// <summary>
/// Tiny marshalling helper around <see cref="Dispatcher.UIThread"/> so
/// the rest of the codebase does not have to import Avalonia.Threading
/// directly. We want exactly one place that decides "is the current
/// thread the Avalonia UI thread, and if not, post there" so that
/// <see cref="ObservableObject"/>-derived types (Settings, the various
/// ViewModels) can fire <c>PropertyChanged</c> safely from background
/// work — which is exactly the cross-thread case that previously blew
/// up inside <c>DataValidationErrors.SetErrors</c> on Avalonia 11.
///
/// The helper is intentionally tiny: a sync post when we are off the
/// UI thread, a no-op when we are already on it, and an async fire-
/// and-forget variant for places where awaiting would deadlock the
/// caller (e.g. <c>Settings.Load</c> continuation paths).
/// </summary>
public static class UiDispatcher
{
/// <summary>
/// True when the calling thread is the Avalonia UI thread. Property
/// setters that touch bindings should check this before mutating
/// state; the safe path is <see cref="InvokeIfNeeded"/>.
/// </summary>
public static bool IsOnUiThread => Dispatcher.UIThread.CheckAccess();
/// <summary>
/// Run <paramref name="action"/> on the UI thread. If the caller is
/// already on the UI thread, run synchronously to preserve stack
/// traces and ordering; otherwise post to the dispatcher and wait.
/// Never throws on shutdown — a missing dispatcher is treated as
/// "best-effort skipped", matching Avalonia's own behaviour when
/// the application lifetime has been torn down.
/// </summary>
public static void InvokeIfNeeded(Action action)
{
if (action is null) return;
if (IsOnUiThread) { action(); return; }
try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
catch (InvalidOperationException) { /* dispatcher gone, nothing to do */ }
}
/// <summary>
/// Fire-and-forget variant: schedules <paramref name="action"/> on
/// the UI thread but does not block the caller. Use this from
/// background workers (OIDC discovery, HTTP callbacks, file I/O)
/// where awaiting the dispatcher would deadlock the calling sync
/// context.
/// </summary>
public static void Post(Action action)
{
if (action is null) return;
try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
catch (InvalidOperationException) { /* dispatcher gone */ }
}
/// <summary>
/// Awaitable variant. Useful inside <c>async</c> ViewModel methods
/// that must touch bindings only after the dispatcher has processed
/// a queued update (e.g. "load file then refresh observable state").
/// </summary>
public static Task InvokeAsync(Action action)
{
if (action is null) return Task.CompletedTask;
if (IsOnUiThread) { action(); return Task.CompletedTask; }
return Dispatcher.UIThread.InvokeAsync(action, DispatcherPriority.Normal).GetTask();
}
}

View file

@ -10,7 +10,7 @@ public partial class AuthenticationSettings : ObservableObject
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
public const string DesktopRedirectUri = "postit://callback";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
@ -34,15 +34,19 @@ public partial class AuthenticationSettings : ObservableObject
[ObservableProperty]
public partial string[] Scopes { get; set; }
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// OAuth redirect URI. Defaults to <see cref="DesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
public partial string RedirectUri { get; set; }
#if ANDROID
= AndroidRedirectUri;
#else
= DesktopRedirectUri;
#endif
/// <summary>
/// Space-separated view of <see cref="Scopes"/>. Exists for the

View file

@ -16,12 +16,6 @@ namespace PostIt;
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
private readonly IServiceProvider _services;
public ViewLocator(IServiceProvider services)
{
_services = services;
}
public Control Build(object? data)
{
@ -38,15 +32,17 @@ public class ViewLocator : IDataTemplate
private Control BuildCore(object? data)
{
var app = App.Current as App;
var services = app!.ServiceProvider!;
return data switch
{
MainViewModel => _services.GetRequiredService<MainPage>(),
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
MainViewModel => services.GetRequiredService<MainPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

@ -2,13 +2,11 @@ using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")]
@ -18,37 +16,6 @@ public partial class Settings : ViewModelBase
{
const string SettingsFileName = "postit-settings.json";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up
/// at application boot by <see cref="App.OnFrameworkInitializationCompleted"/>
/// through <see cref="BindToServiceProvider"/>. The hybrid pattern:
/// <list type="bullet">
/// <item><description>The static <c>Current</c> reference gives
/// ViewModels a non-DI way to reach the same instance (and lets
/// the framework bindings push notifications through one stable
/// <see cref="ObservableObject"/>).</description></item>
/// <item><description>Tests that want to exercise a clean
/// instance still call <c>new Settings()</c>; <c>Current</c>
/// stays null in those contexts because <see cref="BindToServiceProvider"/>
/// is never invoked.</description></item>
/// <item><description>Reads (<see cref="GetCurrent"/>) are
/// thread-safe and never allocate; mutations always go through
/// the DI-resolved singleton so two threads cannot each register
/// a different "current" Settings.</description></item>
/// </list>
/// </summary>
private static Settings? s_current;
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();
@ -336,7 +303,7 @@ public partial class Settings : ViewModelBase
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
AuthenticationSettings.DesktopRedirectUri : settings.Authentication.RedirectUri;
if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0)
{
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
@ -377,7 +344,7 @@ public partial class Settings : ViewModelBase
{
Authority = AuthenticationSettings.DefaultAuthority,
ClientId = AuthenticationSettings.DefaultClientId,
RedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri,
RedirectUri = AuthenticationSettings.DesktopRedirectUri,
Scopes = AuthenticationSettings.DefaultScopes
};
this.DarkMode = false;

View file

@ -97,16 +97,15 @@
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<AvaloniaEdit:TextEditor Grid.Row="2"
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
MinHeight="320"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" />
<TextBox Grid.Row="2" PlaceholderText="Write something here !"
Text="{Binding DraftArticle, Mode=TwoWay}"
MinHeight="320"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
</TextBox>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
</Grid>
</Border>
</Grid>

View file

@ -0,0 +1,22 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:PostIt.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainView"
x:DataType="vm:MainViewModel">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainViewModel />
</Design.DataContext>
<DockPanel LastChildFill="True">
<views:SessionStatusBanner
x:Name="SessionBanner"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class MainView : UserControl
{
public MainView()
{
InitializeComponent();
}
}

View file

@ -7,11 +7,6 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainWindow"
Icon="/Assets/avalonia-logo.ico"
Title="PostIt" >
<DockPanel LastChildFill="True">
<views:SessionStatusBanner x:Name="SessionBanner"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>
Title="PostIt">
<views:MainView x:Name="MainView" />
</Window>

View file

@ -1,51 +0,0 @@
using Avalonia;
using Avalonia.Data;
using AvaloniaEdit;
namespace PostIt.Views;
public sealed class TextEditorBinding
{
public static readonly AttachedProperty<string?> TextProperty =
AvaloniaProperty.RegisterAttached<TextEditorBinding, TextEditor, string?>(
"Text",
defaultBindingMode: BindingMode.TwoWay);
private static readonly AttachedProperty<bool> IsTextChangeSubscribedProperty =
AvaloniaProperty.RegisterAttached<TextEditorBinding, TextEditor, bool>("IsTextChangeSubscribed");
static TextEditorBinding()
{
TextProperty.Changed.AddClassHandler<TextEditor>((editor, args) =>
{
EnsureTextChangeSubscribed(editor);
var text = args.GetNewValue<string?>() ?? string.Empty;
if (editor.Text != text)
{
editor.Text = text;
}
});
}
public static string? GetText(TextEditor editor) => editor.GetValue(TextProperty);
public static void SetText(TextEditor editor, string? value) => editor.SetValue(TextProperty, value);
private static void EnsureTextChangeSubscribed(TextEditor editor)
{
if (editor.GetValue(IsTextChangeSubscribedProperty))
{
return;
}
editor.SetValue(IsTextChangeSubscribedProperty, true);
editor.TextChanged += (_, _) =>
{
if (editor.Text != GetText(editor))
{
SetText(editor, editor.Text);
}
};
}
}

View file

@ -7,6 +7,13 @@ namespace Yavsc
{
public const string APIPrefix = "api/v1";
public const string BlogSpotPath = "blogspot";
public const string BlogAclPath = "blogacl";
public const string BlogTagPath = "blogtag";
public const string CirclePath = "circle";
public const string CommentsPath = "blogcomments";
public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" },
new Scope { Id = "book" , Description ="Your booking interface"},

View file

@ -5,16 +5,13 @@
<Description> A shared model for a little client/server app, dealing about establishing some contract, between some human client and provider.
</Description>
<RootNamespace>Yavsc.Abstract</RootNamespace>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<RepositoryUrl>https://forgejo.pschneider.fr/notazof/yavsc</RepositoryUrl>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Library>true</Library>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -33,7 +33,7 @@ namespace Yavsc.Api.Client;
/// </summary>
public sealed class BlogApiClient
{
private const string DefaultPathPrefix = "blog";
private const string DefaultPathPrefix = "blogspot";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;

View file

@ -17,12 +17,9 @@
<Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>

View file

@ -7,14 +7,11 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -1,10 +1,12 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
@ -38,15 +40,16 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private string BlogUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}";
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}";
/// <summary>Delete any ACL rows tied to the fixture's seeded
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
@ -120,7 +123,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
// owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end.
CleanupAcl();
using var http = NewClient("alice");
using var http = NewClient(_fixture.DefaultUserLogin);
var payload = new PostAccessControlRulePayload
{
@ -128,7 +131,8 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
BlogPostId = _fixture.PostId
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload,
var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
@ -178,7 +182,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
[MemberData(nameof(BlogAclPayloadsForNever500))]
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
{
using var http = NewClient("alice");
using var http = NewClient(_fixture.DefaultUserLogin);
var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload,
@ -216,4 +220,83 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
);
}
[Fact]
public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list()
{
CleanupAcl();
_fixture.SeedUser(_fixture.DefaultUserLogin);
_fixture.SeedUser("tester");
_fixture.SeedCircle(_fixture.DefaultUserLogin, "test",
false,
new String[]
{
_fixture.DefaultUserLogin,
"tester"
});
using var http = NewClient(_fixture.DefaultUserLogin );
// Create a minimal BlogPost. The server assigns Id, so we
// send 0 + an explicit AuthorId; the production
// BlogSpotService.Create() tolerates that.
var draft = new BlogPost
{
Id = 0,
Title = "Premier billet",
AuthorId = "tester",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
ACL = new List<CircleAuthorizationToBlogPost>(
new CircleAuthorizationToBlogPost[]
{
new CircleAuthorizationToBlogPost
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.PostId
}
}
)
};
var postResponse = await http.PostAsJsonAsync(
BlogUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
// The POST returns the server-issued post (with a real Id).
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal(draft.Title, created.Title);
// The list should now contain exactly one entry.
var listResponse = await http.GetAsync(
BlogUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(2, doc.RootElement.GetArrayLength());
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
// detail should return the same post, with ACL and tags.
var detailResponse = await http.GetAsync(
$"{BlogUrl()}/{created.Id}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind);
Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64());
Assert.Equal(1, detailDoc.RootElement.GetProperty("acl").GetArrayLength());
}
}

View file

@ -8,11 +8,13 @@ using Microsoft.IdentityModel.Tokens;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests;
[Collection("JwtClaimMapping")]
public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWebServerFixture>
public sealed class BlogApiMappedClaimsTests :
IClassFixture<MappedClaimsBlogsWebServerFixture>
{
private readonly MappedClaimsBlogsWebServerFixture _fixture;
@ -79,7 +81,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken);
var response = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
@ -93,7 +98,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
ResetDatabase();
using var http = NewClient(subject: "mapped-owner");
var createdResponse = await http.PostAsJsonAsync("/api/v1/blog", new BlogPost
var createdResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost
{
Id = 0,
Title = "Billet à modifier",
@ -107,7 +112,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
var updateResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost
{
Id = created.Id,
Title = "Billet modifié",
@ -126,7 +131,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
ResetDatabase();
using var ownerHttp = NewClient(subject: "mapped-owner");
var createdResponse = await ownerHttp.PostAsJsonAsync("/api/v1/blog", new BlogPost
var createdResponse = await ownerHttp.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost
{
Id = 0,
Title = "Billet protégé",
@ -141,7 +146,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWe
Assert.NotNull(created);
using var otherHttp = NewClient(subject: "mapped-other");
var updateResponse = await otherHttp.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
var updateResponse = await otherHttp.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost
{
Id = created.Id,
Title = "Tentative de modification",

View file

@ -2,11 +2,11 @@ using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests;
@ -31,18 +31,6 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
_fixture = fixture;
}
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required
@ -55,18 +43,10 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
/// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser()
{
ResetDatabase();
_fixture.ResetDatabase();
_fixture.SeedUser("tester");
}
/// <summary>The fixture's <c>WebApplication</c> is bound to
/// <c>https://localhost:&lt;random&gt;</c> via
/// <see cref="WebHostFixture.Addresses"/>. We pick the first
/// https URL and append the controller route
/// (<c>/api/v1/blog</c>, matching the production
/// <c>[Route(APIPrefix + "/blog")]</c>).</summary>
private string BlogsUrl =>
_fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
/// <summary>Build an authenticated client: a real
/// <c>Authorization: Bearer &lt;jwt&gt;</c> header where the JWT
@ -111,10 +91,11 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
{
ResetDatabase();
_fixture.ResetDatabase();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/blog",
var response = await http.GetAsync(
_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@ -147,7 +128,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft,
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -160,7 +141,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
Assert.Equal(draft.Title, created.Title);
// The list should now contain exactly one entry.
var listResponse = await http.GetAsync("/api/v1/blog",
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
@ -188,7 +169,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft,
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -198,7 +179,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
Assert.NotNull(created);
Assert.Equal("tester", created!.AuthorId);
var listResponse = await http.GetAsync("/api/v1/blog",
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
@ -226,7 +207,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft,
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -266,7 +247,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided()
{
ResetDatabase();
_fixture.ResetDatabase();
using var http = NewAnonymousClient();
// No Authorization header → the JwtBearer middleware
@ -275,7 +256,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// the framework returns 401. This is the proof that the
// production policy is wired in the test host and not
// short-circuited by a test-only auth bypass.
var response = await http.GetAsync("/api/v1/blog",
var response = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
@ -303,7 +284,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft,
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -322,13 +303,13 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var putResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created.Id}",
var putResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
update,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
// The list should now reflect the new title.
var listResponse = await http.GetAsync("/api/v1/blog",
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(
@ -356,19 +337,19 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft,
var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
))!;
var deleteResponse = await http.DeleteAsync($"/api/v1/blog/{created.Id}",
var deleteResponse = await http.DeleteAsync(_fixture.BlogSpotUrl()+$"/{created.Id}",
TestContext.Current.CancellationToken
);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
// The list should now be empty.
var listResponse = await http.GetAsync("/api/v1/blog",
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken);
String response = await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
@ -411,7 +392,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync("/api/v1/blog", draft,
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
// Dump the body on failure so the test name + the response
@ -443,7 +424,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// behaviour so a future change that, say, makes Title
// nullable in the model or drops [Required], triggers a
// conscious update of the test (and probably of the VM).
ResetDatabase();
_fixture.ResetDatabase();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
@ -456,7 +437,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync("/api/v1/blog", draft,
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft,
TestContext.Current.CancellationToken);
if (response.StatusCode != HttpStatusCode.BadRequest)

View file

@ -10,7 +10,7 @@ using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
/// <summary>
@ -61,6 +61,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
public long CircleId { get; private set; }
public long PostId { get; private set; }
public string DefaultUserLogin { get => "alice"; }
// A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
@ -169,7 +170,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// PermissionHandler ownership check sees a null
// user id and rejects every PUT.
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
options.TokenValidationParameters
= new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer,
@ -263,6 +265,27 @@ public sealed class BlogsWebServerFixture : WebHostFixture
await Task.CompletedTask;
return app;
}
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
public void ResetDatabase()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
public void CleanupAcl()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == CircleId
&& a.BlogPostId == PostId)
.ExecuteDelete();
}
public override void Dispose()
{
@ -310,7 +333,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null)
public ApplicationUser SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -351,20 +375,31 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedCircle(string ownerId, string name, bool isPublic = false)
public long SeedCircle(string ownerId, string name, bool isPublic = false,
ICollection<String> members = null
)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
db.Circle.Add(circle);
db.SaveChanges();
if (members != null && members.Count > 0)
{
foreach (String memberId in members)
{
var member = new CircleMember { CircleId = circle.Id, MemberId = memberId };
db.CircleMembers.Add(member);
}
db.SaveChanges();
}
return circle.Id;
}
/// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedBlogPost(string authorId, string title)
public long SeedBlogPost(string authorId, string title)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -380,4 +415,5 @@ public sealed class BlogsWebServerFixture : WebHostFixture
db.SaveChanges();
return post.Id;
}
}

View file

@ -21,11 +21,11 @@ namespace Yavsc.Blogs.Tests;
/// This is the closest in-process reproduction of the production
/// authentication surface for the blog API.
/// </summary>
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFixture
{
private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
private readonly Dictionary<string, string> _savedInboundMap;
private readonly WebApplication _app;
private WebApplication? _app = null;
public MappedClaimsBlogsWebServerFixture()
{
@ -86,6 +86,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
public void Dispose()
{
if (_app is null) return;
_app.StopAsync().GetAwaiter().GetResult();
_app.DisposeAsync().AsTask().GetAwaiter().GetResult();
@ -96,6 +97,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
}
}
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)

View file

@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests;
@ -71,12 +72,6 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
return post.Id;
}
private string PublishUrl(long id)
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/v1/blog/{id}/publish";
private string BlogsUrl
=> _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
@ -100,12 +95,13 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice");
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken);
var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal($"post-by-alice", doc.RootElement.GetProperty("title").GetString());
Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
@ -116,11 +112,12 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice");
using var http = NewClient("alice");
await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken);
await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken);
var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
}
@ -130,7 +127,7 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{
ResetDatabase();
using var http = NewClient("alice");
var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
}
@ -141,7 +138,7 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice");
using var http = NewClient("bob");
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
// 401 Challenge (the controller returns Challenge()
// for AuthorizationFailureException). The exact code
// is framework-dependent; what matters is "not 204".

View file

@ -9,7 +9,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -32,7 +32,4 @@
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -1,6 +1,6 @@
namespace Yavsc.Blogs;
public static class Constants
public static class BlogConstants
{
public const string AdminRole = "Admin";
public const string ModeratorRole = "Moderator";

View file

@ -9,7 +9,7 @@ namespace Yavsc.Blogs.Controllers
{
[Authorize("BlogScope")]
[Produces("application/json")]
[Route(APIPrefix + "/blog")]
[Route(APIPrefix + "/" + BlogSpotPath)]
public class BlogApiController : Controller
{
private readonly BlogSpotService blogSpotService;
@ -19,14 +19,14 @@ namespace Yavsc.Blogs.Controllers
this.blogSpotService = blogSpotService;
}
// GET: api/BlogApi
// GET: api/v1/blogspot
[HttpGet]
public async Task<IEnumerable<IBlogPost>> GetBlogspot(int start = 0, int take = 25)
{
return await blogSpotService.Index(User, null, start, take);
}
// GET: api/BlogApi/5
// GET: api/v1/blogspot/5
[HttpGet("{id}", Name = "GetBlog")]
public async Task<IActionResult> GetBlog([FromRoute] long id)
{
@ -43,7 +43,7 @@ namespace Yavsc.Blogs.Controllers
return NotFound();
}
return Ok(blog);
return Ok(blog.GetPayload());
}
catch (AuthorizationFailureException)
{
@ -51,7 +51,7 @@ namespace Yavsc.Blogs.Controllers
}
}
// PUT: api/BlogApi/5
// PUT: api/v1/blogspot/5
[HttpPut("{id}")]
public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
{
@ -83,7 +83,7 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
// POST: api/v1/blog
// POST: api/v1/blogspot
[HttpPost]
public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
{
@ -116,7 +116,8 @@ namespace Yavsc.Blogs.Controllers
: (IFormFileCollection)new FormFileCollection();
var uid = User.GetUserId();
var post = blogSpotService.Create(uid, blog, files);
return CreatedAtRoute("GetBlog", new { id = post.Id }, post);
return CreatedAtRoute("GetBlog", new { id = post.Id },
post.GetPayload());
}
// DELETE: api/BlogApi/5
@ -135,7 +136,7 @@ namespace Yavsc.Blogs.Controllers
}
await blogSpotService.Delete(User, id);
return Ok(blog);
return Ok(blog.GetPayload());
}
/// <summary>

View file

@ -6,7 +6,7 @@ using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{
[Produces("application/json")]
[Route(APIPrefix + "/blogtags")]
[Route(APIPrefix + "/" + BlogTagPath )]
public class BlogTagsApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -8,7 +8,7 @@ using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{
[Produces("application/json")]
[Route(APIPrefix +"/circle")]
[Route(APIPrefix +"/" + CirclePath)]
public class CircleApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -11,7 +11,7 @@ namespace Yavsc.Blogs.Controllers
{
[Authorize]
[Produces("application/json")]
[Route(APIPrefix + "/blogcomments")]
[Route(APIPrefix + "/" + CommentsPath)]
public class CommentsApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -4,18 +4,15 @@
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId>
<RootNamespace>Yavsc.Blogs</RootNamespace>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<RepositoryUrl>https://forgejo.pschneider.fr/notazof/yavsc</RepositoryUrl>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -11,7 +11,7 @@
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -86,7 +86,4 @@
</ItemGroup>
<Copy SourceFiles="@(_YavscOrgStaticAssetsFiles)" DestinationFolder="$(OutDir)" />
</Target>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -70,7 +70,7 @@
<div class="actiongroup">
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
{
<a asp-action="Details" asp-route-id="@((IBlogPost)post).Id" class="btn btn-light">Details</a>
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
}
else
{

View file

@ -93,8 +93,8 @@ A operação é anulável até duas semanas após a sua programação.
<environment names="Lua,Development">
<asciidoc>Este é o meu site perso, uma configuração de _Yavsc_ (outro negócio muito pequeno).
* [README](https://github.com/pazof/yavsc/blob/vnext/README.md)
* [licença: GNU GPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE)
* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md)
* [licença: GNU GPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE)
Outras instalações:
</asciidoc>
@ -109,8 +109,8 @@ Outras instalações:
<asciidoc>
Yet Another Very Small Company ...
* [README](https://github.com/pazof/yavsc/blob/vnext/README.md)
* [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE)
* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md)
* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE)
</asciidoc>
</environment>
@ -118,8 +118,8 @@ Outras instalações:
<environment names="YavscPre">
<asciidoc>
## Yet Another Very Small Company :
* [README](https://github.com/pazof/yavsc/blob/vnext/README.md)
* [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE)
* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md)
* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE)
En production:

View file

@ -9,7 +9,7 @@
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -51,7 +51,4 @@
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,22 @@
using Yavsc.Models.Blog;
public static class PayloadHelpers
{
public static object GetPayload(this BlogPost post)
{
return new
{
post.Id,
post.Title,
post.Article,
post.DateCreated,
post.UserCreated,
post.DateModified,
post.UserModified,
post.AuthorId,
ACL = post.GetACL(),
Tags = post.GetTags(),
post.IsPublished
};
}
}

View file

@ -85,7 +85,7 @@ namespace Yavsc.Models.Blog
public string[] GetTags()
{
return Tags.Select(t => t.Tag.Name).ToArray();
return Tags?.Select(t => t.Tag.Name).ToArray() ?? Array.Empty<string>();
}
[InverseProperty("Post")]
@ -106,6 +106,7 @@ namespace Yavsc.Models.Blog
[NotMapped]
public bool IsPublished { get; set; }
[JsonIgnore]
/// <summary>
/// Explicit interface implementation of
/// <see cref="IBlogPost.Author"/>. The underlying

View file

@ -1,14 +1,17 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Yavsc.Models.Relationship;
namespace Yavsc.Models.Blog
{
public partial class BlogTag
{
[JsonIgnore]
[ForeignKey("PostId")]
public virtual BlogPost Post { get; set; }
public long PostId { get; set; }
[JsonIgnore]
[ForeignKey("TagId")]
public virtual Tag Tag{ get; set; }
public long TagId { get; set; }

View file

@ -14,14 +14,16 @@ namespace Yavsc.Models.Blog
[YaStringLength(1024)]
public string Article { get; set; }
[ForeignKeyAttribute(nameof(ReceiverId))][JsonIgnore]
[JsonIgnore]
[ForeignKeyAttribute(nameof(ReceiverId))]
public virtual BlogPost Post { get; set; }
[Required]
public long ReceiverId { get; set; }
public bool Visible { get; set; }
[ForeignKeyAttribute("AuthorId")][JsonIgnore]
[ForeignKeyAttribute("AuthorId")]
[JsonIgnore]
public virtual ApplicationUser Author {
get; set;
}

View file

@ -5,11 +5,11 @@
<UserSecretsId>53bd70e8-ff81-497a-847f-a15fd8ea7a09</UserSecretsId>
<RootNamespace>Yavsc.Server</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<RepositoryUrl>https://forgejo.pschneider.fr/notazof/yavsc</RepositoryUrl>
<Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -40,7 +40,4 @@
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,31 @@
namespace Yavsc.Blogs.Tests.Fixtures;
using static Yavsc.Constants;
public static class BlogHelpers
{
public static string ApiUrl(this IBackendFixture fixture, string apiSubPath)
{
var secured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("https://"));
if (secured is null)
{
var unsecured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("http://"));
if (unsecured is null)
{
throw new InvalidOperationException("No backend address found");
}
return $"{unsecured}/{APIPrefix}/{apiSubPath}";
}
return $"{secured}/{APIPrefix}/{apiSubPath}";
}
public static string BlogAclUrl(this IBackendFixture fixture)
=> fixture.ApiUrl(BlogAclPath);
public static string BlogSpotUrl(this IBackendFixture fixture)
=> fixture.ApiUrl(BlogSpotPath);
public static string PublishUrl(this IBackendFixture fixture, long id)
=> fixture.ApiUrl(BlogSpotPath) +"/" + id + "/publish";
}

View file

@ -0,0 +1,13 @@
public interface IBackendFixture : IDisposable
{
/// <summary>
/// The addresses the fixture bound to.
/// </summary>
IReadOnlyList<string> Addresses { get; }
/// <summary>
/// The service provider for the fixture host.
/// </summary>
IServiceProvider Services { get; }
}

View file

@ -30,14 +30,13 @@ namespace Yavsc.Tests.Shared;
/// mapping are the responsibility of the subclass, through
/// <see cref="BuildApp"/>.
/// </summary>
public abstract class WebHostFixture : IDisposable
public abstract class WebHostFixture : IBackendFixture
{
private static readonly Lazy<X509Certificate2> _selfSignedCertificate =
new Lazy<X509Certificate2>(CreateSelfSignedCertificate);
private static readonly object _sync = new object();
private static WebApplication? _app;
private static bool _isInitialized;
private static int _instanceCount;
private static readonly List<string> _sharedAddresses = new();
private static IServiceProvider? _sharedServices;
@ -57,11 +56,12 @@ public abstract class WebHostFixture : IDisposable
/// successfully and the host is running.</summary>
public bool IsInitialized { get; private set; }
#pragma warning disable CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez dajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
protected WebHostFixture()
#pragma warning restore CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez dajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null.
{
lock (_sync)
{
_instanceCount++;
if (!_isInitialized)
{
InitializeAsync().GetAwaiter().GetResult();
@ -110,6 +110,8 @@ public abstract class WebHostFixture : IDisposable
/// listen port.</summary>
protected virtual int HttpsPort => 5101;
public WebApplication App { get; private set; }
private async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
@ -122,14 +124,14 @@ public abstract class WebHostFixture : IDisposable
});
});
var app = BuildApp(builder);
app = await ConfigurePipelineAsync(app);
await app.StartAsync();
this.App = BuildApp(builder);
this.App = await ConfigurePipelineAsync(this.App);
await this.App.StartAsync();
_app = app;
_sharedServices = app.Services;
_app = this.App;
_sharedServices = this.App.Services;
var server = app.Services.GetRequiredService<IServer>();
var server = this.App.Services.GetRequiredService<IServer>();
var addressFeatures = server.Features.Get<IServerAddressesFeature>();
_sharedAddresses.Clear();
if (addressFeatures?.Addresses is not null)
@ -147,15 +149,14 @@ public abstract class WebHostFixture : IDisposable
{
lock (_sync)
{
_instanceCount--;
if (_instanceCount == 0 && _app is not null)
{
_app.StopAsync().GetAwaiter().GetResult();
_app = null;
_isInitialized = false;
_sharedAddresses.Clear();
_sharedServices = null;
}
if (!IsInitialized)
throw new InvalidOperationException("Cannot tear down a fixture that has not been initialized.");
this.App.StopAsync().GetAwaiter().GetResult();
IsInitialized = false;
_isInitialized = false;
_sharedAddresses.Clear();
_sharedServices = null;
}
}

View file

@ -14,7 +14,7 @@
-->
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -23,4 +23,7 @@
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
</Project>

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+148.Branch.release-1.0.8-rc4.Sha.6b161b0fb509fec0ce467aa94f1a450b202af2f0</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -22,7 +22,4 @@
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>