Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4272cec614 | ||
|
|
3f4b3a23be | ||
|
|
d058f1b6a2 | ||
|
|
924ebe9776 | ||
|
|
5dc4b21867 | ||
|
|
accd74c9ee | ||
|
|
30cc39606a | ||
|
|
099211b015 | ||
|
|
37a44ffb46 | ||
|
|
045c734eef | ||
|
|
83c1902b08 | ||
|
|
076d60460c | ||
|
|
93b7ff28e1 | ||
|
|
08968fe820 | ||
|
|
3a57e96c80 | ||
|
|
332ba757fe | ||
|
|
09fe92fc95 | ||
|
|
ec2451aad1 | ||
|
|
ff32d68a72 | ||
|
|
1a56b7dcaf | ||
|
|
7c5abc2705 | ||
|
|
aa96b9eb10 | ||
|
|
5a891cd121 | ||
|
|
ad08453859 | ||
|
|
5822061770 | ||
|
|
7920587cf5 | ||
|
|
65aef4e036 | ||
|
|
3843d94e29 | ||
|
|
73a4f75fa5 | ||
|
|
55fadc1a69 | ||
|
|
3432562567 | ||
|
|
a9a3d42e77 | ||
|
|
a71e5dc7e8 | ||
|
|
28e4f63447 | ||
|
|
5eff726f0e | ||
|
|
da22b1da8a | ||
|
|
62538d6b59 | ||
|
|
83d5448b94 | ||
|
|
e609c0fead | ||
|
|
4e9bb87975 | ||
|
|
495fa27b9a | ||
|
|
efaf20d943 | ||
|
|
169a51f04d | ||
|
|
3518521ca2 | ||
|
|
774271eb93 | ||
|
|
5df21b1ede | ||
|
|
dbcdcc031f | ||
|
|
e05c5a3016 | ||
|
|
aa25f0e016 | ||
|
|
2104617b82 | ||
|
|
ea22004e6d | ||
|
|
acea50d7e9 | ||
|
|
500c49e41e | ||
|
|
bad23073f4 | ||
|
|
1fcc08289a | ||
|
|
5d03711f80 | ||
|
|
30cbbc4be1 | ||
|
|
4a196c9c23 | ||
|
|
a62a17a7bf | ||
|
|
b0530cdccb | ||
|
|
e483a4b683 | ||
|
|
23eaa5bc8b | ||
|
|
18286f689b | ||
|
|
319c550af4 | ||
|
|
c7608b6404 | ||
|
|
c71febbb23 | ||
|
|
5ab3bd6b97 | ||
|
|
c59caf7272 | ||
|
|
c36f11ab4f | ||
|
|
7189433e5b | ||
|
|
dadb9e45d4 | ||
|
|
c1fabc5b5c | ||
|
|
5def89f182 | ||
|
|
4bd9ca234e | ||
|
|
924229fafe | ||
|
|
2314fca8ee | ||
|
|
2210d1fa01 | ||
|
|
746d3cca9c | ||
|
|
7f5773fc3c | ||
|
|
90f56143c4 | ||
|
|
ab4b82e492 | ||
|
|
4a3d8e5ca2 | ||
|
|
7f7f57d06d | ||
|
|
c45aed105c | ||
|
|
7e829ef87b | ||
|
|
cc9940c705 | ||
|
|
bb0b5a9a27 | ||
|
|
5686e8785f | ||
|
|
3da62b654e | ||
|
|
b6fb974264 | ||
|
|
07bfd84810 | ||
|
|
2db54e0e19 | ||
|
|
a2252a2ede | ||
|
|
fcc581d48b | ||
|
|
ec3bc51346 | ||
|
|
08a14d79cb | ||
|
|
812063a320 | ||
|
|
983cafc9b9 | ||
|
|
5e05f0d354 | ||
|
|
73d98a3dc3 | ||
|
|
7a78e5e7a6 | ||
|
|
f6164e7cf0 | ||
|
|
43f88c7866 | ||
|
|
70602e4c91 | ||
|
|
653f32e08c | ||
|
|
a98068ba09 | ||
|
|
b2a23d60b8 | ||
|
|
8fddb0a94c | ||
|
|
15e12ec2b9 | ||
|
|
c5945f1a94 | ||
|
|
d4cb543090 | ||
|
|
63b430873d | ||
|
|
3a584bbe8b | ||
|
|
2873df25ae | ||
|
|
bff6b484e5 | ||
|
|
4371335b37 | ||
|
|
468c347357 | ||
|
|
65503e60b8 | ||
|
|
a832de5ce1 | ||
|
|
5caef6b2b3 | ||
|
|
b50081014e | ||
|
|
5dc3a4525a | ||
|
|
716a005da6 | ||
|
|
5890dd0fe3 | ||
|
|
29856d9bea | ||
|
|
5184d3ec24 | ||
|
|
0610460ebb | ||
|
|
8081fc13f6 | ||
|
|
3fec62fbc2 | ||
|
|
3f76f77e5b | ||
|
|
1f33506c13 | ||
|
|
44e09aa89f | ||
|
|
0870cd923a | ||
|
|
49b14337eb | ||
|
|
74f186136b | ||
|
|
bbaa04354c | ||
|
|
86c97cbad1 | ||
|
|
5a05663050 | ||
|
|
ebe1d67620 | ||
|
|
7eaebeb787 | ||
|
|
2f1f7d5ba5 | ||
|
|
92454ae8a1 | ||
|
|
aad70f342b | ||
|
|
cf597c12e0 | ||
|
|
cbddf8ca0b | ||
|
|
ad738a643e | ||
|
|
8f5c59bd64 | ||
|
|
914bab840d | ||
|
|
8f96ba9478 | ||
|
|
5a9e7b0f7f | ||
|
|
5dfd9200f8 | ||
|
|
8c7168a1a9 | ||
|
|
377f3aa775 | ||
|
|
662975c802 | ||
|
|
3b420a7bad | ||
|
|
2dad6c9bcc | ||
|
|
723f80c8af | ||
|
|
450247175f | ||
|
|
5d6f772a1c | ||
|
|
575c19f921 | ||
|
|
328663b9e7 | ||
|
|
c9ddd3488d | ||
|
|
9958386792 | ||
|
|
f5fae6d32e | ||
|
|
b94f1f7bbb | ||
|
|
ffbb87eddd | ||
|
|
63fad4083f | ||
|
|
872d740ce7 | ||
|
|
4c2d46429d | ||
|
|
120abbad93 | ||
|
|
a39002f1b8 | ||
|
|
fb470ecf0c | ||
|
|
2b6ac65a1f | ||
|
|
1b2e83a155 | ||
|
|
8a6cb868d9 | ||
|
|
4a7080b313 | ||
|
|
1afb13aae0 | ||
|
|
3d519bcfcd | ||
|
|
72287fc995 | ||
|
|
0b4a170910 | ||
|
|
72e6f145b3 | ||
|
|
367ed94c01 | ||
|
|
fa9bd93ada | ||
|
|
d1551bc9c1 |
@@ -18,9 +18,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# Each host runs only the publish tasks Gradle actually generated for it.
|
# Each host runs only the publish tasks Gradle actually generated for it.
|
||||||
# macOS also publishes the shared kotlinMultiplatform metadata module.
|
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
|
||||||
# No library module declares jvm() today — apps do their own JVM parity
|
# (see its matrix entry) + the jvm jar + the bridge plugin.
|
||||||
# build against upstream Compose, so JVM isn't part of the publication.
|
|
||||||
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
||||||
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
||||||
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
||||||
@@ -31,13 +30,8 @@ jobs:
|
|||||||
host: macos
|
host: macos
|
||||||
target: MacosArm64
|
target: MacosArm64
|
||||||
exe_ext: kexe
|
exe_ext: kexe
|
||||||
# jvm publication: only :material-symbols declares a jvm() target
|
|
||||||
# (its common API renders via upstream Compose on JVM) — publish
|
|
||||||
# its host-independent jar from the same host as the metadata.
|
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
|
||||||
publishMacosArm64PublicationToGitHubPackagesRepository
|
publishMacosArm64PublicationToGitHubPackagesRepository
|
||||||
publishJvmPublicationToGitHubPackagesRepository
|
|
||||||
- runner: ubuntu-22.04
|
- runner: ubuntu-22.04
|
||||||
host: linux
|
host: linux
|
||||||
target: LinuxX64
|
target: LinuxX64
|
||||||
@@ -49,8 +43,18 @@ jobs:
|
|||||||
host: windows
|
host: windows
|
||||||
target: MingwX64
|
target: MingwX64
|
||||||
exe_ext: exe
|
exe_ext: exe
|
||||||
|
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
|
||||||
|
# the ONLY host that declares every target (vHostSupportsMingw is
|
||||||
|
# host-gated), so only its generated .module files carry the full
|
||||||
|
# variant table — the macOS-published roots of v0.1.15 had NO
|
||||||
|
# mingwX64 variants and Windows consumers could not resolve them.
|
||||||
|
# The host-independent jvm jar (:material-symbols) and the bridge
|
||||||
|
# plugin ride along on the same job.
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishMingwX64PublicationToGitHubPackagesRepository
|
publishMingwX64PublicationToGitHubPackagesRepository
|
||||||
|
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||||
|
publishJvmPublicationToGitHubPackagesRepository
|
||||||
|
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
@@ -99,16 +103,13 @@ jobs:
|
|||||||
# intrinsic K/N's LLD can't resolve is aliased to msvcrt's _setjmpex at
|
# intrinsic K/N's LLD can't resolve is aliased to msvcrt's _setjmpex at
|
||||||
# link time via --defsym in sdl3.def.
|
# link time via --defsym in sdl3.def.
|
||||||
|
|
||||||
# Build the static native deps from scripts/build-sdl/build-sdl.properties.
|
# Build the static SDL3 lib (scripts/build-sdl/build-sdl.properties). Every
|
||||||
# Only Windows ships the SDL3 renderer (no Skiko on mingw), so only it needs
|
# target renders through Skia now, so all platforms need only base SDL3 for
|
||||||
# SDL3_ttf / SDL3_image / FreeType (SDL3_ttf text, SDL3_image decode, FreeType
|
# the main loop (windowing / input / audio / filesystem) — the from-scratch
|
||||||
# icon-axis rasterisation). macOS / Linux publish the Skia renderer — Skiko
|
# SDL renderer and its SDL3_ttf / SDL3_image / FreeType deps were removed.
|
||||||
# handles text / images / icons — so they only need base SDL3 for the main
|
# Cached against the manifest + scripts so version bumps invalidate cleanly;
|
||||||
# loop (windowing / input / audio / filesystem). Matches the cinterop guard
|
# bump the `v3` key when the toolchain (runner OS, compiler flavour, K/N
|
||||||
# in compose/ui/ui/build.gradle.kts, which skips those three cinterops on the
|
# sysroot) changes without a scripts/build-sdl/ edit.
|
||||||
# Skia targets. Cached against the manifest + scripts so version bumps
|
|
||||||
# invalidate cleanly; bump the `v3` key when the toolchain (runner OS,
|
|
||||||
# compiler flavour, K/N sysroot) changes without a scripts/build-sdl/ edit.
|
|
||||||
- name: Cache libs/
|
- name: Cache libs/
|
||||||
id: cache-libs
|
id: cache-libs
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -116,16 +117,10 @@ jobs:
|
|||||||
path: libs
|
path: libs
|
||||||
key: sdl-libs-v3-${{ matrix.host }}-${{ hashFiles('scripts/build-sdl/**') }}
|
key: sdl-libs-v3-${{ matrix.host }}-${{ hashFiles('scripts/build-sdl/**') }}
|
||||||
|
|
||||||
# Windows: full SDL3 renderer stack. macOS / Linux (Skia): base SDL3 only.
|
|
||||||
- name: Build static native deps
|
- name: Build static native deps
|
||||||
if: steps.cache-libs.outputs.cache-hit != 'true'
|
if: steps.cache-libs.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: python3 scripts/build-sdl/build-all.py
|
||||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
|
||||||
python3 scripts/build-sdl/build-all.py
|
|
||||||
else
|
|
||||||
python3 scripts/build-sdl/build-all.py sdl3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# src/vendor/ trees are gitignored — a fresh checkout has zero vendored
|
# src/vendor/ trees are gitignored — a fresh checkout has zero vendored
|
||||||
# androidx.compose.* files. Sync copies them in from the upstream refs
|
# androidx.compose.* files. Sync copies them in from the upstream refs
|
||||||
@@ -135,16 +130,84 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: bash scripts/compose-fork/sync.sh
|
run: bash scripts/compose-fork/sync.sh
|
||||||
|
|
||||||
# GitHub Packages sometimes returns 409 Conflict on a fresh upload when
|
# GitHub Packages FORBIDS overwriting a file that already exists in a
|
||||||
# the same coordinate was recently deleted (soft-delete propagation).
|
# version, and maven-publish uploads each publication's files
|
||||||
# `--continue` lets Gradle finish the rest of the publish tasks past a
|
# SEQUENTIALLY from the first one. So when an upload dies mid-publication
|
||||||
# single 409, and the outer retry re-runs the whole thing (Gradle skips
|
# (the v0.1.13 macOS run lost DNS mid-publish), a blind retry hits 409
|
||||||
# already-uploaded artifacts). Two attempts is enough for the transient
|
# Conflict on the first already-uploaded file and aborts BEFORE reaching
|
||||||
# cases we've hit; the third pass falls through as a hard failure.
|
# the missing tail files — the version is poisoned and can never
|
||||||
|
# complete. A 409 is therefore only benign when the publication had
|
||||||
|
# already completed; you can't tell from the 409 alone. The fix: between
|
||||||
|
# attempts, DELETE this host's package versions for the current release
|
||||||
|
# and re-upload from a clean slate. Each host owns DISJOINT package
|
||||||
|
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
|
||||||
|
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
|
||||||
|
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
|
||||||
|
# a 403 on delete just degrades to the old blind-retry behaviour.
|
||||||
- name: Publish to GitHub Packages
|
- name: Publish to GitHub Packages
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
VER="${PUBLISH_VERSION#v}"
|
||||||
|
OWNER="${GITHUB_REPOSITORY%%/*}"
|
||||||
|
# Keep in sync with the module list in settings.gradle.kts.
|
||||||
|
MODULES="sdl-core ui ui-graphics ui-text ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple desktop-native-window material-symbols navigation3-ui components-resources"
|
||||||
|
case "${{ matrix.host }}" in
|
||||||
|
macos) SUFFIXES="-macosarm64" ;;
|
||||||
|
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
||||||
|
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
|
||||||
|
esac
|
||||||
|
# Published group per module — mirrors the per-area com.bitsycore groups
|
||||||
|
# in root build.gradle.kts. Project-only modules (sdl-core, material-symbols)
|
||||||
|
# fall through to com.bitsycore.compose.sdl; the GitHub Packages Maven
|
||||||
|
# package name is "<group>.<artifact>".
|
||||||
|
group_of() {
|
||||||
|
case "$1" in
|
||||||
|
ui|ui-graphics|ui-text|ui-util|ui-geometry|ui-unit|ui-backhandler|ui-tooling-preview)
|
||||||
|
echo "com.bitsycore.compose.ui" ;;
|
||||||
|
foundation|foundation-layout) echo "com.bitsycore.compose.foundation" ;;
|
||||||
|
animation|animation-core|animation-graphics) echo "com.bitsycore.compose.animation" ;;
|
||||||
|
material3) echo "com.bitsycore.compose.material3" ;;
|
||||||
|
material-ripple) echo "com.bitsycore.compose.material" ;;
|
||||||
|
components-resources) echo "com.bitsycore.compose.components" ;;
|
||||||
|
navigation3-ui) echo "com.bitsycore.navigation3" ;;
|
||||||
|
desktop-native-window) echo "com.bitsycore.compose" ;;
|
||||||
|
*) echo "com.bitsycore.compose.sdl" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
host_packages() {
|
||||||
|
for m in $MODULES; do
|
||||||
|
g=$(group_of "$m")
|
||||||
|
for s in $SUFFIXES; do
|
||||||
|
if [ "$s" = "_ROOT_" ]; then echo "$g.$m"; else echo "$g.$m$s"; fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||||
|
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
|
||||||
|
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
|
||||||
|
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
cleanup_host_versions() {
|
||||||
|
echo "Deleting this host's partially-published $VER package versions"
|
||||||
|
host_packages | while read -r pkg; do
|
||||||
|
vid=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions?per_page=100" \
|
||||||
|
| jq -r --arg v "$VER" '[.[]? | select(.name==$v) | .id] | first // empty')
|
||||||
|
[ -z "$vid" ] && continue
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions/$vid")
|
||||||
|
if [ "$code" != "204" ]; then
|
||||||
|
# Deleting the LAST version of a package is refused (400) —
|
||||||
|
# a package new in this release: drop the whole package.
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/users/$OWNER/packages/maven/$pkg")
|
||||||
|
fi
|
||||||
|
echo " $pkg@$VER -> HTTP $code"
|
||||||
|
done
|
||||||
|
}
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "::group::Publish attempt $attempt"
|
echo "::group::Publish attempt $attempt"
|
||||||
if ./gradlew ${{ matrix.tasks }} --continue --no-daemon --stacktrace; then
|
if ./gradlew ${{ matrix.tasks }} --continue --no-daemon --stacktrace; then
|
||||||
@@ -152,16 +215,21 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
[ $attempt -lt 3 ] && { echo "::warning::Publish attempt $attempt failed — sleeping 60s and retrying"; sleep 60; }
|
if [ $attempt -lt 3 ]; then
|
||||||
|
echo "::warning::Publish attempt $attempt failed — cleaning this host's $VER packages and retrying"
|
||||||
|
cleanup_host_versions || true
|
||||||
|
sleep 30
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
echo "::error::Publish failed after 3 attempts"
|
echo "::error::Publish failed after 3 attempts"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# Release binaries for the host target — the binary plus the data.kres
|
# Release binaries for the host target — the binary plus the data.kres
|
||||||
# (bundled composeResources + fonts + subset icon-font). Each :demo /
|
# (bundled composeResources + fonts + subset icon-font). Each app's
|
||||||
# :apidemo has its linkReleaseExecutable<Target> task wired to auto-run
|
# linkReleaseExecutable<Target> auto-runs its data.kres zip task —
|
||||||
# the matching copy<Variant>ComposeResources<Target> so data.kres is
|
# :demo's own copy<Variant>ComposeResources<Target>, :apidemo's
|
||||||
# produced next to the .kexe / .exe. On Linux only linuxX64 is built —
|
# bridge-plugin package<Variant>ComposeResources<Target> — so data.kres
|
||||||
|
# is produced next to the .kexe / .exe. On Linux only linuxX64 is built —
|
||||||
# linuxArm64 is skipped since the SDL3 static libs on the ubuntu runner
|
# linuxArm64 is skipped since the SDL3 static libs on the ubuntu runner
|
||||||
# are host-arch (x64).
|
# are host-arch (x64).
|
||||||
- name: Build demo + apidemo release binaries
|
- name: Build demo + apidemo release binaries
|
||||||
@@ -184,8 +252,21 @@ jobs:
|
|||||||
for app in demo apidemo; do
|
for app in demo apidemo; do
|
||||||
src="$app/build/bin/$target_dir/releaseExecutable"
|
src="$app/build/bin/$target_dir/releaseExecutable"
|
||||||
zip_path="$(pwd)/dist/$app-$version-$target_dir.zip"
|
zip_path="$(pwd)/dist/$app-$version-$target_dir.zip"
|
||||||
|
files=("$src/$app.${{ matrix.exe_ext }}" "$src/data.kres")
|
||||||
|
# Windows Skia leg ships two runtime sidecars next to the exe — the
|
||||||
|
# skiko fork DLL and Skia's ICU data (icudtl.dat) — provisioned by the
|
||||||
|
# bridge plugin after linkReleaseExecutableMingwX64. The exe cannot run
|
||||||
|
# without them, so include them (and fail loudly if a provision regressed).
|
||||||
|
if [ "${{ matrix.exe_ext }}" = "exe" ]; then
|
||||||
|
for sidecar in skiko-windows-x64.dll icudtl.dat; do
|
||||||
|
if [ ! -f "$src/$sidecar" ]; then
|
||||||
|
echo "::error::Missing Windows runtime sidecar: $src/$sidecar"; exit 1
|
||||||
|
fi
|
||||||
|
files+=("$src/$sidecar")
|
||||||
|
done
|
||||||
|
fi
|
||||||
python3 -c 'import sys,os,zipfile; z=zipfile.ZipFile(sys.argv[1],"w",zipfile.ZIP_DEFLATED,compresslevel=9); [z.write(f,os.path.basename(f)) for f in sys.argv[2:]]; z.close()' \
|
python3 -c 'import sys,os,zipfile; z=zipfile.ZipFile(sys.argv[1],"w",zipfile.ZIP_DEFLATED,compresslevel=9); [z.write(f,os.path.basename(f)) for f in sys.argv[2:]]; z.close()' \
|
||||||
"$zip_path" "$src/$app.${{ matrix.exe_ext }}" "$src/data.kres"
|
"$zip_path" "${files[@]}"
|
||||||
done
|
done
|
||||||
ls -la dist/
|
ls -la dist/
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,6 @@ bin/
|
|||||||
### Python ###
|
### Python ###
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
|
||||||
|
/*.bmp
|
||||||
|
|||||||
@@ -3,17 +3,37 @@
|
|||||||
Guidance for Claude Code working in this repository. This is the primary
|
Guidance for Claude Code working in this repository. This is the primary
|
||||||
context — read it first, then look at the files it points to.
|
context — read it first, then look at the files it points to.
|
||||||
|
|
||||||
|
## Documentation map
|
||||||
|
|
||||||
|
- [README.md](README.md) — public overview + quickstart (bridge plugin,
|
||||||
|
`nativeComposeWindow`, sample apps).
|
||||||
|
- [RENDERER.md](RENDERER.md) — the rendering layer: architecture, the retained-
|
||||||
|
layer engine, convergence decisions, learnings, and open work. Read it before
|
||||||
|
touching any renderer, graphics-actual, or layer-engine code.
|
||||||
|
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
|
||||||
|
(build-sdl, sync + drift checks, parity, probe, profiler, coverage,
|
||||||
|
verify-mac) + the version map and the ref-bump / release runbooks.
|
||||||
|
- [TODO.md](TODO.md) — audited list of no-ops, stubs, and hardcodes left in the
|
||||||
|
port, with severity, plus what's missing for a stable 1.12 release.
|
||||||
|
- This file — architecture, module layout, vendoring rules, source-set
|
||||||
|
hierarchy, density flow, conventions, and common pitfalls.
|
||||||
|
|
||||||
## What this project is
|
## What this project is
|
||||||
|
|
||||||
**ComposeNativeSDL3** — a Kotlin/Native port of Compose Multiplatform running
|
**ComposeNativeSDL3** — a Kotlin/Native port of Compose Multiplatform running
|
||||||
on SDL3, no JVM. Compiles to native binaries for macOS (arm64), Linux
|
on SDL3, no JVM. Compiles to native binaries for macOS (arm64), Linux
|
||||||
(x64/arm64), Windows (mingwX64).
|
(x64/arm64), Windows (mingwX64).
|
||||||
|
|
||||||
Rendering is pluggable behind one `RenderBackend`:
|
Rendering is **Skia everywhere** behind one `RenderBackend` — Metal / OpenGL
|
||||||
|
/ CPU raster:
|
||||||
|
|
||||||
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
|
- **macOS + Linux** link the OFFICIAL Skiko klibs from Maven.
|
||||||
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
|
- **Windows (mingwX64)** links the bitsycore skiko FORK — skiko+Skia compiled
|
||||||
when `-Prenderer=sdl3` is passed.
|
into `skiko-windows-x64.dll` with a flat extern-C surface, bound from K/N via
|
||||||
|
an embedded GNU import lib, published to GitHub Packages as
|
||||||
|
`com.bitsycore.skiko:skiko:0.150.1-mingw.1` (override with
|
||||||
|
`-PskikoMingwVersion`). The runtime DLL is auto-provisioned next to the exe by
|
||||||
|
the bridge plugin (`installWindowsSkiaDll`). See SKIKO-MINGW-FEASIBILITY.md.
|
||||||
|
|
||||||
Windowing, input, audio, filesystem access, and the OS-integration surface
|
Windowing, input, audio, filesystem access, and the OS-integration surface
|
||||||
(file dialogs, clipboard, "open in Finder/Explorer"…) all go through
|
(file dialogs, clipboard, "open in Finder/Explorer"…) all go through
|
||||||
@@ -26,8 +46,11 @@ re-implements the layers on top (`androidx.compose.ui.*`, `.foundation.*`,
|
|||||||
## Module layout
|
## Module layout
|
||||||
|
|
||||||
Library modules mirror upstream Compose Multiplatform's `compose/` tree.
|
Library modules mirror upstream Compose Multiplatform's `compose/` tree.
|
||||||
Only `:window` is the SDL integration layer ("Compose SDL" — SDL3 main loop)
|
The SDL layer is two modules: `:sdl-core` (the NAKED sdl3 cinterop + platform
|
||||||
and lives under `compose/sdl/`.
|
primitives — zero Compose dep, like `skiko`) at `sdl/sdl-core/`, and
|
||||||
|
`:desktop-native-window` (the SDL3 main-loop shell + app entry point) at
|
||||||
|
`compose/desktop/native/window/`. `:ui` depends on `:sdl-core` and its renderer
|
||||||
|
+ SDL↔Compose bridges pick the cinterop from it.
|
||||||
|
|
||||||
One Gradle module per upstream artifact; the directory mirrors the upstream
|
One Gradle module per upstream artifact; the directory mirrors the upstream
|
||||||
`compose/` path, the gradle path is kept short (redirected via `projectDir`).
|
`compose/` path, the gradle path is kept short (redirected via `projectDir`).
|
||||||
@@ -35,14 +58,27 @@ One Gradle module per upstream artifact; the directory mirrors the upstream
|
|||||||
```
|
```
|
||||||
compose/
|
compose/
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── ui/ → :ui — androidx.compose.ui.* (ui + ui-graphics + ui-text) +
|
│ ├── ui/ → :ui — androidx.compose.ui.* CORE (Modifier, LayoutNode,
|
||||||
│ │ com.compose.sdl.* — cinterops + BOTH renderer pipelines live
|
│ │ composition, semantics, input, focus) + com.compose.sdl.* —
|
||||||
│ │ here. ui-graphics/ui-text can't split off: their Canvas /
|
│ │ the Skia RenderBackend + GPU bridges + the SDL↔Compose
|
||||||
│ │ Paragraph `expect`s are the renderers' `actual`s (same-module).
|
│ │ bridges (events / clipboard / cursors / window). Depends on
|
||||||
|
│ │ :ui-graphics, :ui-text, :sdl-core. (ui-graphics + ui-text +
|
||||||
|
│ │ the sdl3 cinterop were split OUT — upstream layout.)
|
||||||
|
│ ├── ui-graphics/ → :ui-graphics — androidx.compose.ui.graphics.* + the Skia actuals
|
||||||
|
│ │ (SkiaBackedCanvas/Path/Paint, GraphicsLayer, SkiaImageCache,
|
||||||
|
│ │ painter/image + resource seams). → skiko; SDL-free.
|
||||||
|
│ ├── ui-text/ → :ui-text — androidx.compose.ui.text.* + the skiko text engine
|
||||||
|
│ │ (SkiaParagraph → NativeParagraphOps → SkiaFonts, IconFont,
|
||||||
|
│ │ NamedFont). → :ui-graphics, skiko; SDL-free.
|
||||||
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
|
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
|
||||||
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
|
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
|
||||||
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
|
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
|
||||||
│ └── ui-backhandler/ → :ui-backhandler — androidx.compose.ui.backhandler.*
|
│ ├── ui-backhandler/ → :ui-backhandler — androidx.compose.ui.backhandler.*
|
||||||
|
│ └── ui-tooling-preview/ → :ui-tooling-preview — androidx.compose.ui.tooling.preview.*
|
||||||
|
│ (the common @Preview + PreviewParameterProvider,
|
||||||
|
│ vendored verbatim; the Maven artifact ships no
|
||||||
|
│ mingwX64/linux klibs). IDE-only metadata — previews
|
||||||
|
│ render through the apps' jvm parity targets.
|
||||||
├── animation/
|
├── animation/
|
||||||
│ ├── animation-core/ → :animation-core — androidx.compose.animation.core.*
|
│ ├── animation-core/ → :animation-core — androidx.compose.animation.core.*
|
||||||
│ ├── animation/ → :animation — androidx.compose.animation.* (non-core)
|
│ ├── animation/ → :animation — androidx.compose.animation.* (non-core)
|
||||||
@@ -54,22 +90,31 @@ compose/
|
|||||||
│ └── material3/ → :material3 — androidx.compose.material3.*
|
│ └── material3/ → :material3 — androidx.compose.material3.*
|
||||||
├── material/
|
├── material/
|
||||||
│ └── material-ripple/ → :material-ripple — androidx.compose.material.ripple.*
|
│ └── material-ripple/ → :material-ripple — androidx.compose.material.ripple.*
|
||||||
└── sdl/ ("Compose SDL" — project modules, not upstream CMP artifacts)
|
└── desktop/native/window/ → :desktop-native-window — nativeComposeApp { Window(...) {} }
|
||||||
└── window/ → :window — nativeComposeApp { Window(...) {} } multi-window
|
multi-window shell + SDL3 main loop; nativeComposeWindow()
|
||||||
shell + SDL3 main loop; nativeComposeWindow() wrapper
|
wrapper (project app-shell, not an upstream CMP artifact).
|
||||||
|
Published as com.bitsycore.compose:desktop-native-window.
|
||||||
|
|
||||||
|
sdl/
|
||||||
|
└── sdl-core/ → :sdl-core — the NAKED sdl3 cinterop + platform primitives
|
||||||
|
(zero Compose dep, like skiko); the single `sdl3` cinterop
|
||||||
|
lives here. :ui depends on it. com.bitsycore.compose.sdl:sdl-core.
|
||||||
|
|
||||||
utils/
|
utils/
|
||||||
└── material-symbols/ → :material-symbols — codepoints + all three style objects
|
└── material-symbols/ → :material-symbols — codepoints + all three style objects
|
||||||
(Outlined / Rounded / Sharp). COMMON API (usable from
|
(Outlined / Rounded / Sharp). COMMON API (usable from
|
||||||
shared app code) + per-stack actuals: native renders
|
shared app code) + per-stack actuals: native renders
|
||||||
via :foundation IconFontIcon (IconFont handles the
|
via :foundation IconFontIcon (Skia), jvm() via Skiko directly
|
||||||
SDL3/Skia split), jvm() via Skiko directly
|
|
||||||
(Typeface.makeClone per axes — upstream's FontCache
|
(Typeface.makeClone per axes — upstream's FontCache
|
||||||
drops variationSettings from its key). Its commonMain
|
drops variationSettings from its key). Its commonMain
|
||||||
declares official Maven compose coords — the root
|
declares official Maven compose coords — the root
|
||||||
build's FULL-COMMONIZATION BRIDGE substitutes ui /
|
build's FULL-COMMONIZATION BRIDGE substitutes the
|
||||||
foundation / material3 to project modules on native
|
whole ui / foundation / animation / material3 /
|
||||||
configs. Apps get one dep; the consumer Zip task
|
nav3-ui / components-resources family to project
|
||||||
|
modules on native configs
|
||||||
|
(:demo and :apidemo commonMains rely on the same
|
||||||
|
bridge; :compose-desktop-native-bridge ships it to
|
||||||
|
consumers). Apps get one dep; the consumer Zip task
|
||||||
bundles only the fonts used (native) and
|
bundles only the fonts used (native) and
|
||||||
jvmProcessResources stages the same fonts (jvm).
|
jvmProcessResources stages the same fonts (jvm).
|
||||||
|
|
||||||
@@ -82,8 +127,8 @@ components/
|
|||||||
no mingwX64/linux klibs. Platform layer is project
|
no mingwX64/linux klibs. Platform layer is project
|
||||||
code: data.kres ResourceReader, pure-Kotlin
|
code: data.kres ResourceReader, pure-Kotlin
|
||||||
DomXmlParser (upstream's is Darwin NSXMLParser),
|
DomXmlParser (upstream's is Darwin NSXMLParser),
|
||||||
SDL3_image decode via the :ui EncodedImageDecoder
|
image decode via the :ui EncodedImageDecoder hook
|
||||||
hook, NamedFont registration, SDL locale/theme env.
|
(Skia), NamedFont registration, SDL locale/theme env.
|
||||||
Apps' JVM targets keep the Maven artifact — the
|
Apps' JVM targets keep the Maven artifact — the
|
||||||
generated Res accessors work against BOTH.
|
generated Res accessors work against BOTH.
|
||||||
|
|
||||||
@@ -103,16 +148,57 @@ demo/ → :demo — flagship showcase app (30+ screens) + th
|
|||||||
MULTIPLATFORM: also has a jvm() target running the SAME shared
|
MULTIPLATFORM: also has a jvm() target running the SAME shared
|
||||||
screens on stock JVM Compose Desktop (`./gradlew :demo:run`,
|
screens on stock JVM Compose Desktop (`./gradlew :demo:run`,
|
||||||
MainJvmKt) — the parity reference; differences vs native = port bugs
|
MainJvmKt) — the parity reference; differences vs native = port bugs
|
||||||
apidemo/ → :apidemo — Postman-style REST API manager
|
apidemo/ → :apidemo — Postman-style REST API manager. MULTIPLATFORM like :demo
|
||||||
|
(`./gradlew :apidemo:run`): the whole UI lives in commonMain
|
||||||
|
against the official Maven coords; SDL-backed APIs go through
|
||||||
|
expect/actual seams (compat/Compat.kt — native actuals delegate
|
||||||
|
to com.compose.sdl, jvm actuals use AWT + upstream desktop).
|
||||||
|
mTLS / TLS-chain inspection stays native-only (bundled libcurl).
|
||||||
|
DOGFOODS the bridge plugin: data.kres packaging + app icon come
|
||||||
|
from compose.desktop.native {}; the font pipeline (Noto +
|
||||||
|
Material Symbols subsetting) comes from the shared buildSrc
|
||||||
|
helper (registerComposeFontBundling).
|
||||||
|
buildSrc/ → shared build logic for the app modules: ComposeFontBundling.kt —
|
||||||
|
registerComposeFontBundling { } (everything OPT-IN; no flag = no-op)
|
||||||
|
registers downloadNotoFonts (bundleNotoSans / bundleNotoSansMono /
|
||||||
|
autoDetectNotoSansMono), detects the Material Symbols styles the
|
||||||
|
sources use (bundleMaterialSymbols), wires the font/ entries into
|
||||||
|
every data.kres Zip (the bridge plugin's package* tasks; hand-rolled
|
||||||
|
copy*ComposeResources* tasks also match) and stages the same fonts on
|
||||||
|
the JVM classpath; hb-subset pipeline behind enableIconSubsetting
|
||||||
|
(findMaterialSymbolsUsage + subsetMaterialSymbols<Style>).
|
||||||
|
gradle-plugin/
|
||||||
|
└── compose-desktop-native-bridge/ → the CONSUMER-side bridge as a published Gradle plugin
|
||||||
|
(id com.bitsycore.compose-desktop-native.bridge, applies to
|
||||||
|
Settings or Project). An INCLUDED build (pluginManagement.
|
||||||
|
includeBuild in settings.gradle.kts), NOT a subproject — a
|
||||||
|
plugins{} block can only resolve plugins from repositories or
|
||||||
|
included builds; :demo and :apidemo apply it from source
|
||||||
|
(dogfooding: data.kres packaging — and apidemo's app icon —
|
||||||
|
run through the plugin).
|
||||||
|
Three halves: (1) substitution — third-party apps declare
|
||||||
|
OFFICIAL CMP coords in commonMain and native configurations
|
||||||
|
swap in the published com.bitsycore.compose.sdl klibs
|
||||||
|
(version defaults to the plugin's own; override:
|
||||||
|
composeDesktopNative.version property; disable:
|
||||||
|
composeDesktopNative.substitution=false — set repo-wide in
|
||||||
|
gradle.properties since the root build's FULL-COMMONIZATION
|
||||||
|
BRIDGE substitutes to project modules in-repo); (2) data.kres
|
||||||
|
packaging (package<Variant>ComposeResources<Target> per native
|
||||||
|
executable, honours -PcompressResources); (3) the
|
||||||
|
compose.desktop.native { entryPoint / icon {} } DSL (.rgba
|
||||||
|
runtime icons + windres .exe embed — injected into
|
||||||
|
hand-declared executables too). Published by the WINDOWS
|
||||||
|
publish job.
|
||||||
scripts/ → vendor-sync + python helper scripts (compose-coverage = API
|
scripts/ → vendor-sync + python helper scripts (compose-coverage = API
|
||||||
coverage/fidelity vs upstream, material-symbols generate/subset)
|
coverage/fidelity vs upstream, material-symbols generate/subset)
|
||||||
+ compose-fork/;
|
+ compose-fork/;
|
||||||
scripts/build-sdl/ = static-lib build script (python)
|
scripts/build-sdl/ = static-lib build script (python)
|
||||||
libs/ → gitignored per-host static SDL3 / SDL3_ttf / SDL3_image / FreeType
|
libs/ → gitignored per-host static SDL3 output of
|
||||||
output of scripts/build-sdl/build-all.py on Windows
|
scripts/build-sdl/build-all.py on Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
Module PATHS stay short (`:ui`, `:foundation`, `:window`, …) —
|
Module PATHS stay short (`:ui`, `:foundation`, `:desktop-native-window`, …) —
|
||||||
`settings.gradle.kts` redirects `projectDir` for each so build files across
|
`settings.gradle.kts` redirects `projectDir` for each so build files across
|
||||||
the repo stay terse. `androidx.collection` is a plain Maven dependency
|
the repo stay terse. `androidx.collection` is a plain Maven dependency
|
||||||
(`androidx.collection:collection`), not a module — same as other simple
|
(`androidx.collection:collection`), not a module — same as other simple
|
||||||
@@ -124,23 +210,27 @@ androidx KMP libs.
|
|||||||
:ui ← :animation-core ← :animation ← :foundation ← :material3 ← :demo, :apidemo
|
:ui ← :animation-core ← :animation ← :foundation ← :material3 ← :demo, :apidemo
|
||||||
:ui ← :foundation-layout ←──────────────────────┘ ↑ ↑
|
:ui ← :foundation-layout ←──────────────────────┘ ↑ ↑
|
||||||
:material-ripple ←────────────┘──────────────┘
|
:material-ripple ←────────────┘──────────────┘
|
||||||
:foundation, :animation-core ← :window ; :foundation, :material3 ← :material-symbols
|
:foundation, :animation-core ← :desktop-native-window ; :foundation, :material3 ← :material-symbols
|
||||||
```
|
```
|
||||||
|
|
||||||
All edges are `api`, so a consumer of `:foundation` / `:material3` transitively
|
All edges are `api`, so a consumer of `:foundation` / `:material3` transitively
|
||||||
sees the split modules. Full DAG: `:ui-util → collection`; `:ui-geometry → :ui-util`;
|
sees the split modules. Full DAG: `:ui-util → collection`; `:ui-geometry → :ui-util`;
|
||||||
`:ui-unit → :ui-geometry, :ui-util`; `:ui-backhandler → :ui-util, navigationevent`;
|
`:ui-unit → :ui-geometry, :ui-util`; `:ui-backhandler → :ui-util, navigationevent`;
|
||||||
`:ui → :ui-util, :ui-geometry, :ui-unit, :ui-backhandler`; `:animation-core → :ui`;
|
`:sdl-core → sdl3 cinterop (NAKED — no Compose)`;
|
||||||
|
`:ui-graphics → :ui-geometry, :ui-unit, :ui-util, skiko`;
|
||||||
|
`:ui-text → :ui-graphics, :ui-unit, :ui-util, skiko`;
|
||||||
|
`:ui → :ui-graphics, :ui-text, :sdl-core, :ui-util, :ui-geometry, :ui-unit, :ui-backhandler`; `:animation-core → :ui`;
|
||||||
`:foundation-layout → :ui`; `:animation → :animation-core, :foundation-layout`;
|
`:foundation-layout → :ui`; `:animation → :animation-core, :foundation-layout`;
|
||||||
`:foundation → :animation, :foundation-layout, :animation-core, :ui`;
|
`:foundation → :animation, :foundation-layout, :animation-core, :ui`;
|
||||||
`:material-ripple → :foundation, :animation-core`;
|
`:material-ripple → :foundation, :animation-core`;
|
||||||
`:material3 → :foundation, :material-ripple, :animation-core, :foundation-layout`.
|
`:material3 → :foundation, :material-ripple, :animation-core, :foundation-layout`.
|
||||||
|
|
||||||
`:ui` is the renderer/cinterop hub (the ui-graphics + ui-text `expect`s resolve to
|
`:ui` is the Compose core + the Skia RenderBackend + the SDL↔Compose bridges; it
|
||||||
its SDL/Skia renderer `actual`s). The pure lower artifacts (`:ui-util`,
|
sits on `:ui-graphics` / `:ui-text` (the graphics/text primitives + their skiko
|
||||||
`:ui-geometry`, `:ui-unit`, `:ui-backhandler`) are split out below it. Everything
|
`actual`s, SDL-free) and the naked `:sdl-core` (sdl3 cinterop). The pure lower
|
||||||
above `:ui` can only touch renderer / cinterop internals via its public surface.
|
artifacts (`:ui-util`, `:ui-geometry`, `:ui-unit`, `:ui-backhandler`) are below.
|
||||||
`:window` depends on `:ui` + `:foundation` (needs `LazyList`-style scaffolding to
|
Everything above `:ui` touches renderer internals only via its public surface.
|
||||||
|
`:desktop-native-window` depends on `:ui` + `:foundation` (needs `LazyList`-style scaffolding to
|
||||||
install the popup / scaffold layer at the composition root).
|
install the popup / scaffold layer at the composition root).
|
||||||
|
|
||||||
## Vendoring philosophy — read this before writing any `androidx.compose.*` code
|
## Vendoring philosophy — read this before writing any `androidx.compose.*` code
|
||||||
@@ -157,7 +247,7 @@ compose-multiplatform umbrella repo that `:components-resources` vendors from).
|
|||||||
There is no implicit default — `SET_REPO` is required. `scripts/compose-fork/sync.sh`
|
There is no implicit default — `SET_REPO` is required. `scripts/compose-fork/sync.sh`
|
||||||
walks all manifests and copies each selected file byte-for-byte from the pinned
|
walks all manifests and copies each selected file byte-for-byte from the pinned
|
||||||
checkout (each distinct repo sparse-cloned to `../cmp-ref[-<name>]`) into
|
checkout (each distinct repo sparse-cloned to `../cmp-ref[-<name>]`) into
|
||||||
`<module>/src/vendor/{common,native,skikoRenderer,sdlRenderer}/kotlin/`. The
|
`<module>/src/vendor/{common,native,skikoRenderer}/kotlin/`. The
|
||||||
`src/vendor/` tree is **gitignored** — you don't check it in, you re-sync
|
`src/vendor/` tree is **gitignored** — you don't check it in, you re-sync
|
||||||
on demand.
|
on demand.
|
||||||
|
|
||||||
@@ -182,8 +272,8 @@ Two categories of code live in each module:
|
|||||||
Never hand-edit these. If upstream diverges, adjust the manifest or the
|
Never hand-edit these. If upstream diverges, adjust the manifest or the
|
||||||
pinned ref and re-sync. This is the bulk of the codebase (~1500 files
|
pinned ref and re-sync. This is the bulk of the codebase (~1500 files
|
||||||
across the modules).
|
across the modules).
|
||||||
2. **Project code (in `src/commonMain/`, `src/nativeMain/`, `src/skikoRendererMain/`,
|
2. **Project code (in `src/commonMain/`, `src/nativeMain/`,
|
||||||
`src/sdlRendererMain/`)** — code we author (project actuals, glue between
|
`src/skikoRendererMain/`)** — code we author (project actuals, glue between
|
||||||
Compose and SDL3, project-specific extensions).
|
Compose and SDL3, project-specific extensions).
|
||||||
|
|
||||||
### The 5 rules for adding upstream Compose surface
|
### The 5 rules for adding upstream Compose surface
|
||||||
@@ -205,19 +295,31 @@ Two categories of code live in each module:
|
|||||||
NON-IDEMPOTENT.** Move it OUT of `src/vendor/` into the corresponding
|
NON-IDEMPOTENT.** Move it OUT of `src/vendor/` into the corresponding
|
||||||
`src/{commonMain,nativeMain,…}` tree, add a header comment noting
|
`src/{commonMain,nativeMain,…}` tree, add a header comment noting
|
||||||
which upstream file it derived from and what changed, and comment
|
which upstream file it derived from and what changed, and comment
|
||||||
its line out of `compose-fork.txt`. Now it's a project file — the
|
its line out of `compose-fork.txt`. **ALWAYS also add the
|
||||||
next sync won't overwrite it, and future upstream changes to that
|
machine-readable provenance line** so the copy can be diffed against
|
||||||
file need to be reconciled by hand. This is fine; do it when the
|
its base and against later upstream versions:
|
||||||
edit is small and the file is unlikely to churn upstream.
|
|
||||||
|
|
||||||
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`, with
|
```
|
||||||
an SDL3 equivalent in `:ui/src/sdlRendererMain/`.** When upstream
|
// VENDOR-BASE: <upstream-repo-relative-path> @ <pinned-tag>
|
||||||
ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …,
|
// VENDOR-BASE(COMPOSE_REF): <path> @ <tag> ← for umbrella-repo files
|
||||||
the `.skiko.kt` variant is fine to vendor into `skikoRendererMain`.
|
```
|
||||||
Then hand-roll (or minimally vendor) the SDL3 counterpart in
|
|
||||||
`sdlRendererMain` — `SkiaCanvas.kt` ↔ `Sdl3Canvas.kt`,
|
`scripts/compose-fork/check-vendor-drift.py` reads these at every ref
|
||||||
`SkiaTextRenderer.kt` ↔ `Sdl3TextRenderer.kt` (using SDL3_ttf +
|
bump: it flags any file whose recorded base lags the current pin and
|
||||||
FreeType), `SkiaImageCache.kt` ↔ `Sdl3ImageCache.kt` (using SDL3_image).
|
(with the local clone) reports whether the upstream base ACTUALLY
|
||||||
|
changed base..pin — i.e. whether the copy needs hand-reconciling or
|
||||||
|
just a ref re-stamp. Now it's a project file — the next sync won't
|
||||||
|
overwrite it, and future upstream changes to that file need to be
|
||||||
|
reconciled by hand. This is fine; do it when the edit is small and
|
||||||
|
the file is unlikely to churn upstream.
|
||||||
|
|
||||||
|
4. **Skiko-specific things go in a `skikoRendererMain` source set.** When
|
||||||
|
upstream ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …,
|
||||||
|
the `.skiko.kt` variant is fine to vendor into `skikoRendererMain` — graphics
|
||||||
|
into `:ui-graphics` (`SkiaBackedCanvas.skiko.kt`, `SkiaImageCache.kt`, …),
|
||||||
|
text into `:ui-text` (`SkiaParagraphEngine.kt`), the render backend into
|
||||||
|
`:ui` (`SkiaRenderBackend.kt`). All native targets attach this source set;
|
||||||
|
mingwX64 layers its fork actuals on top under `skikoRendererMingwMain`.
|
||||||
|
|
||||||
5. **Multi-OS project code goes through SDL3, not hand-rolled per-target
|
5. **Multi-OS project code goes through SDL3, not hand-rolled per-target
|
||||||
ifdefs.** SDL3 already handles the platform differences for filesystem
|
ifdefs.** SDL3 already handles the platform differences for filesystem
|
||||||
@@ -235,48 +337,41 @@ call sites don't care.
|
|||||||
|
|
||||||
## Source-set hierarchy (:ui only)
|
## Source-set hierarchy (:ui only)
|
||||||
|
|
||||||
`:ui` owns cinterops + both renderer pipelines. Its source-set tree:
|
`:ui` owns the Skia RenderBackend + GPU bridges. The SAME source-set tree is used
|
||||||
|
by `:ui-graphics` / `:ui-text` for their skiko `actual`s (only the deps differ);
|
||||||
|
the `sdl3` cinterop lives in `:sdl-core` (see below). `:ui`'s tree:
|
||||||
|
|
||||||
```
|
```
|
||||||
commonMain
|
commonMain
|
||||||
└── nativeMain (vendored .native.kt + project native code)
|
└── nativeMain (vendored .native.kt + project native code)
|
||||||
├── skikoRendererMain (Skia drawing pipeline; Skiko on classpath)
|
├── skikoRendererMain (Skia drawing pipeline; official Skiko on classpath)
|
||||||
│ ├── skikoRendererMacosMain (macOS-only Skia actuals — Metal bridge)
|
│ ├── skikoRendererMacosMain (macOS-only Skia actuals — Metal bridge)
|
||||||
│ └── skikoRendererLinuxMain (Linux-only Skia actuals — OpenGL)
|
│ └── skikoRendererLinuxMain (Linux-only Skia actuals — OpenGL)
|
||||||
│ attached to: macosArm64Main / linuxX64Main / linuxArm64Main
|
│ attached to: macosArm64Main / linuxX64Main / linuxArm64Main.
|
||||||
│ ONLY when the Skia renderer is active for the target.
|
└── skikoRendererMingwSharedMain
|
||||||
└── sdlRendererMain (SDL3 drawing pipeline + TTF/image/FreeType)
|
└── skikoRendererMingwMain (mingwX64-only Skia actuals — the bitsycore
|
||||||
├── sdlRendererMacosMain (macOS-only SDL3 driver hint)
|
skiko FORK: skiko-windows-x64.dll bound via
|
||||||
├── sdlRendererLinuxMain (Linux-only SDL3 driver hint)
|
an embedded GNU import lib; OpenGL context)
|
||||||
└── sdlRendererMingwMain (mingwX64-only SDL3 driver hint)
|
attached to: mingwX64Main.
|
||||||
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
|
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
|
||||||
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
|
`:ui`'s nativeMain with `actual`s in `skikoRendererMain` (shared by every native
|
||||||
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
|
target). `rendererPreferredGpuMode()` picks Metal on macOS, OpenGL on
|
||||||
resolves because **only one of the two renderer source sets is attached to a
|
Linux + Windows, with a Software (CPU raster) auto-fallback if the GPU context
|
||||||
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
fails to come up.
|
||||||
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
|
||||||
pulled in.
|
|
||||||
|
|
||||||
### Cinterop sibling-dependency gotcha
|
### Cinterop
|
||||||
|
|
||||||
`:ui` owns four cinterops in `src/nativeInterop/cinterop/`:
|
The `sdl3` cinterop lives in the NAKED `:sdl-core` module
|
||||||
`sdl3`, `sdl3_ttf`, `sdl3_image`, `freetype`. The `.def` files for `sdl3_ttf` /
|
(`sdl/sdl-core/src/nativeInterop/cinterop/sdl3.def`: SDL_Window / SDL_Event /
|
||||||
`sdl3_image` carry `depends = sdl3` so their `SDL_Surface` / `SDL_Color`
|
SDL_GetBasePath / clipboard / dialogs / GL+Metal context / SDL_Renderer-for-CPU-
|
||||||
references resolve to the *same* types `sdl3` produces (not duplicates inside
|
raster). `:ui` depends on `:sdl-core` (`api`), so `:ui`'s renderer + SDL↔Compose
|
||||||
`sdl3_image.SDL_Surface`). **Gradle does not automatically add a sibling
|
bridges — and `:desktop-native-window` downstream — see `sdl3.*` and inherit SDL3's static-lib +
|
||||||
cinterop's klib to a cinterop task's `-library` list**, so the manifest
|
linker-opt propagation (the `.def` bakes in `staticLibraries = libSDL3.a` + the
|
||||||
directive silently fails and you get cryptic
|
per-OS `linkerOpts`, carried through the klib chain to the final exe, so apps
|
||||||
`expected CPointer<sdl3.SDL_Surface>?, actual CPointer<sdl3_image.SDL_Surface>?`
|
link SDL just by depending on `:ui`/`:desktop-native-window`). `:sdl-core` has NO Compose
|
||||||
errors.
|
dependency, so `:ui → :sdl-core` is cycle-free — the compose way (like `ui → skiko`).
|
||||||
|
|
||||||
`:ui/build.gradle.kts` works around this by passing the sdl3 cinterop output
|
|
||||||
klib path explicitly via `extraOpts("-library", vSdl3Klib)` on `sdl3_ttf` /
|
|
||||||
`sdl3_image`, plus a task dependency
|
|
||||||
(`cinteropSdl3_ttf*Target.dependsOn(cinteropSdl3*Target)`). If you ever add
|
|
||||||
another `depends = sdl3` cinterop, add it to that list too.
|
|
||||||
|
|
||||||
## Density flow (Option B — layout in physical pixels)
|
## Density flow (Option B — layout in physical pixels)
|
||||||
|
|
||||||
@@ -310,35 +405,31 @@ Passing raw `px.dp` will double-scale on Retina.
|
|||||||
./gradlew :demo:runDebugExecutableLinuxX64
|
./gradlew :demo:runDebugExecutableLinuxX64
|
||||||
./gradlew :apidemo:runDebugExecutableLinuxX64
|
./gradlew :apidemo:runDebugExecutableLinuxX64
|
||||||
|
|
||||||
# Windows (from Windows — mingw cross-build from macOS/Linux fails at cinterop)
|
# Windows (from Windows — mingw cross-build from macOS/Linux fails at cinterop).
|
||||||
|
# Links the bitsycore skiko FORK from GitHub Packages (com.bitsycore.skiko:skiko:
|
||||||
|
# 0.150.1-mingw.1, override -PskikoMingwVersion); the bridge plugin drops
|
||||||
|
# skiko-windows-x64.dll next to the exe (installWindowsSkiaDll).
|
||||||
gradlew.bat :demo:runDebugExecutableMingwX64
|
gradlew.bat :demo:runDebugExecutableMingwX64
|
||||||
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||||
|
|
||||||
# Skiko-free build on macOS/Linux — SDL3 renderer everywhere
|
|
||||||
./gradlew :demo:runDebugExecutableMacosArm64 -Prenderer=sdl3
|
|
||||||
|
|
||||||
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
|
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
|
||||||
# demo screens on upstream Compose; differences vs the native build = port bugs.
|
# screens on upstream Compose; differences vs the native build = port bugs.
|
||||||
./gradlew :demo:run
|
./gradlew :demo:run
|
||||||
|
./gradlew :apidemo:run
|
||||||
```
|
```
|
||||||
|
|
||||||
### System dependencies
|
### System dependencies
|
||||||
|
|
||||||
SDL3 + SDL3_ttf + SDL3_image + FreeType (+ image codecs) are **built from
|
SDL3 is **built from source as a static library on every OS** and linked
|
||||||
source as static libraries on every OS** and linked straight into the
|
straight into the executable — no brew/apt SDL packages. Build it once per host
|
||||||
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib;
|
with `python scripts/build-sdl/build-all.py`. Per-host toolchain requirements
|
||||||
a distributable is `<app>` + `data.kres`. One script does it all:
|
and the step breakdown are in [TOOLING.md](TOOLING.md#native-libraries).
|
||||||
`python scripts/build-sdl/build-all.py` (plain Python 3, no Git Bash needed)
|
|
||||||
builds into the gitignored, in-repo `libs/` folder. Versions/URLs pinned in
|
|
||||||
`scripts/build-sdl/build-sdl.properties`. Steps run in order freetype → sdl3
|
|
||||||
→ sdl3-image → sdl3-ttf; pass step names to rebuild a subset
|
|
||||||
(`python scripts/build-sdl/build-all.py sdl3-ttf`).
|
|
||||||
|
|
||||||
Needs everywhere: git, cmake, python 3 (ninja fetched automatically). Per
|
Skia comes in through the Skiko klibs: the official Maven ones on macOS/Linux
|
||||||
host: macOS = Xcode CLT (Skia default; Skiko klibs from Maven); Linux =
|
(no runtime .so/.dylib — Skia is statically inside the klib), and the bitsycore
|
||||||
gcc/g++ + the X11/Wayland/audio dev headers SDL3's configure detects (apt
|
fork on Windows, whose `skiko-windows-x64.dll` the bridge plugin provisions next
|
||||||
list in .github/workflows/publish.yml); Windows = mingw-w64 g++ on PATH
|
to the exe. So a macOS/Linux distributable is `<app>` + `data.kres`; a Windows
|
||||||
(C compiles with K/N's bundled mingw).
|
one is `<app>.exe` + `data.kres` + `skiko-windows-x64.dll`.
|
||||||
|
|
||||||
## Runtime bundling — data.kres
|
## Runtime bundling — data.kres
|
||||||
|
|
||||||
@@ -346,50 +437,51 @@ Every app ships `<app>.exe` + `data.kres` (a STORED zip alongside the
|
|||||||
executable, loaded via `SDL_GetBasePath()`). Contents:
|
executable, loaded via `SDL_GetBasePath()`). Contents:
|
||||||
|
|
||||||
- App drawables + files under `composeResources/{drawable,files}/`
|
- App drawables + files under `composeResources/{drawable,files}/`
|
||||||
- `font/NotoSans.ttf` — the default variable font, downloaded once by
|
- `font/NotoSans.ttf` — the default variable font. Bundling it is the APP's
|
||||||
`:ui:downloadNotoFonts` into `compose/ui/build/fonts/`. Pass
|
job: each app opts in via `registerComposeFontBundling { bundleNotoSans =
|
||||||
`-PbundleDefaultFont=false` to skip.
|
true; … }` (buildSrc — every flag is opt-in, no flag = no-op), which
|
||||||
- `font/NotoSansMono.ttf` — mono body font (apidemo only)
|
registers its `downloadNotoFonts` task (into `<app>/build/fonts/`); the
|
||||||
- Material Symbols fonts for the styles the app **actually uses** — the
|
library ships no download task. Pass `-PbundleDefaultFont=false` to skip.
|
||||||
Zip task scans the app's Kotlin sources for `MaterialSymbolsOutlined` /
|
- `font/NotoSansMono.ttf` — `autoDetectNotoSansMono = true` bundles it when
|
||||||
`Rounded` / `Sharp` and only bundles the fonts referenced.
|
`FontFamily.Monospace` appears in the app sources (demo);
|
||||||
- `-PsubsetIcons=true` (default on): `scripts/subset-material-symbols.py`
|
`bundleNotoSansMono = true` forces it (apidemo's body font, loaded through
|
||||||
scans app sources for `MaterialSymbols.<Name>` usage and hb-subsets each
|
its own seam)
|
||||||
bundled font down to just those glyphs. Needs `hb-subset` on PATH
|
- Material Symbols fonts for the styles the app **actually uses**
|
||||||
(`brew install harfbuzz` / `apt install harfbuzz-utils`) — falls back
|
(`bundleMaterialSymbols = true`) — the Zip task scans the app's Kotlin
|
||||||
to the full font if absent.
|
sources for `MaterialSymbolsOutlined` / `Rounded` / `Sharp` and only
|
||||||
|
bundles the fonts referenced.
|
||||||
|
- `-PsubsetIcons=true` (default on, opted into per app via
|
||||||
|
`enableIconSubsetting = true` — apidemo yes, demo no):
|
||||||
|
`scripts/subset-material-symbols.py` scans app sources for
|
||||||
|
`MaterialSymbols.<Name>` usage and hb-subsets each bundled font down to
|
||||||
|
just those glyphs. Needs `hb-subset` on PATH (`brew install harfbuzz` /
|
||||||
|
`apt install harfbuzz-utils`) — falls back to the full font if absent.
|
||||||
|
|
||||||
## Vendor sync workflow
|
## Vendor sync workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sync every module's compose-fork.txt against the pinned upstream ref.
|
# Sync every module's compose-fork.txt against the pinned upstream ref.
|
||||||
scripts/compose-fork/sync.sh
|
scripts/compose-fork/sync.sh
|
||||||
|
|
||||||
# Sync one module by direct manifest path (module names use ':' → '/' → skip
|
|
||||||
# 'compose/' prefix — nested modules like compose/sdl/window pass the full path).
|
|
||||||
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt
|
|
||||||
|
|
||||||
# Re-format a manifest (align columns, group by upstream folder).
|
|
||||||
scripts/compose-fork/format-manifest.py --discover ../cmp-ref \
|
|
||||||
--manifest compose/ui/compose-fork.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Upstream ref: `scripts/compose-fork/compose.properties` — set to a specific commit
|
Upstream ref: `scripts/compose-fork/compose.properties` — set to a durable tag
|
||||||
of `JetBrains/compose-multiplatform-core`. Bump the ref → re-sync → let the
|
of `JetBrains/compose-multiplatform-core`. Bump the ref → re-sync → let the
|
||||||
build tell you what broke.
|
build tell you what broke. Per-module sync, manifest re-formatting, and the
|
||||||
|
drift / vendor-clean guardrails are in
|
||||||
|
[TOOLING.md](TOOLING.md#vendoring-upstream-compose).
|
||||||
|
|
||||||
## Key files by area — start here when you need to find something
|
## Key files by area — start here when you need to find something
|
||||||
|
|
||||||
### Renderer + main loop
|
### Renderer + main loop
|
||||||
- `compose/sdl/window/src/nativeMain/…/ComposeWindow.kt` — main loop,
|
- `compose/desktop/native/window/src/nativeMain/…/ComposeWindow.kt` — main loop,
|
||||||
recomposer lifecycle, SDL event dispatch, composition-local seeding.
|
recomposer lifecycle, SDL event dispatch, composition-local seeding.
|
||||||
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` — the interface.
|
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` — the interface.
|
||||||
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` — sealed renderer / driver picker.
|
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` — sealed driver picker
|
||||||
- `compose/ui/ui/src/skikoRendererMain/…/renderer/skia/SkiaRenderBackend.kt`.
|
(`Auto` / `Software` / `Skia.OpenGL` / `Skia.Metal`).
|
||||||
- `compose/ui/ui/src/sdlRendererMain/…/renderer/sdl/Sdl3RenderBackend.kt`.
|
- `compose/ui/ui/src/skikoRendererMain/…/renderer/skia/SkiaRenderBackend.kt` —
|
||||||
- `compose/ui/ui/src/sdlRendererMain/…/renderer/sdl/FreeTypeIcons.kt` —
|
the Skia render backend (shared by every native target).
|
||||||
variable-font axis rasterisation (SDL3_ttf has no axis-set API; we go
|
- `compose/ui/ui/src/skikoRendererMingwMain/…` — mingwX64-only Skia actuals
|
||||||
to FreeType directly for icon families).
|
bound against the skiko fork's flat extern-C surface.
|
||||||
|
|
||||||
### Layout / composition wiring
|
### Layout / composition wiring
|
||||||
- `compose/ui/ui/src/commonMain/…/node/ComposeRootHost.kt` — root LayoutNode
|
- `compose/ui/ui/src/commonMain/…/node/ComposeRootHost.kt` — root LayoutNode
|
||||||
@@ -399,15 +491,15 @@ build tell you what broke.
|
|||||||
alpha bridge).
|
alpha bridge).
|
||||||
- `compose/ui/ui/src/commonMain/…/node/NodeApplier.kt`.
|
- `compose/ui/ui/src/commonMain/…/node/NodeApplier.kt`.
|
||||||
|
|
||||||
### Text
|
### Text (all in `:ui-text` now)
|
||||||
- `compose/ui/ui/src/nativeMain/…/ui/text/SdlParagraph.native.kt` — the
|
- `compose/ui/ui-text/src/nativeMain/…/ui/text/SkiaParagraph.native.kt` — the
|
||||||
bridged `Paragraph` implementation (measurement, hit-test, line metrics,
|
`Paragraph` actual (skiko-free; the 30 interface methods over plain data),
|
||||||
span painting).
|
driving the skiko engine through the `NativeParagraphOps` seam.
|
||||||
- `compose/ui/ui/src/nativeMain/…/ui/text/ParagraphFactories.native.kt` —
|
- `compose/ui/ui-text/src/skikoRendererMain/…/renderer/skia/SkiaParagraphEngine.kt`
|
||||||
|
— `SkiaParagraphOps` (builds/queries the real skiko `skparagraph`) + `SkiaFonts`
|
||||||
|
(data.kres bytes → skiko Typeface; the port's family/variable-axis model).
|
||||||
|
- `compose/ui/ui-text/src/nativeMain/…/ui/text/ParagraphFactories.native.kt` —
|
||||||
actuals for the `Paragraph(…)` / `ParagraphIntrinsics(…)` factory family.
|
actuals for the `Paragraph(…)` / `ParagraphIntrinsics(…)` factory family.
|
||||||
- `compose/ui/ui/src/commonMain/…/text/TextMeasurer.kt` — `NativeTextMeasurer`
|
|
||||||
interface (per-renderer implementations: `SkiaTextRenderer` /
|
|
||||||
`Sdl3TextRenderer`).
|
|
||||||
|
|
||||||
### Icons
|
### Icons
|
||||||
- `compose/foundation/foundation/src/nativeMain/…/icons/IconFontIcon.kt` —
|
- `compose/foundation/foundation/src/nativeMain/…/icons/IconFontIcon.kt` —
|
||||||
@@ -430,6 +522,21 @@ build tell you what broke.
|
|||||||
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
||||||
ship drop-in equivalents for our anchor / scrim patterns).
|
ship drop-in equivalents for our anchor / scrim patterns).
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
The full tooling reference is [TOOLING.md](TOOLING.md): the static-lib build,
|
||||||
|
vendor sync + drift/clean guardrails, API coverage, the `verify-mac` runbook,
|
||||||
|
the native-vs-JVM parity harness, the interaction probe, and the frame
|
||||||
|
profiler. Quick rules of thumb:
|
||||||
|
|
||||||
|
- Whole-project renderer/layout change → run **parity** (`scripts/parity/parity.py`),
|
||||||
|
the broad net.
|
||||||
|
- Chasing one reported interaction → run the **probe** (`scripts/probe/`), targeted.
|
||||||
|
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
|
||||||
|
- Any renderer change → the **`verify-mac`** runbook gates it before commit.
|
||||||
|
|
||||||
|
See [RENDERER.md](RENDERER.md) for the renderer work these support.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
Kotlin standard style — plain `camelCase` for parameters, local variables,
|
Kotlin standard style — plain `camelCase` for parameters, local variables,
|
||||||
@@ -475,23 +582,23 @@ that should surface in tooling.
|
|||||||
`Modifier.alpha(x)` desugars to `graphicsLayer(alpha = x, clip = true)`.
|
`Modifier.alpha(x)` desugars to `graphicsLayer(alpha = x, clip = true)`.
|
||||||
For a drag ghost that also translates, put `alpha` and `translationX` on
|
For a drag ghost that also translates, put `alpha` and `translationX` on
|
||||||
the SAME `graphicsLayer(...)` so clip stays false.
|
the SAME `graphicsLayer(...)` so clip stays false.
|
||||||
- **Cinterop `depends = sdl3`** — silently doesn't propagate; see the
|
- **Substituted Maven modules hide their transitives from common metadata** —
|
||||||
gotcha section above.
|
when the bridge swaps an official coord for a project module on native
|
||||||
- **Configuration cache + `-Prenderer=`** — Gradle caches configuration;
|
configs, KGP's granular-metadata visibility check drops that Maven module's
|
||||||
toggling the renderer property may not invalidate it. Delete
|
TRANSITIVES from the commonMain classpath (symptom: `Unresolved reference
|
||||||
`.gradle/configuration-cache/` between switches if you see weird
|
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
|
||||||
"couldn't find sdl3_ttf" errors.
|
while per-target compilation is fine). Declare EVERY artifact the common
|
||||||
- **`Path()` in commonMain returns different actuals per renderer** —
|
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
|
||||||
the Skia renderer produces a `SkiaBackedPath` (wraps
|
own bridge rule. Note only the WINDOWS publish job compiles common metadata
|
||||||
`org.jetbrains.skia.Path`), the SDL renderer produces a project
|
(it owns the root KotlinMultiplatform publications — the only host that
|
||||||
`ProjectPath` (command-list based). `SkiaCanvas.toSkiaPath` handles
|
declares every target, so only its .module files carry the full variant
|
||||||
both — never assume the type without checking.
|
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
|
||||||
|
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
||||||
|
|
||||||
## Useful Gradle tricks
|
## Useful Gradle tricks
|
||||||
|
|
||||||
- `--args="--gpu=sdl3.opengl --screen=Buttons"` — pass CLI to the demo.
|
- `--args="--gpu=skia.opengl --screen=Buttons"` — pass CLI to the demo.
|
||||||
- `--info` — see cinterop classpath + include paths actually used.
|
- `--info` — see cinterop classpath + include paths actually used.
|
||||||
- `--rerun-tasks` — force rebuild after toggling `-Prenderer=`.
|
|
||||||
- After a module rename or IC-cache mismatch: nuke
|
- After a module rename or IC-cache mismatch: nuke
|
||||||
`demo/build/kotlin-native-ic-cache` (or `apidemo/build/…`). Kotlin/Native
|
`demo/build/kotlin-native-ic-cache` (or `apidemo/build/…`). Kotlin/Native
|
||||||
pins module IDs into its klib metadata; a stale cache surfaces as
|
pins module IDs into its klib metadata; a stale cache surfaces as
|
||||||
|
|||||||
@@ -1,227 +1,178 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
|
[](LICENSE.md)
|
||||||
|
|
||||||
# Compose Desktop Native
|
# Compose Desktop Native
|
||||||
|
|
||||||
A Kotlin/Native port of **Compose Multiplatform** running on **SDL3** for
|
Run [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform)
|
||||||
windowing and input — **no JVM**. Compiles to native binaries for macOS
|
as a native binary, with no JVM. Compose Desktop Native compiles to a single
|
||||||
(arm64), Linux (x64/arm64), Windows (mingwX64).
|
executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
|
||||||
|
[SDL3](https://libsdl.org) for windowing and input.
|
||||||
|
|
||||||
Rendering is pluggable behind one `RenderBackend`:
|
<img src="screenshots/demo.png" width="100%" alt="Compose Desktop Native demo" />
|
||||||
|
|
||||||
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
|
## How it works
|
||||||
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
|
|
||||||
when `-Prenderer=sdl3` is passed.
|
|
||||||
|
|
||||||
The Compose **runtime** is the official
|
- **The runtime is the real one.** Composition, snapshots, and the recomposer
|
||||||
`org.jetbrains.compose.runtime:runtime` klibs from Maven — this project
|
are the official `org.jetbrains.compose.runtime` klibs from Maven, never
|
||||||
re-implements the layers on top of it (`androidx.compose.ui.*`,
|
reimplemented.
|
||||||
`.foundation.*`, `.animation.*`, `.material3.*`) by **vendoring
|
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
|
||||||
upstream Compose Multiplatform verbatim** whenever possible and hand-rolling
|
`animation`, and `material3` are copied from Compose Multiplatform verbatim
|
||||||
project actuals + SDL3 / Skia glue only where needed.
|
wherever they compile as is, with project code filling in only the native glue.
|
||||||
|
- **Rendering is Skia everywhere.** macOS and Linux link the official Skiko
|
||||||
|
klibs (Metal / OpenGL); Windows links the bitsycore Skiko fork, which ships
|
||||||
|
Skiko and Skia together in `skiko-windows-x64.dll`.
|
||||||
|
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
|
||||||
|
and clipboard all go through SDL3, so one code path covers every OS.
|
||||||
|
- **Lean distributables.** SDL3 is built as a static library and linked in. On
|
||||||
|
macOS and Linux a distributable is just the executable plus a `data.kres`
|
||||||
|
resource bundle; on Windows it also ships `skiko-windows-x64.dll` next to the
|
||||||
|
exe (auto-provisioned by the bridge plugin).
|
||||||
|
|
||||||
## Module layout
|
| Platform | Gradle target | Renderer |
|
||||||
|
|----------|---------------|----------|
|
||||||
|
| macOS arm64 | `macosArm64` | Skia (Metal) — official Skiko |
|
||||||
|
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) — official Skiko |
|
||||||
|
| Windows | `mingwX64` | Skia — bitsycore Skiko fork |
|
||||||
|
|
||||||
One Gradle module per upstream Compose artifact — the directory tree mirrors
|
## Quickstart
|
||||||
upstream's `compose/` layout, gradle paths stay short via `projectDir`
|
|
||||||
redirects.
|
|
||||||
|
|
||||||
```
|
### A window
|
||||||
compose/
|
|
||||||
├── ui/
|
|
||||||
│ ├── ui/ → :ui (androidx.compose.ui.* + renderers + cinterops)
|
|
||||||
│ ├── ui-util/ → :ui-util
|
|
||||||
│ ├── ui-geometry/ → :ui-geometry
|
|
||||||
│ ├── ui-unit/ → :ui-unit
|
|
||||||
│ └── ui-backhandler/ → :ui-backhandler
|
|
||||||
├── animation/
|
|
||||||
│ ├── animation-core/ → :animation-core
|
|
||||||
│ ├── animation/ → :animation
|
|
||||||
│ └── animation-graphics/ → :animation-graphics
|
|
||||||
├── foundation/
|
|
||||||
│ ├── foundation/ → :foundation
|
|
||||||
│ └── foundation-layout/ → :foundation-layout
|
|
||||||
├── material3/material3/ → :material3
|
|
||||||
├── material/material-ripple/ → :material-ripple
|
|
||||||
└── sdl/ (project modules — the SDL integration layer)
|
|
||||||
└── window/ → :window (nativeComposeApp { Window(...) } + SDL3 main loop,
|
|
||||||
per-window Lifecycle/ViewModelStore/SavedState owners)
|
|
||||||
|
|
||||||
utils/
|
|
||||||
└── material-symbols/ → :material-symbols (Material Symbols: 4200+ codepoints + Outlined/Rounded/
|
|
||||||
Sharp composables with the variable-font axes as
|
|
||||||
parameters (FILL/wght/GRAD/opsz). Common API — native
|
|
||||||
draws via the port's icon-font engine, the jvm target
|
|
||||||
via Skiko, so shared app code uses it on both stacks)
|
|
||||||
|
|
||||||
components/
|
|
||||||
└── resources/library/ → :components-resources (vendored official compose resources runtime —
|
|
||||||
painterResource/stringResource/Res codegen; the Maven
|
|
||||||
artifact ships no mingwX64/linux klibs)
|
|
||||||
|
|
||||||
navigation3/
|
|
||||||
└── navigation3-ui/ → :navigation3-ui (vendored NavDisplay + scene machinery — the one
|
|
||||||
Navigation 3 layer without a K/N desktop artifact)
|
|
||||||
|
|
||||||
demo/ → :demo (showcase + CLI probe suite; multiplatform — the same screens
|
|
||||||
also build for stock JVM Compose Desktop as a parity reference)
|
|
||||||
apidemo/ → :apidemo (Postman-style REST client)
|
|
||||||
```
|
|
||||||
|
|
||||||
## demo — widget & feature showcase
|
|
||||||
|
|
||||||
<img src="screenshots/demo.png" width="100%" alt="ComposeNativeSDL3 demo" />
|
|
||||||
|
|
||||||
`:demo` is a full tour of the re-implemented Compose + Material 3 surface —
|
|
||||||
30+ sidebar screens covering buttons, text fields, layout, modifiers, shapes,
|
|
||||||
images, state & recomposition, scrolling & lazy lists, dialogs, icons, canvas,
|
|
||||||
graphics layers, custom layout, animation and gestures.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
|
||||||
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
|
||||||
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
|
|
||||||
./gradlew :demo:run # stock JVM Compose Desktop (parity reference)
|
|
||||||
```
|
|
||||||
|
|
||||||
The same shared screens drive both stacks — the JVM target runs them on
|
|
||||||
upstream Compose Desktop unchanged, so any visual or behavioural difference
|
|
||||||
against the native build is a porting bug, not a demo artifact.
|
|
||||||
|
|
||||||
CLI: `--gpu=…`, `--screen=<Name>` (one screen, no sidebar),
|
|
||||||
`--screenshot=out.bmp --frames=N`, `--width=W --height=H`.
|
|
||||||
|
|
||||||
## apidemo — API Manager
|
|
||||||
|
|
||||||
<img src="screenshots/apidemo.png" width="100%" alt="ComposeNativeSDL3 API Manager" />
|
|
||||||
|
|
||||||
`:apidemo` is a Postman-style REST client built entirely on the library:
|
|
||||||
request collections (**packs**, nested sub-packs, linked copies), a
|
|
||||||
**Session → Pack → Request** inheritance ladder for variables / headers /
|
|
||||||
query params / client certs, syntax-highlighted JSON / XML / YAML / HTML body
|
|
||||||
editors, a response viewer with timing, size and TLS-chain inspection, mTLS
|
|
||||||
client certificates, drag-and-drop tree management, request history and
|
|
||||||
file-based sessions.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew :apidemo:runDebugExecutableMacosArm64
|
|
||||||
./gradlew :apidemo:runDebugExecutableLinuxX64
|
|
||||||
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `-Prenderer=sdl3` on macOS/Linux to drop Skiko and use the pure-SDL3
|
|
||||||
renderer everywhere.
|
|
||||||
|
|
||||||
## Minimal app
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import com.compose.sdl.nativeComposeWindow
|
import com.compose.sdl.nativeComposeWindow
|
||||||
|
|
||||||
fun main() = nativeComposeWindow(title = "Hello") {
|
fun main() = nativeComposeWindow(title = "Hello") {
|
||||||
Text("Hello from ComposeNativeSDL3")
|
Text("Hello from Compose Desktop Native")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The lambda runs with a `ComposeWindowScope` receiver exposing
|
The content lambda runs with a `ComposeWindowScope` receiver that exposes
|
||||||
`window: ComposeNativeWindow` (`setTitle` / `setSize` / `minimize` /
|
`window: ComposeNativeWindow` (`setTitle`, `setSize`, `minimize`, `maximize`,
|
||||||
`maximize` / `setFullscreen` / `close` / …); the same handle is reachable
|
`setFullscreen`, `close`). The same handle is reachable from any nested
|
||||||
from any nested composable via `LocalComposeNativeWindow.current`.
|
composable via `LocalComposeNativeWindow.current`.
|
||||||
|
|
||||||
Add these to your module's `commonMain.dependencies`:
|
For multiple windows, wrap them in `nativeComposeApp`, where the set of
|
||||||
|
`Window(...)` calls is state driven (Compose Desktop style):
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
implementation(project(":window")) // window + main loop
|
fun main() = nativeComposeApp {
|
||||||
implementation(project(":material3")) // Material 3 widgets
|
Window(title = "Main", onCloseRequest = ::exitApplication) { /* ... */ }
|
||||||
implementation(project(":material-symbols")) // icon-font composables (optional)
|
if (showInspector) {
|
||||||
|
Window(title = "Inspector", onCloseRequest = { showInspector = false }) { /* ... */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each window gets its own `Lifecycle`, `ViewModelStore`, and `SavedStateRegistry`
|
||||||
|
owners, driven by real SDL focus and visibility events, so ViewModels and
|
||||||
|
saved state behave as they do on Android.
|
||||||
|
|
||||||
|
### Building in this repo
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":desktop-native-window")) // window shell + main loop
|
||||||
|
implementation(project(":material3")) // Material 3 widgets
|
||||||
|
implementation(project(":material-symbols")) // icon-font composables (optional)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building from your own project: the bridge plugin
|
||||||
|
|
||||||
|
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
|
||||||
|
the bridge Gradle plugin once, declare the **official** Compose Multiplatform
|
||||||
|
coordinates, and the plugin swaps in the port's klibs on native desktop targets
|
||||||
|
while android, jvm, iOS, and wasm keep resolving the official artifacts.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts, official coordinates everywhere
|
||||||
|
plugins {
|
||||||
|
kotlin("multiplatform")
|
||||||
|
id("org.jetbrains.compose")
|
||||||
|
id("com.bitsycore.compose-desktop-native.bridge") version "<release>"
|
||||||
|
}
|
||||||
|
|
||||||
|
commonMain.dependencies {
|
||||||
|
// The plugin exposes the exact Compose versions the port tracks, so you
|
||||||
|
// never hand-match them (material3 is versioned separately upstream).
|
||||||
|
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
|
||||||
|
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
|
||||||
|
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Repositories, credentials, and version pinning:
|
||||||
|
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||||
|
|
||||||
|
For a complete project that applies the bridge and builds one shared UI for
|
||||||
|
Android, JVM, and native desktop, see the example repo:
|
||||||
|
[bitsycore/compose-desktop-native-bridge-example](https://github.com/bitsycore/compose-desktop-native-bridge-example).
|
||||||
|
|
||||||
|
## Sample apps
|
||||||
|
|
||||||
|
Both live in `commonMain` and also build for stock JVM Compose Desktop, which
|
||||||
|
serves as the visual and behavioural reference: any difference against the
|
||||||
|
native build is a porting bug.
|
||||||
|
|
||||||
|
**`:demo`** is a tour of the re-implemented Compose and Material 3 surface,
|
||||||
|
30-plus screens covering text, layout, shapes, images, state, lazy lists,
|
||||||
|
dialogs, canvas, graphics layers, animation, and gestures.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
||||||
|
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
||||||
|
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (Skia / Skiko fork)
|
||||||
|
./gradlew :demo:run # JVM Compose Desktop (reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`:apidemo`** is a Postman-style REST client built entirely on the library:
|
||||||
|
request collections, a session inheritance ladder, syntax-highlighted body
|
||||||
|
editors, a response viewer with timing and TLS-chain inspection, and mTLS
|
||||||
|
client certificates.
|
||||||
|
|
||||||
|
<img src="screenshots/apidemo.png" width="100%" alt="Compose Desktop Native API Manager" />
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :apidemo:runDebugExecutableMacosArm64
|
||||||
|
./gradlew :apidemo:run # JVM Compose Desktop (reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
One Gradle module per upstream Compose artifact, mirroring the upstream
|
||||||
|
`compose/` tree. The renderer lives in `:ui` and the `sdl3` cinterop in
|
||||||
|
`:sdl-core`; `:desktop-native-window` is the SDL integration layer. `:material-symbols`, `:components-resources`, and
|
||||||
|
`:navigation3-ui` are project or vendored modules that the official Maven
|
||||||
|
artifacts do not cover for Kotlin/Native desktop.
|
||||||
|
|
||||||
|
Most of the androidx architecture stack (lifecycle, viewmodel, navigation3,
|
||||||
|
savedstate, navigationevent) ships real Kotlin/Native desktop klibs and runs on
|
||||||
|
the port unmodified. The full module map, dependency graph, and the list of
|
||||||
|
compatible artifacts are in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
All four native dependencies — **SDL3**, **SDL3_ttf** (the in-house
|
Build the native libraries once per machine, then build any app target:
|
||||||
variable-font-axes fork), **SDL3_image** (+ vendored PNG/WEBP codecs) and
|
|
||||||
**FreeType** — are built from source as **static** libraries by one script,
|
|
||||||
on every OS, and linked straight into the executable. No `brew install`, no
|
|
||||||
`apt install libsdl3-dev`, no runtime `.dll` / `.so` / `.dylib` next to the
|
|
||||||
binary — a distributable is just `<app>` + `data.kres`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/build-sdl/build-all.py # freetype → sdl3 → sdl3-image → sdl3-ttf
|
python3 scripts/build-sdl/build-all.py # SDL3 (static)
|
||||||
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild a subset
|
./gradlew :demo:runDebugExecutableMacosArm64
|
||||||
```
|
```
|
||||||
|
|
||||||
Library versions / URLs are pinned in `scripts/build-sdl/build-sdl.properties`;
|
Every host needs `git`, `cmake`, and Python 3. Per host: macOS needs the Xcode
|
||||||
output lands in the gitignored in-repo `libs/`. Required on every host: `git`,
|
command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
|
||||||
`cmake`, Python 3 (`ninja` is fetched automatically when absent). Per host:
|
headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for
|
||||||
|
the full build and verification workflow.
|
||||||
|
|
||||||
- **macOS** — Xcode command line tools. Skia is the default renderer; Skiko
|
## Documentation
|
||||||
klibs come from Maven.
|
|
||||||
- **Linux** — gcc/g++ plus the X11 / Wayland / audio dev headers SDL3's
|
|
||||||
configure detects (the exact apt list is in
|
|
||||||
`.github/workflows/publish.yml`).
|
|
||||||
- **Windows** — a mingw-w64 g++ on PATH (the C side compiles with
|
|
||||||
Kotlin/Native's bundled mingw).
|
|
||||||
|
|
||||||
Then build any app target — see the demo / apidemo commands above.
|
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
|
||||||
|
- [CLAUDE.md](CLAUDE.md): architecture, source-set hierarchy, vendoring rules,
|
||||||
## Vendoring
|
density flow, and a per-area file map.
|
||||||
|
- [gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md):
|
||||||
The bulk of the `androidx.compose.*` code is vendored byte-for-byte from
|
consuming the port from another build.
|
||||||
`JetBrains/compose-multiplatform-core` and lives under
|
|
||||||
`<module>/src/vendor/` (gitignored — you re-sync on demand). Each module
|
|
||||||
carries a `compose-fork.txt` manifest listing which upstream files it pulls
|
|
||||||
in.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/compose-fork/sync.sh # sync every module
|
|
||||||
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt # one module
|
|
||||||
```
|
|
||||||
|
|
||||||
Upstream ref pinned in `scripts/compose-fork/compose.properties`; manifests can
|
|
||||||
pin additional upstream repos inline (`SET_REPO=<url>@<ref>` — the resources
|
|
||||||
runtime vendors from the compose-multiplatform umbrella repo this way).
|
|
||||||
|
|
||||||
How much of upstream's public API the port actually covers is measured, not
|
|
||||||
guessed — `scripts/compose-coverage.py` diffs this repo's klib ABI dumps
|
|
||||||
against upstream's, per vendored module:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
|
|
||||||
python3 scripts/compose-coverage.py --missing ui-text # list the exact uncovered decls
|
|
||||||
```
|
|
||||||
|
|
||||||
See [CLAUDE.md](CLAUDE.md) for the full architecture, source-set hierarchy,
|
|
||||||
vendoring rules, density flow (physical-pixel Option B), and per-area file
|
|
||||||
map.
|
|
||||||
|
|
||||||
## Known Compatible — official artifacts that just work
|
|
||||||
|
|
||||||
A surprising amount of the androidx architecture stack ships real
|
|
||||||
Kotlin/Native desktop klibs (mingwX64, linuxX64/arm64, macosArm64) and runs
|
|
||||||
on this port **unmodified** — no reimplementation, no vendoring. Sometimes
|
|
||||||
the Google coordinates (`androidx.*`) carry the K/N variant, sometimes the
|
|
||||||
JetBrains ones (`org.jetbrains.*`) — check both.
|
|
||||||
|
|
||||||
Verified working in-tree:
|
|
||||||
|
|
||||||
| Artifact | Notes |
|
|
||||||
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------|
|
|
||||||
| `org.jetbrains.compose.runtime:runtime`, `runtime-saveable` | the Compose runtime itself — never reimplemented |
|
|
||||||
| `androidx.compose.runtime:runtime-retain` | google coordinates |
|
|
||||||
| `androidx.lifecycle:lifecycle-runtime-compose` | `LocalLifecycleOwner`, `rememberLifecycleOwner`, `repeatOnLifecycle` |
|
|
||||||
| `androidx.lifecycle:lifecycle-viewmodel(-compose, -savedstate)` | full ViewModel + SavedStateHandle stack |
|
|
||||||
| `androidx.lifecycle:lifecycle-viewmodel-navigation3` | per-entry ViewModel scoping for Navigation 3 |
|
|
||||||
| `androidx.savedstate:savedstate`, `savedstate-compose` | |
|
|
||||||
| `androidx.navigation3:navigation3-runtime` | backstack / NavEntry / decorators (only the UI layer needed vendoring → `:navigation3-ui`) |
|
|
||||||
| `androidx.navigationevent:navigationevent-compose` | predictive back / BackHandler plumbing |
|
|
||||||
| `androidx.collection:collection` | |
|
|
||||||
|
|
||||||
The window layer supplies what these expect from a host: per-window
|
|
||||||
`LifecycleOwner` (driven by real SDL focus/visibility events — focused →
|
|
||||||
RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
|
|
||||||
(the `activityViewModels()` analog, `SavedStateHandle`-enabled) and
|
|
||||||
`SavedStateRegistryOwner`, plus a `Dispatchers.Main` with a true `immediate`.
|
|
||||||
ViewModels scope per nav entry, per window, or anywhere in between — same
|
|
||||||
semantics as Android.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# RENDERER.md
|
||||||
|
|
||||||
|
The durable reference for the rendering layer: how it is structured, the
|
||||||
|
convergence work that shaped it, the decisions worth remembering, and what is
|
||||||
|
left. Read this before touching anything under `renderer/`, the graphics
|
||||||
|
actuals, or the layer engine.
|
||||||
|
|
||||||
|
For build and verification commands see [TOOLING.md](TOOLING.md). For module
|
||||||
|
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## 1. The renderer
|
||||||
|
|
||||||
|
> **This branch is Skia-only.** The from-scratch SDL renderer leg
|
||||||
|
> (`sdlRendererMain`: `Sdl3Canvas` / `Sdl3TextRenderer` / `FreeTypeIcons` / the
|
||||||
|
> SDL render nodes, ~3,800 lines) and its `SDL3_ttf` / `SDL3_image` / FreeType
|
||||||
|
> cinterops were removed once Skiko-on-mingwX64 landed (Route 1a — see
|
||||||
|
> [SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)). SDL3 remains as the
|
||||||
|
> windowing / input / platform layer only. Below, references to "the SDL leg", a
|
||||||
|
> second `RenderBackend` actual, `-Prenderer=sdl3`, and the SDL-leg fidelity work
|
||||||
|
> are **historical** — kept because the convergence decisions still explain the
|
||||||
|
> Skia leg's shape.
|
||||||
|
|
||||||
|
Rendering goes through one `RenderBackend`, implemented once: the **Skia leg**
|
||||||
|
(`skikoRendererMain`). It draws through upstream Compose Multiplatform's own Skia
|
||||||
|
stack, vendored verbatim: `SkiaBackedCanvas`, `SkiaBackedPaint`, `SkiaShader`,
|
||||||
|
`actual class GraphicsLayer` backed by `org.jetbrains.skiko.node.RenderNode`,
|
||||||
|
`SkiaGraphicsContext` — the layer and draw engine internals are literally
|
||||||
|
upstream. macOS/Linux link the official Skiko klibs; mingwX64 links the
|
||||||
|
bitsycore skiko **fork** (`skikoRendererMingwMain`, Route 1a).
|
||||||
|
|
||||||
|
The text pipeline is now upstream's own `SkiaParagraph` (skparagraph, vendored
|
||||||
|
verbatim); glyph rasterization is Skia. GPU path per platform: Metal (macOS),
|
||||||
|
OpenGL (Linux + Windows), with a CPU-raster `Software` fallback.
|
||||||
|
|
||||||
|
The seam is kept as narrow and low as possible. Code flows
|
||||||
|
`Common (upstream) -> shared native engine -> Skia actual`. What we
|
||||||
|
minimize is hand-rolled actual surface, not actual-side line count: a fat
|
||||||
|
vendored-upstream actual (the Skia leg carrying upstream's whole GraphicsLayer
|
||||||
|
stack) is preferred over a thin hand-written one.
|
||||||
|
|
||||||
|
## 2. The retained-layer engine (the model we copied)
|
||||||
|
|
||||||
|
Upstream skiko skips work at three levels. The Skia leg has all three.
|
||||||
|
|
||||||
|
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
|
||||||
|
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
|
||||||
|
`SDL_WaitEventTimeout` when idle.
|
||||||
|
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
|
||||||
|
used verbatim through `ComposeOwner.measureAndLayout()`.
|
||||||
|
- **L3 draw record-once / replay** (the expensive one). Each isolated
|
||||||
|
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
|
||||||
|
`GraphicsLayer`. The display list re-records only when content is dirty;
|
||||||
|
otherwise it replays.
|
||||||
|
|
||||||
|
The critical property: **transform, alpha, and clip changes do NOT re-record.**
|
||||||
|
Moving, scaling, rotating, or fading a layer replays cached content under a new
|
||||||
|
transform. Only a genuine content change (a state read inside the draw block, or
|
||||||
|
a resize) re-records. Upstream replays the whole scene every frame with no
|
||||||
|
dirty-region present; the entire win is not re-recording clean layers, not
|
||||||
|
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
|
||||||
|
|
||||||
|
The compositing-strategy contract (`requiresLayer()`, from
|
||||||
|
`SkiaGraphicsLayer.skiko.kt`) decides when a layer needs an offscreen:
|
||||||
|
|
||||||
|
| Condition | Auto | Offscreen | ModulateAlpha |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
|
||||||
|
| `colorFilter != null` | offscreen | offscreen | offscreen |
|
||||||
|
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
|
||||||
|
| `renderEffect != null` | offscreen | offscreen | offscreen |
|
||||||
|
| none of the above | replay in place | always offscreen | replay in place |
|
||||||
|
|
||||||
|
## 3. Current state
|
||||||
|
|
||||||
|
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
|
||||||
|
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
|
||||||
|
vendored verbatim. The Skia leg gets real display-list caching and correct
|
||||||
|
clip/shadow/renderEffect for free from upstream. After this landed, Skia
|
||||||
|
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
|
||||||
|
- **Parity is a golden-master against JVM** (about 2% median, dominated by the
|
||||||
|
shared text engine's small metric delta). See TOOLING.md for how to read it.
|
||||||
|
- **Memory is stable.** The historical composition leak is fixed (see section 6)
|
||||||
|
and guarded by the `--soaktest` gate in the verify runbook.
|
||||||
|
|
||||||
|
## 4. Decisions to remember
|
||||||
|
|
||||||
|
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
|
||||||
|
reconciliation cost when following upstream. All platforms now render through
|
||||||
|
real Skia — mingwX64 via the bitsycore skiko fork (Route 1a; see
|
||||||
|
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)) — so there is no
|
||||||
|
fidelity tier below the JVM cross-check. The JVM Compose Desktop target
|
||||||
|
(`:demo:run`) remains the fidelity/parity reference on any host.
|
||||||
|
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
|
||||||
|
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
|
||||||
|
drift tripwire can track it. The litmus test for any divergence: "Is this what
|
||||||
|
upstream does? If not, what real platform constraint forces the difference?"
|
||||||
|
Valid answers name a constraint (e.g. no windowing/input toolkit in K/N ->
|
||||||
|
SDL3). "It was easier" is not valid.
|
||||||
|
- **Text is now upstream's engine (B6.3 done).** Skia-leg text draws through
|
||||||
|
upstream's own `SkiaParagraph` (skparagraph), vendored verbatim — the
|
||||||
|
font-subsystem replacement landed: upstream `PlatformFont`/`FontCache`/
|
||||||
|
`FontCollection` back a `data.kres`-fed font supply, replacing the port's
|
||||||
|
name-to-bytes engine. glyph rasterization stays Skia. This subsumes the P3.1
|
||||||
|
metrics work (measurement is now upstream's, so the numbers match by
|
||||||
|
construction).
|
||||||
|
- **Module split (`:ui-graphics` / `:ui-text`) is done.** The blocker — the
|
||||||
|
`sdl3` cinterop being a shared substrate that Kotlin/Native can't cleanly
|
||||||
|
share across a module boundary — was resolved by extracting a non-upstream
|
||||||
|
`:sdl-core` base module that owns the cinterop and api-exposes it. With that
|
||||||
|
base in place, `androidx.compose.ui.graphics.*` / `.text.*` split off into
|
||||||
|
`:ui-graphics` / `:ui-text` (matching upstream artifacts), leaving `:ui` as
|
||||||
|
the Compose core + Skia renderer + SDL bridges. `RenderBackend.drawRoot` takes
|
||||||
|
`(Canvas) -> Unit`, decoupling the backends from `ComposeRootHost`.
|
||||||
|
- **Real Skia on Windows K/N shipped (Route 1a).** mingwX64 links the bitsycore
|
||||||
|
skiko fork against `skiko-windows-x64.dll` (with an embedded GNU import lib),
|
||||||
|
published to GitHub Packages and auto-provisioned by the bridge plugin. This
|
||||||
|
replaced the SDL renderer as the Windows path. See
|
||||||
|
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md) for the ABI details
|
||||||
|
and the route trade-offs.
|
||||||
|
|
||||||
|
## 5. Convergence status
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
|
||||||
|
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
|
||||||
|
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
|
||||||
|
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
|
||||||
|
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | Done |
|
||||||
|
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
|
||||||
|
| P2.2: composition memory leak | Fixed + soak-gated |
|
||||||
|
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
|
||||||
|
| P3.1: text metrics parity (17% median -> 2%) | Done |
|
||||||
|
| Module split (`:ui-graphics` / `:ui-text` via `:sdl-core`) | Done |
|
||||||
|
| Track A: real Skia on Windows (Route 1a) | Done |
|
||||||
|
|
||||||
|
## 6. Remaining and future work
|
||||||
|
|
||||||
|
Nothing here blocks day-to-day work. These are the open threads worth
|
||||||
|
remembering.
|
||||||
|
|
||||||
|
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook cannot cover the
|
||||||
|
shipped mingwX64 binary, the Windows-only `PrintWindow` probe, or the
|
||||||
|
common-metadata publish job. Run these on a Windows host before any release.
|
||||||
|
This is the only outstanding verification.
|
||||||
|
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
|
||||||
|
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
|
||||||
|
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
|
||||||
|
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
|
||||||
|
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
|
||||||
|
material-symbols to close the skew, then cut the release. The concrete
|
||||||
|
step-by-step (the version map, the ref-bump flow, and the release flow) lives
|
||||||
|
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
|
||||||
|
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
|
||||||
|
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
|
||||||
|
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
|
||||||
|
blocker was outdated) but high blast radius for a marginal gain; elevation
|
||||||
|
shadows already match JVM.
|
||||||
|
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
|
||||||
|
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
|
||||||
|
matrix fn usable from the owner layer).
|
||||||
|
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
|
||||||
|
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
|
||||||
|
accessibility or complex-script roadmap.
|
||||||
|
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
|
||||||
|
vendoring them saves nothing.
|
||||||
|
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
|
||||||
|
`SdlImageBitmap.close()` fully into cache eviction and the renderer
|
||||||
|
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
|
||||||
|
|
||||||
|
## 7. Hard-won learnings (do not relearn)
|
||||||
|
|
||||||
|
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
|
||||||
|
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
|
||||||
|
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
|
||||||
|
the interaction probe caught it. Match upstream verbatim; do not hand-roll
|
||||||
|
engine plumbing from a summary.
|
||||||
|
- **The composition memory leak (root cause).** The port never called
|
||||||
|
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
|
||||||
|
`RootNodeOwner` runs after every measure. Snapshot read-observations for
|
||||||
|
scopes invalidated on dispose lingered forever, each pinning its observed
|
||||||
|
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
|
||||||
|
Memory Tag 246). Most visible on ripple/indication draws. Fix:
|
||||||
|
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
|
||||||
|
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
|
||||||
|
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
|
||||||
|
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
|
||||||
|
budget heap tooling for this class.
|
||||||
|
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
|
||||||
|
animated/settling screens give false signals. Use render-to-quiescence +
|
||||||
|
virtual frame time for parity, the probe for interaction/crash coverage.
|
||||||
|
- **The profiler `present` phase is vsync-capped by the display refresh.**
|
||||||
|
Profile on the target monitor before concluding a frame-rate gap.
|
||||||
|
|
||||||
|
## 8. Explicit non-goals
|
||||||
|
|
||||||
|
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
|
||||||
|
is not re-recording, not redrawing less.
|
||||||
|
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
|
||||||
|
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
|
||||||
|
`SkiaLayer` and windowing; it fights the `:desktop-native-window` SDL loop. The port borrows
|
||||||
|
only the layer engine.
|
||||||
|
|
||||||
|
## 9. Key files
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
|
||||||
|
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
|
||||||
|
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
|
||||||
|
hit-test, event dispatch, snapshot observer sweep.
|
||||||
|
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
|
||||||
|
`Owner` + `GraphicsLayerOwnerLayer` bridge.
|
||||||
|
- `compose/ui/ui-text/src/nativeMain/.../ui/text/SkiaParagraph.native.kt`: the
|
||||||
|
upstream `Paragraph` actual (measurement, hit-test, line metrics, span painting).
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# Skiko on mingwX64 — Feasibility & Effort Assessment
|
||||||
|
|
||||||
|
> **STATUS: SHIPPED (Route 1a).** The real Skia leg now runs on mingwX64 via the
|
||||||
|
> bitsycore skiko fork + `skiko-windows-x64.dll`. This document is retained as
|
||||||
|
> **historical design context** — the feasibility findings and route trade-offs
|
||||||
|
> that led to the shipped implementation.
|
||||||
|
|
||||||
|
Investigation of the effort to run the real Skia leg (`org.jetbrains.skiko`)
|
||||||
|
on the Kotlin/Native **mingwX64** target, under the relaxed constraint that a
|
||||||
|
**DLL is acceptable** (the static / no-DLL invariant may be dropped). Companion
|
||||||
|
to [RENDERER.md](RENDERER.md) §4 "Track A" — which this assessment **partly
|
||||||
|
supersedes** (see [Corrections](#corrections-to-renderermd-4) below).
|
||||||
|
|
||||||
|
Pins at time of writing: skiko `0.150.1` (Skia m150), kotlin `2.4.0`,
|
||||||
|
compose `1.12.0-beta02`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
Feasible — and **more tractable than RENDERER.md §4 claims**. Two of that
|
||||||
|
section's load-bearing premises are now factually stale:
|
||||||
|
|
||||||
|
1. **"Building C++20 Skia to a GNU/mingw archive is an open-ended fork (only
|
||||||
|
abandoned Mozilla-era precedent)."** — **False.** MSYS2 maintains
|
||||||
|
`mingw-w64-skia` at **Skia milestone 143 (C++20)** for `mingw64` (GCC),
|
||||||
|
`ucrt64`, and `clang64` (clang → `x86_64-w64-windows-gnu`), producing
|
||||||
|
`libskia.dll` + a GNU import lib `libskia.dll.a`. Last rebuilt **2026-07-24**.
|
||||||
|
The GNU-ABI recipe is ~8 small GN patches (the key one adds
|
||||||
|
`is_mingw = is_win && (cxx=="g++"||cxx=="clang++")`).
|
||||||
|
2. **"The only working route is a runtime DLL, which breaks the no-DLL
|
||||||
|
invariant."** — The DLL route is real, but the reason it works is **not**
|
||||||
|
what the section implies. See the crux below.
|
||||||
|
|
||||||
|
**Effort:** ~**1–2 weeks** to a proven spike + a CPU-raster Skia-on-Windows
|
||||||
|
prototype; ~**1–2 months** to a tested, GPU-accelerated Windows Skia leg with
|
||||||
|
published fork klibs and repo wiring — **plus** permanent
|
||||||
|
`bitsycore/skiko`-fork maintenance (re-sync at every skiko bump). Difficulty:
|
||||||
|
**research-grade / high**, because no working prior art exists (the two GitHub
|
||||||
|
`skiko-mingw64` forks are vaporware — 0 commits ahead of upstream, no mingw
|
||||||
|
source set). Confidence: **medium** (high on mechanism, medium on timeline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spike result — EXECUTED, GREEN ✅ (2026-07-24)
|
||||||
|
|
||||||
|
The core de-risking experiment has been **run on this machine** and passed. The
|
||||||
|
whole Route 1a link mechanism works on the exact target toolchain (Kotlin
|
||||||
|
**2.4.0**, mingwX64, LLVM **21** `ld.lld`, `x86_64-pc-windows-gnu`).
|
||||||
|
|
||||||
|
**What was built** (`spike-skiko-mingw/` in this repo):
|
||||||
|
- A tiny DLL reproducing skiko's binding shape — an **opaque handle over a real
|
||||||
|
C++/STL object** (`std::string`), **alloc+free paired inside the DLL**, flat
|
||||||
|
`extern "C"` surface. Compiled **MSVC-ABI via `clang-cl /LD /MT /EHsc`**
|
||||||
|
(static CRT). Exports verified undecorated: `spike_make/size/free`.
|
||||||
|
- A **GNU import library** (`libspike.dll.a`) generated with `dlltool`.
|
||||||
|
- A **Kotlin/Native mingwX64 executable** binding it via cinterop (plain C
|
||||||
|
header) + `linkerOpts("-L… -lspike")`.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
- `linkReleaseExecutableMingwX64` **linked clean** — **KT-65671 did NOT bite**
|
||||||
|
(the DLL path puts zero C++ objects in the K/N link graph, so the
|
||||||
|
`--allow-multiple-definition` / `.drectve __clang_call_terminate` failures
|
||||||
|
never arise). LLVM 21 `ld.lld` is also far newer than the 1.9.x-era reports.
|
||||||
|
- Runtime: `SPIKE-RESULT size=7 expected=7 ok=true`, exit 0 — the C++
|
||||||
|
`std::string(7,'x').size()` round-trips through the opaque handle correctly.
|
||||||
|
- `objdump -p` confirms the exe imports `spike.dll`, and `spike.dll` depends on
|
||||||
|
**`KERNEL32.dll` only** → **self-contained, no VC++ redist needed**. The exe
|
||||||
|
carries K/N's `msvcrt.dll` while the DLL carries its own static MSVC CRT —
|
||||||
|
**two CRTs coexist in one process** with no issue (ownership paired inside the
|
||||||
|
DLL), empirically confirming the CRT-boundary discipline.
|
||||||
|
|
||||||
|
**Implication:** the one load-bearing unknown (does the DLL route link on
|
||||||
|
2.4.0?) is resolved **in favour**. Route 1a is de-risked at the mechanism level;
|
||||||
|
remaining effort is engineering the real `skiko.dll` (compile skiko's shim +
|
||||||
|
skia-pack MSVC Skia into one DLL) and repo wiring — not proving feasibility.
|
||||||
|
Feasibility confidence is now **high**; residual medium confidence is on
|
||||||
|
timeline only.
|
||||||
|
|
||||||
|
Reproduce: `./gradlew -p spike-skiko-mingw linkReleaseExecutableMingwX64`, then
|
||||||
|
run the exe with `spike-skiko-mingw/native` on `PATH`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The crux — and why "just use a DLL" is only half the story
|
||||||
|
|
||||||
|
**The blocker is a C++ ABI mismatch at the binding boundary, and it is
|
||||||
|
orthogonal to static-vs-dynamic linkage.**
|
||||||
|
|
||||||
|
- **K/N mingwX64 is GNU/Itanium ABI.** `konan.properties`:
|
||||||
|
`targetTriple.mingw_x64 = x86_64-pc-windows-gnu`,
|
||||||
|
`linker.mingw_x64 = ld.lld.exe`,
|
||||||
|
`linkerKonanFlags.mingw_x64 = -static-libgcc -static-libstdc++ … -lwinpthread`
|
||||||
|
— GCC libstdc++/libgcc, Itanium mangling (`_Z3fooi`).
|
||||||
|
- **skia-pack's only Windows Skia is MSVC-ABI** — built with `clang-cl`,
|
||||||
|
`is_trivial_abi=false`, shipped as MSVC `.lib`; C++ exports are MSVC-mangled
|
||||||
|
(`?foo@@YAHH@Z`).
|
||||||
|
|
||||||
|
LLVM issue #60847 ("LLD cannot link against MSVC libraries in MinGW toolchain",
|
||||||
|
closed *not planned*) confirms the two mangling schemes never reconcile.
|
||||||
|
**Wrapping the same MSVC C++ Skia in a DLL does not change this** — a DLL's C++
|
||||||
|
exports are still MSVC-mangled. So dynamic-vs-static is a red herring; **the C++
|
||||||
|
ABI at whatever boundary the mangled C++ crosses is the wall.**
|
||||||
|
|
||||||
|
What makes it tractable is *where that boundary sits in skiko*:
|
||||||
|
|
||||||
|
- **skiko's Kotlin↔native boundary is already a flat, unmangled C symbol
|
||||||
|
boundary.** Kotlin declares
|
||||||
|
`@ExternalSymbolName("org_jetbrains_skia_Data__1nSize") external fun _nSize(…)`;
|
||||||
|
`ExternalSymbolName` is a typealias for `kotlin.native.SymbolName`, which
|
||||||
|
resolves to a *plain named symbol* at link. The generated `.def` is
|
||||||
|
header-less (only `linkerOpts`). **No C++ type ever crosses into Kotlin.**
|
||||||
|
- **The C++ ABI coupling lives one layer down**, at the *shim↔Skia* link:
|
||||||
|
~78 `extern "C"` (`SKIKO_EXPORT`) `.cc` files under `src/nativeJsMain/cpp/`
|
||||||
|
that `reinterpret_cast<SkData*>(ptr)->size()` etc. This shim **must share one
|
||||||
|
C++ ABI with Skia** — today they're one GNU-ABI island fused via
|
||||||
|
`-include-binary`.
|
||||||
|
|
||||||
|
**Consequence:** a DLL is viable **iff** the *entire* C++ island (shim + Skia)
|
||||||
|
is one consistent ABI internally, and only the **flat extern-C export surface**
|
||||||
|
crosses to K/N via a generated import library. On x86_64 there is a single
|
||||||
|
Windows calling convention and extern-C exports are undecorated on both MSVC and
|
||||||
|
mingw, so `ld.lld` links such a DLL via `gendef`+`dlltool` (or directly, LLD
|
||||||
|
13+). **The repo already does exactly this for system DLLs** (`sdl3.def`:
|
||||||
|
`linkerOpts.mingw_x64 = -lkernel32 -luser32 -lgdi32 …`).
|
||||||
|
|
||||||
|
Crucially the extern-C façade **already exists** (the `nativeJsMain/cpp` shim
|
||||||
|
exports precisely the `org_jetbrains_skia_*` symbols the K/N side binds) — it is
|
||||||
|
**reused, not authored anew**. The catch: no ownership / exceptions / STL may
|
||||||
|
cross the C line — skiko is already handle/RefCnt-based, so this holds if
|
||||||
|
authored carefully. (The JVM `jvmMain/cpp` bridge exports `Java_…` JNI symbols
|
||||||
|
and is **not** reusable — the *native* shim is the right one.)
|
||||||
|
|
||||||
|
The non-negotiable that survives all of this: **upstream skiko ships no
|
||||||
|
mingwX64 K/N target at all** (`throw GradleException("$os not yet supported")`
|
||||||
|
for non-{Mac,iOS,tvOS,Linux}; "Windows" in skiko means the JVM/AWT clang-cl
|
||||||
|
path). So this is a **from-scratch skiko build-system port**, not a flag flip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The dominant risk: KT-65671
|
||||||
|
|
||||||
|
`KT-65671` ("Kotlin/Native: Failed linking cinterop static library for
|
||||||
|
mingwX64", **Open**, filed against 1.9.x, status on 2.4.0 unverified): K/N's
|
||||||
|
mingw link step chokes when **fusing an external C++ static archive** —
|
||||||
|
`ld.lld` rejects `--allow-multiple-definition`, and
|
||||||
|
`-exclude-symbols:__clang_call_terminate` in the objects is rejected in
|
||||||
|
`.drectve`. This is skiko's **exact static-fusion pattern**.
|
||||||
|
|
||||||
|
Key implication for route choice: a **pure extern-C DLL boundary sidesteps this
|
||||||
|
bug class entirely** — no C++ objects enter the K/N link graph; only a flat
|
||||||
|
import surface crosses. So, counter-intuitively, the **DLL route is the
|
||||||
|
risk-*reducing* route** w.r.t. KT-65671, and it is exactly what the user is
|
||||||
|
willing to accept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### ★ Recommended — DLL with a flat extern-C export, K/N binds via import lib
|
||||||
|
|
||||||
|
Put **all** the C++ (skiko's existing shim + Skia) inside one DLL; export only
|
||||||
|
the flat `org_jetbrains_skia_*` symbols; bind from a new mingwX64 skiko target
|
||||||
|
via a generated import lib. K/N resolves at link; the Windows loader binds
|
||||||
|
`skiko.dll` at process start — **no dlopen/`staticLoad` code needed** (the
|
||||||
|
shipped `staticLoad()` no-op is fine for the import-lib path).
|
||||||
|
|
||||||
|
Two sub-choices for the DLL internals (K/N neither knows nor cares which):
|
||||||
|
|
||||||
|
- **1a — MSVC internals (minimal fork).** Recompile skiko's `nativeJsMain/cpp`
|
||||||
|
shim with `clang-cl` against **skia-pack's existing MSVC Windows `.lib`**
|
||||||
|
(`windows-x64` archives are already published). **Zero Skia fork.** Net-new:
|
||||||
|
add `__declspec(dllexport)` to `SKIKO_EXPORT` on this build, a mingw/DLL
|
||||||
|
branch in skiko's build logic, import-lib generation. CRT discipline (MSVC CRT
|
||||||
|
in the DLL vs K/N's static libgcc) is a correctness constraint on the C API
|
||||||
|
(handles + primitives only), not a linkability blocker.
|
||||||
|
- **1b — GNU internals.** Build the shim + Skia GNU-ABI (reuse MSYS2's
|
||||||
|
`mingw-w64-skia` patch set) into the DLL. Slightly more Skia-side work, but a
|
||||||
|
single toolchain end-to-end.
|
||||||
|
|
||||||
|
**Effort:** ~2–4 weeks once the spike is green. **Risk: medium** (sidesteps
|
||||||
|
KT-65671). **"Just works" preserved:** none — ships `skiko.dll` (tens of MB)
|
||||||
|
next to `data.kres` (accepted). **Sub-choice 1a is the least-fork option.**
|
||||||
|
|
||||||
|
### Route 2 — GNU-ABI static Skia + real mingwX64 skiko target (purist)
|
||||||
|
|
||||||
|
Fork skia-pack (or reuse MSYS2's patches) to emit **GNU-ABI static** Skia;
|
||||||
|
fork skiko to add a mingwX64 target that recompiles the existing extern-C shim
|
||||||
|
GNU-ABI and **static-fuses** exactly as macOS/Linux do today.
|
||||||
|
|
||||||
|
**Effort:** Skia build itself is now **days** (MSYS2 proves it). skiko port +
|
||||||
|
link reconciliation ~3–6 weeks. **Risk: medium-high** — this route hits
|
||||||
|
**KT-65671 head-on** (static C++ archive fusion). **Preserves the static /
|
||||||
|
no-DLL invariant** — the only reason to prefer it, and the user said they don't
|
||||||
|
need it.
|
||||||
|
|
||||||
|
### Route 3 — CPU-raster-only (a scope reduction, not an ABI shortcut)
|
||||||
|
|
||||||
|
The repo's `SkiaSurfaceBridge` (`Surface.makeRasterDirect` → `SDL_UpdateTexture`
|
||||||
|
→ `SDL_RenderTexture`) needs **no GPU context** and compiles on mingwX64 the
|
||||||
|
moment a skiko klib exists. Use it to **skip authoring the Windows GPU bridge**
|
||||||
|
for a first cut. Still requires solving Route 1/2's binding problem first.
|
||||||
|
**Saves ~1–2 weeks** of GPU-bridge work → the natural **milestone 1** of
|
||||||
|
whichever route you pick.
|
||||||
|
|
||||||
|
### Baseline — do nothing (already shipping)
|
||||||
|
|
||||||
|
Windows renders via the from-scratch **SDL leg** (`SDL_RenderGeometry` +
|
||||||
|
SDL3_ttf + FreeType); the **JVM parity target** is the full-fidelity Skia
|
||||||
|
reference. The bar any Skia-on-Windows work must clear is "materially better
|
||||||
|
than the SDL leg's fidelity, worth the DLL + maintenance cost."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended work breakdown (Route 1a, CPU-raster first)
|
||||||
|
|
||||||
|
**A. Skia — none (1a) / days (1b).**
|
||||||
|
Route 1a reuses skia-pack's published `windows-x64` MSVC `.lib`. (1b: reuse the
|
||||||
|
MSYS2 `mingw-w64-skia` PKGBUILD + patches at skiko's pinned milestone; backends
|
||||||
|
CPU-raster + `skia_use_gl`, `skia_use_freetype`.)
|
||||||
|
|
||||||
|
**B. `bitsycore/skiko` fork — the bulk, medium risk.**
|
||||||
|
1. Add `mingwX64()` + a `configureNativeTarget(OS.Windows, Arch.X64, …)` branch;
|
||||||
|
replace the `throw GradleException("$os not yet supported")` / empty
|
||||||
|
`linkerFlags` fall-throughs in `NativeTasksConfiguration.kt` with a Windows
|
||||||
|
branch (PE/COFF, `llvm-ar`).
|
||||||
|
2. Compile the **existing** `nativeJsMain/cpp` shim (not the JVM JNI bridge)
|
||||||
|
into `skiko.dll`; make `SKIKO_EXPORT` export the flat symbols
|
||||||
|
(`__declspec(dllexport)` or `--export-all-symbols`/def file).
|
||||||
|
3. Generate the GNU import lib (`gendef` + `dlltool` → `libskiko.dll.a`).
|
||||||
|
4. cinterop `.def` stays header-less (`linkerOpts`/`libraryPaths`), matching the
|
||||||
|
repo's own idiom. Publish mingwX64 klibs + the DLL.
|
||||||
|
|
||||||
|
**C. Windows context actual — deferred behind milestone 1.**
|
||||||
|
5. **Milestone 1:** wire only the CPU-raster `SkiaSurfaceBridge` (already
|
||||||
|
Windows-ready).
|
||||||
|
6. **Milestone 2:** author `SkiaD3D11Bridge` / `SkiaVulkanBridge` — extract HWND
|
||||||
|
via `SDL_GetWindowProperties`, wrap `DirectContext.makeD3D11`/`makeVulkan`,
|
||||||
|
mirror the Metal/GL bridges' per-frame acquire/present.
|
||||||
|
|
||||||
|
**D. This repo's wiring — small, low risk (integration points confirmed).**
|
||||||
|
7. `compose/ui/ui/build.gradle.kts` — flip `isSkiaTarget("mingwX64")` to `true`
|
||||||
|
(currently hardcoded `false`, ~line 58); create a `skikoRendererMingwMain`
|
||||||
|
and `mingwX64Main.dependsOn(...)` it (today mingw attaches only to
|
||||||
|
`sdlRendererMingwMain`, lines ~203–206; the skiko tree, lines ~220–235, is
|
||||||
|
created only under `!useSdl3Everywhere` and never for mingw). Add the fork's
|
||||||
|
coords to that source set.
|
||||||
|
8. **No new expect/actual needed:** `createRenderBackend` /
|
||||||
|
`rendererPreferredGpuMode` are `expect`s in `nativeMain` with `actual`s in
|
||||||
|
`skikoRendererMain` and `sdlRendererMain`; attaching mingwX64 to the skiko
|
||||||
|
source set makes the Skia actual resolve for Windows (one renderer source set
|
||||||
|
per target — the existing invariant).
|
||||||
|
9. Add a Windows GPU-bridge branch to `rendererPreferredGpuMode()`; verify with
|
||||||
|
the demo/apidemo (`--gpu`, `--screenshot`) and the parity harness.
|
||||||
|
|
||||||
|
Repo surface to cover is narrow: **~67 `org.jetbrains.skia` classes + 5
|
||||||
|
`org.jetbrains.skiko.node` APIs** (`RenderNode`, `RenderNodeContext`,
|
||||||
|
`SkikoRenderDelegate`, `SystemTheme`, `currentSystemTheme`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one spike to run first (~1–2 days)
|
||||||
|
|
||||||
|
Before any fork work, **empirically test the K/N-mingw link path**: build a
|
||||||
|
trivial `extern "C"` C++ DLL (one function returning a handle + one that uses
|
||||||
|
it) — or consume MSYS2's `mingw-w64-x86_64-skia` DLL + `libskia.dll.a`
|
||||||
|
directly — from a throwaway mingwX64 K/N project via cinterop + import lib, and
|
||||||
|
**run it**. This answers, in a day instead of a month:
|
||||||
|
|
||||||
|
- Does K/N mingw link + call a GNU-ABI DLL via an import lib at all?
|
||||||
|
- Does **KT-65671** reproduce on Kotlin 2.4.0 (import-lib vs static fusion)?
|
||||||
|
- DLL vs static-fusion behaviour on this toolchain.
|
||||||
|
|
||||||
|
If green, Routes 1/2 are de-risked. (Caveat: MSYS2 Skia exports Skia's *mangled
|
||||||
|
C++* symbols, so a direct cinterop of it tests only the *link mechanism*, not
|
||||||
|
the skiko API — for the API you still need the extern-C shim. The trivial-C-DLL
|
||||||
|
variant is the cleaner mechanism test.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Biggest risks / unknowns
|
||||||
|
|
||||||
|
1. **KT-65671** — whether K/N's mingw link works on 2.4.0 for external C++;
|
||||||
|
the DLL route sidesteps it, the static route hits it. *The single fact that
|
||||||
|
swings the effort.*
|
||||||
|
2. **Toolchain matching** — Konan bundles LLVM 16 clang + `ld.lld`; the C++20
|
||||||
|
Skia + shim must be built by a toolchain whose objects/imports LLD accepts.
|
||||||
|
3. **CRT / ownership discipline** across the C boundary (only matters for 1a).
|
||||||
|
4. **Maintenance** — SKIKO-446 / SKIKO-611 are Open, unassigned → almost
|
||||||
|
certainly a **permanent `bitsycore/skiko` fork** with per-bump re-sync tax.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Pursue **Route 1a** (skiko fork producing a DLL with a flat extern-C export over
|
||||||
|
skia-pack's existing MSVC Windows Skia), **scoped CPU-raster first** (Route 3
|
||||||
|
milestone), GPU bridge second. It matches the user's accepted DLL tolerance,
|
||||||
|
requires **zero Skia fork**, and **dodges KT-65671**. Keep Route 2 (static,
|
||||||
|
GNU-ABI) in reserve only if reclaiming the no-DLL invariant later becomes
|
||||||
|
valuable. **Run the MSYS2/trivial-DLL spike before committing to any fork.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation status — WORKING END-TO-END ✅ (2026-07-24)
|
||||||
|
|
||||||
|
**`demo.exe` (mingwX64) renders the Material 3 Buttons screen through real Skia**
|
||||||
|
and wrote a 1000×700 screenshot ("settled at frame 3"), using the CPU-raster
|
||||||
|
`SkiaSurfaceBridge` backed by the forked `skiko-windows-x64.dll`. Route 1a is
|
||||||
|
complete. Build/run:
|
||||||
|
|
||||||
|
```
|
||||||
|
# fork (C:/Dev/skiko), one-time env: SKIKO_VSBT_PATH=<VS BuildTools dir>
|
||||||
|
./gradlew :skiko:publishKotlinMultiplatformPublicationToMavenLocal \
|
||||||
|
:skiko:publishMingwX64PublicationToMavenLocal -Pskiko.native.windows.enabled=true
|
||||||
|
# compose repo
|
||||||
|
./gradlew :demo:linkDebugExecutableMingwX64 -PwindowsSkia=true
|
||||||
|
cp <fork>/skiko/build/out/link/Release-windows-native-x64/skiko-windows-x64.dll <demo exe dir>/
|
||||||
|
<demo exe dir>/demo.exe --screenshot=out.bmp --screen=Buttons
|
||||||
|
```
|
||||||
|
|
||||||
|
Fork fixes needed beyond the DLL build (skiko uses a CUSTOM hierarchy template,
|
||||||
|
`applyDefaultHierarchyTemplate=false`):
|
||||||
|
1. `sourceHierarchy.kt` — add `group("windows"){ withMingwX64() }` under `native`
|
||||||
|
(else the mingwX64 klib compiles EMPTY — missing all of `org.jetbrains.skia`).
|
||||||
|
2. `SkikoProjectContext` — add `supportNativeWindows` into `supportAnyNative` so
|
||||||
|
the `@SymbolName` opt-in reaches the mingw compile.
|
||||||
|
3. `Resources.native.kt` — `ftell` is 32-bit `Int` on Windows (LLP64); `==` fix.
|
||||||
|
4. `windowsMain` actuals for `SkiaLayer` / `currentSystemTheme` (linux is a stub).
|
||||||
|
5. Force-export the 2 ICU symbols (`uloc_getDefault_skiko`,
|
||||||
|
`uloc_toLanguageTag_skiko`) that Kotlin binds via `@SymbolName`.
|
||||||
|
|
||||||
|
Compose-repo wiring (all behind `-PwindowsSkia=true`): `mavenLocal()` in
|
||||||
|
settings, `isSkiaTarget("mingwX64")`, a separate `skikoRendererMingwMain` tree
|
||||||
|
on the fork's **root** coord (`org.jetbrains.skiko:skiko:0.0.0-SNAPSHOT` — the
|
||||||
|
platform artifact alone doesn't expose api-elements), `PlatformGpu` Windows →
|
||||||
|
`Software`, and a one-line `PlatformGpu.mingw.kt`.
|
||||||
|
|
||||||
|
## Earlier milestone — fork BUILDS ✅ (2026-07-24)
|
||||||
|
|
||||||
|
Route 1a is not just designed — the `bitsycore/skiko` fork now **builds the
|
||||||
|
native Windows Skia DLL + a mingwX64 klib** on this machine (`C:/Dev/skiko`,
|
||||||
|
skiko `v0.150.1`).
|
||||||
|
|
||||||
|
Edits made (all in the fork):
|
||||||
|
- `src/nativeJsMain/cpp/common.h` — `SKIKO_EXPORT` gains `__declspec(dllexport)`
|
||||||
|
under a `SKIKO_WINDOWS_DLL` guard.
|
||||||
|
- `skiko/build.gradle.kts` — `configureNativeTarget(OS.Windows, Arch.X64,
|
||||||
|
mingwX64())`, gated behind `-Pskiko.native.windows.enabled=true`.
|
||||||
|
- `buildSrc/.../NativeTasksConfiguration.kt` — a Windows compile branch
|
||||||
|
(clang-cl + `-DSKIKO_WINDOWS_DLL`) and a new `configureWindowsNativeTarget`
|
||||||
|
that links `skiko-windows-x64.dll` via `lld-link` (reusing the JVM template +
|
||||||
|
`resolveBinaryInputs(…, TargetEnv.JVM, …)`), auto-generates the GNU import lib
|
||||||
|
(`dumpbin`→`.def`→`dlltool`), and wires the K/N cinterop `linkerOpts`.
|
||||||
|
|
||||||
|
Results:
|
||||||
|
- The native bridge (`nativeJsMain/cpp`) **compiled on Windows with clang-cl,
|
||||||
|
zero source changes.** The only link fix was adding `d3d12.lib
|
||||||
|
d3dcompiler.lib dxgi.lib` for Skia's `SK_DIRECT3D` backend.
|
||||||
|
- Produced **`skiko-windows-x64.dll` (14.2 MB, 1005 exported
|
||||||
|
`org_jetbrains_skia_*` symbols)**, `libskiko-windows-x64.dll.a` (GNU import
|
||||||
|
lib), and the **mingwX64 klib** (`build/classes/kotlin/mingwX64/main/klib`).
|
||||||
|
|
||||||
|
Prerequisite for any skiko Windows build on this box:
|
||||||
|
`SKIKO_VSBT_PATH='C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools'`
|
||||||
|
(Gradle's VS locator doesn't auto-find VS 18 BuildTools).
|
||||||
|
|
||||||
|
Remaining: publish the fork to `mavenLocal` → a tiny K/N mingw consumer that
|
||||||
|
calls a real Skia function (links the import lib, runs with the DLL on `PATH`)
|
||||||
|
for the definitive end-to-end proof → then wire the compose repo (flip
|
||||||
|
`isSkiaTarget("mingwX64")`, add `skikoRendererMingwMain`, ship the DLL next to
|
||||||
|
the exe, CPU-raster `SkiaSurfaceBridge` first). One known TODO: the klib's
|
||||||
|
`linkerOpts` use an absolute import-lib path (fine same-machine; make relative /
|
||||||
|
bundled for distribution).
|
||||||
|
|
||||||
|
## Corrections to RENDERER.md §4
|
||||||
|
|
||||||
|
The shelving note should be updated — its two central technical claims are stale
|
||||||
|
as of 2026-07:
|
||||||
|
|
||||||
|
- "GNU-ABI Skia is an open-ended fork / only abandoned Mozilla-era precedent" —
|
||||||
|
**false**: MSYS2 maintains a modern (m143, C++20) GNU-ABI Skia, rebuilt
|
||||||
|
routinely.
|
||||||
|
- The accurate residual blocker is **the K/N-side link/binding port**
|
||||||
|
(KT-65671 + authoring the mingwX64 skiko target), **not** building Skia, and
|
||||||
|
the DLL route is *risk-reducing*, not merely "the only working route."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources (primary, load-bearing)
|
||||||
|
|
||||||
|
- skiko v0.150.1: `skiko/build.gradle.kts`, `buildSrc/.../NativeTasksConfiguration.kt`,
|
||||||
|
`src/commonMain/kotlin/org/jetbrains/skia/Data.kt`,
|
||||||
|
`src/nativeMain/kotlin/org/jetbrains/skia/Actuals.native.kt`,
|
||||||
|
`src/nativeJsMain/cpp/common.h`, `.../impl/Library.native.kt`
|
||||||
|
- skia-pack `script/build.py`; skia.org build docs
|
||||||
|
- Kotlin `konan.properties` (v2.2.0); kotlinlang native-c-interop / target-support
|
||||||
|
- LLVM issue #60847; **KT-65671**; **SKIKO-446**, **SKIKO-611**
|
||||||
|
- MSYS2 `mingw-w64-skia` (packages.msys2.org; MINGW-packages PKGBUILD + patches)
|
||||||
|
- Vaporware forks: github.com/Cdm2883/skiko-mingw64, crowforkotlin/skiko-mingw64
|
||||||
|
- Repo: `compose/ui/ui/build.gradle.kts`, the `SkiaMetalBridge` / `SkiaGLBridge`
|
||||||
|
/ `SkiaSurfaceBridge` / `RenderBackendFactory` seam
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# TODO.md
|
||||||
|
|
||||||
|
Everything the port currently leaves as a no-op, stub, hardcode, or partial
|
||||||
|
implementation, plus the gaps worth closing before a stable 1.12 release. This
|
||||||
|
is an audit of PROJECT code only (the `*.native.kt` / `com.compose.sdl.*`
|
||||||
|
actuals); vendored upstream under `src/vendor/` is out of scope.
|
||||||
|
|
||||||
|
Severity is a rough guide, not a mandate:
|
||||||
|
|
||||||
|
- **Blocker** for stable: a capability a typical desktop app expects that is
|
||||||
|
currently absent or broken.
|
||||||
|
- **Nice-to-have**: real gap, but the app works without it.
|
||||||
|
- **Cosmetic**: minor fidelity or edge-case behavior.
|
||||||
|
|
||||||
|
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
|
||||||
|
(see [RENDERER.md](RENDERER.md)). There is now ONE renderer — **Skia**
|
||||||
|
(macOS/Linux official Skiko; mingwX64 the bitsycore skiko fork), so Windows
|
||||||
|
renders through real Skia. The priorities below are the cross-cutting platform
|
||||||
|
gaps that affect every OS. JVM Compose Desktop remains a parity cross-check.
|
||||||
|
|
||||||
|
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
|
||||||
|
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
|
||||||
|
here.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
Platform / cross-cutting:
|
||||||
|
|
||||||
|
- **Locale / i18n** (`5d03711f`) — `Locale.current` / `LocaleList.current` read the
|
||||||
|
OS locale via SDL, unlocking the 40+ Material 3 translations. The date/number
|
||||||
|
format tail remains (section C).
|
||||||
|
- **IME text input** (`500c49e4`) — a real `PlatformTextInputSession` routes SDL
|
||||||
|
committed + composing text to the focused field (commit replaces the
|
||||||
|
composition); `SDL_SetTextInputArea` positions the OS candidate window.
|
||||||
|
- **Cursor icons** (`1fcc0828`) — `Modifier.pointerHoverIcon` drives the OS cursor
|
||||||
|
via SDL (I-beam over text, hand over links).
|
||||||
|
- **WindowInfo** (`bad23073`) — `isWindowFocused` + `containerSize` / `containerDpSize`
|
||||||
|
report real values (were hardcoded `true` / `Zero`).
|
||||||
|
- **ImageBitmap from bytes** — `ByteArray.decodeToImageBitmap()` /
|
||||||
|
`createImageBitmap(bytes)` decodes encoded image bytes; was
|
||||||
|
`UnsupportedOperationException`. Verified `demo --imagebytestest`.
|
||||||
|
- **`FontFamily.Monospace`** — generic families map to `"generic:<name>"`;
|
||||||
|
`registerGenericFonts` registers bundled NotoSansMono under `generic:monospace`
|
||||||
|
(bundled only when the app references it). Verified `demo --fonttest`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release-blocking shortlist for stable 1.12
|
||||||
|
|
||||||
|
The cross-cutting items to weigh first (all detailed below):
|
||||||
|
|
||||||
|
1. **No copy/paste context menu** (section B). Keyboard copy works; there is no
|
||||||
|
right-click or selection toolbar (the desktop path is the right-click context
|
||||||
|
menu, upstream TODO CMP-7819).
|
||||||
|
2. **Accessibility is entirely absent** (section A). Decide whether stable
|
||||||
|
requires any screen-reader support at all.
|
||||||
|
3. Fonts (section D): compose-resources `Font(Res.font.x)` works, and
|
||||||
|
`FontFamily.Monospace` now renders NotoSansMono. Remaining: `Serif`/`Cursive`
|
||||||
|
generics (no bundled font) and the androidx `Font(bytes)`/`PlatformFontLoader`
|
||||||
|
path. Nice-to-have, not a blocker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Accessibility (entirely absent)
|
||||||
|
|
||||||
|
No accessibility bridge exists. A real `SemanticsOwner` is built but never
|
||||||
|
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
|
||||||
|
a11y). Screen readers get nothing.
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
|
||||||
|
|
||||||
|
## B. Text toolbar and context menus (copy / paste / select-all)
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. NOTE: the floating toolbar is gated on `isInTouchMode`, so on desktop (mouse) it never shows anyway — the desktop path is the right-click context menu below. **Nice-to-have (touch only).**
|
||||||
|
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No right-click copy/select-all menu for `SelectionContainer`. **Blocker for desktop text UX.**
|
||||||
|
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Blocker for desktop text UX.**
|
||||||
|
|
||||||
|
## C. Locale / i18n tail (needs locale-aware DATA, no ICU on K/N)
|
||||||
|
|
||||||
|
`Locale.current` / `LocaleList.current` are wired to the OS (see Completed). The
|
||||||
|
rest needs month/weekday name tables and date/number patterns, which Kotlin/Native
|
||||||
|
has no ICU for; SDL reports language + country only (no script subtag, no
|
||||||
|
calendar/clock/number prefs). These require a bundled CLDR subset or a K/N i18n lib.
|
||||||
|
|
||||||
|
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`; needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. Inert until `PlatformDateFormat` is localized. **Nice-to-have (DatePicker/TimePicker).**
|
||||||
|
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
|
||||||
|
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker for localized DatePicker.**
|
||||||
|
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker for localized DatePicker.**
|
||||||
|
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
|
||||||
|
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
|
||||||
|
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
|
||||||
|
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SkiaParagraph.native.kt` hardcodes `textDirection = Ltr`, so `getParagraphDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
|
||||||
|
- No `NumberFormat` / currency / decimal-grouping actual exists; numbers render via `toString()` with `.` decimal and no grouping regardless of locale. **Cosmetic.**
|
||||||
|
|
||||||
|
## D. Font resolution
|
||||||
|
|
||||||
|
**Custom fonts via compose-resources work.** `org.jetbrains.compose.resources.Font(Res.font.x)`
|
||||||
|
loads the bytes and registers them with the project font registry (IconFont → NamedFont),
|
||||||
|
so the standard CMP way to bundle a font renders correctly.
|
||||||
|
|
||||||
|
- `FontFamily.Serif` / `FontFamily.Cursive` still collapse to the default sans — no bundled
|
||||||
|
serif/cursive font yet (`downloadNotoFonts` fetches Sans + SansMono only). Register one under
|
||||||
|
`generic:serif` / `generic:cursive` the same way to enable them. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP; the androidx `PlatformFontLoader` path (`ResourceFont` / `Font(bytes)` / `LoadedFontFamily`) doesn't load. Use the working compose-resources `Font()` instead. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)` (the renderer resolves by name, so harmless). **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
|
||||||
|
|
||||||
|
## E. Text rendering details
|
||||||
|
|
||||||
|
- `compose/ui/ui-text/src/nativeMain/.../text/SkiaParagraph.native.kt` · text `Shadow` + `TextDecoration` now render (the `paint(...)` overloads forward them to `ops.rebuildAndPaint`), but stroke `DrawStyle` and non-`SrcOver` `blendMode` are still accepted and ignored, so those never render on text. **Cosmetic.**
|
||||||
|
- `compose/foundation/.../text/StringHelpers.native.kt:31` and `compose/ui/ui/src/nativeMain/.../text/CharHelpers.native.kt:14` · `findPrecedingBreak`/`findFollowingBreak` (and `offsetByCodePoints`) do a codepoint/surrogate walk only, with no ICU grapheme clusters, so caret movement and backspace split combining marks and emoji ZWJ sequences. **Nice-to-have.**
|
||||||
|
|
||||||
|
## F. Drag and drop
|
||||||
|
|
||||||
|
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
|
||||||
|
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
|
||||||
|
|
||||||
|
## G. Window and platform info
|
||||||
|
|
||||||
|
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
|
||||||
|
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
|
||||||
|
|
||||||
|
## H. Skia renderer anomalies
|
||||||
|
|
||||||
|
Skia is the renderer everywhere, so its bugs are rarely tracked here. This one
|
||||||
|
surfaced while adding BlendMode coverage (`demo --blendtest`):
|
||||||
|
|
||||||
|
- `BlendMode.Multiply` renders wrong. A filled `drawRect` with opaque
|
||||||
|
`Color.Cyan` over opaque `Color.Yellow` reads back `(0,0,255)` blue; the
|
||||||
|
multiply formula can only yield `(0,255,0)` green for opaque cyan×yellow.
|
||||||
|
`Plus` and `Modulate` composite correctly on the same path, so
|
||||||
|
`paint.blendMode` IS applied — the fault is specific to `MULTIPLY` (likely a
|
||||||
|
Metal-backend / premultiply interaction in the graphics-layer flatten, not
|
||||||
|
the `BlendMode.toSkia()` map, which is correct). Needs isolating on the Skia
|
||||||
|
draw path. **Nice-to-have.**
|
||||||
|
|
||||||
|
## J. Needs further review (2026-07 bug audit)
|
||||||
|
|
||||||
|
An audit (parallel passes over the renderer and the text/input/event actuals)
|
||||||
|
surfaced these. The clear, contained bugs were fixed in the same pass; the items
|
||||||
|
here are left open because they are either invasive, risky, or a judgement call.
|
||||||
|
(The renderer-side fixes from that pass predate the single-Skia consolidation and
|
||||||
|
are no longer tracked here.)
|
||||||
|
|
||||||
|
Fixed in the audit (for reference, not open work): `Locale.region` no longer
|
||||||
|
returns the language for a single-subtag tag; and a `MOUSE_LEAVE` now clears
|
||||||
|
hover so a widget doesn't stay highlighted after the cursor exits.
|
||||||
|
|
||||||
|
Open — platform / input:
|
||||||
|
|
||||||
|
- **Pointer coords are truncated to Int before DPR scaling** (`SDL3EventMapper.kt` ~78-97, `LegacyPointerEvent.kt` x/y are `Int`, consumed in `ComposeWindow.kt:756`). On a 2× display a click at logical (100.9, 50.4) becomes physical (200,100) instead of (~201,~100), so hit-testing / caret placement quantizes to 2-physical-px steps near glyph boundaries. Fix means widening the pointer event to `Float` through the pipeline — invasive, hence deferred. **Review.**
|
||||||
|
- **A window created unfocused is still promoted to RESUMED** (`ComposeWindow.kt:575-576, 713-719`). `windowFocused`/`windowVisible` default `true`, so a second `Window {}` opened while another holds focus (or one opened minimized) reports `RESUMED` until SDL later delivers a focus/minimize event. Should query `SDL_GetWindowFlags` at creation. **Review.**
|
||||||
|
- **IME text-input area is only pushed on the first `TEXT_EDITING`** (`ComposeWindow.kt:803`; `updateImeArea()` not called on focus gain / first `StartTextInput` / caret move). The first candidate popup can appear at (0,0) before correcting. The rect math itself is correct. **Review.**
|
||||||
|
- **`Snapshot.sendApplyNotifications()` is called synchronously inside the global write observer** (`ComposeWindow.kt:143-146`). Compose Desktop's `GlobalSnapshotManager` deliberately defers it to a separate dispatch; calling it per-write is redundant (the main loop already calls it each frame) and risks re-entrancy during snapshot application. Verify against the runtime's guard before changing — behaviour-sensitive. **Review (risky).**
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Tooling
|
||||||
|
|
||||||
|
Helper scripts and workflows used to build, vendor, and verify Compose Desktop
|
||||||
|
Native. Each has a focused job; the deeper ones link to their own README.
|
||||||
|
|
||||||
|
For architecture, source-set layout, and vendoring rules, see
|
||||||
|
[CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## Native libraries
|
||||||
|
|
||||||
|
SDL3 is built from source as a static library and linked straight into the
|
||||||
|
executable — the windowing, input, and platform-integration layer. One script
|
||||||
|
does it on every OS. Output lands in the gitignored `libs/`; the version is
|
||||||
|
pinned in `scripts/build-sdl/build-sdl.properties`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/build-sdl/build-all.py # sdl3
|
||||||
|
python3 scripts/build-sdl/build-all.py sdl3 # rebuild the step
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
|
||||||
|
absent). Run it once per machine, or after bumping the pinned version.
|
||||||
|
|
||||||
|
## Vendoring upstream Compose
|
||||||
|
|
||||||
|
Most `androidx.compose.*` code is copied byte for byte from
|
||||||
|
`JetBrains/compose-multiplatform-core` into each module's gitignored
|
||||||
|
`src/vendor/`. A `compose-fork.txt` manifest per module lists which upstream
|
||||||
|
files it pulls; the pinned ref lives in `scripts/compose-fork/compose.properties`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/compose-fork/sync.sh # re-sync every module
|
||||||
|
scripts/compose-fork/sync.sh compose/ui/ui/compose-fork.txt # one module
|
||||||
|
```
|
||||||
|
|
||||||
|
Reach for this after a fresh checkout or a ref bump. Details:
|
||||||
|
[scripts/compose-fork/README.md](scripts/compose-fork/README.md).
|
||||||
|
|
||||||
|
Guardrails that keep the vendor tree honest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/compose-fork/check-vendor-clean.py # src/vendor matches the pinned refs
|
||||||
|
python3 scripts/compose-fork/check-vendor-drift.py # hand-edited "manual vendors" match their pin
|
||||||
|
```
|
||||||
|
|
||||||
|
`check-vendor-clean` fails if a hand-edit ever leaks into `src/vendor/`.
|
||||||
|
`check-vendor-drift` reads the `// VENDOR-BASE:` header on every edited copy and,
|
||||||
|
using the local upstream clone, reports whether the base actually changed since
|
||||||
|
the pin (needs reconciling) or is merely stale (safe to re-stamp). Run both on
|
||||||
|
every ref bump.
|
||||||
|
|
||||||
|
### Coverage against upstream
|
||||||
|
|
||||||
|
How much of upstream's public API the port covers is measured, not guessed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
|
||||||
|
python3 scripts/compose-coverage.py --missing ui-text # list uncovered declarations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### One-command gate
|
||||||
|
|
||||||
|
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
|
||||||
|
macOS or Linux it exercises the Skia renderer and exits non-zero on any
|
||||||
|
failure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/verify-mac.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs: the vendor drift and clean checks, a build of `:demo` and `:apidemo`,
|
||||||
|
the interaction probes, the parity sweep, a memory soak, and a frame-time spot
|
||||||
|
check. The Windows target is verified separately (see below).
|
||||||
|
|
||||||
|
### Parity: native vs JVM
|
||||||
|
|
||||||
|
`:demo` renders the same shared screens on two stacks: this port, and stock JVM
|
||||||
|
Compose Desktop. `parity.py` screenshots every screen on both and pixel-diffs
|
||||||
|
them, so a screen that diverges is a porting bug. It gates against per-screen
|
||||||
|
golden baselines.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/parity/parity.py # all screens
|
||||||
|
python3 scripts/parity/parity.py Buttons Shapes # a subset
|
||||||
|
python3 scripts/parity/parity.py --no-build # reuse the last renders
|
||||||
|
```
|
||||||
|
|
||||||
|
How to read the diff and the percentage: [scripts/parity/README.md](scripts/parity/README.md).
|
||||||
|
|
||||||
|
### Interaction probe
|
||||||
|
|
||||||
|
`probe.py` launches a native window, sends window-relative input (click, hover,
|
||||||
|
hold), and captures the client area. It packages the rigs used to reproduce
|
||||||
|
interaction bugs. Windows-only. Details:
|
||||||
|
[scripts/probe/README.md](scripts/probe/README.md).
|
||||||
|
|
||||||
|
### Frame profiler
|
||||||
|
|
||||||
|
Set `CDN_PROFILE=1` (or `CDN_PROFILE=<path>`) and run any app. Every couple of
|
||||||
|
seconds it writes per-phase main-loop timings (events, app pump, render, with
|
||||||
|
draw sub-phases and per-frame draw counters) to a file. `CDN_FORCERENDER=1`
|
||||||
|
renders every frame so steady-state timings read on an otherwise idle screen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo.kexe
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `present` is vsync-blocking, so profile on the target refresh rate before
|
||||||
|
drawing conclusions about a frame-rate gap.
|
||||||
|
|
||||||
|
### Demo probes
|
||||||
|
|
||||||
|
`:demo` ships headless regression probes driven by CLI flags, used by the
|
||||||
|
verify runbook and in isolation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
demo.kexe --nav3test # Navigation 3 + ViewModel + lifecycle
|
||||||
|
demo.kexe --backtest # predictive-back / BackHandler
|
||||||
|
demo.kexe --clicktest # pointer -> upstream clickable
|
||||||
|
demo.kexe --scrolltest # wheel -> scrollable
|
||||||
|
demo.kexe --multiwintest # multi-window lifecycle
|
||||||
|
demo.kexe --soaktest # memory soak (CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows smoke
|
||||||
|
|
||||||
|
The Mac runbook cannot cover the Windows target. Before shipping, on a Windows
|
||||||
|
host: build the mingwX64 executables, run `scripts/probe/`, and run a publish
|
||||||
|
dry run (only Windows compiles the full common-metadata variant table).
|
||||||
|
|
||||||
|
```bat
|
||||||
|
gradlew.bat :demo:runDebugExecutableMingwX64
|
||||||
|
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||||
|
```
|
||||||
|
|
||||||
|
mingwX64 renders through Skia via the **bitsycore skiko fork**, consumed from
|
||||||
|
GitHub Packages as `com.bitsycore.skiko:skiko:0.150.1-mingw.1` (macOS/Linux use
|
||||||
|
official Skiko). The runtime `skiko-windows-x64.dll` is auto-provisioned next
|
||||||
|
to the executable by the bridge plugin — no manual copy. The fork itself is
|
||||||
|
published by a separate GitHub Actions workflow in the fork repo, out of band
|
||||||
|
from this repo's release flow.
|
||||||
|
|
||||||
|
## Consuming the port
|
||||||
|
|
||||||
|
To build a third-party app against the published klibs, apply the bridge Gradle
|
||||||
|
plugin and declare official Compose Multiplatform coordinates; the plugin swaps
|
||||||
|
in the port's klibs on native desktop targets. Setup:
|
||||||
|
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||||
|
|
||||||
|
## Versioning and releasing
|
||||||
|
|
||||||
|
### Version map
|
||||||
|
|
||||||
|
Versions live in a few places, some of which move in lockstep. There is no
|
||||||
|
single file to edit; know which axis you are changing.
|
||||||
|
|
||||||
|
| Version | Where | Notes |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| Project release version | The git tag `vX.Y.Z`. `PUBLISH_VERSION` (from the tag) feeds `vPublishVersion` in `build.gradle.kts`, which strips the leading `v`. Group is `com.bitsycore.compose.sdl`. | Set by the tag, not edited by hand. A non-publish build is `0.0.0-SNAPSHOT`. |
|
||||||
|
| Vendored Compose (native side) | `COMPOSE_CORE_REF` and `COMPOSE_REF` in `scripts/compose-fork/compose.properties`, plus `compose` in `gradle/libs.versions.toml`. | Pin to a durable tag (not a `+dev` commit upstream may GC). Re-sync after changing. |
|
||||||
|
| JVM parity forcing | `vComposeJvmVersion` in `demo`, `apidemo`, and `material-symbols` `build.gradle.kts`. | Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published. |
|
||||||
|
| Skiko | `skiko` in `gradle/libs.versions.toml`. | macOS/Linux use official Skiko (`org.jetbrains.skiko`); mingwX64 uses the bitsycore fork (`com.bitsycore.skiko:skiko:0.150.1-mingw.1` from GitHub Packages), published out of band by the fork repo's own workflow. Must expose the `org.jetbrains.skiko.node` `RenderNode` / `GraphicsContext` API the vendored compose-core uses (the fork keeps upstream's `org.jetbrains.skiko.*` package names; only the Maven coord is rebranded). Verify with a throwaway `skikoRendererMain` compile if unsure. |
|
||||||
|
| SDL3 | `scripts/build-sdl/build-sdl.properties`. | Rebuild `libs/` with `build-all.py` after any change. |
|
||||||
|
| Bridge substituted version | Defaults to the bridge plugin's own published version; consumers override with the `composeDesktopNative.version` Gradle property. | The plugin publishes with the release tag, so a consumer on the matching plugin version resolves the right klibs automatically. |
|
||||||
|
|
||||||
|
### Bump the upstream Compose ref
|
||||||
|
|
||||||
|
Run this on each upstream bump; it is the flow that keeps the sync tax low.
|
||||||
|
|
||||||
|
1. Edit `COMPOSE_CORE_REF` / `COMPOSE_REF` in `compose.properties` (and `compose`
|
||||||
|
in `libs.versions.toml` if the coordinate version moved).
|
||||||
|
2. `scripts/compose-fork/sync.sh`
|
||||||
|
3. `python3 scripts/compose-fork/check-vendor-drift.py`. For any manual vendor
|
||||||
|
whose upstream base actually changed, reconcile by hand; otherwise re-stamp
|
||||||
|
its `// VENDOR-BASE:` header to the new ref.
|
||||||
|
4. Build and fix any fallout.
|
||||||
|
5. `scripts/verify-mac.sh` (green, including the soak and parity gates).
|
||||||
|
6. If a matching Compose version is now published to Maven, bump
|
||||||
|
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
|
||||||
|
7. Run WIN-SMOKE on a Windows host.
|
||||||
|
8. Re-seed parity baselines only if metrics legitimately moved
|
||||||
|
(`parity.py --update-baselines`), and record why.
|
||||||
|
|
||||||
|
### Cut a release
|
||||||
|
|
||||||
|
1. Complete the ref-bump flow above and confirm it is green. For a STABLE
|
||||||
|
release, `vComposeJvmVersion` should match the vendored ref (no skew).
|
||||||
|
2. On a Windows host, run `gradlew :desktop-native-window:compileCommonMainKotlinMetadata`.
|
||||||
|
Only Windows compiles the full common-metadata variant table, and the root
|
||||||
|
KotlinMultiplatform publications live there; a macOS-only publish leaves the
|
||||||
|
roots without mingwX64 variants (this bit v0.1.15).
|
||||||
|
3. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
|
||||||
|
|
||||||
|
What the publish job does, so you can read a failure:
|
||||||
|
|
||||||
|
- Each host publishes only its own target's publications to GitHub Packages
|
||||||
|
under `com.bitsycore.compose.sdl:*`. Windows additionally publishes the root
|
||||||
|
KotlinMultiplatform metadata, the `jvm` publication, and the bridge plugin
|
||||||
|
(only Windows declares every target).
|
||||||
|
- The `MODULES` list in the workflow must stay in sync with the modules in
|
||||||
|
`settings.gradle.kts`.
|
||||||
|
- A partial publish (for example a dropped connection mid-upload) deletes this
|
||||||
|
host's half-published versions for the tag and retries, so a rerun is safe.
|
||||||
|
- The demo and apidemo release binaries are zipped and attached to the GitHub
|
||||||
|
Release for the tag.
|
||||||
|
|
||||||
|
4. Verify a consumer build against the new version resolves through the bridge.
|
||||||
@@ -1,76 +1,75 @@
|
|||||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
import com.bitsycore.compose.sdl.build.registerComposeFontBundling
|
||||||
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
|
|
||||||
|
|
||||||
// :apidemo — a Postman-style REST API manager built on the compose-desktop-native
|
|
||||||
// stack. Uses Ktor with the Curl engine on every desktop target (bundled
|
|
||||||
// libcurl: Schannel on Windows, OpenSSL on macOS/Linux),
|
|
||||||
// kotlinx.serialization for the savable "packs", and okio for file I/O.
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.kotlin.plugin.compose)
|
alias(libs.plugins.kotlin.plugin.compose)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
alias(libs.plugins.kotlin.plugin.serialization)
|
alias(libs.plugins.kotlin.plugin.serialization)
|
||||||
|
id("com.bitsycore.compose-desktop-native.bridge")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
val isHostMingwCompatible = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Targets
|
||||||
|
// ==================
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
linuxArm64()
|
jvm()
|
||||||
linuxX64()
|
|
||||||
macosArm64()
|
|
||||||
if (vHostSupportsMingw) mingwX64()
|
|
||||||
|
|
||||||
targets.withType<KotlinNativeTarget>().all {
|
buildList {
|
||||||
val isMingw = name == "mingwX64"
|
add(linuxArm64())
|
||||||
val isLinux = name.startsWith("linux")
|
add(linuxX64())
|
||||||
val isApple = name.startsWith("macos") || name.startsWith("ios")
|
add(macosArm64())
|
||||||
binaries.executable {
|
if (isHostMingwCompatible) add(mingwX64())
|
||||||
if (buildType == NativeBuildType.RELEASE) {
|
}.forEach {
|
||||||
// --gc-sections is a GNU ld/lld flag; Apple's ld64 rejects it
|
it.binaries {
|
||||||
// (it dead-strips at -O anyway). Only pass it on GNU-ld targets.
|
executable {
|
||||||
if (!isApple) linkerOpts("-Wl,--gc-sections")
|
when {
|
||||||
compilerOptions {
|
target.name == "mingwX64" -> linkerOpts(
|
||||||
freeCompilerArgs.add("-opt")
|
// crypt32: client-cert (mTLS) import into the Windows cert store.
|
||||||
|
"-lcrypt32",
|
||||||
|
"-Wl,--gc-sections", "-Wl,-s",
|
||||||
|
// GUI subsystem (no console window), keeping the C `main` entry.
|
||||||
|
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
|
||||||
|
)
|
||||||
|
|
||||||
|
target.name.startsWith("linux") -> linkerOpts(
|
||||||
|
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
|
||||||
|
"-lfontconfig", "-lGL", "-lX11",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entryPoint = "apidemo.main"
|
|
||||||
// SDL3 + SDL3_ttf + SDL3_image + FreeType + image codecs come from
|
|
||||||
// :ui's cinterop klibs (staticLibraries directive in each .def); the
|
|
||||||
// system libs they need are in sdl3.def's linkerOpts.<target>. Only
|
|
||||||
// shell-level flags and app-specific extras (crypt32 for mTLS) live
|
|
||||||
// here. Ktor's Curl engine bundles its own libcurl + TLS stack.
|
|
||||||
if (isMingw) linkerOpts(
|
|
||||||
// crypt32: client-cert (mTLS) import into the Windows cert store
|
|
||||||
// (CertOpenStore / PFXImportCertStore / CertAddCertificateContextToStore…).
|
|
||||||
"-lcrypt32",
|
|
||||||
// Code-shrink: drop unused sections and strip symbols.
|
|
||||||
"-Wl,--gc-sections", "-Wl,-s",
|
|
||||||
// GUI subsystem so launching the .exe pops no console window.
|
|
||||||
// Keep the standard C `main` entry — ld would otherwise default
|
|
||||||
// a GUI-subsystem PE to WinMainCRTStartup (needs WinMain) and
|
|
||||||
// fail to link, since Kotlin/Native emits `main`.
|
|
||||||
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
|
|
||||||
)
|
|
||||||
// Skia (default renderer) references the system graphics stack.
|
|
||||||
// K/N's LLD sysroot on linuxX64 doesn't include the host's multi-arch
|
|
||||||
// dir, so add the -L for both x86_64 and aarch64 (harmless if either
|
|
||||||
// is absent — LLD skips missing dirs).
|
|
||||||
if (isLinux) linkerOpts(
|
|
||||||
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
|
|
||||||
"-lfontconfig", "-lGL", "-lX11",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyDefaultHierarchyTemplate()
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":window"))
|
val vComposeJvmVersion = libs.versions.compose.get()
|
||||||
implementation(project(":material3"))
|
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
|
||||||
|
|
||||||
|
// Official Maven coords for everything the shared UI touches; native
|
||||||
|
// configurations substitute them for the port modules automatically
|
||||||
|
// (root bridge in-repo, the bridge plugin for consumers).
|
||||||
|
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-unit:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-geometry:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-util:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.foundation:foundation:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.foundation:foundation-layout:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.animation:animation:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.animation:animation-core:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.material3:material3:$vComposeM3JvmVersion")
|
||||||
|
|
||||||
|
// Common Material Symbols Utility
|
||||||
implementation(project(":material-symbols"))
|
implementation(project(":material-symbols"))
|
||||||
|
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
@@ -80,17 +79,19 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Single HTTP engine on every desktop target: Ktor's Curl engine. It
|
nativeMain {
|
||||||
// bundles a static libcurl per target (Schannel on Windows, OpenSSL on
|
dependencies {
|
||||||
// macOS/Linux) embedded in its cinterop klib — no runtime DLL, one copy.
|
implementation(project(":desktop-native-window"))
|
||||||
// We use Curl everywhere (not WinHttp/Darwin) so that an upcoming
|
implementation(libs.ktor.client.curl)
|
||||||
// client-certificate (mTLS) path — which calls the same bundled libcurl
|
}
|
||||||
// directly, since Ktor's engines expose no client-cert API — shares the
|
}
|
||||||
// exact same TLS stack as ordinary requests.
|
jvmMain {
|
||||||
if (vHostSupportsMingw) getByName("mingwX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
dependencies {
|
||||||
getByName("macosArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
implementation(compose.desktop.currentOs)
|
||||||
getByName("linuxX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
implementation(libs.ktor.client.cio)
|
||||||
getByName("linuxArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
@@ -99,181 +100,46 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SDL3 / SDL3_ttf / SDL3_image / FreeType are linked statically into the
|
compose.desktop {
|
||||||
// executable (see the mingw linkerOpts above), so there are no runtime DLLs to
|
application {
|
||||||
// bundle — the distributable is just <app>.exe + data.kres.
|
mainClass = "apidemo.MainJvmKt"
|
||||||
val variants = listOf("debug", "release")
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Bundle the default font (data.kres) next to each native executable
|
|
||||||
// ==================
|
|
||||||
// Default UI font (Noto Sans) + monospace body font (Noto Sans Mono) are
|
|
||||||
// downloaded by :core and exposed through these generic "extra" handles (the
|
|
||||||
// same handshake the :material-symbols:* modules use). The renderers load
|
|
||||||
// NotoSans (variable) as the default; apidemo registers NotoSansMono (variable)
|
|
||||||
// with IconFont under "noto-mono" for the body editor.
|
|
||||||
val nativeTargets = listOf("macosArm64", "linuxX64", "linuxArm64", "mingwX64")
|
|
||||||
// :core downloads the variable Noto fonts into its build/fonts. Reference them
|
|
||||||
// by layout (lazy providers, no evaluation of :core needed) and depend on the
|
|
||||||
// download task by path so ordering doesn't rely on :core being configured first.
|
|
||||||
val coreBuildDir = rootProject.project(":ui").layout.buildDirectory
|
|
||||||
val notoSansFile = coreBuildDir.file("fonts/NotoSans.ttf")
|
|
||||||
val notoMonoFile = coreBuildDir.file("fonts/NotoSansMono.ttf")
|
|
||||||
|
|
||||||
// Which Material Symbols style(s) this app uses = which style call sites appear
|
|
||||||
// in its Kotlin sources. `MaterialSymbolsOutlined(...)` → outlined font,
|
|
||||||
// `MaterialSymbolsRounded(...)` → rounded, `MaterialSymbolsSharp(...)` → sharp.
|
|
||||||
// The Zip task below downloads + bundles ONLY the fonts a style actually
|
|
||||||
// referenced — an app that only ever calls MaterialSymbolsOutlined never pays
|
|
||||||
// for Rounded/Sharp .ttf bytes (each is ~600KB uncompressed).
|
|
||||||
val kAllStyles = listOf(
|
|
||||||
"Outlined" to Regex("\\bMaterialSymbolsOutlined\\b"),
|
|
||||||
"Rounded" to Regex("\\bMaterialSymbolsRounded\\b"),
|
|
||||||
"Sharp" to Regex("\\bMaterialSymbolsSharp\\b"),
|
|
||||||
)
|
|
||||||
fun detectUsedStyles(): List<String> {
|
|
||||||
val vSrcRoot = layout.projectDirectory.dir("src").asFile
|
|
||||||
val vUsed = mutableSetOf<String>()
|
|
||||||
if (!vSrcRoot.exists()) return emptyList()
|
|
||||||
vSrcRoot.walk().filter { it.isFile && it.extension == "kt" }.forEach { vFile ->
|
|
||||||
val vText = vFile.readText()
|
|
||||||
for ((vStyle, vRegex) in kAllStyles) {
|
|
||||||
if (vStyle !in vUsed && vRegex.containsMatchIn(vText)) vUsed.add(vStyle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return vUsed.toList()
|
|
||||||
}
|
|
||||||
val vUsedStyles: List<String> = detectUsedStyles()
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Material Symbols subsetting (-PsubsetIcons=true)
|
|
||||||
// ==================
|
|
||||||
// scripts/subset-material-symbols.py scans this module's Kotlin sources for
|
|
||||||
// MaterialSymbols.<Name> references and writes build/icons/usage-codepoint.txt.
|
|
||||||
// When -PsubsetIcons=true is passed, each icon-font module's downloaded TTF
|
|
||||||
// is hb-subset'd against that file and the Zip below bundles the trimmed
|
|
||||||
// font instead of the full one — typically 80-95% smaller.
|
|
||||||
//
|
|
||||||
// Needs: python3 + hb-subset on PATH (brew install harfbuzz /
|
|
||||||
// apt install harfbuzz-utils).
|
|
||||||
val subsetIcons = (findProperty("subsetIcons") as? String)?.toBoolean() ?: false
|
|
||||||
val iconsBuildDir = layout.buildDirectory.dir("icons")
|
|
||||||
|
|
||||||
val findMaterialSymbolsUsage = tasks.register<Exec>("findMaterialSymbolsUsage") {
|
|
||||||
description = "Scan src/ for MaterialSymbols.<Name> usages → usage-codepoint.txt."
|
|
||||||
val vScript = rootProject.layout.projectDirectory.file("scripts/subset-material-symbols.py").asFile
|
|
||||||
val vConstants = rootProject.project(":material-symbols").layout.projectDirectory
|
|
||||||
.file("src/commonMain/kotlin/com/compose/sdl/icons/MaterialSymbols.kt").asFile
|
|
||||||
val vUsageFile = iconsBuildDir.get().file("usage-codepoint.txt").asFile
|
|
||||||
inputs.files(fileTree("src") { include("**/*.kt") })
|
|
||||||
inputs.file(vConstants)
|
|
||||||
outputs.file(vUsageFile)
|
|
||||||
commandLine(
|
|
||||||
"python3", vScript.absolutePath,
|
|
||||||
"--src", layout.projectDirectory.dir("src").asFile.absolutePath,
|
|
||||||
"--constants", vConstants.absolutePath,
|
|
||||||
"--out", vUsageFile.absolutePath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Register `subsetMaterialSymbols<Style>` for a style — hb-subsets the style's
|
compose.desktop.native {
|
||||||
downloaded TTF (owned by :material-symbols) to only the codepoints in
|
entryPoint = "apidemo.main"
|
||||||
usage-codepoint.txt and writes the output under this app's build/icons/ dir. */
|
icon {
|
||||||
fun registerSubsetTask(inStyle: String): TaskProvider<*> {
|
light.from(
|
||||||
val vSymbolsProject = rootProject.project(":material-symbols")
|
layout.projectDirectory.file("icons/voltic-icon-32.png"),
|
||||||
return tasks.register("subsetMaterialSymbols$inStyle") {
|
layout.projectDirectory.file("icons/voltic-icon-128.png"),
|
||||||
description = "hb-subset the $inStyle Material Symbols font to icons actually used."
|
)
|
||||||
@Suppress("UNCHECKED_CAST")
|
dark.from(
|
||||||
val vInputProvider = vSymbolsProject.extra["iconFontFile$inStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
|
layout.projectDirectory.file("icons/voltic-icon-dark-32.png"),
|
||||||
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
|
layout.projectDirectory.file("icons/voltic-icon-dark-128.png"),
|
||||||
val vOut = iconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
|
)
|
||||||
val vUsage = iconsBuildDir.get().file("usage-codepoint.txt").asFile
|
exeIcon.from(
|
||||||
inputs.file(vInputProvider)
|
listOf(16, 32, 48, 64, 128, 256).map {
|
||||||
inputs.file(vUsage)
|
layout.projectDirectory.file("icons/voltic-icon-$it.png")
|
||||||
outputs.file(vOut)
|
|
||||||
dependsOn(findMaterialSymbolsUsage)
|
|
||||||
dependsOn(vDownloadTask)
|
|
||||||
doLast {
|
|
||||||
val vCodepoints = vUsage.readLines()
|
|
||||||
.filter { it.isNotBlank() && !it.startsWith("#") && it.contains("=") }
|
|
||||||
.map { it.substringAfter("=").trim().removePrefix("0x").removePrefix("0X") }
|
|
||||||
if (vCodepoints.isEmpty()) throw GradleException(
|
|
||||||
"usage-codepoint.txt has no entries — refusing to subset to an empty font.")
|
|
||||||
val vUnicodes = vCodepoints.joinToString(",") { "U+$it" }
|
|
||||||
vOut.parentFile.mkdirs()
|
|
||||||
val vInputFile = vInputProvider.get().asFile
|
|
||||||
val vBefore = vInputFile.length()
|
|
||||||
// ProcessBuilder rather than project.exec — the latter isn't
|
|
||||||
// available inside doLast on Gradle 9 (only ExecOperations via
|
|
||||||
// an injected service is, which would mean a buildSrc plugin).
|
|
||||||
// hb-subset (HarfBuzz) is optional: if it isn't on PATH — common on
|
|
||||||
// Windows — bundle the full font instead of failing the build.
|
|
||||||
val vProc = try {
|
|
||||||
ProcessBuilder(
|
|
||||||
"hb-subset",
|
|
||||||
vInputFile.absolutePath,
|
|
||||||
"-o", vOut.absolutePath,
|
|
||||||
"--unicodes=$vUnicodes",
|
|
||||||
).redirectErrorStream(true).start()
|
|
||||||
} catch (e: java.io.IOException) {
|
|
||||||
vInputFile.copyTo(vOut, overwrite = true)
|
|
||||||
logger.warn("[subset $inStyle] hb-subset not found on PATH — bundling the full font " +
|
|
||||||
"(${vBefore / 1024}KB). Install harfbuzz to shrink it (brew install harfbuzz / " +
|
|
||||||
"pacman -S mingw-w64-x86_64-harfbuzz / apt install harfbuzz-utils).")
|
|
||||||
return@doLast
|
|
||||||
}
|
}
|
||||||
val vOutput = vProc.inputStream.bufferedReader().readText()
|
)
|
||||||
val vCode = vProc.waitFor()
|
embedWindowsIcon = providers.gradleProperty("embedWindowsIcon").map { it.toBoolean() }
|
||||||
if (vCode != 0) throw GradleException("hb-subset failed (exit $vCode):\n$vOutput")
|
|
||||||
val vAfter = vOut.length()
|
|
||||||
val vPct = if (vBefore == 0L) 0 else ((100 - 100 * vAfter / vBefore)).coerceAtLeast(0)
|
|
||||||
logger.lifecycle("[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · ${vCodepoints.size} glyphs kept")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-create subset tasks per USED style when enabled; the Zip below uses
|
// ==================
|
||||||
// them as the font source. Built outside the variant×target loop so each
|
// MARK: Fonts (shared pipeline — buildSrc ComposeFontBundling.kt)
|
||||||
// style is subset once and reused across all bundle variants.
|
// ==================
|
||||||
val subsetTasksByStyle: Map<String, TaskProvider<*>> =
|
|
||||||
if (subsetIcons) vUsedStyles.associateWith { registerSubsetTask(it) } else emptyMap()
|
|
||||||
|
|
||||||
for (variant in variants) {
|
registerComposeFontBundling {
|
||||||
for (target in nativeTargets) {
|
bundleNotoSans = true
|
||||||
val variantCap = variant.replaceFirstChar { it.uppercase() }
|
// The mono body font is loaded through the app's own seam (Fonts.kt), not via
|
||||||
val targetCap = target.replaceFirstChar { it.uppercase() }
|
// FontFamily.Monospace call sites — auto-detection can't see it, bundle explicitly.
|
||||||
val outDir = layout.buildDirectory.dir("bin/$target/${variant}Executable")
|
bundleNotoSansMono = true
|
||||||
val copyTask = tasks.register<Zip>("copy${variantCap}ComposeResources${targetCap}") {
|
bundleMaterialSymbols = true
|
||||||
archiveFileName.set("data.kres")
|
enableIconSubsetting = true
|
||||||
destinationDirectory.set(outDir)
|
}
|
||||||
entryCompression = if ((findProperty("compressResources") as? String)?.toBoolean() == true) org.gradle.api.tasks.bundling.ZipEntryCompression.DEFLATED else org.gradle.api.tasks.bundling.ZipEntryCompression.STORED
|
|
||||||
from(notoSansFile) { into("font") }
|
// Parity-window icon (cosmetic : the harness compares the canvas, not chrome).
|
||||||
from(notoMonoFile) { into("font") }
|
tasks.named<ProcessResources>("jvmProcessResources") {
|
||||||
dependsOn(":ui:downloadNotoFonts")
|
from(layout.projectDirectory.file("icons/voltic-icon-256.png")) { into("icon") }
|
||||||
val vSymbolsProject = rootProject.project(":material-symbols")
|
|
||||||
for (vStyle in vUsedStyles) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val vFontFile = vSymbolsProject.extra["iconFontFile$vStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
|
|
||||||
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$vStyle"] as TaskProvider<*>
|
|
||||||
val vSubsetTask = subsetTasksByStyle[vStyle]
|
|
||||||
if (vSubsetTask != null) {
|
|
||||||
// Trimmed font: depend on the subset task and bundle its output
|
|
||||||
// with the original filename so the runtime IconFont registration
|
|
||||||
// doesn't need to know the bundle was subset.
|
|
||||||
val vOriginalName = vFontFile.get().asFile.name
|
|
||||||
from(vSubsetTask.get().outputs.files) {
|
|
||||||
into("font")
|
|
||||||
rename { vOriginalName }
|
|
||||||
}
|
|
||||||
dependsOn(vSubsetTask)
|
|
||||||
} else {
|
|
||||||
from(vFontFile) { into("font") }
|
|
||||||
}
|
|
||||||
dependsOn(vDownloadTask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listOf("link${variantCap}Executable${targetCap}", "run${variantCap}Executable${targetCap}").forEach { taskName ->
|
|
||||||
tasks.matching { it.name == taskName }.configureEach { dependsOn(copyTask) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 605 B |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 367 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 432 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -2,7 +2,8 @@ package apidemo
|
|||||||
|
|
||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
import platform.CoreFoundation.*
|
import platform.CoreFoundation.*
|
||||||
import platform.Foundation.*
|
import platform.Foundation.CFBridgingRelease
|
||||||
|
import platform.Foundation.NSString
|
||||||
import platform.Security.*
|
import platform.Security.*
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
@@ -10,173 +11,173 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
// macOS: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
// macOS: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
||||||
// certificate and key files directly — no certificate-store dance needed.
|
// certificate and key files directly — no certificate-store dance needed.
|
||||||
|
|
||||||
/* Point libcurl straight at the certificate / key files. */
|
/** Point libcurl straight at the certificate / key files. */
|
||||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
||||||
PreparedCert(
|
PreparedCert(
|
||||||
sslCert = inReq.certPath,
|
sslCert = inReq.certPath,
|
||||||
sslCertType = inReq.certFormat.curlName,
|
sslCertType = inReq.certFormat.curlName,
|
||||||
sslKey = inReq.keyPath.ifBlank { null },
|
sslKey = inReq.keyPath.ifBlank { null },
|
||||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||||
cleanup = {},
|
cleanup = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
/* No temporary certificate store off Windows. */
|
/** No temporary certificate store off Windows. */
|
||||||
actual fun sweepTempClientCerts() {}
|
actual fun sweepTempClientCerts() {}
|
||||||
|
|
||||||
/* Continue the server's chain with intermediates / roots pulled from the
|
/** Continue the server's chain with intermediates / roots pulled from the
|
||||||
macOS Keychain — same UX as the Windows path does via the trust store.
|
macOS Keychain — same UX as the Windows path does via the trust store.
|
||||||
Falls back to the name-only placeholder if Apple's APIs can't resolve
|
Falls back to the name-only placeholder if Apple's APIs can't resolve
|
||||||
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
|
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
|
||||||
in any keychain). */
|
in any keychain). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
|
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
|
||||||
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
||||||
?: return serverChainWithIssuerName(inServerCerts)
|
?: return serverChainWithIssuerName(inServerCerts)
|
||||||
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
|
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
|
||||||
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Chain walk via Security.framework
|
// Chain walk via Security.framework
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Build the chain by handing the leaf to SecTrust + evaluating against
|
/** Build the chain by handing the leaf to SecTrust + evaluating against
|
||||||
an SSL policy — Apple then walks Keychain, pulling intermediates and
|
an SSL policy — Apple then walks Keychain, pulling intermediates and
|
||||||
the root in. Returns the resolved chain with fromServer=true for the
|
the root in. Returns the resolved chain with fromServer=true for the
|
||||||
first inServerCount entries (those came down on the wire) and false
|
first inServerCount entries (those came down on the wire) and false
|
||||||
for anything the OS added. */
|
for anything the OS added. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun osChainApple(
|
private fun osChainApple(
|
||||||
inLeafPem: String,
|
inLeafPem: String,
|
||||||
inServerCount: Int,
|
inServerCount: Int,
|
||||||
inServerCerts: List<List<Pair<String, String>>>,
|
inServerCerts: List<List<Pair<String, String>>>,
|
||||||
): List<ChainCert> {
|
): List<ChainCert> {
|
||||||
// Parse every cert the server presented, not just the leaf — Apple's
|
// Parse every cert the server presented, not just the leaf — Apple's
|
||||||
// chain builder uses them as candidate intermediates when stitching to
|
// chain builder uses them as candidate intermediates when stitching to
|
||||||
// a root, instead of relying solely on AIA-fetch which is off by default.
|
// a root, instead of relying solely on AIA-fetch which is off by default.
|
||||||
val vServerCerts = inServerCerts.mapNotNull {
|
val vServerCerts = inServerCerts.mapNotNull {
|
||||||
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
|
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
|
||||||
}
|
}
|
||||||
if (vServerCerts.isEmpty()) return emptyList()
|
if (vServerCerts.isEmpty()) return emptyList()
|
||||||
|
|
||||||
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
|
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
|
||||||
val vCertArray = createCFArray(vCertRefs)
|
val vCertArray = createCFArray(vCertRefs)
|
||||||
|
|
||||||
// SSL policy lets evaluateTrust pull a root out of Keychain.
|
// SSL policy lets evaluateTrust pull a root out of Keychain.
|
||||||
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
|
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
|
||||||
vCertRefs.forEach { CFRelease(it) }
|
vCertRefs.forEach { CFRelease(it) }
|
||||||
CFRelease(vCertArray)
|
CFRelease(vCertArray)
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
|
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
|
||||||
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
|
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
|
||||||
CFRelease(vPolicy)
|
CFRelease(vPolicy)
|
||||||
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
|
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
|
||||||
vCertRefs.forEach { CFRelease(it) }
|
vCertRefs.forEach { CFRelease(it) }
|
||||||
CFRelease(vCertArray)
|
CFRelease(vCertArray)
|
||||||
nativeHeap.free(vTrustVar)
|
nativeHeap.free(vTrustVar)
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val vTrust = vTrustVar.value!!
|
val vTrust = vTrustVar.value!!
|
||||||
|
|
||||||
// Evaluate even if it fails — we still want the partial chain back so
|
// Evaluate even if it fails — we still want the partial chain back so
|
||||||
// the UI can show what it could resolve. SecTrustEvaluateWithError
|
// the UI can show what it could resolve. SecTrustEvaluateWithError
|
||||||
// stops at the highest cert it found.
|
// stops at the highest cert it found.
|
||||||
val vErr = nativeHeap.alloc<CFErrorRefVar>()
|
val vErr = nativeHeap.alloc<CFErrorRefVar>()
|
||||||
SecTrustEvaluateWithError(vTrust, vErr.ptr)
|
SecTrustEvaluateWithError(vTrust, vErr.ptr)
|
||||||
vErr.value?.let { CFRelease(it) }
|
vErr.value?.let { CFRelease(it) }
|
||||||
nativeHeap.free(vErr)
|
nativeHeap.free(vErr)
|
||||||
|
|
||||||
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
|
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
|
||||||
val vResolved = ArrayList<ChainCert>(vCount)
|
val vResolved = ArrayList<ChainCert>(vCount)
|
||||||
val vSubjects = ArrayList<String>(vCount)
|
val vSubjects = ArrayList<String>(vCount)
|
||||||
|
|
||||||
for (vI in 0 until vCount) {
|
for (vI in 0 until vCount) {
|
||||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||||
val vSubject = secSubjectSummary(vCert)
|
val vSubject = secSubjectSummary(vCert)
|
||||||
vSubjects.add(vSubject ?: "")
|
vSubjects.add(vSubject ?: "")
|
||||||
}
|
}
|
||||||
for (vI in 0 until vCount) {
|
for (vI in 0 until vCount) {
|
||||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||||
val vSubject = vSubjects[vI].ifBlank { null }
|
val vSubject = vSubjects[vI].ifBlank { null }
|
||||||
// The issuer is the next cert in the chain's subject (or this
|
// The issuer is the next cert in the chain's subject (or this
|
||||||
// cert's own subject if it's the self-signed root).
|
// cert's own subject if it's the self-signed root).
|
||||||
val vIssuer = when {
|
val vIssuer = when {
|
||||||
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
|
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
|
||||||
vSubject != null -> vSubject // root: self-signed
|
vSubject != null -> vSubject // root: self-signed
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val vPem = secCertPem(vCert)
|
val vPem = secCertPem(vCert)
|
||||||
val vFields = buildList {
|
val vFields = buildList {
|
||||||
vSubject?.let { add("Subject" to "CN=$it") }
|
vSubject?.let { add("Subject" to "CN=$it") }
|
||||||
vIssuer?.let { add("Issuer" to "CN=$it") }
|
vIssuer?.let { add("Issuer" to "CN=$it") }
|
||||||
vPem?.let { add("Cert" to it) }
|
vPem?.let { add("Cert" to it) }
|
||||||
}
|
}
|
||||||
vResolved.add(ChainCert(vFields, vI < inServerCount))
|
vResolved.add(ChainCert(vFields, vI < inServerCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
CFRelease(vTrust)
|
CFRelease(vTrust)
|
||||||
vCertRefs.forEach { CFRelease(it) }
|
vCertRefs.forEach { CFRelease(it) }
|
||||||
CFRelease(vCertArray)
|
CFRelease(vCertArray)
|
||||||
nativeHeap.free(vTrustVar)
|
nativeHeap.free(vTrustVar)
|
||||||
return vResolved
|
return vResolved
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SecCertificate from DER bytes. Returns null if the bytes aren't a
|
/** SecCertificate from DER bytes. Returns null if the bytes aren't a
|
||||||
valid X.509 cert. */
|
valid X.509 cert. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
|
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
|
||||||
val vData = inDer.usePinned { vPin ->
|
val vData = inDer.usePinned { vPin ->
|
||||||
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
|
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
|
||||||
} ?: return null
|
} ?: return null
|
||||||
val vCert = SecCertificateCreateWithData(null, vData)
|
val vCert = SecCertificateCreateWithData(null, vData)
|
||||||
CFRelease(vData)
|
CFRelease(vData)
|
||||||
return vCert
|
return vCert
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
|
/** CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
|
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
|
||||||
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
|
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
|
||||||
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
|
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
|
||||||
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
|
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
|
||||||
nativeHeap.free(vPtrs)
|
nativeHeap.free(vPtrs)
|
||||||
return vArr
|
return vArr
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Apple's "subject summary" — basically the CN, but falls back to the
|
/** Apple's "subject summary" — basically the CN, but falls back to the
|
||||||
organisation when no CN is present (matches what Keychain Access
|
organisation when no CN is present (matches what Keychain Access
|
||||||
displays in its cert list). */
|
displays in its cert list). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun secSubjectSummary(inCert: SecCertificateRef): String? {
|
private fun secSubjectSummary(inCert: SecCertificateRef): String? {
|
||||||
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
|
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
|
||||||
val vNs = CFBridgingRelease(vCf) as? NSString
|
val vNs = CFBridgingRelease(vCf) as? NSString
|
||||||
return vNs?.toString()
|
return vNs?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PEM string for a SecCertificate — base64 of the DER bytes wrapped
|
/** PEM string for a SecCertificate — base64 of the DER bytes wrapped
|
||||||
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
|
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
|
||||||
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
|
||||||
private fun secCertPem(inCert: SecCertificateRef): String? {
|
private fun secCertPem(inCert: SecCertificateRef): String? {
|
||||||
val vData = SecCertificateCopyData(inCert) ?: return null
|
val vData = SecCertificateCopyData(inCert) ?: return null
|
||||||
val vLen = CFDataGetLength(vData).toInt()
|
val vLen = CFDataGetLength(vData).toInt()
|
||||||
val vBytes = ByteArray(vLen)
|
val vBytes = ByteArray(vLen)
|
||||||
val vPtr = CFDataGetBytePtr(vData)
|
val vPtr = CFDataGetBytePtr(vData)
|
||||||
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
|
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
|
||||||
CFRelease(vData)
|
CFRelease(vData)
|
||||||
val vB64 = Base64.Default.encode(vBytes)
|
val vB64 = Base64.encode(vBytes)
|
||||||
val vWrapped = vB64.chunked(64).joinToString("\n")
|
val vWrapped = vB64.chunked(64).joinToString("\n")
|
||||||
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
|
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Decode a PEM block to DER bytes, or pass DER bytes through. */
|
/** Decode a PEM block to DER bytes, or pass DER bytes through. */
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
private fun derFromPem(inPem: String): ByteArray? {
|
private fun derFromPem(inPem: String): ByteArray? {
|
||||||
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
|
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
|
||||||
val vBody = inPem.lineSequence()
|
val vBody = inPem.lineSequence()
|
||||||
.filterNot { it.startsWith("-----") }
|
.filterNot { it.startsWith("-----") }
|
||||||
.joinToString("") { it.trim() }
|
.joinToString("") { it.trim() }
|
||||||
return runCatching { Base64.Default.decode(vBody) }.getOrNull()
|
return runCatching { Base64.decode(vBody) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
// MARK: App-wide constants + pure color / asset mappers
|
// MARK: App-wide constants + pure color / asset mappers
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
// Amber used to flag undefined {{variables}} and unsaved-changes warnings.
|
|
||||||
internal val kWarnColor = Color(0xFFFFAB00)
|
|
||||||
|
|
||||||
// SDL scancodes used for app keyboard shortcuts.
|
|
||||||
internal const val kScS = 22
|
|
||||||
internal const val kScN = 17
|
|
||||||
internal const val kScW = 26
|
|
||||||
internal const val kScEnter = 40
|
|
||||||
internal const val kScKpEnter = 88
|
|
||||||
internal const val kScEscape = 41
|
|
||||||
|
|
||||||
internal fun methodColor(inM: ReqMethod): Color = when (inM) {
|
internal fun methodColor(inM: ReqMethod): Color = when (inM) {
|
||||||
ReqMethod.GET -> Color(0xFF4C9AFF)
|
ReqMethod.GET -> Color(0xFF4C9AFF)
|
||||||
ReqMethod.POST -> Color(0xFF36B37E)
|
ReqMethod.POST -> Color(0xFF36B37E)
|
||||||
@@ -35,11 +24,11 @@ internal val kPackColors: List<Long> = listOf(
|
|||||||
0xFFFF7043, 0xFF8D6E63, 0xFF78909C, 0xFFBDBDBD, 0xFF5C7CFA,
|
0xFFFF7043, 0xFF8D6E63, 0xFF78909C, 0xFFBDBDBD, 0xFF5C7CFA,
|
||||||
)
|
)
|
||||||
|
|
||||||
/* The palette colour for a 1-based pack-colour index, or null when unset. */
|
/** The palette colour for a 1-based pack-colour index, or null when unset. */
|
||||||
internal fun packColor(inIndex: Int): Color? =
|
internal fun packColor(inIndex: Int): Color? =
|
||||||
if (inIndex in 1..kPackColors.size) Color(kPackColors[inIndex - 1]) else null
|
if (inIndex in 1..kPackColors.size) Color(kPackColors[inIndex - 1]) else null
|
||||||
|
|
||||||
/* Default save name for an image response, by content type. */
|
/** Default save name for an image response, by content type. */
|
||||||
internal fun imageFileName(inContentType: String?): String = when {
|
internal fun imageFileName(inContentType: String?): String = when {
|
||||||
inContentType == null -> "image.bin"
|
inContentType == null -> "image.bin"
|
||||||
inContentType.contains("png", ignoreCase = true) -> "image.png"
|
inContentType.contains("png", ignoreCase = true) -> "image.png"
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Working state (session / pack / request / tabs / drag)
|
// MARK: Working state (session / pack / request / tabs / drag)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
internal class HistoryEntry(val method: ReqMethod, val url: String, val status: Int, val timeMs: Long, val request: ApiRequest)
|
internal class HistoryEntry(
|
||||||
|
val method: ReqMethod,
|
||||||
|
val url: String,
|
||||||
|
val status: Int,
|
||||||
|
val timeMs: Long,
|
||||||
|
val request: ApiRequest
|
||||||
|
)
|
||||||
|
|
||||||
/* One request plus its live session state — response, in-flight job (for
|
/** One request plus its live session state — response, in-flight job (for
|
||||||
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
|
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
|
||||||
open-tab strip reference the same request without index juggling. */
|
open-tab strip reference the same request without index juggling. */
|
||||||
internal class ReqState(inInitial: ApiRequest) {
|
internal class ReqState(inInitial: ApiRequest) {
|
||||||
var req by mutableStateOf(inInitial)
|
var req by mutableStateOf(inInitial)
|
||||||
var response by mutableStateOf<ApiResponse?>(null)
|
var response by mutableStateOf<ApiResponse?>(null)
|
||||||
@@ -26,6 +28,7 @@ internal class ReqState(inInitial: ApiRequest) {
|
|||||||
var preview by mutableStateOf(false) // panel 4 showing the resolved, not-yet-sent request
|
var preview by mutableStateOf(false) // panel 4 showing the resolved, not-yet-sent request
|
||||||
var sentReq by mutableStateOf<ApiRequest?>(null) // resolved request actually sent (Request tab)
|
var sentReq by mutableStateOf<ApiRequest?>(null) // resolved request actually sent (Request tab)
|
||||||
var imageKey: String? = null // memory-resource key when the response is an image
|
var imageKey: String? = null // memory-resource key when the response is an image
|
||||||
|
|
||||||
// null = auto-detect from Content-Type each frame; non-null = user-pinned override.
|
// null = auto-detect from Content-Type each frame; non-null = user-pinned override.
|
||||||
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
|
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
|
||||||
var bodyWrap by mutableStateOf(true) // body view: true = soft-wrap, false = no wrap + horizontal scroll
|
var bodyWrap by mutableStateOf(true) // body view: true = soft-wrap, false = no wrap + horizontal scroll
|
||||||
@@ -35,14 +38,21 @@ internal class ReqState(inInitial: ApiRequest) {
|
|||||||
var chainUrl by mutableStateOf("") // resolved URL the chain was fetched from
|
var chainUrl by mutableStateOf("") // resolved URL the chain was fetched from
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One open pack: its file path (null = never saved), a dirty flag (edits since
|
/** One open pack: its file path (null = never saved), a dirty flag (edits since
|
||||||
last file save), and its own requests, variables and open-tab strip. Switching
|
last file save), and its own requests, variables and open-tab strip. Switching
|
||||||
packs swaps the whole working set. */
|
packs swaps the whole working set. */
|
||||||
internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpenTabs: List<Int> = emptyList(), inActive: Int = -1) {
|
internal class PackState(
|
||||||
|
inPack: Pack,
|
||||||
|
inPath: String?,
|
||||||
|
inDirty: Boolean,
|
||||||
|
inOpenTabs: List<Int> = emptyList(),
|
||||||
|
inActive: Int = -1
|
||||||
|
) {
|
||||||
var name by mutableStateOf(inPack.name)
|
var name by mutableStateOf(inPack.name)
|
||||||
var path by mutableStateOf(inPath)
|
var path by mutableStateOf(inPath)
|
||||||
var dirty by mutableStateOf(inDirty)
|
var dirty by mutableStateOf(inDirty)
|
||||||
var color by mutableStateOf(inPack.color)
|
var color by mutableStateOf(inPack.color)
|
||||||
|
|
||||||
// A linked copy mirrors its source's requests read-only; non-linked packs own
|
// A linked copy mirrors its source's requests read-only; non-linked packs own
|
||||||
// their requests. `requests` resolves to whichever applies.
|
// their requests. `requests` resolves to whichever applies.
|
||||||
var linkedSource by mutableStateOf<PackState?>(null)
|
var linkedSource by mutableStateOf<PackState?>(null)
|
||||||
@@ -52,16 +62,21 @@ internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpen
|
|||||||
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
|
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
|
||||||
get() = linkedSource?.requests ?: fOwnRequests
|
get() = linkedSource?.requests ?: fOwnRequests
|
||||||
val isLinked: Boolean get() = linkedSource != null
|
val isLinked: Boolean get() = linkedSource != null
|
||||||
var parent: PackState? = null // enclosing pack (null = top-level / root)
|
var parent: PackState? =
|
||||||
|
null // enclosing pack (null = top-level / root)
|
||||||
val subPacks = mutableStateListOf<PackState>().apply {
|
val subPacks = mutableStateListOf<PackState>().apply {
|
||||||
inPack.subPacks.forEach { add(PackState(it, null, false).also { vSp -> vSp.parent = this@PackState }) }
|
inPack.subPacks.forEach { add(PackState(it, null, false).also { vSp -> vSp.parent = this@PackState }) }
|
||||||
}
|
}
|
||||||
val variables = mutableStateListOf<KeyVal>().apply { addAll(inPack.variables) }
|
val variables = mutableStateListOf<KeyVal>().apply { addAll(inPack.variables) }
|
||||||
val headers = mutableStateListOf<KeyVal>().apply { addAll(inPack.headers) } // pack-level, inherited by requests
|
val headers = mutableStateListOf<KeyVal>().apply { addAll(inPack.headers) } // pack-level, inherited by requests
|
||||||
val params = mutableStateListOf<KeyVal>().apply { addAll(inPack.params) } // pack-level query params (inherited)
|
val params =
|
||||||
|
mutableStateListOf<KeyVal>().apply { addAll(inPack.params) } // pack-level query params (inherited)
|
||||||
var cert by mutableStateOf(inPack.cert) // pack-level client cert (inherited)
|
var cert by mutableStateOf(inPack.cert) // pack-level client cert (inherited)
|
||||||
val id: String = inPack.id.ifBlank { newPackId() } // stable id (linked copies reference it)
|
val id: String =
|
||||||
val isRoot: Boolean = inPack.isRoot // the session-root "pack" (loose requests)
|
inPack.id.ifBlank { newPackId() } // stable id (linked copies reference it)
|
||||||
|
val isRoot: Boolean =
|
||||||
|
inPack.isRoot // the session-root "pack" (loose requests)
|
||||||
|
|
||||||
// No auto-open: tabs start empty and are restored from persisted state only.
|
// No auto-open: tabs start empty and are restored from persisted state only.
|
||||||
val openTabs = mutableStateListOf<ReqState>().apply {
|
val openTabs = mutableStateListOf<ReqState>().apply {
|
||||||
inOpenTabs.forEach { vIdx -> requests.getOrNull(vIdx)?.let { add(it) } }
|
inOpenTabs.forEach { vIdx -> requests.getOrNull(vIdx)?.let { add(it) } }
|
||||||
@@ -73,39 +88,48 @@ internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpen
|
|||||||
fun toPack(): Pack = Pack(
|
fun toPack(): Pack = Pack(
|
||||||
name = name,
|
name = name,
|
||||||
requests = if (isLinked) emptyList() else requests.map { it.req }, // linked: requests live in the source
|
requests = if (isLinked) emptyList() else requests.map { it.req }, // linked: requests live in the source
|
||||||
variables = variables.toList(), color = color, headers = headers.toList(), params = params.toList(), cert = cert,
|
variables = variables.toList(),
|
||||||
id = id, linkedTo = linkedSource?.id, isRoot = isRoot,
|
color = color,
|
||||||
|
headers = headers.toList(),
|
||||||
|
params = params.toList(),
|
||||||
|
cert = cert,
|
||||||
|
id = id,
|
||||||
|
linkedTo = linkedSource?.id,
|
||||||
|
isRoot = isRoot,
|
||||||
subPacks = subPacks.map { it.toPack() },
|
subPacks = subPacks.map { it.toPack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A fresh random id for a pack (used to wire linked-copy packs to their source
|
/** A fresh random id for a pack (used to wire linked-copy packs to their source
|
||||||
across save / load). */
|
across save / load). */
|
||||||
internal fun newPackId(): String = "pk-" + kotlin.random.Random.nextLong().toULong().toString(16)
|
internal fun newPackId(): String = "pk-" + kotlin.random.Random.nextLong().toULong().toString(16)
|
||||||
|
|
||||||
/* One entry in the unified tab strip: a request tab, a pack's settings tab when
|
/** One entry in the unified tab strip: a request tab, a pack's settings tab when
|
||||||
req == null, or the session settings tab when isSession (pack == null). The
|
req == null, or the session settings tab when isSession (pack == null). The
|
||||||
underlying ReqState / PackState (or the session key) is the stable identity. */
|
underlying ReqState / PackState (or the session key) is the stable identity. */
|
||||||
internal const val kSessionTabKey = "session-settings"
|
internal const val kSessionTabKey = "session-settings"
|
||||||
|
|
||||||
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
|
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
|
||||||
/* Identity of a strip tab. A linked pack shares the source's ReqState objects,
|
|
||||||
so the key must include the pack — otherwise the source's and linked's tabs
|
/** Identity of a strip tab. A linked pack shares the source's ReqState objects,
|
||||||
for the same request collide (double selection, duplicate key()). Data class →
|
so the key must include the pack — otherwise the source's and linked's tabs
|
||||||
structural equality, so compare keys with == (not ===). */
|
for the same request collide (double selection, duplicate key()). Data class →
|
||||||
|
structural equality, so compare keys with == (not ===). */
|
||||||
internal data class TabKey(val pack: PackState?, val req: ReqState?)
|
internal data class TabKey(val pack: PackState?, val req: ReqState?)
|
||||||
|
|
||||||
internal val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
|
internal val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Cross-pack drag (sidebar tree)
|
// MARK: Cross-pack drag (sidebar tree)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Shared state for dragging within and across packs in the sidebar tree. One
|
/** Shared state for dragging within and across packs in the sidebar tree. One
|
||||||
instance is hoisted to App so any PackSection can be the drag source or the
|
instance is hoisted to App so any PackSection can be the drag source or the
|
||||||
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
|
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
|
||||||
a whole pack header (dragPack). Every rendered row / header registers its
|
a whole pack header (dragPack). Every rendered row / header registers its
|
||||||
absolute window position + height here so onDrag can hit-test across packs,
|
absolute window position + height here so onDrag can hit-test across packs,
|
||||||
even into collapsed or empty ones. The resolved drop target is published back
|
even into collapsed or empty ones. The resolved drop target is published back
|
||||||
so the target section draws the indicator. */
|
so the target section draws the indicator. */
|
||||||
internal class TreeDrag {
|
internal class TreeDrag {
|
||||||
// What's being dragged (at most one is non-null while a drag is live).
|
// What's being dragged (at most one is non-null while a drag is live).
|
||||||
var dragReq by mutableStateOf<ReqState?>(null)
|
var dragReq by mutableStateOf<ReqState?>(null)
|
||||||
@@ -113,6 +137,7 @@ internal class TreeDrag {
|
|||||||
var dragPack by mutableStateOf<PackState?>(null)
|
var dragPack by mutableStateOf<PackState?>(null)
|
||||||
var dy by mutableStateOf(0f) // draw-only follow offset for the grabbed element
|
var dy by mutableStateOf(0f) // draw-only follow offset for the grabbed element
|
||||||
var pressRel by mutableStateOf(0) // relY at capture (cursor = grabbed-element top + relY)
|
var pressRel by mutableStateOf(0) // relY at capture (cursor = grabbed-element top + relY)
|
||||||
|
|
||||||
// A press doesn't become a drag until the pointer moves past kDragSlop — so a
|
// A press doesn't become a drag until the pointer moves past kDragSlop — so a
|
||||||
// plain click (which carries a pixel or two of jitter) still selects / opens.
|
// plain click (which carries a pixel or two of jitter) still selects / opens.
|
||||||
var engaged by mutableStateOf(false)
|
var engaged by mutableStateOf(false)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Minimal X.509 / DER parser
|
||||||
|
// ==================
|
||||||
|
// Some TLS backends (notably Schannel on Windows) expose only Subject / Issuer /
|
||||||
|
// the PEM through CURLINFO_CERTINFO — not the parsed fields (validity, serial,
|
||||||
|
// SAN, algorithms). Since every backend gives us the PEM, we parse it ourselves
|
||||||
|
// to fill in the detail. Just enough ASN.1 to walk a Certificate; anything odd
|
||||||
|
// is swallowed and the field is simply omitted.
|
||||||
|
|
||||||
|
/** Parsed certificate detail, all nullable/empty when not found or unparseable. */
|
||||||
|
class CertDetails(
|
||||||
|
val version: Int?, // X.509 version (1/2/3)
|
||||||
|
val serial: String?, // colon-separated hex
|
||||||
|
val sigAlg: String?, // signature algorithm (friendly name or OID)
|
||||||
|
val keyAlg: String?, // public-key algorithm (friendly name or OID)
|
||||||
|
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
|
||||||
|
val notAfter: String?,
|
||||||
|
val sans: List<String>, // dNSName SANs
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
|
||||||
|
private class DerReader(val b: ByteArray, var pos: Int, val end: Int) {
|
||||||
|
fun hasMore() = pos < end
|
||||||
|
fun next(): IntArray {
|
||||||
|
val vTag = b[pos++].toInt() and 0xFF
|
||||||
|
var vLen = b[pos++].toInt() and 0xFF
|
||||||
|
if (vLen and 0x80 != 0) {
|
||||||
|
val vN = vLen and 0x7F
|
||||||
|
vLen = 0
|
||||||
|
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
|
||||||
|
}
|
||||||
|
val vStart = pos
|
||||||
|
pos += vLen
|
||||||
|
return intArrayOf(vTag, vStart, vStart + vLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the PEM into the fields the chain dialog shows. Returns null on any
|
||||||
|
structural surprise (the UI just falls back to whatever the backend gave). */
|
||||||
|
fun parseCertDetails(inPem: String): CertDetails? {
|
||||||
|
val vDer = pemToDer(inPem) ?: return null
|
||||||
|
return try {
|
||||||
|
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
|
||||||
|
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
|
||||||
|
val vTbs = vCertInner.next() // tbsCertificate
|
||||||
|
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
|
||||||
|
|
||||||
|
val vT = DerReader(vDer, vTbs[1], vTbs[2])
|
||||||
|
var vE = vT.next()
|
||||||
|
var vVersion: Int? = null
|
||||||
|
if (vE[0] == 0xA0) // [0] EXPLICIT version
|
||||||
|
{
|
||||||
|
val vVi = DerReader(vDer, vE[1], vE[2]).next()
|
||||||
|
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
|
||||||
|
vE = vT.next()
|
||||||
|
}
|
||||||
|
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
|
||||||
|
vT.next() // signature
|
||||||
|
vT.next() // issuer
|
||||||
|
val vValidity = vT.next() // validity SEQUENCE
|
||||||
|
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
|
||||||
|
val vNotBefore = parseAsn1Time(vDer, vVr.next())
|
||||||
|
val vNotAfter = parseAsn1Time(vDer, vVr.next())
|
||||||
|
vT.next() // subject
|
||||||
|
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
|
||||||
|
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
|
||||||
|
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
|
||||||
|
val vSans = ArrayList<String>()
|
||||||
|
while (vT.hasMore()) {
|
||||||
|
val vX = vT.next()
|
||||||
|
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
|
||||||
|
}
|
||||||
|
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
private fun pemToDer(inPem: String): ByteArray? {
|
||||||
|
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
|
||||||
|
if (vBody.isBlank()) return null
|
||||||
|
return try {
|
||||||
|
Base64.decode(vBody)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
|
||||||
|
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
|
||||||
|
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String? {
|
||||||
|
val vR = DerReader(inB, inSeq[1], inSeq[2])
|
||||||
|
val vOid = vR.next()
|
||||||
|
if (vOid[0] != 0x06) return null
|
||||||
|
return oidName(oidToString(inB, vOid[1], vOid[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull dNSName entries out of the SubjectAltName extension under [3]. */
|
||||||
|
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>) {
|
||||||
|
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
|
||||||
|
val vEs = DerReader(inB, vSeq[1], vSeq[2])
|
||||||
|
while (vEs.hasMore()) {
|
||||||
|
val vExtn = vEs.next()
|
||||||
|
val vEr = DerReader(inB, vExtn[1], vExtn[2])
|
||||||
|
val vOid = vEr.next() // extnID
|
||||||
|
// SAN OID 2.5.29.17 == bytes 55 1D 11
|
||||||
|
val vIsSan = vOid[2] - vOid[1] == 3 &&
|
||||||
|
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
|
||||||
|
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
|
||||||
|
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
|
||||||
|
var vVal = vEr.next()
|
||||||
|
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
|
||||||
|
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
|
||||||
|
{
|
||||||
|
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
|
||||||
|
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
|
||||||
|
while (vGn.hasMore()) {
|
||||||
|
val vName = vGn.next()
|
||||||
|
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
|
||||||
|
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String? {
|
||||||
|
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
|
||||||
|
return try {
|
||||||
|
val vYear: String
|
||||||
|
val vRest: String
|
||||||
|
if (inTlv[0] == 0x18) // GeneralizedTime
|
||||||
|
{
|
||||||
|
vYear = vS.substring(0, 4); vRest = vS.substring(4)
|
||||||
|
} else // UTCTime — RFC 5280 pivot at 50
|
||||||
|
{
|
||||||
|
val vYy = vS.substring(0, 2).toInt()
|
||||||
|
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
|
||||||
|
vRest = vS.substring(2)
|
||||||
|
}
|
||||||
|
val vMo = vRest.substring(0, 2)
|
||||||
|
val vDa = vRest.substring(2, 4)
|
||||||
|
val vHh = vRest.substring(4, 6)
|
||||||
|
val vMi = vRest.substring(6, 8)
|
||||||
|
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
|
||||||
|
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
vS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colon-separated hex of a byte range (serial number rendering). */
|
||||||
|
private fun bytesToHex(inB: ByteArray, inFrom: Int, inTo: Int): String =
|
||||||
|
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
|
||||||
|
|
||||||
|
/** Decode an OID's content bytes to dotted-decimal. */
|
||||||
|
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String {
|
||||||
|
if (inTo <= inFrom) return ""
|
||||||
|
val vSb = StringBuilder()
|
||||||
|
val vFirst = inB[inFrom].toInt() and 0xFF
|
||||||
|
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
|
||||||
|
var vAcc = 0L
|
||||||
|
for (vI in (inFrom + 1) until inTo) {
|
||||||
|
val vByte = inB[vI].toInt() and 0xFF
|
||||||
|
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
|
||||||
|
if (vByte and 0x80 == 0) {
|
||||||
|
vSb.append('.').append(vAcc); vAcc = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vSb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Friendly name for the common signature / public-key OIDs, else the OID. */
|
||||||
|
private fun oidName(inOid: String): String = when (inOid) {
|
||||||
|
"1.2.840.113549.1.1.1" -> "RSA"
|
||||||
|
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
|
||||||
|
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
|
||||||
|
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
|
||||||
|
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
|
||||||
|
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
|
||||||
|
"1.2.840.10045.2.1" -> "EC"
|
||||||
|
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
|
||||||
|
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
|
||||||
|
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
|
||||||
|
"1.3.101.112" -> "Ed25519"
|
||||||
|
"1.3.101.113" -> "Ed448"
|
||||||
|
else -> inOid
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Monospace body font (seam)
|
||||||
|
// ==================
|
||||||
|
// Family name the body editor passes for monospace text. Resolves to the
|
||||||
|
// bundled Noto Sans Mono once registered; null means the font isn't shipped
|
||||||
|
// (data.kres on native, classpath font/ on jvm), in which case the body falls
|
||||||
|
// back to the default proportional font.
|
||||||
|
const val kMonoFamily = "noto-mono"
|
||||||
|
|
||||||
|
/** The raw family name string — used by wrappedRowCount, which measures via the
|
||||||
|
platform text pipeline (accepts a name string). Null when not bundled. */
|
||||||
|
expect val monoFontFamilyName: String?
|
||||||
|
|
||||||
|
/** The material3 Text / BasicTextField-shaped FontFamily for the mono family.
|
||||||
|
Null when not bundled. */
|
||||||
|
expect val monoFontFamily: FontFamily?
|
||||||
@@ -25,6 +25,7 @@ fun formatBody(inText: String, inFormat: BodyFormat): String =
|
|||||||
BodyFormat.JSON -> runCatching {
|
BodyFormat.JSON -> runCatching {
|
||||||
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
|
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
|
||||||
}.getOrDefault(inText)
|
}.getOrDefault(inText)
|
||||||
|
|
||||||
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
|
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
|
||||||
else -> inText
|
else -> inText
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import io.ktor.client.*
|
import apidemo.compat.createApiHttpClient
|
||||||
import io.ktor.client.engine.curl.Curl
|
import apidemo.compat.systemFileSystem
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.FileSystem
|
|
||||||
import okio.GzipSource
|
import okio.GzipSource
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
@@ -17,13 +16,13 @@ import kotlin.time.TimeSource
|
|||||||
// MARK: HttpRunner — executes an ApiRequest through Ktor
|
// MARK: HttpRunner — executes an ApiRequest through Ktor
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
|
/** Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
|
||||||
target: Ktor's Curl engine (bundled libcurl — Schannel on Windows, OpenSSL on
|
target: Ktor's Curl engine (bundled libcurl — Schannel on Windows, OpenSSL on
|
||||||
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
|
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
|
||||||
run() is a suspend fun — call it off the UI dispatcher. */
|
run() is a suspend fun — call it off the UI dispatcher. */
|
||||||
class HttpRunner {
|
class HttpRunner {
|
||||||
|
|
||||||
private val fClient = HttpClient(Curl)
|
private val fClient = createApiHttpClient()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Clear any temporary client certs a prior crash left in the Windows
|
// Clear any temporary client certs a prior crash left in the Windows
|
||||||
@@ -45,7 +44,7 @@ class HttpRunner {
|
|||||||
inReq.headers
|
inReq.headers
|
||||||
.filter { it.enabled && it.key.isNotBlank() }
|
.filter { it.enabled && it.key.isNotBlank() }
|
||||||
.forEach { header(it.key, it.value) }
|
.forEach { header(it.key, it.value) }
|
||||||
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE) {
|
if (inReq.bodyType != BodyType.NONE) {
|
||||||
// Content-Type comes from the body type / text format, unless the
|
// Content-Type comes from the body type / text format, unless the
|
||||||
// request already carries an explicit Content-Type header.
|
// request already carries an explicit Content-Type header.
|
||||||
val vCt = inReq.bodyContentType()
|
val vCt = inReq.bodyContentType()
|
||||||
@@ -73,7 +72,6 @@ class HttpRunner {
|
|||||||
// so show a short note instead and let the user Save the raw bytes.
|
// so show a short note instead and let the user Save the raw bytes.
|
||||||
val vBinary = !vIsImage && isBinaryBody(vContentType, vBody)
|
val vBinary = !vIsImage && isBinaryBody(vContentType, vBody)
|
||||||
ApiResponse(
|
ApiResponse(
|
||||||
ok = true,
|
|
||||||
status = vResp.status.value,
|
status = vResp.status.value,
|
||||||
statusText = vResp.status.description,
|
statusText = vResp.status.description,
|
||||||
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
||||||
@@ -104,7 +102,6 @@ class HttpRunner {
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
ApiResponse(
|
ApiResponse(
|
||||||
ok = false,
|
|
||||||
status = 0,
|
status = 0,
|
||||||
statusText = "—",
|
statusText = "—",
|
||||||
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
||||||
@@ -119,30 +116,30 @@ class HttpRunner {
|
|||||||
fun close() = fClient.close()
|
fun close() = fClient.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Heuristic for a non-text body: textual content types (text, json, xml, html,
|
/** Heuristic for a non-text body: textual content types (text, json, xml, html,
|
||||||
javascript, csv, form) are shown as text; everything else, or bytes with
|
javascript, csv, form) are shown as text; everything else, or bytes with
|
||||||
embedded NULs, is treated as binary. */
|
embedded NULs, is treated as binary. */
|
||||||
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
|
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
|
||||||
val vCt = inContentType?.lowercase() ?: ""
|
val vCt = inContentType?.lowercase() ?: ""
|
||||||
val vTextual = vCt.startsWith("text/") ||
|
val vTextual = vCt.startsWith("text/") ||
|
||||||
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
|
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
|
||||||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
|
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
|
||||||
if (vTextual) return false
|
if (vTextual) return false
|
||||||
if (vCt.isNotEmpty()) return true // a non-textual content type → binary
|
if (vCt.isNotEmpty()) return true // a non-textual content type → binary
|
||||||
return inBytes.take(1024).any { it.toInt() == 0 } // no type: sniff for NUL bytes
|
return inBytes.take(1024).any { it.toInt() == 0 } // no type: sniff for NUL bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
/* True when the bytes are gzip — either the response says so or they carry the
|
/** True when the bytes are gzip — either the response says so or they carry the
|
||||||
gzip magic number (1f 8b). */
|
gzip magic number (1f 8b). */
|
||||||
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
|
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
|
||||||
inEncoding?.contains("gzip", ignoreCase = true) == true ||
|
inEncoding?.contains("gzip", ignoreCase = true) == true ||
|
||||||
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
|
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
|
||||||
|
|
||||||
/* Read a file's raw bytes (used for a FILE request body). */
|
/** Read a file's raw bytes (used for a FILE request body). */
|
||||||
internal fun readFileBytes(inPath: String): ByteArray =
|
internal fun readFileBytes(inPath: String): ByteArray =
|
||||||
FileSystem.SYSTEM.read(inPath.trim().toPath()) { readByteArray() }
|
systemFileSystem.read(inPath.trim().toPath()) { readByteArray() }
|
||||||
|
|
||||||
/* Inflate a gzip stream to its original bytes via okio's GzipSource. */
|
/** Inflate a gzip stream to its original bytes via okio's GzipSource. */
|
||||||
private fun gunzip(inBytes: ByteArray): ByteArray {
|
private fun gunzip(inBytes: ByteArray): ByteArray {
|
||||||
val vGz = GzipSource(Buffer().write(inBytes)).buffer()
|
val vGz = GzipSource(Buffer().write(inBytes)).buffer()
|
||||||
val vResult = vGz.readByteArray()
|
val vResult = vGz.readByteArray()
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -21,10 +19,10 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|||||||
// MARK: Headers tab (inherited read-only + own editable)
|
// MARK: Headers tab (inherited read-only + own editable)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* A request tab pairing an inherited (read-only, source-tagged) list with the
|
/** A request tab pairing an inherited (read-only, source-tagged) list with the
|
||||||
request's own editable list: Override copies an inherited entry down, and own
|
request's own editable list: Override copies an inherited entry down, and own
|
||||||
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
|
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
|
||||||
Headers — the only differences are the key case-sensitivity and the labels. */
|
Headers — the only differences are the key case-sensitivity and the labels. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InheritedEditableTab(
|
internal fun InheritedEditableTab(
|
||||||
inInherited: List<InheritedKv>,
|
inInherited: List<InheritedKv>,
|
||||||
@@ -40,8 +38,10 @@ internal fun InheritedEditableTab(
|
|||||||
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
|
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
|
||||||
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
|
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
InheritedKvSection(inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
|
InheritedKvSection(
|
||||||
inOnOverride = if (inReadOnly) null else inOnOverride)
|
inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
|
||||||
|
inOnOverride = if (inReadOnly) null else inOnOverride
|
||||||
|
)
|
||||||
if (inInherited.any { it.kv.key.isNotBlank() }) {
|
if (inInherited.any { it.kv.key.isNotBlank() }) {
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
Text(inOwnTitle, color = c.dim, fontSize = 11.sp)
|
Text(inOwnTitle, color = c.dim, fontSize = 11.sp)
|
||||||
@@ -52,26 +52,52 @@ internal fun InheritedEditableTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Request Headers tab: inherited headers (case-insensitive) + the request's own. */
|
/** Request Headers tab: inherited headers (case-insensitive) + the request's own. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HeadersTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
internal fun HeadersTab(
|
||||||
InheritedEditableTab(inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
|
inReq: ApiRequest,
|
||||||
|
inInherited: List<InheritedKv>,
|
||||||
|
inReadOnly: Boolean,
|
||||||
|
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
|
||||||
|
) =
|
||||||
|
InheritedEditableTab(
|
||||||
|
inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
|
||||||
inOnOverride = { vKv -> inEdit { it.copy(headers = it.headers + vKv) } },
|
inOnOverride = { vKv -> inEdit { it.copy(headers = it.headers + vKv) } },
|
||||||
inOnChange = { v -> inEdit { it.copy(headers = v) } })
|
inOnChange = { v -> inEdit { it.copy(headers = v) } })
|
||||||
|
|
||||||
/* Request Query tab: inherited query params (case-sensitive) + the request's own. */
|
/** Request Query tab: inherited query params (case-sensitive) + the request's own. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun QueryTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
internal fun QueryTab(
|
||||||
InheritedEditableTab(inInherited, inReq.params, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request query params",
|
inReq: ApiRequest,
|
||||||
|
inInherited: List<InheritedKv>,
|
||||||
|
inReadOnly: Boolean,
|
||||||
|
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
|
||||||
|
) =
|
||||||
|
InheritedEditableTab(
|
||||||
|
inInherited,
|
||||||
|
inReq.params,
|
||||||
|
inCaseInsensitive = false,
|
||||||
|
inReadOnly = inReadOnly,
|
||||||
|
inOwnTitle = "Request query params",
|
||||||
inOnOverride = { vKv -> inEdit { it.copy(params = it.params + vKv) } },
|
inOnOverride = { vKv -> inEdit { it.copy(params = it.params + vKv) } },
|
||||||
inOnChange = { v -> inEdit { it.copy(params = v) } })
|
inOnChange = { v -> inEdit { it.copy(params = v) } })
|
||||||
|
|
||||||
/* Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
|
/** Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
|
||||||
own. A request's own variable overrides the same name inherited from a pack /
|
own. A request's own variable overrides the same name inherited from a pack /
|
||||||
the session when the request is sent. */
|
the session when the request is sent. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
internal fun VarTab(
|
||||||
InheritedEditableTab(inInherited, inReq.variables, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request variables",
|
inReq: ApiRequest,
|
||||||
|
inInherited: List<InheritedKv>,
|
||||||
|
inReadOnly: Boolean,
|
||||||
|
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
|
||||||
|
) =
|
||||||
|
InheritedEditableTab(
|
||||||
|
inInherited,
|
||||||
|
inReq.variables,
|
||||||
|
inCaseInsensitive = false,
|
||||||
|
inReadOnly = inReadOnly,
|
||||||
|
inOwnTitle = "Request variables",
|
||||||
inOnOverride = { vKv -> inEdit { it.copy(variables = it.variables + vKv) } },
|
inOnOverride = { vKv -> inEdit { it.copy(variables = it.variables + vKv) } },
|
||||||
inOnChange = { v -> inEdit { it.copy(variables = v) } })
|
inOnChange = { v -> inEdit { it.copy(variables = v) } })
|
||||||
|
|
||||||
@@ -79,14 +105,18 @@ internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnl
|
|||||||
// MARK: Inheritance UI (source tags / override markers / inherited lists)
|
// MARK: Inheritance UI (source tags / override markers / inherited lists)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* A small pill naming the kind of scope an inherited value comes from ("Session"
|
/** A small pill naming the kind of scope an inherited value comes from ("Session"
|
||||||
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
|
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
|
||||||
is skipped when it would just repeat the label (the session). */
|
is skipped when it would just repeat the label (the session). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SourceTag(inLabel: String, inPath: String) {
|
internal fun SourceTag(inLabel: String, inPath: String) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
val vPill = @Composable {
|
val vPill = @Composable {
|
||||||
Box(modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp)).padding(horizontal = 5.dp, vertical = 1.dp)) {
|
Box(
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp))
|
||||||
|
.padding(horizontal = 5.dp, vertical = 1.dp)
|
||||||
|
) {
|
||||||
Text(inLabel, color = c.accent, fontSize = 9.sp)
|
Text(inLabel, color = c.accent, fontSize = 9.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,8 +131,8 @@ internal fun SourceTag(inLabel: String, inPath: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tiny marker shown on an own value that shadows an inherited one — hover for
|
/** The tiny marker shown on an own value that shadows an inherited one — hover for
|
||||||
"Overrides <key> from <path>". */
|
"Overrides <key> from <path>". */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OverrideMark(inKey: String, inPath: String) {
|
internal fun OverrideMark(inKey: String, inPath: String) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -120,9 +150,9 @@ internal fun OverrideMark(inKey: String, inPath: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read-only list of inherited key/values, each tagged with its source. When not
|
/** Read-only list of inherited key/values, each tagged with its source. When not
|
||||||
already overridden in this scope (own key absent) and inOnOverride != null, a
|
already overridden in this scope (own key absent) and inOnOverride != null, a
|
||||||
row offers a one-click Override that copies it into this scope's own list. */
|
row offers a one-click Override that copies it into this scope's own list. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InheritedKvSection(
|
internal fun InheritedKvSection(
|
||||||
inTitle: String,
|
inTitle: String,
|
||||||
@@ -139,15 +169,26 @@ internal fun InheritedKvSection(
|
|||||||
vShown.forEach { vIt ->
|
vShown.forEach { vIt ->
|
||||||
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
|
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
|
||||||
val vOverridden = vKey in inOwnKeys
|
val vOverridden = vKey in inOwnKeys
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
SourceTag(vIt.source, vIt.path)
|
SourceTag(vIt.source, vIt.path)
|
||||||
Text(vIt.kv.key, color = c.dim, fontSize = 12.sp, modifier = Modifier.weight(0.42f))
|
Text(vIt.kv.key, color = c.dim, fontSize = 12.sp, modifier = Modifier.weight(0.42f))
|
||||||
Text(vIt.kv.value, color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f), fontSize = 12.sp, modifier = Modifier.weight(0.58f))
|
Text(
|
||||||
|
vIt.kv.value,
|
||||||
|
color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.weight(0.58f)
|
||||||
|
)
|
||||||
when {
|
when {
|
||||||
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
|
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
|
||||||
inOnOverride != null -> Box(
|
inOnOverride != null -> Box(
|
||||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
|
modifier = Modifier.clip(RoundedCornerShape(6.dp))
|
||||||
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }.padding(horizontal = 8.dp, vertical = 3.dp),
|
.border(1.dp, c.border, RoundedCornerShape(6.dp))
|
||||||
|
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
) { Text("Override", color = c.accent, fontSize = 11.sp) }
|
) { Text("Override", color = c.accent, fontSize = 11.sp) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,11 +200,15 @@ internal fun InheritedKvSection(
|
|||||||
// MARK: Key/value editor
|
// MARK: Key/value editor
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* inOverrideInfo maps an own row to the source label it shadows (or null) so the
|
/** inOverrideInfo maps an own row to the source label it shadows (or null) so the
|
||||||
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
|
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
|
||||||
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
|
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> String? = { null }, inOnChange: (List<KeyVal>) -> Unit) {
|
internal fun KeyValEditor(
|
||||||
|
inRows: List<KeyVal>,
|
||||||
|
inOverrideInfo: (KeyVal) -> String? = { null },
|
||||||
|
inOnChange: (List<KeyVal>) -> Unit
|
||||||
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
val vAnyOverride = inRows.any { inOverrideInfo(it) != null }
|
val vAnyOverride = inRows.any { inOverrideInfo(it) != null }
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -188,15 +233,33 @@ internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> Stri
|
|||||||
Checkbox(
|
Checkbox(
|
||||||
checked = vKv.enabled,
|
checked = vKv.enabled,
|
||||||
onCheckedChange = null,
|
onCheckedChange = null,
|
||||||
colors = CheckboxDefaults.colors(checkedColor = c.accent, uncheckedColor = c.dim, checkmarkColor = c.onAccent),
|
colors = CheckboxDefaults.colors(
|
||||||
|
checkedColor = c.accent,
|
||||||
|
uncheckedColor = c.dim,
|
||||||
|
checkmarkColor = c.onAccent
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (vAnyOverride) Box(modifier = Modifier.width(18.dp), contentAlignment = Alignment.Center) {
|
if (vAnyOverride) Box(modifier = Modifier.width(18.dp), contentAlignment = Alignment.Center) {
|
||||||
inOverrideInfo(vKv)?.let { vSrc -> OverrideMark(vKv.key, vSrc) }
|
inOverrideInfo(vKv)?.let { vSrc -> OverrideMark(vKv.key, vSrc) }
|
||||||
}
|
}
|
||||||
ThinField(vKv.key, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) }, inModifier = Modifier.weight(1f), inPlaceholder = "key")
|
ThinField(
|
||||||
ThinField(vKv.value, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) }, inModifier = Modifier.weight(1.4f), inPlaceholder = "value")
|
vKv.key,
|
||||||
IconBtn(MaterialSymbols.Close, "Remove", inSize = 16.dp) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
|
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) },
|
||||||
|
inModifier = Modifier.weight(1f),
|
||||||
|
inPlaceholder = "key"
|
||||||
|
)
|
||||||
|
ThinField(
|
||||||
|
vKv.value,
|
||||||
|
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) },
|
||||||
|
inModifier = Modifier.weight(1.4f),
|
||||||
|
inPlaceholder = "value"
|
||||||
|
)
|
||||||
|
IconBtn(
|
||||||
|
MaterialSymbols.Close,
|
||||||
|
"Remove",
|
||||||
|
inSize = 16.dp
|
||||||
|
) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutlinedAction(MaterialSymbols.Add, "Add row") { inOnChange(inRows + KeyVal()) }
|
OutlinedAction(MaterialSymbols.Add, "Add row") { inOnChange(inRows + KeyVal()) }
|
||||||
@@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
|
|
||||||
/* Icon + label row used inside nearly every DropdownMenuItem. */
|
/** Icon + label row used inside nearly every DropdownMenuItem. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MenuRow(inIcon: Int, inLabel: String, inColor: Color? = null) {
|
internal fun MenuRow(inIcon: Int, inLabel: String, inColor: Color? = null) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Pack model (serialized to / from .json)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** A "pack" is a savable collection of requests plus its environment variables
|
||||||
|
— the export/import unit. Variables are referenced as {{name}} in any URL,
|
||||||
|
query value, header or body and substituted just before the request is sent
|
||||||
|
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
|
||||||
|
in defaultSession(). */
|
||||||
|
@Serializable
|
||||||
|
data class Pack(
|
||||||
|
val name: String = "My Pack",
|
||||||
|
val requests: List<ApiRequest> = emptyList(),
|
||||||
|
val variables: List<KeyVal> = emptyList(),
|
||||||
|
val color: Int = 0, // 1-based index into the pack-colour palette; 0 = none
|
||||||
|
val headers: List<KeyVal> = emptyList(), // pack-level headers, inherited by requests
|
||||||
|
val params: List<KeyVal> = emptyList(), // pack-level query params, inherited by requests
|
||||||
|
val cert: CertConfig? = null, // pack-level client cert, inherited by requests
|
||||||
|
val id: String = "", // stable id (used by linked-copy packs)
|
||||||
|
val linkedTo: String? = null, // id of the source pack when this is a linked copy
|
||||||
|
val isRoot: Boolean = false, // the hidden session-root "pack" holding loose requests
|
||||||
|
val subPacks: List<Pack> = emptyList(), // nested packs (a pack tree)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A client-certificate (mutual TLS) configuration. Lives on a request today and
|
||||||
|
— with the pack tree — on the session / packs too (inherited downward). Maps
|
||||||
|
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
|
||||||
|
@Serializable
|
||||||
|
data class CertConfig(
|
||||||
|
val certPath: String = "",
|
||||||
|
val certFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val keyPath: String = "",
|
||||||
|
val keyFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val certPassword: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The starter SESSION loaded on first launch (and on demand from the session
|
||||||
|
menu) — a guided tour of every feature against httpbin.org: the inheritance
|
||||||
|
ladder (session → pack → sub-pack → request, for variables / query params /
|
||||||
|
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
|
||||||
|
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
|
||||||
|
/anything endpoint echoes the args + headers it received, so the merged,
|
||||||
|
inherited result is visible in the response. Loaded as an unsaved session. */
|
||||||
|
fun defaultSession(): Session = Session(
|
||||||
|
activePack = 0,
|
||||||
|
// ── Session level — the base of every inheritance ladder ──
|
||||||
|
globalEnv = listOf(
|
||||||
|
KeyVal("baseUrl", "https://httpbin.org"),
|
||||||
|
KeyVal("token", "session-token-abc"),
|
||||||
|
KeyVal("user", "session-user"),
|
||||||
|
KeyVal("password", "passwd"),
|
||||||
|
KeyVal("apiVer", "v1"), // overridden by Methods (v2) then Nested (v3)
|
||||||
|
),
|
||||||
|
globalHeaders = listOf(KeyVal("X-Client", "compose-apidemo")), // every request sends it (echoed by /headers)
|
||||||
|
globalParams = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"trace",
|
||||||
|
"session"
|
||||||
|
)
|
||||||
|
), // every request gets ?trace=session (echoed in /get args)
|
||||||
|
// No session-level cert: it would route every request through mTLS and fail
|
||||||
|
// without a real cert file. Client certs are shown on the "Secure (mTLS)" pack.
|
||||||
|
// ── Loose requests at the session root (in no pack — inherit session only) ──
|
||||||
|
root = Pack(
|
||||||
|
isRoot = true, name = "", requests = listOf(
|
||||||
|
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
||||||
|
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
packs = listOf(
|
||||||
|
// ── Methods — pack header/param/var inherited by its requests, plus a
|
||||||
|
// nested sub-pack and per-request overrides of each kind. ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Methods", id = "pk-methods", color = 1,
|
||||||
|
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
|
||||||
|
params = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"source",
|
||||||
|
"methods"
|
||||||
|
)
|
||||||
|
), // pack query param, inherited (echoed in /get args)
|
||||||
|
variables = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"apiVer",
|
||||||
|
"v2"
|
||||||
|
)
|
||||||
|
), // overrides the session's apiVer for this pack
|
||||||
|
requests = listOf(
|
||||||
|
ApiRequest(
|
||||||
|
name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
||||||
|
params = listOf(KeyVal("q", "compose"))
|
||||||
|
), // own q + inherited trace=session + source=methods
|
||||||
|
ApiRequest(
|
||||||
|
name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
||||||
|
params = listOf(KeyVal("trace", "request"))
|
||||||
|
), // request's trace wins over the session's
|
||||||
|
ApiRequest(
|
||||||
|
name = "POST — overrides header",
|
||||||
|
method = ReqMethod.POST,
|
||||||
|
url = "{{baseUrl}}/post",
|
||||||
|
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
|
||||||
|
bodyType = BodyType.TEXT,
|
||||||
|
bodyFormat = BodyFormat.JSON,
|
||||||
|
body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"
|
||||||
|
),
|
||||||
|
ApiRequest(
|
||||||
|
name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
||||||
|
variables = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"user",
|
||||||
|
"request-user"
|
||||||
|
)
|
||||||
|
), // request var beats session/pack — see body echo
|
||||||
|
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"
|
||||||
|
),
|
||||||
|
ApiRequest(
|
||||||
|
name = "PUT — replace",
|
||||||
|
method = ReqMethod.PUT,
|
||||||
|
url = "{{baseUrl}}/put",
|
||||||
|
bodyType = BodyType.TEXT,
|
||||||
|
bodyFormat = BodyFormat.JSON,
|
||||||
|
body = "{ \"id\": 1, \"name\": \"updated\" }"
|
||||||
|
),
|
||||||
|
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
|
||||||
|
ApiRequest(
|
||||||
|
name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
||||||
|
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
|
||||||
|
// plus its own X-Nested header; trace / source / Accept still flow down).
|
||||||
|
subPacks = listOf(
|
||||||
|
Pack(
|
||||||
|
name = "Nested", color = 4,
|
||||||
|
variables = listOf(KeyVal("apiVer", "v3")),
|
||||||
|
headers = listOf(KeyVal("X-Nested", "true")),
|
||||||
|
requests = listOf(
|
||||||
|
ApiRequest(
|
||||||
|
name = "GET — deep inheritance",
|
||||||
|
method = ReqMethod.GET,
|
||||||
|
url = "{{baseUrl}}/anything/{{apiVer}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Auth & status — uses the inherited {{token}} / {{user}} / {{password}} ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Auth & status", color = 2, requests = listOf(
|
||||||
|
ApiRequest(
|
||||||
|
name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
|
||||||
|
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))
|
||||||
|
),
|
||||||
|
ApiRequest(
|
||||||
|
name = "Basic auth",
|
||||||
|
method = ReqMethod.GET,
|
||||||
|
url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"
|
||||||
|
),
|
||||||
|
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
|
||||||
|
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
|
||||||
|
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
|
||||||
|
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Formats — response viewer (JSON / XML / HTML / image) ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Formats", color = 3, requests = listOf(
|
||||||
|
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
|
||||||
|
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
|
||||||
|
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
|
||||||
|
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
|
||||||
|
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
|
||||||
|
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Secure (mTLS) — a PACK-LEVEL client cert, inherited by its requests.
|
||||||
|
// Open a request's Cert tab to see the inherited cert (source pill +
|
||||||
|
// Override). Set real cert paths to actually send; the example paths
|
||||||
|
// won't load. The second request overrides with its own cert. ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Secure (mTLS)", color = 5,
|
||||||
|
cert = CertConfig(
|
||||||
|
certPath = "/path/to/client.p12",
|
||||||
|
certFormat = CertFormat.PKCS12,
|
||||||
|
certPassword = "changeit"
|
||||||
|
),
|
||||||
|
requests = listOf(
|
||||||
|
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
||||||
|
ApiRequest(
|
||||||
|
name = "Overrides with own cert",
|
||||||
|
method = ReqMethod.GET,
|
||||||
|
url = "{{baseUrl}}/get",
|
||||||
|
certPath = "/path/to/other-client.pem",
|
||||||
|
certFormat = CertFormat.PEM,
|
||||||
|
keyPath = "/path/to/other-key.pem"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
|
||||||
|
// its own baseUrl var → the same calls run against the httpbingo sibling.
|
||||||
|
// Showcases linked packs + per-pack variable override. ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Methods · httpbingo", color = 6, requests = emptyList(),
|
||||||
|
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiRequest(
|
||||||
|
val name: String = "New request",
|
||||||
|
val method: ReqMethod = ReqMethod.GET,
|
||||||
|
val url: String = "https://",
|
||||||
|
val params: List<KeyVal> = emptyList(),
|
||||||
|
val variables: List<KeyVal> = emptyList(), // request-level vars ({{name}}); override inherited ones
|
||||||
|
val headers: List<KeyVal> = emptyList(),
|
||||||
|
val bodyType: BodyType = BodyType.NONE,
|
||||||
|
val body: String = "", // TEXT content, or the file path when bodyType == FILE
|
||||||
|
val bodyFormat: BodyFormat = BodyFormat.RAW, // for a TEXT body: syntax-highlight type + sent Content-Type
|
||||||
|
val form: List<KeyVal> = emptyList(), // key/value fields when bodyType == FORM
|
||||||
|
// Client certificate (mutual TLS). When certPath is set, the request is sent
|
||||||
|
// through the libcurl path (CurlMtls.kt) with these mapped to CURLOPT_SSLCERT
|
||||||
|
// / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. A PKCS#12 file bundles its
|
||||||
|
// own key, so keyPath is left blank for it.
|
||||||
|
val certPath: String = "",
|
||||||
|
val certFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val keyPath: String = "",
|
||||||
|
val keyFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val certPassword: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether this request carries a client certificate (and so is sent via the
|
||||||
|
libcurl mTLS path rather than the default engine). */
|
||||||
|
val ApiRequest.hasClientCert: Boolean
|
||||||
|
get() = certPath.isNotBlank()
|
||||||
|
|
||||||
|
/** This request's cert fields as a CertConfig (certPath may be blank = none). */
|
||||||
|
fun ApiRequest.certConfig(): CertConfig = CertConfig(certPath, certFormat, keyPath, keyFormat, certPassword)
|
||||||
|
|
||||||
|
/** This request with its cert fields replaced by inCert's. */
|
||||||
|
fun ApiRequest.withCert(inCert: CertConfig): ApiRequest =
|
||||||
|
copy(
|
||||||
|
certPath = inCert.certPath, certFormat = inCert.certFormat,
|
||||||
|
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True when this config actually selects a certificate. */
|
||||||
|
val CertConfig.isSet: Boolean
|
||||||
|
get() = certPath.isNotBlank()
|
||||||
|
|
||||||
|
/** Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
|
||||||
|
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
|
||||||
|
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
|
||||||
|
three; Schannel (Windows) effectively supports P12. */
|
||||||
|
@Serializable
|
||||||
|
enum class CertFormat(val curlName: String, val label: String) {
|
||||||
|
PEM("PEM", "PEM"),
|
||||||
|
DER("DER", "DER"),
|
||||||
|
PKCS12("P12", "PKCS#12"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A toggleable key/value row, used for both query params and headers. */
|
||||||
|
@Serializable
|
||||||
|
data class KeyVal(val key: String = "", val value: String = "", val enabled: Boolean = true)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ReqMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class BodyType { NONE, TEXT, FORM, FILE }
|
||||||
|
|
||||||
|
/** Title-case label for the body-type picker. */
|
||||||
|
val BodyType.label: String
|
||||||
|
get() = when (this) {
|
||||||
|
BodyType.NONE -> "None"
|
||||||
|
BodyType.TEXT -> "Text"
|
||||||
|
BodyType.FORM -> "Form"
|
||||||
|
BodyType.FILE -> "File"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Content-Type the request body implies (null when there's no body). For a
|
||||||
|
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
|
||||||
|
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
|
||||||
|
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
|
||||||
|
BodyType.NONE -> null
|
||||||
|
BodyType.TEXT -> bodyFormat.contentType
|
||||||
|
BodyType.FORM -> "application/x-www-form-urlencoded"
|
||||||
|
BodyType.FILE -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Response (runtime only — not part of a pack)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
class ApiResponse(
|
||||||
|
val status: Int,
|
||||||
|
val statusText: String,
|
||||||
|
val timeMs: Long,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val headers: List<Pair<String, String>>,
|
||||||
|
val body: String,
|
||||||
|
val bytes: ByteArray = ByteArray(0),
|
||||||
|
val contentType: String? = null,
|
||||||
|
val requestHeaders: List<Pair<String, String>> = emptyList(), // headers actually sent
|
||||||
|
val httpVersion: String = "HTTP/1.1", // from Ktor's response.version, e.g. "HTTP/1.1" or "HTTP/2"
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True when the response carries an image payload (rendered, not shown as text). */
|
||||||
|
val ApiResponse.isImage: Boolean
|
||||||
|
get() = contentType?.startsWith("image/", ignoreCase = true) == true
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
|
import apidemo.compat.systemFileSystem
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import okio.FileSystem
|
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
@@ -16,10 +16,10 @@ private val fJson = Json {
|
|||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write the pack as pretty JSON to inPath. Returns null on success, else the
|
/** Write the pack as pretty JSON to inPath. Returns null on success, else the
|
||||||
error message. */
|
error message. */
|
||||||
fun exportPack(inPack: Pack, inPath: String): String? = try {
|
fun exportPack(inPack: Pack, inPath: String): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
systemFileSystem.write(inPath.trim().toPath()) {
|
||||||
writeUtf8(fJson.encodeToString(inPack))
|
writeUtf8(fJson.encodeToString(inPack))
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
@@ -27,17 +27,17 @@ fun exportPack(inPack: Pack, inPath: String): String? = try {
|
|||||||
e.message ?: e.toString()
|
e.message ?: e.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read a pack back from inPath. */
|
/** Read a pack back from inPath. */
|
||||||
fun importPack(inPath: String): Result<Pack> = try {
|
fun importPack(inPath: String): Result<Pack> = try {
|
||||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||||
Result.success(fJson.decodeFromString<Pack>(vText))
|
Result.success(fJson.decodeFromString<Pack>(vText))
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write a whole self-contained session to inPath. Returns null on success. */
|
/** Write a whole self-contained session to inPath. Returns null on success. */
|
||||||
fun exportSession(inSession: Session, inPath: String): String? = try {
|
fun exportSession(inSession: Session, inPath: String): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
systemFileSystem.write(inPath.trim().toPath()) {
|
||||||
writeUtf8(fJson.encodeToString(inSession))
|
writeUtf8(fJson.encodeToString(inSession))
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
@@ -45,47 +45,47 @@ fun exportSession(inSession: Session, inPath: String): String? = try {
|
|||||||
e.message ?: e.toString()
|
e.message ?: e.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read a session back from inPath. */
|
/** Read a session back from inPath. */
|
||||||
fun importSession(inPath: String): Result<Session> = try {
|
fun importSession(inPath: String): Result<Session> = try {
|
||||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||||
Result.success(fJson.decodeFromString<Session>(vText))
|
Result.success(fJson.decodeFromString<Session>(vText))
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rename inPath to inNewName within the same directory (adding a .json
|
/** Rename inPath to inNewName within the same directory (adding a .json
|
||||||
extension when the new name has none), returning the new path on success. */
|
extension when the new name has none), returning the new path on success. */
|
||||||
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
|
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
|
||||||
val vSrc = inPath.trim().toPath()
|
val vSrc = inPath.trim().toPath()
|
||||||
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
|
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
|
||||||
val vDst = vSrc.parent?.div(vLeaf) ?: vLeaf.toPath()
|
val vDst = vSrc.parent?.div(vLeaf) ?: vLeaf.toPath()
|
||||||
FileSystem.SYSTEM.atomicMove(vSrc, vDst)
|
systemFileSystem.atomicMove(vSrc, vDst)
|
||||||
Result.success(vDst.toString())
|
Result.success(vDst.toString())
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write arbitrary text (a response body / headers dump) to inPath. Returns null
|
/** Write arbitrary text (a response body / headers dump) to inPath. Returns null
|
||||||
on success, else the error message. */
|
on success, else the error message. */
|
||||||
fun writeTextFile(inPath: String, inText: String): String? = try {
|
fun writeTextFile(inPath: String, inText: String): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
||||||
null
|
null
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
e.message ?: e.toString()
|
e.message ?: e.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write raw bytes (a binary response body, e.g. an image) to inPath. */
|
/** Write raw bytes (a binary response body, e.g. an image) to inPath. */
|
||||||
fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
|
fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { write(inBytes) }
|
systemFileSystem.write(inPath.trim().toPath()) { write(inBytes) }
|
||||||
null
|
null
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
e.message ?: e.toString()
|
e.message ?: e.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Re-indent a JSON response for display; returns the input unchanged if it
|
/** Re-indent a JSON response for display; returns the input unchanged if it
|
||||||
isn't valid JSON (so plain-text / HTML responses still show). */
|
isn't valid JSON (so plain-text / HTML responses still show). */
|
||||||
fun prettyJsonOrRaw(inText: String): String = try {
|
fun prettyJsonOrRaw(inText: String): String = try {
|
||||||
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
|
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
inText
|
inText
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import com.compose.sdl.appDataDir
|
import apidemo.compat.appDataDir
|
||||||
import com.compose.sdl.revealInFileManager
|
import apidemo.compat.revealInFileManager
|
||||||
|
import apidemo.compat.systemFileSystem
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okio.FileSystem
|
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Persisted app state
|
// MARK: Persisted app state
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Everything the app restores on relaunch: the theme, the global override env,
|
/** Everything the app restores on relaunch: the theme, the global override env,
|
||||||
every open pack (with its file path + dirty flag so unsaved work survives a
|
every open pack (with its file path + dirty flag so unsaved work survives a
|
||||||
quit) and which pack was active. `launched` flips to true after the very
|
quit) and which pack was active. `launched` flips to true after the very
|
||||||
first run so the httpbin starter pack is only auto-loaded once. */
|
first run so the httpbin starter pack is only auto-loaded once. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppState(
|
data class AppState(
|
||||||
val launched: Boolean = false,
|
val launched: Boolean = false,
|
||||||
val dark: Boolean = true,
|
val dark: Boolean = true,
|
||||||
|
val theme: String = "purple", // selected voltic palette id (VolticPalette.id)
|
||||||
val globalEnv: List<KeyVal> = emptyList(), // session-level variables (base of the inheritance ladder)
|
val globalEnv: List<KeyVal> = emptyList(), // session-level variables (base of the inheritance ladder)
|
||||||
val globalHeaders: List<KeyVal> = emptyList(), // session-level headers (inherited by every request)
|
val globalHeaders: List<KeyVal> = emptyList(), // session-level headers (inherited by every request)
|
||||||
val globalParams: List<KeyVal> = emptyList(), // session-level query params (inherited by every request)
|
val globalParams: List<KeyVal> = emptyList(), // session-level query params (inherited by every request)
|
||||||
@@ -36,8 +37,8 @@ data class AppState(
|
|||||||
val activeReq: Int = -1,
|
val activeReq: Int = -1,
|
||||||
)
|
)
|
||||||
|
|
||||||
/* A pack as stored in the app state: the pack content plus where it was last
|
/** A pack as stored in the app state: the pack content plus where it was last
|
||||||
saved (null = never saved to a file) and whether it has unsaved edits. */
|
saved (null = never saved to a file) and whether it has unsaved edits. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SavedPack(
|
data class SavedPack(
|
||||||
val path: String? = null,
|
val path: String? = null,
|
||||||
@@ -45,9 +46,9 @@ data class SavedPack(
|
|||||||
val pack: Pack = Pack(),
|
val pack: Pack = Pack(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/* A whole session, the export/import unit for the working set: every open pack
|
/** A whole session, the export/import unit for the working set: every open pack
|
||||||
(self-contained — full content embedded) plus the shared global env and which
|
(self-contained — full content embedded) plus the shared global env and which
|
||||||
pack was active. Only one session is open at a time. */
|
pack was active. Only one session is open at a time. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Session(
|
data class Session(
|
||||||
val packs: List<SavedPack> = emptyList(),
|
val packs: List<SavedPack> = emptyList(),
|
||||||
@@ -74,40 +75,40 @@ private const val kOrg = "ComposeDesktopNative"
|
|||||||
private const val kApp = "ApiManager"
|
private const val kApp = "ApiManager"
|
||||||
private const val kStateFile = "state.json"
|
private const val kStateFile = "state.json"
|
||||||
|
|
||||||
/* Absolute path to the state file in the per-user app-data dir (created on
|
/** Absolute path to the state file in the per-user app-data dir (created on
|
||||||
demand by SDL_GetPrefPath). Null if no writable dir is available. */
|
demand by SDL_GetPrefPath). Null if no writable dir is available. */
|
||||||
private fun stateFilePath(): String? {
|
private fun stateFilePath(): String? {
|
||||||
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
|
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
|
||||||
return vDir + kStateFile
|
return vDir + kStateFile
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read the persisted app state, or a fresh (first-launch) AppState if there's
|
/** Read the persisted app state, or a fresh (first-launch) AppState if there's
|
||||||
nothing saved yet or it can't be read. */
|
nothing saved yet or it can't be read. */
|
||||||
fun loadAppState(): AppState {
|
fun loadAppState(): AppState {
|
||||||
val vPath = stateFilePath() ?: return AppState()
|
val vPath = stateFilePath() ?: return AppState()
|
||||||
return try {
|
return try {
|
||||||
val vText = FileSystem.SYSTEM.read(vPath.toPath()) { readUtf8() }
|
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
|
||||||
fStateJson.decodeFromString<AppState>(vText)
|
fStateJson.decodeFromString<AppState>(vText)
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
AppState()
|
AppState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write the app state. Best-effort — failures are swallowed (it's a cache of
|
/** Write the app state. Best-effort — failures are swallowed (it's a cache of
|
||||||
the session, not the user's exported .json packs). */
|
the session, not the user's exported .json packs). */
|
||||||
fun saveAppState(inState: AppState) {
|
fun saveAppState(inState: AppState) {
|
||||||
val vPath = stateFilePath() ?: return
|
val vPath = stateFilePath() ?: return
|
||||||
try {
|
try {
|
||||||
FileSystem.SYSTEM.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
|
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
// ignore — nothing the user can act on, and packs can still be exported
|
// ignore — nothing the user can act on, and packs can still be exported
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Open the per-user app-data folder where the state file lives in the OS file
|
/** Open the per-user app-data folder where the state file lives in the OS file
|
||||||
manager. Revealing the state file opens its containing folder, which SDL has
|
manager. Revealing the state file opens its containing folder, which SDL has
|
||||||
already created even when nothing's been saved yet. Fire-and-forget — the
|
already created even when nothing's been saved yet. Fire-and-forget — the
|
||||||
reveal launches off the main thread (see revealInFileManager). */
|
reveal launches off the main thread (see revealInFileManager). */
|
||||||
fun openSettingsFolder() {
|
fun openSettingsFolder() {
|
||||||
val vPath = stateFilePath() ?: return
|
val vPath = stateFilePath() ?: return
|
||||||
revealInFileManager(vPath)
|
revealInFileManager(vPath)
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -32,12 +30,18 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|||||||
internal fun OptionsMenu(
|
internal fun OptionsMenu(
|
||||||
inDark: Boolean,
|
inDark: Boolean,
|
||||||
inOnToggleTheme: () -> Unit,
|
inOnToggleTheme: () -> Unit,
|
||||||
|
inPalette: VolticPalette,
|
||||||
|
inOnPalette: (VolticPalette) -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
var vOpen by remember { mutableStateOf(false) }
|
var vOpen by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
IconBtn(MaterialSymbols.Settings, "Options") { vOpen = true }
|
IconBtn(MaterialSymbols.Settings, "Options") { vOpen = true }
|
||||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 200.dp)) {
|
DropdownMenu(
|
||||||
|
expanded = vOpen,
|
||||||
|
onDismissRequest = { vOpen = false },
|
||||||
|
modifier = Modifier.widthIn(min = 200.dp)
|
||||||
|
) {
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
Text("Dark mode", color = if (inDark) c.accent else c.text, fontSize = 13.sp)
|
Text("Dark mode", color = if (inDark) c.accent else c.text, fontSize = 13.sp)
|
||||||
}, onClick = { if (!inDark) inOnToggleTheme(); vOpen = false })
|
}, onClick = { if (!inDark) inOnToggleTheme(); vOpen = false })
|
||||||
@@ -45,6 +49,32 @@ internal fun OptionsMenu(
|
|||||||
Text("Light mode", color = if (!inDark) c.accent else c.text, fontSize = 13.sp)
|
Text("Light mode", color = if (!inDark) c.accent else c.text, fontSize = 13.sp)
|
||||||
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
|
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
|
// Palette picker — a swatch of each theme's primary + a check on the active one.
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 2.dp)) {
|
||||||
|
Text("Theme", color = c.dim, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
VolticPalette.entries.forEach { vPal ->
|
||||||
|
val vSel = vPal == inPalette
|
||||||
|
DropdownMenuItem(text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(12.dp).clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(vPal.scheme.light.primary)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
vPal.label,
|
||||||
|
color = if (vSel) c.accent else c.text,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (vSel) MaterialSymbolsOutlined(MaterialSymbols.Check, tint = c.accent, size = 16.dp)
|
||||||
|
}
|
||||||
|
}, onClick = { inOnPalette(vPal); vOpen = false })
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = c.border)
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(MaterialSymbols.Folder, "Open settings folder")
|
MenuRow(MaterialSymbols.Folder, "Open settings folder")
|
||||||
}, onClick = { vOpen = false; openSettingsFolder() })
|
}, onClick = { vOpen = false; openSettingsFolder() })
|
||||||
@@ -56,8 +86,8 @@ internal fun OptionsMenu(
|
|||||||
// MARK: Method picker (dropdown)
|
// MARK: Method picker (dropdown)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
|
/** Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
|
||||||
a borderless URL field that melts into the bar, and Send (or Cancel). */
|
a borderless URL field that melts into the bar, and Send (or Cancel). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun UrlBar(
|
internal fun UrlBar(
|
||||||
inReq: ApiRequest,
|
inReq: ApiRequest,
|
||||||
@@ -99,16 +129,18 @@ internal fun UrlBar(
|
|||||||
// Borderless URL field — no box, so it reads as part of the bar.
|
// Borderless URL field — no box, so it reads as part of the bar.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.weight(1f).alpha(if (inReadOnly) 0.55f else 1f).onKeyEvent { ev ->
|
modifier = Modifier.weight(1f).alpha(if (inReadOnly) 0.55f else 1f).onKeyEvent { ev ->
|
||||||
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) { inOnSend(); true } else false
|
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
|
||||||
|
inOnSend(); true
|
||||||
|
} else false
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (inReq.url.isEmpty()) Text("https://example.com/path", color = c.dim, fontSize = 14.sp)
|
if (inReq.url.isEmpty()) Text("https://example.com/path", color = c.dim, fontSize = 14.sp)
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
||||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||||
handleColor = c.accent,
|
handleColor = c.accent,
|
||||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inReq.url,
|
value = inReq.url,
|
||||||
@@ -133,7 +165,10 @@ internal fun UrlBar(
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.clip(RoundedCornerShape(6.dp))
|
modifier = Modifier.clip(RoundedCornerShape(6.dp))
|
||||||
.background(if (vLockHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(6.dp))
|
.background(
|
||||||
|
if (vLockHover) c.accent.copy(alpha = 0.18f) else Color.Transparent,
|
||||||
|
RoundedCornerShape(6.dp)
|
||||||
|
)
|
||||||
.hoverable(vLockHoverSrc)
|
.hoverable(vLockHoverSrc)
|
||||||
.clickable(onClick = inOnInspectChain)
|
.clickable(onClick = inOnInspectChain)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
@@ -141,8 +176,10 @@ internal fun UrlBar(
|
|||||||
if (inChainLoading) {
|
if (inChainLoading) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = c.accent, strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = c.accent, strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.Lock, contentDescription = "Inspect TLS chain",
|
MaterialSymbolsOutlined(
|
||||||
tint = if (vLockHover) c.accent else c.dim, size = 18.dp)
|
MaterialSymbols.Lock, contentDescription = "Inspect TLS chain",
|
||||||
|
tint = if (vLockHover) c.accent else c.dim, size = 18.dp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,13 +188,16 @@ internal fun UrlBar(
|
|||||||
// so the bar never changes height when toggling — Cancel is just red.
|
// so the bar never changes height when toggling — Cancel is just red.
|
||||||
if (inLoading) Button(
|
if (inLoading) Button(
|
||||||
onClick = inOnCancel,
|
onClick = inOnCancel,
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = methodColor(ReqMethod.DELETE), contentColor = Color.White),
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = methodColor(ReqMethod.DELETE),
|
||||||
|
contentColor = Color.White
|
||||||
|
),
|
||||||
) { BtnContent(MaterialSymbols.Stop, "Cancel", Color.White) }
|
) { BtnContent(MaterialSymbols.Stop, "Cancel", Color.White) }
|
||||||
else Button(onClick = inOnSend) { BtnContent(MaterialSymbols.Send, "Send", c.onAccent) }
|
else Button(onClick = inOnSend) { BtnContent(MaterialSymbols.Send, "Send", c.onAccent) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom-of-panel-3 dropdown choosing the body type (None/Text/Form/File). */
|
/** Bottom-of-panel-3 dropdown choosing the body type (None/Text/Form/File). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BodyTypeMenu(inType: BodyType, inEnabled: Boolean, inOnPick: (BodyType) -> Unit) {
|
internal fun BodyTypeMenu(inType: BodyType, inEnabled: Boolean, inOnPick: (BodyType) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
@@ -17,17 +15,17 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.compose.sdl.TextLayoutConfig
|
import apidemo.compat.editorTabWidth
|
||||||
|
import apidemo.compat.showOpenFileDialog
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.showOpenFileDialog
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Panel 3 — request builder (Query / Headers / Body)
|
// MARK: Panel 3 — request builder (Query / Headers / Body)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Tabs to edit query params, headers and the body, a Preview toggle top-right,
|
/** Tabs to edit query params, headers and the body, a Preview toggle top-right,
|
||||||
and a body-type dropdown pinned at the bottom. */
|
and a body-type dropdown pinned at the bottom. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RequestBuilder(
|
internal fun RequestBuilder(
|
||||||
inReq: ApiRequest,
|
inReq: ApiRequest,
|
||||||
@@ -42,7 +40,7 @@ internal fun RequestBuilder(
|
|||||||
inEdit: ((ApiRequest) -> ApiRequest) -> Unit,
|
inEdit: ((ApiRequest) -> ApiRequest) -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
val vBodySet = inReq.method.allowsBody && when (inReq.bodyType) {
|
val vBodySet = true && when (inReq.bodyType) {
|
||||||
BodyType.NONE -> false
|
BodyType.NONE -> false
|
||||||
BodyType.FORM -> inReq.form.any { it.enabled && it.key.isNotBlank() }
|
BodyType.FORM -> inReq.form.any { it.enabled && it.key.isNotBlank() }
|
||||||
else -> inReq.body.isNotBlank()
|
else -> inReq.body.isNotBlank()
|
||||||
@@ -55,10 +53,10 @@ internal fun RequestBuilder(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = kWarnColor, size = 15.dp)
|
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = VolticTheme.extended.warning, size = 15.dp)
|
||||||
Text(
|
Text(
|
||||||
"Undefined: ${inUnresolved.joinToString(", ") { "{{$it}}" }}",
|
"Undefined: ${inUnresolved.joinToString(", ") { "{{$it}}" }}",
|
||||||
color = kWarnColor,
|
color = VolticTheme.extended.warning,
|
||||||
fontSize = 11.sp
|
fontSize = 11.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,8 +145,8 @@ internal fun RequestBuilder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The body editing area for the current body type (driven by the bottom menu). A
|
/** The body editing area for the current body type (driven by the bottom menu). A
|
||||||
Text body is highlighted per its chosen format (bodyFormat). */
|
Text body is highlighted per its chosen format (bodyFormat). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
||||||
when (inReq.bodyType) {
|
when (inReq.bodyType) {
|
||||||
@@ -159,21 +157,22 @@ internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest)
|
|||||||
inOnChange = { v -> inEdit { it.copy(body = v) } },
|
inOnChange = { v -> inEdit { it.copy(body = v) } },
|
||||||
inFormat = inReq.bodyFormat,
|
inFormat = inReq.bodyFormat,
|
||||||
)
|
)
|
||||||
|
|
||||||
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
|
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
|
||||||
BodyType.FILE -> FileBody(inReq) { v -> inEdit(v) }
|
BodyType.FILE -> FileBody(inReq) { v -> inEdit(v) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Magic-wand button that pretty-prints the current body in place (JSON /
|
/** Magic-wand button that pretty-prints the current body in place (JSON /
|
||||||
XML). Lives at the right end of the body type/format bar. */
|
XML). Lives at the right end of the body type/format bar. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FormatButton(inOnClick: () -> Unit) {
|
internal fun FormatButton(inOnClick: () -> Unit) {
|
||||||
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
|
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon button with the toolbar's standard hover treatment — accent-tinted
|
/** Icon button with the toolbar's standard hover treatment — accent-tinted
|
||||||
background + accent icon on hover (matching the TLS chain button). inActive
|
background + accent icon on hover (matching the TLS chain button). inActive
|
||||||
keeps it lit, for toggles such as the preview eye. */
|
keeps it lit, for toggles such as the preview eye. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HoverIconBtn(
|
internal fun HoverIconBtn(
|
||||||
inIcon: Int,
|
inIcon: Int,
|
||||||
@@ -188,8 +187,8 @@ internal fun HoverIconBtn(
|
|||||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||||
val vBg = when {
|
val vBg = when {
|
||||||
inActive -> c.accent.copy(alpha = 0.20f)
|
inActive -> c.accent.copy(alpha = 0.20f)
|
||||||
vHover -> c.accent.copy(alpha = 0.18f)
|
vHover -> c.accent.copy(alpha = 0.18f)
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
@@ -204,22 +203,24 @@ internal fun HoverIconBtn(
|
|||||||
.clickable(onClick = inOnClick)
|
.clickable(onClick = inOnClick)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
) {
|
) {
|
||||||
MaterialSymbolsOutlined(inIcon, contentDescription = inTooltip,
|
MaterialSymbolsOutlined(
|
||||||
tint = if (inActive || vHover) c.accent else c.dim, size = inSize)
|
inIcon, contentDescription = inTooltip,
|
||||||
|
tint = if (inActive || vHover) c.accent else c.dim, size = inSize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab-size picker (2 / 4 / 8), shown left of the format button. Sets the global
|
/** Tab-size picker (2 / 4 / 8), shown left of the format button. Sets the global
|
||||||
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
|
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
|
||||||
deep the formatter indents. Snapshot-backed, so the change is live. */
|
deep the formatter indents. Snapshot-backed, so the change is live. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TabSizeSelector() {
|
internal fun TabSizeSelector() {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
var vOpen by remember { mutableStateOf(false) }
|
var vOpen by remember { mutableStateOf(false) }
|
||||||
val vHoverSrc = remember { MutableInteractionSource() }
|
val vHoverSrc = remember { MutableInteractionSource() }
|
||||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||||
val vSize = TextLayoutConfig.tabWidth
|
val vSize = editorTabWidth
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
tooltip = { PlainTooltip { Text("Editor tab size") } },
|
tooltip = { PlainTooltip { Text("Editor tab size") } },
|
||||||
@@ -229,7 +230,10 @@ internal fun TabSizeSelector() {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(6.dp))
|
.background(
|
||||||
|
if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent,
|
||||||
|
RoundedCornerShape(6.dp)
|
||||||
|
)
|
||||||
.hoverable(vHoverSrc)
|
.hoverable(vHoverSrc)
|
||||||
.clickable { vOpen = true }
|
.clickable { vOpen = true }
|
||||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||||
@@ -237,20 +241,24 @@ internal fun TabSizeSelector() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
) {
|
) {
|
||||||
Text("Tab $vSize", color = if (vHover) c.accent else c.dim, fontSize = 12.sp)
|
Text("Tab $vSize", color = if (vHover) c.accent else c.dim, fontSize = 12.sp)
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.UnfoldMore, tint = if (vHover) c.accent else c.dim, size = 14.dp)
|
MaterialSymbolsOutlined(
|
||||||
|
MaterialSymbols.UnfoldMore,
|
||||||
|
tint = if (vHover) c.accent else c.dim,
|
||||||
|
size = 14.dp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
||||||
for (vN in listOf(2, 4, 8)) {
|
for (vN in listOf(2, 4, 8)) {
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
Text("$vN spaces", color = if (vN == vSize) c.accent else c.text)
|
Text("$vN spaces", color = if (vN == vSize) c.accent else c.text)
|
||||||
}, onClick = { TextLayoutConfig.tabWidth = vN; vOpen = false })
|
}, onClick = { editorTabWidth = vN; vOpen = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FILE body — pick a file; its path is stored in body and sent as raw bytes. */
|
/** FILE body — pick a file; its path is stored in body and sent as raw bytes. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -263,12 +271,17 @@ internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Client-certificate (mTLS) editor — certificate + optional separate key +
|
/** Client-certificate (mTLS) editor — certificate + optional separate key +
|
||||||
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
|
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
|
||||||
they're imported into the certificate store for the request then removed
|
they're imported into the certificate store for the request then removed
|
||||||
(see CurlMtls). PKCS#12 bundles its own private key. */
|
(see CurlMtls). PKCS#12 bundles its own private key. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client certificate (mTLS)", inOverrideSource: String? = null, inOnChange: (CertConfig) -> Unit) {
|
internal fun CertConfigEditor(
|
||||||
|
inCert: CertConfig,
|
||||||
|
inHeading: String = "Client certificate (mTLS)",
|
||||||
|
inOverrideSource: String? = null,
|
||||||
|
inOnChange: (CertConfig) -> Unit
|
||||||
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
val vHasCert = inCert.certPath.isNotBlank()
|
val vHasCert = inCert.certPath.isNotBlank()
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
@@ -293,7 +306,11 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
|||||||
inOnChange(CertConfig())
|
inOnChange(CertConfig())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(if (vHasCert) inCert.certPath else "No certificate selected.", color = if (vHasCert) c.text else c.dim, fontSize = 12.sp)
|
Text(
|
||||||
|
if (vHasCert) inCert.certPath else "No certificate selected.",
|
||||||
|
color = if (vHasCert) c.text else c.dim,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Private key (PKCS#12 carries its own key)
|
// Private key (PKCS#12 carries its own key)
|
||||||
@@ -304,10 +321,17 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
|||||||
showOpenFileDialog { vPath -> if (vPath != null) inOnChange(inCert.copy(keyPath = vPath)) }
|
showOpenFileDialog { vPath -> if (vPath != null) inOnChange(inCert.copy(keyPath = vPath)) }
|
||||||
}
|
}
|
||||||
CertFormatMenu(inCert.keyFormat) { vF -> inOnChange(inCert.copy(keyFormat = vF)) }
|
CertFormatMenu(inCert.keyFormat) { vF -> inOnChange(inCert.copy(keyFormat = vF)) }
|
||||||
if (inCert.keyPath.isNotBlank()) IconBtn(MaterialSymbols.Close, "Clear key") { inOnChange(inCert.copy(keyPath = "", keyFormat = CertFormat.PEM)) }
|
if (inCert.keyPath.isNotBlank()) IconBtn(MaterialSymbols.Close, "Clear key") {
|
||||||
|
inOnChange(
|
||||||
|
inCert.copy(
|
||||||
|
keyPath = "",
|
||||||
|
keyFormat = CertFormat.PEM
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
if (inCert.keyPath.isNotBlank()) inCert.keyPath else "Optional — only if the key is in a separate file.",
|
inCert.keyPath.ifBlank { "Optional — only if the key is in a separate file." },
|
||||||
color = if (inCert.keyPath.isNotBlank()) c.text else c.dim, fontSize = 12.sp,
|
color = if (inCert.keyPath.isNotBlank()) c.text else c.dim, fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -315,17 +339,27 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
|||||||
// ============
|
// ============
|
||||||
// Passphrase
|
// Passphrase
|
||||||
Text("Passphrase", color = c.dim, fontSize = 12.sp)
|
Text("Passphrase", color = c.dim, fontSize = 12.sp)
|
||||||
ThinField(inCert.certPassword, { v -> inOnChange(inCert.copy(certPassword = v)) }, inPlaceholder = "Key / PKCS#12 password (optional)")
|
ThinField(
|
||||||
|
inCert.certPassword,
|
||||||
|
{ v -> inOnChange(inCert.copy(certPassword = v)) },
|
||||||
|
inPlaceholder = "Key / PKCS#12 password (optional)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A read-only card for an inherited client cert: its source pill + path, plus an
|
/** A read-only card for an inherited client cert: its source pill + path, plus an
|
||||||
Override action (copy it into this scope) when this scope hasn't set its own. */
|
Override action (copy it into this scope) when this scope hasn't set its own. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, inReadOnly: Boolean, inOnOverride: () -> Unit) {
|
internal fun InheritedCertCard(
|
||||||
|
inInherited: InheritedCert,
|
||||||
|
inOwnSet: Boolean,
|
||||||
|
inReadOnly: Boolean,
|
||||||
|
inOnOverride: () -> Unit
|
||||||
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
|
||||||
|
.border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -343,23 +377,35 @@ internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The request's Cert tab: the inherited cert (from pack / session) shown read-only
|
/** The request's Cert tab: the inherited cert (from pack / session) shown read-only
|
||||||
with its source + an Override action when the request has none, then the
|
with its source + an Override action when the request has none, then the
|
||||||
request's own cert editor (its own cert overrides the inherited one). */
|
request's own cert editor (its own cert overrides the inherited one). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RequestCertTab(inReq: ApiRequest, inInheritedCert: InheritedCert?, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) {
|
internal fun RequestCertTab(
|
||||||
|
inReq: ApiRequest,
|
||||||
|
inInheritedCert: InheritedCert?,
|
||||||
|
inReadOnly: Boolean,
|
||||||
|
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
|
||||||
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = inReq.hasClientCert, inReadOnly = inReadOnly) {
|
if (inInheritedCert != null) InheritedCertCard(
|
||||||
|
inInheritedCert,
|
||||||
|
inOwnSet = inReq.hasClientCert,
|
||||||
|
inReadOnly = inReadOnly
|
||||||
|
) {
|
||||||
inEdit { it.withCert(inInheritedCert.cert) }
|
inEdit { it.withCert(inInheritedCert.cert) }
|
||||||
}
|
}
|
||||||
CertConfigEditor(inReq.certConfig(), inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null) { vCc -> inEdit { it.withCert(vCc) } }
|
CertConfigEditor(
|
||||||
|
inReq.certConfig(),
|
||||||
|
inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null
|
||||||
|
) { vCc -> inEdit { it.withCert(vCc) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
|
/** Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
|
||||||
the session settings tab and each pack's settings tab. Each sub-tab shows what
|
the session settings tab and each pack's settings tab. Each sub-tab shows what
|
||||||
the scope inherits from above (source-tagged, with Override) over its own
|
the scope inherits from above (source-tagged, with Override) over its own
|
||||||
editable list, mirroring the request panels. */
|
editable list, mirroring the request panels. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ScopeSettings(
|
internal fun ScopeSettings(
|
||||||
inTab: Int,
|
inTab: Int,
|
||||||
@@ -390,33 +436,50 @@ internal fun ScopeSettings(
|
|||||||
when (inTab) {
|
when (inTab) {
|
||||||
0 -> {
|
0 -> {
|
||||||
Text(inVarHelp, color = c.dim, fontSize = 12.sp)
|
Text(inVarHelp, color = c.dim, fontSize = 12.sp)
|
||||||
InheritedEditableTab(inInheritedVars, inVars, inCaseInsensitive = false, inReadOnly = false,
|
InheritedEditableTab(
|
||||||
|
inInheritedVars, inVars, inCaseInsensitive = false, inReadOnly = false,
|
||||||
inOwnTitle = "This scope's variables", inInheritedTitle = "Inherited variables",
|
inOwnTitle = "This scope's variables", inInheritedTitle = "Inherited variables",
|
||||||
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars)
|
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
1 -> {
|
1 -> {
|
||||||
Text(inParamHelp, color = c.dim, fontSize = 12.sp)
|
Text(inParamHelp, color = c.dim, fontSize = 12.sp)
|
||||||
InheritedEditableTab(inInheritedParams, inParams, inCaseInsensitive = false, inReadOnly = false,
|
InheritedEditableTab(
|
||||||
|
inInheritedParams, inParams, inCaseInsensitive = false, inReadOnly = false,
|
||||||
inOwnTitle = "This scope's query params", inInheritedTitle = "Inherited query params",
|
inOwnTitle = "This scope's query params", inInheritedTitle = "Inherited query params",
|
||||||
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams)
|
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
2 -> {
|
2 -> {
|
||||||
Text(inHeaderHelp, color = c.dim, fontSize = 12.sp)
|
Text(inHeaderHelp, color = c.dim, fontSize = 12.sp)
|
||||||
InheritedEditableTab(inInheritedHeaders, inHeaders, inCaseInsensitive = true, inReadOnly = false,
|
InheritedEditableTab(
|
||||||
|
inInheritedHeaders, inHeaders, inCaseInsensitive = true, inReadOnly = false,
|
||||||
inOwnTitle = "This scope's headers", inInheritedTitle = "Inherited headers",
|
inOwnTitle = "This scope's headers", inInheritedTitle = "Inherited headers",
|
||||||
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders)
|
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Text(inCertHelp, color = c.dim, fontSize = 12.sp)
|
Text(inCertHelp, color = c.dim, fontSize = 12.sp)
|
||||||
val vOwnSet = inCert?.isSet == true
|
val vOwnSet = inCert?.isSet == true
|
||||||
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = vOwnSet, inReadOnly = false) { inOnCert(inInheritedCert.cert) }
|
if (inInheritedCert != null) InheritedCertCard(
|
||||||
CertConfigEditor(inCert ?: CertConfig(), inCertHeading, inOverrideSource = if (vOwnSet) inInheritedCert?.path else null) { vCc -> inOnCert(vCc.takeIf { it.isSet }) }
|
inInheritedCert,
|
||||||
|
inOwnSet = vOwnSet,
|
||||||
|
inReadOnly = false
|
||||||
|
) { inOnCert(inInheritedCert.cert) }
|
||||||
|
CertConfigEditor(
|
||||||
|
inCert ?: CertConfig(),
|
||||||
|
inCertHeading,
|
||||||
|
inOverrideSource = if (vOwnSet) inInheritedCert?.path else null
|
||||||
|
) { vCc -> inOnCert(vCc.takeIf { it.isSet }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Certificate / key encoding dropdown (PEM / DER / PKCS#12). */
|
/** Certificate / key encoding dropdown (PEM / DER / PKCS#12). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CertFormatMenu(inFormat: CertFormat, inOnPick: (CertFormat) -> Unit) {
|
internal fun CertFormatMenu(inFormat: CertFormat, inOnPick: (CertFormat) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
|
|
||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerButton
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import apidemo.compat.x
|
||||||
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Request tab strip (open requests; drag a tab to reorder)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Strip of open requests above the editor. Each tab is clickable to select
|
||||||
|
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
|
||||||
|
fades, and follows the cursor via a graphicsLayer translation — its
|
||||||
|
neighbours don't shuffle; the reorder commits on drop, to the slot whose
|
||||||
|
neighbours' centres the cursor passed. key(tab) keeps each tab's
|
||||||
|
LayoutNode stable so the drag session survives the reorder. */
|
||||||
|
@Composable
|
||||||
|
internal fun RequestTabStrip(
|
||||||
|
inTabs: List<StripTab>,
|
||||||
|
inActiveKey: Any?,
|
||||||
|
inOnSelect: (StripTab) -> Unit,
|
||||||
|
inOnClose: (StripTab) -> Unit,
|
||||||
|
inOnCloseOthers: (StripTab) -> Unit,
|
||||||
|
inOnCloseAll: () -> Unit,
|
||||||
|
inOnReorder: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val c = LocalAppColors.current
|
||||||
|
val vScroll = rememberScrollState() // strip's horizontal scroll
|
||||||
|
val vLeft = remember { mutableStateMapOf<Any, Int>() } // window-x of each tab's left edge, by tab key
|
||||||
|
val vWidth = remember { mutableStateMapOf<Any, Int>() } // measured tab width, by tab key
|
||||||
|
var vDragKey by remember { mutableStateOf<Any?>(null) }
|
||||||
|
var vPressRelX by remember { mutableStateOf(0) }
|
||||||
|
var vDragDx by remember { mutableStateOf(0f) }
|
||||||
|
var vDragTarget by remember { mutableStateOf(-1) }
|
||||||
|
|
||||||
|
// One shared context menu, anchored to the tab that opened it (m3 DropdownMenu
|
||||||
|
// is parent-anchored, not cursor-anchored — the old x/y capture is gone).
|
||||||
|
var vMenu by remember { mutableStateOf(false) }
|
||||||
|
var vMenuTab by remember { mutableStateOf<StripTab?>(null) }
|
||||||
|
|
||||||
|
var vListOpen by remember { mutableStateOf(false) } // overflow list
|
||||||
|
|
||||||
|
val vShowBar = vDragKey != null && vDragDx != 0f
|
||||||
|
val vOthers = if (vShowBar) inTabs.filter { it.tabKey != vDragKey } else emptyList()
|
||||||
|
val vBarBeforeKey = if (vShowBar) vOthers.getOrNull(vDragTarget)?.tabKey else null
|
||||||
|
val vBarAtEnd = vShowBar && vDragTarget >= vOthers.size
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().background(c.panel),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
inTabs.forEach { vTab ->
|
||||||
|
val vKey = vTab.tabKey
|
||||||
|
if (vKey == vBarBeforeKey) DropBar()
|
||||||
|
key(vKey) {
|
||||||
|
val vSel = vKey == inActiveKey
|
||||||
|
val vDragged = vKey == vDragKey
|
||||||
|
|
||||||
|
var vMod = Modifier
|
||||||
|
.onGloballyPositioned { vLeft[vKey] = it.x }
|
||||||
|
.onSizeChanged { vWidth[vKey] = it.width }
|
||||||
|
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
|
||||||
|
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
|
||||||
|
// applied in the ALPHA layer's local coord space (before any child layer's
|
||||||
|
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
|
||||||
|
// ghost inside its original bounds and the ghost disappears as it slides. One
|
||||||
|
// layer with both properties = clip stays false, alpha modulates via layerPaint.
|
||||||
|
if (vDragged) vMod =
|
||||||
|
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
|
||||||
|
vMod = vMod
|
||||||
|
.clip(RoundedCornerShape(7.dp))
|
||||||
|
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
|
||||||
|
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
|
||||||
|
.pointerInput(vKey) {
|
||||||
|
// Accumulate the per-frame delta rather than reading change.position.
|
||||||
|
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
|
||||||
|
// which transforms the modifier's local pointer frame — so position.x
|
||||||
|
// would compensate for the translation each frame and the tab would
|
||||||
|
// trail the mouse by roughly half its offset. dragAmount is the
|
||||||
|
// root-frame delta and doesn't feed back through the layer transform.
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
vDragKey = vKey
|
||||||
|
vPressRelX = offset.x.toInt()
|
||||||
|
vDragDx = 0f
|
||||||
|
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
|
||||||
|
},
|
||||||
|
onDrag = { _, dragAmount ->
|
||||||
|
val vd = vDragKey ?: return@detectDragGestures
|
||||||
|
vDragDx += dragAmount.x
|
||||||
|
val vRelX = vPressRelX + vDragDx.toInt()
|
||||||
|
val vCursorX = (vLeft[vd] ?: 0) + vRelX
|
||||||
|
var vCount = 0
|
||||||
|
inTabs.forEach { vT ->
|
||||||
|
if (vT.tabKey != vd) {
|
||||||
|
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
|
||||||
|
if (vCursorX > vCenter) vCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vDragTarget = vCount
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val vd = vDragKey
|
||||||
|
if (vd != null) {
|
||||||
|
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
|
||||||
|
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(
|
||||||
|
vFrom,
|
||||||
|
vDragTarget
|
||||||
|
)
|
||||||
|
}
|
||||||
|
vDragKey = null; vDragDx = 0f; vDragTarget = -1
|
||||||
|
},
|
||||||
|
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Middle-click closes the tab; right-click opens the tab menu. Compose
|
||||||
|
// has no first-class match for either — inline pointerInput checks the
|
||||||
|
// pressed PointerButton at each Press.
|
||||||
|
.pointerInput(vTab) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val vEv = awaitPointerEvent()
|
||||||
|
if (vEv.type != PointerEventType.Press) continue
|
||||||
|
when (vEv.button) {
|
||||||
|
PointerButton.Tertiary -> {
|
||||||
|
vEv.changes.firstOrNull()?.consume()
|
||||||
|
inOnClose(vTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
PointerButton.Secondary -> {
|
||||||
|
vEv.changes.firstOrNull()?.consume()
|
||||||
|
vMenuTab = vTab; vMenu = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clickable { inOnSelect(vTab) }
|
||||||
|
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = vMod,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val vRs = vTab.req
|
||||||
|
if (vRs == null) {
|
||||||
|
// Session / pack settings tab.
|
||||||
|
MaterialSymbolsOutlined(
|
||||||
|
MaterialSymbols.Tune,
|
||||||
|
tint = if (vSel) c.accent else c.dim,
|
||||||
|
size = 13.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""),
|
||||||
|
color = if (vSel) c.text else c.dim,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(7.dp)
|
||||||
|
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
|
||||||
|
)
|
||||||
|
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
|
||||||
|
if (vRs.loading) CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
color = c.accent,
|
||||||
|
strokeWidth = 1.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vBarAtEnd) DropBar()
|
||||||
|
|
||||||
|
// Shared right-click menu, opened at the cursor over the clicked tab.
|
||||||
|
val vMTab = vMenuTab
|
||||||
|
if (vMenu && vMTab != null) {
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = true,
|
||||||
|
onDismissRequest = { vMenu = false },
|
||||||
|
modifier = Modifier.widthIn(min = 188.dp),
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Close, "Close tab") },
|
||||||
|
onClick = { vMenu = false; inOnClose(vMTab) })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") },
|
||||||
|
onClick = { vMenu = false; inOnCloseOthers(vMTab) })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") },
|
||||||
|
onClick = { vMenu = false; inOnCloseAll() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow → chevron opens a scrollable list of every open tab.
|
||||||
|
if (vScroll.maxValue > 0) {
|
||||||
|
Box(modifier = Modifier.padding(end = 4.dp)) {
|
||||||
|
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = vListOpen,
|
||||||
|
onDismissRequest = { vListOpen = false },
|
||||||
|
modifier = Modifier.widthIn(min = 240.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
|
||||||
|
inTabs.forEach { vTab ->
|
||||||
|
DropdownMenuItem(text = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
val vSel = vTab.tabKey == inActiveKey
|
||||||
|
val vRs = vTab.req
|
||||||
|
if (vRs == null) {
|
||||||
|
MaterialSymbolsOutlined(
|
||||||
|
MaterialSymbols.Tune,
|
||||||
|
tint = if (vSel) c.accent else c.dim,
|
||||||
|
size = 13.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (vTab.isSession) "Session" else "${vTab.pack?.name} · var",
|
||||||
|
color = if (vSel) c.accent else c.text,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(7.dp)
|
||||||
|
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
vRs.req.name,
|
||||||
|
color = if (vSel) c.accent else c.text,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (vRs.loading) CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
color = c.accent,
|
||||||
|
strokeWidth = 1.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, onClick = { vListOpen = false; inOnSelect(vTab) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
|
||||||
|
between tabs so the Row centres it vertically and reserves a little space. */
|
||||||
|
@Composable
|
||||||
|
internal fun DropBar() {
|
||||||
|
val c = LocalAppColors.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(c.accent, RoundedCornerShape(2.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@file:OptIn(
|
@file:OptIn(
|
||||||
androidx.compose.material3.ExperimentalMaterial3Api::class,
|
|
||||||
androidx.compose.ui.ExperimentalComposeUiApi::class,
|
androidx.compose.ui.ExperimentalComposeUiApi::class,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,18 +30,18 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.compose.sdl.fileManagerName
|
import apidemo.compat.fileManagerName
|
||||||
|
import apidemo.compat.revealInFileManager
|
||||||
|
import apidemo.compat.y
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.layout.y
|
|
||||||
import com.compose.sdl.revealInFileManager
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Pack colour (palette dot + picker)
|
// MARK: Pack colour (palette dot + picker)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* A small rounded square in the pack's palette colour (neutral when unset).
|
/** A small rounded square in the pack's palette colour (neutral when unset).
|
||||||
The pack's visual identity in the sidebar's foldable pack header. */
|
The pack's visual identity in the sidebar's foldable pack header. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
|
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -50,8 +49,8 @@ internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
|
|||||||
Box(Modifier.size(inSize).background(vCol, RoundedCornerShape(3.dp)))
|
Box(Modifier.size(inSize).background(vCol, RoundedCornerShape(3.dp)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
|
/** A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
|
||||||
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
|
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -80,15 +79,19 @@ internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
|||||||
// MARK: Add-pack menu (header '+')
|
// MARK: Add-pack menu (header '+')
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* The header '+' — a small menu to add a pack to the open session, either blank
|
/** The header '+' — a small menu to add a pack to the open session, either blank
|
||||||
or imported from a .json file. Importing a pack always lands it in the session. */
|
or imported from a .json file. Importing a pack always lands it in the session. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
|
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
var vOpen by remember { mutableStateOf(false) }
|
var vOpen by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
IconBtn(MaterialSymbols.Add, "Add", inSize = 18.dp) { vOpen = true }
|
IconBtn(MaterialSymbols.Add, "Add", inSize = 18.dp) { vOpen = true }
|
||||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 200.dp)) {
|
DropdownMenu(
|
||||||
|
expanded = vOpen,
|
||||||
|
onDismissRequest = { vOpen = false },
|
||||||
|
modifier = Modifier.widthIn(min = 200.dp)
|
||||||
|
) {
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Send,
|
MaterialSymbols.Send,
|
||||||
@@ -96,7 +99,9 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
|
|||||||
)
|
)
|
||||||
}, onClick = { vOpen = false; inOnNewRequest() })
|
}, onClick = { vOpen = false; inOnNewRequest() })
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New pack") }, onClick = { vOpen = false; inOnNew() })
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Add, "New pack") },
|
||||||
|
onClick = { vOpen = false; inOnNew() })
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Download,
|
MaterialSymbols.Download,
|
||||||
@@ -111,13 +116,13 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
|
|||||||
// MARK: Session menu (save / open / new / recent)
|
// MARK: Session menu (save / open / new / recent)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* The last path segment of inPath (the file name), tolerant of either slash. */
|
/** The last path segment of inPath (the file name), tolerant of either slash. */
|
||||||
internal fun fileLeaf(inPath: String): String =
|
internal fun fileLeaf(inPath: String): String =
|
||||||
inPath.trimEnd('/', '\\').substringAfterLast('/').substringAfterLast('\\')
|
inPath.trimEnd('/', '\\').substringAfterLast('/').substringAfterLast('\\')
|
||||||
|
|
||||||
/* Shortens a long path to inMax chars keeping the start and end (so the root
|
/** Shortens a long path to inMax chars keeping the start and end (so the root
|
||||||
and file stay visible), joined by an ellipsis. The menu has no text-overflow
|
and file stay visible), joined by an ellipsis. The menu has no text-overflow
|
||||||
primitive, so we trim the string ourselves. */
|
primitive, so we trim the string ourselves. */
|
||||||
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
|
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
|
||||||
if (inText.length <= inMax) return inText
|
if (inText.length <= inMax) return inText
|
||||||
val vKeep = inMax - 1
|
val vKeep = inMax - 1
|
||||||
@@ -125,10 +130,10 @@ internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
|
|||||||
return inText.take(vHead) + "…" + inText.takeLast(vKeep - vHead)
|
return inText.take(vHead) + "…" + inText.takeLast(vKeep - vHead)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header dropdown for the one open session. Shows its file name with the full
|
/** Header dropdown for the one open session. Shows its file name with the full
|
||||||
path underneath when it has a file (so you can see it's saved — file-backed
|
path underneath when it has a file (so you can see it's saved — file-backed
|
||||||
sessions auto-save), or "Untitled session" with a dot when it has no file yet.
|
sessions auto-save), or "Untitled session" with a dot when it has no file yet.
|
||||||
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
|
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SessionMenu(
|
internal fun SessionMenu(
|
||||||
inPath: String?,
|
inPath: String?,
|
||||||
@@ -155,7 +160,10 @@ internal fun SessionMenu(
|
|||||||
// Highlight on hover and stay highlighted while the menu is open.
|
// Highlight on hover and stay highlighted while the menu is open.
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(6.dp))
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(6.dp))
|
||||||
.background(if (vHover || vOpen) c.accent.copy(alpha = 0.16f) else Color.Transparent, RoundedCornerShape(6.dp))
|
.background(
|
||||||
|
if (vHover || vOpen) c.accent.copy(alpha = 0.16f) else Color.Transparent,
|
||||||
|
RoundedCornerShape(6.dp)
|
||||||
|
)
|
||||||
.hoverable(vHoverSrc)
|
.hoverable(vHoverSrc)
|
||||||
.clickable { vOpen = true }.padding(horizontal = 6.dp, vertical = 4.dp),
|
.clickable { vOpen = true }.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -169,7 +177,11 @@ internal fun SessionMenu(
|
|||||||
// No file yet → a dot; once it has a file it auto-saves, so no dot.
|
// No file yet → a dot; once it has a file it auto-saves, so no dot.
|
||||||
if (!vSaved) Box(Modifier.size(6.dp).background(c.accent, RoundedCornerShape(3.dp)))
|
if (!vSaved) Box(Modifier.size(6.dp).background(c.accent, RoundedCornerShape(3.dp)))
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 248.dp)) {
|
DropdownMenu(
|
||||||
|
expanded = vOpen,
|
||||||
|
onDismissRequest = { vOpen = false },
|
||||||
|
modifier = Modifier.widthIn(min = 248.dp)
|
||||||
|
) {
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Tune,
|
MaterialSymbols.Tune,
|
||||||
@@ -177,7 +189,9 @@ internal fun SessionMenu(
|
|||||||
)
|
)
|
||||||
}, onClick = { vOpen = false; inOnSettings() })
|
}, onClick = { vOpen = false; inOnSettings() })
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Save, "Save session") }, onClick = { vOpen = false; inOnSave() })
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Save, "Save session") },
|
||||||
|
onClick = { vOpen = false; inOnSave() })
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Download,
|
MaterialSymbols.Download,
|
||||||
@@ -193,7 +207,8 @@ internal fun SessionMenu(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { vOpen = false; inOnRename() },
|
onClick = { vOpen = false; inOnRename() },
|
||||||
enabled = vSaved)
|
enabled = vSaved
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
@@ -203,7 +218,8 @@ internal fun SessionMenu(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { vOpen = false; inOnReveal() },
|
onClick = { vOpen = false; inOnReveal() },
|
||||||
enabled = vSaved)
|
enabled = vSaved
|
||||||
|
)
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
@@ -211,7 +227,9 @@ internal fun SessionMenu(
|
|||||||
"Default session"
|
"Default session"
|
||||||
)
|
)
|
||||||
}, onClick = { vOpen = false; inOnDefault() })
|
}, onClick = { vOpen = false; inOnDefault() })
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New session") }, onClick = { vOpen = false; inOnNew() })
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Add, "New session") },
|
||||||
|
onClick = { vOpen = false; inOnNew() })
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Folder,
|
MaterialSymbols.Folder,
|
||||||
@@ -248,7 +266,9 @@ internal fun SessionMenu(
|
|||||||
// Reveal this session's folder — its own hover + tooltip so it
|
// Reveal this session's folder — its own hover + tooltip so it
|
||||||
// reads as a separate action from "open the session".
|
// reads as a separate action from "open the session".
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above
|
||||||
|
),
|
||||||
tooltip = { PlainTooltip { Text("Reveal in ${fileManagerName()}") } },
|
tooltip = { PlainTooltip { Text("Reveal in ${fileManagerName()}") } },
|
||||||
state = rememberTooltipState(),
|
state = rememberTooltipState(),
|
||||||
) {
|
) {
|
||||||
@@ -283,8 +303,8 @@ internal fun SessionMenu(
|
|||||||
// MARK: Pack tree (recursive sidebar rendering)
|
// MARK: Pack tree (recursive sidebar rendering)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* The per-pack operations a PackSection needs, bundled so PackTree can bind them
|
/** The per-pack operations a PackSection needs, bundled so PackTree can bind them
|
||||||
recursively for sub-packs while PackSection itself stays callback-driven. */
|
recursively for sub-packs while PackSection itself stays callback-driven. */
|
||||||
internal class PackOps(
|
internal class PackOps(
|
||||||
val onEnv: (PackState) -> Unit,
|
val onEnv: (PackState) -> Unit,
|
||||||
val onToggle: (PackState) -> Unit,
|
val onToggle: (PackState) -> Unit,
|
||||||
@@ -310,11 +330,18 @@ internal class PackOps(
|
|||||||
val packDropEnd: () -> Unit,
|
val packDropEnd: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
/* Renders a pack then its sub-packs recursively, each indented by its depth.
|
/** Renders a pack then its sub-packs recursively, each indented by its depth.
|
||||||
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
|
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
|
||||||
subPacks) — used to position the pack-reorder drop bars by index. */
|
subPacks) — used to position the pack-reorder drop bars by index. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: Int, inOps: PackOps, inActive: PackState?, inEnvActive: Boolean) {
|
internal fun PackTree(
|
||||||
|
inPack: PackState,
|
||||||
|
inSiblings: List<PackState>,
|
||||||
|
inDepth: Int,
|
||||||
|
inOps: PackOps,
|
||||||
|
inActive: PackState?,
|
||||||
|
inEnvActive: Boolean
|
||||||
|
) {
|
||||||
val vDrag = inOps.drag
|
val vDrag = inOps.drag
|
||||||
val vMyIndex = inSiblings.indexOf(inPack)
|
val vMyIndex = inSiblings.indexOf(inPack)
|
||||||
val vMoving = vDrag.draggingPack && vDrag.engaged && vDrag.dropInto == null
|
val vMoving = vDrag.draggingPack && vDrag.engaged && vDrag.dropInto == null
|
||||||
@@ -322,7 +349,7 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
|
|||||||
val vPad = Modifier.padding(start = (inDepth * 14).dp)
|
val vPad = Modifier.padding(start = (inDepth * 14).dp)
|
||||||
val vBarBefore = vMoving && vDrag.dropParent === inPack.parent && vDrag.dropSibIndex == vMyIndex
|
val vBarBefore = vMoving && vDrag.dropParent === inPack.parent && vDrag.dropSibIndex == vMyIndex
|
||||||
val vBarAfter = vMoving && vDrag.dropParent === inPack.parent &&
|
val vBarAfter = vMoving && vDrag.dropParent === inPack.parent &&
|
||||||
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
|
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
|
||||||
key(inPack) {
|
key(inPack) {
|
||||||
if (vBarBefore) Box(modifier = vPad) { RowDropBar() }
|
if (vBarBefore) Box(modifier = vPad) { RowDropBar() }
|
||||||
Box(modifier = vPad) {
|
Box(modifier = vPad) {
|
||||||
@@ -354,7 +381,16 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inPack.expanded) inPack.subPacks.forEach { PackTree(it, inPack.subPacks, inDepth + 1, inOps, inActive, inEnvActive) }
|
if (inPack.expanded) inPack.subPacks.forEach {
|
||||||
|
PackTree(
|
||||||
|
it,
|
||||||
|
inPack.subPacks,
|
||||||
|
inDepth + 1,
|
||||||
|
inOps,
|
||||||
|
inActive,
|
||||||
|
inEnvActive
|
||||||
|
)
|
||||||
|
}
|
||||||
if (vBarAfter) Box(modifier = vPad) { RowDropBar() }
|
if (vBarAfter) Box(modifier = vPad) { RowDropBar() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,11 +398,11 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
|
|||||||
// MARK: Pack section (foldable pack in the sidebar)
|
// MARK: Pack section (foldable pack in the sidebar)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* One open pack rendered as a foldable section: a header (fold chevron, colour
|
/** One open pack rendered as a foldable section: a header (fold chevron, colour
|
||||||
box, name, dirty dot, request count, ⋮ menu) over the pack's request list
|
box, name, dirty dot, request count, ⋮ menu) over the pack's request list
|
||||||
when expanded. Clicking the header body selects the pack (so the Env tab and
|
when expanded. Clicking the header body selects the pack (so the Env tab and
|
||||||
main panel follow it); the chevron only folds. The request list drags to
|
main panel follow it); the chevron only folds. The request list drags to
|
||||||
reorder, scoped to this pack. Right-click the header for the pack menu. */
|
reorder, scoped to this pack. Right-click the header for the pack menu. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PackSection(
|
internal fun PackSection(
|
||||||
inPack: PackState,
|
inPack: PackState,
|
||||||
@@ -407,13 +443,14 @@ internal fun PackSection(
|
|||||||
// to reorder / reparent; highlighted when it's the "drop inside" target.
|
// to reorder / reparent; highlighted when it's the "drop inside" target.
|
||||||
val vHeaderDragged = inDrag.dragPack === inPack && vMoving
|
val vHeaderDragged = inDrag.dragPack === inPack && vMoving
|
||||||
val vIntoHi = vMoving && ((inDrag.draggingPack && inDrag.dropInto === inPack) ||
|
val vIntoHi = vMoving && ((inDrag.draggingPack && inDrag.dropInto === inPack) ||
|
||||||
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
|
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
|
||||||
var vHeadMod = Modifier.fillMaxWidth()
|
var vHeadMod = Modifier.fillMaxWidth()
|
||||||
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
|
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
|
||||||
.onSizeChanged { inDrag.headH[inPack] = it.height }
|
.onSizeChanged { inDrag.headH[inPack] = it.height }
|
||||||
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
|
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
|
||||||
// clip=false and the drag ghost isn't clipped to its original bounds.
|
// clip=false and the drag ghost isn't clipped to its original bounds.
|
||||||
if (vHeaderDragged) vHeadMod = vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
|
if (vHeaderDragged) vHeadMod =
|
||||||
|
vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
|
||||||
vHeadMod = vHeadMod.clip(RoundedCornerShape(6.dp))
|
vHeadMod = vHeadMod.clip(RoundedCornerShape(6.dp))
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
@@ -465,9 +502,15 @@ internal fun PackSection(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
IconBtn(if (inPack.expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight, "Fold", inSize = 18.dp, inPadding = 3.dp) { inOnToggle() }
|
IconBtn(
|
||||||
|
if (inPack.expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
|
||||||
|
"Fold",
|
||||||
|
inSize = 18.dp,
|
||||||
|
inPadding = 3.dp
|
||||||
|
) { inOnToggle() }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f).clip(RoundedCornerShape(5.dp)).clickable { inOnSelect() }.padding(vertical = 4.dp),
|
modifier = Modifier.weight(1f).clip(RoundedCornerShape(5.dp)).clickable { inOnSelect() }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||||
) {
|
) {
|
||||||
@@ -479,8 +522,16 @@ internal fun PackSection(
|
|||||||
// + (new request) and ⋮ (pack menu) — both reveal on hover only, so the
|
// + (new request) and ⋮ (pack menu) — both reveal on hover only, so the
|
||||||
// header width never shifts (alpha, not conditional layout). A linked
|
// header width never shifts (alpha, not conditional layout). A linked
|
||||||
// copy mirrors its source's requests, so it has no "new request".
|
// copy mirrors its source's requests, so it has no "new request".
|
||||||
Row(modifier = Modifier.alpha(if (vHover || vMenu) 1f else 0f), verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
if (!inPack.isLinked) IconBtn(MaterialSymbols.Add, "New request", inSize = 16.dp, inPadding = 4.dp) { inOnNewRequest() }
|
modifier = Modifier.alpha(if (vHover || vMenu) 1f else 0f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (!inPack.isLinked) IconBtn(
|
||||||
|
MaterialSymbols.Add,
|
||||||
|
"New request",
|
||||||
|
inSize = 16.dp,
|
||||||
|
inPadding = 4.dp
|
||||||
|
) { inOnNewRequest() }
|
||||||
Box {
|
Box {
|
||||||
IconBtn(MaterialSymbols.MoreHoriz, "Pack menu", inSize = 16.dp, inPadding = 4.dp) { vMenu = true }
|
IconBtn(MaterialSymbols.MoreHoriz, "Pack menu", inSize = 16.dp, inPadding = 4.dp) { vMenu = true }
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
@@ -488,16 +539,34 @@ internal fun PackSection(
|
|||||||
onDismissRequest = { vMenu = false },
|
onDismissRequest = { vMenu = false },
|
||||||
modifier = Modifier.widthIn(min = 220.dp),
|
modifier = Modifier.widthIn(min = 220.dp),
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename pack") }, onClick = { vMenu = false; inOnRenamePack() })
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") }, onClick = { vMenu = false; inOnDuplicatePack() })
|
text = { MenuRow(MaterialSymbols.Edit, "Rename pack") },
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New sub-pack") }, onClick = { vMenu = false; inOnNewSubPack() })
|
onClick = { vMenu = false; inOnRenamePack() })
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Share, "Linked copy") }, onClick = { vMenu = false; inOnLinkedCopy() })
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Save, "Export pack") }, onClick = { vMenu = false; inOnSavePack() })
|
text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") },
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Folder, "Export pack as…") }, onClick = { vMenu = false; inOnSaveAsPack() })
|
onClick = { vMenu = false; inOnDuplicatePack() })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Add, "New sub-pack") },
|
||||||
|
onClick = { vMenu = false; inOnNewSubPack() })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Share, "Linked copy") },
|
||||||
|
onClick = { vMenu = false; inOnLinkedCopy() })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Save, "Export pack") },
|
||||||
|
onClick = { vMenu = false; inOnSavePack() })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Folder, "Export pack as…") },
|
||||||
|
onClick = { vMenu = false; inOnSaveAsPack() })
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
PackColorPicker(inPack.color) { inOnSetColor(it) }
|
PackColorPicker(inPack.color) { inOnSetColor(it) }
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Delete, "Remove pack", methodColor(ReqMethod.DELETE)) }, onClick = { vMenu = false; inOnRemovePack() })
|
DropdownMenuItem(text = {
|
||||||
|
MenuRow(
|
||||||
|
MaterialSymbols.Delete,
|
||||||
|
"Remove pack",
|
||||||
|
methodColor(ReqMethod.DELETE)
|
||||||
|
)
|
||||||
|
}, onClick = { vMenu = false; inOnRemovePack() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +594,12 @@ internal fun PackSection(
|
|||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
if (inHeaderless && vReqs.isEmpty())
|
if (inHeaderless && vReqs.isEmpty())
|
||||||
Text("Drop a request here to make it loose", color = c.dim, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
|
Text(
|
||||||
|
"Drop a request here to make it loose",
|
||||||
|
color = c.dim,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
vReqs.forEach { vRs ->
|
vReqs.forEach { vRs ->
|
||||||
if (vRs === vBarBefore) RowDropBar()
|
if (vRs === vBarBefore) RowDropBar()
|
||||||
key(vRs) {
|
key(vRs) {
|
||||||
@@ -539,28 +613,30 @@ internal fun PackSection(
|
|||||||
.onSizeChanged { inDrag.rowH[vRs] = it.height }
|
.onSizeChanged { inDrag.rowH[vRs] = it.height }
|
||||||
// translate is draw-only (doesn't shift absoluteY), so the
|
// translate is draw-only (doesn't shift absoluteY), so the
|
||||||
// cursor offset below stays correct while dragging.
|
// cursor offset below stays correct while dragging.
|
||||||
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
|
if (vDragged) vMod =
|
||||||
|
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
|
||||||
if (!inPack.isLinked) vMod = vMod.pointerInput(vRs) {
|
if (!inPack.isLinked) vMod = vMod.pointerInput(vRs) {
|
||||||
// Same graphicsLayer feedback concern as the pack drag —
|
// Same graphicsLayer feedback concern as the pack drag —
|
||||||
// accumulate dragAmount instead of reading change.position.
|
// accumulate dragAmount instead of reading change.position.
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { offset ->
|
onDragStart = { offset ->
|
||||||
inDrag.clear()
|
inDrag.clear()
|
||||||
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
|
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
|
||||||
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
|
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
|
||||||
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
|
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
|
||||||
},
|
},
|
||||||
onDrag = { _, dragAmount ->
|
onDrag = { _, dragAmount ->
|
||||||
val vd = inDrag.dragReq ?: return@detectDragGestures
|
val vd = inDrag.dragReq ?: return@detectDragGestures
|
||||||
inDrag.dy += dragAmount.y
|
inDrag.dy += dragAmount.y
|
||||||
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
|
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
|
||||||
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged = true
|
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged =
|
||||||
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
|
true
|
||||||
},
|
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
|
||||||
onDragEnd = { inOnReqDropEnd() },
|
},
|
||||||
onDragCancel = { inOnReqDropEnd() },
|
onDragEnd = { inOnReqDropEnd() },
|
||||||
)
|
onDragCancel = { inOnReqDropEnd() },
|
||||||
}
|
)
|
||||||
|
}
|
||||||
Box(modifier = vMod) {
|
Box(modifier = vMod) {
|
||||||
RequestRow(
|
RequestRow(
|
||||||
inRs = vRs,
|
inRs = vRs,
|
||||||
@@ -640,10 +716,22 @@ internal fun RequestRow(
|
|||||||
onDismissRequest = { vMenu = false },
|
onDismissRequest = { vMenu = false },
|
||||||
modifier = Modifier.widthIn(min = 168.dp),
|
modifier = Modifier.widthIn(min = 168.dp),
|
||||||
) {
|
) {
|
||||||
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename") }, onClick = { vMenu = false; inOnRename() })
|
if (!inReadOnly) DropdownMenuItem(
|
||||||
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate") }, onClick = { vMenu = false; inOnDuplicate() })
|
text = { MenuRow(MaterialSymbols.Edit, "Rename") },
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Terminal, "Copy as cURL") }, onClick = { vMenu = false; inOnCopyCurl() })
|
onClick = { vMenu = false; inOnRename() })
|
||||||
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.Delete, "Delete", methodColor(ReqMethod.DELETE)) }, onClick = { vMenu = false; inOnDelete() })
|
if (!inReadOnly) DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate") },
|
||||||
|
onClick = { vMenu = false; inOnDuplicate() })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Terminal, "Copy as cURL") },
|
||||||
|
onClick = { vMenu = false; inOnCopyCurl() })
|
||||||
|
if (!inReadOnly) DropdownMenuItem(text = {
|
||||||
|
MenuRow(
|
||||||
|
MaterialSymbols.Delete,
|
||||||
|
"Delete",
|
||||||
|
methodColor(ReqMethod.DELETE)
|
||||||
|
)
|
||||||
|
}, onClick = { vMenu = false; inOnDelete() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: BodyFormat — viewer/highlighter selection
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Format of a body payload as shown in the viewer, and (for a TEXT request body)
|
||||||
|
the type the user picks on the right of the Body tab — it drives both syntax
|
||||||
|
highlighting and the sent Content-Type. RAW disables highlighting and falls back
|
||||||
|
to the selectable BasicTextField; other values pick a tokeniser. */
|
||||||
|
@Serializable
|
||||||
|
enum class BodyFormat { RAW, JSON, XML, YAML, HTML }
|
||||||
|
|
||||||
|
/** Short label for the format / "type" picker (RAW = plain text, no highlighting). */
|
||||||
|
val BodyFormat.label: String
|
||||||
|
get() = when (this) {
|
||||||
|
BodyFormat.RAW -> "RAW"
|
||||||
|
BodyFormat.JSON -> "JSON"
|
||||||
|
BodyFormat.XML -> "XML"
|
||||||
|
BodyFormat.YAML -> "YAML"
|
||||||
|
BodyFormat.HTML -> "HTML"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Content-Type a TEXT body of this format is sent with. */
|
||||||
|
val BodyFormat.contentType: String
|
||||||
|
get() = when (this) {
|
||||||
|
BodyFormat.RAW -> "text/plain"
|
||||||
|
BodyFormat.JSON -> "application/json"
|
||||||
|
BodyFormat.XML -> "application/xml"
|
||||||
|
BodyFormat.YAML -> "application/yaml"
|
||||||
|
BodyFormat.HTML -> "text/html"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick a format from a Content-Type header. JSON / XML / YAML / HTML
|
||||||
|
match their canonical types and common variants; anything else
|
||||||
|
(image types, text/plain, …) falls back to RAW. */
|
||||||
|
fun autoFormatFor(inContentType: String?): BodyFormat {
|
||||||
|
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
|
||||||
|
return when {
|
||||||
|
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
|
||||||
|
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
|
||||||
|
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
|
||||||
|
vCt == "text/html" -> BodyFormat.HTML
|
||||||
|
else -> BodyFormat.RAW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Highlight palette
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
|
||||||
|
light variant matching VS Code's defaults. Caller picks based on
|
||||||
|
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
|
||||||
|
data class SyntaxPalette(
|
||||||
|
val key: Color,
|
||||||
|
val string: Color,
|
||||||
|
val number: Color,
|
||||||
|
val keyword: Color,
|
||||||
|
val tag: Color,
|
||||||
|
val comment: Color,
|
||||||
|
val punct: Color,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Light-on-dark — VS Code Dark+. */
|
||||||
|
val Dark = SyntaxPalette(
|
||||||
|
key = Color(0xFF9CDCFE),
|
||||||
|
string = Color(0xFFCE9178),
|
||||||
|
number = Color(0xFFB5CEA8),
|
||||||
|
keyword = Color(0xFFC586C0),
|
||||||
|
tag = Color(0xFF569CD6),
|
||||||
|
comment = Color(0xFF6A9955),
|
||||||
|
punct = Color(0xFFCCCCCC),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Dark-on-light — VS Code Light+. Higher contrast on white. */
|
||||||
|
val Light = SyntaxPalette(
|
||||||
|
key = Color(0xFF0451A5),
|
||||||
|
string = Color(0xFFA31515),
|
||||||
|
number = Color(0xFF098658),
|
||||||
|
keyword = Color(0xFF0000FF),
|
||||||
|
tag = Color(0xFF800000),
|
||||||
|
comment = Color(0xFF008000),
|
||||||
|
punct = Color(0xFF333333),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Pick a palette by the host theme's background brightness. */
|
||||||
|
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Entry point
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Render the given text with per-token colour spans for the requested
|
||||||
|
format. RAW returns an unstyled AnnotatedString. */
|
||||||
|
fun highlight(
|
||||||
|
inText: String,
|
||||||
|
inFormat: BodyFormat,
|
||||||
|
inPalette: SyntaxPalette = SyntaxPalette.Dark,
|
||||||
|
): AnnotatedString = when (inFormat) {
|
||||||
|
BodyFormat.RAW -> AnnotatedString(inText)
|
||||||
|
BodyFormat.JSON -> highlightJson(inText, inPalette)
|
||||||
|
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
|
||||||
|
BodyFormat.YAML -> highlightYaml(inText, inPalette)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: JSON tokeniser — single-pass, error-tolerant
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Tokeniser for JSON. Walks the string once and emits style spans for
|
||||||
|
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
|
||||||
|
(strings followed by ':') from string values by peeking after each
|
||||||
|
string. Error-tolerant — malformed JSON still highlights as best
|
||||||
|
effort rather than throwing. */
|
||||||
|
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
||||||
|
val vN = inText.length
|
||||||
|
var vI = 0
|
||||||
|
while (vI < vN) {
|
||||||
|
val vC = inText[vI]
|
||||||
|
when {
|
||||||
|
vC == '"' -> {
|
||||||
|
// Find closing quote, honouring backslash-escaped chars so an
|
||||||
|
// escaped \" doesn't terminate the string early.
|
||||||
|
val vStart = vI
|
||||||
|
var vJ = vI + 1
|
||||||
|
while (vJ < vN) {
|
||||||
|
if (inText[vJ] == '\\' && vJ + 1 < vN) {
|
||||||
|
vJ += 2; continue
|
||||||
|
}
|
||||||
|
if (inText[vJ] == '"') {
|
||||||
|
vJ++; break
|
||||||
|
}
|
||||||
|
vJ++
|
||||||
|
}
|
||||||
|
// Peek for ':' (skipping whitespace) → it's a key, not a value.
|
||||||
|
var vK = vJ
|
||||||
|
while (vK < vN && inText[vK].isWhitespace()) vK++
|
||||||
|
val vIsKey = vK < vN && inText[vK] == ':'
|
||||||
|
val vColor = if (vIsKey) inP.key else inP.string
|
||||||
|
pushStyle(SpanStyle(color = vColor))
|
||||||
|
append(inText.substring(vStart, vJ))
|
||||||
|
pop()
|
||||||
|
vI = vJ
|
||||||
|
}
|
||||||
|
|
||||||
|
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
|
||||||
|
val vStart = vI
|
||||||
|
if (vC == '-') vI++
|
||||||
|
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
|
||||||
|
pushStyle(SpanStyle(color = inP.number))
|
||||||
|
append(inText.substring(vStart, vI))
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
vC.isLetter() -> {
|
||||||
|
val vStart = vI
|
||||||
|
while (vI < vN && inText[vI].isLetter()) vI++
|
||||||
|
val vWord = inText.substring(vStart, vI)
|
||||||
|
if (vWord == "true" || vWord == "false" || vWord == "null") {
|
||||||
|
pushStyle(SpanStyle(color = inP.keyword))
|
||||||
|
append(vWord)
|
||||||
|
pop()
|
||||||
|
} else {
|
||||||
|
append(vWord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vC in "{}[],:" -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct))
|
||||||
|
append(vC)
|
||||||
|
pop()
|
||||||
|
vI++
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
append(vC); vI++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: XML / HTML tokeniser
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
|
||||||
|
attribute values (string), and comments. Inside tags, attr=value pairs
|
||||||
|
are recognised; outside tags text is left in the default colour. */
|
||||||
|
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
||||||
|
val vN = inText.length
|
||||||
|
var vI = 0
|
||||||
|
while (vI < vN) {
|
||||||
|
when (val vC = inText[vI]) {
|
||||||
|
'<' if inText.startsWith("<!--", vI) -> {
|
||||||
|
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
|
||||||
|
pushStyle(SpanStyle(color = inP.comment))
|
||||||
|
append(inText.substring(vI, vEnd))
|
||||||
|
pop()
|
||||||
|
vI = vEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
'<' -> {
|
||||||
|
// Tag open: <[/]name attr=val attr2="val2">
|
||||||
|
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
|
||||||
|
var vJ = vI + 1
|
||||||
|
if (vJ < vN && inText[vJ] == '/') {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
|
||||||
|
}
|
||||||
|
// Tag name.
|
||||||
|
val vTagStart = vJ
|
||||||
|
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
||||||
|
if (vJ > vTagStart) {
|
||||||
|
pushStyle(SpanStyle(color = inP.tag))
|
||||||
|
append(inText.substring(vTagStart, vJ))
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
// Attributes — name="value" pairs.
|
||||||
|
while (vJ < vGt - 1) {
|
||||||
|
val vC2 = inText[vJ]
|
||||||
|
when {
|
||||||
|
vC2.isWhitespace() -> {
|
||||||
|
append(vC2); vJ++
|
||||||
|
}
|
||||||
|
|
||||||
|
vC2 == '/' -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
|
||||||
|
}
|
||||||
|
|
||||||
|
vC2 == '=' -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++
|
||||||
|
}
|
||||||
|
|
||||||
|
vC2 == '"' || vC2 == '\'' -> {
|
||||||
|
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
|
||||||
|
pushStyle(SpanStyle(color = inP.string))
|
||||||
|
append(inText.substring(vJ, vEnd))
|
||||||
|
pop()
|
||||||
|
vJ = vEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val vAttrStart = vJ
|
||||||
|
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
||||||
|
pushStyle(SpanStyle(color = inP.key))
|
||||||
|
append(inText.substring(vAttrStart, vJ))
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
|
||||||
|
}
|
||||||
|
vI = vGt
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
append(vC); vI++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: YAML tokeniser — line-based
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Tokeniser for YAML. Walks line by line because YAML's grammar is
|
||||||
|
indent-sensitive and most of the interesting colour decisions
|
||||||
|
(key vs value, list item vs scalar) depend on position within a
|
||||||
|
line. Comments (#), keys (text before ':'), quoted strings, numbers
|
||||||
|
and booleans are coloured; everything else stays default. */
|
||||||
|
private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
||||||
|
val vLines = inText.split('\n')
|
||||||
|
for ((vIdx, vLineRaw) in vLines.withIndex()) {
|
||||||
|
val vLine = vLineRaw
|
||||||
|
// Comment: rest of the line after a # (not inside a quoted string).
|
||||||
|
val vCommentAt = findUnquotedHash(vLine)
|
||||||
|
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
|
||||||
|
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
|
||||||
|
|
||||||
|
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
|
||||||
|
val vColon = indexOfKeyColon(vCodePart)
|
||||||
|
if (vColon > 0) {
|
||||||
|
val vPrefix = vCodePart.substring(0, vColon)
|
||||||
|
val vRest = vCodePart.substring(vColon + 1)
|
||||||
|
// Lead whitespace + optional list marker, then key.
|
||||||
|
var vK = 0
|
||||||
|
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) {
|
||||||
|
append(vPrefix[vK]); vK++
|
||||||
|
}
|
||||||
|
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
||||||
|
append(vPrefix[vK + 1])
|
||||||
|
vK += 2
|
||||||
|
}
|
||||||
|
pushStyle(SpanStyle(color = inP.key))
|
||||||
|
append(vPrefix.substring(vK))
|
||||||
|
pop()
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
|
||||||
|
appendYamlValue(vRest, inP)
|
||||||
|
} else {
|
||||||
|
appendYamlValue(vCodePart, inP)
|
||||||
|
}
|
||||||
|
if (vCommentPart.isNotEmpty()) {
|
||||||
|
pushStyle(SpanStyle(color = inP.comment))
|
||||||
|
append(vCommentPart)
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
if (vIdx < vLines.size - 1) append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the value portion of a YAML line — applies string / number /
|
||||||
|
keyword / list-marker colours where applicable. */
|
||||||
|
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
|
||||||
|
val vTrimmed = inSeg.trimStart()
|
||||||
|
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
|
||||||
|
append(vPad)
|
||||||
|
when {
|
||||||
|
vTrimmed.isEmpty() -> { /* nothing */
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
||||||
|
appendYamlValue(vTrimmed.substring(1), inP)
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
|
||||||
|
val vQ = vTrimmed[0]
|
||||||
|
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
|
||||||
|
pushStyle(SpanStyle(color = inP.string))
|
||||||
|
append(vTrimmed.substring(0, vEnd))
|
||||||
|
pop()
|
||||||
|
append(vTrimmed.substring(vEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
|
||||||
|
var vJ = 0
|
||||||
|
if (vTrimmed[0] == '-') vJ++
|
||||||
|
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
|
||||||
|
pushStyle(SpanStyle(color = inP.number))
|
||||||
|
append(vTrimmed.substring(0, vJ))
|
||||||
|
pop()
|
||||||
|
append(vTrimmed.substring(vJ))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> append(vTrimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the index of the first '#' that isn't inside a quoted string,
|
||||||
|
or -1 if there is none. Used to split a YAML line into code + comment. */
|
||||||
|
private fun findUnquotedHash(inLine: String): Int {
|
||||||
|
var vInSingle = false
|
||||||
|
var vInDouble = false
|
||||||
|
for (vI in inLine.indices) {
|
||||||
|
when (inLine[vI]) {
|
||||||
|
'"' if !vInSingle -> vInDouble = !vInDouble
|
||||||
|
'\'' if !vInDouble -> vInSingle = !vInSingle
|
||||||
|
'#' if !vInSingle && !vInDouble -> return vI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate the colon that separates a YAML key from its value at the
|
||||||
|
current indent level — the FIRST unquoted ':' that's followed by
|
||||||
|
either whitespace or end-of-line. Returns -1 if the line has no key. */
|
||||||
|
private fun indexOfKeyColon(inLine: String): Int {
|
||||||
|
var vInSingle = false
|
||||||
|
var vInDouble = false
|
||||||
|
for (vI in inLine.indices) {
|
||||||
|
when (inLine[vI]) {
|
||||||
|
'"' if !vInSingle -> vInDouble = !vInDouble
|
||||||
|
'\'' if !vInDouble -> vInSingle = !vInSingle
|
||||||
|
':' if !vInSingle && !vInDouble -> {
|
||||||
|
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
|
||||||
|
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Theme — the app's legacy colour slots, derived from the active palette
|
||||||
|
// ==================
|
||||||
|
// AppColors is the app's own colour vocabulary (used by the custom widgets). It
|
||||||
|
// is now DERIVED from the selected Voltic M3 ColorScheme via appColorsFromScheme
|
||||||
|
// (VolticThemes.kt) and provided through LocalAppColors by App(). The default
|
||||||
|
// below (purple dark) only applies before that provider is installed.
|
||||||
|
|
||||||
|
class AppColors(
|
||||||
|
val bg: Color, val panel: Color, val field: Color, val border: Color,
|
||||||
|
val accent: Color, val text: Color, val dim: Color, val onAccent: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val LocalAppColors = staticCompositionLocalOf { appColorsFromScheme(VolticPalette.Purple.scheme.dark) }
|
||||||
@@ -15,41 +15,54 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
|
import apidemo.compat.clipEntryOfText
|
||||||
|
import apidemo.compat.x
|
||||||
|
import apidemo.compat.y
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.layout.x
|
|
||||||
import com.compose.sdl.layout.y
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: TLS certificate-chain dialog + cert cards
|
// MARK: TLS certificate-chain dialog + cert cards
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Dialog showing the server's TLS certificate chain (one card per cert, plus a
|
/** Dialog showing the server's TLS certificate chain (one card per cert, plus a
|
||||||
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
|
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
|
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
Dialog(onDismissRequest = inOnDismiss) {
|
Dialog(onDismissRequest = inOnDismiss) {
|
||||||
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.dp)) {
|
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.dp)) {
|
||||||
Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.Lock, tint = c.accent, size = 20.dp)
|
MaterialSymbolsOutlined(MaterialSymbols.Lock, tint = c.accent, size = 20.dp)
|
||||||
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
|
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
|
||||||
}
|
}
|
||||||
val vCerts = inChain?.certs.orEmpty()
|
val vCerts = inChain?.certs.orEmpty()
|
||||||
when {
|
when {
|
||||||
inChain == null -> Text("Probing…", color = c.dim, fontSize = 13.sp)
|
inChain == null -> Text("Probing…", color = c.dim, fontSize = 13.sp)
|
||||||
vCerts.isEmpty() -> Text(inChain.error ?: "No chain reported.", color = kWarnColor, fontSize = 13.sp)
|
vCerts.isEmpty() -> Text(
|
||||||
|
inChain.error ?: "No chain reported.",
|
||||||
|
color = VolticTheme.extended.warning,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Text("${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}", color = c.dim, fontSize = 12.sp)
|
Text(
|
||||||
|
"${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}",
|
||||||
|
color = c.dim,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@@ -57,7 +70,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
val vChainPem = vCerts.mapNotNull { certField(it.fields, "Cert") }.joinToString("\n")
|
val vChainPem = vCerts.mapNotNull { certField(it.fields, "Cert") }.joinToString("\n")
|
||||||
if (vChainPem.isNotEmpty()) CopyButton(vChainPem, "Copy chain")
|
if (vChainPem.isNotEmpty()) CopyButton(vChainPem, "Copy chain")
|
||||||
OutlinedButton(onClick = inOnDismiss) { Text("Close", color = c.text) }
|
OutlinedButton(onClick = inOnDismiss) { Text("Close", color = c.text) }
|
||||||
@@ -68,10 +84,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One certificate's summary card (subject / issuer / validity). Server-presented
|
/** One certificate's summary card (subject / issuer / validity). Server-presented
|
||||||
certs get a solid border; derived ones (issuer pulled from the OS store, or a
|
certs get a solid border; derived ones (issuer pulled from the OS store, or a
|
||||||
name-only placeholder) get an accent-coloured border + a label. A self-signed
|
name-only placeholder) get an accent-coloured border + a label. A self-signed
|
||||||
cert shows "Self-signed" in green instead of repeating its issuer. */
|
cert shows "Self-signed" in green instead of repeating its issuer. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -88,14 +104,22 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
|
||||||
.background(c.field.copy(alpha = if (inCert.fromServer) 1f else 0.4f), RoundedCornerShape(8.dp))
|
.background(c.field.copy(alpha = if (inCert.fromServer) 1f else 0.4f), RoundedCornerShape(8.dp))
|
||||||
.border(if (inCert.fromServer) 1.dp else 1.5.dp, if (inCert.fromServer) c.border else c.accent, RoundedCornerShape(8.dp))
|
.border(
|
||||||
|
if (inCert.fromServer) 1.dp else 1.5.dp,
|
||||||
|
if (inCert.fromServer) c.border else c.accent,
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
.padding(10.dp),
|
.padding(10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
) {
|
) {
|
||||||
val vTitle = vSubject?.let { cnOf(it) } ?: if (inIndex == 0) "Leaf certificate" else "Issuer #$inIndex"
|
val vTitle = vSubject?.let { cnOf(it) } ?: if (inIndex == 0) "Leaf certificate" else "Issuer #$inIndex"
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(vTitle, color = c.accent, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
Text(vTitle, color = c.accent, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||||
if (!inCert.fromServer) Text(if (vPem != null) "from OS store" else "not presented", color = c.dim, fontSize = 10.sp)
|
if (!inCert.fromServer) Text(
|
||||||
|
if (vPem != null) "from OS store" else "not presented",
|
||||||
|
color = c.dim,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
if (vPem != null) CopyButton(vPem)
|
if (vPem != null) CopyButton(vPem)
|
||||||
}
|
}
|
||||||
vSubject?.let { CertLine("Subject", it) }
|
vSubject?.let { CertLine("Subject", it) }
|
||||||
@@ -117,10 +141,10 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
|||||||
|
|
||||||
internal val kSelfSignedColor = Color(0xFF3FB950L)
|
internal val kSelfSignedColor = Color(0xFF3FB950L)
|
||||||
|
|
||||||
/* Copy button — rounded, hover overlay, real click that copies inText and shows
|
/** Copy button — rounded, hover overlay, real click that copies inText and shows
|
||||||
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
|
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
|
||||||
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
|
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
|
||||||
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
|
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CopyButton(inText: String, inLabel: String? = null) {
|
internal fun CopyButton(inText: String, inLabel: String? = null) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -132,7 +156,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
|||||||
var vHeight by remember { mutableStateOf(0) }
|
var vHeight by remember { mutableStateOf(0) }
|
||||||
val vClipboard = LocalClipboard.current
|
val vClipboard = LocalClipboard.current
|
||||||
val vScope = rememberCoroutineScope()
|
val vScope = rememberCoroutineScope()
|
||||||
LaunchedEffect(vCopied) { if (vCopied) { delay(2000); vCopied = false } }
|
LaunchedEffect(vCopied) {
|
||||||
|
if (vCopied) {
|
||||||
|
delay(2000.milliseconds); vCopied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.onGloballyPositioned { vX = it.x; vY = it.y }
|
.onGloballyPositioned { vX = it.x; vY = it.y }
|
||||||
@@ -141,7 +169,7 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
|||||||
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(7.dp))
|
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(7.dp))
|
||||||
.hoverable(vHoverSrc)
|
.hoverable(vHoverSrc)
|
||||||
.clickable {
|
.clickable {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(inText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(inText)) }
|
||||||
vCopied = true
|
vCopied = true
|
||||||
}
|
}
|
||||||
.padding(horizontal = if (inLabel != null) 10.dp else 5.dp, vertical = 5.dp),
|
.padding(horizontal = if (inLabel != null) 10.dp else 5.dp, vertical = 5.dp),
|
||||||
@@ -154,7 +182,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
|||||||
tint = if (vCopied) kSelfSignedColor else c.dim,
|
tint = if (vCopied) kSelfSignedColor else c.dim,
|
||||||
size = 15.dp,
|
size = 15.dp,
|
||||||
)
|
)
|
||||||
if (inLabel != null) Text(if (vCopied) "Copied" else inLabel, color = if (vCopied) kSelfSignedColor else c.text, fontSize = 12.sp)
|
if (inLabel != null) Text(
|
||||||
|
if (vCopied) "Copied" else inLabel,
|
||||||
|
color = if (vCopied) kSelfSignedColor else c.text,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// Icon-only: float a tiny "Copied" bubble (non-catching popup → no dismiss).
|
// Icon-only: float a tiny "Copied" bubble (non-catching popup → no dismiss).
|
||||||
if (vCopied && inLabel == null) {
|
if (vCopied && inLabel == null) {
|
||||||
@@ -177,23 +209,23 @@ internal fun CertLine(inLabel: String, inValue: String, inValueColor: Color? = n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
|
/** Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
|
||||||
internal fun certField(inFields: List<Pair<String, String>>, inName: String): String? =
|
internal fun certField(inFields: List<Pair<String, String>>, inName: String): String? =
|
||||||
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
||||||
|
|
||||||
/* The CN= value out of a distinguished-name string. Handles every
|
/** The CN= value out of a distinguished-name string. Handles every
|
||||||
format the platform backends emit:
|
format the platform backends emit:
|
||||||
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
|
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
|
||||||
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
|
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
|
||||||
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
|
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
|
||||||
default, with spaces — what
|
default, with spaces — what
|
||||||
libcurl on macOS emits)
|
libcurl on macOS emits)
|
||||||
Null when there's no CN. */
|
Null when there's no CN. */
|
||||||
internal fun cnOf(inDn: String): String? {
|
internal fun cnOf(inDn: String): String? {
|
||||||
val vMatch = Regex("""(?i)\bCN\s*=\s*([^,/]+)""").find(inDn) ?: return null
|
val vMatch = Regex("""(?i)\bCN\s*=\s*([^,/]+)""").find(inDn) ?: return null
|
||||||
return vMatch.groupValues[1].trim().ifBlank { null }
|
return vMatch.groupValues[1].trim().ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The host portion of a URL (for the chain dialog header). */
|
/** The host portion of a URL (for the chain dialog header). */
|
||||||
internal fun hostOf(inUrl: String): String =
|
internal fun hostOf(inUrl: String): String =
|
||||||
inUrl.substringAfter("://", inUrl).substringBefore("/").substringBefore("?").ifBlank { inUrl }
|
inUrl.substringAfter("://", inUrl).substringBefore("/").substringBefore("?").ifBlank { inUrl }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: TLS chain / client-certificate seam
|
||||||
|
// ==================
|
||||||
|
// The real implementations drive the bundled libcurl directly (CurlMtls.kt,
|
||||||
|
// nativeMain) — Ktor's engines expose no client-cert / CERTINFO API. The jvm
|
||||||
|
// parity app has no bundled libcurl, so its actuals report the feature as
|
||||||
|
// native-only instead.
|
||||||
|
|
||||||
|
/** One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
|
||||||
|
Issuer / the PEM under "Cert" / dates …) and whether the server actually
|
||||||
|
presented it. Derived certs — an issuer resolved from the OS store, or a
|
||||||
|
name-only placeholder — have fromServer = false and are drawn dotted. */
|
||||||
|
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
|
||||||
|
|
||||||
|
/** The server's TLS certificate chain. error is set instead when it couldn't be
|
||||||
|
fetched; fields vary by TLS backend. */
|
||||||
|
class TlsChain(val certs: List<ChainCert>, val error: String?)
|
||||||
|
|
||||||
|
/** Fetch the TLS certificate chain the request's server presents. */
|
||||||
|
expect fun inspectTlsChain(inReq: ApiRequest): TlsChain
|
||||||
|
|
||||||
|
/** Send the request with its client certificate attached (mTLS). */
|
||||||
|
expect fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
||||||
|
|
||||||
|
/** Remove temp client certs a previous crashed run left behind. Call once at
|
||||||
|
startup; no-op where the OS store isn't used. */
|
||||||
|
expect fun sweepTempClientCerts()
|
||||||
@@ -4,13 +4,13 @@ package apidemo
|
|||||||
// MARK: Variable substitution ({{name}})
|
// MARK: Variable substitution ({{name}})
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
|
/** Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
|
||||||
Surrounding whitespace inside the braces is tolerated. */
|
Surrounding whitespace inside the braces is tolerated. */
|
||||||
private val kVarRegex = Regex("""\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}""")
|
private val kVarRegex = Regex("""\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}""")
|
||||||
|
|
||||||
/* Replace every {{name}} token in inText with the matching enabled variable's
|
/** Replace every {{name}} token in inText with the matching enabled variable's
|
||||||
value. Unknown / disabled names are left untouched so the user can spot the
|
value. Unknown / disabled names are left untouched so the user can spot the
|
||||||
ones that won't resolve. */
|
ones that won't resolve. */
|
||||||
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
|
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
|
||||||
if (inText.isEmpty()) return inText
|
if (inText.isEmpty()) return inText
|
||||||
val vMap = inVars
|
val vMap = inVars
|
||||||
@@ -20,26 +20,41 @@ fun substituteVars(inText: String, inVars: List<KeyVal>): String {
|
|||||||
return kVarRegex.replace(inText) { vMatch -> vMap[vMatch.groupValues[1]] ?: vMatch.value }
|
return kVarRegex.replace(inText) { vMatch -> vMap[vMatch.groupValues[1]] ?: vMatch.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Apply variable substitution across every field that actually gets sent — URL,
|
/** Apply variable substitution across every field that actually gets sent — URL,
|
||||||
query params, headers and body — returning a fully-resolved request. The
|
query params, headers and body — returning a fully-resolved request. The
|
||||||
original (template) request is left untouched. */
|
original (template) request is left untouched. */
|
||||||
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
|
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
|
||||||
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
|
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
|
||||||
if (!vActive) return inReq
|
if (!vActive) return inReq
|
||||||
return inReq.copy(
|
return inReq.copy(
|
||||||
url = substituteVars(inReq.url, inVars),
|
url = substituteVars(inReq.url, inVars),
|
||||||
params = inReq.params.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
|
params = inReq.params.map {
|
||||||
headers = inReq.headers.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
|
it.copy(
|
||||||
|
key = substituteVars(it.key, inVars),
|
||||||
|
value = substituteVars(it.value, inVars)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headers = inReq.headers.map {
|
||||||
|
it.copy(
|
||||||
|
key = substituteVars(it.key, inVars),
|
||||||
|
value = substituteVars(it.value, inVars)
|
||||||
|
)
|
||||||
|
},
|
||||||
body = substituteVars(inReq.body, inVars),
|
body = substituteVars(inReq.body, inVars),
|
||||||
form = inReq.form.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
|
form = inReq.form.map {
|
||||||
|
it.copy(
|
||||||
|
key = substituteVars(it.key, inVars),
|
||||||
|
value = substituteVars(it.value, inVars)
|
||||||
|
)
|
||||||
|
},
|
||||||
certPath = substituteVars(inReq.certPath, inVars),
|
certPath = substituteVars(inReq.certPath, inVars),
|
||||||
keyPath = substituteVars(inReq.keyPath, inVars),
|
keyPath = substituteVars(inReq.keyPath, inVars),
|
||||||
certPassword = substituteVars(inReq.certPassword, inVars),
|
certPassword = substituteVars(inReq.certPassword, inVars),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The distinct {{names}} referenced anywhere in the request that have no enabled
|
/** The distinct {{names}} referenced anywhere in the request that have no enabled
|
||||||
variable to fill them — surfaced as a warning in the editor. */
|
variable to fill them — surfaced as a warning in the editor. */
|
||||||
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
||||||
val vDefined = inVars
|
val vDefined = inVars
|
||||||
.filter { it.enabled && it.key.isNotBlank() }
|
.filter { it.enabled && it.key.isNotBlank() }
|
||||||
@@ -49,7 +64,7 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
|||||||
append(inReq.url).append('\n')
|
append(inReq.url).append('\n')
|
||||||
inReq.params.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
inReq.params.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
||||||
inReq.headers.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
inReq.headers.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
||||||
if (inReq.method.allowsBody) {
|
if (true) {
|
||||||
when (inReq.bodyType) {
|
when (inReq.bodyType) {
|
||||||
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
||||||
else -> append(inReq.body)
|
else -> append(inReq.body)
|
||||||
@@ -67,10 +82,10 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
|||||||
// MARK: Copy as cURL
|
// MARK: Copy as cURL
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Render inReq as a runnable, multi-line curl command (pass an already-resolved
|
/** Render inReq as a runnable, multi-line curl command (pass an already-resolved
|
||||||
request so the output has real values, not {{tokens}}). Enabled query params
|
request so the output has real values, not {{tokens}}). Enabled query params
|
||||||
are folded into the URL; a Content-Type header is added for the body type
|
are folded into the URL; a Content-Type header is added for the body type
|
||||||
unless the request already sets one. */
|
unless the request already sets one. */
|
||||||
fun toCurl(inReq: ApiRequest): String {
|
fun toCurl(inReq: ApiRequest): String {
|
||||||
val vSb = StringBuilder()
|
val vSb = StringBuilder()
|
||||||
vSb.append("curl -X ").append(inReq.method.name)
|
vSb.append("curl -X ").append(inReq.method.name)
|
||||||
@@ -91,27 +106,31 @@ fun toCurl(inReq: ApiRequest): String {
|
|||||||
if (inReq.certPassword.isNotEmpty()) vSb.append(" \\\n --pass ").append(shellQuote(inReq.certPassword))
|
if (inReq.certPassword.isNotEmpty()) vSb.append(" \\\n --pass ").append(shellQuote(inReq.certPassword))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE) {
|
if (true && inReq.bodyType != BodyType.NONE) {
|
||||||
val vContentType = inReq.bodyContentType()
|
val vContentType = inReq.bodyContentType()
|
||||||
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
||||||
if (vContentType != null && !vHasCt) {
|
if (vContentType != null && !vHasCt) {
|
||||||
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
|
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
|
||||||
}
|
}
|
||||||
when (inReq.bodyType) {
|
when (inReq.bodyType) {
|
||||||
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ").append(shellQuote(formEncode(inReq.form)))
|
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ")
|
||||||
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ").append(shellQuote("@${inReq.body}"))
|
.append(shellQuote(formEncode(inReq.form)))
|
||||||
|
|
||||||
|
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ")
|
||||||
|
.append(shellQuote("@${inReq.body}"))
|
||||||
|
|
||||||
else -> if (inReq.body.isNotEmpty()) vSb.append(" \\\n --data ").append(shellQuote(inReq.body))
|
else -> if (inReq.body.isNotEmpty()) vSb.append(" \\\n --data ").append(shellQuote(inReq.body))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return vSb.toString()
|
return vSb.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
|
/** URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
|
||||||
fun formEncode(inForm: List<KeyVal>): String =
|
fun formEncode(inForm: List<KeyVal>): String =
|
||||||
inForm.filter { it.enabled && it.key.isNotBlank() }
|
inForm.filter { it.enabled && it.key.isNotBlank() }
|
||||||
.joinToString("&") { "${urlEncode(it.key)}=${urlEncode(it.value)}" }
|
.joinToString("&") { "${urlEncode(it.key)}=${urlEncode(it.value)}" }
|
||||||
|
|
||||||
/* The request URL with its enabled query params appended (percent-encoded). */
|
/** The request URL with its enabled query params appended (percent-encoded). */
|
||||||
internal fun urlWithParams(inReq: ApiRequest): String {
|
internal fun urlWithParams(inReq: ApiRequest): String {
|
||||||
val vTrimmed = inReq.url.trim()
|
val vTrimmed = inReq.url.trim()
|
||||||
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
|
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
|
||||||
@@ -121,10 +140,10 @@ internal fun urlWithParams(inReq: ApiRequest): String {
|
|||||||
return vTrimmed + vSep + vQuery
|
return vTrimmed + vSep + vQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Single-quote a string for a POSIX shell, escaping any embedded quotes. */
|
/** Single-quote a string for a POSIX shell, escaping any embedded quotes. */
|
||||||
private fun shellQuote(inS: String): String = "'" + inS.replace("'", "'\\''") + "'"
|
private fun shellQuote(inS: String): String = "'" + inS.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
/* Minimal RFC-3986 percent-encoding for query keys / values. */
|
/** Minimal RFC-3986 percent-encoding for query keys / values. */
|
||||||
private fun urlEncode(inS: String): String = buildString {
|
private fun urlEncode(inS: String): String = buildString {
|
||||||
for (vByte in inS.encodeToByteArray()) {
|
for (vByte in inS.encodeToByteArray()) {
|
||||||
val vCode = vByte.toInt() and 0xFF
|
val vCode = vByte.toInt() and 0xFF
|
||||||
@@ -30,8 +30,8 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|||||||
// MARK: Small UI atoms (shared across panels / dialogs / sidebar)
|
// MARK: Small UI atoms (shared across panels / dialogs / sidebar)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Method as a compact rounded badge (soft colour fill + coloured label), fixed
|
/** Method as a compact rounded badge (soft colour fill + coloured label), fixed
|
||||||
width so request names line up. */
|
width so request names line up. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MethodTag(inMethod: ReqMethod) {
|
internal fun MethodTag(inMethod: ReqMethod) {
|
||||||
val vCol = methodColor(inMethod)
|
val vCol = methodColor(inMethod)
|
||||||
@@ -45,16 +45,16 @@ internal fun MethodTag(inMethod: ReqMethod) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thin horizontal accent line showing where a dragged request row will drop
|
/** Thin horizontal accent line showing where a dragged request row will drop
|
||||||
(the open-tab strip uses the vertical DropBar). */
|
(the open-tab strip uses the vertical DropBar). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RowDropBar() {
|
internal fun RowDropBar() {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(c.accent, RoundedCornerShape(1.dp)))
|
Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(c.accent, RoundedCornerShape(1.dp)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab indices listed in inDots get a small accent dot after their label — used
|
/** Tab indices listed in inDots get a small accent dot after their label — used
|
||||||
to flag a tab that holds content (e.g. a non-empty request body). */
|
to flag a tab that holds content (e.g. a non-empty request body). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
|
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -90,10 +90,17 @@ internal fun TogglePill(inLabel: String, inSelected: Boolean, inOnClick: () -> U
|
|||||||
) { Text(inLabel, color = if (inSelected) c.onAccent else c.dim, fontSize = 12.sp) }
|
) { Text(inLabel, color = if (inSelected) c.onAccent else c.dim, fontSize = 12.sp) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact single-line (or fixed-height multi-line) input — a BasicTextField in
|
/** Compact single-line (or fixed-height multi-line) input — a BasicTextField in
|
||||||
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
|
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier: Modifier = Modifier, inPlaceholder: String = "", inSingleLine: Boolean = true, inOnEnter: (() -> Unit)? = null) {
|
internal fun ThinField(
|
||||||
|
inValue: String,
|
||||||
|
inOnChange: (String) -> Unit,
|
||||||
|
inModifier: Modifier = Modifier,
|
||||||
|
inPlaceholder: String = "",
|
||||||
|
inSingleLine: Boolean = true,
|
||||||
|
inOnEnter: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
// Box is an ancestor of the field, so it sees Enter the single-line field
|
// Box is an ancestor of the field, so it sees Enter the single-line field
|
||||||
// leaves unconsumed (it propagates up the focus → root chain).
|
// leaves unconsumed (it propagates up the focus → root chain).
|
||||||
@@ -102,7 +109,9 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
|
|||||||
.border(1.dp, c.border, RoundedCornerShape(6.dp))
|
.border(1.dp, c.border, RoundedCornerShape(6.dp))
|
||||||
.padding(horizontal = 10.dp, vertical = 9.dp)
|
.padding(horizontal = 10.dp, vertical = 9.dp)
|
||||||
if (inOnEnter != null) vBoxMod = vBoxMod.onKeyEvent { ev ->
|
if (inOnEnter != null) vBoxMod = vBoxMod.onKeyEvent { ev ->
|
||||||
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) { inOnEnter(); true } else false
|
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
|
||||||
|
inOnEnter(); true
|
||||||
|
} else false
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = vBoxMod,
|
modifier = vBoxMod,
|
||||||
@@ -111,10 +120,10 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
|
|||||||
if (inValue.isEmpty() && inPlaceholder.isNotEmpty()) Text(inPlaceholder, color = c.dim, fontSize = 13.sp)
|
if (inValue.isEmpty() && inPlaceholder.isNotEmpty()) Text(inPlaceholder, color = c.dim, fontSize = 13.sp)
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
||||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||||
handleColor = c.accent,
|
handleColor = c.accent,
|
||||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inValue,
|
value = inValue,
|
||||||
@@ -128,10 +137,17 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small icon-only button (burger / pack / options / save / close). inPadding
|
/** Small icon-only button (burger / pack / options / save / close). inPadding
|
||||||
tightens the tap area for slimmer rows. */
|
tightens the tap area for slimmer rows. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifier, inSize: Dp = 18.dp, inPadding: Dp = 6.dp, inOnClick: () -> Unit) {
|
internal fun IconBtn(
|
||||||
|
inIcon: Int,
|
||||||
|
inDesc: String,
|
||||||
|
inModifier: Modifier = Modifier,
|
||||||
|
inSize: Dp = 18.dp,
|
||||||
|
inPadding: Dp = 6.dp,
|
||||||
|
inOnClick: () -> Unit
|
||||||
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
val vHoverSrc = remember { MutableInteractionSource() }
|
val vHoverSrc = remember { MutableInteractionSource() }
|
||||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||||
@@ -152,18 +168,18 @@ internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Outlined icon+label action (New request / Add row / pack actions). */
|
/** Outlined icon+label action (New request / Add row / pack actions). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
|
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
OutlinedButton(onClick = inOnClick) { BtnContent(inIcon, inLabel, c.accent) }
|
OutlinedButton(onClick = inOnClick) { BtnContent(inIcon, inLabel, c.accent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
|
/** Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
|
||||||
Uses the m3 Button so its size + ripple + interaction match every other
|
Uses the m3 Button so its size + ripple + interaction match every other
|
||||||
Button in the app — previously handrolled Box + background + clickable + padding
|
Button in the app — previously handrolled Box + background + clickable + padding
|
||||||
gave it a different height / horizontal padding, and the Discard button sat
|
gave it a different height / horizontal padding, and the Discard button sat
|
||||||
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
|
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
||||||
val vRed = methodColor(ReqMethod.DELETE)
|
val vRed = methodColor(ReqMethod.DELETE)
|
||||||
@@ -173,17 +189,7 @@ internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
|||||||
) { BtnContent(inIcon, inLabel, Color.White) }
|
) { BtnContent(inIcon, inLabel, Color.White) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bordered icon+label chip (Copy / Save as / Cancel). */
|
/** Icon + label row used inside buttons. */
|
||||||
@Composable
|
|
||||||
internal fun IconLabelChip(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
|
|
||||||
val c = LocalAppColors.current
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
|
|
||||||
.clickable(onClick = inOnClick).padding(horizontal = 8.dp, vertical = 5.dp),
|
|
||||||
) { BtnContent(inIcon, inLabel, c.dim, 14.dp) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon + label row used inside buttons. */
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BtnContent(inIcon: Int, inLabel: String, inColor: Color, inSize: Dp = 16.dp) {
|
internal fun BtnContent(inIcon: Int, inLabel: String, inColor: Color, inSize: Dp = 16.dp) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -29,7 +29,6 @@ import androidx.compose.ui.input.pointer.isShiftPressed
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
@@ -37,26 +36,21 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import apidemo.compat.*
|
||||||
|
import apidemo.compat.ScrollbarStyle
|
||||||
|
import apidemo.compat.rememberScrollbarAdapter
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.removeMemoryResource
|
|
||||||
import com.compose.sdl.res.ResourceKind
|
|
||||||
import com.compose.sdl.res.painterResource
|
|
||||||
import com.compose.sdl.scrollbar.ScrollbarStyle
|
|
||||||
import com.compose.sdl.scrollbar.VerticalScrollbar
|
|
||||||
import com.compose.sdl.scrollbar.rememberScrollbarAdapter
|
|
||||||
import com.compose.sdl.showSaveFileDialog
|
|
||||||
import com.compose.sdl.text.currentTextMeasurer
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Response
|
// MARK: Response
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Panel 4 — Request / Response viewer. Each tab stacks a HEADERS section over a
|
/** Panel 4 — Request / Response viewer. Each tab stacks a HEADERS section over a
|
||||||
BODY section. "Request" shows the resolved request that would be sent (the
|
BODY section. "Request" shows the resolved request that would be sent (the
|
||||||
Preview); "Response" shows the result (image-aware). A copy / save toolbar
|
Preview); "Response" shows the result (image-aware). A copy / save toolbar
|
||||||
acts on whichever view is showing. */
|
acts on whichever view is showing. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -93,7 +87,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
Text("PREVIEW", color = c.accent, fontSize = 13.sp)
|
Text("PREVIEW", color = c.accent, fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Text("not sent", color = kWarnColor, fontSize = 11.sp)
|
Text("not sent", color = VolticTheme.extended.warning, fontSize = 11.sp)
|
||||||
} else {
|
} else {
|
||||||
// Request tab — adds the coloured method only once the
|
// Request tab — adds the coloured method only once the
|
||||||
// request has actually been sent (sentReq != null).
|
// request has actually been sent (sentReq != null).
|
||||||
@@ -110,8 +104,8 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
// pre-send / loading state.
|
// pre-send / loading state.
|
||||||
val (vRespAccent, vRespColor) = when {
|
val (vRespAccent, vRespColor) = when {
|
||||||
vResp?.error != null -> "FAILED" to statusColor(0)
|
vResp?.error != null -> "FAILED" to statusColor(0)
|
||||||
vResp != null -> vResp.status.toString() to statusColor(vResp.status)
|
vResp != null -> vResp.status.toString() to statusColor(vResp.status)
|
||||||
else -> null to c.dim
|
else -> null to c.dim
|
||||||
}
|
}
|
||||||
ViewerTab(
|
ViewerTab(
|
||||||
inLabel = "Response",
|
inLabel = "Response",
|
||||||
@@ -137,12 +131,14 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
vShowRequest && vReqShown == null -> ViewerEmpty(MaterialSymbols.Send, "Not sent")
|
vShowRequest && vReqShown == null -> ViewerEmpty(MaterialSymbols.Send, "Not sent")
|
||||||
vShowRequest -> {
|
vShowRequest -> {
|
||||||
val vR = vReqShown!!
|
val vR = vReqShown!!
|
||||||
val vSentHeaders = if (!vPreview) inRs.response?.requestHeaders?.takeIf { it.isNotEmpty() } else null
|
val vSentHeaders =
|
||||||
|
if (!vPreview) inRs.response?.requestHeaders?.takeIf { it.isNotEmpty() } else null
|
||||||
// Synthesize wire-level headers (Host, Content-Length, User-Agent)
|
// Synthesize wire-level headers (Host, Content-Length, User-Agent)
|
||||||
// the engine adds without telling Ktor — same set httpie shows.
|
// the engine adds without telling Ktor — same set httpie shows.
|
||||||
val vRawHeaders = if (vSentHeaders != null) vSentHeaders else parseHeaderLines(requestHeadersText(vR))
|
val vRawHeaders =
|
||||||
|
if (vSentHeaders != null) vSentHeaders else parseHeaderLines(requestHeadersText(vR))
|
||||||
val vHeaders = synthesizeRequestHeaders(vR, vRawHeaders)
|
val vHeaders = synthesizeRequestHeaders(vR, vRawHeaders)
|
||||||
val vBody = if (!vR.method.allowsBody || vR.bodyType == BodyType.NONE) null else requestBodyText(vR)
|
val vBody = if (!true || vR.bodyType == BodyType.NONE) null else requestBodyText(vR)
|
||||||
HttpFlowView(
|
HttpFlowView(
|
||||||
inStatusLine = formatRequestLine(vR, inRs.response?.httpVersion ?: "HTTP/1.1", c),
|
inStatusLine = formatRequestLine(vR, inRs.response?.httpVersion ?: "HTTP/1.1", c),
|
||||||
inStatusLineAccentColor = methodColor(vR.method),
|
inStatusLineAccentColor = methodColor(vR.method),
|
||||||
@@ -156,6 +152,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
inSoftWrap = inRs.bodyWrap,
|
inSoftWrap = inRs.bodyWrap,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
vResp == null && !vLoading -> ViewerEmpty(MaterialSymbols.Download, "Not received")
|
vResp == null && !vLoading -> ViewerEmpty(MaterialSymbols.Download, "Not received")
|
||||||
else -> {
|
else -> {
|
||||||
val vHeaders = vResp?.headers ?: emptyList()
|
val vHeaders = vResp?.headers ?: emptyList()
|
||||||
@@ -169,8 +166,8 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
inBody = if (vRespImage) null else vBody,
|
inBody = if (vRespImage) null else vBody,
|
||||||
inIsImage = vRespImage && !vLoading,
|
inIsImage = vRespImage && !vLoading,
|
||||||
inImagePainter = if (vRespImage && !vLoading) {
|
inImagePainter = if (vRespImage && !vLoading) {
|
||||||
val vKind = if (vResp?.contentType?.contains("svg", ignoreCase = true) == true) ResourceKind.Svg else ResourceKind.Raster
|
val vSvg = vResp.contentType?.contains("svg", ignoreCase = true) == true
|
||||||
painterResource(inRs.imageKey!!, vKind)
|
memoryImagePainter(inRs.imageKey!!, vSvg)
|
||||||
} else null,
|
} else null,
|
||||||
inHeadersCollapsed = vHeadersCollapsed,
|
inHeadersCollapsed = vHeadersCollapsed,
|
||||||
inOnToggleCollapse = { vHeadersCollapsed = !vHeadersCollapsed },
|
inOnToggleCollapse = { vHeadersCollapsed = !vHeadersCollapsed },
|
||||||
@@ -196,7 +193,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
) {
|
) {
|
||||||
// Response viewer: format selector for the body. Auto-detected
|
// Response viewer: format selector for the body. Auto-detected
|
||||||
// from Content-Type until the user overrides, then pinned.
|
// from Content-Type until the user overrides, then pinned.
|
||||||
if (vRespHasContent && vResp != null && !vRespImage) {
|
if (vRespHasContent && !vRespImage) {
|
||||||
val vAuto = autoFormatFor(vResp.contentType)
|
val vAuto = autoFormatFor(vResp.contentType)
|
||||||
val vCurrent = inRs.respFormatOverride ?: vAuto
|
val vCurrent = inRs.respFormatOverride ?: vAuto
|
||||||
BodyFormatSelector(
|
BodyFormatSelector(
|
||||||
@@ -210,7 +207,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
// Wrap toggle — soft-wrap long lines (default) vs. no wrap +
|
// Wrap toggle — soft-wrap long lines (default) vs. no wrap +
|
||||||
// horizontal scroll (one source line per row). Shown for any text
|
// horizontal scroll (one source line per row). Shown for any text
|
||||||
// body (request preview or response), hidden for an image.
|
// body (request preview or response), hidden for an image.
|
||||||
val vHasTextBody = (vRespHasContent && vResp != null && !vRespImage) || vReqHasContent
|
val vHasTextBody = (vRespHasContent && !vRespImage) || vReqHasContent
|
||||||
if (vHasTextBody) {
|
if (vHasTextBody) {
|
||||||
val vWrapOn = inRs.bodyWrap
|
val vWrapOn = inRs.bodyWrap
|
||||||
Box(
|
Box(
|
||||||
@@ -234,7 +231,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
}
|
}
|
||||||
vMsg?.let { Text(it, color = c.dim, fontSize = 11.sp) }
|
vMsg?.let { Text(it, color = c.dim, fontSize = 11.sp) }
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (vRespHasContent && vResp != null && vResp.error == null) {
|
if (vRespHasContent && vResp.error == null) {
|
||||||
Text(formatTimingSize(vResp), color = c.dim, fontSize = 11.sp)
|
Text(formatTimingSize(vResp), color = c.dim, fontSize = 11.sp)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
@@ -247,7 +244,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
inResponseBody = vRespBody,
|
inResponseBody = vRespBody,
|
||||||
inOnMessage = { vMsg = it },
|
inOnMessage = { vMsg = it },
|
||||||
inOnClear = {
|
inOnClear = {
|
||||||
inRs.imageKey?.let { removeMemoryResource(it) }
|
inRs.imageKey?.let { removeMemoryImage(it) }
|
||||||
inRs.imageKey = null
|
inRs.imageKey = null
|
||||||
inRs.response = null
|
inRs.response = null
|
||||||
inRs.sentReq = null
|
inRs.sentReq = null
|
||||||
@@ -263,10 +260,10 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
// MARK: HttpFlowView — flat httpie-style request/response layout
|
// MARK: HttpFlowView — flat httpie-style request/response layout
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Renders the body of a Request or Response tab in a httpie-flavoured
|
/** Renders the body of a Request or Response tab in a httpie-flavoured
|
||||||
layout: status line (with collapse arrow) + headers table + raw body
|
layout: status line (with collapse arrow) + headers table + raw body
|
||||||
inline, no card / rounded-rect chrome around each section. The
|
inline, no card / rounded-rect chrome around each section. The
|
||||||
headers section can be collapsed via the arrow. */
|
headers section can be collapsed via the arrow. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HttpFlowView(
|
internal fun HttpFlowView(
|
||||||
inStatusLine: AnnotatedString,
|
inStatusLine: AnnotatedString,
|
||||||
@@ -311,7 +308,7 @@ internal fun HttpFlowView(
|
|||||||
if (inBody == null) emptyList()
|
if (inBody == null) emptyList()
|
||||||
else {
|
else {
|
||||||
val vAnn = if (inBodyFormat == BodyFormat.RAW) AnnotatedString(inBody)
|
val vAnn = if (inBodyFormat == BodyFormat.RAW) AnnotatedString(inBody)
|
||||||
else highlight(inBody, inBodyFormat, SyntaxPalette.forDark(vDark))
|
else highlight(inBody, inBodyFormat, SyntaxPalette.forDark(vDark))
|
||||||
buildBodyChunks(inBody, vAnn, kLinesPerChunk)
|
buildBodyChunks(inBody, vAnn, kLinesPerChunk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +328,12 @@ internal fun HttpFlowView(
|
|||||||
// smoothness win when thousands of line items churn through the viewport.
|
// smoothness win when thousands of line items churn through the viewport.
|
||||||
val vBodyStyle = remember(c.text) { TextStyle(color = c.text, fontSize = 12.sp, fontFamily = monoFontFamily) }
|
val vBodyStyle = remember(c.text) { TextStyle(color = c.text, fontSize = 12.sp, fontFamily = monoFontFamily) }
|
||||||
val vNumStyle = remember(c.dim) {
|
val vNumStyle = remember(c.dim) {
|
||||||
TextStyle(color = c.dim.copy(alpha = 0.45f), fontSize = 12.sp, fontFamily = monoFontFamily, textAlign = TextAlign.End)
|
TextStyle(
|
||||||
|
color = c.dim.copy(alpha = 0.45f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = monoFontFamily,
|
||||||
|
textAlign = TextAlign.End
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body-relative x offset (px) of the block's BasicText inside the outer Box.
|
// Body-relative x offset (px) of the block's BasicText inside the outer Box.
|
||||||
@@ -342,8 +344,8 @@ internal fun HttpFlowView(
|
|||||||
// moves off-screen via keyboard. Only stable while we're on the "have body"
|
// moves off-screen via keyboard. Only stable while we're on the "have body"
|
||||||
// branch, but that's the only branch that renders chunks in the first place.
|
// branch, but that's the only branch that renders chunks in the first place.
|
||||||
val vChunkItemOffset = 1 +
|
val vChunkItemOffset = 1 +
|
||||||
(if (!inHeadersCollapsed && inHeaders.isNotEmpty()) 1 else 0) +
|
(if (!inHeadersCollapsed && inHeaders.isNotEmpty()) 1 else 0) +
|
||||||
(if (inBody != null || inIsImage) 1 else 0)
|
(if (inBody != null || inIsImage) 1 else 0)
|
||||||
|
|
||||||
// After a keyboard action that moves the caret, if the caret's chunk is not
|
// After a keyboard action that moves the caret, if the caret's chunk is not
|
||||||
// in the visible items list scroll it in. Instant (no anim) so rapid
|
// in the visible items list scroll it in. Instant (no anim) so rapid
|
||||||
@@ -392,7 +394,7 @@ internal fun HttpFlowView(
|
|||||||
val vMaxScrollPx = with(density) { 24.dp.toPx() }
|
val vMaxScrollPx = with(density) { 24.dp.toPx() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
androidx.compose.runtime.withFrameNanos { }
|
withFrameNanos { }
|
||||||
val vPos = vDragPos ?: continue
|
val vPos = vDragPos ?: continue
|
||||||
val vVpH = vState.layoutInfo.viewportSize.height.toFloat()
|
val vVpH = vState.layoutInfo.viewportSize.height.toFloat()
|
||||||
val vDelta = when {
|
val vDelta = when {
|
||||||
@@ -463,10 +465,12 @@ internal fun HttpFlowView(
|
|||||||
val vR = wordRangeAt(vBody, vOffset)
|
val vR = wordRangeAt(vBody, vOffset)
|
||||||
BodySel(anchor = vR.first, caret = vR.last)
|
BodySel(anchor = vR.first, caret = vR.last)
|
||||||
}
|
}
|
||||||
|
|
||||||
vPressCount >= 3 -> BodySel(
|
vPressCount >= 3 -> BodySel(
|
||||||
anchor = lineStartAt(vBody, vOffset),
|
anchor = lineStartAt(vBody, vOffset),
|
||||||
caret = lineEndAt(vBody, vOffset),
|
caret = lineEndAt(vBody, vOffset),
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> BodySel(anchor = vOffset, caret = vOffset)
|
else -> BodySel(anchor = vOffset, caret = vOffset)
|
||||||
}
|
}
|
||||||
vChange.consume()
|
vChange.consume()
|
||||||
@@ -504,47 +508,58 @@ internal fun HttpFlowView(
|
|||||||
vMod && ev.key == Key.A -> {
|
vMod && ev.key == Key.A -> {
|
||||||
vSel = BodySel(0, vBody.length); true
|
vSel = BodySel(0, vBody.length); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vMod && ev.key == Key.C -> {
|
vMod && ev.key == Key.C -> {
|
||||||
val vS = vSel
|
val vS = vSel
|
||||||
if (vS != null && !vS.isEmpty) {
|
if (vS != null && !vS.isEmpty) {
|
||||||
val vText = vBody.substring(vS.start, vS.end)
|
val vText = vBody.substring(vS.start, vS.end)
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vText)) }
|
||||||
true
|
true
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
|
|
||||||
ev.key == Key.Escape -> {
|
ev.key == Key.Escape -> {
|
||||||
if (vSel != null) { vSel = null; true } else false
|
if (vSel != null) {
|
||||||
|
vSel = null; true
|
||||||
|
} else false
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.DirectionRight -> {
|
vShift && ev.key == Key.DirectionRight -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = (vCur.caret + 1).coerceAtMost(vBody.length))
|
vSel = vCur.copy(caret = (vCur.caret + 1).coerceAtMost(vBody.length))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.DirectionLeft -> {
|
vShift && ev.key == Key.DirectionLeft -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = (vCur.caret - 1).coerceAtLeast(0))
|
vSel = vCur.copy(caret = (vCur.caret - 1).coerceAtLeast(0))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.DirectionDown -> {
|
vShift && ev.key == Key.DirectionDown -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = moveDown(vBody, vCur.caret))
|
vSel = vCur.copy(caret = moveDown(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.DirectionUp -> {
|
vShift && ev.key == Key.DirectionUp -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = moveUp(vBody, vCur.caret))
|
vSel = vCur.copy(caret = moveUp(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.MoveHome -> {
|
vShift && ev.key == Key.MoveHome -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = if (vMod) 0 else lineStartAt(vBody, vCur.caret))
|
vSel = vCur.copy(caret = if (vMod) 0 else lineStartAt(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.MoveEnd -> {
|
vShift && ev.key == Key.MoveEnd -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = if (vMod) vBody.length else lineEndAt(vBody, vCur.caret))
|
vSel = vCur.copy(caret = if (vMod) vBody.length else lineEndAt(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -637,23 +652,23 @@ internal fun HttpFlowView(
|
|||||||
// MARK: Body selection (editor-style, spans virtualized chunks)
|
// MARK: Body selection (editor-style, spans virtualized chunks)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* A response body's selection is a pair of character offsets in the whole body
|
/** A response body's selection is a pair of character offsets in the whole body
|
||||||
string: [anchor] is where the user pressed / started the selection, [caret] is
|
string: [anchor] is where the user pressed / started the selection, [caret] is
|
||||||
where they've moved to. When they differ, the selected range is [min..max].
|
where they've moved to. When they differ, the selected range is [ min..max ].
|
||||||
When equal, it's a zero-width caret still useful as a starting point for
|
When equal, it's a zero-width caret still useful as a starting point for
|
||||||
subsequent Shift+Arrow / Shift+Click extensions.
|
subsequent Shift+Arrow / Shift+Click extensions.
|
||||||
|
|
||||||
Note this lives OUTSIDE the SelectionContainer machinery — that machinery only
|
Note this lives OUTSIDE the SelectionContainer machinery — that machinery only
|
||||||
tracks selectables composed by the LazyColumn (visible chunks), which is why
|
tracks selectables composed by the LazyColumn (visible chunks), which is why
|
||||||
Ctrl+A and drag-select cannot reach off-screen text through it. This state is
|
Ctrl+A and drag-select cannot reach off-screen text through it. This state is
|
||||||
plain integer offsets so it survives scroll and covers the whole body. */
|
plain integer offsets so it survives scroll and covers the whole body. */
|
||||||
private data class BodySel(val anchor: Int, val caret: Int) {
|
private data class BodySel(val anchor: Int, val caret: Int) {
|
||||||
val start get() = minOf(anchor, caret)
|
val start get() = minOf(anchor, caret)
|
||||||
val end get() = maxOf(anchor, caret)
|
val end get() = maxOf(anchor, caret)
|
||||||
val isEmpty get() = anchor == caret
|
val isEmpty get() = anchor == caret
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Character offset of the start of the line containing [inOffset]. */
|
/** Character offset of the start of the line containing [inOffset]. */
|
||||||
private fun lineStartAt(inText: String, inOffset: Int): Int {
|
private fun lineStartAt(inText: String, inOffset: Int): Int {
|
||||||
if (inOffset <= 0) return 0
|
if (inOffset <= 0) return 0
|
||||||
val vClamped = inOffset.coerceAtMost(inText.length)
|
val vClamped = inOffset.coerceAtMost(inText.length)
|
||||||
@@ -661,17 +676,17 @@ private fun lineStartAt(inText: String, inOffset: Int): Int {
|
|||||||
return if (vPrev < 0) 0 else vPrev + 1
|
return if (vPrev < 0) 0 else vPrev + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Character offset of the end of the line containing [inOffset] — the position of
|
/** Character offset of the end of the line containing [inOffset] — the position of
|
||||||
its '\n', or the length of [inText] if this is the last line. */
|
its '\n', or the length of [inText] if this is the last line. */
|
||||||
private fun lineEndAt(inText: String, inOffset: Int): Int {
|
private fun lineEndAt(inText: String, inOffset: Int): Int {
|
||||||
val vClamped = inOffset.coerceIn(0, inText.length)
|
val vClamped = inOffset.coerceIn(0, inText.length)
|
||||||
val vNext = inText.indexOf('\n', vClamped)
|
val vNext = inText.indexOf('\n', vClamped)
|
||||||
return if (vNext < 0) inText.length else vNext
|
return if (vNext < 0) inText.length else vNext
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Move a caret one visual "row" down while trying to keep its column — i.e. the
|
/** Move a caret one visual "row" down while trying to keep its column — i.e. the
|
||||||
number of chars past the current line's start. If the target line is shorter,
|
number of chars past the current line's start. If the target line is shorter,
|
||||||
the caret snaps to its end. Off-by-one at EOF collapses to the body length. */
|
the caret snaps to its end. Off-by-one at EOF collapses to the body length. */
|
||||||
private fun moveDown(inText: String, inOffset: Int): Int {
|
private fun moveDown(inText: String, inOffset: Int): Int {
|
||||||
val vLineStart = lineStartAt(inText, inOffset)
|
val vLineStart = lineStartAt(inText, inOffset)
|
||||||
val vCol = inOffset - vLineStart
|
val vCol = inOffset - vLineStart
|
||||||
@@ -682,7 +697,7 @@ private fun moveDown(inText: String, inOffset: Int): Int {
|
|||||||
return (vNextStart + vCol).coerceAtMost(vNextEnd)
|
return (vNextStart + vCol).coerceAtMost(vNextEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Up counterpart to [moveDown]. */
|
/** The Up counterpart to [moveDown]. */
|
||||||
private fun moveUp(inText: String, inOffset: Int): Int {
|
private fun moveUp(inText: String, inOffset: Int): Int {
|
||||||
val vLineStart = lineStartAt(inText, inOffset)
|
val vLineStart = lineStartAt(inText, inOffset)
|
||||||
if (vLineStart == 0) return 0
|
if (vLineStart == 0) return 0
|
||||||
@@ -692,10 +707,10 @@ private fun moveUp(inText: String, inOffset: Int): Int {
|
|||||||
return (vPrevStart + vCol).coerceAtMost(vPrevEnd)
|
return (vPrevStart + vCol).coerceAtMost(vPrevEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Word boundary rule for double-click selection: contiguous run of "word chars"
|
/** Word boundary rule for double-click selection: contiguous run of "word chars"
|
||||||
(letters, digits, underscore) containing [inOffset], or an empty range at the
|
(letters, digits, underscore) containing [inOffset], or an empty range at the
|
||||||
click point when it lands on non-word text (whitespace, punctuation). Matches
|
click point when it lands on non-word text (whitespace, punctuation). Matches
|
||||||
the boundary editors like VS Code / IntelliJ use for double-click select. */
|
the boundary editors like VS Code / IntelliJ use for double-click select. */
|
||||||
private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
||||||
val vN = inText.length
|
val vN = inText.length
|
||||||
if (vN == 0) return 0..0
|
if (vN == 0) return 0..0
|
||||||
@@ -703,8 +718,8 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
|||||||
fun isWord(c: Char) = c.isLetterOrDigit() || c == '_'
|
fun isWord(c: Char) = c.isLetterOrDigit() || c == '_'
|
||||||
// A click just after the last char of a word should still select that word.
|
// A click just after the last char of a word should still select that word.
|
||||||
val vProbe = if (vClamped < vN && isWord(inText[vClamped])) vClamped
|
val vProbe = if (vClamped < vN && isWord(inText[vClamped])) vClamped
|
||||||
else if (vClamped > 0 && isWord(inText[vClamped - 1])) vClamped - 1
|
else if (vClamped > 0 && isWord(inText[vClamped - 1])) vClamped - 1
|
||||||
else return vClamped..vClamped
|
else return vClamped..vClamped
|
||||||
var vStart = vProbe
|
var vStart = vProbe
|
||||||
while (vStart > 0 && isWord(inText[vStart - 1])) vStart--
|
while (vStart > 0 && isWord(inText[vStart - 1])) vStart--
|
||||||
var vEnd = vProbe
|
var vEnd = vProbe
|
||||||
@@ -712,9 +727,9 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
|||||||
return vStart..vEnd
|
return vStart..vEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The index of the chunk in [inChunks] whose character range contains
|
/** The index of the chunk in [inChunks] whose character range contains
|
||||||
[inOffset], or null when the body is empty. Uses a linear scan — the chunk
|
[inOffset], or null when the body is empty. Uses a linear scan — the chunk
|
||||||
count is O(body / kLinesPerChunk) which stays modest even for huge bodies. */
|
count is O(body / kLinesPerChunk) which stays modest even for huge bodies. */
|
||||||
private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
|
private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
|
||||||
if (inChunks.isEmpty()) return null
|
if (inChunks.isEmpty()) return null
|
||||||
for (i in inChunks.indices) {
|
for (i in inChunks.indices) {
|
||||||
@@ -729,22 +744,22 @@ private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
|
|||||||
// measure at the viewport edges, large enough to slash node/selectable count.
|
// measure at the viewport edges, large enough to slash node/selectable count.
|
||||||
private const val kLinesPerChunk = 48
|
private const val kLinesPerChunk = 48
|
||||||
|
|
||||||
/* One BLOCK of a read-only body: a right-aligned column of line numbers in the
|
/** One BLOCK of a read-only body: a right-aligned column of line numbers in the
|
||||||
gutter + the block's (syntax-coloured) text, each a single BasicText. Rendering
|
gutter + the block's (syntax-coloured) text, each a single BasicText. Rendering
|
||||||
blocks (not one-BasicText-per-line) keeps the node / selectable / paragraph-set-up
|
blocks (not one-BasicText-per-line) keeps the node / selectable / paragraph-set-up
|
||||||
count low as blocks scroll through the viewport. The gutter is pinned outside
|
count low as blocks scroll through the viewport. The gutter is pinned outside
|
||||||
the shared h-scroll and never joins the selection.
|
the shared h-scroll and never joins the selection.
|
||||||
|
|
||||||
Gutter alignment under soft-wrap: the gutter starts as plain 1:1 numbers, then the
|
Gutter alignment under soft-wrap: the gutter starts as plain 1:1 numbers, then the
|
||||||
body's own onTextLayout tells us how many VISUAL rows each source line wrapped into,
|
body's own onTextLayout tells us how many VISUAL rows each source line wrapped into,
|
||||||
and we pad the gutter with that many blank rows so number N still sits on line N's
|
and we pad the gutter with that many blank rows so number N still sits on line N's
|
||||||
first row. It reuses the body's layout (no extra measurement) and only recomputes on
|
first row. It reuses the body's layout (no extra measurement) and only recomputes on
|
||||||
(re)layout, not per frame. No-wrap mode is 1 row per line, so the plain gutter is exact.
|
(re)layout, not per frame. No-wrap mode is 1 row per line, so the plain gutter is exact.
|
||||||
|
|
||||||
The block's body text carries the current selection as a background SpanStyle
|
The block's body text carries the current selection as a background SpanStyle
|
||||||
intersected with [chunk.startCharOffset, +chunk.body.length). onTextLayout also
|
intersected with [chunk.startCharOffset, +chunk.body.length). onTextLayout also
|
||||||
reports the layout back up to the viewer so hit-testing can convert pointer
|
reports the layout back up to the viewer so hit-testing can convert pointer
|
||||||
positions into character offsets across the whole body. */
|
positions into character offsets across the whole body. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun BodyChunkRow(
|
private fun BodyChunkRow(
|
||||||
inChunk: BodyChunk,
|
inChunk: BodyChunk,
|
||||||
@@ -830,8 +845,9 @@ private fun BodyChunkRow(
|
|||||||
vSb.append(inChunk.firstLine + j)
|
vSb.append(inChunk.firstLine + j)
|
||||||
if (vLen > 0) {
|
if (vLen > 0) {
|
||||||
val vStart = inChunk.lineStartsInBlock[j].coerceIn(0, vMaxOff)
|
val vStart = inChunk.lineStartsInBlock[j].coerceIn(0, vMaxOff)
|
||||||
val vEnd = (if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
|
val vEnd =
|
||||||
.coerceIn(vStart, vMaxOff)
|
(if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
|
||||||
|
.coerceIn(vStart, vMaxOff)
|
||||||
val vRows = layout.getLineForOffset(vEnd) - layout.getLineForOffset(vStart) + 1
|
val vRows = layout.getLineForOffset(vEnd) - layout.getLineForOffset(vStart) + 1
|
||||||
repeat((vRows - 1).coerceAtLeast(0)) { vSb.append('\n') }
|
repeat((vRows - 1).coerceAtLeast(0)) { vSb.append('\n') }
|
||||||
}
|
}
|
||||||
@@ -855,11 +871,11 @@ private fun BodyChunkRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A block of consecutive source lines: 1-based number of the first line, the line
|
/** A block of consecutive source lines: 1-based number of the first line, the line
|
||||||
count, each source line's start offset WITHIN the block text (to map a wrapped body
|
count, each source line's start offset WITHIN the block text (to map a wrapped body
|
||||||
layout back to line numbers), the character offset of the block's first char in the
|
layout back to line numbers), the character offset of the block's first char in the
|
||||||
full body (used to map local text offsets to global ones for cross-chunk selection),
|
full body (used to map local text offsets to global ones for cross-chunk selection),
|
||||||
and the highlighted block text. */
|
and the highlighted block text. */
|
||||||
private class BodyChunk(
|
private class BodyChunk(
|
||||||
val firstLine: Int,
|
val firstLine: Int,
|
||||||
val lineCount: Int,
|
val lineCount: Int,
|
||||||
@@ -868,9 +884,9 @@ private class BodyChunk(
|
|||||||
val body: AnnotatedString,
|
val body: AnnotatedString,
|
||||||
)
|
)
|
||||||
|
|
||||||
/* Slice a highlighted body into blocks of [inLinesPerChunk] lines. The O(total-spans)
|
/** Slice a highlighted body into blocks of [inLinesPerChunk] lines. The O(total-spans)
|
||||||
subSequence runs once per block HERE (build time, memoised) instead of once per
|
subSequence runs once per block HERE (build time, memoised) instead of once per
|
||||||
visible line every frame — the difference between smooth and janky on a big body. */
|
visible line every frame — the difference between smooth and janky on a big body. */
|
||||||
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
|
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
|
||||||
val vStarts = lineStartOffsets(inText)
|
val vStarts = lineStartOffsets(inText)
|
||||||
val vN = vStarts.size
|
val vN = vStarts.size
|
||||||
@@ -890,10 +906,10 @@ private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerCh
|
|||||||
return vOut
|
return vOut
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Offsets where each source line starts (line i spans [starts[i], starts[i+1]-1),
|
/** Offsets where each source line starts (line i spans [starts[ i], starts[i+1]-1),
|
||||||
the -1 dropping the '\n'; the last line runs to the string end). One cheap O(n)
|
the -1 dropping the '\n'; the last line runs to the string end). One cheap O(n)
|
||||||
scan, memoised — replaces the old gutter pre-pass that ran measurer.wrap() on
|
scan, memoised — replaces the old gutter pre-pass that measured wrapping on
|
||||||
every one of N lines on each width change (the other half of the freeze). */
|
every one of N lines on each width change (the other half of the freeze). */
|
||||||
private fun lineStartOffsets(inText: String): IntArray {
|
private fun lineStartOffsets(inText: String): IntArray {
|
||||||
val vStarts = ArrayList<Int>(64)
|
val vStarts = ArrayList<Int>(64)
|
||||||
vStarts.add(0)
|
vStarts.add(0)
|
||||||
@@ -901,9 +917,9 @@ private fun lineStartOffsets(inText: String): IntArray {
|
|||||||
return vStarts.toIntArray()
|
return vStarts.toIntArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header table: each row is (key | value) where the key column has a
|
/** Header table: each row is (key | value) where the key column has a
|
||||||
fixed width so values line up. Long keys wrap; long values wrap too.
|
fixed width so values line up. Long keys wrap; long values wrap too.
|
||||||
Keys render in accent colour, values in regular text. */
|
Keys render in accent colour, values in regular text. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifier = Modifier) {
|
internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifier = Modifier) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -927,8 +943,8 @@ internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Body view with line numbers in the gutter — mimics the code panel
|
/** Body view with line numbers in the gutter — mimics the code panel
|
||||||
in httpie. Numbers are dim; body text uses the regular colour. */
|
in httpie. Numbers are dim; body text uses the regular colour. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BodyView(
|
internal fun BodyView(
|
||||||
inText: String,
|
inText: String,
|
||||||
@@ -947,10 +963,10 @@ internal fun BodyView(
|
|||||||
val vGutterWidth = (vDigits * 7 + 4).dp
|
val vGutterWidth = (vDigits * 7 + 4).dp
|
||||||
// Body wrap width (physical px, from onSizeChanged), reported by the body
|
// Body wrap width (physical px, from onSizeChanged), reported by the body
|
||||||
// Box once it's laid out (0 on the first frame). The gutter numbering
|
// Box once it's laid out (0 on the first frame). The gutter numbering
|
||||||
// depends on it — see vNumbers. NativeTextMeasurer.wrap expects the font
|
// depends on it — see vNumbers. Wrap measurement expects the font
|
||||||
// size + max-width in the SAME unit (both physical px on Retina), so we
|
// size + max-width in the SAME unit (both physical px on Retina), so we
|
||||||
// scale 12.sp through the current density here, matching what SdlParagraph
|
// scale 12.sp through the current density here, matching what the paragraph
|
||||||
// does when it builds the body's real paragraph.
|
// engine does when it builds the body's real paragraph.
|
||||||
var vBodyWidthPx by remember { mutableStateOf(0) }
|
var vBodyWidthPx by remember { mutableStateOf(0) }
|
||||||
val vFontPx = with(LocalDensity.current) { 12.sp.toPx().toInt() }
|
val vFontPx = with(LocalDensity.current) { 12.sp.toPx().toInt() }
|
||||||
// Horizontal scroll for no-wrap mode (read-only body only); dormant when wrapping.
|
// Horizontal scroll for no-wrap mode (read-only body only); dormant when wrapping.
|
||||||
@@ -974,18 +990,18 @@ internal fun BodyView(
|
|||||||
// also use it for the first frame, before the body reports its width).
|
// also use it for the first frame, before the body reports its width).
|
||||||
val vNumColor = c.dim.copy(alpha = 0.45f)
|
val vNumColor = c.dim.copy(alpha = 0.45f)
|
||||||
val vNumbers = remember(inText, vBodyWidthPx, inSoftWrap, vFontPx) {
|
val vNumbers = remember(inText, vBodyWidthPx, inSoftWrap, vFontPx) {
|
||||||
val vM = if (inSoftWrap && vBodyWidthPx > 0) currentTextMeasurer else null
|
val vWrapRows = inSoftWrap && vBodyWidthPx > 0
|
||||||
buildString {
|
buildString {
|
||||||
for ((vIdx, vSrc) in vLines.withIndex()) {
|
for ((vIdx, vSrc) in vLines.withIndex()) {
|
||||||
if (vIdx > 0) append('\n')
|
if (vIdx > 0) append('\n')
|
||||||
append(vIdx + 1)
|
append(vIdx + 1)
|
||||||
if (vM != null) {
|
if (vWrapRows) {
|
||||||
// Pass the density-scaled font size so this wrap matches the body's
|
// Pass the density-scaled font size so this wrap matches the body's
|
||||||
// actual glyph pixel widths — passing 12 (sp) here counted each
|
// actual glyph pixel widths — passing 12 (sp) here counted each
|
||||||
// char as half its rendered size, so long lines that visually
|
// char as half its rendered size, so long lines that visually
|
||||||
// wrapped into 3 rows in the body were estimated as 1 or 2 rows
|
// wrapped into 3 rows in the body were estimated as 1 or 2 rows
|
||||||
// here and the gutter numbers drifted upward.
|
// here and the gutter numbers drifted upward.
|
||||||
val vRows = vM.wrap(vSrc, vFontPx, vBodyWidthPx, monoFontFamilyName).lines.size.coerceAtLeast(1)
|
val vRows = wrappedRowCount(vSrc, vFontPx, vBodyWidthPx, monoFontFamilyName).coerceAtLeast(1)
|
||||||
repeat(vRows - 1) { append('\n') }
|
repeat(vRows - 1) { append('\n') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1024,8 @@ internal fun BodyView(
|
|||||||
// anywhere (not just on the text line) focuses it and starts writing.
|
// anywhere (not just on the text line) focuses it and starts writing.
|
||||||
// Read-only (response) keeps wrap-height so it grows with content.
|
// Read-only (response) keeps wrap-height so it grows with content.
|
||||||
val vEditable = inOnChange != null
|
val vEditable = inOnChange != null
|
||||||
Box(modifier = Modifier.weight(1f)
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
.onSizeChanged { vBodyWidthPx = it.width }
|
.onSizeChanged { vBodyWidthPx = it.width }
|
||||||
.then(if (vEditable) Modifier.fillMaxHeight() else Modifier)) {
|
.then(if (vEditable) Modifier.fillMaxHeight() else Modifier)) {
|
||||||
if (inText.isEmpty() && inPlaceholder.isNotEmpty()) {
|
if (inText.isEmpty() && inPlaceholder.isNotEmpty()) {
|
||||||
@@ -1031,16 +1048,16 @@ internal fun BodyView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
LocalTextSelectionColors provides
|
||||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||||
handleColor = c.accent,
|
handleColor = c.accent,
|
||||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inText,
|
value = inText,
|
||||||
onValueChange = inOnChange,
|
onValueChange = inOnChange,
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
textStyle = TextStyle(
|
||||||
color = c.text,
|
color = c.text,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontFamily = monoFontFamily,
|
fontFamily = monoFontFamily,
|
||||||
@@ -1079,11 +1096,11 @@ internal fun BodyView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "Request" + colored "GET" tab. inAccent is shown next to the label
|
/** "Request" + colored "GET" tab. inAccent is shown next to the label
|
||||||
when non-null (e.g. method name once sent, status code once received);
|
when non-null (e.g. method name once sent, status code once received);
|
||||||
when null the tab is just the label, no placeholder dash. Selected
|
when null the tab is just the label, no placeholder dash. Selected
|
||||||
tab gets full-strength labels and a 2dp underline; unselected tabs
|
tab gets full-strength labels and a 2dp underline; unselected tabs
|
||||||
stay dimmed. */
|
stay dimmed. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerTab(
|
internal fun ViewerTab(
|
||||||
inLabel: String,
|
inLabel: String,
|
||||||
@@ -1103,7 +1120,11 @@ internal fun ViewerTab(
|
|||||||
) {
|
) {
|
||||||
Text(inLabel, color = if (inSelected) c.dim else c.dim.copy(alpha = 0.6f), fontSize = 14.sp)
|
Text(inLabel, color = if (inSelected) c.dim else c.dim.copy(alpha = 0.6f), fontSize = 14.sp)
|
||||||
if (inAccent != null) {
|
if (inAccent != null) {
|
||||||
Text(inAccent, color = if (inSelected) inAccentColor else inAccentColor.copy(alpha = 0.6f), fontSize = 14.sp)
|
Text(
|
||||||
|
inAccent,
|
||||||
|
color = if (inSelected) inAccentColor else inAccentColor.copy(alpha = 0.6f),
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
@@ -1120,10 +1141,10 @@ internal fun ViewerTab(
|
|||||||
// MARK: Helpers — header parsing / formatting
|
// MARK: Helpers — header parsing / formatting
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* "HTTP/1.1 200 OK" status line for a Response, with the protocol
|
/** "HTTP/1.1 200 OK" status line for a Response, with the protocol
|
||||||
token dimmed (it's structural) and the status code + reason in the
|
token dimmed (it's structural) and the status code + reason in the
|
||||||
status colour. Just "HTTP/1.1" pre-response — no placeholder. Triple-
|
status colour. Just "HTTP/1.1" pre-response — no placeholder. Triple-
|
||||||
spaced to match the request-line formatting. */
|
spaced to match the request-line formatting. */
|
||||||
internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): AnnotatedString {
|
internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): AnnotatedString {
|
||||||
if (inResp == null) return buildAnnotatedString {
|
if (inResp == null) return buildAnnotatedString {
|
||||||
pushStyle(SpanStyle(color = inColors.dim))
|
pushStyle(SpanStyle(color = inColors.dim))
|
||||||
@@ -1150,34 +1171,35 @@ internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): Annota
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "GET /path HTTP/1.1" status line for a Request. Each part takes
|
/** "GET /path HTTP/1.1" status line for a Request. Each part takes
|
||||||
its own colour: method in its canonical method colour, URL in the
|
its own colour: method in its canonical method colour, URL in the
|
||||||
default text colour, protocol token dimmed so the eye lands on the
|
default text colour, protocol token dimmed so the eye lands on the
|
||||||
request target first. Triple-spaced so the columns read at a glance.
|
request target first. Triple-spaced so the columns read at a glance.
|
||||||
The protocol string comes from the matching response when we have
|
The protocol string comes from the matching response when we have
|
||||||
one (so HTTP/2 servers show "HTTP/2"); fallback "HTTP/1.1" before
|
one (so HTTP/2 servers show "HTTP/2"); fallback "HTTP/1.1" before
|
||||||
send. */
|
send. */
|
||||||
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString = buildAnnotatedString {
|
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString =
|
||||||
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
|
buildAnnotatedString {
|
||||||
append(inReq.method.name)
|
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
|
||||||
pop()
|
append(inReq.method.name)
|
||||||
append(" ")
|
pop()
|
||||||
pushStyle(SpanStyle(color = inColors.text))
|
append(" ")
|
||||||
// urlWithParams appends the Query-tab key/values percent-encoded;
|
pushStyle(SpanStyle(color = inColors.text))
|
||||||
// urlPathOnly then strips scheme+host so the request line reads
|
// urlWithParams appends the Query-tab key/values percent-encoded;
|
||||||
// "GET /search?q=foo&n=10 HTTP/1.1" like httpie / curl trace.
|
// urlPathOnly then strips scheme+host so the request line reads
|
||||||
append(urlPathOnly(urlWithParams(inReq)).ifEmpty { "/" })
|
// "GET /search?q=foo&n=10 HTTP/1.1" like httpie / curl trace.
|
||||||
pop()
|
append(urlPathOnly(urlWithParams(inReq)).ifEmpty { "/" })
|
||||||
append(" ")
|
pop()
|
||||||
pushStyle(SpanStyle(color = inColors.dim))
|
append(" ")
|
||||||
append(inHttpVersion)
|
pushStyle(SpanStyle(color = inColors.dim))
|
||||||
pop()
|
append(inHttpVersion)
|
||||||
}
|
pop()
|
||||||
|
}
|
||||||
|
|
||||||
/* Pull the path (+ query) out of a URL, dropping scheme + host. The
|
/** Pull the path (+ query) out of a URL, dropping scheme + host. The
|
||||||
request line in HTTP is "METHOD path HTTP/x.y" — the host lives on a
|
request line in HTTP is "METHOD path HTTP/x.y" — the host lives on a
|
||||||
separate Host: header line, not in the path. Falls back to the input
|
separate Host: header line, not in the path. Falls back to the input
|
||||||
string when no "://" is present (relative URL). */
|
string when no "://" is present (relative URL). */
|
||||||
internal fun urlPathOnly(inUrl: String): String {
|
internal fun urlPathOnly(inUrl: String): String {
|
||||||
val vIdx = inUrl.indexOf("://")
|
val vIdx = inUrl.indexOf("://")
|
||||||
if (vIdx < 0) return inUrl
|
if (vIdx < 0) return inUrl
|
||||||
@@ -1186,8 +1208,8 @@ internal fun urlPathOnly(inUrl: String): String {
|
|||||||
return if (vSlash < 0) "/" else vAfterScheme.substring(vSlash)
|
return if (vSlash < 0) "/" else vAfterScheme.substring(vSlash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Extract the host (+ port) from a URL — used to synthesize a Host
|
/** Extract the host (+ port) from a URL — used to synthesize a Host
|
||||||
header for display when the engine didn't surface one. */
|
header for display when the engine didn't surface one. */
|
||||||
internal fun urlHost(inUrl: String): String? {
|
internal fun urlHost(inUrl: String): String? {
|
||||||
val vIdx = inUrl.indexOf("://")
|
val vIdx = inUrl.indexOf("://")
|
||||||
if (vIdx < 0) return null
|
if (vIdx < 0) return null
|
||||||
@@ -1197,44 +1219,44 @@ internal fun urlHost(inUrl: String): String? {
|
|||||||
return vAuthority.ifEmpty { null }
|
return vAuthority.ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The user-agent string our Darwin engine sends is opaque to Ktor —
|
/** The user-agent string our Darwin engine sends is opaque to Ktor —
|
||||||
NSURLSession picks the default. Match what httpie does: identify
|
NSURLSession picks the default. Match what httpie does: identify
|
||||||
ourselves so the wire log isn't missing the field entirely. */
|
ourselves so the wire log isn't missing the field entirely. */
|
||||||
internal const val kUserAgent: String = "compose-apidemo/1.0"
|
internal const val kUserAgent: String = "compose-apidemo/1.0"
|
||||||
|
|
||||||
/* Combine Ktor's reported request headers with the ones the engine
|
/** Combine Ktor's reported request headers with the ones the engine
|
||||||
adds at the wire level (Host, Content-Length, User-Agent) so the
|
adds at the wire level (Host, Content-Length, User-Agent) so the
|
||||||
Request tab shows the actual on-the-wire header set rather than
|
Request tab shows the actual on-the-wire header set rather than
|
||||||
just the subset Ktor sees. Sorted alphabetically. */
|
just the subset Ktor sees. Sorted alphabetically. */
|
||||||
internal fun synthesizeRequestHeaders(
|
internal fun synthesizeRequestHeaders(
|
||||||
inReq: ApiRequest,
|
inReq: ApiRequest,
|
||||||
inReported: List<Pair<String, String>>,
|
inReported: List<Pair<String, String>>,
|
||||||
): List<Pair<String, String>> {
|
): List<Pair<String, String>> {
|
||||||
val vByKey = inReported.associate { it.first.lowercase() to it }.toMutableMap()
|
val vByKey = inReported.associateBy { it.first.lowercase() }.toMutableMap()
|
||||||
urlHost(inReq.url)?.let { vHost ->
|
urlHost(inReq.url)?.let { vHost ->
|
||||||
vByKey.getOrPut("host") { "Host" to vHost }
|
vByKey.getOrPut("host") { "Host" to vHost }
|
||||||
}
|
}
|
||||||
if (!vByKey.containsKey("user-agent")) {
|
if (!vByKey.containsKey("user-agent")) {
|
||||||
vByKey["user-agent"] = "User-Agent" to kUserAgent
|
vByKey["user-agent"] = "User-Agent" to kUserAgent
|
||||||
}
|
}
|
||||||
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE && !vByKey.containsKey("content-length")) {
|
if (true && inReq.bodyType != BodyType.NONE && !vByKey.containsKey("content-length")) {
|
||||||
val vLen = computedBodyLength(inReq)
|
val vLen = computedBodyLength(inReq)
|
||||||
if (vLen != null) vByKey["content-length"] = "Content-Length" to vLen.toString()
|
if (vLen != null) vByKey["content-length"] = "Content-Length" to vLen.toString()
|
||||||
}
|
}
|
||||||
return vByKey.values.sortedBy { it.first.lowercase() }
|
return vByKey.values.sortedBy { it.first.lowercase() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Body length in bytes for the headers synthesis. JSON / TEXT use the
|
/** Body length in bytes for the headers synthesis. JSON / TEXT use the
|
||||||
raw UTF-8 byte count; FORM serialises and counts; FILE skips
|
raw UTF-8 byte count; FORM serialises and counts; FILE skips
|
||||||
(loading the file just for the count would be wasteful — the engine
|
(loading the file just for the count would be wasteful — the engine
|
||||||
sets the field anyway). */
|
sets the field anyway). */
|
||||||
internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType) {
|
internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType) {
|
||||||
BodyType.TEXT -> inReq.body.encodeToByteArray().size
|
BodyType.TEXT -> inReq.body.encodeToByteArray().size
|
||||||
BodyType.FORM -> formEncode(inReq.form).encodeToByteArray().size
|
BodyType.FORM -> formEncode(inReq.form).encodeToByteArray().size
|
||||||
BodyType.FILE, BodyType.NONE -> null
|
BodyType.FILE, BodyType.NONE -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Format the timing / size footer text shown bottom-right. */
|
/** Format the timing / size footer text shown bottom-right. */
|
||||||
internal fun formatTimingSize(inResp: ApiResponse): String {
|
internal fun formatTimingSize(inResp: ApiResponse): String {
|
||||||
val vSize = when {
|
val vSize = when {
|
||||||
inResp.sizeBytes < 1024 -> "${inResp.sizeBytes} B"
|
inResp.sizeBytes < 1024 -> "${inResp.sizeBytes} B"
|
||||||
@@ -1248,10 +1270,10 @@ internal fun formatTimingSize(inResp: ApiResponse): String {
|
|||||||
// MARK: ViewerOverflowMenu — 3-dot menu in the bottom-right of the viewer
|
// MARK: ViewerOverflowMenu — 3-dot menu in the bottom-right of the viewer
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Replaces the inline Copy / Save chips with a single MoreHoriz menu.
|
/** Replaces the inline Copy / Save chips with a single MoreHoriz menu.
|
||||||
Copy actions target whichever tab is showing (request or response);
|
Copy actions target whichever tab is showing (request or response);
|
||||||
Clear is global — wipes the response, sentReq, preview state, and any
|
Clear is global — wipes the response, sentReq, preview state, and any
|
||||||
memory-backed image resource for the current request. */
|
memory-backed image resource for the current request. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerOverflowMenu(
|
internal fun ViewerOverflowMenu(
|
||||||
inIsRequestTab: Boolean,
|
inIsRequestTab: Boolean,
|
||||||
@@ -1309,18 +1331,18 @@ internal fun ViewerOverflowMenu(
|
|||||||
}
|
}
|
||||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
||||||
DropdownMenuItem(text = { Text("Copy all") }, onClick = {
|
DropdownMenuItem(text = { Text("Copy all") }, onClick = {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(copyAll())) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(copyAll())) }
|
||||||
inOnMessage("Copied all.")
|
inOnMessage("Copied all.")
|
||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
DropdownMenuItem(text = { Text("Copy headers") }, onClick = {
|
DropdownMenuItem(text = { Text("Copy headers") }, onClick = {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vHeadersText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vHeadersText)) }
|
||||||
inOnMessage("Copied headers.")
|
inOnMessage("Copied headers.")
|
||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
if (vCanCopyBody) {
|
if (vCanCopyBody) {
|
||||||
DropdownMenuItem(text = { Text("Copy body") }, onClick = {
|
DropdownMenuItem(text = { Text("Copy body") }, onClick = {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vBodyText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vBodyText)) }
|
||||||
inOnMessage("Copied body.")
|
inOnMessage("Copied body.")
|
||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
@@ -1341,13 +1363,15 @@ internal fun ViewerOverflowMenu(
|
|||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = c.border)
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = c.border)
|
||||||
DropdownMenuItem(text = { Text("Clear", color = Color(0xFFFF5630)) }, onClick = { inOnClear(); vOpen = false })
|
DropdownMenuItem(
|
||||||
|
text = { Text("Clear", color = Color(0xFFFF5630)) },
|
||||||
|
onClick = { inOnClear(); vOpen = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Perceptual luminance check — true when the colour reads as "dark"
|
/** Perceptual luminance check — true when the colour reads as "dark"
|
||||||
(background gets a light foreground). Standard Rec. 709 weights. */
|
(background gets a light foreground). Standard Rec. 709 weights. */
|
||||||
internal fun isDarkBg(inColor: Color): Boolean {
|
internal fun isDarkBg(inColor: Color): Boolean {
|
||||||
val vY = 0.299f * inColor.red + 0.587f * inColor.green + 0.114f * inColor.blue
|
val vY = 0.299f * inColor.red + 0.587f * inColor.green + 0.114f * inColor.blue
|
||||||
return vY < 0.5f
|
return vY < 0.5f
|
||||||
@@ -1357,9 +1381,9 @@ internal fun isDarkBg(inColor: Color): Boolean {
|
|||||||
// MARK: BodyFormatSelector — small dropdown for RAW / JSON / XML / YAML / HTML
|
// MARK: BodyFormatSelector — small dropdown for RAW / JSON / XML / YAML / HTML
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Format / "type" picker. inBordered renders it as a full dropdown matching
|
/** Format / "type" picker. inBordered renders it as a full dropdown matching
|
||||||
BodyTypeMenu (the Body-tab Text-type picker); otherwise it's the compact inline
|
BodyTypeMenu (the Body-tab Text-type picker); otherwise it's the compact inline
|
||||||
control used by the response viewer. */
|
control used by the response viewer. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BodyFormatSelector(
|
internal fun BodyFormatSelector(
|
||||||
inSelected: BodyFormat,
|
inSelected: BodyFormat,
|
||||||
@@ -1381,23 +1405,28 @@ internal fun BodyFormatSelector(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(if (inBordered) 5.dp else 4.dp),
|
horizontalArrangement = Arrangement.spacedBy(if (inBordered) 5.dp else 4.dp),
|
||||||
) {
|
) {
|
||||||
Text(vLabel, color = if (inBordered) c.text else c.dim, fontSize = if (inBordered) 13.sp else 11.sp)
|
Text(vLabel, color = if (inBordered) c.text else c.dim, fontSize = if (inBordered) 13.sp else 11.sp)
|
||||||
MaterialSymbolsOutlined(if (inBordered) MaterialSymbols.UnfoldMore else MaterialSymbols.ArrowDropDown, tint = c.dim, size = if (inBordered) 15.dp else 14.dp)
|
MaterialSymbolsOutlined(
|
||||||
|
if (inBordered) MaterialSymbols.UnfoldMore else MaterialSymbols.ArrowDropDown,
|
||||||
|
tint = c.dim,
|
||||||
|
size = if (inBordered) 15.dp else 14.dp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
||||||
for (vF in BodyFormat.values()) {
|
for (vF in BodyFormat.entries) {
|
||||||
DropdownMenuItem(text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
|
DropdownMenuItem(
|
||||||
|
text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
|
||||||
onClick = { inOnChange(vF); vOpen = false })
|
onClick = { inOnChange(vF); vOpen = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TLS validation indicator: only true when the URL is https AND we got
|
/** TLS validation indicator: only true when the URL is https AND we got
|
||||||
a real response back (i.e. the OS engine completed the TLS handshake
|
a real response back (i.e. the OS engine completed the TLS handshake
|
||||||
without an error). The engines we ship — NSURLSession on macOS,
|
without an error). The engines we ship — NSURLSession on macOS,
|
||||||
WinHttp on Windows, libcurl on Linux — all reject an untrusted
|
WinHttp on Windows, libcurl on Linux — all reject an untrusted
|
||||||
certificate by default, so a non-error response is implicit proof
|
certificate by default, so a non-error response is implicit proof
|
||||||
that the OS validated the chain. */
|
that the OS validated the chain. */
|
||||||
internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
|
internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
|
||||||
if (!inUrl.startsWith("https://", ignoreCase = true)) return false
|
if (!inUrl.startsWith("https://", ignoreCase = true)) return false
|
||||||
if (inResp == null) return false
|
if (inResp == null) return false
|
||||||
@@ -1405,8 +1434,8 @@ internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
|
|||||||
return inResp.status > 0
|
return inResp.status > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Parse "Key: value\nKey2: value2" into a list of pairs so the headers
|
/** Parse "Key: value\nKey2: value2" into a list of pairs so the headers
|
||||||
table renderer can lay them out as a key/value grid. */
|
table renderer can lay them out as a key/value grid. */
|
||||||
internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
|
internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
|
||||||
if (inText.isBlank() || inText == "(no headers)") return emptyList()
|
if (inText.isBlank() || inText == "(no headers)") return emptyList()
|
||||||
return inText.split('\n').mapNotNull { vLine ->
|
return inText.split('\n').mapNotNull { vLine ->
|
||||||
@@ -1415,16 +1444,16 @@ internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "content-type" → "Content-Type"; preserves single-word keys; leaves
|
/** "content-type" → "Content-Type"; preserves single-word keys; leaves
|
||||||
already-correct ones unchanged. */
|
already-correct ones unchanged. */
|
||||||
internal fun titleCaseHeader(inKey: String): String =
|
internal fun titleCaseHeader(inKey: String): String =
|
||||||
inKey.split('-').joinToString("-") { vWord ->
|
inKey.split('-').joinToString("-") { vWord ->
|
||||||
if (vWord.isEmpty()) vWord
|
if (vWord.isEmpty()) vWord
|
||||||
else vWord[0].uppercaseChar() + vWord.drop(1).lowercase()
|
else vWord[0].uppercaseChar() + vWord.drop(1).lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Centered icon + label placeholder for the viewer (Not sent / Not received /
|
/** Centered icon + label placeholder for the viewer (Not sent / Not received /
|
||||||
No Body). Fills its parent unless a sized modifier is supplied. */
|
No Body). Fills its parent unless a sized modifier is supplied. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Modifier.fillMaxSize()) {
|
internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Modifier.fillMaxSize()) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -1436,64 +1465,32 @@ internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Mod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A labelled, read-only (selectable) code block used by the viewer sections. */
|
/** Format an actual header list (key: value per line). */
|
||||||
@Composable
|
|
||||||
internal fun CodeSection(inLabel: String, inText: String) {
|
|
||||||
val c = LocalAppColors.current
|
|
||||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(inLabel, color = c.dim, fontSize = 11.sp)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(c.bg, RoundedCornerShape(8.dp)).border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(12.dp),
|
|
||||||
) {
|
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
|
||||||
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
|
||||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
|
||||||
handleColor = c.accent,
|
|
||||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = inText.ifEmpty { "(empty)" }, onValueChange = {}, readOnly = true,
|
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(color = c.text, fontSize = 12.sp),
|
|
||||||
cursorBrush = androidx.compose.ui.graphics.SolidColor(c.accent),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Format an actual header list (key: value per line). */
|
|
||||||
internal fun headersText(inHeaders: List<Pair<String, String>>): String =
|
internal fun headersText(inHeaders: List<Pair<String, String>>): String =
|
||||||
inHeaders.joinToString("\n") { (vK, vV) -> "$vK: $vV" }.ifEmpty { "(no headers)" }
|
inHeaders.joinToString("\n") { (vK, vV) -> "$vK: $vV" }.ifEmpty { "(no headers)" }
|
||||||
|
|
||||||
/* The headers that *would* be sent — explicit enabled headers plus the inferred
|
/** The headers that *would* be sent — explicit enabled headers plus the inferred
|
||||||
Content-Type for the body type (unless one is already set). Used for Preview;
|
Content-Type for the body type (unless one is already set). Used for Preview;
|
||||||
a sent request shows the real headers via headersText(response.requestHeaders). */
|
a sent request shows the real headers via headersText(response.requestHeaders). */
|
||||||
internal fun requestHeadersText(inReq: ApiRequest): String {
|
internal fun requestHeadersText(inReq: ApiRequest): String {
|
||||||
val vLines = mutableListOf<String>()
|
val vLines = mutableListOf<String>()
|
||||||
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { vLines.add("${it.key}: ${it.value}") }
|
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { vLines.add("${it.key}: ${it.value}") }
|
||||||
val vCt = inReq.bodyContentType()
|
val vCt = inReq.bodyContentType()
|
||||||
if (inReq.method.allowsBody && vCt != null && inReq.headers.none { it.enabled && it.key.equals("content-type", ignoreCase = true) }) {
|
if (true && vCt != null && inReq.headers.none {
|
||||||
|
it.enabled && it.key.equals(
|
||||||
|
"content-type",
|
||||||
|
ignoreCase = true
|
||||||
|
)
|
||||||
|
}) {
|
||||||
vLines.add("Content-Type: $vCt")
|
vLines.add("Content-Type: $vCt")
|
||||||
}
|
}
|
||||||
return vLines.joinToString("\n").ifEmpty { "(no headers)" }
|
return vLines.joinToString("\n").ifEmpty { "(no headers)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The body that would be sent, rendered as text for the preview. */
|
/** The body that would be sent, rendered as text for the preview. */
|
||||||
internal fun requestBodyText(inReq: ApiRequest): String = when (inReq.bodyType) {
|
internal fun requestBodyText(inReq: ApiRequest): String = when (inReq.bodyType) {
|
||||||
BodyType.NONE -> "(no body)"
|
BodyType.NONE -> "(no body)"
|
||||||
BodyType.TEXT -> inReq.body.ifEmpty { "(empty)" }
|
BodyType.TEXT -> inReq.body.ifEmpty { "(empty)" }
|
||||||
BodyType.FORM -> formEncode(inReq.form).ifEmpty { "(empty form)" }
|
BodyType.FORM -> formEncode(inReq.form).ifEmpty { "(empty form)" }
|
||||||
BodyType.FILE -> if (inReq.body.isBlank()) "(no file)" else "(file) ${inReq.body}"
|
BodyType.FILE -> if (inReq.body.isBlank()) "(no file)" else "(file) ${inReq.body}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun StatusPill(inStatus: Int, inLabel: String) {
|
|
||||||
val vC = statusColor(inStatus)
|
|
||||||
Box(modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(vC.copy(alpha = 0.20f), RoundedCornerShape(6.dp)).padding(horizontal = 12.dp, vertical = 6.dp)) {
|
|
||||||
Text(inLabel, color = vC, fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Voltic themes — selectable Material 3 palettes
|
||||||
|
// ==================
|
||||||
|
// The three "voltic" brand palettes (Kotlin purple, orange, and a stock-M3
|
||||||
|
// purple), each a full light + dark M3 ColorScheme plus an EXTENDED group that
|
||||||
|
// M3 has no slot for (warning). The app runs on MaterialTheme(colorScheme = …)
|
||||||
|
// driven by the selected palette; the legacy AppColors abstraction is DERIVED
|
||||||
|
// from the active scheme (appColorsFromScheme) so existing widgets follow along.
|
||||||
|
|
||||||
|
/** Colors M3's ColorScheme has no slot for. Warning is the amber "unsaved /
|
||||||
|
undefined variable" accent; read it with VolticTheme.extended.warning. */
|
||||||
|
@Immutable
|
||||||
|
data class VolticExtendedColors(
|
||||||
|
val warning: Color,
|
||||||
|
val onWarning: Color,
|
||||||
|
val warningContainer: Color,
|
||||||
|
val onWarningContainer: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One palette's four schemes: the M3 light/dark ColorSchemes + the matching
|
||||||
|
extended groups. */
|
||||||
|
@Immutable
|
||||||
|
class VolticScheme(
|
||||||
|
val light: ColorScheme,
|
||||||
|
val dark: ColorScheme,
|
||||||
|
val lightExtended: VolticExtendedColors,
|
||||||
|
val darkExtended: VolticExtendedColors,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The palettes the user can pick from. `id` is the stable persistence key. */
|
||||||
|
enum class VolticPalette(val id: String, val label: String) {
|
||||||
|
Purple("purple", "Kotlin Purple"),
|
||||||
|
Orange("orange", "Voltic Orange"),
|
||||||
|
PurpleStock("purple-stock", "Purple");
|
||||||
|
|
||||||
|
val scheme: VolticScheme
|
||||||
|
get() = when (this) {
|
||||||
|
Purple -> kPurpleScheme
|
||||||
|
Orange -> kOrangeScheme
|
||||||
|
PurpleStock -> kPurpleStockScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromId(inId: String?): VolticPalette = entries.firstOrNull { it.id == inId } ?: Purple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: MaterialTheme.colorScheme.primary, VolticTheme.extended.warning
|
||||||
|
val LocalVolticExtended = staticCompositionLocalOf { kPurpleScheme.darkExtended }
|
||||||
|
|
||||||
|
object VolticTheme {
|
||||||
|
val extended: VolticExtendedColors
|
||||||
|
@Composable get() = LocalVolticExtended.current
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the active M3 ColorScheme onto the app's legacy AppColors slots, so the
|
||||||
|
~50 existing `LocalAppColors.current` call sites theme with the palette:
|
||||||
|
panel = surface, field = surfaceVariant, border = the subtle outlineVariant,
|
||||||
|
dim = onSurfaceVariant, accent = primary. */
|
||||||
|
internal fun appColorsFromScheme(inScheme: ColorScheme): AppColors = AppColors(
|
||||||
|
bg = inScheme.background,
|
||||||
|
panel = inScheme.surface,
|
||||||
|
field = inScheme.surfaceVariant,
|
||||||
|
border = inScheme.outlineVariant,
|
||||||
|
accent = inScheme.primary,
|
||||||
|
text = inScheme.onSurface,
|
||||||
|
dim = inScheme.onSurfaceVariant,
|
||||||
|
onAccent = inScheme.onPrimary,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Palette definitions (from voltic-pack/theme)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
private val kPurpleScheme = VolticScheme(
|
||||||
|
light = lightColorScheme(
|
||||||
|
primary = Color(0xFF7F52FF), onPrimary = Color(0xFFFFFFFF),
|
||||||
|
primaryContainer = Color(0xFFE3D4FF), onPrimaryContainer = Color(0xFF2A1065),
|
||||||
|
secondary = Color(0xFF5F5873), onSecondary = Color(0xFFFFFFFF),
|
||||||
|
secondaryContainer = Color(0xFFE5DFF3), onSecondaryContainer = Color(0xFF1B1626),
|
||||||
|
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
|
||||||
|
tertiaryContainer = Color(0xFFFFE8B8), onTertiaryContainer = Color(0xFF2F2000),
|
||||||
|
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
|
||||||
|
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
|
||||||
|
background = Color(0xFFFAF8FF), onBackground = Color(0xFF1B1626),
|
||||||
|
surface = Color(0xFFFFFFFF), onSurface = Color(0xFF1B1626),
|
||||||
|
surfaceVariant = Color(0xFFECE6F6), onSurfaceVariant = Color(0xFF453D5C),
|
||||||
|
outline = Color(0xFF6A5F8A), outlineVariant = Color(0xFFD7CDEB),
|
||||||
|
inverseSurface = Color(0xFF2E2840), inverseOnSurface = Color(0xFFF3EFFB),
|
||||||
|
inversePrimary = Color(0xFF9D7BFF), surfaceTint = Color(0xFF7F52FF), scrim = Color(0xFF000000),
|
||||||
|
),
|
||||||
|
dark = darkColorScheme(
|
||||||
|
primary = Color(0xFF9D7BFF), onPrimary = Color(0xFF1F0068),
|
||||||
|
primaryContainer = Color(0xFF4A22C4), onPrimaryContainer = Color(0xFFE3D4FF),
|
||||||
|
secondary = Color(0xFFC9C1DC), onSecondary = Color(0xFF2E2840),
|
||||||
|
secondaryContainer = Color(0xFF453D5C), onSecondaryContainer = Color(0xFFE5DFF3),
|
||||||
|
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
|
||||||
|
tertiaryContainer = Color(0xFF6D5000), onTertiaryContainer = Color(0xFFFFE8B8),
|
||||||
|
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
|
||||||
|
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
|
||||||
|
background = Color(0xFF0F0D17), onBackground = Color(0xFFE6E1F2),
|
||||||
|
surface = Color(0xFF171423), onSurface = Color(0xFFE6E1F2),
|
||||||
|
surfaceVariant = Color(0xFF37304A), onSurfaceVariant = Color(0xFFCFC6E4),
|
||||||
|
outline = Color(0xFF8F86A8), outlineVariant = Color(0xFF37304A),
|
||||||
|
inverseSurface = Color(0xFFE6E1F2), inverseOnSurface = Color(0xFF171423),
|
||||||
|
inversePrimary = Color(0xFF7F52FF), surfaceTint = Color(0xFF9D7BFF), scrim = Color(0xFF000000),
|
||||||
|
),
|
||||||
|
lightExtended = VolticExtendedColors(
|
||||||
|
warning = Color(0xFFB26A00), onWarning = Color(0xFFFFFFFF),
|
||||||
|
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF2A1800),
|
||||||
|
),
|
||||||
|
darkExtended = VolticExtendedColors(
|
||||||
|
warning = Color(0xFFFFC960), onWarning = Color(0xFF3F2E00),
|
||||||
|
warningContainer = Color(0xFF5C4300), onWarningContainer = Color(0xFFFFDF9E),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val kOrangeScheme = VolticScheme(
|
||||||
|
light = lightColorScheme(
|
||||||
|
primary = Color(0xFFE86A33), onPrimary = Color(0xFFFFFFFF),
|
||||||
|
primaryContainer = Color(0xFFFFDBCB), onPrimaryContainer = Color(0xFF380D00),
|
||||||
|
secondary = Color(0xFF9C4E2A), onSecondary = Color(0xFFFFFFFF),
|
||||||
|
secondaryContainer = Color(0xFFFFE0D2), onSecondaryContainer = Color(0xFF3A0F00),
|
||||||
|
tertiary = Color(0xFF8F6A00), onTertiary = Color(0xFFFFFFFF),
|
||||||
|
tertiaryContainer = Color(0xFFFBE08A), onTertiaryContainer = Color(0xFF2D2000),
|
||||||
|
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
|
||||||
|
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
|
||||||
|
background = Color(0xFFFFF8F5), onBackground = Color(0xFF221A15),
|
||||||
|
surface = Color(0xFFFFF8F5), onSurface = Color(0xFF221A15),
|
||||||
|
surfaceVariant = Color(0xFFF4DED4), onSurfaceVariant = Color(0xFF52443C),
|
||||||
|
outline = Color(0xFF85736B), outlineVariant = Color(0xFFD7C2B8),
|
||||||
|
inverseSurface = Color(0xFF382E29), inverseOnSurface = Color(0xFFFFEDE5),
|
||||||
|
inversePrimary = Color(0xFFFFB598), surfaceTint = Color(0xFFE86A33), scrim = Color(0xFF000000),
|
||||||
|
),
|
||||||
|
dark = darkColorScheme(
|
||||||
|
primary = Color(0xFFFFB598), onPrimary = Color(0xFF5B1B00),
|
||||||
|
primaryContainer = Color(0xFFC1531F), onPrimaryContainer = Color(0xFFFFDBCB),
|
||||||
|
secondary = Color(0xFFFFB599), onSecondary = Color(0xFF5E1A00),
|
||||||
|
secondaryContainer = Color(0xFF7D3715), onSecondaryContainer = Color(0xFFFFE0D2),
|
||||||
|
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
|
||||||
|
tertiaryContainer = Color(0xFF6D5000), onTertiaryContainer = Color(0xFFFBE08A),
|
||||||
|
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
|
||||||
|
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
|
||||||
|
background = Color(0xFF101B28), onBackground = Color(0xFFE3E7ED),
|
||||||
|
surface = Color(0xFF16283A), onSurface = Color(0xFFE3E7ED),
|
||||||
|
surfaceVariant = Color(0xFF33414F), onSurfaceVariant = Color(0xFFC9D3DD),
|
||||||
|
outline = Color(0xFF7C8A97), outlineVariant = Color(0xFF33414F),
|
||||||
|
inverseSurface = Color(0xFFE3E7ED), inverseOnSurface = Color(0xFF16283A),
|
||||||
|
inversePrimary = Color(0xFFE86A33), surfaceTint = Color(0xFFFFB598), scrim = Color(0xFF000000),
|
||||||
|
),
|
||||||
|
lightExtended = VolticExtendedColors(
|
||||||
|
warning = Color(0xFF7D5800), onWarning = Color(0xFFFFFFFF),
|
||||||
|
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF271900),
|
||||||
|
),
|
||||||
|
darkExtended = VolticExtendedColors(
|
||||||
|
warning = Color(0xFFF2C13D), onWarning = Color(0xFF3E2E00),
|
||||||
|
warningContainer = Color(0xFF5E4600), onWarningContainer = Color(0xFFFFDF9E),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val kPurpleStockScheme = VolticScheme(
|
||||||
|
light = lightColorScheme(
|
||||||
|
primary = Color(0xFF7F52FF), onPrimary = Color(0xFFFFFFFF),
|
||||||
|
primaryContainer = Color(0xFFE9DDFF), onPrimaryContainer = Color(0xFF22005D),
|
||||||
|
secondary = Color(0xFF615B71), onSecondary = Color(0xFFFFFFFF),
|
||||||
|
secondaryContainer = Color(0xFFE7DEF8), onSecondaryContainer = Color(0xFF1D192B),
|
||||||
|
tertiary = Color(0xFF9A25AE), onTertiary = Color(0xFFFFFFFF),
|
||||||
|
tertiaryContainer = Color(0xFFFFD6FE), onTertiaryContainer = Color(0xFF35003F),
|
||||||
|
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
|
||||||
|
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
|
||||||
|
background = Color(0xFFFDF7FF), onBackground = Color(0xFF1C1B20),
|
||||||
|
surface = Color(0xFFFDF7FF), onSurface = Color(0xFF1C1B20),
|
||||||
|
surfaceVariant = Color(0xFFE7E0EB), onSurfaceVariant = Color(0xFF49454E),
|
||||||
|
outline = Color(0xFF7A757F), outlineVariant = Color(0xFFCBC4CF),
|
||||||
|
inverseSurface = Color(0xFF313035), inverseOnSurface = Color(0xFFF4EFF4),
|
||||||
|
inversePrimary = Color(0xFFCFBCFF), surfaceTint = Color(0xFF7F52FF), scrim = Color(0xFF000000),
|
||||||
|
),
|
||||||
|
dark = darkColorScheme(
|
||||||
|
primary = Color(0xFFCFBCFF), onPrimary = Color(0xFF3A0AA5),
|
||||||
|
primaryContainer = Color(0xFF6236E0), onPrimaryContainer = Color(0xFFE9DDFF),
|
||||||
|
secondary = Color(0xFFCBC2DB), onSecondary = Color(0xFF332D41),
|
||||||
|
secondaryContainer = Color(0xFF494458), onSecondaryContainer = Color(0xFFE7DEF8),
|
||||||
|
tertiary = Color(0xFFF9ABFF), onTertiary = Color(0xFF570066),
|
||||||
|
tertiaryContainer = Color(0xFF7B0E8F), onTertiaryContainer = Color(0xFFFFD6FE),
|
||||||
|
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
|
||||||
|
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
|
||||||
|
background = Color(0xFF141220), onBackground = Color(0xFFE6E1E9),
|
||||||
|
surface = Color(0xFF1D1A2C), onSurface = Color(0xFFE6E1E9),
|
||||||
|
surfaceVariant = Color(0xFF3B3548), onSurfaceVariant = Color(0xFFCBC4CF),
|
||||||
|
outline = Color(0xFF948F99), outlineVariant = Color(0xFF3B3548),
|
||||||
|
inverseSurface = Color(0xFFE6E1E9), inverseOnSurface = Color(0xFF1D1A2C),
|
||||||
|
inversePrimary = Color(0xFF7F52FF), surfaceTint = Color(0xFFCFBCFF), scrim = Color(0xFF000000),
|
||||||
|
),
|
||||||
|
lightExtended = VolticExtendedColors(
|
||||||
|
warning = Color(0xFFB26A00), onWarning = Color(0xFFFFFFFF),
|
||||||
|
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF2A1800),
|
||||||
|
),
|
||||||
|
darkExtended = VolticExtendedColors(
|
||||||
|
warning = Color(0xFFFFC960), onWarning = Color(0xFF3F2E00),
|
||||||
|
warningContainer = Color(0xFF5C4300), onWarningContainer = Color(0xFFFFDF9E),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import io.ktor.client.*
|
||||||
|
import okio.FileSystem
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Platform seams (native = com.compose.sdl / SDL3, jvm = AWT + upstream desktop)
|
||||||
|
// ==================
|
||||||
|
// The port modules are native-only, so shared code can't call com.compose.sdl
|
||||||
|
// directly — every SDL-backed API the app uses goes through these expects.
|
||||||
|
// Signatures mirror the native originals so the native actuals are pure
|
||||||
|
// delegation (same pattern as :demo's demo.shim package).
|
||||||
|
|
||||||
|
/** Per-user app-data dir (created on demand, ends with a separator) or null. */
|
||||||
|
expect fun appDataDir(inOrg: String, inApp: String): String?
|
||||||
|
|
||||||
|
/** Open the OS file manager showing the given path. Fire-and-forget. */
|
||||||
|
expect fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)? = null)
|
||||||
|
|
||||||
|
/** "Explorer" / "Finder" / "Files" — for menu labels. */
|
||||||
|
expect fun fileManagerName(): String
|
||||||
|
|
||||||
|
/** Async save-file dialog; the callback gets the chosen absolute path or null. */
|
||||||
|
expect fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit)
|
||||||
|
|
||||||
|
/** Async open-file dialog; the callback gets the chosen absolute path or null. */
|
||||||
|
expect fun showOpenFileDialog(inOnResult: (String?) -> Unit)
|
||||||
|
|
||||||
|
/** A plain-text clipboard entry (ClipEntry construction differs per stack). */
|
||||||
|
expect fun clipEntryOfText(inText: String): ClipEntry
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// In-memory response images
|
||||||
|
|
||||||
|
/** Register/replace decoded-image source bytes under a key (resp-image://N). */
|
||||||
|
expect fun registerMemoryImage(inKey: String, inBytes: ByteArray)
|
||||||
|
|
||||||
|
/** Drop a registered image's bytes. */
|
||||||
|
expect fun removeMemoryImage(inKey: String)
|
||||||
|
|
||||||
|
/** Painter for a registered image key (raster or SVG). */
|
||||||
|
@Composable
|
||||||
|
expect fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Text layout
|
||||||
|
|
||||||
|
/** Rows the given line occupies when soft-wrapped to inMaxWidthPx at
|
||||||
|
inFontPx (already density-scaled). Used by the body gutter so its line
|
||||||
|
numbers stay aligned with the wrapped body text. */
|
||||||
|
expect fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int
|
||||||
|
|
||||||
|
/** Editor tab width in spaces — how wide a typed '\t' renders. On the native
|
||||||
|
stack this drives the project text pipeline (TextLayoutConfig); the jvm
|
||||||
|
parity app only stores the preference. */
|
||||||
|
expect var editorTabWidth: Int
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Infrastructure
|
||||||
|
|
||||||
|
/** okio's FileSystem.SYSTEM is declared per-platform, not in its common
|
||||||
|
metadata — surface it through a seam. */
|
||||||
|
internal expect val systemFileSystem: FileSystem
|
||||||
|
|
||||||
|
/** The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
|
||||||
|
the mTLS path), CIO on the jvm parity target. */
|
||||||
|
expect fun createApiHttpClient(): HttpClient
|
||||||
|
|
||||||
|
/** Window-shell integration: an intercepting close handler (return true to
|
||||||
|
allow the close) and an app-wide key-shortcut handler; both cleared when
|
||||||
|
the composition leaves. */
|
||||||
|
@Composable
|
||||||
|
expect fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
|
||||||
|
// COPY of the port's com.compose.sdl.layout LayoutCoordinates sugar so the
|
||||||
|
// same helpers resolve on the jvm parity target (the port modules are
|
||||||
|
// native-only). Guards `isAttached`: a menu/tooltip anchor can be read while
|
||||||
|
// its node is mid-recycle, and positionInWindow() throws on a detached
|
||||||
|
// coordinator.
|
||||||
|
|
||||||
|
/** Logical-point X of this layout in the window. */
|
||||||
|
val LayoutCoordinates.x: Int get() = if (isAttached) positionInWindow().x.toInt() else 0
|
||||||
|
|
||||||
|
/** Logical-point Y of this layout in the window. */
|
||||||
|
val LayoutCoordinates.y: Int get() = if (isAttached) positionInWindow().y.toInt() else 0
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// COPY of the port's Scrollbar (package com.compose.sdl.scrollbar) so the SAME
|
||||||
|
// implementation runs on the jvm parity target — the port modules are
|
||||||
|
// native-only, so apidemo's commonMain cannot reference them directly.
|
||||||
|
// Keep in sync with the original when it changes.
|
||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Scrollbar (Compose Desktop-style)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
|
||||||
|
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
|
||||||
|
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
|
||||||
|
no BoxScope (so no Modifier.align) — pin the bar with the Box's
|
||||||
|
contentAlignment; the fillMaxSize content fills the rest:
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
val state = rememberScrollState()
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(state)) { ... }
|
||||||
|
VerticalScrollbar(
|
||||||
|
adapter = rememberScrollbarAdapter(state),
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Always visible (no auto-hide); the thumb tweens unhoverColor -> hoverColor on
|
||||||
|
hover over hoverDurationMillis, matching upstream. Drag the thumb to scroll;
|
||||||
|
click the track to page. */
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class ScrollbarStyle(
|
||||||
|
val minimalHeight: Dp,
|
||||||
|
val thickness: Dp,
|
||||||
|
val shape: Shape,
|
||||||
|
val hoverDurationMillis: Int,
|
||||||
|
val unhoverColor: Color,
|
||||||
|
val hoverColor: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
|
||||||
|
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
|
||||||
|
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
||||||
|
minimalHeight = 16.dp,
|
||||||
|
thickness = 8.dp,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
hoverDurationMillis = 300,
|
||||||
|
unhoverColor = Color.Black.copy(alpha = 0.12f),
|
||||||
|
hoverColor = Color.Black.copy(alpha = 0.50f),
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: ScrollbarAdapter
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
|
||||||
|
contentSize / viewportSize are along the scroll axis; the thumb is sized by
|
||||||
|
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
||||||
|
interface ScrollbarAdapter {
|
||||||
|
val scrollOffset: Double
|
||||||
|
val contentSize: Double
|
||||||
|
val viewportSize: Double
|
||||||
|
suspend fun scrollTo(scrollOffset: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (The port also ships a ScrollState-based adapter; it reads foundation
|
||||||
|
// internals (ScrollState.contentSize/viewportSize) that a copy outside
|
||||||
|
// :foundation cannot access. apidemo only scrollbars a LazyColumn.)
|
||||||
|
|
||||||
|
/** Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
|
||||||
|
size of its VISIBLE items, so exact pixel geometry of off-screen content is
|
||||||
|
unknowable — we estimate it from the average visible item size × total item
|
||||||
|
count (the same approach as Compose Desktop). Accurate enough for a thumb
|
||||||
|
whose length/position track the scroll position; the estimate self-corrects
|
||||||
|
as items of different sizes scroll through the viewport. */
|
||||||
|
@Composable
|
||||||
|
fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
|
||||||
|
remember(scrollState) { LazyListScrollbarAdapter(scrollState) }
|
||||||
|
|
||||||
|
private class LazyListScrollbarAdapter(private val state: LazyListState) : ScrollbarAdapter {
|
||||||
|
// Mean main-axis size of the currently-visible items, in px (>= 1 to avoid /0).
|
||||||
|
private fun averageItemSize(): Double {
|
||||||
|
val vVisible = state.layoutInfo.visibleItemsInfo
|
||||||
|
if (vVisible.isEmpty()) return 0.0
|
||||||
|
val vSpan = (vVisible.last().offset + vVisible.last().size) - vVisible.first().offset
|
||||||
|
return (vSpan.toDouble() / vVisible.size).coerceAtLeast(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val scrollOffset: Double
|
||||||
|
get() = averageItemSize() * state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset
|
||||||
|
|
||||||
|
override val contentSize: Double
|
||||||
|
get() = averageItemSize() * state.layoutInfo.totalItemsCount
|
||||||
|
|
||||||
|
override val viewportSize: Double
|
||||||
|
get() = (state.layoutInfo.viewportEndOffset - state.layoutInfo.viewportStartOffset).toDouble()
|
||||||
|
|
||||||
|
override suspend fun scrollTo(scrollOffset: Double) {
|
||||||
|
val vDistance = scrollOffset - this.scrollOffset
|
||||||
|
// Within a viewport, scroll by EXACT pixels so a drag stays smooth. A lazy
|
||||||
|
// list's pixel geometry is only ESTIMATED (average visible item size), and
|
||||||
|
// that estimate drifts as differently-sized items scroll through the
|
||||||
|
// viewport — so index-snapping every frame makes the thumb jump. scrollBy
|
||||||
|
// sidesteps the estimate entirely. This is what Compose Desktop's own
|
||||||
|
// LazyListScrollbarAdapter does, and for the same reason.
|
||||||
|
if (abs(vDistance) <= viewportSize) {
|
||||||
|
state.scrollBy(vDistance.toFloat())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Far jump (track-click paging past a viewport): no exact geometry to lean
|
||||||
|
// on, so estimate an index/offset from the average and snap to it.
|
||||||
|
val vAvg = averageItemSize()
|
||||||
|
if (vAvg <= 0.0) return
|
||||||
|
val vTarget = scrollOffset.coerceAtLeast(0.0)
|
||||||
|
val vTotal = state.layoutInfo.totalItemsCount
|
||||||
|
val vIndex = (vTarget / vAvg).toInt().coerceIn(0, (vTotal - 1).coerceAtLeast(0))
|
||||||
|
val vItemOffset = (vTarget - vIndex * vAvg).toInt().coerceAtLeast(0)
|
||||||
|
state.scrollToItem(vIndex, vItemOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: VerticalScrollbar / HorizontalScrollbar
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerticalScrollbar(
|
||||||
|
adapter: ScrollbarAdapter,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
reverseLayout: Boolean = false,
|
||||||
|
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
|
||||||
|
|
||||||
|
/** Shared impl. The root Box is the track (fixed thickness on the cross axis,
|
||||||
|
filling the main axis from the caller's modifier); a child Box is the thumb,
|
||||||
|
positioned with an offset. Drag + track-click are handled on the track in
|
||||||
|
track-relative coordinates (a stable origin), so the grab point stays under
|
||||||
|
the pointer with no feedback from the thumb moving. interactionSource is
|
||||||
|
accepted for API parity; hover here is driven by Modifier.hoverable. */
|
||||||
|
@Composable
|
||||||
|
private fun Scrollbar(
|
||||||
|
adapter: ScrollbarAdapter,
|
||||||
|
modifier: Modifier,
|
||||||
|
reverseLayout: Boolean,
|
||||||
|
style: ScrollbarStyle,
|
||||||
|
@Suppress("UNUSED_PARAMETER") interactionSource: MutableInteractionSource,
|
||||||
|
isVertical: Boolean,
|
||||||
|
) {
|
||||||
|
val vScope = rememberCoroutineScope()
|
||||||
|
var vTrackPx by remember { mutableStateOf(0) }
|
||||||
|
val vHoverSource = remember { MutableInteractionSource() }
|
||||||
|
val vHovered by vHoverSource.collectIsHoveredAsState()
|
||||||
|
var vDraggingThumb by remember { mutableStateOf(false) }
|
||||||
|
// onSizeChanged reports PHYSICAL pixels (Option-B density flow), so every
|
||||||
|
// int/px carried through here — vTrackPx, vThumbLen, vThumbPos — is in
|
||||||
|
// physical pixels. Passing them into `.dp` doubles again through
|
||||||
|
// density.toPx() on Retina, which visibly bloated the thumb (filled the
|
||||||
|
// whole track) and made drag/track-click positions land 2× too far down.
|
||||||
|
// Route pixel-sized modifiers through the current density so `.dp` and the
|
||||||
|
// pixel value stay in the same coord space.
|
||||||
|
val vDensity = LocalDensity.current
|
||||||
|
|
||||||
|
val vContent = adapter.contentSize
|
||||||
|
val vViewport = adapter.viewportSize
|
||||||
|
val vOffset = adapter.scrollOffset
|
||||||
|
val vMaxScroll = vContent - vViewport
|
||||||
|
val vScrollable = vMaxScroll > 0.5 && vViewport > 0.0
|
||||||
|
|
||||||
|
val vColor by animateColorAsState(
|
||||||
|
targetValue = if (vHovered || vDraggingThumb) style.hoverColor else style.unhoverColor,
|
||||||
|
animationSpec = tween(style.hoverDurationMillis),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vRoot = (if (isVertical) modifier.width(style.thickness) else modifier.height(style.thickness))
|
||||||
|
.onSizeChanged { vTrackPx = if (isVertical) it.height else it.width }
|
||||||
|
.hoverable(vHoverSource)
|
||||||
|
|
||||||
|
Box(modifier = vRoot) {
|
||||||
|
if (!vScrollable || vTrackPx <= 0) return@Box
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Thumb metrics (px)
|
||||||
|
val vMinLen = style.minimalHeight.value.toInt()
|
||||||
|
val vThumbLen = (vTrackPx * (vViewport / vContent)).roundToInt()
|
||||||
|
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||||
|
val vTravel = vTrackPx - vThumbLen
|
||||||
|
val vRawPos = if (vTravel <= 0) 0 else (vTravel * (vOffset / vMaxScroll)).roundToInt()
|
||||||
|
val vThumbPos = (if (reverseLayout) vTravel - vRawPos else vRawPos).coerceIn(0, vTravel)
|
||||||
|
|
||||||
|
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
|
||||||
|
val vThumbMod = (
|
||||||
|
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
|
||||||
|
else Modifier.offset { IntOffset(vThumbPos, 0) }.height(style.thickness).width(vThumbLenDp)
|
||||||
|
)
|
||||||
|
.clip(style.shape)
|
||||||
|
.background(vColor)
|
||||||
|
|
||||||
|
Box(modifier = vThumbMod)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Drag the thumb / page on track click — handled on the track so the
|
||||||
|
// coordinates have a stable (non-moving) origin. The press is CONSUMED:
|
||||||
|
// a scrollbar owns the pointer input it handles, so content sharing this
|
||||||
|
// region (the scrollable beneath, or a parent that also reads pointer
|
||||||
|
// input) never reacts to the same press. Geometry is read LIVE from the
|
||||||
|
// adapter at press time — not from the captured render metrics above,
|
||||||
|
// which go stale between pointerInput relaunches — so a grab after a
|
||||||
|
// wheel-scroll still hit-tests against the thumb's current position.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchTrackSize(isVertical, with(vDensity) { vTrackPx.toDp() }, style.thickness)
|
||||||
|
.pointerInput(isVertical, adapter, vTrackPx, reverseLayout) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val vDown = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
vDown.consume()
|
||||||
|
|
||||||
|
// Live geometry — stable for the drag's duration (content /
|
||||||
|
// viewport / track sizes don't change mid-drag).
|
||||||
|
val vContentPx = adapter.contentSize
|
||||||
|
val vViewportPx = adapter.viewportSize
|
||||||
|
val vMaxScrollPx = vContentPx - vViewportPx
|
||||||
|
if (vMaxScrollPx <= 0.0 || vTrackPx <= 0) return@awaitEachGesture
|
||||||
|
val vMinLen = style.minimalHeight.value.toInt()
|
||||||
|
val vLen = (vTrackPx * (vViewportPx / vContentPx)).roundToInt()
|
||||||
|
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||||
|
val vTravelPx = vTrackPx - vLen
|
||||||
|
val vRaw = if (vTravelPx <= 0) 0
|
||||||
|
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
|
||||||
|
val vPos = (if (reverseLayout) vTravelPx - vRaw else vRaw).coerceIn(0, vTravelPx)
|
||||||
|
|
||||||
|
// Map a track-relative thumb-top to a scroll offset. The
|
||||||
|
// target uses the LIVE max scroll (read fresh each move),
|
||||||
|
// not the press-time value — the adapter compares it against
|
||||||
|
// its live scrollOffset, and mixing a stale scale with a live
|
||||||
|
// one makes the delta run away (grab at the bottom, drag up,
|
||||||
|
// and the list overshoots to the middle).
|
||||||
|
fun scrollToTrackPos(inThumbTop: Int) {
|
||||||
|
if (vTravelPx <= 0) return
|
||||||
|
val vLiveMax = adapter.contentSize - adapter.viewportSize
|
||||||
|
if (vLiveMax <= 0.0) return
|
||||||
|
val vClamped = inThumbTop.coerceIn(0, vTravelPx)
|
||||||
|
val vFrac = (if (reverseLayout) vTravelPx - vClamped else vClamped).toDouble() / vTravelPx
|
||||||
|
vScope.launch { adapter.scrollTo(vFrac * vLiveMax) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val vPress = (if (isVertical) vDown.position.y else vDown.position.x).toInt()
|
||||||
|
val vGrab: Int
|
||||||
|
if (vPress in vPos..(vPos + vLen)) {
|
||||||
|
vDraggingThumb = true
|
||||||
|
vGrab = vPress - vPos
|
||||||
|
} else {
|
||||||
|
// Page toward the click by one viewport.
|
||||||
|
vDraggingThumb = false
|
||||||
|
vGrab = 0
|
||||||
|
val vDir = if (vPress < vPos) -1.0 else 1.0
|
||||||
|
vScope.launch { adapter.scrollTo(adapter.scrollOffset + vDir * vViewportPx) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow the pressed pointer to release, consuming every
|
||||||
|
// change so the interaction can't leak. Only a thumb grab
|
||||||
|
// drags-to-scroll; a track press just consumes.
|
||||||
|
do {
|
||||||
|
val vEvent = awaitPointerEvent()
|
||||||
|
val vChange = vEvent.changes.firstOrNull { it.id == vDown.id } ?: break
|
||||||
|
if (vDraggingThumb) {
|
||||||
|
val vCur = (if (isVertical) vChange.position.y else vChange.position.x).toInt()
|
||||||
|
scrollToTrackPos(vCur - vGrab)
|
||||||
|
}
|
||||||
|
vChange.consume()
|
||||||
|
} while (vEvent.changes.any { it.id == vDown.id && it.pressed })
|
||||||
|
|
||||||
|
vDraggingThumb = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
|
||||||
|
comes from the caller pre-converted via LocalDensity (Option-B: layout
|
||||||
|
coords are physical px, so px-to-dp goes through the current density). */
|
||||||
|
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
|
||||||
|
if (inVertical) this.width(inThickness).height(inTrackDp)
|
||||||
|
else this.height(inThickness).width(inTrackDp)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// COPY of the port's SplitPane (package com.compose.sdl.widgets) so the SAME
|
||||||
|
// implementation runs on the jvm parity target — the port modules are
|
||||||
|
// native-only, so apidemo's commonMain cannot reference them directly.
|
||||||
|
// Keep in sync with the original when it changes.
|
||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: SplitPane
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Two-pane resizable split. `initialFirstSize` seeds the first pane's width
|
||||||
|
* (horizontal split) or height (vertical split); after first layout it tracks
|
||||||
|
* the user's drag. `minFirstSize` / `minSecondSize` clamp so neither pane can
|
||||||
|
* shrink past its minimum.
|
||||||
|
*
|
||||||
|
* Internal sizing is in **pixels** to match this port's pixel-space layout
|
||||||
|
* tree (Option-B density flow). `Modifier.width((px / density).dp)` converts
|
||||||
|
* back at the modifier boundary. */
|
||||||
|
@Composable
|
||||||
|
fun HorizontalSplitPane(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
initialFirstSize: Dp = 200.dp,
|
||||||
|
minFirstSize: Dp = 50.dp,
|
||||||
|
minSecondSize: Dp = 50.dp,
|
||||||
|
dividerColor: Color = Color(0x33FFFFFFL),
|
||||||
|
dividerHoverColor: Color = Color(0x66FFFFFFL),
|
||||||
|
first: @Composable () -> Unit,
|
||||||
|
second: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val vDensity = LocalDensity.current
|
||||||
|
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
|
||||||
|
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
|
||||||
|
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
|
||||||
|
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
|
||||||
|
|
||||||
|
var vTotalWidthPx by remember { mutableStateOf(0) }
|
||||||
|
var vFirstWidthPx by remember { mutableStateOf(vInitialFirstPx) }
|
||||||
|
val vDividerSource = remember { MutableInteractionSource() }
|
||||||
|
val vDividerHover by vDividerSource.collectIsHoveredAsState()
|
||||||
|
var vDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onSizeChanged { vTotalWidthPx = it.width },
|
||||||
|
) {
|
||||||
|
val vClampedFirstPx = if (vTotalWidthPx > 0)
|
||||||
|
vFirstWidthPx.coerceIn(vMinFirstPx, (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
|
||||||
|
else vFirstWidthPx
|
||||||
|
val vSecondWidthPx = (vTotalWidthPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
|
||||||
|
|
||||||
|
// Solid divider that fills its own slot — what you see is exactly what
|
||||||
|
// you can grab. Hover or an in-progress drag just changes the colour (no
|
||||||
|
// size change → no layout shift, and no flicker when the pointer briefly
|
||||||
|
// leaves the slot mid-drag).
|
||||||
|
val vActive = vDividerHover || vDragging
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(SplitPaneDefaults.DividerThickness)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(if (vActive) dividerHoverColor else dividerColor)
|
||||||
|
.hoverable(vDividerSource)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { vDragging = true },
|
||||||
|
onDragEnd = { vDragging = false },
|
||||||
|
onDragCancel = { vDragging = false },
|
||||||
|
onDrag = { _, delta ->
|
||||||
|
val vMax = (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
|
||||||
|
vFirstWidthPx = (vFirstWidthPx + delta.x.toInt()).coerceIn(vMinFirstPx, vMax)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.width(with(vDensity) { vSecondWidthPx.toDp() }).fillMaxHeight()) { second() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object SplitPaneDefaults {
|
||||||
|
val DividerThickness: Dp = 4.dp // divider width = hit/drag area (solid; what you see is what you grab)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.platform.Font
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Monospace body font — JVM actuals (classpath font/ staged by
|
||||||
|
// jvmProcessResources)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
actual val monoFontFamily: FontFamily? by lazy {
|
||||||
|
val vBytes = object {}.javaClass.getResourceAsStream("/font/NotoSansMono.ttf")?.use { it.readBytes() }
|
||||||
|
if (vBytes == null) {
|
||||||
|
println("apidemo: NotoSansMono.ttf not on the classpath — body uses the default font")
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
FontFamily(Font(identity = kMonoFamily, data = vBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual val monoFontFamilyName: String? by lazy {
|
||||||
|
if (monoFontFamily != null) kMonoFamily else null
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.res.useResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||||
|
|
||||||
|
// The JVM comparison app's entry point. Runs the SAME shared App() as the
|
||||||
|
// native :apidemo, but on Compose Desktop (JVM) against upstream
|
||||||
|
// org.jetbrains.compose. Client-certificate (mTLS) features are native-only
|
||||||
|
// (they drive the bundled libcurl); the jvm actuals report that instead.
|
||||||
|
fun main() = application {
|
||||||
|
// Voltic window icon (mirrors the native window icon). Staged onto the
|
||||||
|
// classpath at icon/ by jvmProcessResources.
|
||||||
|
val vIcon = remember {
|
||||||
|
BitmapPainter(useResource("icon/voltic-icon-256.png") { it.readAllBytes().decodeToImageBitmap() })
|
||||||
|
}
|
||||||
|
Window(
|
||||||
|
// The shared App installs a persist-then-close hook (InstallWindowHooks).
|
||||||
|
onCloseRequest = { if (jvmOnCloseRequest?.invoke() != false) exitApplication() },
|
||||||
|
onPreviewKeyEvent = { jvmOnKeyShortcut?.invoke(it) ?: false },
|
||||||
|
title = "API Manager — JVM (upstream Compose)",
|
||||||
|
icon = vIcon,
|
||||||
|
state = rememberWindowState(width = 1240.dp, height = 820.dp),
|
||||||
|
) {
|
||||||
|
App()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: TLS chain / client-certificate — JVM actuals (native-only feature)
|
||||||
|
// ==================
|
||||||
|
// The native builds drive the bundled libcurl (CURLOPT_SSLCERT / CERTINFO);
|
||||||
|
// there is no libcurl on the jvm parity stack, so both entry points report
|
||||||
|
// the limitation instead of half-implementing it over JSSE.
|
||||||
|
|
||||||
|
private const val kNativeOnly =
|
||||||
|
"Client-certificate (mTLS) features need the native build — they drive the bundled libcurl directly."
|
||||||
|
|
||||||
|
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain = TlsChain(emptyList(), kNativeOnly)
|
||||||
|
|
||||||
|
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse = ApiResponse(
|
||||||
|
status = 0,
|
||||||
|
statusText = "",
|
||||||
|
timeMs = 0,
|
||||||
|
sizeBytes = 0,
|
||||||
|
headers = emptyList(),
|
||||||
|
body = "",
|
||||||
|
error = kNativeOnly,
|
||||||
|
)
|
||||||
|
|
||||||
|
actual fun sweepTempClientCerts() {}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
|
|
||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.createFontFamilyResolver
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.jetbrains.compose.resources.decodeToSvgPainter
|
||||||
|
import org.jetbrains.skia.Image
|
||||||
|
import java.awt.Desktop
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.awt.FileDialog
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.awt.datatransfer.StringSelection
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: JVM actuals — AWT + upstream Compose Desktop
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
private val kIsWindows = System.getProperty("os.name").startsWith("Windows")
|
||||||
|
private val kIsMac = System.getProperty("os.name").startsWith("Mac")
|
||||||
|
|
||||||
|
internal actual val systemFileSystem: okio.FileSystem = okio.FileSystem.SYSTEM
|
||||||
|
|
||||||
|
actual fun createApiHttpClient(): io.ktor.client.HttpClient =
|
||||||
|
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Window hooks — the shared App installs them; MainJvm wires them into the
|
||||||
|
// upstream Window() (onCloseRequest / onPreviewKeyEvent).
|
||||||
|
|
||||||
|
internal var jvmOnCloseRequest: (() -> Boolean)? = null
|
||||||
|
internal var jvmOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
|
||||||
|
androidx.compose.runtime.DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
|
||||||
|
jvmOnCloseRequest = inOnCloseRequest
|
||||||
|
jvmOnKeyShortcut = inOnKeyShortcut
|
||||||
|
onDispose { jvmOnCloseRequest = null; jvmOnKeyShortcut = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors SDL_GetPrefPath's layout: <os user-data dir>/<org>/<app>/ with a
|
||||||
|
trailing separator, created on demand. */
|
||||||
|
actual fun appDataDir(inOrg: String, inApp: String): String? {
|
||||||
|
val vHome = System.getProperty("user.home") ?: return null
|
||||||
|
val vBase = when {
|
||||||
|
kIsWindows -> System.getenv("APPDATA") ?: "$vHome/AppData/Roaming"
|
||||||
|
kIsMac -> "$vHome/Library/Application Support"
|
||||||
|
else -> System.getenv("XDG_DATA_HOME") ?: "$vHome/.local/share"
|
||||||
|
}
|
||||||
|
val vDir = File(File(vBase, inOrg), inApp)
|
||||||
|
if (!vDir.exists() && !vDir.mkdirs()) return null
|
||||||
|
return vDir.absolutePath + File.separator
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the path's containing folder (AWT has no cross-platform "select in
|
||||||
|
file manager"); fire-and-forget off the EDT. */
|
||||||
|
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) {
|
||||||
|
Thread {
|
||||||
|
val vOk = runCatching {
|
||||||
|
val vFile = File(inPath)
|
||||||
|
Desktop.getDesktop().open(if (vFile.isDirectory) vFile else vFile.parentFile ?: vFile)
|
||||||
|
}.isSuccess
|
||||||
|
inOnResult?.invoke(vOk)
|
||||||
|
}.apply { isDaemon = true }.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun fileManagerName(): String = when {
|
||||||
|
kIsWindows -> "Explorer"
|
||||||
|
kIsMac -> "Finder"
|
||||||
|
else -> "Files"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AWT FileDialog runs its own nested event loop, so invoking from the EDT
|
||||||
|
(where Compose Desktop also runs) keeps the UI serviced while modal. */
|
||||||
|
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
val vDlg = FileDialog(null as Frame?, "Save", FileDialog.SAVE)
|
||||||
|
inDefaultName?.let { vDlg.file = it }
|
||||||
|
vDlg.isVisible = true
|
||||||
|
inOnResult(vDlg.file?.let { vDlg.directory + it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
val vDlg = FileDialog(null as Frame?, "Open", FileDialog.LOAD)
|
||||||
|
vDlg.isVisible = true
|
||||||
|
inOnResult(vDlg.file?.let { vDlg.directory + it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upstream desktop's ClipEntry wraps an AWT Transferable. */
|
||||||
|
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry(StringSelection(inText))
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// In-memory response images
|
||||||
|
|
||||||
|
private val kMemoryImages = HashMap<String, ByteArray>()
|
||||||
|
|
||||||
|
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) {
|
||||||
|
kMemoryImages[inKey] = inBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun removeMemoryImage(inKey: String) {
|
||||||
|
kMemoryImages.remove(inKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter {
|
||||||
|
val vDensity = LocalDensity.current
|
||||||
|
return remember(inKey, inSvg) {
|
||||||
|
val vBytes = kMemoryImages[inKey]
|
||||||
|
runCatching {
|
||||||
|
if (vBytes == null) error("no bytes for $inKey")
|
||||||
|
if (inSvg) vBytes.inputStream().readAllBytes().decodeToSvgPainter(vDensity)
|
||||||
|
else BitmapPainter(Image.makeFromEncoded(vBytes).toComposeImageBitmap())
|
||||||
|
}.getOrElse { BitmapPainter(ImageBitmap(1, 1)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Text layout
|
||||||
|
|
||||||
|
/** Upstream TextMeasurer at density 1 so the px-valued font size maps 1:1
|
||||||
|
(the caller passes an already density-scaled size). */
|
||||||
|
private val kRowMeasurer: TextMeasurer by lazy {
|
||||||
|
TextMeasurer(createFontFamilyResolver(), Density(1f), LayoutDirection.Ltr)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int =
|
||||||
|
runCatching {
|
||||||
|
kRowMeasurer.measure(
|
||||||
|
AnnotatedString(inText),
|
||||||
|
TextStyle(fontSize = inFontPx.sp, fontFamily = monoFontFamily ?: FontFamily.Monospace),
|
||||||
|
constraints = Constraints(maxWidth = inMaxWidthPx),
|
||||||
|
).lineCount
|
||||||
|
}.getOrDefault(1)
|
||||||
|
|
||||||
|
/** The jvm parity app only stores the preference — upstream's text pipeline has
|
||||||
|
no global tab-width knob. Backed by state so menu checkmarks recompose. */
|
||||||
|
private var fTabWidth by mutableStateOf(4)
|
||||||
|
actual var editorTabWidth: Int
|
||||||
|
get() = fTabWidth
|
||||||
|
set(value) {
|
||||||
|
fTabWidth = value
|
||||||
|
}
|
||||||
@@ -3,20 +3,20 @@ package apidemo
|
|||||||
// Linux: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
// Linux: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
||||||
// certificate and key files directly — no certificate-store dance needed.
|
// certificate and key files directly — no certificate-store dance needed.
|
||||||
|
|
||||||
/* Point libcurl straight at the certificate / key files. */
|
/** Point libcurl straight at the certificate / key files. */
|
||||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
||||||
PreparedCert(
|
PreparedCert(
|
||||||
sslCert = inReq.certPath,
|
sslCert = inReq.certPath,
|
||||||
sslCertType = inReq.certFormat.curlName,
|
sslCertType = inReq.certFormat.curlName,
|
||||||
sslKey = inReq.keyPath.ifBlank { null },
|
sslKey = inReq.keyPath.ifBlank { null },
|
||||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||||
cleanup = {},
|
cleanup = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
/* No temporary certificate store off Windows. */
|
/** No temporary certificate store off Windows. */
|
||||||
actual fun sweepTempClientCerts() {}
|
actual fun sweepTempClientCerts() {}
|
||||||
|
|
||||||
/* No OS-store issuer resolution here — just continue with the name-only issuer. */
|
/** No OS-store issuer resolution here — just continue with the name-only issuer. */
|
||||||
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> =
|
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> =
|
||||||
serverChainWithIssuerName(inServerCerts)
|
serverChainWithIssuerName(inServerCerts)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package apidemo
|
|||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
import platform.windows.*
|
import platform.windows.*
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Windows client-certificate store import (Schannel mTLS)
|
// MARK: Windows client-certificate store import (Schannel mTLS)
|
||||||
@@ -32,464 +31,529 @@ private const val kCryptUserKeyset = 0x00001000u // CRYPT_USER_KEYSET (no
|
|||||||
|
|
||||||
private val kEncoding: UInt = X509_ASN_ENCODING.toUInt() or PKCS_7_ASN_ENCODING.toUInt()
|
private val kEncoding: UInt = X509_ASN_ENCODING.toUInt() or PKCS_7_ASN_ENCODING.toUInt()
|
||||||
|
|
||||||
/* Import the request's certificate into CurrentUser\MY and return a libcurl
|
/** Import the request's certificate into CurrentUser\MY and return a libcurl
|
||||||
store reference plus a cleanup that removes it again. */
|
store reference plus a cleanup that removes it again. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
|
||||||
{
|
val vCertBytes = readFileBytes(inReq.certPath)
|
||||||
val vCertBytes = readFileBytes(inReq.certPath)
|
val (vThumb, vContainer) = when (inReq.certFormat) {
|
||||||
val (vThumb, vContainer) = when (inReq.certFormat)
|
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
|
||||||
{
|
else -> importPemDerRsa(vCertBytes, inReq)
|
||||||
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
|
}
|
||||||
else -> importPemDerRsa(vCertBytes, inReq)
|
return PreparedCert(
|
||||||
}
|
sslCert = "$kStorePath\\$vThumb",
|
||||||
return PreparedCert(
|
sslCertType = null, // store reference — not a file type
|
||||||
sslCert = "$kStorePath\\$vThumb",
|
sslKey = null,
|
||||||
sslCertType = null, // store reference — not a file type
|
sslKeyType = null,
|
||||||
sslKey = null,
|
keyPassword = null,
|
||||||
sslKeyType = null,
|
cleanup = { deleteTempCert(vThumb, vContainer) },
|
||||||
keyPassword = null,
|
)
|
||||||
cleanup = { deleteTempCert(vThumb, vContainer) },
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// PEM / DER (RSA) → store
|
// PEM / DER (RSA) → store
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Build a cert context from PEM/DER, import its RSA private key into a named
|
/** Build a cert context from PEM/DER, import its RSA private key into a named
|
||||||
container, link the two, tag and add to CurrentUser\MY. Returns
|
container, link the two, tag and add to CurrentUser\MY. Returns
|
||||||
(thumbprintHex, containerName). */
|
(thumbprintHex, containerName). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?>
|
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?> {
|
||||||
{
|
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
|
||||||
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
|
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
|
||||||
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
|
val vKeyDer = privateKeyDer(vKeyBytes)
|
||||||
val vKeyDer = privateKeyDer(vKeyBytes)
|
?: throw RuntimeException("No private key found — PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
|
||||||
?: throw RuntimeException("No private key found — PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
|
val vBlob = try {
|
||||||
val vBlob = try { rsaKeyDerToBlob(vKeyDer) }
|
rsaKeyDerToBlob(vKeyDer)
|
||||||
catch (e: Throwable) { throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12") }
|
} catch (e: Throwable) {
|
||||||
|
throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12")
|
||||||
|
}
|
||||||
|
|
||||||
val vCert = vCertDer.usePinned { vPin ->
|
val vCert = vCertDer.usePinned { vPin ->
|
||||||
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
|
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
|
||||||
} ?: winError("CertCreateCertificateContext")
|
} ?: winError("CertCreateCertificateContext")
|
||||||
|
|
||||||
try
|
try {
|
||||||
{
|
val vThumb = thumbprintHex(vCert)
|
||||||
val vThumb = thumbprintHex(vCert)
|
val vContainer = kTagPrefix + vThumb
|
||||||
val vContainer = kTagPrefix + vThumb
|
importRsaIntoContainer(vContainer, vBlob)
|
||||||
importRsaIntoContainer(vContainer, vBlob)
|
try {
|
||||||
try
|
linkKeyToCert(vCert, vContainer)
|
||||||
{
|
tagFriendlyName(vCert, kTagPrefix + vThumb)
|
||||||
linkKeyToCert(vCert, vContainer)
|
addToStore(vCert)
|
||||||
tagFriendlyName(vCert, kTagPrefix + vThumb)
|
return vThumb to vContainer
|
||||||
addToStore(vCert)
|
} catch (e: Throwable) {
|
||||||
return vThumb to vContainer
|
deleteContainer(vContainer); throw e
|
||||||
}
|
}
|
||||||
catch (e: Throwable) { deleteContainer(vContainer); throw e }
|
} finally {
|
||||||
}
|
CertFreeCertificateContext(vCert)
|
||||||
finally { CertFreeCertificateContext(vCert) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Create a fresh named key container and import the RSA blob into it. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
|
|
||||||
deleteContainer(inContainer) // drop a stale one if it somehow exists
|
|
||||||
val vProv = alloc<ULongVar>()
|
|
||||||
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
|
|
||||||
winError("CryptAcquireContext(NEWKEYSET)")
|
|
||||||
try
|
|
||||||
{
|
|
||||||
val vKey = alloc<ULongVar>()
|
|
||||||
val vOk = inBlob.usePinned { vPin ->
|
|
||||||
CryptImportKey(vProv.value, vPin.addressOf(0).reinterpret(), inBlob.size.convert(), 0uL, CRYPT_EXPORTABLE.toUInt(), vKey.ptr)
|
|
||||||
}
|
|
||||||
if (vOk == 0) winError("CryptImportKey")
|
|
||||||
CryptDestroyKey(vKey.value)
|
|
||||||
}
|
|
||||||
finally { CryptReleaseContext(vProv.value, 0u) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attach the named container to the cert so Schannel can find its key. */
|
/** Create a fresh named key container and import the RSA blob into it. */
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
|
||||||
|
deleteContainer(inContainer) // drop a stale one if it somehow exists
|
||||||
|
val vProv = alloc<ULongVar>()
|
||||||
|
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
|
||||||
|
winError("CryptAcquireContext(NEWKEYSET)")
|
||||||
|
try {
|
||||||
|
val vKey = alloc<ULongVar>()
|
||||||
|
val vOk = inBlob.usePinned { vPin ->
|
||||||
|
CryptImportKey(
|
||||||
|
vProv.value,
|
||||||
|
vPin.addressOf(0).reinterpret(),
|
||||||
|
inBlob.size.convert(),
|
||||||
|
0uL,
|
||||||
|
CRYPT_EXPORTABLE.toUInt(),
|
||||||
|
vKey.ptr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (vOk == 0) winError("CryptImportKey")
|
||||||
|
CryptDestroyKey(vKey.value)
|
||||||
|
} finally {
|
||||||
|
CryptReleaseContext(vProv.value, 0u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach the named container to the cert so Schannel can find its key. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
|
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
|
||||||
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
|
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
|
||||||
vInfo.pwszContainerName = inContainer.wcstr.ptr
|
vInfo.pwszContainerName = inContainer.wcstr.ptr
|
||||||
vInfo.pwszProvName = kProvName.wcstr.ptr
|
vInfo.pwszProvName = kProvName.wcstr.ptr
|
||||||
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
|
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
|
||||||
vInfo.dwFlags = 0u
|
vInfo.dwFlags = 0u
|
||||||
vInfo.cProvParam = 0u
|
vInfo.cProvParam = 0u
|
||||||
vInfo.rgProvParam = null
|
vInfo.rgProvParam = null
|
||||||
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
|
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
|
||||||
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
|
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
|
||||||
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
|
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
|
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Minimal DER reader for the RSA key structures. */
|
/** Minimal DER reader for the RSA key structures. */
|
||||||
private class Der(val b: ByteArray)
|
private class Der(val b: ByteArray) {
|
||||||
{
|
var p = 0
|
||||||
var p = 0
|
fun tag(): Int = b[p++].toInt() and 0xFF
|
||||||
fun tag(): Int = b[p++].toInt() and 0xFF
|
fun len(): Int {
|
||||||
fun len(): Int
|
val vFirst = b[p++].toInt() and 0xFF
|
||||||
{
|
if (vFirst and 0x80 == 0) return vFirst
|
||||||
val vFirst = b[p++].toInt() and 0xFF
|
var vLen = 0
|
||||||
if (vFirst and 0x80 == 0) return vFirst
|
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
|
||||||
var vLen = 0
|
return vLen
|
||||||
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
|
}
|
||||||
return vLen
|
|
||||||
}
|
|
||||||
fun sequence() { require(tag() == 0x30) { "SEQUENCE expected" }; len() }
|
|
||||||
fun peek(): Int = b[p].toInt() and 0xFF
|
|
||||||
fun skip() { tag(); val vL = len(); p += vL }
|
|
||||||
fun octetString(): ByteArray { require(tag() == 0x04) { "OCTET STRING expected" }; val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL } }
|
|
||||||
// An INTEGER's magnitude (big-endian, leading zero stripped).
|
|
||||||
fun integer(): ByteArray
|
|
||||||
{
|
|
||||||
require(tag() == 0x02) { "INTEGER expected" }
|
|
||||||
val vL = len(); val vV = b.copyOfRange(p, p + vL); p += vL
|
|
||||||
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
|
|
||||||
return vV.copyOfRange(vS, vV.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
|
fun sequence() {
|
||||||
Throws if it isn't an RSA key we can lay out. */
|
require(tag() == 0x30) { "SEQUENCE expected" }; len()
|
||||||
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray
|
}
|
||||||
{
|
|
||||||
val vR = Der(inDer)
|
|
||||||
vR.sequence(); vR.integer() // outer version
|
|
||||||
return if (vR.peek() == 0x30)
|
|
||||||
{
|
|
||||||
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
|
|
||||||
vR.skip()
|
|
||||||
parsePkcs1(vR.octetString())
|
|
||||||
}
|
|
||||||
else
|
|
||||||
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
|
|
||||||
buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parsePkcs1(inDer: ByteArray): ByteArray
|
fun peek(): Int = b[p].toInt() and 0xFF
|
||||||
{
|
fun skip() {
|
||||||
val vR = Der(inDer)
|
tag()
|
||||||
vR.sequence(); vR.integer() // version
|
val vL = len(); p += vL
|
||||||
return buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
|
fun octetString(): ByteArray {
|
||||||
|
require(tag() == 0x04) { "OCTET STRING expected" }
|
||||||
|
val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL }
|
||||||
|
}
|
||||||
|
|
||||||
|
// An INTEGER's magnitude (big-endian, leading zero stripped).
|
||||||
|
fun integer(): ByteArray {
|
||||||
|
require(tag() == 0x02) { "INTEGER expected" }
|
||||||
|
val vL = len()
|
||||||
|
val vV = b.copyOfRange(p, p + vL); p += vL
|
||||||
|
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
|
||||||
|
return vV.copyOfRange(vS, vV.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
|
||||||
|
Throws if it isn't an RSA key we can lay out. */
|
||||||
|
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray {
|
||||||
|
val vR = Der(inDer)
|
||||||
|
vR.sequence(); vR.integer() // outer version
|
||||||
|
return if (vR.peek() == 0x30) {
|
||||||
|
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
|
||||||
|
vR.skip()
|
||||||
|
parsePkcs1(vR.octetString())
|
||||||
|
} else
|
||||||
|
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
|
||||||
|
buildBlob(
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePkcs1(inDer: ByteArray): ByteArray {
|
||||||
|
val vR = Der(inDer)
|
||||||
|
vR.sequence(); vR.integer() // version
|
||||||
|
return buildBlob(
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
|
||||||
private fun buildBlob(
|
private fun buildBlob(
|
||||||
inN: ByteArray, inE: ByteArray, inD: ByteArray,
|
inN: ByteArray, inE: ByteArray, inD: ByteArray,
|
||||||
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
|
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
|
||||||
): ByteArray
|
): ByteArray {
|
||||||
{
|
val vMod = inN.size // modulus length in bytes
|
||||||
val vMod = inN.size // modulus length in bytes
|
val vHalf = vMod / 2
|
||||||
val vHalf = vMod / 2
|
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
|
||||||
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
|
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
|
||||||
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
|
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
|
||||||
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
|
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
|
||||||
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
|
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
|
||||||
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
|
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
|
||||||
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
|
vOut.addAll(leField(inN, vMod).toList())
|
||||||
vOut.addAll(leField(inN, vMod).toList())
|
vOut.addAll(leField(inP, vHalf).toList())
|
||||||
vOut.addAll(leField(inP, vHalf).toList())
|
vOut.addAll(leField(inQ, vHalf).toList())
|
||||||
vOut.addAll(leField(inQ, vHalf).toList())
|
vOut.addAll(leField(inDp, vHalf).toList())
|
||||||
vOut.addAll(leField(inDp, vHalf).toList())
|
vOut.addAll(leField(inDq, vHalf).toList())
|
||||||
vOut.addAll(leField(inDq, vHalf).toList())
|
vOut.addAll(leField(inIq, vHalf).toList())
|
||||||
vOut.addAll(leField(inIq, vHalf).toList())
|
vOut.addAll(leField(inD, vMod).toList())
|
||||||
vOut.addAll(leField(inD, vMod).toList())
|
return vOut.toByteArray()
|
||||||
return vOut.toByteArray()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
|
/** A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
|
||||||
private fun leField(inBe: ByteArray, inSize: Int): ByteArray
|
private fun leField(inBe: ByteArray, inSize: Int): ByteArray {
|
||||||
{
|
val vOut = ByteArray(inSize)
|
||||||
val vOut = ByteArray(inSize)
|
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
|
||||||
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
|
return vOut
|
||||||
return vOut
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun le32(inV: UInt): ByteArray =
|
private fun le32(inV: UInt): ByteArray =
|
||||||
byteArrayOf((inV and 0xFFu).toByte(), ((inV shr 8) and 0xFFu).toByte(), ((inV shr 16) and 0xFFu).toByte(), ((inV shr 24) and 0xFFu).toByte())
|
byteArrayOf(
|
||||||
|
(inV and 0xFFu).toByte(),
|
||||||
|
((inV shr 8) and 0xFFu).toByte(),
|
||||||
|
((inV shr 16) and 0xFFu).toByte(),
|
||||||
|
((inV shr 24) and 0xFFu).toByte()
|
||||||
|
)
|
||||||
|
|
||||||
private fun beToUInt(inBe: ByteArray): UInt
|
private fun beToUInt(inBe: ByteArray): UInt {
|
||||||
{
|
var vV = 0u
|
||||||
var vV = 0u
|
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
|
||||||
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
|
return vV
|
||||||
return vV
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// PKCS#12 → store
|
// PKCS#12 → store
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Import a .p12/.pfx into a memory store, then copy its cert (with the key
|
/** Import a .p12/.pfx into a memory store, then copy its cert (with the key
|
||||||
container PFXImportCertStore created) into CurrentUser\MY. */
|
container PFXImportCertStore created) into CurrentUser\MY. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
|
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
|
||||||
inBytes.usePinned { vPin ->
|
inBytes.usePinned { vPin ->
|
||||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||||
vBlob.cbData = inBytes.size.convert()
|
vBlob.cbData = inBytes.size.convert()
|
||||||
vBlob.pbData = vPin.addressOf(0).reinterpret()
|
vBlob.pbData = vPin.addressOf(0).reinterpret()
|
||||||
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
|
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
|
||||||
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
|
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
|
||||||
try
|
try {
|
||||||
{
|
val vSrc =
|
||||||
val vSrc = CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
|
CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
|
||||||
val vThumb = thumbprintHex(vSrc)
|
val vThumb = thumbprintHex(vSrc)
|
||||||
val vContainer = containerNameOf(vSrc)
|
val vContainer = containerNameOf(vSrc)
|
||||||
tagFriendlyName(vSrc, kTagPrefix + vThumb)
|
tagFriendlyName(vSrc, kTagPrefix + vThumb)
|
||||||
addToStore(vSrc)
|
addToStore(vSrc)
|
||||||
vThumb to vContainer
|
vThumb to vContainer
|
||||||
}
|
} finally {
|
||||||
finally { CertCloseStore(vMem, 0u) }
|
CertCloseStore(vMem, 0u)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The key-container name PFXImportCertStore assigned to a cert (from its
|
/** The key-container name PFXImportCertStore assigned to a cert (from its
|
||||||
CERT_KEY_PROV_INFO), so cleanup can delete it. */
|
CERT_KEY_PROV_INFO), so cleanup can delete it. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||||
val vLen = alloc<UIntVar>()
|
val vLen = alloc<UIntVar>()
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
|
if (CertGetCertificateContextProperty(
|
||||||
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
inCert,
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
|
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
|
||||||
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
|
null,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
||||||
|
if (CertGetCertificateContextProperty(
|
||||||
|
inCert,
|
||||||
|
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
|
||||||
|
vBuf,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Shared store helpers
|
// Shared store helpers
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
|
/** Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
|
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
|
||||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||||
vBlob.cbData = ((inName.length + 1) * 2).convert()
|
vBlob.cbData = ((inName.length + 1) * 2).convert()
|
||||||
vBlob.pbData = inName.wcstr.ptr.reinterpret()
|
vBlob.pbData = inName.wcstr.ptr.reinterpret()
|
||||||
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
|
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
|
/** Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun addToStore(inCert: CPointer<CERT_CONTEXT>)
|
private fun addToStore(inCert: CPointer<CERT_CONTEXT>) {
|
||||||
{
|
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
|
||||||
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
|
try {
|
||||||
try
|
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
|
||||||
{
|
winError("CertAddCertificateContextToStore")
|
||||||
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
|
} finally {
|
||||||
winError("CertAddCertificateContextToStore")
|
CertCloseStore(vStore, 0u)
|
||||||
}
|
}
|
||||||
finally { CertCloseStore(vStore, 0u) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Open CurrentUser\MY (the current user's Personal store). */
|
/** Open CurrentUser\MY (the current user's Personal store). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun openMyStore() = CertOpenSystemStoreW(0.convert(), kStoreName)
|
private fun openMyStore() = CertOpenSystemStoreW(0.convert(), kStoreName)
|
||||||
|
|
||||||
/* The SHA-1 thumbprint of a cert as uppercase hex. */
|
/** The SHA-1 thumbprint of a cert as uppercase hex. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
|
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
|
||||||
val vLen = alloc<UIntVar>().apply { value = 20u }
|
val vLen = alloc<UIntVar>().apply { value = 20u }
|
||||||
val vBuf = allocArray<UByteVar>(20)
|
val vBuf = allocArray<UByteVar>(20)
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
|
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
|
||||||
winError("CertGetCertificateContextProperty(SHA1)")
|
winError("CertGetCertificateContextProperty(SHA1)")
|
||||||
buildString { for (vI in 0 until vLen.value.toInt()) append(vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')) }
|
buildString {
|
||||||
|
for (vI in 0 until vLen.value.toInt()) append(
|
||||||
|
vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
|
/** Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
|
||||||
key container. Called per-request in finally; ignores what's already gone. */
|
key container. Called per-request in finally; ignores what's already gone. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun deleteTempCert(inThumbHex: String, inContainer: String?)
|
private fun deleteTempCert(inThumbHex: String, inContainer: String?) {
|
||||||
{
|
val vStore = openMyStore()
|
||||||
val vStore = openMyStore()
|
if (vStore != null) memScoped {
|
||||||
if (vStore != null) memScoped {
|
val vHash = hexToBytes(inThumbHex)
|
||||||
val vHash = hexToBytes(inThumbHex)
|
val vBuf = allocArray<UByteVar>(vHash.size)
|
||||||
val vBuf = allocArray<UByteVar>(vHash.size)
|
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
|
||||||
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
|
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
vBlob.cbData = vHash.size.convert()
|
||||||
vBlob.cbData = vHash.size.convert()
|
vBlob.pbData = vBuf
|
||||||
vBlob.pbData = vBuf
|
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
|
||||||
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
|
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
|
||||||
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
|
CertCloseStore(vStore, 0u)
|
||||||
CertCloseStore(vStore, 0u)
|
}
|
||||||
}
|
if (inContainer != null) deleteContainer(inContainer)
|
||||||
if (inContainer != null) deleteContainer(inContainer)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Delete a named key container (best-effort). */
|
/** Delete a named key container (best-effort). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun deleteContainer(inContainer: String) = memScoped {
|
private fun deleteContainer(inContainer: String) = memScoped {
|
||||||
val vProv = alloc<ULongVar>()
|
val vProv = alloc<ULongVar>()
|
||||||
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
|
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove every temp cert we ever added (prefix-tagged) plus its container — for
|
/** Remove every temp cert we ever added (prefix-tagged) plus its container — for
|
||||||
clearing crash leftovers at startup. The user's own certs are never matched. */
|
clearing crash leftovers at startup. The user's own certs are never matched. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual fun sweepTempClientCerts()
|
actual fun sweepTempClientCerts() {
|
||||||
{
|
val vStore = openMyStore() ?: return
|
||||||
val vStore = openMyStore() ?: return
|
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
|
||||||
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
|
var vCert = CertEnumCertificatesInStore(vStore, null)
|
||||||
var vCert = CertEnumCertificatesInStore(vStore, null)
|
while (vCert != null) {
|
||||||
while (vCert != null)
|
val vFriendly = friendlyNameOf(vCert)
|
||||||
{
|
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
|
||||||
val vFriendly = friendlyNameOf(vCert)
|
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
|
||||||
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
|
vCert = CertEnumCertificatesInStore(vStore, vCert)
|
||||||
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
|
}
|
||||||
vCert = CertEnumCertificatesInStore(vStore, vCert)
|
CertCloseStore(vStore, 0u)
|
||||||
}
|
vDoomed.forEach { deleteTempCert(it.first, it.second) }
|
||||||
CertCloseStore(vStore, 0u)
|
}
|
||||||
vDoomed.forEach { deleteTempCert(it.first, it.second) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A cert's friendly name, or null. */
|
/** A cert's friendly name, or null. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||||
val vLen = alloc<UIntVar>()
|
val vLen = alloc<UIntVar>()
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
|
if (CertGetCertificateContextProperty(
|
||||||
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
inCert,
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
|
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
|
||||||
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
|
null,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
||||||
|
if (CertGetCertificateContextProperty(
|
||||||
|
inCert,
|
||||||
|
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
|
||||||
|
vBuf,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Chain extension via the OS certificate store
|
// Chain extension via the OS certificate store
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Continue the server's chain by asking the OS to build it from the leaf — this
|
/** Continue the server's chain by asking the OS to build it from the leaf — this
|
||||||
pulls intermediates/roots from the Windows store with full info. Falls back to
|
pulls intermediates/roots from the Windows store with full info. Falls back to
|
||||||
a name-only issuer if the OS can't (or the leaf can't be parsed). */
|
a name-only issuer if the OS can't (or the leaf can't be parsed). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
|
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
|
||||||
{
|
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
||||||
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
?: return serverChainWithIssuerName(inServerCerts)
|
||||||
?: return serverChainWithIssuerName(inServerCerts)
|
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
|
||||||
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
|
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
||||||
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Build the full chain from the leaf via CertGetCertificateChain (OS store). */
|
/** Build the full chain from the leaf via CertGetCertificateChain (OS store). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
|
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
|
||||||
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
|
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
|
||||||
val vLeaf = vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
|
val vLeaf =
|
||||||
?: return@memScoped emptyList()
|
vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
|
||||||
try
|
?: return@memScoped emptyList()
|
||||||
{
|
try {
|
||||||
val vPara = alloc<CERT_CHAIN_PARA>()
|
val vPara = alloc<CERT_CHAIN_PARA>()
|
||||||
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
|
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
|
||||||
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
|
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
|
||||||
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
|
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
|
||||||
return@memScoped emptyList()
|
return@memScoped emptyList()
|
||||||
val vChain = vChainPtr.value ?: return@memScoped emptyList()
|
val vChain = vChainPtr.value ?: return@memScoped emptyList()
|
||||||
try
|
try {
|
||||||
{
|
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
|
||||||
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
|
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
|
||||||
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
|
val vResult = ArrayList<ChainCert>()
|
||||||
val vResult = ArrayList<ChainCert>()
|
for (vI in 0 until vSimple.cElement.toInt()) {
|
||||||
for (vI in 0 until vSimple.cElement.toInt())
|
val vCtx = vElems[vI]?.pointed?.pCertContext ?: continue
|
||||||
{
|
vResult.add(ChainCert(certFieldsFromContext(vCtx), vI < inServerCount))
|
||||||
val vCtx = vElems[vI]?.pointed?.pCertContext ?: continue
|
}
|
||||||
vResult.add(ChainCert(certFieldsFromContext(vCtx), vI < inServerCount))
|
// If the chain didn't reach a self-signed root, end with the name only.
|
||||||
}
|
val vLast = vResult.lastOrNull()
|
||||||
// If the chain didn't reach a self-signed root, end with the name only.
|
if (vLast != null) {
|
||||||
val vLast = vResult.lastOrNull()
|
val vSub = certFieldOf(vLast.fields, "Subject")
|
||||||
if (vLast != null)
|
val vIss = certFieldOf(vLast.fields, "Issuer")
|
||||||
{
|
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
|
||||||
val vSub = certFieldOf(vLast.fields, "Subject")
|
}
|
||||||
val vIss = certFieldOf(vLast.fields, "Issuer")
|
vResult
|
||||||
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
|
} finally {
|
||||||
}
|
CertFreeCertificateChain(vChain)
|
||||||
vResult
|
}
|
||||||
}
|
} finally {
|
||||||
finally { CertFreeCertificateChain(vChain) }
|
CertFreeCertificateContext(vLeaf)
|
||||||
}
|
}
|
||||||
finally { CertFreeCertificateContext(vLeaf) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
|
/** Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>>
|
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>> {
|
||||||
{
|
val vFields = ArrayList<Pair<String, String>>()
|
||||||
val vFields = ArrayList<Pair<String, String>>()
|
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
|
||||||
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
|
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
|
||||||
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
|
certPem(inCtx)?.let { vFields.add("Cert" to it) }
|
||||||
certPem(inCtx)?.let { vFields.add("Cert" to it) }
|
return vFields
|
||||||
return vFields
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
|
/** A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
|
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
|
||||||
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
|
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
|
||||||
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
|
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
|
||||||
if (vLen <= 1u) return@memScoped null
|
if (vLen <= 1u) return@memScoped null
|
||||||
val vBuf = allocArray<ByteVar>(vLen.toInt())
|
val vBuf = allocArray<ByteVar>(vLen.toInt())
|
||||||
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
|
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
|
||||||
vBuf.toKString()
|
vBuf.toKString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The PEM (base64-with-header) of a cert context. */
|
/** The PEM (base64-with-header) of a cert context. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
|
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||||
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
|
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
|
||||||
val vSize = inCtx.pointed.cbCertEncoded
|
val vSize = inCtx.pointed.cbCertEncoded
|
||||||
val vLen = alloc<UIntVar>()
|
val vLen = alloc<UIntVar>()
|
||||||
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), null, vLen.ptr) == 0) return@memScoped null
|
if (CryptBinaryToStringA(
|
||||||
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
vData,
|
||||||
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
|
vSize,
|
||||||
vBuf.toKString()
|
CRYPT_STRING_BASE64HEADER.toUInt(),
|
||||||
|
null,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
||||||
|
if (CryptBinaryToStringA(
|
||||||
|
vData,
|
||||||
|
vSize,
|
||||||
|
CRYPT_STRING_BASE64HEADER.toUInt(),
|
||||||
|
vBuf,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
vBuf.toKString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Small helpers
|
// Small helpers
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
|
/** Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray {
|
||||||
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray
|
val vText = inBytes.decodeToString()
|
||||||
{
|
if (!vText.contains("-----BEGIN")) return inBytes
|
||||||
val vText = inBytes.decodeToString()
|
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
|
||||||
if (!vText.contains("-----BEGIN")) return inBytes
|
val vEnd = vText.indexOf("-----END $inLabel-----")
|
||||||
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
|
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
|
||||||
val vEnd = vText.indexOf("-----END $inLabel-----")
|
return Base64.decode(pemBody(vBlock))
|
||||||
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
|
}
|
||||||
return Base64.Default.decode(pemBody(vBlock))
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
|
/** Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
|
||||||
bytes through. Null if no key. */
|
bytes through. Null if no key. */
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
private fun privateKeyDer(inBytes: ByteArray): ByteArray? {
|
||||||
private fun privateKeyDer(inBytes: ByteArray): ByteArray?
|
val vText = inBytes.decodeToString()
|
||||||
{
|
if (!vText.contains("-----BEGIN")) return inBytes // already DER
|
||||||
val vText = inBytes.decodeToString()
|
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
|
||||||
if (!vText.contains("-----BEGIN")) return inBytes // already DER
|
?: return null
|
||||||
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
|
return Base64.decode(pemBody(vMatch.value))
|
||||||
?: return null
|
}
|
||||||
return Base64.Default.decode(pemBody(vMatch.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
|
/** The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
|
||||||
private fun pemBody(inPem: String): String =
|
private fun pemBody(inPem: String): String =
|
||||||
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
|
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
|
||||||
|
|
||||||
private fun hexToBytes(inHex: String): ByteArray =
|
private fun hexToBytes(inHex: String): ByteArray =
|
||||||
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||||
|
|
||||||
/* Read a null-terminated UTF-16 (wide) string. */
|
/** Read a null-terminated UTF-16 (wide) string. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun CPointer<UShortVar>.toKStringFromUtf16(): String
|
private fun CPointer<UShortVar>.toKStringFromUtf16(): String {
|
||||||
{
|
val vSb = StringBuilder()
|
||||||
val vSb = StringBuilder()
|
var vI = 0
|
||||||
var vI = 0
|
while (true) {
|
||||||
while (true) { val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++ }
|
val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++
|
||||||
return vSb.toString()
|
}
|
||||||
}
|
return vSb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun winError(inWhat: String): Nothing =
|
private fun winError(inWhat: String): Nothing =
|
||||||
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
|
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Minimal X.509 / DER parser
|
|
||||||
// ==================
|
|
||||||
// Some TLS backends (notably Schannel on Windows) expose only Subject / Issuer /
|
|
||||||
// the PEM through CURLINFO_CERTINFO — not the parsed fields (validity, serial,
|
|
||||||
// SAN, algorithms). Since every backend gives us the PEM, we parse it ourselves
|
|
||||||
// to fill in the detail. Just enough ASN.1 to walk a Certificate; anything odd
|
|
||||||
// is swallowed and the field is simply omitted.
|
|
||||||
|
|
||||||
/* Parsed certificate detail, all nullable/empty when not found or unparseable. */
|
|
||||||
class CertDetails(
|
|
||||||
val version: Int?, // X.509 version (1/2/3)
|
|
||||||
val serial: String?, // colon-separated hex
|
|
||||||
val sigAlg: String?, // signature algorithm (friendly name or OID)
|
|
||||||
val keyAlg: String?, // public-key algorithm (friendly name or OID)
|
|
||||||
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
|
|
||||||
val notAfter: String?,
|
|
||||||
val sans: List<String>, // dNSName SANs
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
|
|
||||||
private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
|
|
||||||
{
|
|
||||||
fun hasMore() = pos < end
|
|
||||||
fun next(): IntArray
|
|
||||||
{
|
|
||||||
val vTag = b[pos++].toInt() and 0xFF
|
|
||||||
var vLen = b[pos++].toInt() and 0xFF
|
|
||||||
if (vLen and 0x80 != 0)
|
|
||||||
{
|
|
||||||
val vN = vLen and 0x7F
|
|
||||||
vLen = 0
|
|
||||||
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
|
|
||||||
}
|
|
||||||
val vStart = pos
|
|
||||||
pos += vLen
|
|
||||||
return intArrayOf(vTag, vStart, vStart + vLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Parse the PEM into the fields the chain dialog shows. Returns null on any
|
|
||||||
structural surprise (the UI just falls back to whatever the backend gave). */
|
|
||||||
fun parseCertDetails(inPem: String): CertDetails?
|
|
||||||
{
|
|
||||||
val vDer = pemToDer(inPem) ?: return null
|
|
||||||
return try
|
|
||||||
{
|
|
||||||
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
|
|
||||||
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
|
|
||||||
val vTbs = vCertInner.next() // tbsCertificate
|
|
||||||
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
|
|
||||||
|
|
||||||
val vT = DerReader(vDer, vTbs[1], vTbs[2])
|
|
||||||
var vE = vT.next()
|
|
||||||
var vVersion: Int? = null
|
|
||||||
if (vE[0] == 0xA0) // [0] EXPLICIT version
|
|
||||||
{
|
|
||||||
val vVi = DerReader(vDer, vE[1], vE[2]).next()
|
|
||||||
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
|
|
||||||
vE = vT.next()
|
|
||||||
}
|
|
||||||
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
|
|
||||||
vT.next() // signature
|
|
||||||
vT.next() // issuer
|
|
||||||
val vValidity = vT.next() // validity SEQUENCE
|
|
||||||
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
|
|
||||||
val vNotBefore = parseAsn1Time(vDer, vVr.next())
|
|
||||||
val vNotAfter = parseAsn1Time(vDer, vVr.next())
|
|
||||||
vT.next() // subject
|
|
||||||
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
|
|
||||||
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
|
|
||||||
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
|
|
||||||
val vSans = ArrayList<String>()
|
|
||||||
while (vT.hasMore())
|
|
||||||
{
|
|
||||||
val vX = vT.next()
|
|
||||||
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
|
|
||||||
}
|
|
||||||
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
|
|
||||||
}
|
|
||||||
catch (e: Throwable) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Helpers
|
|
||||||
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
|
||||||
private fun pemToDer(inPem: String): ByteArray?
|
|
||||||
{
|
|
||||||
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
|
|
||||||
if (vBody.isBlank()) return null
|
|
||||||
return try { Base64.decode(vBody) } catch (e: Throwable) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
|
|
||||||
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
|
|
||||||
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String?
|
|
||||||
{
|
|
||||||
val vR = DerReader(inB, inSeq[1], inSeq[2])
|
|
||||||
val vOid = vR.next()
|
|
||||||
if (vOid[0] != 0x06) return null
|
|
||||||
return oidName(oidToString(inB, vOid[1], vOid[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pull dNSName entries out of the SubjectAltName extension under [3]. */
|
|
||||||
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>)
|
|
||||||
{
|
|
||||||
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
|
|
||||||
val vEs = DerReader(inB, vSeq[1], vSeq[2])
|
|
||||||
while (vEs.hasMore())
|
|
||||||
{
|
|
||||||
val vExtn = vEs.next()
|
|
||||||
val vEr = DerReader(inB, vExtn[1], vExtn[2])
|
|
||||||
val vOid = vEr.next() // extnID
|
|
||||||
// SAN OID 2.5.29.17 == bytes 55 1D 11
|
|
||||||
val vIsSan = vOid[2] - vOid[1] == 3 &&
|
|
||||||
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
|
|
||||||
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
|
|
||||||
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
|
|
||||||
var vVal = vEr.next()
|
|
||||||
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
|
|
||||||
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
|
|
||||||
{
|
|
||||||
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
|
|
||||||
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
|
|
||||||
while (vGn.hasMore())
|
|
||||||
{
|
|
||||||
val vName = vGn.next()
|
|
||||||
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
|
|
||||||
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String?
|
|
||||||
{
|
|
||||||
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
|
|
||||||
return try
|
|
||||||
{
|
|
||||||
val vYear: String
|
|
||||||
val vRest: String
|
|
||||||
if (inTlv[0] == 0x18) // GeneralizedTime
|
|
||||||
{ vYear = vS.substring(0, 4); vRest = vS.substring(4) }
|
|
||||||
else // UTCTime — RFC 5280 pivot at 50
|
|
||||||
{
|
|
||||||
val vYy = vS.substring(0, 2).toInt()
|
|
||||||
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
|
|
||||||
vRest = vS.substring(2)
|
|
||||||
}
|
|
||||||
val vMo = vRest.substring(0, 2); val vDa = vRest.substring(2, 4)
|
|
||||||
val vHh = vRest.substring(4, 6); val vMi = vRest.substring(6, 8)
|
|
||||||
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
|
|
||||||
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
|
|
||||||
}
|
|
||||||
catch (e: Throwable) { vS }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colon-separated hex of a byte range (serial number rendering). */
|
|
||||||
private fun bytesToHex(inB: ByteArray, inFrom: Int, inTo: Int): String =
|
|
||||||
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
|
|
||||||
|
|
||||||
/* Decode an OID's content bytes to dotted-decimal. */
|
|
||||||
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String
|
|
||||||
{
|
|
||||||
if (inTo <= inFrom) return ""
|
|
||||||
val vSb = StringBuilder()
|
|
||||||
val vFirst = inB[inFrom].toInt() and 0xFF
|
|
||||||
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
|
|
||||||
var vAcc = 0L
|
|
||||||
for (vI in (inFrom + 1) until inTo)
|
|
||||||
{
|
|
||||||
val vByte = inB[vI].toInt() and 0xFF
|
|
||||||
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
|
|
||||||
if (vByte and 0x80 == 0) { vSb.append('.').append(vAcc); vAcc = 0 }
|
|
||||||
}
|
|
||||||
return vSb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Friendly name for the common signature / public-key OIDs, else the OID. */
|
|
||||||
private fun oidName(inOid: String): String = when (inOid)
|
|
||||||
{
|
|
||||||
"1.2.840.113549.1.1.1" -> "RSA"
|
|
||||||
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
|
|
||||||
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
|
|
||||||
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
|
|
||||||
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
|
|
||||||
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
|
|
||||||
"1.2.840.10045.2.1" -> "EC"
|
|
||||||
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
|
|
||||||
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
|
|
||||||
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
|
|
||||||
"1.3.101.112" -> "Ed25519"
|
|
||||||
"1.3.101.113" -> "Ed448"
|
|
||||||
else -> inOid
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.*
|
||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
import libcurl.*
|
import libcurl.*
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
@@ -21,321 +21,349 @@ import kotlin.time.TimeSource
|
|||||||
// from HttpRunner.run(); cancellation is best-effort (an in-flight perform can't
|
// from HttpRunner.run(); cancellation is best-effort (an in-flight perform can't
|
||||||
// be interrupted — the result is simply discarded if the caller cancelled).
|
// be interrupted — the result is simply discarded if the caller cancelled).
|
||||||
|
|
||||||
/* A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
|
/** A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
|
||||||
other SSL options to set, and a cleanup to run once the request is done.
|
other SSL options to set, and a cleanup to run once the request is done.
|
||||||
macOS/Linux (OpenSSL) just point curl at the files. Windows (Schannel) imports
|
macOS/Linux (OpenSSL) just point curl at the files. Windows (Schannel) imports
|
||||||
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
|
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
|
||||||
cleanup removes it again so the store never accumulates entries. */
|
cleanup removes it again so the store never accumulates entries. */
|
||||||
class PreparedCert(
|
class PreparedCert(
|
||||||
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
|
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
|
||||||
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
|
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
|
||||||
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
|
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
|
||||||
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
|
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
|
||||||
val keyPassword: String?, // CURLOPT_KEYPASSWD
|
val keyPassword: String?, // CURLOPT_KEYPASSWD
|
||||||
val cleanup: () -> Unit,
|
val cleanup: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
/* Ready the request's client certificate for libcurl (platform-specific).
|
/** Ready the request's client certificate for libcurl (platform-specific).
|
||||||
Throws with a user-facing message if the cert/key can't be loaded. */
|
Throws with a user-facing message if the cert/key can't be loaded. */
|
||||||
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
||||||
|
|
||||||
/* Remove any temporary client certs (and their key containers) a previous run
|
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
|
||||||
left behind in the Windows store after a crash — identified by our prefix, so
|
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) — the UI
|
||||||
the user's own certificates are never touched. No-op off Windows. Call once
|
// consumes them from shared code; the per-OS ClientCert*.kt actuals below the
|
||||||
at startup. */
|
// native tree actualize sweepTempClientCerts directly.
|
||||||
expect fun sweepTempClientCerts()
|
|
||||||
|
|
||||||
/* One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
|
/** Continue a server-presented chain with its issuer(s). Where possible the
|
||||||
Issuer / the PEM under "Cert" / dates …) and whether the server actually
|
issuer is resolved from the OS certificate store with full info; otherwise the
|
||||||
presented it. Derived certs — an issuer resolved from the OS store, or a
|
chain ends with a name-only placeholder. Every derived (non-server) cert is
|
||||||
name-only placeholder — have fromServer = false and are drawn dotted. */
|
flagged fromServer = false. Platform-specific (OS store on Windows). */
|
||||||
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
|
|
||||||
|
|
||||||
/* The server's TLS certificate chain. error is set instead when it couldn't be
|
|
||||||
fetched; fields vary by TLS backend. */
|
|
||||||
class TlsChain(val certs: List<ChainCert>, val error: String?)
|
|
||||||
|
|
||||||
/* Continue a server-presented chain with its issuer(s). Where possible the
|
|
||||||
issuer is resolved from the OS certificate store with full info; otherwise the
|
|
||||||
chain ends with a name-only placeholder. Every derived (non-server) cert is
|
|
||||||
flagged fromServer = false. Platform-specific (OS store on Windows). */
|
|
||||||
expect fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
|
expect fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
|
||||||
|
|
||||||
/* A CURLINFO_CERTINFO field by name (case-insensitive). */
|
/** A CURLINFO_CERTINFO field by name (case-insensitive). */
|
||||||
internal fun certFieldOf(inFields: List<Pair<String, String>>, inName: String): String? =
|
internal fun certFieldOf(inFields: List<Pair<String, String>>, inName: String): String? =
|
||||||
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
||||||
|
|
||||||
/* Server certs as ChainCerts; if the last one isn't self-signed, append a
|
/** Server certs as ChainCerts; if the last one isn't self-signed, append a
|
||||||
dotted, name-only issuer so the chain visibly continues. The cross-platform
|
dotted, name-only issuer so the chain visibly continues. The cross-platform
|
||||||
fallback when the OS store can't (or isn't able to) resolve the real issuer. */
|
fallback when the OS store can't (or isn't able to) resolve the real issuer. */
|
||||||
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert>
|
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert> {
|
||||||
{
|
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
|
||||||
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
|
val vLast = inServer.lastOrNull()
|
||||||
val vLast = inServer.lastOrNull()
|
if (vLast != null) {
|
||||||
if (vLast != null)
|
val vSubject = certFieldOf(vLast, "Subject")
|
||||||
{
|
val vIssuer = certFieldOf(vLast, "Issuer")
|
||||||
val vSubject = certFieldOf(vLast, "Subject")
|
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
|
||||||
val vIssuer = certFieldOf(vLast, "Issuer")
|
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
|
||||||
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
|
}
|
||||||
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
|
return vOut
|
||||||
}
|
|
||||||
return vOut
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Per-transfer accumulators handed to the C callbacks through a StableRef. */
|
|
||||||
private class CurlSink
|
|
||||||
{
|
|
||||||
val body = Buffer() // response body bytes (already decompressed by curl)
|
|
||||||
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Body write callback — appends each chunk to the sink's buffer. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun onCurlBody(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
|
|
||||||
{
|
|
||||||
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
|
||||||
val vLen = (inSize * inCount).toLong()
|
|
||||||
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
|
|
||||||
return vLen.convert()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header callback — one parsed header line per call; accumulated for parsing. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun onCurlHeader(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
|
|
||||||
{
|
|
||||||
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
|
||||||
val vLen = (inSize * inCount).toLong()
|
|
||||||
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
|
|
||||||
return vLen.convert()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sink callback that discards everything (used by the TLS-chain probe so the
|
|
||||||
response body/headers don't spill to stdout). */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun onCurlDiscard(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t =
|
|
||||||
(inSize * inCount)
|
|
||||||
|
|
||||||
/* Open a TLS connection to the request's URL (handshake only — no body) and
|
|
||||||
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
|
|
||||||
request's client certificate if it has one (so mTLS endpoints work too).
|
|
||||||
inReq is already var-resolved. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
fun inspectTlsChain(inReq: ApiRequest): TlsChain
|
|
||||||
{
|
|
||||||
val vCert = try { if (inReq.hasClientCert) prepareClientCert(inReq) else null }
|
|
||||||
catch (e: Throwable) { return TlsChain(emptyList(), e.message ?: "Client certificate error") }
|
|
||||||
val vCurl = curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
|
|
||||||
try
|
|
||||||
{
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
|
|
||||||
vCert?.let { vC ->
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
|
|
||||||
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
|
||||||
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
|
||||||
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
|
||||||
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
|
||||||
}
|
|
||||||
val vCode = curl_easy_perform(vCurl)
|
|
||||||
if (vCode != CURLE_OK)
|
|
||||||
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
|
|
||||||
val vRaw = readCertInfo(vCurl)
|
|
||||||
if (vRaw.isEmpty()) return TlsChain(emptyList(), "No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it).")
|
|
||||||
return TlsChain(extendChain(vRaw), null)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
curl_easy_cleanup(vCurl)
|
|
||||||
vCert?.cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
|
|
||||||
val vPtr = alloc<COpaquePointerVar>()
|
|
||||||
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
|
|
||||||
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
|
|
||||||
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
|
|
||||||
val vResult = ArrayList<List<Pair<String, String>>>()
|
|
||||||
for (vI in 0 until vInfo.num_of_certs)
|
|
||||||
{
|
|
||||||
val vFields = ArrayList<Pair<String, String>>()
|
|
||||||
var vNode = vArr[vI]
|
|
||||||
while (vNode != null)
|
|
||||||
{
|
|
||||||
val vData = vNode.pointed.data?.toKString()
|
|
||||||
if (vData != null)
|
|
||||||
{
|
|
||||||
val vColon = vData.indexOf(':')
|
|
||||||
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
|
|
||||||
else vFields.add("" to vData)
|
|
||||||
}
|
|
||||||
vNode = vNode.pointed.next
|
|
||||||
}
|
|
||||||
vResult.add(vFields)
|
|
||||||
}
|
|
||||||
vResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Send a client-certificate request through libcurl and adapt the result into
|
/** Per-transfer accumulators handed to the C callbacks through a StableRef. */
|
||||||
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
|
private class CurlSink {
|
||||||
|
val body = Buffer() // response body bytes (already decompressed by curl)
|
||||||
|
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body write callback — appends each chunk to the sink's buffer. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
private fun onCurlBody(
|
||||||
{
|
inBuffer: CPointer<ByteVar>,
|
||||||
val vMark = TimeSource.Monotonic.markNow()
|
inSize: size_t,
|
||||||
val vCert = try { prepareClientCert(inReq) }
|
inCount: size_t,
|
||||||
catch (e: Throwable) { return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds) }
|
inUserdata: COpaquePointer
|
||||||
val vCurl = curl_easy_init()
|
): size_t {
|
||||||
?: run { vCert.cleanup(); return errorResponse("Could not initialize libcurl", vMark.elapsedNow().inWholeMilliseconds) }
|
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
||||||
|
val vLen = (inSize * inCount).toLong()
|
||||||
|
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
|
||||||
|
return vLen.convert()
|
||||||
|
}
|
||||||
|
|
||||||
val vSink = CurlSink()
|
/** Header callback — one parsed header line per call; accumulated for parsing. */
|
||||||
val vSinkRef = StableRef.create(vSink)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
var vHeaders: CPointer<curl_slist>? = null
|
private fun onCurlHeader(
|
||||||
|
inBuffer: CPointer<ByteVar>,
|
||||||
|
inSize: size_t,
|
||||||
|
inCount: size_t,
|
||||||
|
inUserdata: COpaquePointer
|
||||||
|
): size_t {
|
||||||
|
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
||||||
|
val vLen = (inSize * inCount).toLong()
|
||||||
|
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
|
||||||
|
return vLen.convert()
|
||||||
|
}
|
||||||
|
|
||||||
try
|
/** Sink callback that discards everything (used by the TLS-chain probe so the
|
||||||
{
|
response body/headers don't spill to stdout). */
|
||||||
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
|
private fun onCurlDiscard(
|
||||||
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
|
inBuffer: CPointer<ByteVar>,
|
||||||
|
inSize: size_t,
|
||||||
|
inCount: size_t,
|
||||||
|
inUserdata: COpaquePointer
|
||||||
|
): size_t =
|
||||||
|
(inSize * inCount)
|
||||||
|
|
||||||
// ============
|
/** Open a TLS connection to the request's URL (handshake only — no body) and
|
||||||
// Method + body
|
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
|
||||||
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
|
request's client certificate if it has one (so mTLS endpoints work too).
|
||||||
if (inReq.method == ReqMethod.HEAD)
|
inReq is already var-resolved. */
|
||||||
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
else
|
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain {
|
||||||
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
|
val vCert = try {
|
||||||
if (vBody != null && vBody.bytes.isNotEmpty())
|
if (inReq.hasClientCert) prepareClientCert(inReq) else null
|
||||||
{
|
} catch (e: Throwable) {
|
||||||
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
|
return TlsChain(emptyList(), e.message ?: "Client certificate error")
|
||||||
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
|
}
|
||||||
}
|
val vCurl =
|
||||||
|
curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
|
||||||
|
try {
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
|
||||||
|
vCert?.let { vC ->
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
|
||||||
|
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
||||||
|
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
||||||
|
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
||||||
|
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
||||||
|
}
|
||||||
|
val vCode = curl_easy_perform(vCurl)
|
||||||
|
if (vCode != CURLE_OK)
|
||||||
|
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
|
||||||
|
val vRaw = readCertInfo(vCurl)
|
||||||
|
if (vRaw.isEmpty()) return TlsChain(
|
||||||
|
emptyList(),
|
||||||
|
"No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it)."
|
||||||
|
)
|
||||||
|
return TlsChain(extendChain(vRaw), null)
|
||||||
|
} finally {
|
||||||
|
curl_easy_cleanup(vCurl)
|
||||||
|
vCert?.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============
|
/** Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
|
||||||
// Headers (+ Content-Type for the body type if the user didn't set one)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
inReq.headers
|
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
|
||||||
.filter { it.enabled && it.key.isNotBlank() }
|
val vPtr = alloc<COpaquePointerVar>()
|
||||||
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
|
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
|
||||||
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
|
||||||
if (vBody?.contentType != null && !vHasCt)
|
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
|
||||||
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
|
val vResult = ArrayList<List<Pair<String, String>>>()
|
||||||
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
|
for (vI in 0 until vInfo.num_of_certs) {
|
||||||
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
|
val vFields = ArrayList<Pair<String, String>>()
|
||||||
|
var vNode = vArr[vI]
|
||||||
|
while (vNode != null) {
|
||||||
|
val vData = vNode.pointed.data?.toKString()
|
||||||
|
if (vData != null) {
|
||||||
|
val vColon = vData.indexOf(':')
|
||||||
|
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
|
||||||
|
else vFields.add("" to vData)
|
||||||
|
}
|
||||||
|
vNode = vNode.pointed.next
|
||||||
|
}
|
||||||
|
vResult.add(vFields)
|
||||||
|
}
|
||||||
|
vResult
|
||||||
|
}
|
||||||
|
|
||||||
// ============
|
/** Send a client-certificate request through libcurl and adapt the result into
|
||||||
// Response capture callbacks
|
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
|
||||||
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
|
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
|
||||||
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
|
val vMark = TimeSource.Monotonic.markNow()
|
||||||
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
|
val vCert = try {
|
||||||
|
prepareClientCert(inReq)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
val vCurl = curl_easy_init()
|
||||||
|
?: run {
|
||||||
|
vCert.cleanup(); return errorResponse(
|
||||||
|
"Could not initialize libcurl",
|
||||||
|
vMark.elapsedNow().inWholeMilliseconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ============
|
val vSink = CurlSink()
|
||||||
// Client certificate (prepared per-platform: cert/key files on the
|
val vSinkRef = StableRef.create(vSink)
|
||||||
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
|
var vHeaders: CPointer<curl_slist>? = null
|
||||||
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
|
|
||||||
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
|
||||||
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
|
||||||
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
|
||||||
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
|
||||||
|
|
||||||
// ============
|
try {
|
||||||
// Perform
|
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
||||||
val vCode = curl_easy_perform(vCurl)
|
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
|
||||||
val vMs = vMark.elapsedNow().inWholeMilliseconds
|
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
|
||||||
if (vCode != CURLE_OK)
|
|
||||||
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
|
|
||||||
|
|
||||||
val vBytes = vSink.body.readByteArray()
|
// ============
|
||||||
val vStatus = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_RESPONSE_CODE, p.ptr); p.value.toInt() }
|
// Method + body
|
||||||
val vCt = memScoped { val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(vCurl, CURLINFO_CONTENT_TYPE, p.ptr); p.value?.toKString() }
|
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
|
||||||
val vVer = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_HTTP_VERSION, p.ptr); httpVersionName(p.value) }
|
if (inReq.method == ReqMethod.HEAD)
|
||||||
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
|
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
|
||||||
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
|
else
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
|
||||||
|
if (vBody != null && vBody.bytes.isNotEmpty()) {
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
|
||||||
|
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
|
||||||
|
}
|
||||||
|
|
||||||
// Headers we explicitly put on the wire (curl also adds Host / Accept /
|
// ============
|
||||||
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
|
// Headers (+ Content-Type for the body type if the user didn't set one)
|
||||||
val vSent = buildList {
|
inReq.headers
|
||||||
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
|
.filter { it.enabled && it.key.isNotBlank() }
|
||||||
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
|
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
|
||||||
}.sortedBy { it.first.lowercase() }
|
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
||||||
|
if (vBody?.contentType != null && !vHasCt)
|
||||||
|
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
|
||||||
|
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
|
||||||
|
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
|
||||||
|
|
||||||
return ApiResponse(
|
// ============
|
||||||
ok = true,
|
// Response capture callbacks
|
||||||
status = vStatus,
|
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
|
||||||
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
|
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
|
||||||
timeMs = vMs,
|
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
|
||||||
sizeBytes = vBytes.size.toLong(),
|
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
|
||||||
headers = parseRawHeaders(vSink.headerRaw.toString()),
|
|
||||||
body = when {
|
|
||||||
vIsImage -> ""
|
|
||||||
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
|
|
||||||
else -> vBytes.decodeToString()
|
|
||||||
},
|
|
||||||
bytes = vBytes,
|
|
||||||
contentType = vCt,
|
|
||||||
requestHeaders = vSent,
|
|
||||||
httpVersion = vVer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
vHeaders?.let { curl_slist_free_all(it) }
|
|
||||||
curl_easy_cleanup(vCurl)
|
|
||||||
vSinkRef.dispose()
|
|
||||||
vCert.cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
|
// ============
|
||||||
|
// Client certificate (prepared per-platform: cert/key files on the
|
||||||
|
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
|
||||||
|
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
||||||
|
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
||||||
|
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
||||||
|
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Perform
|
||||||
|
val vCode = curl_easy_perform(vCurl)
|
||||||
|
val vMs = vMark.elapsedNow().inWholeMilliseconds
|
||||||
|
if (vCode != CURLE_OK)
|
||||||
|
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
|
||||||
|
|
||||||
|
val vBytes = vSink.body.readByteArray()
|
||||||
|
val vStatus = memScoped {
|
||||||
|
val p = alloc<LongVar>(); curl_easy_getinfo(
|
||||||
|
vCurl,
|
||||||
|
CURLINFO_RESPONSE_CODE,
|
||||||
|
p.ptr
|
||||||
|
); p.value.toInt()
|
||||||
|
}
|
||||||
|
val vCt = memScoped {
|
||||||
|
val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(
|
||||||
|
vCurl,
|
||||||
|
CURLINFO_CONTENT_TYPE,
|
||||||
|
p.ptr
|
||||||
|
); p.value?.toKString()
|
||||||
|
}
|
||||||
|
val vVer = memScoped {
|
||||||
|
val p = alloc<LongVar>(); curl_easy_getinfo(
|
||||||
|
vCurl,
|
||||||
|
CURLINFO_HTTP_VERSION,
|
||||||
|
p.ptr
|
||||||
|
); httpVersionName(p.value)
|
||||||
|
}
|
||||||
|
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
|
||||||
|
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
|
||||||
|
|
||||||
|
// Headers we explicitly put on the wire (curl also adds Host / Accept /
|
||||||
|
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
|
||||||
|
val vSent = buildList {
|
||||||
|
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
|
||||||
|
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
|
||||||
|
}.sortedBy { it.first.lowercase() }
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
status = vStatus,
|
||||||
|
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
|
||||||
|
timeMs = vMs,
|
||||||
|
sizeBytes = vBytes.size.toLong(),
|
||||||
|
headers = parseRawHeaders(vSink.headerRaw.toString()),
|
||||||
|
body = when {
|
||||||
|
vIsImage -> ""
|
||||||
|
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
|
||||||
|
else -> vBytes.decodeToString()
|
||||||
|
},
|
||||||
|
bytes = vBytes,
|
||||||
|
contentType = vCt,
|
||||||
|
requestHeaders = vSent,
|
||||||
|
httpVersion = vVer,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
vHeaders?.let { curl_slist_free_all(it) }
|
||||||
|
curl_easy_cleanup(vCurl)
|
||||||
|
vSinkRef.dispose()
|
||||||
|
vCert.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
|
||||||
private fun errorResponse(inMessage: String, inMs: Long): ApiResponse =
|
private fun errorResponse(inMessage: String, inMs: Long): ApiResponse =
|
||||||
ApiResponse(ok = false, status = 0, statusText = "—", timeMs = inMs, sizeBytes = 0, headers = emptyList(), body = "", error = inMessage)
|
ApiResponse(
|
||||||
|
status = 0,
|
||||||
|
statusText = "—",
|
||||||
|
timeMs = inMs,
|
||||||
|
sizeBytes = 0,
|
||||||
|
headers = emptyList(),
|
||||||
|
body = "",
|
||||||
|
error = inMessage
|
||||||
|
)
|
||||||
|
|
||||||
/* The encoded body for the request's body type, with the Content-Type curl
|
/** The encoded body for the request's body type, with the Content-Type curl
|
||||||
should send (null when there's no body). Mirrors HttpRunner.run(). */
|
should send (null when there's no body). Mirrors HttpRunner.run(). */
|
||||||
private class CurlBody(val contentType: String?, val bytes: ByteArray)
|
private class CurlBody(val contentType: String?, val bytes: ByteArray)
|
||||||
|
|
||||||
private fun requestBodyBytes(inReq: ApiRequest): CurlBody?
|
private fun requestBodyBytes(inReq: ApiRequest): CurlBody? {
|
||||||
{
|
if (inReq.bodyType == BodyType.NONE) return null
|
||||||
if (inReq.bodyType == BodyType.NONE) return null
|
return when (inReq.bodyType) {
|
||||||
return when (inReq.bodyType)
|
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
|
||||||
{
|
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
|
||||||
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
|
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(
|
||||||
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
|
inReq.bodyContentType(),
|
||||||
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(inReq.bodyContentType(), readFileBytes(inReq.body))
|
readFileBytes(inReq.body)
|
||||||
BodyType.NONE -> null
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Parse libcurl's accumulated raw header text into ordered key/value pairs,
|
BodyType.NONE -> null
|
||||||
keeping only the final response's block (a new "HTTP/" status line — emitted
|
}
|
||||||
per redirect hop — resets what we've gathered). */
|
}
|
||||||
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>>
|
|
||||||
{
|
|
||||||
var vCurrent = mutableListOf<Pair<String, String>>()
|
|
||||||
for (vRawLine in inRaw.split("\r\n", "\n"))
|
|
||||||
{
|
|
||||||
val vLine = vRawLine.trimEnd()
|
|
||||||
if (vLine.isEmpty()) continue
|
|
||||||
if (vLine.startsWith("HTTP/", ignoreCase = true)) { vCurrent = mutableListOf(); continue }
|
|
||||||
val vColon = vLine.indexOf(':')
|
|
||||||
if (vColon <= 0) continue
|
|
||||||
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
|
|
||||||
}
|
|
||||||
return vCurrent.sortedBy { it.first.lowercase() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
|
/** Parse libcurl's accumulated raw header text into ordered key/value pairs,
|
||||||
|
keeping only the final response's block (a new "HTTP/" status line — emitted
|
||||||
|
per redirect hop — resets what we've gathered). */
|
||||||
|
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>> {
|
||||||
|
var vCurrent = mutableListOf<Pair<String, String>>()
|
||||||
|
for (vRawLine in inRaw.split("\r\n", "\n")) {
|
||||||
|
val vLine = vRawLine.trimEnd()
|
||||||
|
if (vLine.isEmpty()) continue
|
||||||
|
if (vLine.startsWith("HTTP/", ignoreCase = true)) {
|
||||||
|
vCurrent = mutableListOf(); continue
|
||||||
|
}
|
||||||
|
val vColon = vLine.indexOf(':')
|
||||||
|
if (vColon <= 0) continue
|
||||||
|
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
|
||||||
|
}
|
||||||
|
return vCurrent.sortedBy { it.first.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun httpVersionName(inCode: Long): String = when (inCode)
|
private fun httpVersionName(inCode: Long): String = when (inCode) {
|
||||||
{
|
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
|
||||||
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
|
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
|
||||||
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
|
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
|
||||||
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
|
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
|
||||||
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
|
else -> "HTTP/1.1"
|
||||||
else -> "HTTP/1.1"
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,17 +6,10 @@ import com.compose.sdl.loadComposeResourceBytes
|
|||||||
import com.compose.sdl.text.namedFontFamily
|
import com.compose.sdl.text.namedFontFamily
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Monospace body font
|
// MARK: Monospace body font — native actuals (data.kres + IconFont)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
// Family name the body editor passes for monospace text. Resolves to the bundled
|
actual val monoFontFamilyName: String? by lazy {
|
||||||
// Noto Sans Mono once registered; null means the font isn't in data.kres, in
|
|
||||||
// which case the body falls back to the default (proportional Noto Sans).
|
|
||||||
const val kMonoFamily = "noto-mono"
|
|
||||||
|
|
||||||
// The raw project family name string — used by the SyntaxHighlight tokeniser's
|
|
||||||
// wrap() call which measures via NativeTextMeasurer (accepts a name string).
|
|
||||||
val monoFontFamilyName: String? by lazy {
|
|
||||||
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
|
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
|
||||||
if (vBytes == null) {
|
if (vBytes == null) {
|
||||||
println("apidemo: NotoSansMono.ttf not bundled — body uses the default font")
|
println("apidemo: NotoSansMono.ttf not bundled — body uses the default font")
|
||||||
@@ -27,9 +20,6 @@ val monoFontFamilyName: String? by lazy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The material3 Text / BasicTextField-shaped FontFamily wrapper. Wraps the
|
actual val monoFontFamily: FontFamily? by lazy {
|
||||||
// registered project family in a `NamedFont`-backed FontFamily so the m3 Text
|
|
||||||
// signature (which takes `FontFamily?` proper) can be given the mono family.
|
|
||||||
val monoFontFamily: FontFamily? by lazy {
|
|
||||||
monoFontFamilyName?.let { namedFontFamily(it) }
|
monoFontFamilyName?.let { namedFontFamily(it) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Pack model (serialized to / from .json)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* A "pack" is a savable collection of requests plus its environment variables
|
|
||||||
— the export/import unit. Variables are referenced as {{name}} in any URL,
|
|
||||||
query value, header or body and substituted just before the request is sent
|
|
||||||
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
|
|
||||||
in defaultSession(). */
|
|
||||||
@Serializable
|
|
||||||
data class Pack(
|
|
||||||
val name: String = "My Pack",
|
|
||||||
val requests: List<ApiRequest> = emptyList(),
|
|
||||||
val variables: List<KeyVal> = emptyList(),
|
|
||||||
val color: Int = 0, // 1-based index into the pack-colour palette; 0 = none
|
|
||||||
val headers: List<KeyVal> = emptyList(), // pack-level headers, inherited by requests
|
|
||||||
val params: List<KeyVal> = emptyList(), // pack-level query params, inherited by requests
|
|
||||||
val cert: CertConfig? = null, // pack-level client cert, inherited by requests
|
|
||||||
val id: String = "", // stable id (used by linked-copy packs)
|
|
||||||
val linkedTo: String? = null, // id of the source pack when this is a linked copy
|
|
||||||
val isRoot: Boolean = false, // the hidden session-root "pack" holding loose requests
|
|
||||||
val subPacks: List<Pack> = emptyList(), // nested packs (a pack tree)
|
|
||||||
)
|
|
||||||
|
|
||||||
/* A client-certificate (mutual TLS) configuration. Lives on a request today and
|
|
||||||
— with the pack tree — on the session / packs too (inherited downward). Maps
|
|
||||||
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
|
|
||||||
@Serializable
|
|
||||||
data class CertConfig(
|
|
||||||
val certPath: String = "",
|
|
||||||
val certFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val keyPath: String = "",
|
|
||||||
val keyFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val certPassword: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/* The starter SESSION loaded on first launch (and on demand from the session
|
|
||||||
menu) — a guided tour of every feature against httpbin.org: the inheritance
|
|
||||||
ladder (session → pack → sub-pack → request, for variables / query params /
|
|
||||||
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
|
|
||||||
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
|
|
||||||
/anything endpoint echoes the args + headers it received, so the merged,
|
|
||||||
inherited result is visible in the response. Loaded as an unsaved session. */
|
|
||||||
fun defaultSession(): Session = Session(
|
|
||||||
activePack = 0,
|
|
||||||
// ── Session level — the base of every inheritance ladder ──
|
|
||||||
globalEnv = listOf(
|
|
||||||
KeyVal("baseUrl", "https://httpbin.org"),
|
|
||||||
KeyVal("token", "session-token-abc"),
|
|
||||||
KeyVal("user", "session-user"),
|
|
||||||
KeyVal("password", "passwd"),
|
|
||||||
KeyVal("apiVer", "v1"), // overridden by Methods (v2) then Nested (v3)
|
|
||||||
),
|
|
||||||
globalHeaders = listOf(KeyVal("X-Client", "compose-apidemo")), // every request sends it (echoed by /headers)
|
|
||||||
globalParams = listOf(KeyVal("trace", "session")), // every request gets ?trace=session (echoed in /get args)
|
|
||||||
// No session-level cert: it would route every request through mTLS and fail
|
|
||||||
// without a real cert file. Client certs are shown on the "Secure (mTLS)" pack.
|
|
||||||
// ── Loose requests at the session root (in no pack — inherit session only) ──
|
|
||||||
root = Pack(isRoot = true, name = "", requests = listOf(
|
|
||||||
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
|
||||||
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
|
|
||||||
)),
|
|
||||||
packs = listOf(
|
|
||||||
// ── Methods — pack header/param/var inherited by its requests, plus a
|
|
||||||
// nested sub-pack and per-request overrides of each kind. ──
|
|
||||||
SavedPack(pack = Pack(name = "Methods", id = "pk-methods", color = 1,
|
|
||||||
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
|
|
||||||
params = listOf(KeyVal("source", "methods")), // pack query param, inherited (echoed in /get args)
|
|
||||||
variables = listOf(KeyVal("apiVer", "v2")), // overrides the session's apiVer for this pack
|
|
||||||
requests = listOf(
|
|
||||||
ApiRequest(name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
|
||||||
params = listOf(KeyVal("q", "compose"))), // own q + inherited trace=session + source=methods
|
|
||||||
ApiRequest(name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
|
||||||
params = listOf(KeyVal("trace", "request"))), // request's trace wins over the session's
|
|
||||||
ApiRequest(name = "POST — overrides header", method = ReqMethod.POST, url = "{{baseUrl}}/post",
|
|
||||||
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"),
|
|
||||||
ApiRequest(name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
|
||||||
variables = listOf(KeyVal("user", "request-user")), // request var beats session/pack — see body echo
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"),
|
|
||||||
ApiRequest(name = "PUT — replace", method = ReqMethod.PUT, url = "{{baseUrl}}/put",
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"id\": 1, \"name\": \"updated\" }"),
|
|
||||||
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
|
|
||||||
ApiRequest(name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"),
|
|
||||||
),
|
|
||||||
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
|
|
||||||
// plus its own X-Nested header; trace / source / Accept still flow down).
|
|
||||||
subPacks = listOf(
|
|
||||||
Pack(name = "Nested", color = 4,
|
|
||||||
variables = listOf(KeyVal("apiVer", "v3")),
|
|
||||||
headers = listOf(KeyVal("X-Nested", "true")),
|
|
||||||
requests = listOf(
|
|
||||||
ApiRequest(name = "GET — deep inheritance", method = ReqMethod.GET, url = "{{baseUrl}}/anything/{{apiVer}}"),
|
|
||||||
)),
|
|
||||||
))),
|
|
||||||
// ── Auth & status — uses the inherited {{token}} / {{user}} / {{password}} ──
|
|
||||||
SavedPack(pack = Pack(name = "Auth & status", color = 2, requests = listOf(
|
|
||||||
ApiRequest(name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
|
|
||||||
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))),
|
|
||||||
ApiRequest(name = "Basic auth", method = ReqMethod.GET, url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"),
|
|
||||||
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
|
|
||||||
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
|
|
||||||
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
|
|
||||||
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
|
|
||||||
))),
|
|
||||||
// ── Formats — response viewer (JSON / XML / HTML / image) ──
|
|
||||||
SavedPack(pack = Pack(name = "Formats", color = 3, requests = listOf(
|
|
||||||
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
|
|
||||||
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
|
|
||||||
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
|
|
||||||
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
|
|
||||||
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
|
|
||||||
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
|
|
||||||
))),
|
|
||||||
// ── Secure (mTLS) — a PACK-LEVEL client cert, inherited by its requests.
|
|
||||||
// Open a request's Cert tab to see the inherited cert (source pill +
|
|
||||||
// Override). Set real cert paths to actually send; the example paths
|
|
||||||
// won't load. The second request overrides with its own cert. ──
|
|
||||||
SavedPack(pack = Pack(name = "Secure (mTLS)", color = 5,
|
|
||||||
cert = CertConfig(certPath = "/path/to/client.p12", certFormat = CertFormat.PKCS12, certPassword = "changeit"),
|
|
||||||
requests = listOf(
|
|
||||||
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
|
||||||
ApiRequest(name = "Overrides with own cert", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
|
||||||
certPath = "/path/to/other-client.pem", certFormat = CertFormat.PEM, keyPath = "/path/to/other-key.pem"),
|
|
||||||
))),
|
|
||||||
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
|
|
||||||
// its own baseUrl var → the same calls run against the httpbingo sibling.
|
|
||||||
// Showcases linked packs + per-pack variable override. ──
|
|
||||||
SavedPack(pack = Pack(name = "Methods · httpbingo", color = 6, requests = emptyList(),
|
|
||||||
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiRequest(
|
|
||||||
val name: String = "New request",
|
|
||||||
val method: ReqMethod = ReqMethod.GET,
|
|
||||||
val url: String = "https://",
|
|
||||||
val params: List<KeyVal> = emptyList(),
|
|
||||||
val variables: List<KeyVal> = emptyList(), // request-level vars ({{name}}); override inherited ones
|
|
||||||
val headers: List<KeyVal> = emptyList(),
|
|
||||||
val bodyType: BodyType = BodyType.NONE,
|
|
||||||
val body: String = "", // TEXT content, or the file path when bodyType == FILE
|
|
||||||
val bodyFormat: BodyFormat = BodyFormat.RAW, // for a TEXT body: syntax-highlight type + sent Content-Type
|
|
||||||
val form: List<KeyVal> = emptyList(), // key/value fields when bodyType == FORM
|
|
||||||
// Client certificate (mutual TLS). When certPath is set, the request is sent
|
|
||||||
// through the libcurl path (CurlMtls.kt) with these mapped to CURLOPT_SSLCERT
|
|
||||||
// / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. A PKCS#12 file bundles its
|
|
||||||
// own key, so keyPath is left blank for it.
|
|
||||||
val certPath: String = "",
|
|
||||||
val certFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val keyPath: String = "",
|
|
||||||
val keyFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val certPassword: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/* Whether this request carries a client certificate (and so is sent via the
|
|
||||||
libcurl mTLS path rather than the default engine). */
|
|
||||||
val ApiRequest.hasClientCert: Boolean
|
|
||||||
get() = certPath.isNotBlank()
|
|
||||||
|
|
||||||
/* This request's cert fields as a CertConfig (certPath may be blank = none). */
|
|
||||||
fun ApiRequest.certConfig(): CertConfig = CertConfig(certPath, certFormat, keyPath, keyFormat, certPassword)
|
|
||||||
|
|
||||||
/* This request with its cert fields replaced by inCert's. */
|
|
||||||
fun ApiRequest.withCert(inCert: CertConfig): ApiRequest =
|
|
||||||
copy(certPath = inCert.certPath, certFormat = inCert.certFormat,
|
|
||||||
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword)
|
|
||||||
|
|
||||||
/* True when this config actually selects a certificate. */
|
|
||||||
val CertConfig.isSet: Boolean
|
|
||||||
get() = certPath.isNotBlank()
|
|
||||||
|
|
||||||
/* Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
|
|
||||||
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
|
|
||||||
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
|
|
||||||
three; Schannel (Windows) effectively supports P12. */
|
|
||||||
@Serializable
|
|
||||||
enum class CertFormat(val curlName: String, val label: String) {
|
|
||||||
PEM("PEM", "PEM"),
|
|
||||||
DER("DER", "DER"),
|
|
||||||
PKCS12("P12", "PKCS#12"),
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A toggleable key/value row, used for both query params and headers. */
|
|
||||||
@Serializable
|
|
||||||
data class KeyVal(val key: String = "", val value: String = "", val enabled: Boolean = true)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class ReqMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }
|
|
||||||
|
|
||||||
/* HTTP doesn't forbid a body on GET / HEAD / OPTIONS — RFC 7230 §3.3
|
|
||||||
leaves it as "SHOULD NOT" / server discretion. Many APIs use a body
|
|
||||||
on GET (Elasticsearch search, GraphQL queries) so we don't gate it
|
|
||||||
here; the UI just sets bodyType=NONE by default for those methods. */
|
|
||||||
val ReqMethod.allowsBody: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class BodyType { NONE, TEXT, FORM, FILE }
|
|
||||||
|
|
||||||
/* Title-case label for the body-type picker. */
|
|
||||||
val BodyType.label: String
|
|
||||||
get() = when (this) {
|
|
||||||
BodyType.NONE -> "None"
|
|
||||||
BodyType.TEXT -> "Text"
|
|
||||||
BodyType.FORM -> "Form"
|
|
||||||
BodyType.FILE -> "File"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Content-Type the request body implies (null when there's no body). For a
|
|
||||||
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
|
|
||||||
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
|
|
||||||
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
|
|
||||||
BodyType.NONE -> null
|
|
||||||
BodyType.TEXT -> bodyFormat.contentType
|
|
||||||
BodyType.FORM -> "application/x-www-form-urlencoded"
|
|
||||||
BodyType.FILE -> "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Response (runtime only — not part of a pack)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
class ApiResponse(
|
|
||||||
val ok: Boolean,
|
|
||||||
val status: Int,
|
|
||||||
val statusText: String,
|
|
||||||
val timeMs: Long,
|
|
||||||
val sizeBytes: Long,
|
|
||||||
val headers: List<Pair<String, String>>,
|
|
||||||
val body: String,
|
|
||||||
val bytes: ByteArray = ByteArray(0),
|
|
||||||
val contentType: String? = null,
|
|
||||||
val requestHeaders: List<Pair<String, String>> = emptyList(), // headers actually sent
|
|
||||||
val httpVersion: String = "HTTP/1.1", // from Ktor's response.version, e.g. "HTTP/1.1" or "HTTP/2"
|
|
||||||
val error: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/* True when the response carries an image payload (rendered, not shown as text). */
|
|
||||||
val ApiResponse.isImage: Boolean
|
|
||||||
get() = contentType?.startsWith("image/", ignoreCase = true) == true
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
|
||||||
|
|
||||||
package apidemo
|
|
||||||
|
|
||||||
import androidx.compose.foundation.*
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.PointerButton
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventType
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|
||||||
import com.compose.sdl.layout.x
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Request tab strip (open requests; drag a tab to reorder)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Strip of open requests above the editor. Each tab is clickable to select
|
|
||||||
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
|
|
||||||
fades, and follows the cursor via a graphicsLayer translation — its
|
|
||||||
neighbours don't shuffle; the reorder commits on drop, to the slot whose
|
|
||||||
neighbours' centres the cursor passed. key(tab) keeps each tab's
|
|
||||||
LayoutNode stable so the drag session survives the reorder. */
|
|
||||||
@Composable
|
|
||||||
internal fun RequestTabStrip(
|
|
||||||
inTabs: List<StripTab>,
|
|
||||||
inActiveKey: Any?,
|
|
||||||
inOnSelect: (StripTab) -> Unit,
|
|
||||||
inOnClose: (StripTab) -> Unit,
|
|
||||||
inOnCloseOthers: (StripTab) -> Unit,
|
|
||||||
inOnCloseAll: () -> Unit,
|
|
||||||
inOnReorder: (Int, Int) -> Unit,
|
|
||||||
) {
|
|
||||||
val c = LocalAppColors.current
|
|
||||||
val vScroll = rememberScrollState() // strip's horizontal scroll
|
|
||||||
val vLeft = remember { mutableStateMapOf<Any, Int>() } // window-x of each tab's left edge, by tab key
|
|
||||||
val vWidth = remember { mutableStateMapOf<Any, Int>() } // measured tab width, by tab key
|
|
||||||
var vDragKey by remember { mutableStateOf<Any?>(null) }
|
|
||||||
var vPressRelX by remember { mutableStateOf(0) }
|
|
||||||
var vDragDx by remember { mutableStateOf(0f) }
|
|
||||||
var vDragTarget by remember { mutableStateOf(-1) }
|
|
||||||
|
|
||||||
// One shared context menu, anchored to the tab that opened it (m3 DropdownMenu
|
|
||||||
// is parent-anchored, not cursor-anchored — the old x/y capture is gone).
|
|
||||||
var vMenu by remember { mutableStateOf(false) }
|
|
||||||
var vMenuTab by remember { mutableStateOf<StripTab?>(null) }
|
|
||||||
|
|
||||||
var vListOpen by remember { mutableStateOf(false) } // overflow list
|
|
||||||
|
|
||||||
val vShowBar = vDragKey != null && vDragDx != 0f
|
|
||||||
val vOthers = if (vShowBar) inTabs.filter { it.tabKey != vDragKey } else emptyList()
|
|
||||||
val vBarBeforeKey = if (vShowBar) vOthers.getOrNull(vDragTarget)?.tabKey else null
|
|
||||||
val vBarAtEnd = vShowBar && vDragTarget >= vOthers.size
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().background(c.panel),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
inTabs.forEach { vTab ->
|
|
||||||
val vKey = vTab.tabKey
|
|
||||||
if (vKey == vBarBeforeKey) DropBar()
|
|
||||||
key(vKey) {
|
|
||||||
val vSel = vKey == inActiveKey
|
|
||||||
val vDragged = vKey == vDragKey
|
|
||||||
|
|
||||||
var vMod = Modifier
|
|
||||||
.onGloballyPositioned { vLeft[vKey] = it.x }
|
|
||||||
.onSizeChanged { vWidth[vKey] = it.width }
|
|
||||||
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
|
|
||||||
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
|
|
||||||
// applied in the ALPHA layer's local coord space (before any child layer's
|
|
||||||
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
|
|
||||||
// ghost inside its original bounds and the ghost disappears as it slides. One
|
|
||||||
// layer with both properties = clip stays false, alpha modulates via layerPaint.
|
|
||||||
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
|
|
||||||
vMod = vMod
|
|
||||||
.clip(RoundedCornerShape(7.dp))
|
|
||||||
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
|
|
||||||
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
|
|
||||||
.pointerInput(vKey) {
|
|
||||||
// Accumulate the per-frame delta rather than reading change.position.
|
|
||||||
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
|
|
||||||
// which transforms the modifier's local pointer frame — so position.x
|
|
||||||
// would compensate for the translation each frame and the tab would
|
|
||||||
// trail the mouse by roughly half its offset. dragAmount is the
|
|
||||||
// root-frame delta and doesn't feed back through the layer transform.
|
|
||||||
detectDragGestures(
|
|
||||||
onDragStart = { offset ->
|
|
||||||
vDragKey = vKey
|
|
||||||
vPressRelX = offset.x.toInt()
|
|
||||||
vDragDx = 0f
|
|
||||||
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
|
|
||||||
},
|
|
||||||
onDrag = { _, dragAmount ->
|
|
||||||
val vd = vDragKey ?: return@detectDragGestures
|
|
||||||
vDragDx += dragAmount.x
|
|
||||||
val vRelX = vPressRelX + vDragDx.toInt()
|
|
||||||
val vCursorX = (vLeft[vd] ?: 0) + vRelX
|
|
||||||
var vCount = 0
|
|
||||||
inTabs.forEach { vT ->
|
|
||||||
if (vT.tabKey != vd) {
|
|
||||||
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
|
|
||||||
if (vCursorX > vCenter) vCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vDragTarget = vCount
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
val vd = vDragKey
|
|
||||||
if (vd != null) {
|
|
||||||
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
|
|
||||||
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(vFrom, vDragTarget)
|
|
||||||
}
|
|
||||||
vDragKey = null; vDragDx = 0f; vDragTarget = -1
|
|
||||||
},
|
|
||||||
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Middle-click closes the tab; right-click opens the tab menu. Compose
|
|
||||||
// has no first-class match for either — inline pointerInput checks the
|
|
||||||
// pressed PointerButton at each Press.
|
|
||||||
.pointerInput(vTab) {
|
|
||||||
awaitPointerEventScope {
|
|
||||||
while (true) {
|
|
||||||
val vEv = awaitPointerEvent()
|
|
||||||
if (vEv.type != PointerEventType.Press) continue
|
|
||||||
when (vEv.button) {
|
|
||||||
PointerButton.Tertiary -> {
|
|
||||||
vEv.changes.firstOrNull()?.consume()
|
|
||||||
inOnClose(vTab)
|
|
||||||
}
|
|
||||||
PointerButton.Secondary -> {
|
|
||||||
vEv.changes.firstOrNull()?.consume()
|
|
||||||
vMenuTab = vTab; vMenu = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clickable { inOnSelect(vTab) }
|
|
||||||
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = vMod,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
val vRs = vTab.req
|
|
||||||
if (vRs == null) {
|
|
||||||
// Session / pack settings tab.
|
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.Tune, tint = if (vSel) c.accent else c.dim, size = 13.dp)
|
|
||||||
Text(if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""), color = if (vSel) c.text else c.dim, fontSize = 13.sp)
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
|
|
||||||
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
|
|
||||||
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
|
|
||||||
}
|
|
||||||
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vBarAtEnd) DropBar()
|
|
||||||
|
|
||||||
// Shared right-click menu, opened at the cursor over the clicked tab.
|
|
||||||
val vMTab = vMenuTab
|
|
||||||
if (vMenu && vMTab != null) {
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = true,
|
|
||||||
onDismissRequest = { vMenu = false },
|
|
||||||
modifier = Modifier.widthIn(min = 188.dp),
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Close, "Close tab") }, onClick = { vMenu = false; inOnClose(vMTab) })
|
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") }, onClick = { vMenu = false; inOnCloseOthers(vMTab) })
|
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") }, onClick = { vMenu = false; inOnCloseAll() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overflow → chevron opens a scrollable list of every open tab.
|
|
||||||
if (vScroll.maxValue > 0) {
|
|
||||||
Box(modifier = Modifier.padding(end = 4.dp)) {
|
|
||||||
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
|
|
||||||
DropdownMenu(expanded = vListOpen, onDismissRequest = { vListOpen = false }, modifier = Modifier.widthIn(min = 240.dp)) {
|
|
||||||
Column(modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
|
|
||||||
inTabs.forEach { vTab ->
|
|
||||||
DropdownMenuItem(text = {
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
val vSel = vTab.tabKey == inActiveKey
|
|
||||||
val vRs = vTab.req
|
|
||||||
if (vRs == null) {
|
|
||||||
MaterialSymbolsOutlined(
|
|
||||||
MaterialSymbols.Tune,
|
|
||||||
tint = if (vSel) c.accent else c.dim,
|
|
||||||
size = 13.dp
|
|
||||||
)
|
|
||||||
Text(if (vTab.isSession) "Session" else "${vTab.pack?.name} · var", color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
|
|
||||||
Text(vRs.req.name, color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
|
|
||||||
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, onClick = { vListOpen = false; inOnSelect(vTab) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
|
|
||||||
between tabs so the Row centres it vertically and reserves a little space. */
|
|
||||||
@Composable
|
|
||||||
internal fun DropBar() {
|
|
||||||
val c = LocalAppColors.current
|
|
||||||
Box(modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp)).background(c.accent, RoundedCornerShape(2.dp)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: BodyFormat — viewer/highlighter selection
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Format of a body payload as shown in the viewer, and (for a TEXT request body)
|
|
||||||
the type the user picks on the right of the Body tab — it drives both syntax
|
|
||||||
highlighting and the sent Content-Type. RAW disables highlighting and falls back
|
|
||||||
to the selectable BasicTextField; other values pick a tokeniser. */
|
|
||||||
@Serializable
|
|
||||||
enum class BodyFormat { RAW, JSON, XML, YAML, HTML }
|
|
||||||
|
|
||||||
/* Short label for the format / "type" picker (RAW = plain text, no highlighting). */
|
|
||||||
val BodyFormat.label: String
|
|
||||||
get() = when (this) {
|
|
||||||
BodyFormat.RAW -> "RAW"
|
|
||||||
BodyFormat.JSON -> "JSON"
|
|
||||||
BodyFormat.XML -> "XML"
|
|
||||||
BodyFormat.YAML -> "YAML"
|
|
||||||
BodyFormat.HTML -> "HTML"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Content-Type a TEXT body of this format is sent with. */
|
|
||||||
val BodyFormat.contentType: String
|
|
||||||
get() = when (this) {
|
|
||||||
BodyFormat.RAW -> "text/plain"
|
|
||||||
BodyFormat.JSON -> "application/json"
|
|
||||||
BodyFormat.XML -> "application/xml"
|
|
||||||
BodyFormat.YAML -> "application/yaml"
|
|
||||||
BodyFormat.HTML -> "text/html"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pick a format from a Content-Type header. JSON / XML / YAML / HTML
|
|
||||||
match their canonical types and common variants; anything else
|
|
||||||
(image types, text/plain, …) falls back to RAW. */
|
|
||||||
fun autoFormatFor(inContentType: String?): BodyFormat {
|
|
||||||
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
|
|
||||||
return when {
|
|
||||||
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
|
|
||||||
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
|
|
||||||
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
|
|
||||||
vCt == "text/html" -> BodyFormat.HTML
|
|
||||||
else -> BodyFormat.RAW
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Highlight palette
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
|
|
||||||
light variant matching VS Code's defaults. Caller picks based on
|
|
||||||
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
|
|
||||||
data class SyntaxPalette(
|
|
||||||
val key: Color,
|
|
||||||
val string: Color,
|
|
||||||
val number: Color,
|
|
||||||
val keyword: Color,
|
|
||||||
val tag: Color,
|
|
||||||
val comment: Color,
|
|
||||||
val punct: Color,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
/* Light-on-dark — VS Code Dark+. */
|
|
||||||
val Dark = SyntaxPalette(
|
|
||||||
key = Color(0xFF9CDCFE),
|
|
||||||
string = Color(0xFFCE9178),
|
|
||||||
number = Color(0xFFB5CEA8),
|
|
||||||
keyword = Color(0xFFC586C0),
|
|
||||||
tag = Color(0xFF569CD6),
|
|
||||||
comment = Color(0xFF6A9955),
|
|
||||||
punct = Color(0xFFCCCCCC),
|
|
||||||
)
|
|
||||||
/* Dark-on-light — VS Code Light+. Higher contrast on white. */
|
|
||||||
val Light = SyntaxPalette(
|
|
||||||
key = Color(0xFF0451A5),
|
|
||||||
string = Color(0xFFA31515),
|
|
||||||
number = Color(0xFF098658),
|
|
||||||
keyword = Color(0xFF0000FF),
|
|
||||||
tag = Color(0xFF800000),
|
|
||||||
comment = Color(0xFF008000),
|
|
||||||
punct = Color(0xFF333333),
|
|
||||||
)
|
|
||||||
|
|
||||||
/* Pick a palette by the host theme's background brightness. */
|
|
||||||
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Entry point
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Render the given text with per-token colour spans for the requested
|
|
||||||
format. RAW returns an unstyled AnnotatedString. */
|
|
||||||
fun highlight(
|
|
||||||
inText: String,
|
|
||||||
inFormat: BodyFormat,
|
|
||||||
inPalette: SyntaxPalette = SyntaxPalette.Dark,
|
|
||||||
): AnnotatedString = when (inFormat) {
|
|
||||||
BodyFormat.RAW -> AnnotatedString(inText)
|
|
||||||
BodyFormat.JSON -> highlightJson(inText, inPalette)
|
|
||||||
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
|
|
||||||
BodyFormat.YAML -> highlightYaml(inText, inPalette)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: JSON tokeniser — single-pass, error-tolerant
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Tokeniser for JSON. Walks the string once and emits style spans for
|
|
||||||
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
|
|
||||||
(strings followed by ':') from string values by peeking after each
|
|
||||||
string. Error-tolerant — malformed JSON still highlights as best
|
|
||||||
effort rather than throwing. */
|
|
||||||
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
|
||||||
val vN = inText.length
|
|
||||||
var vI = 0
|
|
||||||
while (vI < vN) {
|
|
||||||
val vC = inText[vI]
|
|
||||||
when {
|
|
||||||
vC == '"' -> {
|
|
||||||
// Find closing quote, honouring backslash-escaped chars so an
|
|
||||||
// escaped \" doesn't terminate the string early.
|
|
||||||
val vStart = vI
|
|
||||||
var vJ = vI + 1
|
|
||||||
while (vJ < vN) {
|
|
||||||
if (inText[vJ] == '\\' && vJ + 1 < vN) { vJ += 2; continue }
|
|
||||||
if (inText[vJ] == '"') { vJ++; break }
|
|
||||||
vJ++
|
|
||||||
}
|
|
||||||
// Peek for ':' (skipping whitespace) → it's a key, not a value.
|
|
||||||
var vK = vJ
|
|
||||||
while (vK < vN && inText[vK].isWhitespace()) vK++
|
|
||||||
val vIsKey = vK < vN && inText[vK] == ':'
|
|
||||||
val vColor = if (vIsKey) inP.key else inP.string
|
|
||||||
pushStyle(SpanStyle(color = vColor))
|
|
||||||
append(inText.substring(vStart, vJ))
|
|
||||||
pop()
|
|
||||||
vI = vJ
|
|
||||||
}
|
|
||||||
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
|
|
||||||
val vStart = vI
|
|
||||||
if (vC == '-') vI++
|
|
||||||
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
|
|
||||||
pushStyle(SpanStyle(color = inP.number))
|
|
||||||
append(inText.substring(vStart, vI))
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
vC.isLetter() -> {
|
|
||||||
val vStart = vI
|
|
||||||
while (vI < vN && inText[vI].isLetter()) vI++
|
|
||||||
val vWord = inText.substring(vStart, vI)
|
|
||||||
if (vWord == "true" || vWord == "false" || vWord == "null") {
|
|
||||||
pushStyle(SpanStyle(color = inP.keyword))
|
|
||||||
append(vWord)
|
|
||||||
pop()
|
|
||||||
} else {
|
|
||||||
append(vWord)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vC in "{}[],:" -> {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct))
|
|
||||||
append(vC)
|
|
||||||
pop()
|
|
||||||
vI++
|
|
||||||
}
|
|
||||||
else -> { append(vC); vI++ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: XML / HTML tokeniser
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
|
|
||||||
attribute values (string), and comments. Inside tags, attr=value pairs
|
|
||||||
are recognised; outside tags text is left in the default colour. */
|
|
||||||
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
|
||||||
val vN = inText.length
|
|
||||||
var vI = 0
|
|
||||||
while (vI < vN) {
|
|
||||||
val vC = inText[vI]
|
|
||||||
when {
|
|
||||||
// Comment block: <!-- ... -->
|
|
||||||
vC == '<' && inText.startsWith("<!--", vI) -> {
|
|
||||||
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
|
|
||||||
pushStyle(SpanStyle(color = inP.comment))
|
|
||||||
append(inText.substring(vI, vEnd))
|
|
||||||
pop()
|
|
||||||
vI = vEnd
|
|
||||||
}
|
|
||||||
vC == '<' -> {
|
|
||||||
// Tag open: <[/]name attr=val attr2="val2">
|
|
||||||
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
|
|
||||||
var vJ = vI + 1
|
|
||||||
if (vJ < vN && inText[vJ] == '/') { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
|
|
||||||
// Tag name.
|
|
||||||
val vTagStart = vJ
|
|
||||||
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
|
||||||
if (vJ > vTagStart) {
|
|
||||||
pushStyle(SpanStyle(color = inP.tag))
|
|
||||||
append(inText.substring(vTagStart, vJ))
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
// Attributes — name="value" pairs.
|
|
||||||
while (vJ < vGt - 1) {
|
|
||||||
val vC2 = inText[vJ]
|
|
||||||
when {
|
|
||||||
vC2.isWhitespace() -> { append(vC2); vJ++ }
|
|
||||||
vC2 == '/' -> { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
|
|
||||||
vC2 == '=' -> { pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++ }
|
|
||||||
vC2 == '"' || vC2 == '\'' -> {
|
|
||||||
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
|
|
||||||
pushStyle(SpanStyle(color = inP.string))
|
|
||||||
append(inText.substring(vJ, vEnd))
|
|
||||||
pop()
|
|
||||||
vJ = vEnd
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val vAttrStart = vJ
|
|
||||||
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
|
||||||
pushStyle(SpanStyle(color = inP.key))
|
|
||||||
append(inText.substring(vAttrStart, vJ))
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
|
|
||||||
}
|
|
||||||
vI = vGt
|
|
||||||
}
|
|
||||||
else -> { append(vC); vI++ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: YAML tokeniser — line-based
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Tokeniser for YAML. Walks line by line because YAML's grammar is
|
|
||||||
indent-sensitive and most of the interesting colour decisions
|
|
||||||
(key vs value, list item vs scalar) depend on position within a
|
|
||||||
line. Comments (#), keys (text before ':'), quoted strings, numbers
|
|
||||||
and booleans are coloured; everything else stays default. */
|
|
||||||
private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
|
||||||
val vLines = inText.split('\n')
|
|
||||||
for ((vIdx, vLineRaw) in vLines.withIndex()) {
|
|
||||||
val vLine = vLineRaw
|
|
||||||
// Comment: rest of the line after a # (not inside a quoted string).
|
|
||||||
val vCommentAt = findUnquotedHash(vLine)
|
|
||||||
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
|
|
||||||
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
|
|
||||||
|
|
||||||
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
|
|
||||||
val vColon = indexOfKeyColon(vCodePart)
|
|
||||||
if (vColon > 0) {
|
|
||||||
val vPrefix = vCodePart.substring(0, vColon)
|
|
||||||
val vRest = vCodePart.substring(vColon + 1)
|
|
||||||
// Lead whitespace + optional list marker, then key.
|
|
||||||
var vK = 0
|
|
||||||
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) { append(vPrefix[vK]); vK++ }
|
|
||||||
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
|
||||||
append(vPrefix[vK + 1])
|
|
||||||
vK += 2
|
|
||||||
}
|
|
||||||
pushStyle(SpanStyle(color = inP.key))
|
|
||||||
append(vPrefix.substring(vK))
|
|
||||||
pop()
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
|
|
||||||
appendYamlValue(vRest, inP)
|
|
||||||
} else {
|
|
||||||
appendYamlValue(vCodePart, inP)
|
|
||||||
}
|
|
||||||
if (vCommentPart.isNotEmpty()) {
|
|
||||||
pushStyle(SpanStyle(color = inP.comment))
|
|
||||||
append(vCommentPart)
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
if (vIdx < vLines.size - 1) append('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Render the value portion of a YAML line — applies string / number /
|
|
||||||
keyword / list-marker colours where applicable. */
|
|
||||||
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
|
|
||||||
val vTrimmed = inSeg.trimStart()
|
|
||||||
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
|
|
||||||
append(vPad)
|
|
||||||
when {
|
|
||||||
vTrimmed.isEmpty() -> { /* nothing */ }
|
|
||||||
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
|
||||||
appendYamlValue(vTrimmed.substring(1), inP)
|
|
||||||
}
|
|
||||||
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
|
|
||||||
val vQ = vTrimmed[0]
|
|
||||||
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
|
|
||||||
pushStyle(SpanStyle(color = inP.string))
|
|
||||||
append(vTrimmed.substring(0, vEnd))
|
|
||||||
pop()
|
|
||||||
append(vTrimmed.substring(vEnd))
|
|
||||||
}
|
|
||||||
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
|
|
||||||
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
|
|
||||||
}
|
|
||||||
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
|
|
||||||
var vJ = 0
|
|
||||||
if (vTrimmed[0] == '-') vJ++
|
|
||||||
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
|
|
||||||
pushStyle(SpanStyle(color = inP.number))
|
|
||||||
append(vTrimmed.substring(0, vJ))
|
|
||||||
pop()
|
|
||||||
append(vTrimmed.substring(vJ))
|
|
||||||
}
|
|
||||||
else -> append(vTrimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Find the index of the first '#' that isn't inside a quoted string,
|
|
||||||
or -1 if there is none. Used to split a YAML line into code + comment. */
|
|
||||||
private fun findUnquotedHash(inLine: String): Int {
|
|
||||||
var vInSingle = false
|
|
||||||
var vInDouble = false
|
|
||||||
for (vI in inLine.indices) {
|
|
||||||
val vC = inLine[vI]
|
|
||||||
when {
|
|
||||||
vC == '"' && !vInSingle -> vInDouble = !vInDouble
|
|
||||||
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
|
|
||||||
vC == '#' && !vInSingle && !vInDouble -> return vI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Locate the colon that separates a YAML key from its value at the
|
|
||||||
current indent level — the FIRST unquoted ':' that's followed by
|
|
||||||
either whitespace or end-of-line. Returns -1 if the line has no key. */
|
|
||||||
private fun indexOfKeyColon(inLine: String): Int {
|
|
||||||
var vInSingle = false
|
|
||||||
var vInDouble = false
|
|
||||||
for (vI in inLine.indices) {
|
|
||||||
val vC = inLine[vI]
|
|
||||||
when {
|
|
||||||
vC == '"' && !vInSingle -> vInDouble = !vInDouble
|
|
||||||
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
|
|
||||||
vC == ':' && !vInSingle && !vInDouble -> {
|
|
||||||
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
|
|
||||||
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Theme — switchable dark / light palette
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
class AppColors(
|
|
||||||
val bg: Color, val panel: Color, val field: Color, val border: Color,
|
|
||||||
val accent: Color, val text: Color, val dim: Color, val onAccent: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal val DarkColors = AppColors(
|
|
||||||
bg = Color(0xFF15161A), panel = Color(0xFF23252C), field = Color(0xFF2D2F37),
|
|
||||||
border = Color(0xFF474C57), accent = Color(0xFF9F88FF), text = Color(0xFFECEEF2),
|
|
||||||
dim = Color(0xFFAEB4BD), onAccent = Color(0xFFFFFFFF),
|
|
||||||
)
|
|
||||||
internal val LightColors = AppColors(
|
|
||||||
bg = Color(0xFFF3F4F7), panel = Color(0xFFFFFFFF), field = Color(0xFFFFFFFF),
|
|
||||||
border = Color(0xFFCED3DB), accent = Color(0xFF6B4BE6), text = Color(0xFF1B1D22),
|
|
||||||
dim = Color(0xFF5C636E), onAccent = Color(0xFFFFFFFF),
|
|
||||||
)
|
|
||||||
internal val LocalAppColors = staticCompositionLocalOf { DarkColors }
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import com.compose.sdl.LocalComposeNativeWindow
|
||||||
|
import com.compose.sdl.TextLayoutConfig
|
||||||
|
import com.compose.sdl.appDataDir
|
||||||
|
import com.compose.sdl.fileManagerName
|
||||||
|
import com.compose.sdl.registerMemoryResource
|
||||||
|
import com.compose.sdl.removeMemoryResource
|
||||||
|
import com.compose.sdl.res.ResourceKind
|
||||||
|
import com.compose.sdl.res.painterResource
|
||||||
|
import com.compose.sdl.revealInFileManager
|
||||||
|
import com.compose.sdl.showOpenFileDialog
|
||||||
|
import com.compose.sdl.showSaveFileDialog
|
||||||
|
import com.compose.sdl.text.namedFontFamily
|
||||||
|
import androidx.compose.ui.text.Paragraph
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.createFontFamilyResolver
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.engine.curl.*
|
||||||
|
import okio.FileSystem
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Native actuals — pure delegation to the port's SDL-backed APIs
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
actual fun appDataDir(inOrg: String, inApp: String): String? =
|
||||||
|
appDataDir(inOrg, inApp)
|
||||||
|
|
||||||
|
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) =
|
||||||
|
revealInFileManager(inPath, inOnResult)
|
||||||
|
|
||||||
|
actual fun fileManagerName(): String = fileManagerName()
|
||||||
|
|
||||||
|
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) =
|
||||||
|
showSaveFileDialog(inDefaultName, inOnResult)
|
||||||
|
|
||||||
|
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) =
|
||||||
|
showOpenFileDialog(inOnResult)
|
||||||
|
|
||||||
|
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry.withPlainText(inText)
|
||||||
|
|
||||||
|
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) = registerMemoryResource(inKey, inBytes)
|
||||||
|
|
||||||
|
actual fun removeMemoryImage(inKey: String) = removeMemoryResource(inKey)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter =
|
||||||
|
painterResource(inKey, if (inSvg) ResourceKind.Svg else ResourceKind.Raster)
|
||||||
|
|
||||||
|
// Cached resolver for wrappedRowCount — the skiko engine resolves fonts by name
|
||||||
|
// (projectFontName), so this is just the required non-null Paragraph argument.
|
||||||
|
private val mRowCountFontResolver = createFontFamilyResolver()
|
||||||
|
|
||||||
|
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int {
|
||||||
|
if (inText.isEmpty()) return 1
|
||||||
|
// inFontPx is already physical pixels, so measure at density 1 (fontSize.value
|
||||||
|
// * density = inFontPx) and wrap at inMaxWidthPx px by building a Paragraph.
|
||||||
|
// lineCount comes from skiko's paragraph layout.
|
||||||
|
val vStyle = TextStyle(
|
||||||
|
fontSize = inFontPx.sp,
|
||||||
|
fontFamily = inFamilyName?.let { namedFontFamily(it) },
|
||||||
|
)
|
||||||
|
return Paragraph(
|
||||||
|
text = inText,
|
||||||
|
style = vStyle,
|
||||||
|
constraints = Constraints(maxWidth = inMaxWidthPx.coerceAtLeast(0)),
|
||||||
|
density = Density(1f),
|
||||||
|
fontFamilyResolver = mRowCountFontResolver,
|
||||||
|
spanStyles = emptyList(),
|
||||||
|
placeholders = emptyList(),
|
||||||
|
maxLines = Int.MAX_VALUE,
|
||||||
|
ellipsis = false,
|
||||||
|
).lineCount
|
||||||
|
}
|
||||||
|
|
||||||
|
actual var editorTabWidth: Int
|
||||||
|
get() = TextLayoutConfig.tabWidth
|
||||||
|
set(value) {
|
||||||
|
TextLayoutConfig.tabWidth = value
|
||||||
|
}
|
||||||
|
|
||||||
|
internal actual val systemFileSystem: FileSystem = FileSystem.SYSTEM
|
||||||
|
|
||||||
|
actual fun createApiHttpClient(): HttpClient =
|
||||||
|
HttpClient(Curl)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
|
||||||
|
val vWindow = LocalComposeNativeWindow.current
|
||||||
|
DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
|
||||||
|
vWindow.setOnCloseRequest(inOnCloseRequest)
|
||||||
|
vWindow.setOnKeyShortcut(inOnKeyShortcut)
|
||||||
|
onDispose { vWindow.setOnCloseRequest(null); vWindow.setOnKeyShortcut(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,62 @@
|
|||||||
// Root build — declare the Kotlin/Compose plugins once (apply false) so every
|
import kotlinx.validation.ExperimentalBCVApi
|
||||||
// subproject shares a single plugin classloader. Applying them independently
|
|
||||||
// per-subproject can load Kotlin Gradle plugin build services under isolated
|
|
||||||
// classloaders, which fails with "X cannot be cast to X" once several modules
|
|
||||||
// apply the multiplatform plugin. Each subproject still applies the ones it needs.
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.kotlin.plugin.compose) apply false
|
alias(libs.plugins.kotlin.plugin.compose) apply false
|
||||||
alias(libs.plugins.kotlin.plugin.serialization) apply false
|
alias(libs.plugins.kotlin.plugin.serialization) apply false
|
||||||
alias(libs.plugins.compose.multiplatform) apply false
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
// Public-ABI dumps (api/<module>.klib.api). Used by scripts/compose-coverage.py
|
|
||||||
// to diff our mirrored API surface against the official upstream klib ABI dumps.
|
|
||||||
alias(libs.plugins.binary.compatibility.validator)
|
alias(libs.plugins.binary.compatibility.validator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate klib ABI dumps; the apps and icon-font modules carry no public API
|
|
||||||
// surface worth tracking, so skip them.
|
|
||||||
apiValidation {
|
apiValidation {
|
||||||
|
@OptIn(ExperimentalBCVApi::class)
|
||||||
klib { enabled = true }
|
klib { enabled = true }
|
||||||
ignoredProjects.addAll(listOf("demo", "apidemo"))
|
ignoredProjects.addAll(listOf("demo", "apidemo"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project coordinates. `group` is baked into every module's Kotlin/Native klib
|
|
||||||
// metadata as its module ID (e.g. `com.bitsycore.compose.sdl:ui`) — keep it
|
|
||||||
// stable, or a stale IC cache surfaces as "Unknown dependent library …" (see
|
|
||||||
// the pitfall note in CLAUDE.md).
|
|
||||||
//
|
|
||||||
// Version is driven by PUBLISH_VERSION (set from the git tag in the publish
|
// Version is driven by PUBLISH_VERSION (set from the git tag in the publish
|
||||||
// workflow, `v1.2.3` → `1.2.3`). Local dev / demo runs default to SNAPSHOT.
|
// workflow, `v1.2.3` → `1.2.3`). Local dev / demo runs default to SNAPSHOT.
|
||||||
val vPublishVersion = (System.getenv("PUBLISH_VERSION") ?: "0.0.0-SNAPSHOT").removePrefix("v")
|
val vPublishVersion = (System.getenv("PUBLISH_VERSION") ?: "0.0.0-SNAPSHOT").removePrefix("v")
|
||||||
|
|
||||||
|
// Published groups mirror the org.jetbrains.compose.<area> originals under the
|
||||||
|
// com.bitsycore fork name, so each module is the obvious 1:1 fork of its upstream
|
||||||
|
// coord (e.g. org.jetbrains.compose.ui:ui → com.bitsycore.compose.ui:ui). Modules
|
||||||
|
// with no upstream area (project-only: :sdl-core, :material-symbols) fall through
|
||||||
|
// to the default com.bitsycore.compose.sdl group; :desktop-native-window is the
|
||||||
|
// headline app-shell artifact, published under com.bitsycore.compose.
|
||||||
|
val kDefaultGroup = "com.bitsycore.compose.sdl"
|
||||||
|
val kAreaGroups = mapOf(
|
||||||
|
":ui" to "com.bitsycore.compose.ui",
|
||||||
|
":ui-graphics" to "com.bitsycore.compose.ui",
|
||||||
|
":ui-text" to "com.bitsycore.compose.ui",
|
||||||
|
":ui-unit" to "com.bitsycore.compose.ui",
|
||||||
|
":ui-geometry" to "com.bitsycore.compose.ui",
|
||||||
|
":ui-util" to "com.bitsycore.compose.ui",
|
||||||
|
":ui-backhandler" to "com.bitsycore.compose.ui",
|
||||||
|
":ui-tooling-preview" to "com.bitsycore.compose.ui",
|
||||||
|
":foundation" to "com.bitsycore.compose.foundation",
|
||||||
|
":foundation-layout" to "com.bitsycore.compose.foundation",
|
||||||
|
":animation" to "com.bitsycore.compose.animation",
|
||||||
|
":animation-core" to "com.bitsycore.compose.animation",
|
||||||
|
":animation-graphics" to "com.bitsycore.compose.animation",
|
||||||
|
":material3" to "com.bitsycore.compose.material3",
|
||||||
|
":material-ripple" to "com.bitsycore.compose.material",
|
||||||
|
":components-resources" to "com.bitsycore.compose.components",
|
||||||
|
":navigation3-ui" to "com.bitsycore.navigation3",
|
||||||
|
":desktop-native-window" to "com.bitsycore.compose",
|
||||||
|
)
|
||||||
|
fun groupFor(path: String): String = kAreaGroups[path] ?: kDefaultGroup
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = "com.bitsycore.compose.sdl"
|
group = groupFor(path)
|
||||||
version = vPublishVersion
|
version = vPublishVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Publish to GitHub Packages
|
// MARK: Publish to GitHub Packages
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
// Every library module (everything except the two demo apps) auto-registers a
|
// Every library module (everything except the two demo apps) auto-registers a
|
||||||
// MavenPublication via the kotlin-multiplatform plugin — one per target + one
|
// MavenPublication via the kotlin-multiplatform plugin — one per target + one
|
||||||
// for the shared kotlinMultiplatform metadata. The CI publish workflow runs on
|
// for the shared kotlinMultiplatform metadata. The CI publish workflow runs on
|
||||||
@@ -46,11 +67,13 @@ allprojects {
|
|||||||
|
|
||||||
val kAppModules = setOf(":demo", ":apidemo")
|
val kAppModules = setOf(":demo", ":apidemo")
|
||||||
val kPublishedLibs = setOf(
|
val kPublishedLibs = setOf(
|
||||||
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
|
":sdl-core",
|
||||||
|
":ui", ":ui-util", ":ui-geometry", ":ui-graphics", ":ui-text",
|
||||||
|
":ui-unit", ":ui-backhandler", ":ui-tooling-preview",
|
||||||
":animation-core", ":animation", ":animation-graphics",
|
":animation-core", ":animation", ":animation-graphics",
|
||||||
":foundation", ":foundation-layout",
|
":foundation", ":foundation-layout",
|
||||||
":material3", ":material-ripple",
|
":material3", ":material-ripple",
|
||||||
":window", ":material-symbols",
|
":desktop-native-window", ":material-symbols",
|
||||||
":navigation3-ui", ":components-resources",
|
":navigation3-ui", ":components-resources",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,6 +86,20 @@ val kPublishedLibs = setOf(
|
|||||||
val kUseGhPackages = (findProperty("useGithubPackages") as? String)?.toBoolean() == true
|
val kUseGhPackages = (findProperty("useGithubPackages") as? String)?.toBoolean() == true
|
||||||
val kConsumeVersion = (findProperty("consumeVersion") as? String) ?: "0.1.0"
|
val kConsumeVersion = (findProperty("consumeVersion") as? String) ?: "0.1.0"
|
||||||
|
|
||||||
|
// JVM-PARITY VERSION PIN (app modules): force the org.jetbrains.compose groups
|
||||||
|
// on every jvm configuration to the catalog pin matching COMPOSE_CORE_REF.
|
||||||
|
// Gradle orders "+dev" BELOW the plain version, so whenever the pin is a dev
|
||||||
|
// build the umbrella plugin's published artifacts would win conflict resolution
|
||||||
|
// and silently break byte-exact parity with the vendored sources.
|
||||||
|
val kComposeJvmForced = mapOf(
|
||||||
|
"org.jetbrains.compose.runtime" to libs.versions.compose.get(),
|
||||||
|
"org.jetbrains.compose.ui" to libs.versions.compose.get(),
|
||||||
|
"org.jetbrains.compose.foundation" to libs.versions.compose.get(),
|
||||||
|
"org.jetbrains.compose.animation" to libs.versions.compose.get(),
|
||||||
|
"org.jetbrains.compose.material" to libs.versions.compose.get(),
|
||||||
|
"org.jetbrains.compose.material3" to libs.versions.composeMaterial3.get(),
|
||||||
|
)
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
if (kUseGhPackages && path in kAppModules) {
|
if (kUseGhPackages && path in kAppModules) {
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
@@ -70,13 +107,22 @@ subprojects {
|
|||||||
kPublishedLibs.forEach { modulePath ->
|
kPublishedLibs.forEach { modulePath ->
|
||||||
val vArtifactId = modulePath.removePrefix(":")
|
val vArtifactId = modulePath.removePrefix(":")
|
||||||
substitute(project(modulePath))
|
substitute(project(modulePath))
|
||||||
.using(module("com.bitsycore.compose.sdl:$vArtifactId:$kConsumeVersion"))
|
.using(module("${groupFor(modulePath)}:$vArtifactId:$kConsumeVersion"))
|
||||||
.because("-PuseGithubPackages=true")
|
.because("-PuseGithubPackages=true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (path in kAppModules) return@subprojects
|
if (path in kAppModules) {
|
||||||
|
configurations.configureEach {
|
||||||
|
if (name.startsWith("jvm")) {
|
||||||
|
resolutionStrategy.eachDependency {
|
||||||
|
kComposeJvmForced[requested.group]?.let { useVersion(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@subprojects
|
||||||
|
}
|
||||||
plugins.apply("maven-publish")
|
plugins.apply("maven-publish")
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
extensions.configure<PublishingExtension> {
|
extensions.configure<PublishingExtension> {
|
||||||
@@ -114,26 +160,43 @@ subprojects {
|
|||||||
// swaps those modules for the port's project equivalents — the Maven
|
// swaps those modules for the port's project equivalents — the Maven
|
||||||
// artifacts ship no mingwX64/linux klibs. org.jetbrains.compose.runtime is
|
// artifacts ship no mingwX64/linux klibs. org.jetbrains.compose.runtime is
|
||||||
// deliberately NOT here: the port uses the official runtime klibs everywhere.
|
// deliberately NOT here: the port uses the official runtime klibs everywhere.
|
||||||
// (:demo carries the same pattern locally for components-resources.)
|
|
||||||
val vNativeTargetTokens = listOf("mingwX64", "linuxX64", "linuxArm64", "macosArm64")
|
val vNativeTargetTokens = listOf("mingwX64", "linuxX64", "linuxArm64", "macosArm64")
|
||||||
allprojects {
|
allprojects {
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
if (vNativeTargetTokens.any { name.contains(it, ignoreCase = true) }) {
|
if (vNativeTargetTokens.any { name.contains(it, ignoreCase = true) }) {
|
||||||
resolutionStrategy.dependencySubstitution {
|
resolutionStrategy.dependencySubstitution {
|
||||||
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":ui"))
|
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":ui"))
|
||||||
|
// ui-graphics / ui-text are their own modules (split out of :ui,
|
||||||
|
// upstream layout). Declare each DIRECTLY so the app commonMain sees
|
||||||
|
// them under the granular-metadata visibility rule.
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-graphics")).using(project(":ui-graphics"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":ui-text"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-unit")).using(project(":ui-unit"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-geometry")).using(project(":ui-geometry"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-util")).using(project(":ui-util"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-tooling-preview")).using(project(":ui-tooling-preview"))
|
||||||
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":foundation"))
|
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":foundation"))
|
||||||
|
substitute(module("org.jetbrains.compose.foundation:foundation-layout")).using(project(":foundation-layout"))
|
||||||
|
substitute(module("org.jetbrains.compose.animation:animation")).using(project(":animation"))
|
||||||
|
substitute(module("org.jetbrains.compose.animation:animation-core")).using(project(":animation-core"))
|
||||||
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":material3"))
|
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":material3"))
|
||||||
|
// navigation3-ui: the JB Maven artifact has no K/N desktop
|
||||||
|
// klibs — the port vendors it as :navigation3-ui.
|
||||||
|
substitute(module("org.jetbrains.androidx.navigation3:navigation3-ui")).using(project(":navigation3-ui"))
|
||||||
|
// components-resources: the official resources runtime ships no
|
||||||
|
// mingwX64/linux klibs — the port vendors it as :components-resources.
|
||||||
|
substitute(module("org.jetbrains.compose.components:components-resources")).using(project(":components-resources"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether the current host can build the mingwX64 target. Kotlin/Native can
|
// Whether the current host can build the mingwX64 target. Kotlin/Native can
|
||||||
// only cross-compile mingwX64 cinterops from a Windows host — the sdl3_ttf /
|
// only cross-compile the mingwX64 sdl3 cinterop from a Windows host — it needs
|
||||||
// sdl3_image cinterops need Windows SDL3 headers under libs/ (produced by
|
// the Windows SDL3 headers under libs/ (produced by scripts/build-sdl/build-all.py
|
||||||
// scripts/build-sdl/build-all.py run on a Windows host). Declaring `mingwX64()` on
|
// run on a Windows host). Declaring `mingwX64()` on a non-Windows host is safe
|
||||||
// a non-Windows host is safe for pure-Kotlin modules but blows up the moment
|
// for pure-Kotlin modules but blows up the moment the sdl3 cinterop tries to
|
||||||
// a `depends = sdl3` cinterop tries to include SDL3_ttf/SDL_ttf.h.
|
// include SDL3's headers.
|
||||||
// Override with `-PforceMingw=true` if you actually have the headers wired.
|
// Override with `-PforceMingw=true` if you actually have the headers wired.
|
||||||
val vHostSupportsMingw = System.getProperty("os.name").startsWith("Windows") ||
|
val vHostSupportsMingw = System.getProperty("os.name").startsWith("Windows") ||
|
||||||
(findProperty("forceMingw") as? String)?.toBoolean() == true
|
(findProperty("forceMingw") as? String)?.toBoolean() == true
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package com.bitsycore.compose.sdl.build
|
||||||
|
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.file.Directory
|
||||||
|
import org.gradle.api.file.RegularFile
|
||||||
|
import org.gradle.api.provider.Provider
|
||||||
|
import org.gradle.api.tasks.AbstractCopyTask
|
||||||
|
import org.gradle.api.tasks.Exec
|
||||||
|
import org.gradle.api.tasks.TaskProvider
|
||||||
|
import org.gradle.api.tasks.bundling.Zip
|
||||||
|
import org.gradle.kotlin.dsl.extra
|
||||||
|
import org.gradle.kotlin.dsl.register
|
||||||
|
import org.gradle.kotlin.dsl.withType
|
||||||
|
import org.gradle.language.jvm.tasks.ProcessResources
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Configuration
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-app knobs for [registerComposeFontBundling]. EVERYTHING is opt-in — with no
|
||||||
|
* flag set the call registers no tasks and stages nothing.
|
||||||
|
*/
|
||||||
|
class ComposeFontBundlingConfig {
|
||||||
|
/**
|
||||||
|
* Bundle font/NotoSans.ttf, the default UI font the text renderers load at startup.
|
||||||
|
* -PbundleDefaultFont=false still skips it (system-font fallback).
|
||||||
|
*/
|
||||||
|
var bundleNotoSans: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always bundle font/NotoSansMono.ttf — for apps that load the mono font through
|
||||||
|
* their own seam (apidemo's body font, Fonts.kt), invisible to auto-detection.
|
||||||
|
*/
|
||||||
|
var bundleNotoSansMono: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle font/NotoSansMono.ttf only when `FontFamily.Monospace` appears in the
|
||||||
|
* app's Kotlin sources (it backs the generic monospace family at runtime).
|
||||||
|
*/
|
||||||
|
var autoDetectNotoSansMono: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle one Material Symbols font per style whose call sites appear in the app's
|
||||||
|
* Kotlin sources (MaterialSymbolsOutlined / Rounded / Sharp).
|
||||||
|
*/
|
||||||
|
var bundleMaterialSymbols: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hb-subset each bundled Material Symbols font to the glyphs the sources reference
|
||||||
|
* (scripts/subset-material-symbols.py + hb-subset; full font when hb-subset is
|
||||||
|
* absent). Needs [bundleMaterialSymbols]; still gated by -PsubsetIcons.
|
||||||
|
*/
|
||||||
|
var enableIconSubsetting: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// A style's font is bundled only when its call sites appear in the sources.
|
||||||
|
private val kAllStyles = listOf(
|
||||||
|
"Outlined" to Regex("\\bMaterialSymbolsOutlined\\b"),
|
||||||
|
"Rounded" to Regex("\\bMaterialSymbolsRounded\\b"),
|
||||||
|
"Sharp" to Regex("\\bMaterialSymbolsSharp\\b"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// FontFamily.Monospace call sites → NotoSansMono is worth bundling.
|
||||||
|
private val kMonospaceRegex = Regex("\\bFontFamily\\.Monospace\\b")
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Entry point
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the app font pipeline shared by :demo and :apidemo. Everything is OPT-IN via
|
||||||
|
* [ComposeFontBundlingConfig] — a bare call is a no-op.
|
||||||
|
*
|
||||||
|
* - Registers downloadNotoFonts (Noto Sans + Noto Sans Mono variable fonts →
|
||||||
|
* build/fonts/) when a Noto font is bundled.
|
||||||
|
* - Adds the opted-in font/ entries to every data.kres Zip task — the bridge plugin's
|
||||||
|
* package<Variant>ComposeResources<Target> tasks or an app's own copy* tasks
|
||||||
|
* (matching{} is lazy: the plugin's tasks appear in its afterEvaluate). Material
|
||||||
|
* Symbols fonts are hb-subset to the used glyphs when subsetting is enabled.
|
||||||
|
* - Stages the same fonts (always full, never subset) onto the JVM classpath via
|
||||||
|
* jvmProcessResources: the :material-symbols JVM actual loads font/<Style>.ttf, and
|
||||||
|
* the parity harness's JVM leg renders with the same NotoSans as the native leg
|
||||||
|
* (P0.3, RENDERER.md §8).
|
||||||
|
*/
|
||||||
|
fun Project.registerComposeFontBundling(configure: ComposeFontBundlingConfig.() -> Unit) {
|
||||||
|
val vConfig = ComposeFontBundlingConfig().apply(configure)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// What to bundle
|
||||||
|
// ============
|
||||||
|
val vBundleSans = vConfig.bundleNotoSans &&
|
||||||
|
providers.gradleProperty("bundleDefaultFont").map(String::toBoolean).getOrElse(true)
|
||||||
|
val vBundleMono = vConfig.bundleNotoSansMono ||
|
||||||
|
(vConfig.autoDetectNotoSansMono && sourcesMatch(kMonospaceRegex))
|
||||||
|
val vUsedStyles = if (vConfig.bundleMaterialSymbols) detectUsedStyles() else emptyList()
|
||||||
|
if (!vBundleSans && !vBundleMono && vUsedStyles.isEmpty()) return
|
||||||
|
|
||||||
|
val vSubsetIcons = vConfig.enableIconSubsetting && vUsedStyles.isNotEmpty() &&
|
||||||
|
providers.gradleProperty("subsetIcons").map(String::toBoolean).getOrElse(false)
|
||||||
|
// One subset task per used style, shared by all variant×target archives.
|
||||||
|
val vSubsetTasksByStyle = if (vSubsetIcons) registerIconSubsetPipeline(vUsedStyles) else emptyMap()
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Noto downloads
|
||||||
|
// ============
|
||||||
|
val vNotoSansFile = layout.buildDirectory.file("fonts/NotoSans.ttf")
|
||||||
|
val vNotoMonoFile = layout.buildDirectory.file("fonts/NotoSansMono.ttf")
|
||||||
|
val vNotoFiles = buildList {
|
||||||
|
if (vBundleSans) add(vNotoSansFile)
|
||||||
|
if (vBundleMono) add(vNotoMonoFile)
|
||||||
|
}
|
||||||
|
val vDownloadNotoFonts = if (vNotoFiles.isEmpty()) null else tasks.register("downloadNotoFonts") {
|
||||||
|
description = "Download the Google Noto variable fonts (Sans + SansMono) to build/fonts/."
|
||||||
|
val vDownloads = listOf(
|
||||||
|
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosans/NotoSans%5Bwdth%2Cwght%5D.ttf"
|
||||||
|
to vNotoSansFile.get().asFile,
|
||||||
|
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosansmono/NotoSansMono%5Bwdth%2Cwght%5D.ttf"
|
||||||
|
to vNotoMonoFile.get().asFile,
|
||||||
|
)
|
||||||
|
outputs.files(vDownloads.map { it.second })
|
||||||
|
doLast {
|
||||||
|
for ((vUrl, vOut) in vDownloads) {
|
||||||
|
if (vOut.exists() && vOut.length() > 0) continue
|
||||||
|
vOut.parentFile.mkdirs()
|
||||||
|
println("Downloading $vUrl")
|
||||||
|
URI(vUrl).toURL().openStream().use { vIn -> vOut.outputStream().use { vIn.copyTo(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// data.kres font/ entries (native) + JVM classpath analog
|
||||||
|
// ============
|
||||||
|
tasks.withType<Zip>().matching {
|
||||||
|
(it.name.startsWith("package") || it.name.startsWith("copy")) && it.name.contains("ComposeResources")
|
||||||
|
}.configureEach {
|
||||||
|
addFontEntries(this, vNotoFiles, vDownloadNotoFonts, vUsedStyles, vSubsetTasksByStyle)
|
||||||
|
}
|
||||||
|
tasks.withType<ProcessResources>().matching { it.name == "jvmProcessResources" }.configureEach {
|
||||||
|
// Full fonts on JVM — Skiko applies the variable axes itself (Typeface.makeClone).
|
||||||
|
addFontEntries(this, vNotoFiles, vDownloadNotoFonts, vUsedStyles, emptyMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Source scanning
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when any Kotlin file under src/ matches inRegex.
|
||||||
|
*/
|
||||||
|
private fun Project.sourcesMatch(inRegex: Regex): Boolean {
|
||||||
|
val vSrcRoot = layout.projectDirectory.dir("src").asFile
|
||||||
|
return vSrcRoot.exists() && vSrcRoot.walk().any {
|
||||||
|
it.isFile && it.extension == "kt" && inRegex.containsMatchIn(it.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Material Symbols styles whose call sites appear in the app's Kotlin sources.
|
||||||
|
*/
|
||||||
|
private fun Project.detectUsedStyles(): List<String> {
|
||||||
|
val vSrcRoot = layout.projectDirectory.dir("src").asFile
|
||||||
|
if (!vSrcRoot.exists()) return emptyList()
|
||||||
|
val vUsed = mutableSetOf<String>()
|
||||||
|
vSrcRoot.walk().filter { it.isFile && it.extension == "kt" }.forEach { vFile ->
|
||||||
|
val vText = vFile.readText()
|
||||||
|
for ((vStyle, vRegex) in kAllStyles) {
|
||||||
|
if (vStyle !in vUsed && vRegex.containsMatchIn(vText)) vUsed.add(vStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vUsed.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Font staging
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the font/ entries (Notos + used Material Symbols styles) to inTask — a data.kres
|
||||||
|
* Zip or jvmProcessResources. inSubsetTasksByStyle maps styles to hb-subset tasks whose
|
||||||
|
* output replaces the full font; pass an empty map to stage the full fonts.
|
||||||
|
*/
|
||||||
|
private fun Project.addFontEntries(
|
||||||
|
inTask: AbstractCopyTask,
|
||||||
|
inNotoFiles: List<Provider<RegularFile>>,
|
||||||
|
inDownloadNotoFonts: TaskProvider<*>?,
|
||||||
|
inUsedStyles: List<String>,
|
||||||
|
inSubsetTasksByStyle: Map<String, TaskProvider<*>>,
|
||||||
|
) {
|
||||||
|
for (vNotoFile in inNotoFiles) {
|
||||||
|
inTask.from(vNotoFile) { into("font") }
|
||||||
|
inDownloadNotoFonts?.let { inTask.dependsOn(it) }
|
||||||
|
}
|
||||||
|
val vSymbolsProject = rootProject.project(":material-symbols")
|
||||||
|
for (vStyle in inUsedStyles) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val vFontFile = vSymbolsProject.extra["iconFontFile$vStyle"] as Provider<RegularFile>
|
||||||
|
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$vStyle"] as TaskProvider<*>
|
||||||
|
val vSubsetTask = inSubsetTasksByStyle[vStyle]
|
||||||
|
if (vSubsetTask != null) {
|
||||||
|
// Subset output keeps the original filename : runtime registration unchanged.
|
||||||
|
val vOriginalName = vFontFile.get().asFile.name
|
||||||
|
inTask.from(vSubsetTask.get().outputs.files) {
|
||||||
|
into("font")
|
||||||
|
rename { vOriginalName }
|
||||||
|
}
|
||||||
|
inTask.dependsOn(vSubsetTask)
|
||||||
|
} else {
|
||||||
|
inTask.from(vFontFile) { into("font") }
|
||||||
|
}
|
||||||
|
inTask.dependsOn(vDownloadTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Icon subsetting
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the icon-subset pipeline: findMaterialSymbolsUsage scans src/ for
|
||||||
|
* MaterialSymbols.<Name> usages → usage-codepoint.txt, then one subsetMaterialSymbols<Style>
|
||||||
|
* task per used style hb-subsets that style's font to the referenced glyphs.
|
||||||
|
*/
|
||||||
|
private fun Project.registerIconSubsetPipeline(inUsedStyles: List<String>): Map<String, TaskProvider<*>> {
|
||||||
|
val vIconsBuildDir = layout.buildDirectory.dir("icons")
|
||||||
|
val vFindUsage = tasks.register<Exec>("findMaterialSymbolsUsage") {
|
||||||
|
description = "Scan src/ for MaterialSymbols.<Name> usages → usage-codepoint.txt."
|
||||||
|
val vScript = rootProject.layout.projectDirectory.file("scripts/subset-material-symbols.py").asFile
|
||||||
|
val vConstants = rootProject.project(":material-symbols").layout.projectDirectory
|
||||||
|
.file("src/commonMain/kotlin/com/compose/sdl/icons/MaterialSymbols.kt").asFile
|
||||||
|
val vUsageFile = vIconsBuildDir.get().file("usage-codepoint.txt").asFile
|
||||||
|
inputs.files(fileTree("src") { include("**/*.kt") })
|
||||||
|
inputs.file(vConstants)
|
||||||
|
outputs.file(vUsageFile)
|
||||||
|
commandLine(
|
||||||
|
"python3", vScript.absolutePath,
|
||||||
|
"--src", layout.projectDirectory.dir("src").asFile.absolutePath,
|
||||||
|
"--constants", vConstants.absolutePath,
|
||||||
|
"--out", vUsageFile.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return inUsedStyles.associateWith { vStyle -> registerSubsetTask(vStyle, vIconsBuildDir, vFindUsage) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hb-subset inStyle's downloaded TTF to the codepoints in usage-codepoint.txt, into this
|
||||||
|
* app's build/icons/. Bundles the full font instead when hb-subset is not on PATH.
|
||||||
|
*/
|
||||||
|
private fun Project.registerSubsetTask(
|
||||||
|
inStyle: String,
|
||||||
|
inIconsBuildDir: Provider<Directory>,
|
||||||
|
inFindUsage: TaskProvider<*>,
|
||||||
|
): TaskProvider<*> {
|
||||||
|
val vSymbolsProject = rootProject.project(":material-symbols")
|
||||||
|
return tasks.register("subsetMaterialSymbols$inStyle") {
|
||||||
|
description = "hb-subset the $inStyle Material Symbols font to icons actually used."
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val vInputProvider = vSymbolsProject.extra["iconFontFile$inStyle"] as Provider<RegularFile>
|
||||||
|
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
|
||||||
|
val vOut = inIconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
|
||||||
|
val vUsage = inIconsBuildDir.get().file("usage-codepoint.txt").asFile
|
||||||
|
inputs.file(vInputProvider)
|
||||||
|
inputs.file(vUsage)
|
||||||
|
outputs.file(vOut)
|
||||||
|
dependsOn(inFindUsage)
|
||||||
|
dependsOn(vDownloadTask)
|
||||||
|
doLast {
|
||||||
|
val vCodepoints = vUsage.readLines()
|
||||||
|
.filter { it.isNotBlank() && !it.startsWith("#") && it.contains("=") }
|
||||||
|
.map { it.substringAfter("=").trim().removePrefix("0x").removePrefix("0X") }
|
||||||
|
if (vCodepoints.isEmpty()) throw GradleException(
|
||||||
|
"usage-codepoint.txt has no entries : refusing to subset to an empty font."
|
||||||
|
)
|
||||||
|
val vUnicodes = vCodepoints.joinToString(",") { "U+$it" }
|
||||||
|
vOut.parentFile.mkdirs()
|
||||||
|
val vInputFile = vInputProvider.get().asFile
|
||||||
|
val vBefore = vInputFile.length()
|
||||||
|
// ProcessBuilder: project.exec isn't usable inside doLast on Gradle 9.
|
||||||
|
// hb-subset is optional : without it, bundle the full font.
|
||||||
|
val vProc = try {
|
||||||
|
ProcessBuilder(
|
||||||
|
"hb-subset",
|
||||||
|
vInputFile.absolutePath,
|
||||||
|
"-o", vOut.absolutePath,
|
||||||
|
"--unicodes=$vUnicodes",
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
vInputFile.copyTo(vOut, overwrite = true)
|
||||||
|
logger.warn(
|
||||||
|
"[subset $inStyle] hb-subset not found on PATH : bundling the full font " +
|
||||||
|
"(${vBefore / 1024}KB). Install harfbuzz to shrink it (brew install harfbuzz / " +
|
||||||
|
"pacman -S mingw-w64-x86_64-harfbuzz / apt install harfbuzz-utils)."
|
||||||
|
)
|
||||||
|
return@doLast
|
||||||
|
}
|
||||||
|
val vOutput = vProc.inputStream.bufferedReader().readText()
|
||||||
|
val vCode = vProc.waitFor()
|
||||||
|
if (vCode != 0) throw GradleException("hb-subset failed (exit $vCode):\n$vOutput")
|
||||||
|
val vAfter = vOut.length()
|
||||||
|
val vPct = if (vBefore == 0L) 0 else ((100 - 100 * vAfter / vBefore)).coerceAtLeast(0)
|
||||||
|
logger.lifecycle("[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · ${vCodepoints.size} glyphs kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
// - Show manifest properties: true
|
// - Show manifest properties: true
|
||||||
// - Show declarations: true
|
// - Show declarations: true
|
||||||
|
|
||||||
// Library unique name: <com.bitsycore.compose.sdl:components-resources>
|
// Library unique name: <com.bitsycore.compose.components:components-resources>
|
||||||
open annotation class org.jetbrains.compose.resources/ExperimentalResourceApi : kotlin/Annotation { // org.jetbrains.compose.resources/ExperimentalResourceApi|null[0]
|
open annotation class org.jetbrains.compose.resources/ExperimentalResourceApi : kotlin/Annotation { // org.jetbrains.compose.resources/ExperimentalResourceApi|null[0]
|
||||||
constructor <init>() // org.jetbrains.compose.resources/ExperimentalResourceApi.<init>|<init>(){}[0]
|
constructor <init>() // org.jetbrains.compose.resources/ExperimentalResourceApi.<init>|<init>(){}[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
// Maven artifact ships no mingwX64/linux klibs. Public API is byte-for-byte
|
// Maven artifact ships no mingwX64/linux klibs. Public API is byte-for-byte
|
||||||
// upstream (painterResource / stringResource / Font / qualifiers / Res codegen
|
// upstream (painterResource / stringResource / Font / qualifiers / Res codegen
|
||||||
// compatibility); the platform actuals are this port's: data.kres reading,
|
// compatibility); the platform actuals are this port's: data.kres reading,
|
||||||
// SDL3_image decoding, SDL locale/theme environment. Apps' JVM targets keep
|
// image decode via :ui's Skia decoder, SDL locale/theme environment. Apps' JVM
|
||||||
// using the official Maven artifact — this module is native-only.
|
// targets keep using the official Maven artifact — this module is native-only.
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
@@ -17,7 +17,6 @@ repositories {
|
|||||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||||
}
|
}
|
||||||
|
|
||||||
val useSdl3Everywhere = (findProperty("renderer") as? String) == "sdl3"
|
|
||||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -41,52 +40,11 @@ kotlin {
|
|||||||
}
|
}
|
||||||
nativeMain {
|
nativeMain {
|
||||||
kotlin.srcDir("src/vendor/native/kotlin")
|
kotlin.srcDir("src/vendor/native/kotlin")
|
||||||
}
|
// src/nativeMain/kotlin also holds the Font / Image resource actuals —
|
||||||
|
// pure project code that delegates to the project font registry
|
||||||
// ============
|
// (IconFont / NamedFont) and the :ui-graphics Skia decoder
|
||||||
// Renderer roots — same conditional wiring as :ui: only the source
|
// (decodeEncodedImageBitmap). Skiko-free, so a single native set covers
|
||||||
// sets that will actually be attached are created.
|
// every target.
|
||||||
val sdlRendererMain = create("sdlRendererMain") {
|
|
||||||
dependsOn(nativeMain.get())
|
|
||||||
kotlin.srcDir("src/vendor/sdlRenderer/kotlin")
|
|
||||||
}
|
|
||||||
if (vHostSupportsMingw) {
|
|
||||||
val sdlRendererMingwMain = create("sdlRendererMingwMain") { dependsOn(sdlRendererMain) }
|
|
||||||
get("mingwX64Main").dependsOn(sdlRendererMingwMain)
|
|
||||||
}
|
|
||||||
|
|
||||||
val macosArm64Main = get("macosArm64Main")
|
|
||||||
val linuxX64Main = get("linuxX64Main")
|
|
||||||
val linuxArm64Main = get("linuxArm64Main")
|
|
||||||
|
|
||||||
if (useSdl3Everywhere) {
|
|
||||||
val sdlRendererMacosMain = create("sdlRendererMacosMain") { dependsOn(sdlRendererMain) }
|
|
||||||
val sdlRendererLinuxMain = create("sdlRendererLinuxMain") { dependsOn(sdlRendererMain) }
|
|
||||||
macosArm64Main.dependsOn(sdlRendererMacosMain)
|
|
||||||
linuxX64Main.dependsOn(sdlRendererLinuxMain)
|
|
||||||
linuxArm64Main.dependsOn(sdlRendererLinuxMain)
|
|
||||||
} else {
|
|
||||||
// The Font/Image actuals for this module are pure project code
|
|
||||||
// (:ui's IconFont / NamedFont / decodeEncodedImageBitmap in nativeMain)
|
|
||||||
// so they work identically under either renderer. Only one renderer
|
|
||||||
// source set is attached to a given target, so pointing skikoRenderer
|
|
||||||
// at sdlRendererMain's srcDir doesn't cause duplicate actuals.
|
|
||||||
// Upstream's skikoMain vendored files here referenced Compose-Desktop
|
|
||||||
// Skia extensions (SystemFont / toComposeImageBitmap / nativeCanvas)
|
|
||||||
// that this port's :ui doesn't expose — sharing the SDL actuals sidesteps
|
|
||||||
// that gap entirely.
|
|
||||||
val skikoRendererMain = create("skikoRendererMain") {
|
|
||||||
dependsOn(nativeMain.get())
|
|
||||||
kotlin.srcDir("src/sdlRendererMain/kotlin")
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.skiko)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val skikoRendererMacosMain = create("skikoRendererMacosMain") { dependsOn(skikoRendererMain) }
|
|
||||||
val skikoRendererLinuxMain = create("skikoRendererLinuxMain") { dependsOn(skikoRendererMain) }
|
|
||||||
macosArm64Main.dependsOn(skikoRendererMacosMain)
|
|
||||||
linuxX64Main.dependsOn(skikoRendererLinuxMain)
|
|
||||||
linuxArm64Main.dependsOn(skikoRendererLinuxMain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
# mingwX64/linux variants, so the runtime library is vendored and the platform
|
# mingwX64/linux variants, so the runtime library is vendored and the platform
|
||||||
# actuals are project code:
|
# actuals are project code:
|
||||||
# src/nativeMain — ResourceReader (data.kres), getSystemEnvironment (SDL),
|
# src/nativeMain — ResourceReader (data.kres), getSystemEnvironment (SDL),
|
||||||
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser)
|
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser),
|
||||||
# src/sdlRendererMain — image decode via the :ui SDL3_image hook, SVG unsupported,
|
# and the Font/Image resource actuals: renderer-agnostic
|
||||||
# Font via the project font registry
|
# project code (image decode via the :ui-graphics Skia
|
||||||
# vendor/skikoRenderer — upstream skikoMain Skia-bound files, verbatim
|
# decoder, Font via the project font registry)
|
||||||
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
|
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
|
||||||
|
|
||||||
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
|
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
|
||||||
@@ -54,10 +54,9 @@ skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt -> src/
|
|||||||
|
|
||||||
# Skia-bound pieces — upstream's skikoMain actuals rely on Compose-Desktop Skia
|
# Skia-bound pieces — upstream's skikoMain actuals rely on Compose-Desktop Skia
|
||||||
# extensions (SystemFont / toComposeImageBitmap / nativeCanvas) this port's :ui
|
# extensions (SystemFont / toComposeImageBitmap / nativeCanvas) this port's :ui
|
||||||
# doesn't expose. Since the sdlRenderer actuals (src/sdlRendererMain/kotlin/) use
|
# doesn't expose. The module's own Font/Image actuals (src/nativeMain/kotlin/)
|
||||||
# pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap) that work
|
# use pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap)
|
||||||
# on either renderer, the module's build shares those with skikoRendererMain
|
# instead, so opt the upstream Skia-bound files out.
|
||||||
# instead of vendoring the upstream files. Opt them out of the sync.
|
|
||||||
!skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
|
!skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
|
||||||
!skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
|
!skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
|
||||||
!skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt
|
!skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ import com.compose.sdl.icons.IconFont
|
|||||||
import com.compose.sdl.text.NamedFont
|
import com.compose.sdl.text.NamedFont
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Font actuals — SDL renderer
|
// MARK: Font actuals — Skia text stack via the project font registry
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* The SDL text stack resolves fonts by FAMILY NAME through the project font
|
/** The Skia text stack resolves fonts by FAMILY NAME through the project font
|
||||||
registry (IconFont.registerIcon — the general byte-font table, not just
|
registry (IconFont.registerIcon — the general byte-font table, not just
|
||||||
icons). A font resource therefore loads its bytes through the resource
|
icons). A font resource therefore loads its bytes through the resource
|
||||||
reader, registers them under a per-resource family name, and returns a
|
reader, registers them under a per-resource family name, and returns a
|
||||||
[NamedFont] — `FontFamily(Font(Res.font.x))` then renders through the
|
[NamedFont] — `FontFamily(Font(Res.font.x))` then renders through the
|
||||||
standard project pipeline. Weight/style are carried on the NamedFont;
|
standard project pipeline. Weight/style are carried on the NamedFont;
|
||||||
variation axes apply at draw time via the renderer's axis support. */
|
variation axes apply at draw time via Skia's axis support. */
|
||||||
|
|
||||||
private val emptyFont: Font = NamedFont("res-font:<loading>")
|
private val emptyFont: Font = NamedFont("res-font:<loading>")
|
||||||
|
|
||||||
@@ -12,21 +12,20 @@ import androidx.compose.ui.unit.Density
|
|||||||
import com.compose.sdl.graphics.decodeEncodedImageBitmap
|
import com.compose.sdl.graphics.decodeEncodedImageBitmap
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Image actuals — SDL renderer
|
// MARK: Image actuals — Skia decode via the :ui-graphics hook
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Decoding goes through the :ui hook the SDL backend registers at init
|
/** Decoding goes through the :ui-graphics Skia hook the SDL backend registers
|
||||||
(SDL3_image, IMG auto-detect). resourceDensity/targetDensity are ignored:
|
at init (decodeEncodedImageBitmap). resourceDensity/targetDensity are ignored:
|
||||||
under this port's Option-B density flow layout runs in physical pixels and
|
under this port's Option-B density flow layout runs in physical pixels and
|
||||||
drawables ship at a single density, so no decode-time rescale applies. */
|
drawables ship at a single density, so no decode-time rescale applies. */
|
||||||
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
|
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
|
||||||
decodeEncodedImageBitmap(this)
|
decodeEncodedImageBitmap(this)
|
||||||
?: error("Image decode failed — is the SDL renderer initialised before painterResource ran?")
|
?: error("Image decode failed — is the render backend initialised before painterResource ran?")
|
||||||
|
|
||||||
/* SVG on SDL: SDL3_image rasterises SVG at its intrinsic size (IMG_LoadSVG),
|
/** SVG: Skia rasterises it at its intrinsic size, so the "element" is just the
|
||||||
so the "element" is just the raw bytes and the painter is the rasterised
|
raw bytes and the painter is the rasterised bitmap. No DOM, no vector scaling —
|
||||||
bitmap. No DOM, no vector scaling — prefer XML vector drawables for
|
prefer XML vector drawables for resolution-independent art. */
|
||||||
resolution-independent art on this renderer. */
|
|
||||||
internal actual class SvgElement(val bytes: ByteArray)
|
internal actual class SvgElement(val bytes: ByteArray)
|
||||||
|
|
||||||
internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
||||||
@@ -34,5 +33,5 @@ internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
|||||||
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
|
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
|
||||||
BitmapPainter(
|
BitmapPainter(
|
||||||
decodeEncodedImageBitmap(bytes)
|
decodeEncodedImageBitmap(bytes)
|
||||||
?: error("SVG rasterisation failed — is the SDL renderer initialised before painterResource ran?")
|
?: error("SVG rasterisation failed — is the render backend initialised before painterResource ran?")
|
||||||
)
|
)
|
||||||
@@ -4,46 +4,24 @@ package org.jetbrains.compose.resources
|
|||||||
|
|
||||||
import org.jetbrains.compose.resources.InternalResourceApi
|
import org.jetbrains.compose.resources.InternalResourceApi
|
||||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||||
|
import com.compose.sdl.res.preferredLocaleProvider
|
||||||
import kotlinx.cinterop.alloc
|
import com.compose.sdl.res.systemThemeIsDarkProvider
|
||||||
import kotlinx.cinterop.get
|
|
||||||
import kotlinx.cinterop.memScoped
|
|
||||||
import kotlinx.cinterop.pointed
|
|
||||||
import kotlinx.cinterop.ptr
|
|
||||||
import kotlinx.cinterop.toKString
|
|
||||||
import kotlinx.cinterop.IntVar
|
|
||||||
import kotlinx.cinterop.value
|
|
||||||
import sdl3.SDL_GetPreferredLocales
|
|
||||||
import sdl3.SDL_GetSystemTheme
|
|
||||||
import sdl3.SDL_SystemTheme
|
|
||||||
import sdl3.SDL_free
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: ResourceEnvironment — SDL actual
|
// MARK: ResourceEnvironment — platform-env-seam actual
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Non-composable system environment for qualifier resolution (values-fr,
|
/** Non-composable system environment for qualifier resolution (values-fr,
|
||||||
drawable-dark, …): locale from SDL_GetPreferredLocales, theme from
|
drawable-dark, …): locale + theme come from the platform-env seams installed
|
||||||
SDL_GetSystemTheme. Density is reported as 1f — under this port's Option-B
|
by the SDL layer, so :components-resources carries no dependency on the sdl3
|
||||||
density flow layout runs in physical pixels and drawables are bundled at a
|
cinterop. Density is reported as 1f — under this port's Option-B density flow
|
||||||
single density, so the mdpi bucket is always the right one. The COMPOSABLE
|
layout runs in physical pixels and drawables are bundled at a single density,
|
||||||
path (rememberResourceEnvironment) doesn't use this: it reads
|
so the mdpi bucket is always the right one. The COMPOSABLE path
|
||||||
LocalDensity / isSystemInDarkTheme from the composition. */
|
(rememberResourceEnvironment) doesn't use this: it reads LocalDensity /
|
||||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
isSystemInDarkTheme from the composition. */
|
||||||
internal actual fun getSystemEnvironment(): ResourceEnvironment {
|
internal actual fun getSystemEnvironment(): ResourceEnvironment {
|
||||||
var vLanguage = ""
|
val (vLanguage, vRegion) = preferredLocaleProvider?.invoke() ?: ("" to "")
|
||||||
var vRegion = ""
|
val vDark = systemThemeIsDarkProvider?.invoke() ?: false
|
||||||
memScoped {
|
|
||||||
val vCount = alloc<IntVar>()
|
|
||||||
val vLocales = SDL_GetPreferredLocales(vCount.ptr)
|
|
||||||
if (vLocales != null && vCount.value > 0) {
|
|
||||||
val vFirst = vLocales[0]?.pointed
|
|
||||||
vLanguage = vFirst?.language?.toKString() ?: ""
|
|
||||||
vRegion = vFirst?.country?.toKString() ?: ""
|
|
||||||
SDL_free(vLocales)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val vDark = SDL_GetSystemTheme() == SDL_SystemTheme.SDL_SYSTEM_THEME_DARK
|
|
||||||
return ResourceEnvironment(
|
return ResourceEnvironment(
|
||||||
language = LanguageQualifier(vLanguage),
|
language = LanguageQualifier(vLanguage),
|
||||||
// SDL locales carry language+country only — no script subtag.
|
// SDL locales carry language+country only — no script subtag.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import com.compose.sdl.loadComposeResourceBytes
|
|||||||
// MARK: ResourceReader — data.kres actual
|
// MARK: ResourceReader — data.kres actual
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* The platform reader for this port: every app ships its composeResources
|
/** The platform reader for this port: every app ships its composeResources
|
||||||
content inside data.kres (a STORED zip next to the executable — see the
|
content inside data.kres (a STORED zip next to the executable — see the
|
||||||
apps' Zip tasks), and :ui's ResourceIO serves entries by exact path with an
|
apps' Zip tasks), and :ui's ResourceIO serves entries by exact path with an
|
||||||
fseek+fread. The paths the generated Res accessors produce
|
fseek+fread. The paths the generated Res accessors produce
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ package org.jetbrains.compose.resources.vector.xmldom
|
|||||||
CDATA skipping, and the five predefined entities plus numeric character
|
CDATA skipping, and the five predefined entities plus numeric character
|
||||||
references. No DTD expansion, no processing-instruction handling beyond
|
references. No DTD expansion, no processing-instruction handling beyond
|
||||||
skipping — malformed input throws MalformedXMLException like upstream. */
|
skipping — malformed input throws MalformedXMLException like upstream. */
|
||||||
|
// VENDOR-BASE(COMPOSE_REF): components/resources/library/src/nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt @ v1.12.0-beta03+dev4483
|
||||||
|
// (fresh REIMPL, not a copy-edit — the base ref marks the upstream API it tracks)
|
||||||
|
|
||||||
internal fun parse(xml: String): Element {
|
internal fun parse(xml: String): Element {
|
||||||
val vParser = XmlDomParser(xml)
|
val vParser = XmlDomParser(xml)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// - Show manifest properties: true
|
// - Show manifest properties: true
|
||||||
// - Show declarations: true
|
// - Show declarations: true
|
||||||
|
|
||||||
// Library unique name: <com.bitsycore.compose.sdl:animation-core>
|
// Library unique name: <com.bitsycore.compose.animation:animation-core>
|
||||||
open annotation class androidx.compose.animation.core/ExperimentalAnimationSpecApi : kotlin/Annotation { // androidx.compose.animation.core/ExperimentalAnimationSpecApi|null[0]
|
open annotation class androidx.compose.animation.core/ExperimentalAnimationSpecApi : kotlin/Annotation { // androidx.compose.animation.core/ExperimentalAnimationSpecApi|null[0]
|
||||||
constructor <init>() // androidx.compose.animation.core/ExperimentalAnimationSpecApi.<init>|<init>(){}[0]
|
constructor <init>() // androidx.compose.animation.core/ExperimentalAnimationSpecApi.<init>|<init>(){}[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
|||||||
|
|
||||||
// :animation-core — androidx.compose.animation.core.* vendored VERBATIM from
|
// :animation-core — androidx.compose.animation.core.* vendored VERBATIM from
|
||||||
// upstream. Renderer-agnostic (spring/tween specs, animatable, transition,
|
// upstream. Renderer-agnostic (spring/tween specs, animatable, transition,
|
||||||
// vector math). Split out of :core so :core stays about renderer+ui glue only.
|
// vector math). Split out of :ui so :ui stays about renderer+ui glue only.
|
||||||
//
|
//
|
||||||
// Provenance = animation-core/compose-fork.txt + scripts/compose-fork/compose.properties.
|
// Provenance = animation-core/compose-fork.txt + scripts/compose-fork/compose.properties.
|
||||||
// Never hand-edit files under animation-core/src/vendor/ — change the manifest and
|
// Never hand-edit files under animation-core/src/vendor/ — change the manifest and
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// - Show manifest properties: true
|
// - Show manifest properties: true
|
||||||
// - Show declarations: true
|
// - Show declarations: true
|
||||||
|
|
||||||
// Library unique name: <com.bitsycore.compose.sdl:animation-graphics>
|
// Library unique name: <com.bitsycore.compose.animation:animation-graphics>
|
||||||
open annotation class androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi : kotlin/Annotation { // androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi|null[0]
|
open annotation class androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi : kotlin/Annotation { // androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi|null[0]
|
||||||
constructor <init>() // androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi.<init>|<init>(){}[0]
|
constructor <init>() // androidx.compose.animation.graphics/ExperimentalAnimationGraphicsApi.<init>|<init>(){}[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// - Show manifest properties: true
|
// - Show manifest properties: true
|
||||||
// - Show declarations: true
|
// - Show declarations: true
|
||||||
|
|
||||||
// Library unique name: <com.bitsycore.compose.sdl:animation>
|
// Library unique name: <com.bitsycore.compose.animation:animation>
|
||||||
open annotation class androidx.compose.animation/ExperimentalAnimationApi : kotlin/Annotation { // androidx.compose.animation/ExperimentalAnimationApi|null[0]
|
open annotation class androidx.compose.animation/ExperimentalAnimationApi : kotlin/Annotation { // androidx.compose.animation/ExperimentalAnimationApi|null[0]
|
||||||
constructor <init>() // androidx.compose.animation/ExperimentalAnimationApi.<init>|<init>(){}[0]
|
constructor <init>() // androidx.compose.animation/ExperimentalAnimationApi.<init>|<init>(){}[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
// - Show manifest properties: true
|
// - Show manifest properties: true
|
||||||
// - Show declarations: true
|
// - Show declarations: true
|
||||||
|
|
||||||
// Library unique name: <com.bitsycore.compose.native:window>
|
// Library unique name: <com.bitsycore.compose:desktop-native-window>
|
||||||
final fun com.compose.desktop.native/nativeComposeWindow(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.desktop.native/GpuMode = ..., kotlin/Function2<com.compose.desktop.native/RenderBackend, kotlin/Int, kotlin/Boolean>? = ..., kotlin/Function3<com.compose.desktop.native/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.desktop.native/nativeComposeWindow|nativeComposeWindow(kotlin.String;kotlin.Int;kotlin.Int;com.compose.desktop.native.GpuMode;kotlin.Function2<com.compose.desktop.native.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.desktop.native.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
|
final fun com.compose.desktop.native/nativeComposeWindow(kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., com.compose.desktop.native/GpuMode = ..., kotlin/Function2<com.compose.desktop.native/RenderBackend, kotlin/Int, kotlin/Boolean>? = ..., kotlin/Function3<com.compose.desktop.native/ComposeWindowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>) // com.compose.desktop.native/nativeComposeWindow|nativeComposeWindow(kotlin.String;kotlin.Int;kotlin.Int;com.compose.desktop.native.GpuMode;kotlin.Function2<com.compose.desktop.native.RenderBackend,kotlin.Int,kotlin.Boolean>?;kotlin.Function3<com.compose.desktop.native.ComposeWindowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>){}[0]
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// :window — the module apps depend on. Owns nativeComposeWindow() (main loop,
|
// :desktop-native-window — the module apps depend on. Owns nativeComposeWindow() (main loop,
|
||||||
// recomposer lifecycle, event dispatch, Snapshot apply notifications).
|
// recomposer lifecycle, event dispatch, Snapshot apply notifications).
|
||||||
// Renderer selection happens entirely inside :core via source-set wiring
|
// Renderer selection happens entirely inside :ui via source-set wiring
|
||||||
// (skikoRendererMain vs sdlRendererMain) — this module just calls
|
// (skikoRendererMain — Skia-only) — this module just calls
|
||||||
// `createRenderBackend(...)` and `rendererPreferredGpuMode()` from :core and
|
// `createRenderBackend(...)` and `rendererPreferredGpuMode()` from :ui and
|
||||||
// the right symbol resolves per target.
|
// the right symbol resolves per target.
|
||||||
// Publication artifactId (when set up): compose-desktop-native.
|
// Publication artifactId (when set up): compose-desktop-native.
|
||||||
|
|
||||||
@@ -23,15 +23,15 @@ kotlin {
|
|||||||
|
|
||||||
applyDefaultHierarchyTemplate()
|
applyDefaultHierarchyTemplate()
|
||||||
|
|
||||||
// sdl3.* types are api-exposed via :ui's cinterop klib — no separate
|
// sdl3.* types are api-exposed via :ui → :sdl-core's cinterop klib — no
|
||||||
// sdl3 cinterop here.
|
// separate sdl3 cinterop here.
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// api so apps depending on :window also get the compose re-impl,
|
// api so apps depending on :desktop-native-window also get the compose re-impl,
|
||||||
// Res/resources, GpuMode, and the renderer pipeline from :core, plus
|
// Res/resources, GpuMode, and the renderer pipeline from :ui, plus
|
||||||
// the foundation / animation-core / animation modules that were split
|
// the foundation / animation-core / animation modules that were split
|
||||||
// out of :core (upstream Compose layout).
|
// out of :ui (upstream Compose layout).
|
||||||
// Material widgets used to be re-exported from :material here; the
|
// Material widgets used to be re-exported from :material here; the
|
||||||
// module was retired when :apidemo and :demo migrated to :material3.
|
// module was retired when :apidemo and :demo migrated to :material3.
|
||||||
// Apps that want Material 3 widgets pull `implementation(project(":material3"))`
|
// Apps that want Material 3 widgets pull `implementation(project(":material3"))`
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.compose.sdl
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: AppWindowIcon
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window / taskbar icon for a [Window] / [nativeComposeWindow], resolved
|
||||||
|
* against the OS theme: the [dark] set is used when the system is in dark mode
|
||||||
|
* (and it is non-empty), otherwise [light].
|
||||||
|
*
|
||||||
|
* Each list holds data.kres resource paths of PRE-DECODED `.rgba` icon blobs
|
||||||
|
* (produced by the bridge plugin's `icon { }` packaging, or by
|
||||||
|
* `scripts/make-app-icon.py rgba` — i.e. an 8-byte
|
||||||
|
* `[width u32-le][height u32-le]` header + straight-alpha RGBA pixels). List
|
||||||
|
* one path per size you bundle; the largest becomes the base and the rest are
|
||||||
|
* attached as alternate resolutions SDL chooses from (title bar vs taskbar vs
|
||||||
|
* Alt-Tab), so the icon stays crisp at every size.
|
||||||
|
*
|
||||||
|
* On Windows the runtime icon set here complements the icon EMBEDDED in the
|
||||||
|
* `.exe` (Explorer / pinned taskbar) — see the app / bridge-plugin Gradle setup.
|
||||||
|
*/
|
||||||
|
class AppWindowIcon(
|
||||||
|
val light: List<String>,
|
||||||
|
val dark: List<String> = light,
|
||||||
|
)
|
||||||
@@ -25,16 +25,21 @@ import androidx.compose.ui.input.pointer.PointerEventType
|
|||||||
import androidx.lifecycle.enableSavedStateHandles
|
import androidx.lifecycle.enableSavedStateHandles
|
||||||
import com.compose.sdl.node.ComposeRootHost
|
import com.compose.sdl.node.ComposeRootHost
|
||||||
import com.compose.sdl.res.currentImageLoader
|
import com.compose.sdl.res.currentImageLoader
|
||||||
import com.compose.sdl.text.currentTextMeasurer
|
|
||||||
import com.compose.sdl.window.LocalPopupHost
|
import com.compose.sdl.window.LocalPopupHost
|
||||||
import com.compose.sdl.window.PopupLayer
|
import com.compose.sdl.window.PopupLayer
|
||||||
import com.compose.sdl.window.createPopupHostState
|
import com.compose.sdl.window.createPopupHostState
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.cinterop.toKString
|
||||||
import kotlinx.cinterop.reinterpret
|
import kotlinx.cinterop.reinterpret
|
||||||
|
import kotlinx.cinterop.alloc
|
||||||
|
import kotlinx.cinterop.ptr
|
||||||
import sdl3.SDL_Delay
|
import sdl3.SDL_Delay
|
||||||
|
import sdl3.SDL_GetPerformanceCounter
|
||||||
|
import sdl3.SDL_GetPerformanceFrequency
|
||||||
import sdl3.SDL_GetTicks
|
import sdl3.SDL_GetTicks
|
||||||
|
import sdl3.SDL_GetTicksNS
|
||||||
import sdl3.SDL_Quit
|
import sdl3.SDL_Quit
|
||||||
import sdl3.SDL_SetWindowTitle
|
import sdl3.SDL_SetWindowTitle
|
||||||
import sdl3.SDL_WaitEventTimeout
|
import sdl3.SDL_WaitEventTimeout
|
||||||
@@ -43,7 +48,7 @@ import sdl3.SDL_WaitEventTimeout
|
|||||||
// MARK: Application entry — nativeComposeApp + Window()
|
// MARK: Application entry — nativeComposeApp + Window()
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Multi-window entry point, shaped like Compose Desktop's `application {}`:
|
Multi-window entry point, shaped like Compose Desktop's `application {}`:
|
||||||
|
|
||||||
nativeComposeApp {
|
nativeComposeApp {
|
||||||
@@ -59,7 +64,7 @@ import sdl3.SDL_WaitEventTimeout
|
|||||||
routes events per SDL window id.
|
routes events per SDL window id.
|
||||||
|
|
||||||
Per-window pumping order matters: the render-bridge globals
|
Per-window pumping order matters: the render-bridge globals
|
||||||
(currentTextMeasurer / currentImageLoader / viewport) are per-renderer, so the
|
(currentImageLoader / viewport) are per-renderer, so the
|
||||||
loop installs a window's globals before recomposing / laying out / drawing it.
|
loop installs a window's globals before recomposing / laying out / drawing it.
|
||||||
|
|
||||||
`nativeComposeWindow(...)` remains as the single-window wrapper (all demo
|
`nativeComposeWindow(...)` remains as the single-window wrapper (all demo
|
||||||
@@ -67,12 +72,12 @@ import sdl3.SDL_WaitEventTimeout
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
interface ApplicationScope {
|
interface ApplicationScope {
|
||||||
/* Requests the main loop to exit after the current iteration (all windows
|
/** Requests the main loop to exit after the current iteration (all windows
|
||||||
are torn down on the way out). */
|
are torn down on the way out). */
|
||||||
fun exitApplication()
|
fun exitApplication()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Declares one native window for as long as this composable stays in the app
|
/** Declares one native window for as long as this composable stays in the app
|
||||||
composition. `onCloseRequest` fires when the user asks the window to close
|
composition. `onCloseRequest` fires when the user asks the window to close
|
||||||
(OS close button, or `window.close()` from content) — remove the state that
|
(OS close button, or `window.close()` from content) — remove the state that
|
||||||
composes this Window to actually close it, or call exitApplication(). */
|
composes this Window to actually close it, or call exitApplication(). */
|
||||||
@@ -83,6 +88,7 @@ fun ApplicationScope.Window(
|
|||||||
width: Int = 800,
|
width: Int = 800,
|
||||||
height: Int = 600,
|
height: Int = 600,
|
||||||
gpu: GpuMode = GpuMode.Auto,
|
gpu: GpuMode = GpuMode.Auto,
|
||||||
|
icon: AppWindowIcon? = null,
|
||||||
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
|
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
|
||||||
content: @Composable ComposeWindowScope.() -> Unit,
|
content: @Composable ComposeWindowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -96,6 +102,7 @@ fun ApplicationScope.Window(
|
|||||||
inWidth = width,
|
inWidth = width,
|
||||||
inHeight = height,
|
inHeight = height,
|
||||||
inGpu = gpu,
|
inGpu = gpu,
|
||||||
|
inIcon = icon,
|
||||||
inOnFrame = onFrame,
|
inOnFrame = onFrame,
|
||||||
// The window composition reads the holder each recomposition, so
|
// The window composition reads the holder each recomposition, so
|
||||||
// updated content lambdas propagate without recreating the window.
|
// updated content lambdas propagate without recreating the window.
|
||||||
@@ -110,7 +117,7 @@ fun ApplicationScope.Window(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Boots SDL + the Compose runtime, runs `content` as the application
|
/** Boots SDL + the Compose runtime, runs `content` as the application
|
||||||
composition, and drives every declared Window until exitApplication() or
|
composition, and drives every declared Window until exitApplication() or
|
||||||
the last window closes. */
|
the last window closes. */
|
||||||
fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||||
@@ -141,12 +148,18 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
|
|
||||||
appComposition.setContent { appScope.content() }
|
appComposition.setContent { appScope.content() }
|
||||||
// Compose the initial Window() declarations before entering the loop.
|
// Compose the initial Window() declarations before entering the loop.
|
||||||
appClock.sendFrame()
|
appClock.sendFrame(frameClockNanos())
|
||||||
yield()
|
yield()
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Main loop
|
// Main loop
|
||||||
|
var vGcLastTicks = SDL_GetTicks()
|
||||||
|
var vRenderedSinceGc = false
|
||||||
while (!runtime.exitRequested) {
|
while (!runtime.exitRequested) {
|
||||||
|
FrameProfiler.mark()
|
||||||
|
// One virtual 16.6ms tick per loop iteration (no-op unless useVirtualFrameTime) —
|
||||||
|
// all windows' clocks share the same timestamp this iteration.
|
||||||
|
advanceVirtualFrame()
|
||||||
Snapshot.sendApplyNotifications()
|
Snapshot.sendApplyNotifications()
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
@@ -164,21 +177,25 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
is AppEvent.WindowClose -> runtime.windowFor(vEvent.windowId)?.requestClose()
|
is AppEvent.WindowClose -> runtime.windowFor(vEvent.windowId)?.requestClose()
|
||||||
is AppEvent.WindowResized -> runtime.windowFor(vEvent.windowId)?.onResizedEvent()
|
is AppEvent.WindowResized -> runtime.windowFor(vEvent.windowId)?.onResizedEvent()
|
||||||
is AppEvent.RedrawNeeded -> runtime.windowFor(vEvent.windowId)?.let { it.needsFrame = true }
|
is AppEvent.RedrawNeeded -> runtime.windowFor(vEvent.windowId)?.let { it.needsFrame = true }
|
||||||
|
is AppEvent.SystemThemeChanged -> for (vW in runtime.windows) vW.onSystemThemeChanged()
|
||||||
is AppEvent.WindowActivation -> runtime.windowFor(vEvent.windowId)?.onActivationEvent(vEvent)
|
is AppEvent.WindowActivation -> runtime.windowFor(vEvent.windowId)?.onActivationEvent(vEvent)
|
||||||
is AppEvent.Pointer -> runtime.windowFor(vEvent.windowId)?.onPointerEvent(vEvent)
|
is AppEvent.Pointer -> runtime.windowFor(vEvent.windowId)?.onPointerEvent(vEvent)
|
||||||
|
is AppEvent.PointerExit -> runtime.windowFor(vEvent.windowId)?.onPointerExit()
|
||||||
is AppEvent.MouseWheel -> runtime.windowFor(vEvent.windowId)?.onWheelEvent(vEvent)
|
is AppEvent.MouseWheel -> runtime.windowFor(vEvent.windowId)?.onWheelEvent(vEvent)
|
||||||
is AppEvent.Key -> runtime.windowFor(vEvent.windowId)?.onKeyEvent(vEvent)
|
is AppEvent.Key -> runtime.windowFor(vEvent.windowId)?.onKeyEvent(vEvent)
|
||||||
is AppEvent.TextInput -> runtime.windowFor(vEvent.windowId)?.onTextInputEvent(vEvent)
|
is AppEvent.TextInput -> runtime.windowFor(vEvent.windowId)?.onTextInputEvent(vEvent)
|
||||||
|
is AppEvent.TextEditing -> runtime.windowFor(vEvent.windowId)?.onTextEditingEvent(vEvent)
|
||||||
is AppEvent.Drop -> runtime.windowFor(vEvent.windowId)?.onDropEvent(vEvent)
|
is AppEvent.Drop -> runtime.windowFor(vEvent.windowId)?.onDropEvent(vEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mainDispatcher.drainPending()
|
mainDispatcher.drainPending()
|
||||||
|
FrameProfiler.phase("events")
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// App composition pump — Window()s may appear / disappear here.
|
// App composition pump — Window()s may appear / disappear here.
|
||||||
Snapshot.sendApplyNotifications()
|
Snapshot.sendApplyNotifications()
|
||||||
appClock.sendFrame()
|
appClock.sendFrame(frameClockNanos())
|
||||||
yield()
|
yield()
|
||||||
runtime.reapDestroyed()
|
runtime.reapDestroyed()
|
||||||
|
|
||||||
@@ -193,6 +210,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
// Exit when the last window is gone (after at least one existed).
|
// Exit when the last window is gone (after at least one existed).
|
||||||
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
|
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
|
||||||
if (runtime.exitRequested) break
|
if (runtime.exitRequested) break
|
||||||
|
FrameProfiler.phase("app")
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Per-window pump + render.
|
// Per-window pump + render.
|
||||||
@@ -201,18 +219,29 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
val vAppPending = appRecomposer.hasPendingWork
|
val vAppPending = appRecomposer.hasPendingWork
|
||||||
for (vW in runtime.windows.toList()) {
|
for (vW in runtime.windows.toList()) {
|
||||||
vW.installGlobals()
|
vW.installGlobals()
|
||||||
vW.host.sendAnimationFrame(SDL_GetTicks().toLong() * 1_000_000L)
|
vW.host.sendAnimationFrame(animationClockNanos())
|
||||||
mainDispatcher.drainPending()
|
mainDispatcher.drainPending()
|
||||||
Snapshot.sendApplyNotifications()
|
Snapshot.sendApplyNotifications()
|
||||||
vW.frameClock.sendFrame()
|
vW.frameClock.sendFrame(frameClockNanos())
|
||||||
yield()
|
yield()
|
||||||
|
FrameProfiler.phase("pump")
|
||||||
if (vW.shouldRender()) {
|
if (vW.shouldRender()) {
|
||||||
vW.renderFrame()
|
vW.renderFrame()
|
||||||
vAnyRendered = true
|
vAnyRendered = true
|
||||||
if (!vW.backend.vsyncEnabled) vAllVsync = false
|
if (!vW.backend.vsyncEnabled) vAllVsync = false
|
||||||
}
|
}
|
||||||
|
FrameProfiler.phase("render")
|
||||||
}
|
}
|
||||||
runtime.reapDestroyed()
|
runtime.reapDestroyed()
|
||||||
|
FrameProfiler.frameDone(vAnyRendered)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Drain deferred native-resource disposals on the MAIN thread —
|
||||||
|
// textures/surfaces whose owner closed them or whose Cleaner fired
|
||||||
|
// on a GC worker enqueue here (SDL calls aren't thread-safe). This
|
||||||
|
// is the ownership path that makes the GC nudge below a mere
|
||||||
|
// backstop (ROADMAP.md item 1).
|
||||||
|
com.compose.sdl.graphics.NativeReleaseQueue.drain()
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Pace / idle-skip.
|
// Pace / idle-skip.
|
||||||
@@ -224,6 +253,23 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
SDL_WaitEventTimeout(null, 10)
|
SDL_WaitEventTimeout(null, 10)
|
||||||
for (vW in runtime.windows) vW.resetFpsWindow()
|
for (vW in runtime.windows) vW.resetFpsWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Native-memory nudge. Renderer resources (Skia surfaces / images /
|
||||||
|
// fonts, SDL textures) are freed by Cleaners that only run when the
|
||||||
|
// Kotlin/Native GC collects — and a Compose app's Kotlin heap is
|
||||||
|
// small enough that the allocation-driven scheduler can starve them
|
||||||
|
// for minutes while the NATIVE heap balloons (issue #2: memory
|
||||||
|
// "never released" while navigating). Collect periodically, only
|
||||||
|
// after rendering activity, between frames; costs ~ms at these
|
||||||
|
// heap sizes and keeps RSS tracking real usage.
|
||||||
|
if (vAnyRendered) vRenderedSinceGc = true
|
||||||
|
val vNowTicks = SDL_GetTicks()
|
||||||
|
if (vRenderedSinceGc && vNowTicks - vGcLastTicks >= 10_000uL) {
|
||||||
|
vGcLastTicks = vNowTicks
|
||||||
|
vRenderedSinceGc = false
|
||||||
|
collectNativeGarbage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
@@ -231,6 +277,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
snapshotHandle.dispose()
|
snapshotHandle.dispose()
|
||||||
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
|
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
|
||||||
runtime.reapDestroyed()
|
runtime.reapDestroyed()
|
||||||
|
com.compose.sdl.graphics.NativeReleaseQueue.drain()
|
||||||
appComposition.dispose()
|
appComposition.dispose()
|
||||||
appRecomposer.cancel()
|
appRecomposer.cancel()
|
||||||
appRecomposeJob.cancelAndJoin()
|
appRecomposeJob.cancelAndJoin()
|
||||||
@@ -243,13 +290,143 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
SDL_Quit()
|
SDL_Quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Single-window compatibility wrapper — the pre-multi-window entry point.
|
/** CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
|
||||||
|
A named-phase SINGLETON so both the main loop (events / app / pump / render)
|
||||||
|
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
|
||||||
|
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
|
||||||
|
the elapsed since the last mark/phase to that name. Measure first, optimize
|
||||||
|
second — see ROADMAP.md. */
|
||||||
|
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||||
|
internal object FrameProfiler {
|
||||||
|
// null until first checked; then true/false for the run's lifetime.
|
||||||
|
private var fEnabled: Boolean? = null
|
||||||
|
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
|
||||||
|
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
|
||||||
|
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
|
||||||
|
// the cwd; CDN_PROFILE=<path> → that path.
|
||||||
|
private var fPath: String = "cdn_profile.log"
|
||||||
|
val enabled: Boolean
|
||||||
|
get() = fEnabled ?: run {
|
||||||
|
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
|
||||||
|
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
|
||||||
|
(vEnv != null).also { fEnabled = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||||
|
// Insertion-ordered so the printed line follows the call order.
|
||||||
|
private val fSum = LinkedHashMap<String, Double>()
|
||||||
|
private val fMax = LinkedHashMap<String, Double>()
|
||||||
|
private var fFrames = 0
|
||||||
|
private var fLastPrintMs = SDL_GetTicks()
|
||||||
|
private var fMark = 0uL
|
||||||
|
|
||||||
|
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
|
||||||
|
|
||||||
|
fun phase(inName: String) {
|
||||||
|
if (!enabled) return
|
||||||
|
val vNow = SDL_GetPerformanceCounter()
|
||||||
|
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
|
||||||
|
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
|
||||||
|
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
|
||||||
|
fMark = vNow
|
||||||
|
}
|
||||||
|
|
||||||
|
fun frameDone(inRendered: Boolean) {
|
||||||
|
if (!enabled) return
|
||||||
|
if (inRendered) fFrames++
|
||||||
|
val vNowMs = SDL_GetTicks()
|
||||||
|
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
|
||||||
|
val vParts = fSum.keys.map { vName ->
|
||||||
|
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
||||||
|
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
||||||
|
}
|
||||||
|
// Per-frame draw-work averages (see DrawStats): what's inside `draw`.
|
||||||
|
val vStats = com.compose.sdl.graphics.DrawStats
|
||||||
|
val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
|
||||||
|
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
|
||||||
|
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
|
||||||
|
val vFile = platform.posix.fopen(fPath, "a")
|
||||||
|
if (vFile != null) {
|
||||||
|
platform.posix.fputs(vLine, vFile)
|
||||||
|
platform.posix.fclose(vFile)
|
||||||
|
}
|
||||||
|
vStats.reset()
|
||||||
|
fSum.clear(); fMax.clear()
|
||||||
|
fFrames = 0
|
||||||
|
fLastPrintMs = vNowMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CDN_FORCERENDER=1 forces every frame to render so sustained steady-state
|
||||||
|
timings can be measured on otherwise-idle screens (see TOOLING.md, frame
|
||||||
|
profiler). */
|
||||||
|
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Probe / screenshot support (render-to-quiescence)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
|
||||||
|
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
|
||||||
|
rememberInfiniteTransition & co. freeze at their initial value — the same mechanism
|
||||||
|
upstream's test rules use. Screenshot/parity runs enable it so looping screens can
|
||||||
|
reach quiescence and capture deterministically. */
|
||||||
|
var disableInfiniteAnimations: Boolean = false
|
||||||
|
|
||||||
|
/** The cancelling policy: a coroutine that ends in CancellationException counts as
|
||||||
|
cancelled (not failed), so only the animation coroutine stops — nothing propagates. */
|
||||||
|
private object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.InfiniteAnimationPolicy {
|
||||||
|
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
|
||||||
|
throw CancellationException("infinite animations are disabled (disableInfiniteAnimations)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set BEFORE nativeComposeApp/nativeComposeWindow: the composition + animation frame
|
||||||
|
clocks advance a VIRTUAL 16.6ms per main-loop iteration instead of reading SDL's
|
||||||
|
real-time ticks — the native mirror of the JVM parity leg's render(nanos) stepping.
|
||||||
|
Animations then progress by exact per-frame deltas, so anything time-raced (e.g. a
|
||||||
|
bring-into-view scroll interrupted mid-flight) resolves identically on every run and
|
||||||
|
screenshots become deterministic. Input timestamps and FPS stay on real time. */
|
||||||
|
var useVirtualFrameTime: Boolean = false
|
||||||
|
|
||||||
|
private var virtualFrameNanos = 0L
|
||||||
|
|
||||||
|
private fun advanceVirtualFrame() {
|
||||||
|
if (useVirtualFrameTime) virtualFrameNanos += 16_666_667L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp for the composition frame clocks (recomposer + withFrameNanos animations).
|
||||||
|
private fun frameClockNanos(): Long =
|
||||||
|
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicksNS().toLong()
|
||||||
|
|
||||||
|
// Timestamp for the owner's node-animation clock — real path keeps the pre-existing
|
||||||
|
// ms-resolution SDL_GetTicks base so non-screenshot behaviour is bit-for-bit unchanged.
|
||||||
|
private fun animationClockNanos(): Long =
|
||||||
|
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicks().toLong() * 1_000_000L
|
||||||
|
|
||||||
|
// The window currently inside renderFrame — the loop is single-threaded, so a plain
|
||||||
|
// var is enough for onFrame probes to address "the window I'm being called for".
|
||||||
|
private var renderingWindow: WindowInstance? = null
|
||||||
|
|
||||||
|
/** From inside an onFrame callback: true while the just-rendered window still has pending
|
||||||
|
work (recomposition, layout/draw invalidation, or an animation awaiting the next frame).
|
||||||
|
Screenshot probes capture once this stays false for a few consecutive frames instead of
|
||||||
|
at a fixed frame count. Outside onFrame it answers false. */
|
||||||
|
fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: false
|
||||||
|
|
||||||
|
/** Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
|
||||||
|
their native memory (see the main loop's native-memory nudge). */
|
||||||
|
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
|
||||||
|
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
|
||||||
|
|
||||||
|
/** Single-window compatibility wrapper — the pre-multi-window entry point.
|
||||||
Closing the window exits the app, exactly as before. */
|
Closing the window exits the app, exactly as before. */
|
||||||
fun nativeComposeWindow(
|
fun nativeComposeWindow(
|
||||||
title: String = "ComposeNativeSDL3",
|
title: String = "ComposeNativeSDL3",
|
||||||
width: Int = 800,
|
width: Int = 800,
|
||||||
height: Int = 600,
|
height: Int = 600,
|
||||||
gpu: GpuMode = GpuMode.Auto,
|
gpu: GpuMode = GpuMode.Auto,
|
||||||
|
icon: AppWindowIcon? = null,
|
||||||
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
|
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
|
||||||
content: @Composable ComposeWindowScope.() -> Unit,
|
content: @Composable ComposeWindowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -260,6 +437,7 @@ fun nativeComposeWindow(
|
|||||||
width = width,
|
width = width,
|
||||||
height = height,
|
height = height,
|
||||||
gpu = gpu,
|
gpu = gpu,
|
||||||
|
icon = icon,
|
||||||
onFrame = onFrame,
|
onFrame = onFrame,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
@@ -274,7 +452,7 @@ internal class ApplicationScopeImpl(val runtime: AppRuntime) : ApplicationScope
|
|||||||
override fun exitApplication() { runtime.exitRequested = true }
|
override fun exitApplication() { runtime.exitRequested = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Registry + lifecycle for the live windows. Windows are created by the app
|
/** Registry + lifecycle for the live windows. Windows are created by the app
|
||||||
composition (Window()'s remember) and destroyed by the LOOP — DisposableEffect
|
composition (Window()'s remember) and destroyed by the LOOP — DisposableEffect
|
||||||
onDispose only schedules, because teardown disposes a composition and that
|
onDispose only schedules, because teardown disposes a composition and that
|
||||||
must not run re-entrantly inside another composition's apply pass. */
|
must not run re-entrantly inside another composition's apply pass. */
|
||||||
@@ -298,10 +476,11 @@ internal class AppRuntime {
|
|||||||
inWidth: Int,
|
inWidth: Int,
|
||||||
inHeight: Int,
|
inHeight: Int,
|
||||||
inGpu: GpuMode,
|
inGpu: GpuMode,
|
||||||
|
inIcon: AppWindowIcon?,
|
||||||
inOnFrame: ((RenderBackend, Int) -> Boolean)?,
|
inOnFrame: ((RenderBackend, Int) -> Boolean)?,
|
||||||
inContent: () -> (@Composable ComposeWindowScope.() -> Unit),
|
inContent: () -> (@Composable ComposeWindowScope.() -> Unit),
|
||||||
): WindowInstance {
|
): WindowInstance {
|
||||||
val vWindow = WindowInstance(inTitle, inWidth, inHeight, inGpu, inOnFrame, inContent)
|
val vWindow = WindowInstance(inTitle, inWidth, inHeight, inGpu, inIcon, inOnFrame, inContent)
|
||||||
// A Window() was declared either way — the "exit when the last window
|
// A Window() was declared either way — the "exit when the last window
|
||||||
// is gone" rule must also fire when every declared window failed to
|
// is gone" rule must also fire when every declared window failed to
|
||||||
// initialise (otherwise the loop would spin forever with none).
|
// initialise (otherwise the loop would spin forever with none).
|
||||||
@@ -346,11 +525,19 @@ internal class WindowInstance(
|
|||||||
inWidth: Int,
|
inWidth: Int,
|
||||||
inHeight: Int,
|
inHeight: Int,
|
||||||
inGpu: GpuMode,
|
inGpu: GpuMode,
|
||||||
|
inIcon: AppWindowIcon?,
|
||||||
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
|
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
|
||||||
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
|
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
|
||||||
) {
|
) {
|
||||||
private val gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
// Resolved renderer mode. `var` so init() can fall back GPU → CPU raster when
|
||||||
val backend = SDL3Backend(initialTitle, inWidth, inHeight, gpuMode = gpuMode)
|
// a Skia GPU context can't be created (RDP / headless / missing GL driver).
|
||||||
|
private var gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||||
|
private val initialWidth = inWidth
|
||||||
|
private val initialHeight = inHeight
|
||||||
|
private val icon = inIcon
|
||||||
|
// Assigned by init() from the first backend that comes up (see makeBackend()).
|
||||||
|
lateinit var backend: SDL3Backend
|
||||||
|
private set
|
||||||
private var renderBackend: RenderBackend? = null
|
private var renderBackend: RenderBackend? = null
|
||||||
lateinit var host: ComposeRootHost
|
lateinit var host: ComposeRootHost
|
||||||
private set
|
private set
|
||||||
@@ -378,8 +565,9 @@ internal class WindowInstance(
|
|||||||
private var hasMousePos = false
|
private var hasMousePos = false
|
||||||
|
|
||||||
private var frameIndex = 0
|
private var frameIndex = 0
|
||||||
private var fpsFrames = 0
|
private var fpsEma = 0.0
|
||||||
private var fpsLastMs = SDL_GetTicks()
|
private var fpsLastFrameMs = SDL_GetTicks()
|
||||||
|
private var fpsLastTitleMs = 0uL
|
||||||
|
|
||||||
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
|
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
|
||||||
// Escape completes a back navigation on THIS window's dispatcher.
|
// Escape completes a back navigation on THIS window's dispatcher.
|
||||||
@@ -407,6 +595,7 @@ internal class WindowInstance(
|
|||||||
fun onActivationEvent(inEvent: AppEvent.WindowActivation) {
|
fun onActivationEvent(inEvent: AppEvent.WindowActivation) {
|
||||||
inEvent.focused?.let { windowFocused = it }
|
inEvent.focused?.let { windowFocused = it }
|
||||||
inEvent.visible?.let { windowVisible = it }
|
inEvent.visible?.let { windowVisible = it }
|
||||||
|
if (::host.isInitialized) host.setWindowFocused(windowFocused)
|
||||||
architectureOwner.setLifecycleState(
|
architectureOwner.setLifecycleState(
|
||||||
when {
|
when {
|
||||||
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
|
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
|
||||||
@@ -419,20 +608,52 @@ internal class WindowInstance(
|
|||||||
needsFrame = true
|
needsFrame = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(inScope: CoroutineScope): Boolean {
|
// Creates + initialises an SDL3Backend for [mode] and its RenderBackend. On
|
||||||
if (!backend.init()) return false
|
// success commits `backend` and returns the renderer; else tears everything
|
||||||
backend.updateWindowSize()
|
// down and returns null so the caller can retry with a different mode.
|
||||||
|
private fun makeBackend(mode: GpuMode): RenderBackend? {
|
||||||
|
val vBackend = SDL3Backend(
|
||||||
|
initialTitle, initialWidth, initialHeight, gpuMode = mode,
|
||||||
|
iconLightResourcePaths = icon?.light ?: emptyList(),
|
||||||
|
iconDarkResourcePaths = icon?.dark ?: emptyList(),
|
||||||
|
)
|
||||||
|
if (!vBackend.init()) {
|
||||||
|
vBackend.destroy(inQuitSdl = false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
vBackend.updateWindowSize()
|
||||||
|
val vRender = createRenderBackend(vBackend, mode)
|
||||||
|
if (vRender == null || !vRender.ensureSize(vBackend.pixelWidth, vBackend.pixelHeight)) {
|
||||||
|
vRender?.destroy()
|
||||||
|
vBackend.destroy(inQuitSdl = false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
backend = vBackend
|
||||||
|
return vRender
|
||||||
|
}
|
||||||
|
|
||||||
val vRender = createRenderBackend(backend, gpuMode)
|
fun init(inScope: CoroutineScope): Boolean {
|
||||||
if (vRender == null || !vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)) {
|
// Try the resolved gpuMode; if it's a Skia GPU mode whose context/bridge
|
||||||
println("Failed to init render backend for $gpuMode")
|
// can't come up (RDP / headless / missing GL driver), fall back once to
|
||||||
backend.destroy(inQuitSdl = false)
|
// CPU raster (Software) so the window still opens instead of failing.
|
||||||
|
var vRender = makeBackend(gpuMode)
|
||||||
|
if (vRender == null && gpuMode is GpuMode.Skia) {
|
||||||
|
println("GPU renderer ($gpuMode) unavailable — falling back to CPU raster (Software)")
|
||||||
|
gpuMode = GpuMode.Software
|
||||||
|
vRender = makeBackend(gpuMode)
|
||||||
|
}
|
||||||
|
if (vRender == null) {
|
||||||
|
println("Failed to init render backend")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
renderBackend = vRender
|
renderBackend = vRender
|
||||||
|
|
||||||
host = ComposeRootHost(inDensity = backend.pixelDensity)
|
host = ComposeRootHost(inDensity = backend.pixelDensity)
|
||||||
host.attach()
|
host.attach()
|
||||||
|
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
|
||||||
|
// even when nothing else (recompose / relayout) is pending — retained layers
|
||||||
|
// need this so a draw-only state change still repaints.
|
||||||
|
host.setInvalidationCallback { needsFrame = true }
|
||||||
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
|
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
|
||||||
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
|
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
|
||||||
|
|
||||||
@@ -441,7 +662,11 @@ internal class WindowInstance(
|
|||||||
// setContent below).
|
// setContent below).
|
||||||
installGlobals()
|
installGlobals()
|
||||||
|
|
||||||
val vRecomposer = Recomposer(inScope.coroutineContext + frameClock)
|
// Effect context for this window's composition — the screenshot flag injects the
|
||||||
|
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
|
||||||
|
var vEffectContext = inScope.coroutineContext + frameClock
|
||||||
|
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
|
||||||
|
val vRecomposer = Recomposer(vEffectContext)
|
||||||
recomposer = vRecomposer
|
recomposer = vRecomposer
|
||||||
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
|
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
|
||||||
val vComposition = Composition(host.applier, vRecomposer)
|
val vComposition = Composition(host.applier, vRecomposer)
|
||||||
@@ -512,12 +737,16 @@ internal class WindowInstance(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// Seed the :ui-internal LocalPointerIconService so Modifier.pointerHoverIcon
|
||||||
// Read through the holder so Window() recompositions with a
|
// drives the SDL cursor (the local + service type can't cross the module boundary).
|
||||||
// new content lambda propagate into this composition.
|
host.ProvidePointerIconService {
|
||||||
val vContent = contentHolder()
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
with(vWindowScope) { vContent() }
|
// Read through the holder so Window() recompositions with a
|
||||||
PopupLayer(vPopupHost)
|
// new content lambda propagate into this composition.
|
||||||
|
val vContent = contentHolder()
|
||||||
|
with(vWindowScope) { vContent() }
|
||||||
|
PopupLayer(vPopupHost)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,15 +765,17 @@ internal class WindowInstance(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Points the render-bridge globals (text measurer / image loader /
|
/** Points the render-bridge globals (text measurer / image loader /
|
||||||
viewport) at this window's renderer. Must be called before composing,
|
viewport) at this window's renderer. Must be called before composing,
|
||||||
measuring, or drawing this window's tree. */
|
measuring, or drawing this window's tree. */
|
||||||
fun installGlobals() {
|
fun installGlobals() {
|
||||||
val vRender = renderBackend ?: return
|
val vRender = renderBackend ?: return
|
||||||
currentTextMeasurer = vRender.textMeasurer
|
|
||||||
currentImageLoader = vRender.imageLoader
|
currentImageLoader = vRender.imageLoader
|
||||||
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
|
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
|
||||||
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
|
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
|
||||||
|
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
|
||||||
|
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
|
||||||
|
com.compose.sdl.text.registerGenericFonts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
@@ -577,6 +808,17 @@ internal class WindowInstance(
|
|||||||
hasMousePos = true
|
hasMousePos = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pointer left the window (SDL_EVENT_WINDOW_MOUSE_LEAVE). Stop the per-frame
|
||||||
|
synthetic hover re-dispatch and fire one Exit at the last position so a
|
||||||
|
hovered widget clears its highlight instead of sticking forever. */
|
||||||
|
fun onPointerExit() {
|
||||||
|
needsFrame = true
|
||||||
|
if (hasMousePos) {
|
||||||
|
host.onPointerRaw(lastMouseX, lastMouseY, 3, 0, SDL_GetTicks().toLong())
|
||||||
|
}
|
||||||
|
hasMousePos = false
|
||||||
|
}
|
||||||
|
|
||||||
fun onWheelEvent(inEvent: AppEvent.MouseWheel) {
|
fun onWheelEvent(inEvent: AppEvent.MouseWheel) {
|
||||||
needsFrame = true
|
needsFrame = true
|
||||||
installGlobals()
|
installGlobals()
|
||||||
@@ -596,12 +838,43 @@ internal class WindowInstance(
|
|||||||
fun onTextInputEvent(inEvent: AppEvent.TextInput) {
|
fun onTextInputEvent(inEvent: AppEvent.TextInput) {
|
||||||
needsFrame = true
|
needsFrame = true
|
||||||
installGlobals()
|
installGlobals()
|
||||||
// SDL's committed text is the only layout-correct source of characters —
|
// A focused text field runs an IME session (ComposeOwner.textInputSession):
|
||||||
// SDL key events carry UNSHIFTED keycodes (no uppercase, no numpad digits,
|
// commit through it so the text REPLACES any active composition. Falls back
|
||||||
// no dead keys). Synthesise typed KeyEvents so the vendored text stacks
|
// to synthesising typed KeyEvents when no field is focused — SDL key events
|
||||||
// (CoreTextField's isTypedEvent path and the state-based field's key
|
// carry UNSHIFTED keycodes (no uppercase/numpad/dead keys), so committed
|
||||||
// handler) commit it.
|
// text is the only layout-correct character source.
|
||||||
dispatchTypedText(host, inEvent.text)
|
if (!com.compose.sdl.text.input.ImeBridge.commit(inEvent.text)) {
|
||||||
|
dispatchTypedText(host, inEvent.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTextEditingEvent(inEvent: AppEvent.TextEditing) {
|
||||||
|
needsFrame = true
|
||||||
|
installGlobals()
|
||||||
|
// IME preedit: show the composing (underlined) region in the focused field.
|
||||||
|
// No-op if no field is focused. Also nudge the OS candidate window to the
|
||||||
|
// field's on-screen rect.
|
||||||
|
com.compose.sdl.text.input.ImeBridge.compose(inEvent.text)
|
||||||
|
updateImeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nudge the OS IME candidate window to the focused field's on-screen rect.
|
||||||
|
// Best-effort: no-op without an active session or a laid-out field.
|
||||||
|
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||||
|
private fun updateImeArea() {
|
||||||
|
val vRequest = com.compose.sdl.text.input.ImeBridge.request ?: return
|
||||||
|
val vRect = vRequest.focusedRectInRoot() ?: return
|
||||||
|
val vWindow = backend.window ?: return
|
||||||
|
val vDpr = backend.pixelDensity
|
||||||
|
kotlinx.cinterop.memScoped {
|
||||||
|
// SDL wants the area in logical window points; layout runs in physical px.
|
||||||
|
val vSdlRect = alloc<sdl3.SDL_Rect>()
|
||||||
|
vSdlRect.x = (vRect.left / vDpr).toInt()
|
||||||
|
vSdlRect.y = (vRect.top / vDpr).toInt()
|
||||||
|
vSdlRect.w = (vRect.width / vDpr).toInt().coerceAtLeast(1)
|
||||||
|
vSdlRect.h = (vRect.height / vDpr).toInt().coerceAtLeast(1)
|
||||||
|
sdl3.SDL_SetTextInputArea(vWindow.reinterpret(), vSdlRect.ptr, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDropEvent(inEvent: AppEvent.Drop) {
|
fun onDropEvent(inEvent: AppEvent.Drop) {
|
||||||
@@ -626,7 +899,13 @@ internal class WindowInstance(
|
|||||||
facade.onResized()
|
facade.onResized()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OS close button / app-level Quit: honour the veto handler, then hand the
|
/** OS light/dark theme changed — re-pick the theme-appropriate window icon
|
||||||
|
(no-op if this window has no icon configured or the choice is unchanged). */
|
||||||
|
fun onSystemThemeChanged() {
|
||||||
|
backend.applyThemeIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OS close button / app-level Quit: honour the veto handler, then hand the
|
||||||
decision to the Window() declaration. */
|
decision to the Window() declaration. */
|
||||||
fun requestClose() {
|
fun requestClose() {
|
||||||
if (closeDispatched) return
|
if (closeDispatched) return
|
||||||
@@ -640,7 +919,15 @@ internal class WindowInstance(
|
|||||||
// Frame pump
|
// Frame pump
|
||||||
|
|
||||||
fun shouldRender(): Boolean =
|
fun shouldRender(): Boolean =
|
||||||
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null
|
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
|
||||||
|
|
||||||
|
/** True while this window still has pending work: a state/layout/draw invalidation
|
||||||
|
(needsFrame), recomposer work, or a composition/node animation awaiting the next
|
||||||
|
frame. Sampled by windowHasInvalidations() from onFrame probes — the quiescence
|
||||||
|
signal for render-to-settle screenshot capture. */
|
||||||
|
fun hasInvalidations(): Boolean =
|
||||||
|
needsFrame || recomposer?.hasPendingWork == true ||
|
||||||
|
frameClock.hasAwaiters || host.hasAnimationAwaiters()
|
||||||
|
|
||||||
fun renderFrame() {
|
fun renderFrame() {
|
||||||
val vRender = renderBackend ?: return
|
val vRender = renderBackend ?: return
|
||||||
@@ -650,7 +937,16 @@ internal class WindowInstance(
|
|||||||
backend.updateWindowSize()
|
backend.updateWindowSize()
|
||||||
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
||||||
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
|
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
|
||||||
|
// Deliver any state written by this iteration's frame-clock continuations
|
||||||
|
// (withFrameNanos animations — notably smooth wheel scrolling) BEFORE we lay
|
||||||
|
// out. Those writes happen in the per-window pump's sendFrame()/yield(), which
|
||||||
|
// is AFTER the loop's last sendApplyNotifications() — so without this the
|
||||||
|
// relayout they trigger wasn't registered until the next frame, and the frame
|
||||||
|
// drew last frame's positions. That one-frame trail read as "the clip is a
|
||||||
|
// frame late" when scrolling (content briefly drawn past the viewport edge).
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
host.measureAndLayout()
|
host.measureAndLayout()
|
||||||
|
FrameProfiler.phase(" layout")
|
||||||
|
|
||||||
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
|
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
|
||||||
if (hasMousePos) {
|
if (hasMousePos) {
|
||||||
@@ -658,30 +954,48 @@ internal class WindowInstance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
vRender.beginFrame(1f)
|
vRender.beginFrame(1f)
|
||||||
vRender.drawRoot(host)
|
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
|
||||||
if (onFrame != null && !onFrame.invoke(vRender, frameIndex)) {
|
if (onFrame != null) {
|
||||||
|
renderingWindow = this
|
||||||
|
val vContinue = onFrame.invoke(vRender, frameIndex)
|
||||||
|
renderingWindow = null
|
||||||
// Probe consumers end the app when their scenario completes.
|
// Probe consumers end the app when their scenario completes.
|
||||||
facade.close()
|
if (!vContinue) facade.close()
|
||||||
}
|
}
|
||||||
|
FrameProfiler.phase(" draw")
|
||||||
vRender.endFrame()
|
vRender.endFrame()
|
||||||
|
FrameProfiler.phase(" present")
|
||||||
frameIndex++
|
frameIndex++
|
||||||
|
|
||||||
// FPS — refreshed ~once a second, per window.
|
// FPS — instantaneous inter-frame rate, EMA-smoothed, title refreshed
|
||||||
fpsFrames++
|
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
|
||||||
|
// than waiting for a full second of unbroken rendering to accumulate
|
||||||
|
// (the old fixed-window counter never got its first update: bursty
|
||||||
|
// interaction kept idling before 1s elapsed, and the idle-skip reset the
|
||||||
|
// window each time — so FPS only appeared during a long enough page
|
||||||
|
// transition). A dt outside 1..100ms is the first frame or a resume from
|
||||||
|
// idle (the gap isn't a real frame interval), so it's not sampled — the
|
||||||
|
// title just holds the last active rate while idle.
|
||||||
val vNowMs = SDL_GetTicks()
|
val vNowMs = SDL_GetTicks()
|
||||||
val vElapsed = (vNowMs - fpsLastMs).toInt()
|
val vDt = (vNowMs - fpsLastFrameMs).toInt()
|
||||||
if (vElapsed >= 1000) {
|
fpsLastFrameMs = vNowMs
|
||||||
val vFps = fpsFrames * 1000 / vElapsed
|
if (vDt in 1..100) {
|
||||||
facade.updateFps(vFps)
|
val vInst = 1000.0 / vDt
|
||||||
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
|
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
|
||||||
fpsFrames = 0
|
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
|
||||||
fpsLastMs = vNowMs
|
val vFps = (fpsEma + 0.5).toInt()
|
||||||
|
facade.updateFps(vFps)
|
||||||
|
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
|
||||||
|
fpsLastTitleMs = vNowMs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
|
||||||
|
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
|
||||||
|
// intact so the title keeps showing the last active rate while idle.
|
||||||
fun resetFpsWindow() {
|
fun resetFpsWindow() {
|
||||||
fpsFrames = 0
|
fpsLastFrameMs = SDL_GetTicks()
|
||||||
fpsLastMs = SDL_GetTicks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
@@ -704,7 +1018,7 @@ internal class WindowInstance(
|
|||||||
// MARK: WindowArchitectureOwner
|
// MARK: WindowArchitectureOwner
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Per-window architecture-components owner, modeled on upstream desktop's
|
/** Per-window architecture-components owner, modeled on upstream desktop's
|
||||||
DefaultArchitectureComponentsOwner (compose/ui skikoMain
|
DefaultArchitectureComponentsOwner (compose/ui skikoMain
|
||||||
PlatformOwnerProvider.skiko.kt): one object implements LifecycleOwner +
|
PlatformOwnerProvider.skiko.kt): one object implements LifecycleOwner +
|
||||||
ViewModelStoreOwner + SavedStateRegistryOwner (+ the SavedState-aware
|
ViewModelStoreOwner + SavedStateRegistryOwner (+ the SavedState-aware
|
||||||
@@ -752,7 +1066,7 @@ private class WindowArchitectureOwner :
|
|||||||
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.CREATED
|
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.CREATED
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus/visibility-driven state (see WindowInstance.onActivationEvent).
|
/** Focus/visibility-driven state (see WindowInstance.onActivationEvent).
|
||||||
Ignored once destroyed — a stray SDL event during teardown must not
|
Ignored once destroyed — a stray SDL event during teardown must not
|
||||||
resurrect the registry. */
|
resurrect the registry. */
|
||||||
fun setLifecycleState(inState: androidx.lifecycle.Lifecycle.State) {
|
fun setLifecycleState(inState: androidx.lifecycle.Lifecycle.State) {
|
||||||
@@ -773,7 +1087,7 @@ private class WindowArchitectureOwner :
|
|||||||
// MARK: Input helpers
|
// MARK: Input helpers
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Escape → back: port of upstream desktop's BackNavigationEventInput. An
|
/** Escape → back: port of upstream desktop's BackNavigationEventInput. An
|
||||||
unconsumed Escape KeyDown completes a back navigation on the dispatcher
|
unconsumed Escape KeyDown completes a back navigation on the dispatcher
|
||||||
this input is registered with (BackHandler / PredictiveBackHandler
|
this input is registered with (BackHandler / PredictiveBackHandler
|
||||||
consumers: m3 SearchBar collapse, dialog dismissal, …). */
|
consumers: m3 SearchBar collapse, dialog dismissal, …). */
|
||||||
@@ -789,7 +1103,7 @@ private class BackNavigationInput : androidx.navigationevent.NavigationEventInpu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
|
/** Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
|
||||||
KeyDown per Unicode codepoint (surrogate pairs folded). Key.Unknown +
|
KeyDown per Unicode codepoint (surrogate pairs folded). Key.Unknown +
|
||||||
codePoint + no modifiers matches both vendored text stacks' isTypedEvent
|
codePoint + no modifiers matches both vendored text stacks' isTypedEvent
|
||||||
criteria; the SDL key mapper leaves codePoint = 0 on real key events, so
|
criteria; the SDL key mapper leaves codePoint = 0 on real key events, so
|
||||||
@@ -4,7 +4,7 @@ import androidx.compose.runtime.BroadcastFrameClock
|
|||||||
import androidx.compose.runtime.MonotonicFrameClock
|
import androidx.compose.runtime.MonotonicFrameClock
|
||||||
import sdl3.SDL_GetTicksNS
|
import sdl3.SDL_GetTicksNS
|
||||||
|
|
||||||
/* MonotonicFrameClock for the SDL main loop, with upstream frame semantics:
|
/** MonotonicFrameClock for the SDL main loop, with upstream frame semantics:
|
||||||
|
|
||||||
- withFrameNanos ALWAYS suspends until the NEXT sendFrame — never completes
|
- withFrameNanos ALWAYS suspends until the NEXT sendFrame — never completes
|
||||||
with a stale frame. Deferred animations (AnimatedVisibility's enter/exit
|
with a stale frame. Deferred animations (AnimatedVisibility's enter/exit
|
||||||
@@ -26,7 +26,12 @@ internal class SDL3FrameClock : MonotonicFrameClock {
|
|||||||
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R =
|
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R =
|
||||||
broadcast.withFrameNanos(onFrame)
|
broadcast.withFrameNanos(onFrame)
|
||||||
|
|
||||||
fun sendFrame() {
|
fun sendFrame(inNanos: Long = SDL_GetTicksNS().toLong()) {
|
||||||
broadcast.sendFrame(SDL_GetTicksNS().toLong())
|
broadcast.sendFrame(inNanos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coroutines (recomposer / composition animations) currently suspended in withFrameNanos
|
||||||
|
// awaiting the next sendFrame — the "an animation is still running" half of the window's
|
||||||
|
// quiescence signal (see WindowInstance.hasInvalidations).
|
||||||
|
val hasAwaiters: Boolean get() = broadcast.hasAwaiters
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import sdl3.SDL_GetCurrentThreadID
|
|||||||
// MARK: Sdl3MainDispatcher
|
// MARK: Sdl3MainDispatcher
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Single-threaded Main dispatcher driven by the SDL3 main loop. Kotlin/Native
|
/** Single-threaded Main dispatcher driven by the SDL3 main loop. Kotlin/Native
|
||||||
ships no Dispatchers.Main on Linux/Windows targets (and the Darwin one
|
ships no Dispatchers.Main on Linux/Windows targets (and the Darwin one
|
||||||
posts to GCD's main queue, which only drains when the run loop pumps —
|
posts to GCD's main queue, which only drains when the run loop pumps —
|
||||||
our SDL_Delay-based loop doesn't reliably pump it). nativeComposeWindow
|
our SDL_Delay-based loop doesn't reliably pump it). nativeComposeWindow
|
||||||
@@ -48,7 +48,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
|
|||||||
fQueue.trySend(block)
|
fQueue.trySend(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drains all pending tasks. Called from the SDL3 main loop once per
|
/** Drains all pending tasks. Called from the SDL3 main loop once per
|
||||||
frame (after event dispatch, before layout) so withContext(Main) {}
|
frame (after event dispatch, before layout) so withContext(Main) {}
|
||||||
resumptions land before the recomposer kicks off the next frame's
|
resumptions land before the recomposer kicks off the next frame's
|
||||||
composition. */
|
composition. */
|
||||||
@@ -61,7 +61,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Closes the queue. After this, dispatch() drops new posts silently.
|
/** Closes the queue. After this, dispatch() drops new posts silently.
|
||||||
Called from nativeComposeWindow as part of shutdown. */
|
Called from nativeComposeWindow as part of shutdown. */
|
||||||
fun close() {
|
fun close() {
|
||||||
fQueue.close()
|
fQueue.close()
|
||||||
@@ -69,7 +69,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
|
|||||||
|
|
||||||
override fun toString(): String = "Sdl3MainDispatcher"
|
override fun toString(): String = "Sdl3MainDispatcher"
|
||||||
|
|
||||||
/* Dispatchers.Main.immediate: inline when already on the SDL main thread
|
/** Dispatchers.Main.immediate: inline when already on the SDL main thread
|
||||||
(isDispatchNeeded = false), queued like the base dispatcher otherwise. */
|
(isDispatchNeeded = false), queued like the base dispatcher otherwise. */
|
||||||
private inner class ImmediateDispatcher : MainCoroutineDispatcher() {
|
private inner class ImmediateDispatcher : MainCoroutineDispatcher() {
|
||||||
override val immediate: MainCoroutineDispatcher get() = this
|
override val immediate: MainCoroutineDispatcher get() = this
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
// - Show manifest properties: true
|
// - Show manifest properties: true
|
||||||
// - Show declarations: true
|
// - Show declarations: true
|
||||||
|
|
||||||
// Library unique name: <com.bitsycore.compose.sdl:foundation-layout>
|
// Library unique name: <com.bitsycore.compose.foundation:foundation-layout>
|
||||||
open annotation class androidx.compose.foundation.layout/ExperimentalFlexBoxApi : kotlin/Annotation { // androidx.compose.foundation.layout/ExperimentalFlexBoxApi|null[0]
|
open annotation class androidx.compose.foundation.layout/ExperimentalFlexBoxApi : kotlin/Annotation { // androidx.compose.foundation.layout/ExperimentalFlexBoxApi|null[0]
|
||||||
constructor <init>() // androidx.compose.foundation.layout/ExperimentalFlexBoxApi.<init>|<init>(){}[0]
|
constructor <init>() // androidx.compose.foundation.layout/ExperimentalFlexBoxApi.<init>|<init>(){}[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// - Show manifest properties: true
|
// - Show manifest properties: true
|
||||||
// - Show declarations: true
|
// - Show declarations: true
|
||||||
|
|
||||||
// Library unique name: <com.bitsycore.compose.sdl:foundation>
|
// Library unique name: <com.bitsycore.compose.foundation:foundation>
|
||||||
open annotation class androidx.compose.foundation.lazy.grid/LazyGridScopeMarker : kotlin/Annotation { // androidx.compose.foundation.lazy.grid/LazyGridScopeMarker|null[0]
|
open annotation class androidx.compose.foundation.lazy.grid/LazyGridScopeMarker : kotlin/Annotation { // androidx.compose.foundation.lazy.grid/LazyGridScopeMarker|null[0]
|
||||||
constructor <init>() // androidx.compose.foundation.lazy.grid/LazyGridScopeMarker.<init>|<init>(){}[0]
|
constructor <init>() // androidx.compose.foundation.lazy.grid/LazyGridScopeMarker.<init>|<init>(){}[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
||||||
|
|
||||||
// :foundation — androidx.compose.foundation.* AND androidx.compose.animation.*
|
// :foundation — androidx.compose.foundation.* AND androidx.compose.animation.*
|
||||||
// (non-core) vendored VERBATIM from upstream. Sits on :core (which still owns
|
// (non-core) vendored VERBATIM from upstream. Sits on :ui (which still owns
|
||||||
// androidx.compose.ui.*, both renderers, cinterops) + :animation-core.
|
// androidx.compose.ui.*, the Skia renderer, cinterops) + :animation-core.
|
||||||
//
|
//
|
||||||
// Why the two upstream modules (`:animation` and `:foundation`) are combined
|
// Why the two upstream modules (`:animation` and `:foundation`) are combined
|
||||||
// here: upstream splits `foundation-layout` off from `foundation`, then has
|
// here: upstream splits `foundation-layout` off from `foundation`, then has
|
||||||
@@ -20,9 +20,9 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
|||||||
// Note on DarkTheme: foundation declares `internal expect fun _isSystemInDarkTheme()`,
|
// Note on DarkTheme: foundation declares `internal expect fun _isSystemInDarkTheme()`,
|
||||||
// and its actual lives here as a plain nativeMain kt file (see
|
// and its actual lives here as a plain nativeMain kt file (see
|
||||||
// src/nativeMain/kotlin/androidx/compose/foundation/DarkTheme.native.kt) — a single
|
// src/nativeMain/kotlin/androidx/compose/foundation/DarkTheme.native.kt) — a single
|
||||||
// stub returning false. The upstream skiko/sdl per-renderer actuals were dropped
|
// stub returning false. The upstream skiko per-renderer actuals were dropped
|
||||||
// when :foundation was split from :core: expect/actual must live in the same
|
// when :foundation was split from :ui: expect/actual must live in the same
|
||||||
// module, and duplicating the skikoRenderer/sdlRenderer source-set hierarchy on
|
// module, and duplicating the skikoRenderer source-set hierarchy on
|
||||||
// :foundation just to read `LocalSystemTheme` on the Skia path was more build
|
// :foundation just to read `LocalSystemTheme` on the Skia path was more build
|
||||||
// wiring than is worth it for one function no in-repo app calls.
|
// wiring than is worth it for one function no in-repo app calls.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
// MANUAL VENDOR of upstream compose/foundation/foundation/src/commonMain/kotlin/
|
// MANUAL VENDOR of upstream compose/foundation/foundation/src/commonMain/kotlin/
|
||||||
// androidx/compose/foundation/text/selection/SelectionLayout.kt.
|
// androidx/compose/foundation/text/selection/SelectionLayout.kt.
|
||||||
|
// VENDOR-BASE: compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionLayout.kt @ v1.12.0-beta03+dev4483
|
||||||
//
|
//
|
||||||
// Change vs upstream: MultiSelectionLayout.crossStatus and slotToIndex clamp their
|
// Change vs upstream: MultiSelectionLayout.crossStatus and slotToIndex clamp their
|
||||||
// infoList index to the valid range. Upstream assumes that if the drag position never
|
// infoList index to the valid range. Upstream assumes that if the drag position never
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import androidx.compose.runtime.remember
|
|||||||
// MARK: InteractionSource convenience
|
// MARK: InteractionSource convenience
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Convenience around the official MutableInteractionSource() factory. Official
|
/** Convenience around the official MutableInteractionSource() factory. Official
|
||||||
Compose uses `remember { MutableInteractionSource() }` directly; this helper
|
Compose uses `remember { MutableInteractionSource() }` directly; this helper
|
||||||
is a project shorthand. Lives in :foundation because it references
|
is a project shorthand. Lives in :foundation because it references
|
||||||
androidx.compose.foundation.interaction.MutableInteractionSource. */
|
androidx.compose.foundation.interaction.MutableInteractionSource. */
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import kotlin.math.roundToInt
|
|||||||
// MARK: Scrollbar (Compose Desktop-style)
|
// MARK: Scrollbar (Compose Desktop-style)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
|
/** A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
|
||||||
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
|
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
|
||||||
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
|
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
|
||||||
no BoxScope (so no Modifier.align) — pin the bar with the Box's
|
no BoxScope (so no Modifier.align) — pin the bar with the Box's
|
||||||
@@ -71,7 +71,7 @@ class ScrollbarStyle(
|
|||||||
val hoverColor: Color,
|
val hoverColor: Color,
|
||||||
)
|
)
|
||||||
|
|
||||||
/* Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
|
/** Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
|
||||||
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
|
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
|
||||||
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
||||||
minimalHeight = 16.dp,
|
minimalHeight = 16.dp,
|
||||||
@@ -88,7 +88,7 @@ val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
|
|||||||
// MARK: ScrollbarAdapter
|
// MARK: ScrollbarAdapter
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
|
/** Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
|
||||||
contentSize / viewportSize are along the scroll axis; the thumb is sized by
|
contentSize / viewportSize are along the scroll axis; the thumb is sized by
|
||||||
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
||||||
interface ScrollbarAdapter {
|
interface ScrollbarAdapter {
|
||||||
@@ -115,7 +115,7 @@ private class ScrollStateScrollbarAdapter(private val state: ScrollState) : Scro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
|
/** Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
|
||||||
size of its VISIBLE items, so exact pixel geometry of off-screen content is
|
size of its VISIBLE items, so exact pixel geometry of off-screen content is
|
||||||
unknowable — we estimate it from the average visible item size × total item
|
unknowable — we estimate it from the average visible item size × total item
|
||||||
count (the same approach as Compose Desktop). Accurate enough for a thumb
|
count (the same approach as Compose Desktop). Accurate enough for a thumb
|
||||||
@@ -189,7 +189,7 @@ fun HorizontalScrollbar(
|
|||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
|
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
|
||||||
|
|
||||||
/* Shared impl. The root Box is the track (fixed thickness on the cross axis,
|
/** Shared impl. The root Box is the track (fixed thickness on the cross axis,
|
||||||
filling the main axis from the caller's modifier); a child Box is the thumb,
|
filling the main axis from the caller's modifier); a child Box is the thumb,
|
||||||
positioned with an offset. Drag + track-click are handled on the track in
|
positioned with an offset. Drag + track-click are handled on the track in
|
||||||
track-relative coordinates (a stable origin), so the grab point stays under
|
track-relative coordinates (a stable origin), so the grab point stays under
|
||||||
@@ -334,7 +334,7 @@ private fun Scrollbar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
|
/** Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
|
||||||
comes from the caller pre-converted via LocalDensity (Option-B: layout
|
comes from the caller pre-converted via LocalDensity (Option-B: layout
|
||||||
coords are physical px, so px-to-dp goes through the current density). */
|
coords are physical px, so px-to-dp goes through the current density). */
|
||||||
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
|
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
|
||||||
|
|||||||
@@ -1,38 +1,31 @@
|
|||||||
package com.compose.sdl.text
|
package com.compose.sdl.text
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
import androidx.compose.ui.text.font.FontVariation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: IconText — project-only text composable for icon fonts
|
// MARK: IconText — icon-font text on the standard text path
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
A minimal project text composable that renders a codepoint through a named
|
A minimal project text composable that renders a codepoint through a named
|
||||||
icon font with optional variable-font axis settings (Material Symbols).
|
icon font with optional variable-font axis settings (Material Symbols FILL /
|
||||||
|
wght / GRAD / opsz).
|
||||||
|
|
||||||
Sits outside `androidx.compose.foundation.text.BasicText` so BasicText can
|
Now just a [BasicText]: the icon font family and its variable axes are threaded
|
||||||
remain a byte-identical match of upstream. Icons need:
|
through [namedFontFamily] (`axes = …`), which the skiko text engine reads via
|
||||||
- `fontFamily: String` — the registered icon font family name (project's
|
`FontFamily.projectFontVariations()` and applies to the typeface. So icons
|
||||||
`FontFamily.Named`, not upstream `FontFamily.Resolver`).
|
measure + draw through the same skiko `skparagraph` path as ordinary text — no
|
||||||
- `fontVariationSettings` — per-usage variable-font axis values (FILL,
|
separate renderer/measurer seam.
|
||||||
wght, GRAD, opsz for Material Symbols).
|
|
||||||
|
|
||||||
Upstream `TextStyle` has neither: fontFamily routes through the
|
Material `Icon(codepoint = …, fontFamily = …)` uses this; Text / BasicText /
|
||||||
`FontFamily.Resolver`, and axis values are set per-Font at construction
|
TextField go through the upstream-shaped path already.
|
||||||
time. Rather than fight that abstraction for one composable, icons use
|
|
||||||
this project path — the TextDrawElement modifier feeds directly into
|
|
||||||
SdlParagraph / Sdl3Canvas.drawNativeText.
|
|
||||||
|
|
||||||
Material `Icon(codepoint = ..., fontFamily = ...)` uses this. Everything
|
|
||||||
else (Text, BasicText, TextField) goes through the upstream-shaped path.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun IconText(
|
fun IconText(
|
||||||
@@ -44,28 +37,16 @@ fun IconText(
|
|||||||
textAlign: TextAlign = TextAlign.Start,
|
textAlign: TextAlign = TextAlign.Start,
|
||||||
fontVariationSettings: List<FontVariation.Setting>? = null,
|
fontVariationSettings: List<FontVariation.Setting>? = null,
|
||||||
) {
|
) {
|
||||||
// Layout runs in physical pixels (LocalDensity = DPR), so the icon font
|
BasicText(
|
||||||
// size must also convert `sp → px`. Matches SdlParagraph's `fontSize.value
|
text = text,
|
||||||
// * density`. Without this the icons render at half size on Retina.
|
modifier = modifier,
|
||||||
val vDensity = LocalDensity.current.density
|
style = TextStyle(
|
||||||
val vFontPx = (fontSize.value * vDensity).toInt().coerceAtLeast(1)
|
color = if (color == Color.Unspecified) Color.Black else color,
|
||||||
Box(modifier = modifier) {
|
fontSize = fontSize,
|
||||||
androidx.compose.ui.layout.Layout(
|
fontFamily = namedFontFamily(fontFamily, axes = fontVariationSettings),
|
||||||
modifier = TextDrawElement(
|
textAlign = textAlign,
|
||||||
text = text,
|
),
|
||||||
spans = null,
|
softWrap = false,
|
||||||
color = if (color == Color.Unspecified) Color.Black else color,
|
maxLines = 1,
|
||||||
fontSizePx = vFontPx,
|
)
|
||||||
textAlign = textAlign,
|
|
||||||
softWrap = false,
|
|
||||||
fontFamily = fontFamily,
|
|
||||||
fontVariations = fontVariationSettings,
|
|
||||||
),
|
|
||||||
) { _, constraints ->
|
|
||||||
val vSize = currentTextMeasurer.measure(text, vFontPx, Int.MAX_VALUE, fontFamily, fontVariationSettings)
|
|
||||||
val w = vSize.width.coerceIn(constraints.minWidth, constraints.maxWidth)
|
|
||||||
val h = vSize.height.coerceIn(constraints.minHeight, constraints.maxHeight)
|
|
||||||
layout(w, h) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
// MARK: SelectionRect
|
// MARK: SelectionRect
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* One selection-highlight rectangle (in layout pixels), painted behind the glyphs.
|
/** One selection-highlight rectangle (in layout pixels), painted behind the glyphs.
|
||||||
Internal render glue shared by BasicText (selection-aware) and BasicTextField.
|
Internal render glue shared by BasicText (selection-aware) and BasicTextField.
|
||||||
Sizes come from Paragraph.getBoundingBox / getPathForRange — already pixels
|
Sizes come from Paragraph.getBoundingBox / getPathForRange — already pixels
|
||||||
(see § HiDPI in CLAUDE.md), so use the pixel-based offset + a raw Layout
|
(see § HiDPI in CLAUDE.md), so use the pixel-based offset + a raw Layout
|
||||||
|
|||||||