Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c76f629971 | ||
|
|
c4564b3a10 | ||
|
|
fb8e97b74d | ||
|
|
6c3bfd8679 | ||
|
|
ec2d9b06ad | ||
|
|
1d6e5a76de | ||
|
|
eb44db915e | ||
|
|
1120abc488 | ||
|
|
9d2656f905 | ||
|
|
48cfa5da4d | ||
|
|
856cbe5ef3 | ||
|
|
5c7308c614 | ||
|
|
2fb322776a | ||
|
|
7c7bb63cae | ||
|
|
02fb2ddf4e | ||
|
|
126e83c056 | ||
|
|
0c09109e6e | ||
|
|
5804128e1d | ||
|
|
04d1df58de | ||
|
|
a87ff2d7f6 | ||
|
|
10380e5422 | ||
|
|
2fe26cf7f8 | ||
|
|
71aab5d502 | ||
|
|
d19948059b | ||
|
|
20714b3eeb | ||
|
|
d029b48e10 | ||
|
|
47445ab7ab | ||
|
|
b965c505ee | ||
|
|
05faaa7f7c | ||
|
|
59bb159351 | ||
|
|
fcba77b622 | ||
|
|
82ffd7cf4d | ||
|
|
d3eef74803 | ||
|
|
a07f4634b2 | ||
|
|
164d2b5e66 | ||
|
|
b8f7595a5f | ||
|
|
cdce0575e3 | ||
|
|
623266d467 | ||
|
|
52817af282 | ||
|
|
749a405a03 | ||
|
|
675bc99b23 | ||
|
|
eae87b98ba | ||
|
|
982dc50d2a | ||
|
|
5ec5f4cb9a | ||
|
|
3c759476ac | ||
|
|
7ee2e4a09f | ||
|
|
2b49ececa7 | ||
|
|
f16ceea0da | ||
|
|
7f332ead0a | ||
|
|
d022462cad | ||
|
|
9637215931 | ||
|
|
66653f903e | ||
|
|
88efdba9f7 | ||
|
|
487e151ac8 | ||
|
|
e68de8794f | ||
|
|
c8aefebbf1 | ||
|
|
cc04c82042 | ||
|
|
416ae3a03e | ||
|
|
9e97b4357f | ||
|
|
8c5a795055 | ||
|
|
9a8540499d | ||
|
|
68ea57204c | ||
|
|
e3f3d64d0b | ||
|
|
229a39b38d | ||
|
|
57cf99608b | ||
|
|
05ec6d5b40 | ||
|
|
e2d125dfea | ||
|
|
50b31641ed | ||
|
|
71738dafb7 | ||
|
|
739d6e4852 | ||
|
|
c828888590 | ||
|
|
9b165a07d2 | ||
|
|
971047b799 | ||
|
|
1e8ccdbeee | ||
|
|
8f53880854 | ||
|
|
82d2e9b16f | ||
|
|
27d1dc1637 | ||
|
|
b16f456aaf | ||
|
|
bdb5c64df3 | ||
|
|
5dc78f47b5 | ||
|
|
862da85de0 | ||
|
|
25689c1f17 | ||
|
|
275ee97d56 | ||
|
|
e5fb04f882 | ||
|
|
23849330d1 | ||
|
|
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 |
@@ -7,6 +7,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# Manual fallback: run with --ref <tag> so github.ref_name is the tag itself
|
||||
# (PUBLISH_VERSION is derived from it).
|
||||
workflow_dispatch:
|
||||
|
||||
# Never overlap two publish runs for the same tag.
|
||||
concurrency:
|
||||
@@ -18,10 +21,9 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Each host runs only the publish tasks Gradle actually generated for it.
|
||||
# macOS also publishes the shared kotlinMultiplatform metadata module.
|
||||
# No library module declares jvm() today — apps do their own JVM parity
|
||||
# build against upstream Compose, so JVM isn't part of the publication.
|
||||
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
||||
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
|
||||
# (see its matrix entry) + the jvm jar + the bridge plugin.
|
||||
# `target` is the Gradle K/N target token (Capitalised) - one per host,
|
||||
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
||||
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
||||
# are host-arch only; cross-compile would fail at link time.
|
||||
@@ -31,13 +33,8 @@ jobs:
|
||||
host: macos
|
||||
target: MacosArm64
|
||||
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: >-
|
||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||
publishMacosArm64PublicationToGitHubPackagesRepository
|
||||
publishJvmPublicationToGitHubPackagesRepository
|
||||
- runner: ubuntu-22.04
|
||||
host: linux
|
||||
target: LinuxX64
|
||||
@@ -49,8 +46,18 @@ jobs:
|
||||
host: windows
|
||||
target: MingwX64
|
||||
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: >-
|
||||
publishMingwX64PublicationToGitHubPackagesRepository
|
||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||
publishJvmPublicationToGitHubPackagesRepository
|
||||
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
@@ -95,20 +102,17 @@ jobs:
|
||||
|
||||
# windows-2022 runners pre-install Chocolatey's mingw-w64 at C:\mingw64.
|
||||
# SDL3 >= 3.4 needs a C++ compiler for its GameInput backend (K/N's
|
||||
# bundled mingw is C-only) — this pre-install covers that. The setjmp
|
||||
# bundled mingw is C-only) - this pre-install covers that. The setjmp
|
||||
# intrinsic K/N's LLD can't resolve is aliased to msvcrt's _setjmpex at
|
||||
# link time via --defsym in sdl3.def.
|
||||
|
||||
# Build the static native deps from scripts/build-sdl/build-sdl.properties.
|
||||
# Only Windows ships the SDL3 renderer (no Skiko on mingw), so only it needs
|
||||
# SDL3_ttf / SDL3_image / FreeType (SDL3_ttf text, SDL3_image decode, FreeType
|
||||
# icon-axis rasterisation). macOS / Linux publish the Skia renderer — Skiko
|
||||
# handles text / images / icons — so they only need base SDL3 for the main
|
||||
# loop (windowing / input / audio / filesystem). Matches the cinterop guard
|
||||
# in compose/ui/ui/build.gradle.kts, which skips those three cinterops on the
|
||||
# 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.
|
||||
# Build the static SDL3 lib (scripts/build-sdl/build-sdl.properties). Every
|
||||
# target renders through Skia now, so all platforms need only base SDL3 for
|
||||
# the main loop (windowing / input / audio / filesystem) - the from-scratch
|
||||
# SDL renderer and its SDL3_ttf / SDL3_image / FreeType deps were removed.
|
||||
# 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/
|
||||
id: cache-libs
|
||||
uses: actions/cache@v4
|
||||
@@ -116,18 +120,12 @@ jobs:
|
||||
path: libs
|
||||
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
|
||||
if: steps.cache-libs.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||
python3 scripts/build-sdl/build-all.py
|
||||
else
|
||||
python3 scripts/build-sdl/build-all.py sdl3
|
||||
fi
|
||||
run: python3 scripts/build-sdl/build-all.py
|
||||
|
||||
# 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
|
||||
# pinned in scripts/compose-fork/compose.properties. Needs python3 + git;
|
||||
# both are pre-installed on every runner.
|
||||
@@ -135,16 +133,84 @@ jobs:
|
||||
shell: bash
|
||||
run: bash scripts/compose-fork/sync.sh
|
||||
|
||||
# GitHub Packages sometimes returns 409 Conflict on a fresh upload when
|
||||
# the same coordinate was recently deleted (soft-delete propagation).
|
||||
# `--continue` lets Gradle finish the rest of the publish tasks past a
|
||||
# single 409, and the outer retry re-runs the whole thing (Gradle skips
|
||||
# already-uploaded artifacts). Two attempts is enough for the transient
|
||||
# cases we've hit; the third pass falls through as a hard failure.
|
||||
# GitHub Packages FORBIDS overwriting a file that already exists in a
|
||||
# version, and maven-publish uploads each publication's files
|
||||
# SEQUENTIALLY from the first one. So when an upload dies mid-publication
|
||||
# (the v0.1.13 macOS run lost DNS mid-publish), a blind retry hits 409
|
||||
# Conflict on the first already-uploaded file and aborts BEFORE reaching
|
||||
# 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
|
||||
shell: bash
|
||||
run: |
|
||||
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" 'if type=="array" then ([.[] | select(.name==$v) | .id] | first // empty) else empty end')
|
||||
[ -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
|
||||
echo "::group::Publish attempt $attempt"
|
||||
if ./gradlew ${{ matrix.tasks }} --continue --no-daemon --stacktrace; then
|
||||
@@ -152,16 +218,48 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
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
|
||||
echo "::error::Publish failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
# Release binaries for the host target — the binary plus the data.kres
|
||||
# (bundled composeResources + fonts + subset icon-font). Each :demo /
|
||||
# :apidemo has its linkReleaseExecutable<Target> task wired to auto-run
|
||||
# the matching copy<Variant>ComposeResources<Target> so data.kres is
|
||||
# produced next to the .kexe / .exe. On Linux only linuxX64 is built —
|
||||
# Second target: the self-hosted Reposilite instance. Same publications,
|
||||
# task names derived from the matrix by swapping the repository name.
|
||||
# The releases repo has redeployment enabled, so a plain retry after a
|
||||
# mid-upload failure is safe (no GitHub-Packages-style 409 poisoning).
|
||||
- name: Publish to maven.bitsycore.com
|
||||
shell: bash
|
||||
env:
|
||||
BITSYCORE_MAVEN_USER: ${{ secrets.BITSYCORE_MAVEN_USER }}
|
||||
BITSYCORE_MAVEN_TOKEN: ${{ secrets.BITSYCORE_MAVEN_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
BITSY_TASKS=$(echo "${{ matrix.tasks }}" | sed 's/GitHubPackagesRepository/BitsycoreRepository/g')
|
||||
for attempt in 1 2 3; do
|
||||
echo "::group::Bitsycore publish attempt $attempt"
|
||||
if ./gradlew $BITSY_TASKS --continue --no-daemon --stacktrace; then
|
||||
echo "::endgroup::"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ $attempt -lt 3 ]; then
|
||||
echo "::warning::Bitsycore publish attempt $attempt failed - retrying"
|
||||
sleep 30
|
||||
fi
|
||||
done
|
||||
echo "::error::Bitsycore publish failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
# Release binaries for the host target - the binary plus the data.kres
|
||||
# (bundled composeResources + fonts + subset icon-font). Each app's
|
||||
# linkReleaseExecutable<Target> auto-runs its data.kres zip task -
|
||||
# :demo's own copy<Variant>ComposeResources<Target>, :apidemo's
|
||||
# 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
|
||||
# are host-arch (x64).
|
||||
- name: Build demo + apidemo release binaries
|
||||
@@ -176,7 +274,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Lower-case first letter — gradle target tokens are Capitalised but
|
||||
# Lower-case first letter - gradle target tokens are Capitalised but
|
||||
# the build/bin dir uses the plain target name (macosArm64 etc).
|
||||
target_dir=$(echo "${{ matrix.target }}" | awk '{print tolower(substr($0,1,1)) substr($0,2)}')
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
@@ -184,8 +282,21 @@ jobs:
|
||||
for app in demo apidemo; do
|
||||
src="$app/build/bin/$target_dir/releaseExecutable"
|
||||
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()' \
|
||||
"$zip_path" "$src/$app.${{ matrix.exe_ext }}" "$src/data.kres"
|
||||
"$zip_path" "${files[@]}"
|
||||
done
|
||||
ls -la dist/
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ bin/
|
||||
|
||||
### Vendored Compose sources ###
|
||||
# Byte-for-byte verbatim copies from JetBrains/compose-multiplatform-core,
|
||||
# regenerated on demand by scripts/compose-fork/sync.sh. Not committed — only the
|
||||
# regenerated on demand by scripts/compose-fork/sync.sh. Not committed - only the
|
||||
# sync tooling (script + <module>/compose-fork.txt manifests + pinned ref + README)
|
||||
# is tracked.
|
||||
**/src/vendor/
|
||||
@@ -61,3 +61,7 @@ bin/
|
||||
### Python ###
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
|
||||
/*.bmp
|
||||
/local.properties
|
||||
|
||||
@@ -0,0 +1,966 @@
|
||||
# ROAD TO 1.0.0
|
||||
|
||||
**Objective:** get ComposeNativeSDL3 **as close to upstream Compose Multiplatform as
|
||||
possible** - vendor every file we can instead of rewriting, stay performant, and look
|
||||
faithful (ideally pixel-identical across macOS / Linux / Windows). The port is already at
|
||||
99% upstream API coverage with maximal vendoring and no steady-state perf gap on
|
||||
macOS/Metal; the remaining 1.0.0 work is **cross-platform rendering fidelity** (Windows
|
||||
skiko-fork divergences), **completing native-actual stubs**, and the release mechanics
|
||||
(1.12.0-stable re-pin + WIN-SMOKE).
|
||||
|
||||
**Status / handoff:** the macOS/Linux-verifiable work is done and committed on `main` (see
|
||||
**"Landed toward 1.0.0"** below - don't redo it). The remaining blockers need a **Windows host**
|
||||
→ jump to **"▶ Continuing on Windows (mingwX64) - START HERE"**. This file is self-contained: it
|
||||
has the build/run commands, the exact confirm steps, the macOS baseline data to diff against, and
|
||||
per-task fixes. So on Windows you can say **"continue PLAN.md"** and have everything.
|
||||
|
||||
## Definition of done for 1.0.0
|
||||
|
||||
- [x] No text tofu on any platform - Windows tab fix **CONFIRMED on-device** (§1a, 2026-08-04:
|
||||
literal `\t`→space, no `.notdef`); CJK / color-emoji / control chars render clean too (§1c).
|
||||
- [x] Fork-vs-official divergence surface (FontMgr, gamma, ICU) **audited on native Windows**
|
||||
(2026-08-04): metrics == JVM (§1b), FontMgr fallback works (CJK + color emoji, §1c), ICU
|
||||
classification works (§1c). Net - the fork's Skia matches upstream; the only deltas are its
|
||||
thin extern-C bindings, handled in shared Kotlin (LineMetrics reconstruction). No fork-side
|
||||
fix needed for 1.0.
|
||||
- [~] Native-actual fidelity blockers closed: float pointer coords **DONE**, screen-reader
|
||||
no-op **DONE**, text context menu **RECONCILED - already works** via the legacy path
|
||||
(§2). Date/time localization is P1 polish (formatter already works).
|
||||
- [x] Vendor hygiene stays clean: zero drift (all 10 manual vendors match pin), zero
|
||||
commonMain rule-1 violations, zero vendored files touched this session - verified via
|
||||
`check-vendor-drift.py`.
|
||||
- [~] SDL static lib slimmed to the used subsystem surface (§3) - **DONE + verified on
|
||||
macOS/Metal**; other hosts pending WIN-SMOKE.
|
||||
- [ ] Refs re-pinned to Compose **1.12.0 stable**, JVM parity versions bumped, WIN-SMOKE
|
||||
fidelity pass green on a real Windows host.
|
||||
- [x] `CLAUDE.md` documentation map consistent with tree - line-1 typo fixed; the stale
|
||||
historical docs `RENDERER.md` + `SKIKO-MINGW-FEASIBILITY.md` removed (current renderer
|
||||
essentials inlined into CLAUDE.md, the rest is in git history); all dead doc links
|
||||
(TODO.md / RENDERER.md / SKIKO) removed or repointed to PLAN.md.
|
||||
|
||||
## Landed toward 1.0.0 (this pass - all build + run verified on macOS/Metal)
|
||||
|
||||
Everything below is committed; details + file refs are in the sections that follow.
|
||||
|
||||
1. **Tab tofu fix** (§1a P0) - normalize `\t`→space before shaping so the Windows fork
|
||||
can't render `.notdef`. Platform-independent, length-preserving.
|
||||
2. **HiDPI caret quantization fix** (§2 P0) - pointer/wheel coords carried as `Float`
|
||||
end-to-end instead of truncated to `Int`.
|
||||
3. **Screen-reader no-op** (§2 P0) - `LocalPlatformScreenReader` defaults to inactive
|
||||
instead of throwing.
|
||||
4. **SDL slimming** (§3) - 10 unused subsystems disabled in `build-all.py`; 6 now-dead
|
||||
macOS frameworks dropped from `sdl3.def`. Rebuilt; demo + apidemo link, demo runs.
|
||||
5. **Context menu reconciled** (§2 P0) - confirmed already working via the vendored legacy
|
||||
path; the 3 "NOP" seams are vestigial (disabled new API) - misleading TODOs corrected.
|
||||
6. **`CDN_TEXT_METRICS` diagnostic** (§1b) - env-gated line-metrics dump; macOS baseline
|
||||
captured, hypothesis sharpened (fork likely drops lineHeight leading).
|
||||
7. **Doc hygiene** (§5) - removed the stale historical `RENDERER.md` +
|
||||
`SKIKO-MINGW-FEASIBILITY.md` (renderer essentials folded into `CLAUDE.md`; rest in git
|
||||
history), fixed the `CLAUDE.md` typo + all dead doc links; extracted the fork
|
||||
FreeType-scaler lead into §1c before deleting. Vendor drift verified clean.
|
||||
|
||||
**Windows pass (2026-08-04, this host - DPR 1).** On a real Windows host at last: (8) tab fix
|
||||
**CONFIRMED on-device** (§1a); (9) vertical metrics **RESOLVED** - native `--metricsprobe` heights
|
||||
== JVM `--metrics` exactly, feared "dropped leading" DISPROVEN (§1b); (10) fork `LineMetrics.
|
||||
ascent/descent` mis-decode **FIXED in shared Kotlin** (`SkiaParagraphOps.lineMetrics()`, guarded
|
||||
no-op on official skiko) - repairs Windows caret/selection; (11) fork FontMgr/ICU **AUDITED** - CJK
|
||||
+ color-emoji + control-char fallback all work → the "empty FreeType stub" fear DISPROVEN (§1c);
|
||||
(12) `FontRasterizationSettings.native.kt` verified to mirror upstream VERBATIM + given a
|
||||
`VENDOR-BASE` provenance line; rasterization stays upstream-faithful per-OS (user decision). Net:
|
||||
the fork's Skia matches upstream - the only deltas are its thin extern-C bindings, fixed in shared
|
||||
Kotlin, **zero fork rebuild**.
|
||||
|
||||
**Still not done - WIN-SMOKE interactive + release** (§5): interactive caret-height check (the one
|
||||
behavioral consequence of the LineMetrics fix), the `PrintWindow` probe, `compileCommonMainKotlin
|
||||
Metadata`, apiDump + publish FROM Windows, and the Compose 1.12.0-stable re-pin. **Deliberately
|
||||
deferred** (large/structural, rationale in-section): RTL, `PlatformFontLoader`, grapheme source-set
|
||||
move, date CLDR, brush/gradient text - plus the big **vendor-the-upstream-skiko-text-engine
|
||||
refactor (§6)**, sequenced after these fixes land.
|
||||
|
||||
---
|
||||
|
||||
## ▶ Continuing on Windows (mingwX64) - START HERE
|
||||
|
||||
**Read this before doing anything.** This file is the single source of truth for the road to
|
||||
1.0.0. Everything in **"Landed toward 1.0.0"** above is **already implemented and committed on
|
||||
`main`** - do NOT redo it, just verify it on this host. Every open `[ ]` / `[~]` below is
|
||||
remaining work. The macOS/Linux side is done and verified; the remaining blockers need a **real
|
||||
Windows host** - the mingwX64 build cross-compiled from mac/Linux fails at cinterop, and the
|
||||
bitsycore skiko **fork** can only be rebuilt on Windows.
|
||||
|
||||
**The Windows fidelity reference is skiko-on-JVM Compose Desktop on THIS Windows host**
|
||||
(`gradlew :demo:run` - also DirectWrite/system fonts), NOT macOS. mac↔Windows metric deltas are
|
||||
expected in upstream Compose Desktop too, so "matches Windows-JVM" is the acceptance bar, not
|
||||
"matches macOS".
|
||||
|
||||
### Build / run (Windows shell, repo root)
|
||||
|
||||
```bat
|
||||
gradlew.bat :demo:runDebugExecutableMingwX64 :: native demo (the port under test)
|
||||
gradlew.bat :apidemo:runDebugExecutableMingwX64 :: native apidemo (has the Session/Override buttons)
|
||||
gradlew :demo:run :: JVM Compose Desktop = the FIDELITY REFERENCE
|
||||
gradlew :apidemo:run :: JVM apidemo reference
|
||||
```
|
||||
|
||||
- The bridge plugin auto-drops `skiko-windows-x64.dll` next to the exe (`installWindowsSkiaDll`).
|
||||
Fork coords `com.bitsycore.skiko:skiko:0.150.1-mingw.2` (override `-PskikoMingwVersion`).
|
||||
- `:demo` CLI flags (native AND jvm): `--screen=<Name>`, `--screenshot=<path.bmp>` (capture at
|
||||
quiescence then quit), `--gpu=auto|software|skia.opengl`. `CDN_TEXT_METRICS=1` env var dumps
|
||||
text metrics (§1b). `CDN_PROFILE=1` frame profiler. `:apidemo` has NO screenshot CLI - drive it
|
||||
by hand for the button-metrics visual.
|
||||
- If an IC-cache error appears after any module churn: delete `demo\build\kotlin-native-ic-cache`.
|
||||
|
||||
### ✅ Windows session results (2026-08-04, this host - DPR 1.0)
|
||||
|
||||
Items 1–3 below are **DONE + verified on the shipped mingwX64 demo binary**; findings folded into
|
||||
§1a/§1b/§1c. Only 4–5 (WIN-SMOKE interactive gate + apiDump/publish) remain. TL;DR:
|
||||
|
||||
- **§1a tab fix - CONFIRMED.** `--screen=BasicText` with `"start>a\tb<end\tcols\tand\tthere"`
|
||||
rendered `start>a b<end cols and there` - every `\t` a SPACE, zero `.notdef`. (Structural proof
|
||||
too: the whole module has exactly 2 `.addText(` sites, both `shapedText`.)
|
||||
- **§1b vertical metrics - RESOLVED, the feared bug is DISPROVEN.** Native `--metricsprobe` vs JVM
|
||||
`--metrics` (same host, NotoSans, density 1): **every paragraph height + non-M3 baseline matches
|
||||
JVM EXACTLY** (size 11→24). Leading is NOT dropped. Two residual deltas found (details in §1b):
|
||||
(a) the fork's `LineMetrics.ascent/descent` are mis-decoded - **fixed in Kotlin** this session;
|
||||
(b) M3 baseline drifts ≤0.77px (halfLeading, a shared-engine omission on ALL platforms, sub-pixel).
|
||||
- **§1c FontMgr/ICU - the "empty FreeType stub" fear is DISPROVEN.** Native render of CJK
|
||||
(你好世界 日本語 한국어) + **full-COLOR emoji** (😀🎉🚀❤) + control chars (nbsp/zwsp/en-dash) = zero
|
||||
tofu. Color emoji + CJK fallback prove `FontMgr.default` is a real DirectWrite-backed system
|
||||
manager, not an empty custom FreeType one. Remaining §1c work is doc-only (capture `args.gn`).
|
||||
|
||||
Reference commands actually used (both probes print `metrics:` lines; native runs a real window):
|
||||
```bat
|
||||
demo\build\bin\mingwX64\debugExecutable\demo.exe --metricsprobe :: native/fork metrics
|
||||
gradlew :demo:run --args="--metrics" :: JVM/upstream metrics
|
||||
set CDN_TEXT_METRICS=1 && ...demo.exe --screen=Buttons :: raw per-paragraph dump
|
||||
```
|
||||
(The JVM leg's screenshot flag is `--screenshot-all=<dir>`, not the native `--screenshot=<file>`.)
|
||||
|
||||
### Ordered Windows work (do top-to-bottom; full detail in the numbered sections below)
|
||||
|
||||
1. **[§1a] Confirm the tab fix - ✅ DONE** (see session results above). Rendered a literal-tab
|
||||
string on the fork; all `\t`→space, no tofu. The `SkiaParagraphEngine.shapedText` normalization
|
||||
holds; no bypass path exists (2 `.addText(` sites, both `shapedText`).
|
||||
|
||||
2. **[§1b] Confirm/resolve vertical metrics - ✅ RESOLVED (feared bug DISPROVEN).** Used the
|
||||
`--metricsprobe` (native) vs `--metrics` (JVM) pair, NOT the screenshot diff (the JVM leg has no
|
||||
`--screenshot=<file>`; it dumps the same `metrics:` table). Every paragraph HEIGHT matches JVM
|
||||
exactly (leading applied identically) → correct-by-reference. Only a ≤0.77px M3-baseline drift
|
||||
remains (halfLeading, all platforms). Separately fixed the fork's broken `LineMetrics.ascent/
|
||||
descent` in Kotlin. Full data in §1b.
|
||||
|
||||
3. **[§1c] Audit the fork FontMgr / gamma / ICU - ✅ AUDITED (fallback WORKS).** CJK + color emoji +
|
||||
control chars all render with zero tofu on the fork → `FontMgr.default` is a real system
|
||||
(DirectWrite) manager with working glyph fallback, NOT an empty FreeType stub. Remaining: capture
|
||||
the fork's REAL `args.gn` (`SK_GAMMA_*`, ICU packaging, freetype-vs-dwrite) into the fork repo so
|
||||
drift is auditable (doc-only, not a code blocker).
|
||||
|
||||
4. **[§5] WIN-SMOKE gate** (pre-ship): (1) the §1b metrics dump, (2) tab/control-char render clean,
|
||||
(3) the Windows-only `PrintWindow` probe (`scripts/probe/`), (4) `gradlew
|
||||
:<module>:compileCommonMainKotlinMetadata` (only the Windows job compiles common metadata).
|
||||
|
||||
5. **[§5] apiDump + publish FROM Windows** (host-specific; only the Windows job carries the full
|
||||
mingwX64 variant table). Then, when Compose **1.12.0 stable** ships, do the re-pin + version
|
||||
bump. Release runbook: [TOOLING.md](TOOLING.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Cross-platform FIDELITY (headline)
|
||||
|
||||
All Windows divergences live **below Kotlin** - the mingw source set reuses
|
||||
`src/skikoRendererMain/kotlin` byte-for-byte (`compose/ui/ui/build.gradle.kts:102-113`),
|
||||
the only mingw-unique file is `PlatformGpu.mingw.kt` (13 lines, GPU only). So every
|
||||
divergence is in the **fork's Skia binary**, in **`FontMgr.default`'s per-OS impl**, or in
|
||||
the **GPU backend**. The fork is external (`bitsycore/skiko`,
|
||||
`com.bitsycore.skiko:skiko:0.150.1-mingw.2`) and its GN args are **not verifiable from this
|
||||
tree** - recovering them is a prerequisite for several fixes below.
|
||||
|
||||
**Fidelity reference for Windows is skiko-on-JVM Compose Desktop on Windows** (also
|
||||
DirectWrite), NOT macOS/Linux. macOS↔Windows metric deltas partly exist in upstream
|
||||
Compose Desktop too (per-platform font backends). Reframe V-items as "match JVM-Windows"
|
||||
acceptance tests, not "bugs vs macOS".
|
||||
|
||||
### 1a. Windows tab-character tofu - HIGH CONFIDENCE (root cause traced)
|
||||
|
||||
Kotlin sets `ParagraphStyle.replaceTabCharacters = true`
|
||||
(`compose/ui/ui-text/src/skikoRendererMain/kotlin/.../renderer/skia/SkiaParagraphEngine.kt:234-236`,
|
||||
exact mirror of upstream `ParagraphBuilder.skiko.kt:625`, CMP-6589). Works on
|
||||
macOS/Linux (official skiko honors the flag → `U+0009` replaced by space before shaping).
|
||||
On Windows the same call runs against the fork's flat extern-C DLL, which evidently does
|
||||
**not** wire the setter → raw `\t` reaches HarfBuzz → NotoSans has no tab glyph → `.notdef`
|
||||
box. Font fallback is a **red herring**: no font has a tab glyph, only tab→space
|
||||
replacement cures it.
|
||||
|
||||
- [x] **P0** Confirm - ✅ DONE (Windows 2026-08-04): rendered a literal-tab string on the fork
|
||||
(`--screen=BasicText`); every `\t` shows a SPACE, no `.notdef`. The fork renders everything
|
||||
else clean too, so tab→space was the only gap. Structural backstop: the module has exactly 2
|
||||
`.addText(` sites, both `shapedText` - no raw tab can reach skiko.
|
||||
- [x] **P0** Fix (ship now, author project code): in
|
||||
`.../SkiaParagraphEngine.kt`, feed skiko a normalized copy - added
|
||||
`private val shapedText = if (text.indexOf('\t') >= 0) text.replace('\t', ' ') else text`,
|
||||
used at `addText` (no-span path) and via `shapedText.substring(segStart, segEnd)`
|
||||
(span path); `text` kept for ALL length/index queries (`getRectsForRange`,
|
||||
`getCursorRect`, `wordBoundary`, `lineMetrics`, span cut-points). Length-preserving so
|
||||
every offset consumer stays correct; this is exactly what Skia's flag does internally →
|
||||
upstream-faithful; makes behavior platform-independent (no fork rebuild needed).
|
||||
Kept `replaceTabCharacters = true` (harmless/redundant elsewhere).
|
||||
**DONE** - compiles clean on macosArm64. Windows tofu render still needs on-device
|
||||
confirmation (WIN-SMOKE §5).
|
||||
- [ ] **P2** Long-term true fix (fork, out of tree): wire
|
||||
`ParagraphStyle::setReplaceTabCharacters` through the fork's extern-C surface,
|
||||
rebuild/republish `skiko-windows-x64.dll`, bump `-PskikoMingwVersion`. Do this *in
|
||||
addition to* the Kotlin fix, not instead - it keeps the port thin. Can't be verified
|
||||
in-tree.
|
||||
|
||||
### 1b. macOS-vs-Windows vertical text metrics - ✅ RESOLVED (Windows 2026-08-04): heights match JVM, feared bug DISPROVEN
|
||||
|
||||
**WINDOWS RESULT (2026-08-04).** All targets render through the SAME Skia, so metrics are expected
|
||||
to be similar - and they are. Native `--metricsprobe` (fork) vs JVM `--metrics` (upstream skiko) on
|
||||
this host (NotoSans, density 1) print IDENTICAL paragraph heights and non-M3 baselines for every
|
||||
size 11→24 (`cell`/`one`/`three`/`oneM3`/`threeM3`, `base1`/`base3` all match). The fork does NOT
|
||||
drop lineHeight leading - the hypothesis below is disproven. Two small deltas remained, both
|
||||
handled in SHARED Kotlin (no fork rebuild, keeping fork-reliance minimal):
|
||||
|
||||
1. **Fork `LineMetrics.ascent/descent` mis-decode → FIXED in Kotlin (this session).**
|
||||
`CDN_TEXT_METRICS` showed the fork's per-line `ascent`/`descent` constant (18.837/5.163)
|
||||
regardless of `fontPx`, while `baseline`/`height`/`width` are correct (mac scales them:
|
||||
37.674@30px). This is the fork's flat extern-C LineMetrics binding, NOT a Skia difference.
|
||||
Lines stack contiguously so true ascent = `baseline - lineTop`; `SkiaParagraphOps.lineMetrics()`
|
||||
now rebuilds ascent/descent from the reliable baseline+height when they're internally
|
||||
inconsistent (guard `|ascent-(baseline-lineTop)|>0.5` = no-op on official skiko). Repairs caret
|
||||
height / getLineTop/Bottom / vertical hit-test on Windows. **Behavioral caret check → WIN-SMOKE.**
|
||||
2. **M3 baseline drift ≤0.77px (halfLeading) - SHARED-engine omission, all platforms.** `base1M3`
|
||||
differs native-vs-JVM by ≤0.77px in the 11→24 sweep (size 14 → 0.28px, sub-pixel), 2.19px in
|
||||
the contrived `boundary 24/25` case. Root cause is NOT the fork: `SkiaParagraphEngine` maps
|
||||
`LineHeightStyle.trim`→HeightMode but never wires `halfLeading`/`LineHeightStyle.Alignment`, so
|
||||
macOS-native drifts from JVM by the same amount (parity tolerated it as sub-pixel). Fixable in
|
||||
shared Kotlin (`textStyle.halfLeading` from the alignment); deferred pending a cross-platform
|
||||
parity re-check (can't run mac parity from a Windows host).
|
||||
|
||||
Native-fork metricsprobe vs JVM `--metrics` (this host) - identical except `base?M3`:
|
||||
```
|
||||
size=14 lh=20 cell=19 one=19 three=59 base1=14.898 base3=54.898 oneM3=20 threeM3=60 base1M3: 15.682 fork / 15.398 jvm
|
||||
size=24 lh=30 cell=33 one=33 three=93 base1=25.968 base3=85.968 oneM3=30 threeM3=90 base1M3: 23.546 fork / 24.312 jvm
|
||||
boundary 24/25 m3=25 base: 19.622 fork / 21.812 jvm
|
||||
```
|
||||
|
||||
The vertical-layout code path is **identical** on all targets. The lineHeight/HeightMode
|
||||
path is a byte-faithful port of upstream and is NOT the cause: m3 `labelLarge` has
|
||||
lineHeight 20sp > fontSize 14sp with `LineHeightStyle.Default.trim == Both` →
|
||||
`HeightMode.DISABLE_ALL`, which collapses the single-line box to `fontAscent + fontDescent`
|
||||
of the font itself (`SkiaParagraphEngine.kt:244-249`, matches upstream
|
||||
`ParagraphBuilder.skiko.kt:631-646`). So box height is driven entirely by the font's
|
||||
reported ascent/descent.
|
||||
|
||||
Root cause: **`FontMgr.default` resolves to a different Skia font scaler per build** for
|
||||
the *same* `font/NotoSans.ttf` bytes (`SkiaFonts.kt:38,44,50`) - CoreText on macOS,
|
||||
fontconfig/FreeType on Linux, DirectWrite (or FreeType, unverified) in the fork on Windows.
|
||||
Different backends select different metric tables (hhea `ascender/descender` vs OS/2
|
||||
`sTypoAscender/Descender` vs `usWinAscent/Descent`) and honor the OS/2 `USE_TYPO_METRICS`
|
||||
fs_selection bit differently. NotoSans is the textbook case (large top-heavy ascent).
|
||||
`SkiaParagraphEngine.kt:147-148` read `-defaultFont.metrics.ascent`/`.descent`;
|
||||
`:150-169 lineMetrics()` forwards skiko's numbers verbatim; `SkiaParagraph.native.kt:116,148-153`
|
||||
consume them. Contributors V2 (hinting Normal on Windows grid-fits vertically, CoreText
|
||||
ignores it) and V3 (subpixel/edging) are secondary and per-scaler.
|
||||
|
||||
**Hypothesis, not confirmed:** whether Windows-native actually *diverges from
|
||||
Windows-JVM*. If it matches JVM-Windows it's correct-by-reference (not a bug), and the
|
||||
macOS↔Windows delta is expected upstream behavior.
|
||||
|
||||
- [x] **P0** Confirm FIRST - ✅ RESOLVED (Windows 2026-08-04; heights == JVM, see WINDOWS RESULT
|
||||
above). Diagnostic **LANDED**: `SkiaParagraphEngine.kt` dumps
|
||||
`defaultFont.metrics.{ascent,descent,leading}`, `paragraph.height`,
|
||||
`lineMetrics[0].{ascent,descent,baseline,height}`, `familyName` + `fontPx` per built
|
||||
paragraph when `CDN_TEXT_METRICS=1` (mirrors the `CDN_PROFILE` env-flag convention).
|
||||
Run `CDN_TEXT_METRICS=1 <app> --screen=Buttons` (native) and the JVM parity app on the
|
||||
SAME host, diff the lines. macOS baseline captured (see below). **REMAINING (Windows
|
||||
host):** compare Windows-native vs **skiko-JVM-Windows**. Expectation: `fontPx` +
|
||||
lineHeight + heightMode identical, `metrics.ascent` differs by scaler.
|
||||
**Acceptance test:** Windows-native == skiko-JVM-Windows (→ correct-by-reference, not a
|
||||
bug); if they differ, it's a fork scaler fix (next item).
|
||||
|
||||
**macOS baseline captured (`--screen=Buttons`, DPR 2):** for the fontPx=30 button
|
||||
labels ("Filled Button" / "OutlinedButton" / "TextButton"): font metrics
|
||||
`ascent=-32.07 descent=8.79 leading=0` (font box = **40.86px**), but the line box is
|
||||
`line0[ascent=37.67 descent=10.33 height=48.0]`, `paraHeight=48`. So macOS DOES apply
|
||||
the lineHeight leading (48 > 40.86) and distributes it **asymmetrically - ~5.6px above,
|
||||
~1.5px below** → exactly the "extra space on top of the text" the report describes.
|
||||
**SHARPENED HYPOTHESIS:** this is NOT a mac bug - it's correct lineHeight distribution.
|
||||
The likely Windows story is the MIRROR of §1a: the fork's flat extern-C surface doesn't
|
||||
wire the `ParagraphStyle` height/strut path, so Windows renders **tight to the font box
|
||||
(~41px, no leading)** = the "fitted to text" look. **Sharpened WIN-SMOKE check:** for the
|
||||
SAME label, compare `paraHeight` + `line0.height`. If macOS/JVM = 48 but Windows-native
|
||||
≈ 41, the fork drops lineHeight leading (fork fix: wire `heightMode`/strut, like
|
||||
`replaceTabCharacters`); a Kotlin fallback is a manual `StrutStyle`. If Windows-native
|
||||
== Windows-JVM, it's correct-by-reference.
|
||||
|
||||
**macOS baseline raw lines** (`CDN_TEXT_METRICS=1 demo --screen=Buttons`, DPR 2) - diff
|
||||
Windows-native + Windows-JVM against these; the fontPx=30 rows are the cleanest signal:
|
||||
```
|
||||
text='Filled Button' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0]
|
||||
text='OutlinedButton' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0]
|
||||
text='TextButton' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0]
|
||||
text='Outlined' fontPx=28.0 ascent=-29.932007 descent=8.203980 leading=0.0 paraHeight=40.0 line0[ascent=31.395 descent=8.605 baseline=31.364 height=40.0]
|
||||
```
|
||||
(family='Noto Sans' on all.) Expectation if correct: `fontPx`, `paraHeight`, `line0.height`
|
||||
match JVM-Windows exactly; the raw `ascent`/`descent` may differ from these macOS numbers
|
||||
because the Windows scaler is FreeType (§1c), but the LINE BOX must still match JVM-Windows.
|
||||
- [x] **P1** ~~If Windows-native != Windows-JVM: fix in the fork~~ - **N/A (2026-08-04):** heights
|
||||
MATCH JVM-Windows exactly, so no fork scaler/FontMgr change is needed for vertical metrics.
|
||||
The `makeFromData` typefaces already get the same metric-table selection as skiko-JVM-Windows.
|
||||
The one residual (M3 halfLeading) is a shared-Kotlin fix, not a fork one.
|
||||
- [ ] **P2** (Optional, STRONGER than upstream) If cross-platform pixel-identity is
|
||||
wanted over matching each host's stock Compose Desktop: build a single
|
||||
FreeType/`SkFontMgr_Custom_Empty`-backed `SkFontMgr` used on ALL native targets for
|
||||
the bundled fonts, so NotoSans yields identical ascent/descent everywhere.
|
||||
Intentional departure from per-platform upstream behavior - decide deliberately.
|
||||
- [ ] **DO NOT** "fix" by injecting a default `LineHeightStyle` or clamping ascent in
|
||||
`SkiaParagraphEngine.kt` - that desyncs from upstream `DISABLE_ALL` and masks, not
|
||||
resolves, the metric divergence.
|
||||
|
||||
### 1c. Other fork-vs-official divergences
|
||||
|
||||
- [x] **P1** ~~`FontMgr.default` may be an empty/stub manager on the fork~~ - **DISPROVEN
|
||||
(Windows 2026-08-04).** Native render of CJK (你好世界 日本語 한국어) + **full-COLOR emoji**
|
||||
(😀🎉🚀❤) + control chars = zero tofu. Color emoji + CJK fallback are impossible with an empty
|
||||
custom FreeType manager, so the fork's `FontMgr.default` IS a real DirectWrite-backed system
|
||||
manager with working glyph enumeration/fallback. No fork change needed. `SkiaFonts.kt` wiring
|
||||
stays as-is.
|
||||
- [x] **P1** `FontMgrWithFallback` - ANALYZED + fallback now VERIFIED on Windows. The port
|
||||
does `setDefaultFontManager(fontMgr)` [system] + `setAssetFontManager(provider)`
|
||||
[bundled] (`SkiaFonts.kt:49-52`). This is **functionally equivalent** for missing-glyph
|
||||
fallback on macOS/Linux/Windows-JVM: skiko's shaper tries the asset provider (bundled
|
||||
Noto + aliases) first, then the default/system manager for CJK/emoji fallback, which
|
||||
resolves because the system FontMgr has those fonts. Rewriting to
|
||||
`setDefaultFontManager(FontMgrWithFallback(provider))` risks a macOS fallback
|
||||
regression with no observable gain, and I can't visually verify CJK/emoji fallback on
|
||||
this host. **The real gap was thought to be Windows-fork-only** (fork `FontMgr.default`
|
||||
empty stub → no system fallback) - but that's **DISPROVEN (2026-08-04):** CJK + color-emoji
|
||||
fallback works on native Windows too (item above). So the setDefault/setAsset wiring is fine
|
||||
on ALL targets. **Decision:** leave the Kotlin as-is; nothing to fix in the fork here. Re-open
|
||||
only if a CJK/emoji fallback bug is actually observed.
|
||||
- [x] **P2** ~~ICU/unicode data packaged differently in the fork~~ - **VERIFIED OK (2026-08-04).**
|
||||
Rendered U+00A0 (nbsp → visible space), U+200B (zwsp → zero-width/collapsed), en-dash, and
|
||||
U+0009 (tab, pre-replacement) on native Windows: all classified/handled correctly, no tofu.
|
||||
The force-exported `uloc_*_skiko` symbols do their job; skunicode classification works. (This
|
||||
is also why HarfBuzz shaping + skparagraph bidi work at all - ICU data is present via
|
||||
icudtl.dat.)
|
||||
- [~] **P2** Text gamma/AA edges: a Skia **build-time constant**
|
||||
(`SK_GAMMA_EXPONENT`/`SK_GAMMA_CONTRAST`/`SK_GAMMA_APPLY_TO_A8`), NOT a GL-vs-Metal
|
||||
runtime difference. **Note (2026-08-04):** native Windows text AA looked clean in the render
|
||||
(no visible AA anomaly), so no fork gamma fix is pursued for 1.0. Still worth capturing the
|
||||
fork's values in the fork repo (see the args.gn item below). **Kotlin-side rasterization
|
||||
settled:** the `RASTER_EDGING/HINTING/SUBPIXEL` in `SkiaParagraphEngine` come from
|
||||
`FontRasterizationSettings.PlatformDefault`, which was verified (2026-08-04) to reproduce
|
||||
upstream Compose Desktop's per-OS defaults VERBATIM (Win/mac=Normal hinting, Linux=Slight).
|
||||
**User decision:** keep the upstream-faithful per-OS behavior (match each platform's stock
|
||||
CMP), NOT a forced uniform cross-platform look. So there is no SDL_TTF-era gamma hack left to
|
||||
strip - Skia is configured exactly as upstream does it.
|
||||
- [x] **GL-vs-Metal AA/gamma/color-space - RULED OUT as a primary cause.** Both bridges
|
||||
create the surface `colorSpace = null` (`SkiaGLBridge.kt:68-74`,
|
||||
`SkiaMetalBridge.kt:133-139`) - identical un-color-managed legacy blending. Only
|
||||
deltas are cosmetic-correct: `RGBA_8888`/`BOTTOM_LEFT` (GL) vs `BGRA_8888`/`TOP_LEFT`
|
||||
(Metal) - channel order + Y-flip, matched to buffers. Windows uses OpenGL
|
||||
(`PlatformGpu.kt:20`), same backend as Linux, so GL-vs-Metal can't explain a
|
||||
Windows-vs-Linux gap at all. Deprioritized - no action.
|
||||
- [~] **P1** The fork's Skia build config (GN args). **Lead extracted from git history**
|
||||
(the old `SKIKO-MINGW-FEASIBILITY.md`, deleted as stale - its build recipe lives in
|
||||
`git show bdb5c64d^:SKIKO-MINGW-FEASIBILITY.md`): the fork's Route-1a recipe builds Skia
|
||||
with **`skia_use_freetype`**, i.e. the Windows text scaler is **FreeType**, not
|
||||
DirectWrite and not macOS CoreText - a concrete reason the SAME NotoSans bytes yield
|
||||
different ascent/descent on Windows (different metric-table selection) AND why fork
|
||||
`FontMgr.default` may not enumerate Windows system fonts for glyph fallback (FreeType
|
||||
has no system fontmgr without fontconfig). **CAVEAT:** that's the *recommended recipe*,
|
||||
not a verified dump of the shipped `0.150.1-mingw.2` GN args. **REMAINING:** whoever
|
||||
rebuilds the fork should capture the real `args.gn` (`SK_GAMMA_*`, ICU packaging,
|
||||
freetype-vs-dwrite) alongside the fork sources so drift is auditable - NOT as a doc in
|
||||
this repo (kept lean); a comment in the fork repo or `build-sdl.properties`-style pin.
|
||||
|
||||
---
|
||||
|
||||
## 2. VENDORING & upstream-fidelity debt
|
||||
|
||||
Vendor hygiene is **release-ready**: `check-vendor-drift.py` reports all 10 manual vendors
|
||||
match pin `v1.12.0-beta03+dev4483`; zero commonMain rule-1 violations (the 3 authored
|
||||
`androidx.compose.*` files in commonMain are all provenance-tracked Rule-3 vendors). The
|
||||
macosMain DIAGNOSTIC GAP families (`ui/ui` 12, `foundation` 23: `CoreTextField.macos.kt`,
|
||||
`SelectionManager.macos.kt`, `PlatformClipboard.macos.kt`, …) are **correctly excluded**
|
||||
(AppKit/NSView; the port uses SDL) - keep them in gaps. **Do not spend 1.0.0 effort
|
||||
re-vendoring.** The debt is **completing native-actual stubs**.
|
||||
|
||||
### P0 - blocks fidelity/correctness
|
||||
|
||||
- [~] **P0** Text context menu (right-click copy/paste/select-all) - **RECONCILED: already
|
||||
works, no reimplementation.** Static trace confirmed the menu is wired end-to-end
|
||||
through the vendored LEGACY path: text-level `ContextMenuArea` (`ContextMenu.native.kt`)
|
||||
→ `CommonContextMenuArea` (`vendor/common/.../text/CommonContextMenuArea.kt`) → (native
|
||||
`ComposeFoundationFlags.isNewContextMenuEnabled = false`, so the legacy branch) →
|
||||
`contextmenu.ContextMenuArea` → `contextMenuGestures`/`onRightClickDown`
|
||||
(`isSecondaryPressed`) → `ContextMenuPopup` → `Popup.native.kt` (hosted by
|
||||
`LocalPopupHost` at the app root). Items are real: `TextFieldSelectionManager` /
|
||||
`SelectionManager.contextMenuBuilder` emit Cut/Copy/Paste/SelectAll with real actions.
|
||||
SDL right-click → `PointerButton.Secondary` (`AppEvent.kt`) →
|
||||
`isSecondaryPressed` (`PointerEventBridge`). The three "NOP" seams belong to the
|
||||
DISABLED *new* context-menu API and are unreachable - **DONE:** their misleading
|
||||
`TODO(CMP-7819)` comments corrected to say so, in all three files.
|
||||
**REMAINING:** an interactive right-click smoke check (no headless driver here - fold
|
||||
into manual/WIN-SMOKE). Known limitation, not a breakage: Paste enablement is
|
||||
plain-text-only (`ClipboardPasteState.hasClip = hasText`, see P2 below).
|
||||
- [ ] **P1** DatePicker/TimePicker localization - VERIFIED current state:
|
||||
`material3/.../internal/PlatformDateFormat.native.kt` is a real kotlinx-datetime
|
||||
formatter that DOES honor CLDR patterns/skeletons (renders "Jul 29, 2026" /
|
||||
"July 2026" correctly - the agent's "always yyyy-MM-dd" finding was stale). Genuine
|
||||
remaining gaps, all narrower than a correctness blocker: field NAMES English-only
|
||||
(`:29 weekdayNames`, `:147-154 MONTH_NAMES/ABBR` - no CLDR data bundled), `:27
|
||||
firstDayOfWeek=1` fixed Sunday, `:67 is24HourFormat()=true` fixed, `:46 parse()`
|
||||
ISO-8601-only. Upstream `darwinMain` uses `NSDateFormatter` (not portable). **Fix
|
||||
(author):** bundle a CLDR subset or K/N i18n lib for localized names + locale-aware
|
||||
first-day/24h; also unblocks `CalendarLocale.native.kt:20` (fixed `"en"`). Ships
|
||||
readable English dates today - polish, not a P0 gate.
|
||||
- [x] **P0** Float pointer coordinates - `AppEvent.kt` `.toInt()`-truncated SDL's
|
||||
Float `mb.x`/`mb.y`/`mm.x`/`mm.y` and the wheel `mouse_x/y` before the DPR multiply in
|
||||
`ComposeWindow.kt`. On 2× displays a click at logical 100.9 → physical 200 not ~201,
|
||||
quantizing the caret to 2px steps near glyph edges. **DONE:** widened
|
||||
`LegacyPointerEvent.x/y` and `AppEvent.MouseWheel.x/y` to `Float`, dropped the
|
||||
`.toInt()` at the three mapper construction sites, and removed the now-redundant
|
||||
`.toFloat()` at the two `ComposeWindow` read sites. Consumers fully contained (verified
|
||||
by grep). Builds + runs clean on macosArm64.
|
||||
|
||||
### P1 - quality / parity
|
||||
|
||||
- [ ] **P1** RTL text unsupported - `ui-text/.../SkiaParagraph.native.kt:112`
|
||||
`textDirection = ResolvedTextDirection.Ltr` fixed field; `getParagraphDirection()`
|
||||
(`:202`) always Ltr. Bidi run direction (`:204`) does read box direction (partial
|
||||
machinery). Root cause: `text/intl/Locale.native.kt isRtl()` unimplemented. Fix
|
||||
(author) tied to P1.2 engine.
|
||||
- [ ] **P1** `PlatformFontLoader` NOP - `ui-text/.../font/FontFamilyResolver.native.kt`
|
||||
`SdlPlatformFontLoader.loadBlocking`/`awaitLoad` NOP;
|
||||
`PlatformFontFamilyTypefaceAdapter.resolve` returns `Immutable(Unit)`. Raw androidx
|
||||
`Font(bytes)`/`ResourceFont` don't load (compose-resources `Font()` works). Upstream
|
||||
`SkiaFontLoader.skiko.kt` is the vendorable reference. This is the long-deferred P3.2
|
||||
font resolver - **high-risk L**, parity harness wouldn't exercise it. Scope
|
||||
deliberately for 1.0.0 (accept-and-document vs attempt). **NOW ALSO GATES §6** - the
|
||||
2026-08-04 spike found the verbatim engine-vendor routes font resolution through this NOP, so
|
||||
implementing this loader (so `SkiaFonts`' icon/variable-axis model rides upstream's seam) and
|
||||
the §6 engine-vendor are ONE effort. Do them together, with macOS/Linux verification.
|
||||
- [ ] **P1** `CharHelpers.skiko.kt` grapheme-break logic - the ONE cheap selective-vendor
|
||||
win from the hand-rolled text engine. Fixes `findPrecedingBreak`/`findFollowingBreak`
|
||||
splitting emoji/combining marks in `StringHelpers.native.kt:31` +
|
||||
`CharHelpers.native.kt:14`. (Giving `:foundation` a `skikoRenderer` source set would
|
||||
unblock this + `DragAndDropSource.skiko` - L-effort structural move, per §13a.)
|
||||
- [~] **P1** `Serif`/`Cursive` generic families - RE-SCOPED after reading `SkiaFonts.kt`.
|
||||
They do NOT silently collapse to sans: `baseTypeface()` routes `generic:serif` /
|
||||
`generic:cursive` through `resolveGeneric()` → `fontMgr.matchFamilyStyle()` over a
|
||||
per-OS candidate list (`genericFamilyAliases`: Times/Noto Serif/… on each host), only
|
||||
falling back to the bundled default if NONE is installed. So serif/cursive already
|
||||
render as a real serif/cursive wherever the OS ships one (all three target OSes do).
|
||||
The only true gap is **cross-platform pixel-identity** / a host with no serif installed,
|
||||
which needs a BUNDLED Noto Serif - an app-level opt-in (buildSrc `downloadNotoFonts` +
|
||||
data.kres size cost), not a library correctness bug. **Decision:** leave library
|
||||
resolution as-is; offer bundled-serif as an opt-in later if an app needs it.
|
||||
- [x] **P1** Document the hand-rolled text engine as an **accepted architectural
|
||||
deviation** - **DONE:** rationale header added to the top of
|
||||
`ui-text/compose-fork.txt` (the exact place someone would go to "fill" the gaps),
|
||||
explaining the reduced local engine, the flat-source-set reason, and that RTL /
|
||||
stroke-DrawStyle / grapheme reductions are tracked here (§2), not fixed by vendoring.
|
||||
Also reflected in `SkiaParagraphEngine.kt`'s header and CLAUDE.md's renderer summary.
|
||||
|
||||
### P1/P2 - reduced-coverage + accessibility
|
||||
|
||||
- [ ] **P1** Text paint path handles only `SolidColor`
|
||||
(`SkiaParagraph.native.kt:253-256`) - no brush/gradient text fill, `drawStyle`
|
||||
(stroke), or non-`SrcOver` blendMode. Part of the reduced style coverage; extend on
|
||||
the local engine.
|
||||
- [ ] **P1** `SkiaParagraphEngine.kt:150-164` span segmentation drops partial `SpanStyle`
|
||||
overlaps (keeps only fully-covering `start<=segStart && end>=segEnd`). Documented
|
||||
divergence (`b63-upstream-text-mingw.md`) - widen to true interval segmentation.
|
||||
- [~] **P1** Accessibility absent - `ComposeOwner.kt:~340` builds a `semanticsOwner` never
|
||||
traversed to any OS a11y API; `SemanticsRegion.native.kt intersect()/difference()`
|
||||
hardcode `false`. Not vendorable (NSAccessibility/UIA/AT-SPI). **Decision for 1.0.0:
|
||||
out of scope for desktop.** The full a11y pipeline stays P2/out.
|
||||
- [x] **P0** No-op `PlatformScreenReader` - `CompositionLocals.native.kt`
|
||||
`LocalPlatformScreenReader` default no longer throws (`error(...)`); it returns an
|
||||
`InactivePlatformScreenReader` (`isActive = false`) so a11y-gated vendored code
|
||||
degrades to "no reader present" instead of crashing. **DONE** - builds + runs clean.
|
||||
- [ ] **P2** `SkiaFonts.kt` fidelity: `resolveCache` unbounded (font-cache half of P3.4
|
||||
deferred); variation key passes `null` density (`:65,78`) so density-dependent `opsz`
|
||||
in sp resolves wrong. `NamedFont.equals/hashCode` axis identity reportedly fixed -
|
||||
re-verify.
|
||||
|
||||
### P2 - cosmetic / edge
|
||||
|
||||
- [ ] **P2** `BlendMode.Multiply` renders wrong - opaque cyan×yellow reads back blue;
|
||||
likely Metal premultiply in graphics-layer flatten, not the `toSkia()` map. Isolate on
|
||||
the Skia draw path.
|
||||
- [ ] **P2** Drag-OUT of window NOP - `ui/.../draganddrop/Sdl3DragAndDropOwner.kt:39`
|
||||
`requestDragAndDropTransfer`; `DragAndDropSource.native.kt:36` no drag-shadow. SDL3
|
||||
has no portable start-drag → needs NSDraggingSession/DoDragDrop/XDND per-OS.
|
||||
Drop-INTO works. Accepted 1.0 gap (SDL platform limit) - document.
|
||||
- [ ] **P2** `NativeStringDelegate.native.kt:17` `toUpper/toLowerCase` ignore `locale`
|
||||
(Turkish i). Upstream `darwin` unvendorable (NSString).
|
||||
- [ ] **P2** Prefetch scheduler NOP - `platform/PrefetchLocals.native.kt:18`, lazy lists
|
||||
skip ahead-of-time composition (scroll-in jank).
|
||||
- [ ] **P2** `ClipboardPasteState.hasClip` aliased to `hasText`
|
||||
(`TextFieldSelectionState.native.kt:43`) - image-only clipboard undetected.
|
||||
- [ ] **P2** `ComposeOwner.kt` gaps: `autofill`/`autofillManager` null (`:324`),
|
||||
`hapticFeedBack` NOP (`:279`), deprecated `clipboardManager` NOP (`:287`, real
|
||||
`LocalClipboard` works), `textToolbar` stub (`:311`, touch-gated). No multi-monitor
|
||||
enumeration (`SDL_GetDisplays` unused). Most match upstream desktop - verify, then
|
||||
leave or fill.
|
||||
- [x] **P2** `installGlobals()` → `registerGenericFonts()`: VERIFIED not a hot-path cost -
|
||||
`registerGenericFonts()` already early-returns on a `fRegistered` flag
|
||||
(`GenericFonts.kt:20-26`), so the per-event call is a single boolean check, not a
|
||||
re-register. Original premise inaccurate; no change needed. (Minor residue: the
|
||||
`ImeBridge.onSessionActiveChange` lambda is reassigned per event - negligible, left
|
||||
as-is since it repoints per active window.)
|
||||
|
||||
---
|
||||
|
||||
## 3. SDL slimming
|
||||
|
||||
Static lib built from source per host (`scripts/build-sdl/build-all.py`, ref
|
||||
`release-3.4.12`); linked into the exe via `sdl3.def` (`staticLibraries=libSDL3.a`). Today
|
||||
only tests/examples + Windows D3D12/GPU are off (`build-all.py:311-319`). `SDL_Init` uses
|
||||
**`SDL_INIT_VIDEO` only** (`Sdl3Backend.kt:49`).
|
||||
|
||||
**USED (keep ON):** Video/window, Events, Clipboard, Dialog (file open/save - no cheaper
|
||||
substitute; Linux uses portal/zenity, keep deps), OpenGL + Metal contexts, Render (2D CPU-
|
||||
raster blit path: `SkiaSurfaceBridge` uploads to `SDL_Texture` + `SDL_RenderPresent`),
|
||||
Filesystem, Cursor, Locale, System theme, Text input/IME, OpenURL, timing/hints.
|
||||
|
||||
**UNUSED (disable - zero references, no transitive need):** Audio, Joystick (→disables
|
||||
Gamepad), Haptic, HIDAPI, Sensor, Power, Camera, GPU API, Offscreen video driver, virtual
|
||||
joystick.
|
||||
|
||||
- [x] **P1** Added to `build-all.py` shared `vExtra`: `-DSDL_AUDIO=OFF`,
|
||||
`-DSDL_JOYSTICK=OFF`, `-DSDL_HAPTIC=OFF`, `-DSDL_HIDAPI=OFF`, `-DSDL_SENSOR=OFF`,
|
||||
`-DSDL_POWER=OFF`, `-DSDL_CAMERA=OFF`, `-DSDL_GPU=OFF`, `-DSDL_OFFSCREEN=OFF`,
|
||||
`-DSDL_VIRTUAL_JOYSTICK=OFF`. Promoted `-DSDL_GPU=OFF` from the Windows-only branch to
|
||||
the shared list; kept the per-host `-DSDL_RENDER_D3D12=OFF` (Windows). (Skipped
|
||||
`-DSDL_DISABLE_INSTALL_DOCS` - not a real SDL3 option; the build's `cmake --install`
|
||||
is relied upon.) All ten confirmed zero-reference by grep before disabling.
|
||||
- [~] **P1** After the flag change, rebuild + run apps to confirm no regression.
|
||||
**DONE on macOS/Metal:** `build-all.py` rebuilt libSDL3.a clean with the new flags;
|
||||
`:demo` links + runs to a settled screenshot (TextField screen, no visual regression);
|
||||
`:apidemo` links clean too. **PENDING:** both apps on Linux + Windows hosts (fold into
|
||||
WIN-SMOKE §5). If audio/gamepad is ever needed by a consumer app, re-enable the flag
|
||||
and rebuild the static lib (build-time only).
|
||||
- [x] **P2** Prune now-unreferenced macOS frameworks from `sdl3.def`. **DONE:** verified
|
||||
against the regenerated `libs/SDL3/lib/pkgconfig/sdl3.pc` `Libs:` line (source of
|
||||
truth), which after slimming no longer references `CoreAudio`, `AudioToolbox`,
|
||||
`AVFoundation`, `GameController`, `ForceFeedback`, or weak `CoreHaptics` - removed all
|
||||
six from `linkerOpts.osx` (kept `CoreMedia`, which the `.pc` still lists). Demo relinks
|
||||
clean. (Left Linux `linkerOpts` alone - those `-l` entries serve Skia/GL/X11, not the
|
||||
disabled SDL subsystems.)
|
||||
- [ ] **P2** (higher-risk, flag-don't-apply) Render-driver pruning to software-only. Only
|
||||
the CPU-raster fallback uses `SDL_Render`; GL/Metal go direct. But
|
||||
`SDL_CreateRenderer(window, null)` (`Sdl3Backend.kt:117`) lets SDL pick the first
|
||||
driver - with only software present it picks software (fine) but couples the fallback
|
||||
to that assumption. **Conservative: leave render drivers alone.**
|
||||
|
||||
**Do NOT** add `-DSDL_DISABLE_INSTALL` - the build relies on `cmake --install`
|
||||
(`build-all.py:342`) to stage `libSDL3.a` + headers. `SDL_DYNAMIC_API` auto-disables for
|
||||
static builds (no flag needed). **Risk:** a future consumer app needing SDL audio/gamepad
|
||||
requires a static-lib rebuild (build-time, not a code change).
|
||||
|
||||
---
|
||||
|
||||
## 4. Performance
|
||||
|
||||
Prior PLAN Phases 0–3 landed; §12 measurement showed **no steady-state perf gap on
|
||||
macOS/Metal**. Remaining perf question (if any) is Windows-GL or heavy-interaction
|
||||
specific. Retained-layer engine is byte-for-byte upstream - not the gap.
|
||||
|
||||
- [ ] **P2** WON'T-FIX confirmed, keep as-is (don't relitigate): P0.4 per-frame full-surface
|
||||
clear + P0.6 per-frame Metal drawable reacquire (`CAMetalDrawable` single-use per
|
||||
frame, matches upstream `MetalRedrawer`).
|
||||
- [ ] **P2** P2.2 upstream `GlobalSnapshotManager` invalidation-driven scheduling -
|
||||
deferred; coalescing half done by P1.3; full manager is skiko-windowing-coupled
|
||||
(an explicit renderer non-goal - skiko-windowing-coupled). Leave deferred unless a
|
||||
Windows-GL perf gap surfaces.
|
||||
- [ ] **P2** `SkiaImageCache` font-cache half of P3.4 - evicted typefaces stay referenced by
|
||||
live paragraphs + `TypefaceFontProvider` (no clean unregister) so eviction frees no
|
||||
memory. Native-resource lifecycle backstop is the periodic `GC.collect()` nudge (top-
|
||||
level `Paragraph` a live `Text` holds has no Compose dispose seam). Accept for 1.0 or
|
||||
add a provider-unregister path.
|
||||
- [ ] **P2** P4.1 second-renderer manual-vendor reversal (restore upstream RenderNode
|
||||
shadows via `SkiaGraphicsContext.setLightingInfo` + relocate
|
||||
`prepareTransformationMatrix`; deltas D2–D6). Buys vendoring cleanliness at
|
||||
shadow-lighting/hit-test regression risk (current `NativeShadowCanvas` shadows render,
|
||||
draw=0.08ms). Low ROI - deferred, needs full verify-mac + parity.
|
||||
- [ ] **P2** Profile the shipped **Windows-GL** binary (`CDN_PROFILE=1`) under heavy
|
||||
interaction before ship (present phase is vsync-capped by display refresh - profile on
|
||||
the target monitor). Only open perf unknown.
|
||||
|
||||
---
|
||||
|
||||
## 5. API coverage / release mechanics
|
||||
|
||||
API coverage vs upstream is **99%** (8665/8751 decls via `compose-coverage.py`). Misses are
|
||||
AppKit/UIKit/web host actuals (N/A), `ImageComposeScene`/`renderComposeScene` (test util),
|
||||
and the `ui-text.platform` font layer (= P3.2 font resolver). The 1076 "extra" decls are
|
||||
umbrella-repo modules the tool can't compare + version skew, not invented surface.
|
||||
|
||||
- [ ] **P0** Re-pin refs to Compose **1.12.0 stable** when it ships (currently
|
||||
`v1.12.0-beta03+dev4483` - no clean beta03 tag on Maven). Bump both
|
||||
`scripts/compose-fork/compose.properties` refs, re-sync (`scripts/compose-fork/sync.sh`),
|
||||
let the build surface breakage.
|
||||
- [ ] **P0** Bump `vComposeJvmVersion` in `:demo` / `:apidemo` / `:material-symbols` (JVM
|
||||
parity leg currently forced to beta02 by documented skew - native leads).
|
||||
- [ ] **P1** Run `check-vendor-drift.py` after the ref bump - re-stamp `VENDOR-BASE` on the
|
||||
10 manual vendors, hand-reconcile any that actually changed base..pin.
|
||||
- [~] **P0** WIN-SMOKE fidelity pass (Windows host only, pre-ship gate): the Mac runbook
|
||||
cannot cover the shipped mingwX64 binary. Assert: (1) NotoSans `FontMetrics` dump
|
||||
native-vs-JVM-Windows (§1b acceptance) - **✅ DONE 2026-08-04 (`--metricsprobe`==`--metrics`)**;
|
||||
(2) `\t`/control-char render clean (§1a) - **✅ DONE**; (3) the Windows-only `PrintWindow`
|
||||
probe - pending (interactive); (4) the common-metadata publish job - compile gate now
|
||||
**✅ GREEN (2026-08-04, see the fix below)**; the actual publish + downstream-consume smoke
|
||||
still owed. Remaining: (3) `PrintWindow` probe + the interactive caret-height check for the
|
||||
§1b LineMetrics fix.
|
||||
- [ ] **P0** apiDump is **host-specific** - do NOT commit macOS dumps. Only the **Windows
|
||||
publish job compiles common metadata** (owns the root KotlinMultiplatform publications
|
||||
- the only host declaring every target, so only its `.module` files carry the full
|
||||
variant table; macOS-published roots left v0.1.15 without mingwX64 variants). Test
|
||||
`gradlew :<module>:compileCommonMainKotlinMetadata` before tagging; publish from
|
||||
Windows.
|
||||
- [x] **P0** **FIXED (2026-08-04) - common-metadata compile is now GREEN** (`compileCommonMainKotlin
|
||||
Metadata` across all modules → BUILD SUCCESSFUL). Was RED, blocking the Windows publish.
|
||||
Running `:material3:compileCommonMainKotlinMetadata` (or the aggregate) had failed at
|
||||
`:foundation:compileNativeMainKotlinMetadata` (pulled in via `:foundation:allMetadataJar`):
|
||||
`Scrollbar.skiko.kt` + `v2/Scrollbar.skiko.kt` - `Declaration annotated with
|
||||
'@OptionalExpectation' can only be used in common module sources`
|
||||
(`OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE`). **Root cause:** upstream keeps these
|
||||
`.skiko.kt` files in `skikoMain`, which INCLUDES the JVM-desktop target, so `@JvmName`/
|
||||
`@JvmStatic`/`@file:JvmName` resolve; the port vendors them into **native-only `nativeMain`**
|
||||
(no JVM target under it), so those JVM annotations are orphaned and the *metadata* compile
|
||||
rejects them (per-target compile tolerates them - that's why the APPS build fine, but the
|
||||
publish's `allMetadataJar` recompiles nativeMain metadata and dies). `:ui` and below pass;
|
||||
`:foundation` is the first break. **Blast radius:** 14 vendored native files use `@Jvm*`
|
||||
(`grep -rE "@file:JvmName|@JvmName|@JvmStatic" .../src/vendor/native .../src/nativeMain`); 11
|
||||
are in the GITIGNORED, sync-regenerated `src/vendor/native/` tree, so per-file edits DON'T
|
||||
survive a re-sync. **Fix (build/sync-level, not per-file):** either (a) a `sync.sh`
|
||||
post-step that injects `@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE")`
|
||||
(or strips `@Jvm*`, which are no-ops with no JVM library target) into vendored native files -
|
||||
there's precedent: `components-resources/…/ResourceState.blocking.kt` already carries that
|
||||
suppress; or (b) a KGP-level relaxation of the native metadata compilation. Needs a real
|
||||
publish + downstream-consume verification after. Dedicated effort - likely THE reason the
|
||||
Windows metadata publish never went green.
|
||||
**FIX APPLIED (2026-08-04):** the root turned out narrower - sync.py ALREADY injects the K2
|
||||
`@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE", "LESS_VISIBLE_TYPE_ACCESS_IN_INLINE")`
|
||||
into every `src/vendor/` .kt (`sync.py:313`), so the regenerated tree was fine. The only gap
|
||||
was the two COMMITTED manual vendors in `src/nativeMain` that sync doesn't regenerate:
|
||||
`foundation/…/Scrollbar.skiko.kt` + `v2/Scrollbar.skiko.kt`. Added the identical `@file:Suppress`
|
||||
to both (provenance comment updated; `check-vendor-drift` still clean). Full
|
||||
`compileCommonMainKotlinMetadata` now green. **Remaining before publish:** run the actual
|
||||
publish + a downstream-consume smoke (the compile gate is necessary, not proof the `.module`
|
||||
variant table is complete).
|
||||
- [ ] **P1** Version bump to `1.0.0` across published coords once the above are green.
|
||||
- [x] **P0** Doc-hygiene blocker: `CLAUDE.md` documentation map referenced `RENDERER.md`,
|
||||
`SKIKO-MINGW-FEASIBILITY.md`, and `TODO.md`. **DONE:** removed `RENDERER.md` +
|
||||
`SKIKO-MINGW-FEASIBILITY.md` (stale/historical - current renderer essentials inlined in
|
||||
CLAUDE.md's doc map, the rest recoverable from git history); fixed the `CLAUDE.md`
|
||||
line-1 `ean` typo; dropped the `TODO.md` link (never committed) → its content is PLAN.md
|
||||
§2. `git grep` confirms no remaining links to any deleted doc.
|
||||
|
||||
---
|
||||
|
||||
## 6. Vendor the upstream skiko text engine (next major refactor - approved 2026-08-04)
|
||||
|
||||
**Goal:** honour "VENDOR ALL WE CAN". Delete the hand-rolled reduced text engine
|
||||
(`SkiaParagraph.native.kt` + `NativeParagraphOps` seam + `SkiaParagraphEngine.kt` +
|
||||
`FontRasterizationSettings.native.kt` port) and vendor upstream's skiko text subsystem VERBATIM.
|
||||
|
||||
**Why it was hand-rolled (the REAL reason, not SDL_TTF):** `Paragraph` is `expect sealed interface`
|
||||
(`vendor/common/…/Paragraph.kt:56`) whose `actual` sits in the **skiko-FREE `nativeMain`**, so a
|
||||
sealed implementer can't touch skiko → the hand seam. `nativeMain` is skiko-free because it's the
|
||||
shared parent of the **official-skiko** (mac/linux, `libs.skiko`) and **fork-skiko** (mingw,
|
||||
`com.bitsycore.skiko:skiko`) legs - two different artifacts, so the shared parent can carry neither.
|
||||
|
||||
**Why it's feasible NOW (all-skiko + srcDir reuse):** every native leg already reaches
|
||||
`src/skikoRendererMain/kotlin` (mingw via `kotlin.srcDir(...)` in `skikoRendererMingwSharedMain`,
|
||||
`ui-text/build.gradle.kts:54`), and the fork exposes the SAME `org.jetbrains.skia.*` Kotlin API. So
|
||||
the `Paragraph` **actual + engine can move DOWN into the skiko source set** (served to all targets)
|
||||
instead of skiko-free `nativeMain` - which lets upstream's skiko files compile as-is.
|
||||
|
||||
**⚠️ SPIKE FINDING (2026-08-04) - the real gate is the FONT LOADER, not the source-set move.**
|
||||
A read-only feasibility map (no code changed) found two layers:
|
||||
- **Layer 1 - source-set / sealed move: FEASIBLE.** The `actual sealed interface Paragraph`
|
||||
(`vendor/native/Paragraph.native.kt:36`, signature-only mirror) + its implementer + the 11 factory
|
||||
actuals can move to `skikoRendererMain` (all leaves reach it; sealed same-source-set holds). Just
|
||||
mechanical.
|
||||
- **Layer 2 - font model: THIS is the blocker, and it's the SAME task as §2-P1 `PlatformFontLoader`.**
|
||||
Upstream's engine (`SkiaParagraphIntrinsics.skiko.kt:38,61`) resolves typefaces through
|
||||
`FontFamily.Resolver` → `PlatformFontLoader`. The port's `SdlPlatformFontLoader`
|
||||
(`font/FontFamilyResolver.native.kt:30-49`) is a **NOP** (`loadBlocking`/`awaitLoad`→`Unit`,
|
||||
`resolve`→`Immutable(Unit)`). The port's hand-rolled engine exists precisely to BYPASS that NOP by
|
||||
resolving through `SkiaFonts` (icon fonts + variable axes + data.kres bytes). So vendoring the
|
||||
upstream engine verbatim routes font resolution through the NOP → **all text goes blank/tofu**
|
||||
until the loader is real.
|
||||
- **Consequence:** the verbatim engine-vendor REQUIRES first implementing upstream's font-loader path
|
||||
(vendor `SkiaFontLoader.skiko.kt` as the reference) so the port's `SkiaFonts` icon/variable-axis
|
||||
model rides upstream's `PlatformFontLoader`/`FontLoadResult` seam. This is the deferred high-risk P1
|
||||
in §2 - it and this §6 are one effort. **Intermediate option:** vendor the engine files but keep
|
||||
`SkiaFonts` via a Rule-3 edit at the one resolve call site (vendors most files verbatim, 1–2 stay
|
||||
hand-reconciled). **Verification needs macOS + Linux hosts** (parity), so this is a dedicated
|
||||
effort, not a Windows-only session - do it with mac access, on its own branch.
|
||||
|
||||
**Plan (spike on a branch, verify on THIS Windows host - build + render mingwX64):**
|
||||
1. Move the `Paragraph`/`ParagraphIntrinsics` **actuals** into `skikoRendererMain` (reused by mingw
|
||||
via the existing `srcDir`). Confirm the `expect sealed` actual is accepted there for every leaf
|
||||
target (the b63 memo hit a sealed-in-`nativeMain` wall - test whether hosting the actual in the
|
||||
skiko set clears it; the sealed same-module rule is per-target-compilation, which should hold).
|
||||
2. Vendor VERBATIM into `src/vendor/skikoRenderer/`: `SkiaParagraph.skiko.kt`,
|
||||
`ParagraphBuilder.skiko.kt`, `SkiaParagraphIntrinsics.skiko.kt`, `SkiaTextPaint.skiko.kt`,
|
||||
`ParagraphLayouter.skiko.kt`, `PlatformFont.skiko.kt`, `FontRasterizationSettings.skiko.kt`,
|
||||
`TextStyle.skiko.kt`; add them to `ui-text/compose-fork.txt`; delete the hand-rolled files.
|
||||
3. Reconcile the ~7 hierarchy-crossing `expect/actual` pairs the inverted layout needed; keep the
|
||||
`SkiaFonts` family/variable-axis model (it's the port's real value-add) behind upstream's seams.
|
||||
4. **Fork-only Rule-3 edit:** upstream's code calls `paragraph.lineMetrics` - the fork's extern-C
|
||||
binding mis-decodes ascent/descent (§1b finding). Either carry the guarded reconstruction (from
|
||||
`SkiaParagraphOps.lineMetrics()`) into the vendored `SkiaParagraph.skiko.kt` as a Rule-3 edit, or
|
||||
fix it in the fork DLL. **Bonus:** vendoring `ParagraphBuilder.skiko.kt` verbatim wires
|
||||
`halfLeading` → the §1b M3-baseline drift disappears for free.
|
||||
5. Verify: mingwX64 build + `--screen=BasicText/Buttons/AnnotatedString` render clean +
|
||||
`--metricsprobe` == JVM `--metrics`. Then macOS/Linux verify + parity (needs those hosts).
|
||||
|
||||
**Sequencing:** AFTER the current shared-Kotlin fixes land (this session). If the sealed-actual move
|
||||
proves infeasible, fall back to keeping the seam but at least converting the hand files to tracked
|
||||
Rule-3 vendors (like `FontRasterizationSettings.native.kt` now has a `VENDOR-BASE` line).
|
||||
|
||||
### 7c. `demo --multiwintest` segfault on mingwX64 - ✅ FIXED
|
||||
|
||||
The multi-window probe exited 139 with no output, so it had silently been
|
||||
asserting nothing. Root cause: an OpenGL context is per-WINDOW but "current" is
|
||||
per-THREAD, and `SDL_GL_MakeCurrent` ran only once at creation
|
||||
(`Sdl3Backend.init`) with nothing re-binding it per frame. With two windows open,
|
||||
whichever initialised last owned the thread - so window A issued its draw and
|
||||
swap against window B's context (silent corruption while both lived), and
|
||||
destroying B left the current context dangling, faulting in `present()`.
|
||||
|
||||
Fixed in `SkiaGLBridge` by binding the window's own context at every entry point
|
||||
that touches GL: `init`, `ensureSize` (before its unchanged-size early return -
|
||||
this is the per-frame bind that covers the draw phase), `present`, and `destroy`
|
||||
(so a window frees its GPU objects against its own context). Purely additive, 29
|
||||
lines.
|
||||
|
||||
Single-window rendering is bit-identical before and after (same mean RGB
|
||||
[46.6 44.4 51.5] and 167 distinct colours on the Buttons screenshot), which is
|
||||
expected: SDL short-circuits a redundant bind. `--multiwintest` now PASSes.
|
||||
|
||||
NOTE the multi-window path was never correct, not just at teardown - two live
|
||||
windows were sharing one context. Anything that looked like flicker or
|
||||
cross-window corruption before this is explained by it.
|
||||
|
||||
## Accepted 1.0.0 gaps (documented, not fixed)
|
||||
|
||||
Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of
|
||||
scope for desktop 1.0 - but ship the non-throwing `PlatformScreenReader` no-op), the
|
||||
hand-rolled text engine as an architectural deviation (§2 P1), per-focus
|
||||
`SDL_StartTextInput/StopTextInput`, `loadImageBitmap`/`loadSvgPainter` (JVM `InputStream`
|
||||
signatures, N/A on K/N).
|
||||
|
||||
## 8. material3-adaptive ported to desktop native (2026-09-22) - ✅ LANDED
|
||||
|
||||
Asked for in [issue #4](https://github.com/bitsycore/compose-desktop-native/issues/4):
|
||||
`org.jetbrains.compose.material3.adaptive:*` 1.3.0-rc01 publishes `ios_arm64
|
||||
ios_simulator_arm64 macos_arm64` and nothing else - no linux, no mingw. (It DOES
|
||||
carry macosArm64, which answers the reporter's open question.)
|
||||
|
||||
**The port needed zero source edits.** Two things made it a pure re-target:
|
||||
|
||||
1. Every platform actual the native legs want already exists upstream -
|
||||
`adaptive/nonAndroidMain` (`calculatePosture`), `adaptive-layout/skikoMain`
|
||||
(the ~75 l10n tables + `Strings` + the drag handle) and
|
||||
`adaptive-layout/nativeMain` (`identityHashCode`, `isGraphicsLayerElement`).
|
||||
2. `androidx.window:window-core` - the dependency `adaptive` api-exposes, and the
|
||||
obvious suspect for why the artifacts stop at apple - has published
|
||||
`linux_x64 linux_arm64 mingw_x64 macos_arm64` klibs all along, under BOTH the
|
||||
google and jetbrains coordinates.
|
||||
|
||||
So the artifacts were missing purely because upstream never enabled the targets.
|
||||
|
||||
**Landed** as four modules under `compose/material3/adaptive/`
|
||||
(`com.bitsycore.compose.material3.adaptive:*`), 125 files vendored verbatim off the
|
||||
existing `COMPOSE_CORE_REF` pin - no new SET_REPO. Upstream's `skikoMain` lands in
|
||||
`src/vendor/native/` beside upstream's own `nativeMain`; they actualise disjoint
|
||||
expects so they share one source set. Both bridges substitute all four. `demo --screen=Adaptive` renders a
|
||||
`ListDetailPaneScaffold`, and `demo --adaptivetest` drives one window
|
||||
wide -> narrow -> wide and asserts the scaffold folds and unfolds, which is what
|
||||
actually proves the resize chain end to end (SDL resize -> the owner's
|
||||
snapshot-backed containerSize -> recomposition -> BoxWithConstraints re-measure ->
|
||||
a new PaneScaffoldDirective -> a new scaffold value).
|
||||
|
||||
The demo screen computes its directive from the width IT gets rather than taking
|
||||
the `rememberListDetailPaneScaffoldNavigator` default, which is the window size
|
||||
class: the shell spends 190dp on the sidebar plus 48dp of padding, so defaulting
|
||||
folds ~240dp too late and leaves two panes in a region already too cramped for
|
||||
them. Pane-hosting apps that are not full-window should do the same.
|
||||
|
||||
One wrinkle worth remembering, and a textbook case of the granular-metadata trap
|
||||
already documented in CLAUDE.md: because `adaptive` is SUBSTITUTED on native
|
||||
configs, KGP drops its transitives from the commonMain metadata classpath, so the
|
||||
shared demo screen could not see `WindowSizeClass` until `window-core` was declared
|
||||
DIRECTLY in `demo`'s commonMain.
|
||||
|
||||
Not yet exercised: `adaptive-navigation3` builds and dumps API but has no demo
|
||||
screen - the nav3 SceneStrategy path is unproven at runtime.
|
||||
|
||||
## 7. Post-1.12.0 open items (opened 2026-09-12)
|
||||
|
||||
### 7a. JVM parity reference used a DIFFERENT default font than native - ✅ FIXED
|
||||
|
||||
Symptom (apidemo, `SourceTag` in `InheritanceUi.kt`): the "Session" / "Pack" pill is
|
||||
`Box(padding(horizontal = 5.dp, vertical = 1.dp)) { Text(fontSize = 9.sp) }` - no explicit
|
||||
vertical centering, so the pill's look is entirely decided by where the glyphs sit inside the
|
||||
Text node's measured paragraph box. On the JVM leg the label looks vertically centred in the
|
||||
pill; on native it does not.
|
||||
|
||||
**Not a centering bug - a font-resolution divergence.** The two stacks render that label in
|
||||
two different typefaces, which have different ascent / descent / line-gap, so the glyph run
|
||||
sits at a different height inside an equally-tall box:
|
||||
|
||||
- NATIVE: `FontFamily.Default` resolves to the BUNDLED **NotoSans** -
|
||||
`SkiaFonts.defaultTypeface` (`compose/ui/ui-text/src/skikoRendererMain/.../SkiaFonts.kt`)
|
||||
loads `font/NotoSans.ttf` from data.kres and registers it as "the fallback for every
|
||||
unresolved family".
|
||||
- JVM: nothing registers NotoSans as the default. `apidemo/src/jvmMain/kotlin/Fonts.jvm.kt`
|
||||
only defines `monoFontFamily`; `jvmProcessResources` stages the .ttf on the classpath but
|
||||
never installs it. So `FontFamily.Default` is Skiko's SYSTEM default (Segoe UI on Windows).
|
||||
|
||||
This is a defect in the PARITY HARNESS, not (necessarily) in the renderer: every `Text` that
|
||||
doesn't name a `fontFamily` is being compared against a different typeface, so any such
|
||||
difference is uninterpretable. It likely masks or manufactures other "port bugs".
|
||||
|
||||
**Fixed** by giving apidemo a `defaultFontFamily` expect/actual (`Fonts.kt`) that loads the
|
||||
bundled `font/NotoSans.ttf` on BOTH legs - native through `IconFont.register` +
|
||||
`namedFontFamily`, jvm from the classpath copy `jvmProcessResources` already stages - and
|
||||
stamping it across the M3 `Typography` (`withDefaultFontFamily`), since typography styles
|
||||
carry their own `fontFamily` and overriding `LocalTextStyle` alone would not reach them.
|
||||
|
||||
This is the SAME treatment `:demo`'s parity leg already had (see the `notoSans` /
|
||||
`notoTypography` block in `demo/src/jvmMain/kotlin/MainJvm.kt`, commented "so the parity %
|
||||
measures real divergence, not 'different default typeface'"). apidemo had simply never been
|
||||
given it. Verified: the SourceTag pill is now vertically centred on both stacks.
|
||||
|
||||
### 7a-bis. apidemo drift check is now mechanised - and two residuals found
|
||||
|
||||
`:apidemo` had no way to capture itself, so drift could only be eyeballed. Both legs now take
|
||||
`--screenshot=<path>`:
|
||||
|
||||
apidemo.exe --screenshot=out.bmp # native: GPU readback
|
||||
./gradlew :apidemo:run --args=--screenshot=out.png # jvm: ImageComposeScene
|
||||
|
||||
Both render OFFSCREEN to quiescence (virtual 60fps clock, infinite animations frozen via
|
||||
upstream's `InfiniteAnimationPolicy`, capture after 3 quiet frames), mirroring `:demo`'s
|
||||
parity path. Neither needs a visible window, so the capture is immune to occlusion - a
|
||||
screen-scrape approach was tried first and grabbed whatever happened to be in front.
|
||||
|
||||
First run after the font fix, 1240x820: **2.9% of pixels differ at all, 2.1% by >32/255.**
|
||||
Two residuals, neither a text-metrics bug:
|
||||
|
||||
- **Inherited lineHeight leading distribution - ✅ FIXED.** The port never forwarded
|
||||
`topRatio` to skiko. Upstream's `ParagraphBuilder.skiko.kt` sets `res.topRatio = topRatio`
|
||||
on EVERY SkTextStyle, sourced from `LineHeightStyle.Alignment.topRatio`; the port set
|
||||
`ts.height` and `heightMode` but left `topRatio` alone, so skiko fell back to its own
|
||||
default (-1 = split the extra leading proportionally to the font's ascent:descent). For a
|
||||
line box much taller than the glyphs that pushes them visibly DOWN - while Material 3's
|
||||
type styles ask for `Alignment.Center` (topRatio 0.5).
|
||||
|
||||
One line in `SkiaParagraphEngine.kt`'s SkTextStyle builder, with `Alignment.Proportional`
|
||||
as the fallback exactly like upstream, so text specifying no lineHeightStyle is unchanged.
|
||||
`topRatio` is present on both the official skiko and the bitsycore mingw fork.
|
||||
|
||||
Measured on apidemo (1240x820), whole window, native vs jvm:
|
||||
|
||||
| | differ | >32/255 |
|
||||
|--------------------|--------|---------|
|
||||
| before | 2.94% | 2.14% |
|
||||
| after | 1.52% | 0.29% |
|
||||
|
||||
The Session/Pack pills are now pixel-identical. This ALSO killed the "constant ~2px
|
||||
vertical offset" logged alongside it - that was the same bug compounding through stacked
|
||||
text rows, not an independent issue: shifting the jvm capture now makes the match WORSE
|
||||
(0.29% at +0px vs 2.13% at +1px), where before +2px halved the error. The residual 0.29%
|
||||
is rasterizer AA.
|
||||
|
||||
- **Icon-font glyphs rendered LOW - ✅ FIXED.** Material Symbols sat 2-4px lower on native
|
||||
than on jvm, the offset scaling with icon size (~13% of it). Cause: the JVM actual centres
|
||||
the glyph explicitly (`y = height/2 - (ascent+descent)/2`), while `IconFontIcon` placed
|
||||
`IconText` in a Box at the DEFAULT TopStart alignment - and the glyph's natural text box is
|
||||
taller than the nominal icon size. Fixed by centring, with
|
||||
`wrapContentSize(unbounded = true)` so the text measures at its natural size first:
|
||||
`Modifier.size()` imposes FIXED constraints, so without it nothing overflows and
|
||||
contentAlignment is a no-op (a first attempt without it changed literally nothing).
|
||||
gear/download/lock are now pixel-exact; the eye is 1px off from half-pixel rounding (odd
|
||||
glyph height centred in an even box; jvm draws at a float y, native placement is integer).
|
||||
Whole-window >32/255 drift 0.29% -> 0.17%.
|
||||
|
||||
- **Missing glyph in the session header.** The leading icon next to "Untitled session"
|
||||
renders as a `.notdef` TOFU on jvm and as NOTHING on native - so the glyph is absent from
|
||||
the subsetted Material Symbols font on both, and the two stacks just disagree on how to
|
||||
draw a missing glyph. The gear icon beside it is fine, so the icon-font path works; this
|
||||
is almost certainly `enableIconSubsetting` missing a NON-LITERAL `MaterialSymbols.<Name>`
|
||||
reference that `findMaterialSymbolsUsage` cannot see. Check that call site, and consider
|
||||
making the native side draw .notdef too so a missing glyph is never silent.
|
||||
|
||||
### 7b. Vendor Koin + Coil3 + Pulse MVI (requested 2026-09-12) - ✅ DONE
|
||||
|
||||
Target-availability audit against Maven Central (the deciding fact - re-check on every
|
||||
version bump, and check BOTH the google and jetbrains coordinate sets before concluding an
|
||||
artifact "has no desktop klibs"):
|
||||
|
||||
| Artifact | mingwX64 | linuxX64/arm64 | macosArm64 | Action |
|
||||
|---|---|---|---|---|
|
||||
| `androidx.navigationevent:navigationevent-compose` 1.1.2 | YES | YES | YES | **none - already used as-is** |
|
||||
| `org.jetbrains.androidx.navigationevent:navigationevent-compose` 1.1.0 | YES | YES | YES | **none** |
|
||||
| `io.insert-koin:koin-core` 4.2.2 | YES | YES | YES | none - Maven as-is |
|
||||
| `io.insert-koin:koin-core-viewmodel` 4.2.2 | no | no | YES | VENDOR |
|
||||
| `io.insert-koin:koin-compose` 4.2.2 | no | no | YES | VENDOR |
|
||||
| `io.insert-koin:koin-compose-viewmodel` 4.2.2 | no | no | YES | VENDOR |
|
||||
| `io.insert-koin:koin-compose-viewmodel-navigation` 4.2.2 | no | no | YES | VENDOR |
|
||||
| `io.coil-kt.coil3:coil-core` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
|
||||
| `io.coil-kt.coil3:coil` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
|
||||
| `io.coil-kt.coil3:coil-network-core` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
|
||||
| `io.coil-kt.coil3:coil-network-ktor3` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
|
||||
| `io.coil-kt.coil3:coil-svg` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
|
||||
| `io.coil-kt.coil3:coil-compose-core` 3.6.2 | no | no | YES | VENDOR |
|
||||
| `io.coil-kt.coil3:coil-compose` 3.6.2 | no | no | YES | VENDOR |
|
||||
|
||||
NOTE the original premise that navigationevent-compose lacks desktop-native artifacts is
|
||||
WRONG - both coordinate sets publish mingwX64 + linux + macos, and the port already consumes
|
||||
`androidx.navigationevent:navigationevent-compose:1.1.2` unmodified ("Known Compatible" in
|
||||
CLAUDE.md, exercised by `demo --backtest`). Nothing to vendor there.
|
||||
|
||||
**Landed** as `koin/` (4 modules, `com.bitsycore.koin`), `coil/` (7 modules,
|
||||
`com.bitsycore.coil3`) and `pulse/` (4 modules, `com.bitsycore.compose.desktop.native.pulse`) - 202 files vendored
|
||||
verbatim across three new pinned repos (`KOIN_REF=4.2.2`, `COIL_REF=3.6.2`,
|
||||
`PULSE_REF=0.3.7`), ZERO local source edits. All build for mingwX64 + linuxX64/arm64, are
|
||||
BCV-dumped, publish, and carry bridge substitution rules so consumers keep declaring the
|
||||
official coordinates.
|
||||
|
||||
Three things worth remembering from doing it:
|
||||
|
||||
- **nav2 is a dead end here.** koin-compose-viewmodel-navigation needs nav2's
|
||||
`navigation-compose`, which has no mingwX64/linux klibs under either coordinate set.
|
||||
Vendored `koin-compose-navigation3` instead - it needs only koin-compose +
|
||||
navigation3-runtime, matching this port's nav3 story.
|
||||
- **Upstream `non*` source sets must stay ABOVE nativeMain, not merge into it.** Several
|
||||
declare `expect`s whose `actual`s live in upstream nativeMain; collapsing both into one
|
||||
source set makes them siblings, which is a hard error.
|
||||
- **A shared-metadata compilation is a real compile.** Coil's nonAndroid code uses
|
||||
`org.jetbrains.skia.*` directly, so the INTERMEDIATE source set holding it needs skiko on
|
||||
its own classpath - and since mingwX64 needs the FORK while macOS/Linux need the official
|
||||
build, the two cannot share an intermediate. coil-core / coil-svg / coil-compose-core
|
||||
therefore carry two PARALLEL branches over the same srcDirs, the shape
|
||||
:compose:ui:ui-graphics already used. Kotlin emits no metadata compilation for the
|
||||
single-target fork branch, so they never collide.
|
||||
|
||||
Also hit the granular-metadata trap (CLAUDE.md "Common pitfalls") in coil-compose-core:
|
||||
every artifact the shared code touches must be declared DIRECTLY and get its own bridge
|
||||
rule, or `compileCommonMainKotlinMetadata` loses its transitives on the Windows publish job.
|
||||
|
||||
REMAINING (deliberate): upstream Koin/Coil/Pulse reference the
|
||||
`org.jetbrains.androidx.lifecycle:*` mirrors while this port standardises on the GOOGLE
|
||||
`androidx.lifecycle:*` coords. The build files repoint them, but a consumer that pulls the
|
||||
jetbrains mirrors in itself will get duplicate `unique_name` klib warnings at metadata
|
||||
compile. Harmless today; if it ever becomes an error, force the google coords repo-wide.
|
||||
@@ -1,227 +1,326 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](https://kotlinlang.org)
|
||||
[](LICENSE.md)
|
||||
|
||||
# Compose Desktop Native
|
||||
|
||||
A Kotlin/Native port of **Compose Multiplatform** running on **SDL3** for
|
||||
windowing and input — **no JVM**. Compiles to native binaries for macOS
|
||||
(arm64), Linux (x64/arm64), Windows (mingwX64).
|
||||
Run [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform)
|
||||
as a native binary, with no JVM. Compose Desktop Native compiles to a single
|
||||
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.
|
||||
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
|
||||
when `-Prenderer=sdl3` is passed.
|
||||
## How it works
|
||||
|
||||
The Compose **runtime** is the official
|
||||
`org.jetbrains.compose.runtime:runtime` klibs from Maven — this project
|
||||
re-implements the layers on top of it (`androidx.compose.ui.*`,
|
||||
`.foundation.*`, `.animation.*`, `.material3.*`) by **vendoring
|
||||
upstream Compose Multiplatform verbatim** whenever possible and hand-rolling
|
||||
project actuals + SDL3 / Skia glue only where needed.
|
||||
- **The runtime is the real one.** Composition, snapshots, and the recomposer
|
||||
are the official `org.jetbrains.compose.runtime` klibs from Maven, never
|
||||
reimplemented.
|
||||
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
|
||||
`animation`, and `material3` are copied from Compose Multiplatform verbatim
|
||||
wherever they compile as is, with project code filling in only the native glue.
|
||||
The same treatment extends to ecosystem libraries that stop short of
|
||||
Kotlin/Native desktop - Koin, Coil 3 and Pulse MVI are vendored and rebuilt for
|
||||
these targets, and you still declare their official coordinates.
|
||||
- **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
|
||||
upstream's `compose/` layout, gradle paths stay short via `projectDir`
|
||||
redirects.
|
||||
## Quickstart
|
||||
|
||||
```
|
||||
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
|
||||
### A window
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.material3.Text
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
|
||||
fun main() = nativeComposeWindow(title = "Hello") {
|
||||
Text("Hello from ComposeNativeSDL3")
|
||||
Text("Hello from Compose Desktop Native")
|
||||
}
|
||||
```
|
||||
|
||||
The lambda runs with a `ComposeWindowScope` receiver exposing
|
||||
`window: ComposeNativeWindow` (`setTitle` / `setSize` / `minimize` /
|
||||
`maximize` / `setFullscreen` / `close` / …); the same handle is reachable
|
||||
from any nested composable via `LocalComposeNativeWindow.current`.
|
||||
The content lambda runs with a `ComposeWindowScope` receiver that exposes
|
||||
`window: ComposeNativeWindow` (`setTitle`, `setSize`, `minimize`, `maximize`,
|
||||
`setFullscreen`, `close`). The same handle is reachable from any nested
|
||||
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
|
||||
implementation(project(":window")) // window + main loop
|
||||
implementation(project(":material3")) // Material 3 widgets
|
||||
implementation(project(":material-symbols")) // icon-font composables (optional)
|
||||
fun main() = nativeComposeApp {
|
||||
Window(title = "Main", onCloseRequest = ::exitApplication) { /* ... */ }
|
||||
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.
|
||||
|
||||
`Window()` takes the same attributes as Compose Desktop's - `undecorated`,
|
||||
`transparent`, `resizable`, `enabled`, `focusable`, `alwaysOnTop`,
|
||||
`onPreviewKeyEvent` and `onKeyEvent` - and re-applies them when they change.
|
||||
(`transparent` is the one exception: SDL needs it at window-creation time, so it
|
||||
is fixed for the window's life.)
|
||||
|
||||
When the Compose-level API doesn't reach far enough, drop to SDL directly:
|
||||
|
||||
```kotlin
|
||||
val handles = window.rawSdlHandles() // SDL_Window* / SDL_Renderer* / GL / Metal
|
||||
handles.window?.let { sdl3.SDL_FlashWindow(it.reinterpret(), SDL_FLASH_BRIEFLY) }
|
||||
```
|
||||
|
||||
The pointers are a snapshot valid only while the window lives, and which ones
|
||||
are non-null depends on the resolved renderer (`glContext` for Skia OpenGL,
|
||||
`metalView` for Metal, `renderer` for CPU raster).
|
||||
|
||||
### Building in this repo
|
||||
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
implementation(project(":compose:desktop:native:desktop-native-window")) // window shell + main loop
|
||||
implementation(project(":compose:material3:material3")) // Material 3 widgets
|
||||
implementation(project(":utils:material-symbols")) // icon-font composables (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### Building from your own project: the bridge plugin
|
||||
|
||||
The klibs publish to [maven.bitsycore.com](https://maven.bitsycore.com/releases)
|
||||
(no auth) and GitHub Packages (authenticated fallback) under per-area coordinates that mirror
|
||||
upstream - `com.bitsycore.compose.ui:ui`, `com.bitsycore.compose.foundation:foundation`,
|
||||
… (the `com.bitsycore` fork of each `org.jetbrains.compose.*`). 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 and material3-adaptive each ride their
|
||||
// own release train 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}")
|
||||
|
||||
// material3-adaptive, which upstream publishes for apple targets only.
|
||||
// Note adaptive api-exposes androidx.window:window-core - declare it
|
||||
// DIRECTLY if your commonMain touches WindowSizeClass, since substituted
|
||||
// modules hide their transitives from the common metadata classpath.
|
||||
val vAdaptive = composeDesktopNative.composeMaterial3Adaptive
|
||||
implementation("org.jetbrains.compose.material3.adaptive:adaptive:$vAdaptive")
|
||||
implementation("org.jetbrains.compose.material3.adaptive:adaptive-layout:$vAdaptive")
|
||||
implementation("androidx.window:window-core:1.5.0")
|
||||
}
|
||||
```
|
||||
|
||||
**One repository, no credentials.** `https://maven.bitsycore.com/releases`
|
||||
carries both the port's artifacts and the bitsycore skiko fork
|
||||
(`com.bitsycore.skiko`), which the Windows (mingwX64) target renders through;
|
||||
macOS/Linux pull the official skiko from Maven Central instead. The exact
|
||||
`repositories {}` snippet 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 artifact, and the Gradle path mirrors the
|
||||
directory (`:compose:ui:ui`, `:compose:foundation:foundation`, …). The renderer
|
||||
lives in `:compose:ui:ui` and the `sdl3` cinterop in `:sdl:sdl-core`;
|
||||
`:compose:desktop:native:desktop-native-window` is the SDL integration layer.
|
||||
|
||||
Most of the androidx architecture stack (lifecycle, viewmodel, navigation3,
|
||||
savedstate, navigationevent) ships real Kotlin/Native desktop klibs and runs on
|
||||
the port unmodified. Where an ecosystem library stops short of Kotlin/Native
|
||||
desktop, this repo vendors it verbatim from upstream and rebuilds it for these
|
||||
targets, so you keep writing against the official coordinates:
|
||||
|
||||
| Vendored | Why | Published as |
|
||||
|----------|-----|--------------|
|
||||
| Compose `ui` / `foundation` / `animation` / `material3` | the port itself | `com.bitsycore.compose.*` |
|
||||
| `components-resources`, `navigation3-ui` | no mingwX64 / linux klibs | `com.bitsycore.compose.components`, `com.bitsycore.navigation3` |
|
||||
| **Koin** viewmodel + compose modules | apple + android only upstream (`koin-core` itself is fine) | `com.bitsycore.koin` |
|
||||
| **Coil 3** (whole stack) | no mingwX64 anywhere; no desktop native at all for its compose layer | `com.bitsycore.coil3` |
|
||||
| **Pulse MVI** | no desktop-native artifact upstream | `com.bitsycore.compose.desktop.native.pulse` |
|
||||
| **material3-adaptive** (all four) | ios + macosArm64 only upstream - no linux, no mingw | `com.bitsycore.compose.material3.adaptive` |
|
||||
|
||||
The bridge plugin substitutes each of these on native desktop configurations, so
|
||||
app code declares `io.insert-koin:koin-compose`, `io.coil-kt.coil3:coil-compose`
|
||||
and so on exactly as it would anywhere else. The full module map, dependency
|
||||
graph, and the list of compatible artifacts are in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Pinned versions
|
||||
|
||||
Everything below is pinned in exactly two files -
|
||||
[`gradle/libs.versions.toml`](gradle/libs.versions.toml) (Maven coordinates) and
|
||||
[`scripts/compose-fork/compose.properties`](scripts/compose-fork/compose.properties)
|
||||
(the upstream git refs the vendored sources are copied from) - plus
|
||||
[`scripts/build-sdl/build-sdl.properties`](scripts/build-sdl/build-sdl.properties)
|
||||
for SDL3. A release tracks one Compose Multiplatform version; the table is the
|
||||
contract.
|
||||
|
||||
**Version policy.** Anything Compose Multiplatform integrates is pinned to what
|
||||
CMP ships - for the androidx mirrors, the *Jetpack* version CMP's release notes
|
||||
map its `org.jetbrains.androidx.*` artifact to, since this port consumes the
|
||||
google coordinates. Anything CMP does not integrate is pinned to the latest
|
||||
release. Deliberate exceptions are commented in the catalog; there is currently
|
||||
one (`savedstate`, below).
|
||||
|
||||
### Toolchain
|
||||
|
||||
| | Version | Notes |
|
||||
|---|---|---|
|
||||
| Kotlin | **2.4.20** | Newer than the 2.3.20 CMP 1.12.1 is built with. Kotlin/Native consumes older klibs fine, and the whole port builds clean on 2.4.20 with no klib API diff, so there is no reason to hold back. |
|
||||
| Compose Multiplatform | **1.12.1** | The vendored sources are the `v1.12.1` tag of both upstream repos, and the JVM parity leg forces the same version - no dev-build skew. skiko is unchanged at 0.150.1, so the mingwX64 fork still matches its Skia base. |
|
||||
| SDL3 | **release-3.4.16** | Built from source as a static lib per host (`scripts/build-sdl/build-all.py`), linked into the executable. |
|
||||
| Skiko | **0.150.1** | macOS / Linux use the official `org.jetbrains.skiko`. |
|
||||
| Skiko (Windows fork) | **0.150.1-mingw.2** | `com.bitsycore.skiko:skiko` - Skiko + Skia in `skiko-windows-x64.dll`. Same Skia base as the official build. Public repo, no auth. |
|
||||
|
||||
### Compose libraries
|
||||
|
||||
The port republishes each vendored artifact under a `com.bitsycore` group that
|
||||
mirrors the upstream one, so the fork of any given coordinate is obvious. Apply
|
||||
the bridge plugin and you keep declaring the **official** coordinate.
|
||||
|
||||
| Library | Official coordinate | Port coordinate |
|
||||
|---|---|---|
|
||||
| Runtime | `org.jetbrains.compose.runtime:runtime*:1.12.1` | *not forked - the official klibs serve every target* |
|
||||
| UI | `org.jetbrains.compose.ui:ui*:1.12.1` | `com.bitsycore.compose.ui:ui*` |
|
||||
| Foundation | `org.jetbrains.compose.foundation:foundation*:1.12.1` | `com.bitsycore.compose.foundation:foundation*` |
|
||||
| Animation | `org.jetbrains.compose.animation:animation*:1.12.1` | `com.bitsycore.compose.animation:animation*` |
|
||||
| Material Ripple | `org.jetbrains.compose.material:material-ripple:1.12.1` | `com.bitsycore.compose.material:material-ripple` |
|
||||
| Material3 | `org.jetbrains.compose.material3:material3:1.12.0-alpha03` | `com.bitsycore.compose.material3:material3` |
|
||||
| Material3 Adaptive | `org.jetbrains.compose.material3.adaptive:adaptive*:1.3.0-rc01` | `com.bitsycore.compose.material3.adaptive:adaptive*` |
|
||||
| Resources | `org.jetbrains.compose.components:components-resources:1.12.1` | `com.bitsycore.compose.components:components-resources` |
|
||||
| Navigation3 UI | `org.jetbrains.androidx.navigation3:navigation3-ui` | `com.bitsycore.navigation3:navigation3-ui` |
|
||||
| Window shell | *no upstream equivalent* | `com.bitsycore.compose:desktop-native-window` |
|
||||
| SDL layer | *no upstream equivalent* | `com.bitsycore.compose.sdl:sdl-core` |
|
||||
|
||||
Material3 rides its own release train upstream: `1.12.0-alpha03` **is** the
|
||||
version Compose Multiplatform 1.12.1 ships (Jetpack Material3 1.5.0-alpha22).
|
||||
material3-adaptive rides a third train again.
|
||||
|
||||
**material3-adaptive** (`adaptive`, `adaptive-layout`, `adaptive-navigation`,
|
||||
`adaptive-navigation3`) is published upstream for ios and macosArm64 only - no
|
||||
linux, no mingw. This port ships all four for every desktop-native target. It
|
||||
needed no source changes at all: every platform actual the native legs want is
|
||||
already in upstream's `nonAndroidMain` / `skikoMain` / `nativeMain`, and
|
||||
`androidx.window:window-core` - the one dependency involved - has published
|
||||
linux, mingw and macos klibs the whole time. The artifacts were missing only
|
||||
because upstream never turned the targets on. `demo --screen=Adaptive` renders
|
||||
a live `ListDetailPaneScaffold`.
|
||||
|
||||
### Ecosystem libraries
|
||||
|
||||
Vendored because upstream stops short of Kotlin/Native desktop. The bridge
|
||||
substitutes them on native desktop targets only.
|
||||
|
||||
| Library | Official coordinate | Port coordinate | Why |
|
||||
|---|---|---|---|
|
||||
| Koin | `io.insert-koin:koin-compose*:4.2.2` | `com.bitsycore.koin:*` | apple + android only upstream |
|
||||
| Koin core | `io.insert-koin:koin-core:4.2.2` | *not forked* | already ships mingwX64 + linux |
|
||||
| Coil 3 | `io.coil-kt.coil3:coil*:3.6.3` | `com.bitsycore.coil3:*` | no mingwX64 anywhere; no desktop native at all for the compose layer |
|
||||
| Pulse MVI | `com.bitsycore.lib:pulse*:0.3.7` | `com.bitsycore.compose.desktop.native.pulse:*` | no desktop-native artifact upstream - and Pulse is itself a `com.bitsycore` library, so the republish is namespaced under this project |
|
||||
|
||||
Koin's `koin-compose-viewmodel-navigation` is deliberately **not** provided: it
|
||||
needs Navigation 2's `navigation-compose`, which has no mingwX64 or linux klibs
|
||||
under either coordinate set. Use `koin-compose-navigation3`.
|
||||
|
||||
### AndroidX, used as-is
|
||||
|
||||
These publish real Kotlin/Native desktop klibs and run on the port unmodified -
|
||||
nothing is vendored or substituted. The port standardises on the **google**
|
||||
`androidx.*` coordinates; do not also pull the `org.jetbrains.androidx.*`
|
||||
mirrors or you will have every class twice.
|
||||
|
||||
| Library | Coordinate |
|
||||
|---|---|
|
||||
| Lifecycle / ViewModel | `androidx.lifecycle:lifecycle-*:2.11.0` |
|
||||
| SavedState | `androidx.savedstate:savedstate*:1.5.0` (CMP builds against 1.4.0; lifecycle 2.11.0 requires it only as a *minimum*, so 1.5.0 resolves cleanly and gives consumers the newer API) |
|
||||
| Navigation3 runtime | `androidx.navigation3:navigation3-runtime:1.1.7` |
|
||||
| Navigation Event | `androidx.navigationevent:navigationevent-compose:1.1.2` |
|
||||
| Collection | `androidx.collection:collection:1.5.0` |
|
||||
| Graphics Shapes | `androidx.graphics:graphics-shapes:1.1.0` |
|
||||
|
||||
## Building
|
||||
|
||||
All four native dependencies — **SDL3**, **SDL3_ttf** (the in-house
|
||||
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`.
|
||||
Build the native libraries once per machine, then build any app target:
|
||||
|
||||
```bash
|
||||
python3 scripts/build-sdl/build-all.py # freetype → sdl3 → sdl3-image → sdl3-ttf
|
||||
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild a subset
|
||||
python3 scripts/build-sdl/build-all.py # SDL3 (static)
|
||||
./gradlew :demo:runDebugExecutableMacosArm64
|
||||
```
|
||||
|
||||
Library versions / URLs are pinned in `scripts/build-sdl/build-sdl.properties`;
|
||||
output lands in the gitignored in-repo `libs/`. Required on every host: `git`,
|
||||
`cmake`, Python 3 (`ninja` is fetched automatically when absent). Per host:
|
||||
Every host needs `git`, `cmake`, and Python 3. Per host: macOS needs the Xcode
|
||||
command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
|
||||
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
|
||||
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).
|
||||
## Support
|
||||
|
||||
Then build any app target — see the demo / apidemo commands above.
|
||||
[Bitsycore's Discord](https://discord.gg/kGnraVAtDR)
|
||||
|
||||
## Vendoring
|
||||
## Documentation
|
||||
|
||||
The bulk of the `androidx.compose.*` code is vendored byte-for-byte from
|
||||
`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.
|
||||
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
|
||||
- [CLAUDE.md](CLAUDE.md): architecture, source-set hierarchy, vendoring rules,
|
||||
density flow, and a per-area file map.
|
||||
- [gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md):
|
||||
consuming the port from another build.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# 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.
|
||||
|
||||
The static lib is built **slim**: the port uses SDL only for video/window,
|
||||
events, clipboard, file dialogs, GL/Metal contexts, the CPU-raster `SDL_Render`
|
||||
blit, filesystem, cursor, locale, theme, and text input/IME. `build-all.py`
|
||||
disables every unused subsystem - audio, joystick, haptic, hidapi, sensor,
|
||||
power, camera, GPU, offscreen, virtual-joystick (all zero-reference), plus
|
||||
tests/examples and the Windows D3D12 driver. If a consumer app ever needs one of
|
||||
these (e.g. SDL audio), re-enable its `-DSDL_*` flag in `build-all.py` and
|
||||
rebuild - a build-time change, no code edit.
|
||||
|
||||
## 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
|
||||
python3 scripts/compose-fork/audit-exclusions.py # every `!` exclusion is classified
|
||||
```
|
||||
|
||||
`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).
|
||||
|
||||
`audit-exclusions` covers the other half: a manifest `!` line takes a file OUT of
|
||||
the verbatim sync, and the local replacement is either a derived copy
|
||||
(`// VENDOR-BASE:`, drift-tracked) or a fresh reimplementation
|
||||
(`// VENDOR-REIMPL:`, deliberately not tracked - only a SIGNATURE change matters).
|
||||
It classifies each one, flags any local counterpart carrying NEITHER marker
|
||||
(invisible to the drift check, silently rotting) and any copy that has become
|
||||
identical to upstream (drop it and re-vendor), and exits non-zero on an
|
||||
unclassified file. Run all three 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.
|
||||
|
||||
### Text metrics diagnostic
|
||||
|
||||
Set `CDN_TEXT_METRICS=1` and run any app to dump, per built paragraph, the font
|
||||
scaler's `ascent`/`descent`/`leading`, the resolved `familyName` + `fontPx`, and
|
||||
the resulting paragraph line box (`paraHeight`, `line0[ascent/descent/baseline/
|
||||
height]`). Its job is the mac-vs-Windows vertical-spacing question (PLAN.md §1b):
|
||||
the SAME `NotoSans.ttf` bytes go through a different Skia `FontMgr` scaler per
|
||||
host (CoreText / fontconfig-FreeType / DirectWrite), which can pick different
|
||||
metric tables. Run it on native AND on the JVM parity app on the SAME host and
|
||||
diff the lines - Windows-native must match skiko-JVM-Windows.
|
||||
|
||||
```bash
|
||||
CDN_TEXT_METRICS=1 demo.kexe --screen=Buttons --screenshot=/tmp/b.bmp
|
||||
```
|
||||
|
||||
### 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 --adaptivetest # material3-adaptive folds/unfolds on a LIVE window resize
|
||||
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
|
||||
the PUBLIC `https://maven.bitsycore.com/releases` (no credentials) as
|
||||
`com.bitsycore.skiko:skiko:0.150.1-mingw.2` (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`. Groups mirror upstream per area (`com.bitsycore.compose.<area>:<module>`, e.g. `com.bitsycore.compose.ui:ui`); project-only modules use `com.bitsycore.compose.sdl` and `:desktop-native-window` is `com.bitsycore.compose` - see `groupFor()` in the root build. | 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. |
|
||||
| Vendored ecosystem libs | `KOIN_REF`, `COIL_REF`, `PULSE_REF` in the same file, plus `koin` / `coil` in `gradle/libs.versions.toml`. | Same rule: durable tags. Each repo gets its own sibling sparse clone (`../cmp-ref-koin`, `../cmp-ref-coil`, `../cmp-ref-pulse-mvi`). Re-sync, then `apiDump` - these modules are BCV-validated like the rest. |
|
||||
| JVM parity forcing | `compose` / `composeMaterial3` / `composeRuntime` in `gradle/libs.versions.toml` (read by `demo`, `apidemo`, `material-symbols` and the root build's forcing map). | Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published; at v1.12.1 they are in lockstep. |
|
||||
| Skiko | `skiko` (official) and `skikoMingw` (the fork) in `gradle/libs.versions.toml`; keep the fork's `-mingw.N` base equal to `skiko`. | macOS/Linux use official Skiko (`org.jetbrains.skiko`); mingwX64 uses the bitsycore fork (`com.bitsycore.skiko:skiko:0.150.1-mingw.2` from the public maven.bitsycore.com), 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 the relevant `*_REF` in `compose.properties` - `COMPOSE_CORE_REF` /
|
||||
`COMPOSE_REF` for Compose, `KOIN_REF` / `COIL_REF` / `PULSE_REF` for the
|
||||
ecosystem libs (and the matching entry 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. Then
|
||||
`python3 scripts/compose-fork/audit-exclusions.py` - a newly-identical copy
|
||||
should go back to verbatim vendoring, and an unclassified one needs a marker.
|
||||
Finish with `./gradlew apiDump` and review the klib API diff.
|
||||
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. Refresh the klib API baselines: on a Windows host (JDK 21, matching CI) run
|
||||
`./gradlew apiDump` and commit any diff. Not a hard gate yet - the API is
|
||||
pre-stable and `apiCheck` isn't wired - but regenerating each release keeps
|
||||
the baselines honest so the eventual gate is a no-op. `macosArm64` is inferred
|
||||
from the linux/mingw ABIs (accurate for the target-independent Compose
|
||||
surface); `:sdl-core` (cinterop) and `:material-symbols` (generated icon maps)
|
||||
are excluded via `apiValidation.ignoredProjects`. Best host: Windows - it
|
||||
builds the mingw slice (the fork's unique surface) for real and only infers
|
||||
macos; the macos ABI is target-independent for the tracked pure-Kotlin modules.
|
||||
4. `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 their per-area `com.bitsycore.compose.*` coords. 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 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.
|
||||
import com.bitsycore.compose.sdl.build.registerComposeFontBundling
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
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 vHostSupportsMingw: Boolean by rootProject.extra
|
||||
val isHostMingwCompatible = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
// ==================
|
||||
// MARK: Targets
|
||||
// ==================
|
||||
|
||||
kotlin {
|
||||
linuxArm64()
|
||||
linuxX64()
|
||||
macosArm64()
|
||||
if (vHostSupportsMingw) mingwX64()
|
||||
jvm()
|
||||
|
||||
targets.withType<KotlinNativeTarget>().all {
|
||||
val isMingw = name == "mingwX64"
|
||||
val isLinux = name.startsWith("linux")
|
||||
val isApple = name.startsWith("macos") || name.startsWith("ios")
|
||||
binaries.executable {
|
||||
if (buildType == NativeBuildType.RELEASE) {
|
||||
// --gc-sections is a GNU ld/lld flag; Apple's ld64 rejects it
|
||||
// (it dead-strips at -O anyway). Only pass it on GNU-ld targets.
|
||||
if (!isApple) linkerOpts("-Wl,--gc-sections")
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-opt")
|
||||
buildList {
|
||||
add(linuxArm64())
|
||||
add(linuxX64())
|
||||
add(macosArm64())
|
||||
if (isHostMingwCompatible) add(mingwX64())
|
||||
}.forEach {
|
||||
it.binaries {
|
||||
executable {
|
||||
when {
|
||||
target.name == "mingwX64" -> linkerOpts(
|
||||
// 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 {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(project(":window"))
|
||||
implementation(project(":material3"))
|
||||
implementation(project(":material-symbols"))
|
||||
val vComposeJvmVersion = libs.versions.compose.get()
|
||||
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(":utils:material-symbols"))
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
@@ -80,17 +79,23 @@ kotlin {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
}
|
||||
}
|
||||
// Single HTTP engine on every desktop target: Ktor's Curl engine. It
|
||||
// bundles a static libcurl per target (Schannel on Windows, OpenSSL on
|
||||
// macOS/Linux) embedded in its cinterop klib — no runtime DLL, one copy.
|
||||
// We use Curl everywhere (not WinHttp/Darwin) so that an upcoming
|
||||
// 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.
|
||||
if (vHostSupportsMingw) getByName("mingwX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
getByName("macosArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
getByName("linuxX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
getByName("linuxArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
nativeMain {
|
||||
dependencies {
|
||||
implementation(project(":compose:desktop:native:desktop-native-window"))
|
||||
implementation(libs.ktor.client.curl)
|
||||
}
|
||||
}
|
||||
jvmMain {
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
// decodeToImageBitmap / decodeToSvgPainter (window icon + in-memory
|
||||
// image viewer). JVM-only: the native side goes through the port's
|
||||
// own EncodedImageDecoder seam, so this stays out of commonMain.
|
||||
implementation("org.jetbrains.compose.components:components-resources:${libs.versions.compose.get()}")
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compilerOptions {
|
||||
@@ -99,181 +104,46 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// SDL3 / SDL3_ttf / SDL3_image / FreeType are linked statically into the
|
||||
// executable (see the mingw linkerOpts above), so there are no runtime DLLs to
|
||||
// bundle — the distributable is just <app>.exe + data.kres.
|
||||
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)
|
||||
}
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "apidemo.MainJvmKt"
|
||||
}
|
||||
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
|
||||
downloaded TTF (owned by :material-symbols) to only the codepoints in
|
||||
usage-codepoint.txt and writes the output under this app's build/icons/ dir. */
|
||||
fun registerSubsetTask(inStyle: String): 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 org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
|
||||
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
|
||||
val vOut = iconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
|
||||
val vUsage = iconsBuildDir.get().file("usage-codepoint.txt").asFile
|
||||
inputs.file(vInputProvider)
|
||||
inputs.file(vUsage)
|
||||
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
|
||||
compose.desktop.native {
|
||||
entryPoint = "apidemo.main"
|
||||
icon {
|
||||
light.from(
|
||||
layout.projectDirectory.file("icons/voltic-icon-32.png"),
|
||||
layout.projectDirectory.file("icons/voltic-icon-128.png"),
|
||||
)
|
||||
dark.from(
|
||||
layout.projectDirectory.file("icons/voltic-icon-dark-32.png"),
|
||||
layout.projectDirectory.file("icons/voltic-icon-dark-128.png"),
|
||||
)
|
||||
exeIcon.from(
|
||||
listOf(16, 32, 48, 64, 128, 256).map {
|
||||
layout.projectDirectory.file("icons/voltic-icon-$it.png")
|
||||
}
|
||||
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")
|
||||
}
|
||||
)
|
||||
embedWindowsIcon = providers.gradleProperty("embedWindowsIcon").map { it.toBoolean() }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// style is subset once and reused across all bundle variants.
|
||||
val subsetTasksByStyle: Map<String, TaskProvider<*>> =
|
||||
if (subsetIcons) vUsedStyles.associateWith { registerSubsetTask(it) } else emptyMap()
|
||||
// ==================
|
||||
// MARK: Fonts (shared pipeline - buildSrc ComposeFontBundling.kt)
|
||||
// ==================
|
||||
|
||||
for (variant in variants) {
|
||||
for (target in nativeTargets) {
|
||||
val variantCap = variant.replaceFirstChar { it.uppercase() }
|
||||
val targetCap = target.replaceFirstChar { it.uppercase() }
|
||||
val outDir = layout.buildDirectory.dir("bin/$target/${variant}Executable")
|
||||
val copyTask = tasks.register<Zip>("copy${variantCap}ComposeResources${targetCap}") {
|
||||
archiveFileName.set("data.kres")
|
||||
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") }
|
||||
from(notoMonoFile) { into("font") }
|
||||
dependsOn(":ui:downloadNotoFonts")
|
||||
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) }
|
||||
}
|
||||
}
|
||||
registerComposeFontBundling {
|
||||
bundleNotoSans = true
|
||||
// The mono body font is loaded through the app's own seam (Fonts.kt), not via
|
||||
// FontFamily.Monospace call sites - auto-detection can't see it, bundle explicitly.
|
||||
bundleNotoSansMono = true
|
||||
bundleMaterialSymbols = true
|
||||
enableIconSubsetting = true
|
||||
}
|
||||
|
||||
// Parity-window icon (cosmetic : the harness compares the canvas, not chrome).
|
||||
tasks.named<ProcessResources>("jvmProcessResources") {
|
||||
from(layout.projectDirectory.file("icons/voltic-icon-256.png")) { into("icon") }
|
||||
}
|
||||
|
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,181 +2,182 @@ package apidemo
|
||||
|
||||
import kotlinx.cinterop.*
|
||||
import platform.CoreFoundation.*
|
||||
import platform.Foundation.*
|
||||
import platform.Foundation.CFBridgingRelease
|
||||
import platform.Foundation.NSString
|
||||
import platform.Security.*
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
// 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 =
|
||||
PreparedCert(
|
||||
sslCert = inReq.certPath,
|
||||
sslCertType = inReq.certFormat.curlName,
|
||||
sslKey = inReq.keyPath.ifBlank { null },
|
||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||
cleanup = {},
|
||||
)
|
||||
PreparedCert(
|
||||
sslCert = inReq.certPath,
|
||||
sslCertType = inReq.certFormat.curlName,
|
||||
sslKey = inReq.keyPath.ifBlank { null },
|
||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||
cleanup = {},
|
||||
)
|
||||
|
||||
/* No temporary certificate store off Windows. */
|
||||
/** No temporary certificate store off Windows. */
|
||||
actual fun sweepTempClientCerts() {}
|
||||
|
||||
/* Continue the server's chain with intermediates / roots pulled from the
|
||||
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
|
||||
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
|
||||
in any keychain). */
|
||||
/** Continue the server's chain with intermediates / roots pulled from the
|
||||
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
|
||||
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
|
||||
in any keychain). */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
|
||||
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
||||
?: return serverChainWithIssuerName(inServerCerts)
|
||||
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
|
||||
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
||||
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
||||
?: return serverChainWithIssuerName(inServerCerts)
|
||||
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
|
||||
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
||||
}
|
||||
|
||||
// ============
|
||||
// Chain walk via Security.framework
|
||||
// ============
|
||||
|
||||
/* Build the chain by handing the leaf to SecTrust + evaluating against
|
||||
an SSL policy — Apple then walks Keychain, pulling intermediates and
|
||||
the root in. Returns the resolved chain with fromServer=true for the
|
||||
first inServerCount entries (those came down on the wire) and false
|
||||
for anything the OS added. */
|
||||
/** Build the chain by handing the leaf to SecTrust + evaluating against
|
||||
an SSL policy - Apple then walks Keychain, pulling intermediates and
|
||||
the root in. Returns the resolved chain with fromServer=true for the
|
||||
first inServerCount entries (those came down on the wire) and false
|
||||
for anything the OS added. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun osChainApple(
|
||||
inLeafPem: String,
|
||||
inServerCount: Int,
|
||||
inServerCerts: List<List<Pair<String, String>>>,
|
||||
inLeafPem: String,
|
||||
inServerCount: Int,
|
||||
inServerCerts: List<List<Pair<String, String>>>,
|
||||
): List<ChainCert> {
|
||||
// Parse every cert the server presented, not just the leaf — Apple's
|
||||
// chain builder uses them as candidate intermediates when stitching to
|
||||
// a root, instead of relying solely on AIA-fetch which is off by default.
|
||||
val vServerCerts = inServerCerts.mapNotNull {
|
||||
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
|
||||
}
|
||||
if (vServerCerts.isEmpty()) return emptyList()
|
||||
// Parse every cert the server presented, not just the leaf - Apple's
|
||||
// chain builder uses them as candidate intermediates when stitching to
|
||||
// a root, instead of relying solely on AIA-fetch which is off by default.
|
||||
val vServerCerts = inServerCerts.mapNotNull {
|
||||
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
|
||||
}
|
||||
if (vServerCerts.isEmpty()) return emptyList()
|
||||
|
||||
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
|
||||
val vCertArray = createCFArray(vCertRefs)
|
||||
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
|
||||
val vCertArray = createCFArray(vCertRefs)
|
||||
|
||||
// SSL policy lets evaluateTrust pull a root out of Keychain.
|
||||
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
|
||||
vCertRefs.forEach { CFRelease(it) }
|
||||
CFRelease(vCertArray)
|
||||
return emptyList()
|
||||
}
|
||||
// SSL policy lets evaluateTrust pull a root out of Keychain.
|
||||
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
|
||||
vCertRefs.forEach { CFRelease(it) }
|
||||
CFRelease(vCertArray)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
|
||||
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
|
||||
CFRelease(vPolicy)
|
||||
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
|
||||
vCertRefs.forEach { CFRelease(it) }
|
||||
CFRelease(vCertArray)
|
||||
nativeHeap.free(vTrustVar)
|
||||
return emptyList()
|
||||
}
|
||||
val vTrust = vTrustVar.value!!
|
||||
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
|
||||
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
|
||||
CFRelease(vPolicy)
|
||||
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
|
||||
vCertRefs.forEach { CFRelease(it) }
|
||||
CFRelease(vCertArray)
|
||||
nativeHeap.free(vTrustVar)
|
||||
return emptyList()
|
||||
}
|
||||
val vTrust = vTrustVar.value!!
|
||||
|
||||
// Evaluate even if it fails — we still want the partial chain back so
|
||||
// the UI can show what it could resolve. SecTrustEvaluateWithError
|
||||
// stops at the highest cert it found.
|
||||
val vErr = nativeHeap.alloc<CFErrorRefVar>()
|
||||
SecTrustEvaluateWithError(vTrust, vErr.ptr)
|
||||
vErr.value?.let { CFRelease(it) }
|
||||
nativeHeap.free(vErr)
|
||||
// Evaluate even if it fails - we still want the partial chain back so
|
||||
// the UI can show what it could resolve. SecTrustEvaluateWithError
|
||||
// stops at the highest cert it found.
|
||||
val vErr = nativeHeap.alloc<CFErrorRefVar>()
|
||||
SecTrustEvaluateWithError(vTrust, vErr.ptr)
|
||||
vErr.value?.let { CFRelease(it) }
|
||||
nativeHeap.free(vErr)
|
||||
|
||||
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
|
||||
val vResolved = ArrayList<ChainCert>(vCount)
|
||||
val vSubjects = ArrayList<String>(vCount)
|
||||
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
|
||||
val vResolved = ArrayList<ChainCert>(vCount)
|
||||
val vSubjects = ArrayList<String>(vCount)
|
||||
|
||||
for (vI in 0 until vCount) {
|
||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||
val vSubject = secSubjectSummary(vCert)
|
||||
vSubjects.add(vSubject ?: "")
|
||||
}
|
||||
for (vI in 0 until vCount) {
|
||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||
val vSubject = vSubjects[vI].ifBlank { null }
|
||||
// The issuer is the next cert in the chain's subject (or this
|
||||
// cert's own subject if it's the self-signed root).
|
||||
val vIssuer = when {
|
||||
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
|
||||
vSubject != null -> vSubject // root: self-signed
|
||||
else -> null
|
||||
}
|
||||
val vPem = secCertPem(vCert)
|
||||
val vFields = buildList {
|
||||
vSubject?.let { add("Subject" to "CN=$it") }
|
||||
vIssuer?.let { add("Issuer" to "CN=$it") }
|
||||
vPem?.let { add("Cert" to it) }
|
||||
}
|
||||
vResolved.add(ChainCert(vFields, vI < inServerCount))
|
||||
}
|
||||
for (vI in 0 until vCount) {
|
||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||
val vSubject = secSubjectSummary(vCert)
|
||||
vSubjects.add(vSubject ?: "")
|
||||
}
|
||||
for (vI in 0 until vCount) {
|
||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||
val vSubject = vSubjects[vI].ifBlank { null }
|
||||
// The issuer is the next cert in the chain's subject (or this
|
||||
// cert's own subject if it's the self-signed root).
|
||||
val vIssuer = when {
|
||||
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
|
||||
vSubject != null -> vSubject // root: self-signed
|
||||
else -> null
|
||||
}
|
||||
val vPem = secCertPem(vCert)
|
||||
val vFields = buildList {
|
||||
vSubject?.let { add("Subject" to "CN=$it") }
|
||||
vIssuer?.let { add("Issuer" to "CN=$it") }
|
||||
vPem?.let { add("Cert" to it) }
|
||||
}
|
||||
vResolved.add(ChainCert(vFields, vI < inServerCount))
|
||||
}
|
||||
|
||||
CFRelease(vTrust)
|
||||
vCertRefs.forEach { CFRelease(it) }
|
||||
CFRelease(vCertArray)
|
||||
nativeHeap.free(vTrustVar)
|
||||
return vResolved
|
||||
CFRelease(vTrust)
|
||||
vCertRefs.forEach { CFRelease(it) }
|
||||
CFRelease(vCertArray)
|
||||
nativeHeap.free(vTrustVar)
|
||||
return vResolved
|
||||
}
|
||||
|
||||
/* SecCertificate from DER bytes. Returns null if the bytes aren't a
|
||||
valid X.509 cert. */
|
||||
/** SecCertificate from DER bytes. Returns null if the bytes aren't a
|
||||
valid X.509 cert. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
|
||||
val vData = inDer.usePinned { vPin ->
|
||||
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
|
||||
} ?: return null
|
||||
val vCert = SecCertificateCreateWithData(null, vData)
|
||||
CFRelease(vData)
|
||||
return vCert
|
||||
val vData = inDer.usePinned { vPin ->
|
||||
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
|
||||
} ?: return null
|
||||
val vCert = SecCertificateCreateWithData(null, vData)
|
||||
CFRelease(vData)
|
||||
return vCert
|
||||
}
|
||||
|
||||
/* CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
|
||||
/** CFArray of SecCertificateRef - the input to SecTrustCreateWithCertificates. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
|
||||
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
|
||||
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
|
||||
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
|
||||
nativeHeap.free(vPtrs)
|
||||
return vArr
|
||||
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
|
||||
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
|
||||
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
|
||||
nativeHeap.free(vPtrs)
|
||||
return vArr
|
||||
}
|
||||
|
||||
/* Apple's "subject summary" — basically the CN, but falls back to the
|
||||
organisation when no CN is present (matches what Keychain Access
|
||||
displays in its cert list). */
|
||||
/** Apple's "subject summary" - basically the CN, but falls back to the
|
||||
organisation when no CN is present (matches what Keychain Access
|
||||
displays in its cert list). */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun secSubjectSummary(inCert: SecCertificateRef): String? {
|
||||
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
|
||||
val vNs = CFBridgingRelease(vCf) as? NSString
|
||||
return vNs?.toString()
|
||||
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
|
||||
val vNs = CFBridgingRelease(vCf) as? NSString
|
||||
return vNs?.toString()
|
||||
}
|
||||
|
||||
/* PEM string for a SecCertificate — base64 of the DER bytes wrapped
|
||||
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
|
||||
/** PEM string for a SecCertificate - base64 of the DER bytes wrapped
|
||||
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
|
||||
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
|
||||
private fun secCertPem(inCert: SecCertificateRef): String? {
|
||||
val vData = SecCertificateCopyData(inCert) ?: return null
|
||||
val vLen = CFDataGetLength(vData).toInt()
|
||||
val vBytes = ByteArray(vLen)
|
||||
val vPtr = CFDataGetBytePtr(vData)
|
||||
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
|
||||
CFRelease(vData)
|
||||
val vB64 = Base64.Default.encode(vBytes)
|
||||
val vWrapped = vB64.chunked(64).joinToString("\n")
|
||||
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
|
||||
val vData = SecCertificateCopyData(inCert) ?: return null
|
||||
val vLen = CFDataGetLength(vData).toInt()
|
||||
val vBytes = ByteArray(vLen)
|
||||
val vPtr = CFDataGetBytePtr(vData)
|
||||
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
|
||||
CFRelease(vData)
|
||||
val vB64 = Base64.encode(vBytes)
|
||||
val vWrapped = vB64.chunked(64).joinToString("\n")
|
||||
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)
|
||||
private fun derFromPem(inPem: String): ByteArray? {
|
||||
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
|
||||
val vBody = inPem.lineSequence()
|
||||
.filterNot { it.startsWith("-----") }
|
||||
.joinToString("") { it.trim() }
|
||||
return runCatching { Base64.Default.decode(vBody) }.getOrNull()
|
||||
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
|
||||
val vBody = inPem.lineSequence()
|
||||
.filterNot { it.startsWith("-----") }
|
||||
.joinToString("") { it.trim() }
|
||||
return runCatching { Base64.decode(vBody) }.getOrNull()
|
||||
}
|
||||
|
||||
@@ -6,17 +6,6 @@ import androidx.compose.ui.graphics.Color
|
||||
// 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) {
|
||||
ReqMethod.GET -> Color(0xFF4C9AFF)
|
||||
ReqMethod.POST -> Color(0xFF36B37E)
|
||||
@@ -35,11 +24,11 @@ internal val kPackColors: List<Long> = listOf(
|
||||
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? =
|
||||
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 {
|
||||
inContentType == null -> "image.bin"
|
||||
inContentType.contains("png", ignoreCase = true) -> "image.png"
|
||||
@@ -51,9 +40,9 @@ internal fun imageFileName(inContentType: String?): String = when {
|
||||
}
|
||||
|
||||
internal fun statusColor(inStatus: Int): Color = when (inStatus) {
|
||||
in 200..299 -> Color(0xFF36B37E) // success — green
|
||||
in 300..399 -> Color(0xFF4C9AFF) // redirect — blue
|
||||
in 400..499 -> Color(0xFFFF991F) // client error — orange (warning)
|
||||
in 500..599 -> Color(0xFFFF5630) // server error — red
|
||||
else -> Color(0xFFFF991F) // unknown / pending — orange
|
||||
in 200..299 -> Color(0xFF36B37E) // success - green
|
||||
in 300..399 -> Color(0xFF4C9AFF) // redirect - blue
|
||||
in 400..499 -> Color(0xFFFF991F) // client error - orange (warning)
|
||||
in 500..599 -> Color(0xFFFF5630) // server error - red
|
||||
else -> Color(0xFFFF991F) // unknown / pending - orange
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
// ==================
|
||||
// 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
|
||||
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
|
||||
open-tab strip reference the same request without index juggling. */
|
||||
/** 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
|
||||
open-tab strip reference the same request without index juggling. */
|
||||
internal class ReqState(inInitial: ApiRequest) {
|
||||
var req by mutableStateOf(inInitial)
|
||||
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 sentReq by mutableStateOf<ApiRequest?>(null) // resolved request actually sent (Request tab)
|
||||
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.
|
||||
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
|
||||
var bodyWrap by mutableStateOf(true) // body view: true = soft-wrap, false = no wrap + horizontal scroll
|
||||
@@ -35,77 +38,98 @@ internal class ReqState(inInitial: ApiRequest) {
|
||||
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
|
||||
last file save), and its own requests, variables and open-tab strip. Switching
|
||||
packs swaps the whole working set. */
|
||||
internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpenTabs: List<Int> = emptyList(), inActive: Int = -1) {
|
||||
/** 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
|
||||
packs swaps the whole working set. */
|
||||
internal class PackState(
|
||||
inPack: Pack,
|
||||
inPath: String?,
|
||||
inDirty: Boolean,
|
||||
inOpenTabs: List<Int> = emptyList(),
|
||||
inActive: Int = -1
|
||||
) {
|
||||
var name by mutableStateOf(inPack.name)
|
||||
var path by mutableStateOf(inPath)
|
||||
var dirty by mutableStateOf(inDirty)
|
||||
var color by mutableStateOf(inPack.color)
|
||||
|
||||
// A linked copy mirrors its source's requests read-only; non-linked packs own
|
||||
// their requests. `requests` resolves to whichever applies.
|
||||
var linkedSource by mutableStateOf<PackState?>(null)
|
||||
private val fOwnRequests = mutableStateListOf<ReqState>().apply {
|
||||
private val mOwnRequests = mutableStateListOf<ReqState>().apply {
|
||||
inPack.requests.forEach { add(ReqState(it)) } // packs (and the loose root) may be empty
|
||||
}
|
||||
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
|
||||
get() = linkedSource?.requests ?: fOwnRequests
|
||||
get() = linkedSource?.requests ?: mOwnRequests
|
||||
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 {
|
||||
inPack.subPacks.forEach { add(PackState(it, null, false).also { vSp -> vSp.parent = this@PackState }) }
|
||||
}
|
||||
val variables = mutableStateListOf<KeyVal>().apply { addAll(inPack.variables) }
|
||||
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)
|
||||
val id: String = inPack.id.ifBlank { newPackId() } // stable id (linked copies reference it)
|
||||
val isRoot: Boolean = inPack.isRoot // the session-root "pack" (loose requests)
|
||||
val id: String =
|
||||
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.
|
||||
val openTabs = mutableStateListOf<ReqState>().apply {
|
||||
inOpenTabs.forEach { vIdx -> requests.getOrNull(vIdx)?.let { add(it) } }
|
||||
}
|
||||
var active by mutableStateOf(requests.getOrNull(inActive)?.takeIf { it in openTabs } ?: openTabs.firstOrNull())
|
||||
var expanded by mutableStateOf(true) // sidebar fold state (transient — not part of the pack file)
|
||||
var expanded by mutableStateOf(true) // sidebar fold state (transient - not part of the pack file)
|
||||
var envOpen by mutableStateOf(false) // whether this pack's env tab is open in the strip (transient)
|
||||
|
||||
fun toPack(): Pack = Pack(
|
||||
name = name,
|
||||
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,
|
||||
id = id, linkedTo = linkedSource?.id, isRoot = isRoot,
|
||||
variables = variables.toList(),
|
||||
color = color,
|
||||
headers = headers.toList(),
|
||||
params = params.toList(),
|
||||
cert = cert,
|
||||
id = id,
|
||||
linkedTo = linkedSource?.id,
|
||||
isRoot = isRoot,
|
||||
subPacks = subPacks.map { it.toPack() },
|
||||
)
|
||||
}
|
||||
|
||||
/* A fresh random id for a pack (used to wire linked-copy packs to their source
|
||||
across save / load). */
|
||||
/** A fresh random id for a pack (used to wire linked-copy packs to their source
|
||||
across save / load). */
|
||||
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
|
||||
req == null, or the session settings tab when isSession (pack == null). The
|
||||
underlying ReqState / PackState (or the session key) is the stable identity. */
|
||||
/** 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
|
||||
underlying ReqState / PackState (or the session key) is the stable identity. */
|
||||
internal const val kSessionTabKey = "session-settings"
|
||||
|
||||
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
|
||||
for the same request collide (double selection, duplicate key()). Data class →
|
||||
structural equality, so compare keys with == (not ===). */
|
||||
|
||||
/** 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
|
||||
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 val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
|
||||
|
||||
// ==================
|
||||
// MARK: Cross-pack drag (sidebar tree)
|
||||
// ==================
|
||||
|
||||
/* 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
|
||||
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
|
||||
a whole pack header (dragPack). Every rendered row / header registers its
|
||||
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
|
||||
so the target section draws the indicator. */
|
||||
/** 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
|
||||
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
|
||||
a whole pack header (dragPack). Every rendered row / header registers its
|
||||
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
|
||||
so the target section draws the indicator. */
|
||||
internal class TreeDrag {
|
||||
// What's being dragged (at most one is non-null while a drag is live).
|
||||
var dragReq by mutableStateOf<ReqState?>(null)
|
||||
@@ -113,7 +137,8 @@ internal class TreeDrag {
|
||||
var dragPack by mutableStateOf<PackState?>(null)
|
||||
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)
|
||||
// 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.
|
||||
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,68 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
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"
|
||||
|
||||
/** Family name the bundled proportional font registers under. */
|
||||
const val kDefaultFamily = "noto-sans"
|
||||
|
||||
/** 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?
|
||||
|
||||
// ==================
|
||||
// MARK: Default proportional font (seam)
|
||||
// ==================
|
||||
// PARITY: the native side resolves FontFamily.Default to the BUNDLED NotoSans
|
||||
// (SkiaFonts.defaultTypeface is the fallback for every unresolved family), while
|
||||
// stock Compose Desktop on the JVM resolves it to a SYSTEM font (Segoe UI on
|
||||
// Windows, Helvetica on macOS). Different typefaces mean different ascent /
|
||||
// descent / line-gap, so identical layout code puts glyphs at a different height
|
||||
// inside an identically-sized text box - which is what made the SourceTag pill
|
||||
// look vertically centred on JVM and off-centre on native.
|
||||
//
|
||||
// The two stacks must rasterise the SAME face for the comparison to mean
|
||||
// anything, so the app pins its default family explicitly rather than inheriting
|
||||
// whatever the platform picks. Null = the bundled font isn't available, in which
|
||||
// case we fall back to the platform default and the skew returns.
|
||||
|
||||
/** The bundled proportional font, applied app-wide as the default text family.
|
||||
Null when NotoSans isn't shipped (data.kres on native, classpath font/ on jvm). */
|
||||
expect val defaultFontFamily: FontFamily?
|
||||
|
||||
/** Re-stamps every style in a Material 3 [Typography] with [inFamily]. M3 seeds
|
||||
LocalTextStyle from the typography, so this reaches every `Text` that doesn't name
|
||||
a family of its own. A null family leaves the typography untouched. */
|
||||
fun Typography.withDefaultFontFamily(inFamily: FontFamily?): Typography {
|
||||
if (inFamily == null) return this
|
||||
return copy(
|
||||
displayLarge = displayLarge.copy(fontFamily = inFamily),
|
||||
displayMedium = displayMedium.copy(fontFamily = inFamily),
|
||||
displaySmall = displaySmall.copy(fontFamily = inFamily),
|
||||
headlineLarge = headlineLarge.copy(fontFamily = inFamily),
|
||||
headlineMedium = headlineMedium.copy(fontFamily = inFamily),
|
||||
headlineSmall = headlineSmall.copy(fontFamily = inFamily),
|
||||
titleLarge = titleLarge.copy(fontFamily = inFamily),
|
||||
titleMedium = titleMedium.copy(fontFamily = inFamily),
|
||||
titleSmall = titleSmall.copy(fontFamily = inFamily),
|
||||
bodyLarge = bodyLarge.copy(fontFamily = inFamily),
|
||||
bodyMedium = bodyMedium.copy(fontFamily = inFamily),
|
||||
bodySmall = bodySmall.copy(fontFamily = inFamily),
|
||||
labelLarge = labelLarge.copy(fontFamily = inFamily),
|
||||
labelMedium = labelMedium.copy(fontFamily = inFamily),
|
||||
labelSmall = labelSmall.copy(fontFamily = inFamily),
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonElement
|
||||
// respects the editor's "tab size" (TextLayoutConfig.tabWidth), exactly like
|
||||
// typed tabs. Lenient so slightly-off JSON (single quotes, trailing commas)
|
||||
// still reformats instead of failing.
|
||||
private val fPrettyJson = Json {
|
||||
private val mPrettyJson = Json {
|
||||
prettyPrint = true
|
||||
prettyPrintIndent = "\t"
|
||||
isLenient = true
|
||||
@@ -23,15 +23,16 @@ private val fPrettyJson = Json {
|
||||
fun formatBody(inText: String, inFormat: BodyFormat): String =
|
||||
when (inFormat) {
|
||||
BodyFormat.JSON -> runCatching {
|
||||
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
|
||||
mPrettyJson.encodeToString(JsonElement.serializer(), mPrettyJson.parseToJsonElement(inText))
|
||||
}.getOrDefault(inText)
|
||||
|
||||
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
|
||||
else -> inText
|
||||
}
|
||||
|
||||
// Minimal XML re-indenter: a newline between adjacent tags, one tab per
|
||||
// nesting level (rendered at the editor's tab size). Declarations (<?…?>), comments / doctype (<!…>), self-closing tags and
|
||||
// single-line <a>text</a> elements don't change depth. Best-effort — content
|
||||
// single-line <a>text</a> elements don't change depth. Best-effort - content
|
||||
// with a literal '>' inside attributes or CDATA may not round-trip, hence the
|
||||
// runCatching guard at the call site.
|
||||
private fun formatXml(inText: String): String {
|
||||
@@ -1,29 +1,28 @@
|
||||
package apidemo
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.curl.Curl
|
||||
import apidemo.compat.createApiHttpClient
|
||||
import apidemo.compat.systemFileSystem
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okio.Buffer
|
||||
import okio.FileSystem
|
||||
import okio.GzipSource
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.buffer
|
||||
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
|
||||
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.
|
||||
run() is a suspend fun — call it off the UI dispatcher. */
|
||||
/** 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
|
||||
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
|
||||
run() is a suspend fun - call it off the UI dispatcher. */
|
||||
class HttpRunner {
|
||||
|
||||
private val fClient = HttpClient(Curl)
|
||||
private val mClient = createApiHttpClient()
|
||||
|
||||
init {
|
||||
// Clear any temporary client certs a prior crash left in the Windows
|
||||
@@ -33,11 +32,11 @@ class HttpRunner {
|
||||
|
||||
suspend fun run(inReq: ApiRequest): ApiResponse {
|
||||
// Client-certificate requests bypass Ktor (no engine exposes a cert API)
|
||||
// and go straight through libcurl — same bundled TLS stack.
|
||||
// and go straight through libcurl - same bundled TLS stack.
|
||||
if (inReq.hasClientCert) return curlSendWithClientCert(inReq)
|
||||
val vMark = TimeSource.Monotonic.markNow()
|
||||
return try {
|
||||
val vResp: HttpResponse = fClient.request(inReq.url.trim()) {
|
||||
val vResp: HttpResponse = mClient.request(inReq.url.trim()) {
|
||||
method = HttpMethod.parse(inReq.method.name)
|
||||
inReq.params
|
||||
.filter { it.enabled && it.key.isNotBlank() }
|
||||
@@ -45,7 +44,7 @@ class HttpRunner {
|
||||
inReq.headers
|
||||
.filter { it.enabled && it.key.isNotBlank() }
|
||||
.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
|
||||
// request already carries an explicit Content-Type header.
|
||||
val vCt = inReq.bodyContentType()
|
||||
@@ -73,7 +72,6 @@ class HttpRunner {
|
||||
// so show a short note instead and let the user Save the raw bytes.
|
||||
val vBinary = !vIsImage && isBinaryBody(vContentType, vBody)
|
||||
ApiResponse(
|
||||
ok = true,
|
||||
status = vResp.status.value,
|
||||
statusText = vResp.status.description,
|
||||
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
||||
@@ -83,7 +81,7 @@ class HttpRunner {
|
||||
.sortedBy { it.first.lowercase() },
|
||||
body = when {
|
||||
vIsImage -> ""
|
||||
vBinary -> "(${vContentType ?: "binary"} · ${vBody.size} bytes — not shown; use Save as…)"
|
||||
vBinary -> "(${vContentType ?: "binary"} · ${vBody.size} bytes - not shown; use Save as…)"
|
||||
else -> vBody.decodeToString()
|
||||
},
|
||||
bytes = vBody,
|
||||
@@ -99,14 +97,13 @@ class HttpRunner {
|
||||
httpVersion = vResp.version.toString(),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
// The caller cancelled (Cancel button) — let it propagate so the
|
||||
// The caller cancelled (Cancel button) - let it propagate so the
|
||||
// request is dropped rather than reported as a failed response.
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
ApiResponse(
|
||||
ok = false,
|
||||
status = 0,
|
||||
statusText = "—",
|
||||
statusText = "-",
|
||||
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
||||
sizeBytes = 0,
|
||||
headers = emptyList(),
|
||||
@@ -116,33 +113,33 @@ class HttpRunner {
|
||||
}
|
||||
}
|
||||
|
||||
fun close() = fClient.close()
|
||||
fun close() = mClient.close()
|
||||
}
|
||||
|
||||
/* 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
|
||||
embedded NULs, is treated as binary. */
|
||||
/** 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
|
||||
embedded NULs, is treated as binary. */
|
||||
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
|
||||
val vCt = inContentType?.lowercase() ?: ""
|
||||
val vTextual = vCt.startsWith("text/") ||
|
||||
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
|
||||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
|
||||
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
|
||||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
|
||||
if (vTextual) return false
|
||||
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
|
||||
}
|
||||
|
||||
/* True when the bytes are gzip — either the response says so or they carry the
|
||||
gzip magic number (1f 8b). */
|
||||
/** True when the bytes are gzip - either the response says so or they carry the
|
||||
gzip magic number (1f 8b). */
|
||||
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
|
||||
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 =
|
||||
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 {
|
||||
val vGz = GzipSource(Buffer().write(inBytes)).buffer()
|
||||
val vResult = vGz.readByteArray()
|
||||
@@ -1,5 +1,3 @@
|
||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
|
||||
package apidemo
|
||||
|
||||
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)
|
||||
// ==================
|
||||
|
||||
/* 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
|
||||
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. */
|
||||
/** 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
|
||||
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. */
|
||||
@Composable
|
||||
internal fun InheritedEditableTab(
|
||||
inInherited: List<InheritedKv>,
|
||||
@@ -40,8 +38,10 @@ internal fun InheritedEditableTab(
|
||||
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
|
||||
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
InheritedKvSection(inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
|
||||
inOnOverride = if (inReadOnly) null else inOnOverride)
|
||||
InheritedKvSection(
|
||||
inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
|
||||
inOnOverride = if (inReadOnly) null else inOnOverride
|
||||
)
|
||||
if (inInherited.any { it.kv.key.isNotBlank() }) {
|
||||
HorizontalDivider(color = c.border)
|
||||
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
|
||||
internal fun HeadersTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
||||
InheritedEditableTab(inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
|
||||
internal fun HeadersTab(
|
||||
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) } },
|
||||
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
|
||||
internal fun QueryTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
||||
InheritedEditableTab(inInherited, inReq.params, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request query params",
|
||||
internal fun QueryTab(
|
||||
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) } },
|
||||
inOnChange = { v -> inEdit { it.copy(params = v) } })
|
||||
|
||||
/* 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 /
|
||||
the session when the request is sent. */
|
||||
/** 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 /
|
||||
the session when the request is sent. */
|
||||
@Composable
|
||||
internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
||||
InheritedEditableTab(inInherited, inReq.variables, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request variables",
|
||||
internal fun VarTab(
|
||||
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) } },
|
||||
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)
|
||||
// ==================
|
||||
|
||||
/* 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
|
||||
is skipped when it would just repeat the label (the 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
|
||||
is skipped when it would just repeat the label (the session). */
|
||||
@Composable
|
||||
internal fun SourceTag(inLabel: String, inPath: String) {
|
||||
val c = LocalAppColors.current
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
"Overrides <key> from <path>". */
|
||||
/** The tiny marker shown on an own value that shadows an inherited one - hover for
|
||||
"Overrides <key> from <path>". */
|
||||
@Composable
|
||||
internal fun OverrideMark(inKey: String, inPath: String) {
|
||||
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
|
||||
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. */
|
||||
/** 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
|
||||
row offers a one-click Override that copies it into this scope's own list. */
|
||||
@Composable
|
||||
internal fun InheritedKvSection(
|
||||
inTitle: String,
|
||||
@@ -139,15 +169,26 @@ internal fun InheritedKvSection(
|
||||
vShown.forEach { vIt ->
|
||||
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
|
||||
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)
|
||||
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 {
|
||||
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
|
||||
inOnOverride != null -> Box(
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.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),
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.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) }
|
||||
}
|
||||
}
|
||||
@@ -159,11 +200,15 @@ internal fun InheritedKvSection(
|
||||
// MARK: Key/value editor
|
||||
// ==================
|
||||
|
||||
/* 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
|
||||
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
|
||||
/** 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
|
||||
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
|
||||
@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 vAnyOverride = inRows.any { inOverrideInfo(it) != null }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -188,15 +233,33 @@ internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> Stri
|
||||
Checkbox(
|
||||
checked = vKv.enabled,
|
||||
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) {
|
||||
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(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 }) }
|
||||
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(
|
||||
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()) }
|
||||
@@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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
|
||||
internal fun MenuRow(inIcon: Int, inLabel: String, inColor: Color? = null) {
|
||||
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,91 +1,91 @@
|
||||
package apidemo
|
||||
|
||||
import apidemo.compat.systemFileSystem
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
// ==================
|
||||
// MARK: Pack persistence (okio + kotlinx.serialization)
|
||||
// ==================
|
||||
|
||||
private val fJson = Json {
|
||||
private val mJson = Json {
|
||||
prettyPrint = true
|
||||
prettyPrintIndent = " "
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/* Write the pack as pretty JSON to inPath. Returns null on success, else the
|
||||
error message. */
|
||||
/** Write the pack as pretty JSON to inPath. Returns null on success, else the
|
||||
error message. */
|
||||
fun exportPack(inPack: Pack, inPath: String): String? = try {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
||||
writeUtf8(fJson.encodeToString(inPack))
|
||||
systemFileSystem.write(inPath.trim().toPath()) {
|
||||
writeUtf8(mJson.encodeToString(inPack))
|
||||
}
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
|
||||
/* Read a pack back from inPath. */
|
||||
/** Read a pack back from inPath. */
|
||||
fun importPack(inPath: String): Result<Pack> = try {
|
||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(fJson.decodeFromString<Pack>(vText))
|
||||
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(mJson.decodeFromString<Pack>(vText))
|
||||
} catch (e: Throwable) {
|
||||
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 {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
||||
writeUtf8(fJson.encodeToString(inSession))
|
||||
systemFileSystem.write(inPath.trim().toPath()) {
|
||||
writeUtf8(mJson.encodeToString(inSession))
|
||||
}
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
|
||||
/* Read a session back from inPath. */
|
||||
/** Read a session back from inPath. */
|
||||
fun importSession(inPath: String): Result<Session> = try {
|
||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(fJson.decodeFromString<Session>(vText))
|
||||
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(mJson.decodeFromString<Session>(vText))
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
/* Rename inPath to inNewName within the same directory (adding a .json
|
||||
extension when the new name has none), returning the new path on success. */
|
||||
/** Rename inPath to inNewName within the same directory (adding a .json
|
||||
extension when the new name has none), returning the new path on success. */
|
||||
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
|
||||
val vSrc = inPath.trim().toPath()
|
||||
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
|
||||
val vDst = vSrc.parent?.div(vLeaf) ?: vLeaf.toPath()
|
||||
FileSystem.SYSTEM.atomicMove(vSrc, vDst)
|
||||
systemFileSystem.atomicMove(vSrc, vDst)
|
||||
Result.success(vDst.toString())
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
/* Write arbitrary text (a response body / headers dump) to inPath. Returns null
|
||||
on success, else the error message. */
|
||||
/** Write arbitrary text (a response body / headers dump) to inPath. Returns null
|
||||
on success, else the error message. */
|
||||
fun writeTextFile(inPath: String, inText: String): String? = try {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
||||
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
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 {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { write(inBytes) }
|
||||
systemFileSystem.write(inPath.trim().toPath()) { write(inBytes) }
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
|
||||
/* Re-indent a JSON response for display; returns the input unchanged if it
|
||||
isn't valid JSON (so plain-text / HTML responses still show). */
|
||||
/** Re-indent a JSON response for display; returns the input unchanged if it
|
||||
isn't valid JSON (so plain-text / HTML responses still show). */
|
||||
fun prettyJsonOrRaw(inText: String): String = try {
|
||||
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
|
||||
} catch (e: Throwable) {
|
||||
mJson.encodeToString(JsonElement.serializer(), mJson.parseToJsonElement(inText))
|
||||
} catch (_: Throwable) {
|
||||
inText
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
package apidemo
|
||||
|
||||
import com.compose.sdl.appDataDir
|
||||
import com.compose.sdl.revealInFileManager
|
||||
import apidemo.compat.appDataDir
|
||||
import apidemo.compat.revealInFileManager
|
||||
import apidemo.compat.systemFileSystem
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
// ==================
|
||||
// MARK: Persisted app state
|
||||
// ==================
|
||||
|
||||
/* 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
|
||||
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. */
|
||||
/** 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
|
||||
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. */
|
||||
@Serializable
|
||||
data class AppState(
|
||||
val launched: Boolean = false,
|
||||
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 globalHeaders: List<KeyVal> = emptyList(), // session-level headers (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,
|
||||
)
|
||||
|
||||
/* 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. */
|
||||
/** 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. */
|
||||
@Serializable
|
||||
data class SavedPack(
|
||||
val path: String? = null,
|
||||
@@ -45,9 +46,9 @@ data class SavedPack(
|
||||
val pack: Pack = 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
|
||||
pack was active. Only one session is open at a time. */
|
||||
/** 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
|
||||
pack was active. Only one session is open at a time. */
|
||||
@Serializable
|
||||
data class Session(
|
||||
val packs: List<SavedPack> = emptyList(),
|
||||
@@ -63,7 +64,7 @@ data class Session(
|
||||
// MARK: Load / save (app-data dir via okio)
|
||||
// ==================
|
||||
|
||||
private val fStateJson = Json {
|
||||
private val mStateJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
prettyPrint = true
|
||||
@@ -74,40 +75,40 @@ private const val kOrg = "ComposeDesktopNative"
|
||||
private const val kApp = "ApiManager"
|
||||
private const val kStateFile = "state.json"
|
||||
|
||||
/* 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. */
|
||||
/** 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. */
|
||||
private fun stateFilePath(): String? {
|
||||
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
|
||||
return vDir + kStateFile
|
||||
}
|
||||
|
||||
/* Read the persisted app state, or a fresh (first-launch) AppState if there's
|
||||
nothing saved yet or it can't be read. */
|
||||
/** Read the persisted app state, or a fresh (first-launch) AppState if there's
|
||||
nothing saved yet or it can't be read. */
|
||||
fun loadAppState(): AppState {
|
||||
val vPath = stateFilePath() ?: return AppState()
|
||||
return try {
|
||||
val vText = FileSystem.SYSTEM.read(vPath.toPath()) { readUtf8() }
|
||||
fStateJson.decodeFromString<AppState>(vText)
|
||||
} catch (e: Throwable) {
|
||||
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
|
||||
mStateJson.decodeFromString<AppState>(vText)
|
||||
} catch (_: Throwable) {
|
||||
AppState()
|
||||
}
|
||||
}
|
||||
|
||||
/* Write the app state. Best-effort — failures are swallowed (it's a cache of
|
||||
the session, not the user's exported .json packs). */
|
||||
/** Write the app state. Best-effort - failures are swallowed (it's a cache of
|
||||
the session, not the user's exported .json packs). */
|
||||
fun saveAppState(inState: AppState) {
|
||||
val vPath = stateFilePath() ?: return
|
||||
try {
|
||||
FileSystem.SYSTEM.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
|
||||
} catch (e: Throwable) {
|
||||
// ignore — nothing the user can act on, and packs can still be exported
|
||||
systemFileSystem.write(vPath.toPath()) { writeUtf8(mStateJson.encodeToString(inState)) }
|
||||
} catch (_: Throwable) {
|
||||
// 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
|
||||
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
|
||||
reveal launches off the main thread (see revealInFileManager). */
|
||||
/** 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
|
||||
already created even when nothing's been saved yet. Fire-and-forget - the
|
||||
reveal launches off the main thread (see revealInFileManager). */
|
||||
fun openSettingsFolder() {
|
||||
val vPath = stateFilePath() ?: return
|
||||
revealInFileManager(vPath)
|
||||
@@ -1,5 +1,3 @@
|
||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
@@ -32,12 +30,18 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||
internal fun OptionsMenu(
|
||||
inDark: Boolean,
|
||||
inOnToggleTheme: () -> Unit,
|
||||
inPalette: VolticPalette,
|
||||
inOnPalette: (VolticPalette) -> Unit,
|
||||
) {
|
||||
val c = LocalAppColors.current
|
||||
var vOpen by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
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 = {
|
||||
Text("Dark mode", color = if (inDark) c.accent else c.text, fontSize = 13.sp)
|
||||
}, 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)
|
||||
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
|
||||
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 = {
|
||||
MenuRow(MaterialSymbols.Folder, "Open settings folder")
|
||||
}, onClick = { vOpen = false; openSettingsFolder() })
|
||||
@@ -56,8 +86,8 @@ internal fun OptionsMenu(
|
||||
// MARK: Method picker (dropdown)
|
||||
// ==================
|
||||
|
||||
/* 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). */
|
||||
/** 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). */
|
||||
@Composable
|
||||
internal fun UrlBar(
|
||||
inReq: ApiRequest,
|
||||
@@ -96,19 +126,21 @@ 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(
|
||||
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)
|
||||
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),
|
||||
),
|
||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||
handleColor = c.accent,
|
||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||
),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = inReq.url,
|
||||
@@ -122,8 +154,8 @@ internal fun UrlBar(
|
||||
}
|
||||
|
||||
// Inspect the server's TLS certificate chain (handshake-only probe).
|
||||
// During the probe the lock glyph swaps to a spinner IN PLACE — same
|
||||
// box, padding and size — so the bar doesn't shift.
|
||||
// During the probe the lock glyph swaps to a spinner IN PLACE - same
|
||||
// box, padding and size - so the bar doesn't shift.
|
||||
val vLockHoverSrc = remember { MutableInteractionSource() }
|
||||
val vLockHover by vLockHoverSrc.collectIsHoveredAsState()
|
||||
TooltipBox(
|
||||
@@ -133,7 +165,10 @@ internal fun UrlBar(
|
||||
) {
|
||||
Box(
|
||||
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)
|
||||
.clickable(onClick = inOnInspectChain)
|
||||
.padding(6.dp),
|
||||
@@ -141,23 +176,28 @@ internal fun UrlBar(
|
||||
if (inChainLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = c.accent, strokeWidth = 2.dp)
|
||||
} else {
|
||||
MaterialSymbolsOutlined(MaterialSymbols.Lock, contentDescription = "Inspect TLS chain",
|
||||
tint = if (vLockHover) c.accent else c.dim, size = 18.dp)
|
||||
MaterialSymbolsOutlined(
|
||||
MaterialSymbols.Lock, contentDescription = "Inspect TLS chain",
|
||||
tint = if (vLockHover) c.accent else c.dim, size = 18.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send and Cancel are the same Material Button (same MinHeight + padding)
|
||||
// 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(
|
||||
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) }
|
||||
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
|
||||
internal fun BodyTypeMenu(inType: BodyType, inEnabled: Boolean, inOnPick: (BodyType) -> Unit) {
|
||||
val c = LocalAppColors.current
|
||||
@@ -1,5 +1,3 @@
|
||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
|
||||
package apidemo
|
||||
|
||||
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.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.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,
|
||||
and a body-type dropdown pinned at the bottom. */
|
||||
/** Tabs to edit query params, headers and the body, a Preview toggle top-right,
|
||||
and a body-type dropdown pinned at the bottom. */
|
||||
@Composable
|
||||
internal fun RequestBuilder(
|
||||
inReq: ApiRequest,
|
||||
@@ -42,7 +40,7 @@ internal fun RequestBuilder(
|
||||
inEdit: ((ApiRequest) -> ApiRequest) -> Unit,
|
||||
) {
|
||||
val c = LocalAppColors.current
|
||||
val vBodySet = inReq.method.allowsBody && when (inReq.bodyType) {
|
||||
val vBodySet = true && when (inReq.bodyType) {
|
||||
BodyType.NONE -> false
|
||||
BodyType.FORM -> inReq.form.any { it.enabled && it.key.isNotBlank() }
|
||||
else -> inReq.body.isNotBlank()
|
||||
@@ -55,17 +53,17 @@ internal fun RequestBuilder(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = kWarnColor, size = 15.dp)
|
||||
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = VolticTheme.extended.warning, size = 15.dp)
|
||||
Text(
|
||||
"Undefined: ${inUnresolved.joinToString(", ") { "{{$it}}" }}",
|
||||
color = kWarnColor,
|
||||
color = VolticTheme.extended.warning,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs, with the status message and a Preview toggle (icon only) on the
|
||||
// same line — the toggle no longer needs its own row.
|
||||
// same line - the toggle no longer needs its own row.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 10.dp, top = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -100,7 +98,7 @@ internal fun RequestBuilder(
|
||||
}
|
||||
HorizontalDivider(color = c.border)
|
||||
|
||||
// Tab content — scrolls, except a Text body which fills the whole panel
|
||||
// Tab content - scrolls, except a Text body which fills the whole panel
|
||||
// (so it stays one editable surface you can click anywhere in). Greyed when
|
||||
// read-only (a linked-copy request).
|
||||
val vScroll = rememberScrollState()
|
||||
@@ -119,7 +117,7 @@ internal fun RequestBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
// Body-type selector — only on the Body tab, pinned at the bottom. For a
|
||||
// Body-type selector - only on the Body tab, pinned at the bottom. For a
|
||||
// Text body the format/type picker sits beside it; that type drives both the
|
||||
// syntax colours and the sent Content-Type.
|
||||
if (inRs.reqTab == 3) {
|
||||
@@ -147,8 +145,8 @@ internal fun RequestBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
/* The body editing area for the current body type (driven by the bottom menu). A
|
||||
Text body is highlighted per its chosen format (bodyFormat). */
|
||||
/** The body editing area for the current body type (driven by the bottom menu). A
|
||||
Text body is highlighted per its chosen format (bodyFormat). */
|
||||
@Composable
|
||||
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
||||
when (inReq.bodyType) {
|
||||
@@ -159,21 +157,22 @@ internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest)
|
||||
inOnChange = { v -> inEdit { it.copy(body = v) } },
|
||||
inFormat = inReq.bodyFormat,
|
||||
)
|
||||
|
||||
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
|
||||
BodyType.FILE -> FileBody(inReq) { v -> inEdit(v) }
|
||||
}
|
||||
}
|
||||
|
||||
/* Magic-wand button that pretty-prints the current body in place (JSON /
|
||||
XML). Lives at the right end of the body type/format bar. */
|
||||
/** Magic-wand button that pretty-prints the current body in place (JSON /
|
||||
XML). Lives at the right end of the body type/format bar. */
|
||||
@Composable
|
||||
internal fun FormatButton(inOnClick: () -> Unit) {
|
||||
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
|
||||
}
|
||||
|
||||
/* Icon button with the toolbar's standard hover treatment — accent-tinted
|
||||
background + accent icon on hover (matching the TLS chain button). inActive
|
||||
keeps it lit, for toggles such as the preview eye. */
|
||||
/** Icon button with the toolbar's standard hover treatment - accent-tinted
|
||||
background + accent icon on hover (matching the TLS chain button). inActive
|
||||
keeps it lit, for toggles such as the preview eye. */
|
||||
@Composable
|
||||
internal fun HoverIconBtn(
|
||||
inIcon: Int,
|
||||
@@ -188,8 +187,8 @@ internal fun HoverIconBtn(
|
||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||
val vBg = when {
|
||||
inActive -> c.accent.copy(alpha = 0.20f)
|
||||
vHover -> c.accent.copy(alpha = 0.18f)
|
||||
else -> Color.Transparent
|
||||
vHover -> c.accent.copy(alpha = 0.18f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
@@ -204,22 +203,24 @@ internal fun HoverIconBtn(
|
||||
.clickable(onClick = inOnClick)
|
||||
.padding(6.dp),
|
||||
) {
|
||||
MaterialSymbolsOutlined(inIcon, contentDescription = inTooltip,
|
||||
tint = if (inActive || vHover) c.accent else c.dim, size = inSize)
|
||||
MaterialSymbolsOutlined(
|
||||
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
|
||||
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
|
||||
deep the formatter indents. Snapshot-backed, so the change is live. */
|
||||
/** 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
|
||||
deep the formatter indents. Snapshot-backed, so the change is live. */
|
||||
@Composable
|
||||
internal fun TabSizeSelector() {
|
||||
val c = LocalAppColors.current
|
||||
var vOpen by remember { mutableStateOf(false) }
|
||||
val vHoverSrc = remember { MutableInteractionSource() }
|
||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||
val vSize = TextLayoutConfig.tabWidth
|
||||
val vSize = editorTabWidth
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = { PlainTooltip { Text("Editor tab size") } },
|
||||
@@ -229,7 +230,10 @@ internal fun TabSizeSelector() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.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)
|
||||
.clickable { vOpen = true }
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
@@ -237,20 +241,24 @@ internal fun TabSizeSelector() {
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
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 }) {
|
||||
for (vN in listOf(2, 4, 8)) {
|
||||
DropdownMenuItem(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
|
||||
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
||||
val c = LocalAppColors.current
|
||||
@@ -263,12 +271,17 @@ internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) ->
|
||||
}
|
||||
}
|
||||
|
||||
/* Client-certificate (mTLS) editor — certificate + optional separate key +
|
||||
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
|
||||
(see CurlMtls). PKCS#12 bundles its own private key. */
|
||||
/** Client-certificate (mTLS) editor - certificate + optional separate key +
|
||||
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
|
||||
(see CurlMtls). PKCS#12 bundles its own private key. */
|
||||
@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 vHasCert = inCert.certPath.isNotBlank()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -293,7 +306,11 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
||||
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)
|
||||
@@ -304,10 +321,17 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
||||
showOpenFileDialog { vPath -> if (vPath != null) inOnChange(inCert.copy(keyPath = vPath)) }
|
||||
}
|
||||
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(
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -315,17 +339,27 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
||||
// ============
|
||||
// Passphrase
|
||||
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
|
||||
Override action (copy it into this scope) when this scope hasn't set its own. */
|
||||
/** 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. */
|
||||
@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
|
||||
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),
|
||||
) {
|
||||
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
|
||||
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). */
|
||||
/** 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
|
||||
request's own cert editor (its own cert overrides the inherited one). */
|
||||
@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)) {
|
||||
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) }
|
||||
}
|
||||
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
|
||||
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
|
||||
editable list, mirroring the request panels. */
|
||||
/** 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 scope inherits from above (source-tagged, with Override) over its own
|
||||
editable list, mirroring the request panels. */
|
||||
@Composable
|
||||
internal fun ScopeSettings(
|
||||
inTab: Int,
|
||||
@@ -369,7 +415,7 @@ internal fun ScopeSettings(
|
||||
inParams: List<KeyVal>, inOnParams: (List<KeyVal>) -> Unit, inParamHelp: String,
|
||||
inHeaders: List<KeyVal>, inOnHeaders: (List<KeyVal>) -> Unit, inHeaderHelp: String,
|
||||
inCert: CertConfig?, inOnCert: (CertConfig?) -> Unit, inCertHelp: String, inCertHeading: String,
|
||||
// What this scope inherits from above (empty for the session — it's the top).
|
||||
// What this scope inherits from above (empty for the session - it's the top).
|
||||
inInheritedVars: List<InheritedKv> = emptyList(),
|
||||
inInheritedParams: List<InheritedKv> = emptyList(),
|
||||
inInheritedHeaders: List<InheritedKv> = emptyList(),
|
||||
@@ -390,33 +436,50 @@ internal fun ScopeSettings(
|
||||
when (inTab) {
|
||||
0 -> {
|
||||
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",
|
||||
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars)
|
||||
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars
|
||||
)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
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",
|
||||
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams)
|
||||
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams
|
||||
)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
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",
|
||||
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders)
|
||||
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(inCertHelp, color = c.dim, fontSize = 12.sp)
|
||||
val vOwnSet = inCert?.isSet == true
|
||||
if (inInheritedCert != null) InheritedCertCard(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 }) }
|
||||
if (inInheritedCert != null) InheritedCertCard(
|
||||
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
|
||||
internal fun CertFormatMenu(inFormat: CertFormat, inOnPick: (CertFormat) -> Unit) {
|
||||
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(
|
||||
androidx.compose.material3.ExperimentalMaterial3Api::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.sp
|
||||
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.material.symbols.MaterialSymbolsOutlined
|
||||
import com.compose.sdl.layout.y
|
||||
import com.compose.sdl.revealInFileManager
|
||||
|
||||
// ==================
|
||||
// MARK: Pack colour (palette dot + picker)
|
||||
// ==================
|
||||
|
||||
/* 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. */
|
||||
/** 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. */
|
||||
@Composable
|
||||
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
|
||||
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)))
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/** 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. */
|
||||
@Composable
|
||||
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
||||
val c = LocalAppColors.current
|
||||
@@ -80,15 +79,19 @@ internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
||||
// MARK: Add-pack menu (header '+')
|
||||
// ==================
|
||||
|
||||
/* 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. */
|
||||
/** 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. */
|
||||
@Composable
|
||||
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
|
||||
val c = LocalAppColors.current
|
||||
var vOpen by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
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 = {
|
||||
MenuRow(
|
||||
MaterialSymbols.Send,
|
||||
@@ -96,7 +99,9 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
|
||||
)
|
||||
}, onClick = { vOpen = false; inOnNewRequest() })
|
||||
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 = {
|
||||
MenuRow(
|
||||
MaterialSymbols.Download,
|
||||
@@ -111,13 +116,13 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
|
||||
// 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 =
|
||||
inPath.trimEnd('/', '\\').substringAfterLast('/').substringAfterLast('\\')
|
||||
|
||||
/* 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
|
||||
primitive, so we trim the string ourselves. */
|
||||
/** 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
|
||||
primitive, so we trim the string ourselves. */
|
||||
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
|
||||
if (inText.length <= inMax) return inText
|
||||
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)
|
||||
}
|
||||
|
||||
/* 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
|
||||
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. */
|
||||
/** 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
|
||||
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. */
|
||||
@Composable
|
||||
internal fun SessionMenu(
|
||||
inPath: String?,
|
||||
@@ -155,7 +160,10 @@ internal fun SessionMenu(
|
||||
// Highlight on hover and stay highlighted while the menu is open.
|
||||
Row(
|
||||
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)
|
||||
.clickable { vOpen = true }.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
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.
|
||||
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 = {
|
||||
MenuRow(
|
||||
MaterialSymbols.Tune,
|
||||
@@ -177,7 +189,9 @@ internal fun SessionMenu(
|
||||
)
|
||||
}, onClick = { vOpen = false; inOnSettings() })
|
||||
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 = {
|
||||
MenuRow(
|
||||
MaterialSymbols.Download,
|
||||
@@ -193,7 +207,8 @@ internal fun SessionMenu(
|
||||
)
|
||||
},
|
||||
onClick = { vOpen = false; inOnRename() },
|
||||
enabled = vSaved)
|
||||
enabled = vSaved
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
MenuRow(
|
||||
@@ -203,7 +218,8 @@ internal fun SessionMenu(
|
||||
)
|
||||
},
|
||||
onClick = { vOpen = false; inOnReveal() },
|
||||
enabled = vSaved)
|
||||
enabled = vSaved
|
||||
)
|
||||
HorizontalDivider(color = c.border)
|
||||
DropdownMenuItem(text = {
|
||||
MenuRow(
|
||||
@@ -211,7 +227,9 @@ internal fun SessionMenu(
|
||||
"Default session"
|
||||
)
|
||||
}, 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 = {
|
||||
MenuRow(
|
||||
MaterialSymbols.Folder,
|
||||
@@ -222,7 +240,7 @@ internal fun SessionMenu(
|
||||
HorizontalDivider(color = c.border)
|
||||
// Aligned to the icon column (the DropdownMenuItem's 16dp pad), so
|
||||
// it lines up with the recent items' file icon. Padding goes on a
|
||||
// wrapping Box — a leaf Text's own start padding isn't applied to its
|
||||
// wrapping Box - a leaf Text's own start padding isn't applied to its
|
||||
// draw position by this layout engine.
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 2.dp)) {
|
||||
Text("Recent", color = c.dim, fontSize = 11.sp)
|
||||
@@ -245,10 +263,12 @@ internal fun SessionMenu(
|
||||
Text(fileLeaf(vPath), color = c.text, fontSize = 12.sp)
|
||||
Text(ellipsizeMiddle(vPath, 32), color = c.dim, fontSize = 10.sp, softWrap = false)
|
||||
}
|
||||
// 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".
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above
|
||||
),
|
||||
tooltip = { PlainTooltip { Text("Reveal in ${fileManagerName()}") } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
@@ -283,8 +303,8 @@ internal fun SessionMenu(
|
||||
// MARK: Pack tree (recursive sidebar rendering)
|
||||
// ==================
|
||||
|
||||
/* The per-pack operations a PackSection needs, bundled so PackTree can bind them
|
||||
recursively for sub-packs while PackSection itself stays callback-driven. */
|
||||
/** The per-pack operations a PackSection needs, bundled so PackTree can bind them
|
||||
recursively for sub-packs while PackSection itself stays callback-driven. */
|
||||
internal class PackOps(
|
||||
val onEnv: (PackState) -> Unit,
|
||||
val onToggle: (PackState) -> Unit,
|
||||
@@ -310,11 +330,18 @@ internal class PackOps(
|
||||
val packDropEnd: () -> Unit,
|
||||
)
|
||||
|
||||
/* 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
|
||||
subPacks) — used to position the pack-reorder drop bars by index. */
|
||||
/** 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
|
||||
subPacks) - used to position the pack-reorder drop bars by index. */
|
||||
@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 vMyIndex = inSiblings.indexOf(inPack)
|
||||
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 vBarBefore = vMoving && vDrag.dropParent === inPack.parent && vDrag.dropSibIndex == vMyIndex
|
||||
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) {
|
||||
if (vBarBefore) Box(modifier = vPad) { RowDropBar() }
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -362,11 +398,11 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
|
||||
// MARK: Pack section (foldable pack in the sidebar)
|
||||
// ==================
|
||||
|
||||
/* 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
|
||||
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
|
||||
reorder, scoped to this pack. Right-click the header for the pack menu. */
|
||||
/** 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
|
||||
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
|
||||
reorder, scoped to this pack. Right-click the header for the pack menu. */
|
||||
@Composable
|
||||
internal fun PackSection(
|
||||
inPack: PackState,
|
||||
@@ -407,13 +443,14 @@ internal fun PackSection(
|
||||
// to reorder / reparent; highlighted when it's the "drop inside" target.
|
||||
val vHeaderDragged = inDrag.dragPack === inPack && vMoving
|
||||
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()
|
||||
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
|
||||
.onSizeChanged { inDrag.headH[inPack] = it.height }
|
||||
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
|
||||
// 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))
|
||||
.background(
|
||||
when {
|
||||
@@ -465,9 +502,15 @@ internal fun PackSection(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
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,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
@@ -476,11 +519,19 @@ internal fun PackSection(
|
||||
Text(inPack.name, color = c.text, fontSize = 14.sp, modifier = Modifier.weight(1f))
|
||||
Text("${inPack.requests.size}", color = c.dim, fontSize = 11.sp)
|
||||
}
|
||||
// + (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
|
||||
// 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) {
|
||||
if (!inPack.isLinked) IconBtn(MaterialSymbols.Add, "New request", inSize = 16.dp, inPadding = 4.dp) { inOnNewRequest() }
|
||||
Row(
|
||||
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 {
|
||||
IconBtn(MaterialSymbols.MoreHoriz, "Pack menu", inSize = 16.dp, inPadding = 4.dp) { vMenu = true }
|
||||
DropdownMenu(
|
||||
@@ -488,23 +539,41 @@ internal fun PackSection(
|
||||
onDismissRequest = { vMenu = false },
|
||||
modifier = Modifier.widthIn(min = 220.dp),
|
||||
) {
|
||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename pack") }, onClick = { vMenu = false; inOnRenamePack() })
|
||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") }, 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() })
|
||||
DropdownMenuItem(
|
||||
text = { MenuRow(MaterialSymbols.Edit, "Rename pack") },
|
||||
onClick = { vMenu = false; inOnRenamePack() })
|
||||
DropdownMenuItem(
|
||||
text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") },
|
||||
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)
|
||||
PackColorPicker(inPack.color) { inOnSetColor(it) }
|
||||
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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Pack body — request list (expanded, or always for the loose root).
|
||||
// Pack body - request list (expanded, or always for the loose root).
|
||||
// Rows drag within and across packs via the shared controller; the drop
|
||||
// bar shows here whenever this pack is the resolved target.
|
||||
if (inPack.expanded || inHeaderless) {
|
||||
@@ -515,7 +584,7 @@ internal fun PackSection(
|
||||
val vBarBefore = if (vIsTarget) vOthers.getOrNull(inDrag.dropIndex) else null
|
||||
val vBarAtEnd = vIsTarget && inDrag.dropIndex >= vOthers.size
|
||||
// The loose root has no header, so register the body box as its drop
|
||||
// anchor — that gives an empty root a region to target while dragging.
|
||||
// anchor - that gives an empty root a region to target while dragging.
|
||||
var vBodyMod = Modifier.fillMaxWidth().padding(start = 10.dp, top = 2.dp, bottom = 4.dp)
|
||||
if (inHeaderless) vBodyMod = vBodyMod
|
||||
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
|
||||
@@ -525,13 +594,18 @@ internal fun PackSection(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
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 ->
|
||||
if (vRs === vBarBefore) RowDropBar()
|
||||
key(vRs) {
|
||||
val vDragged = vRs === inDrag.dragReq && vMoving
|
||||
var vMod: Modifier = Modifier
|
||||
// Linked copies mirror the source's requests — read-only, so
|
||||
// Linked copies mirror the source's requests - read-only, so
|
||||
// their rows neither register geometry (would clobber the
|
||||
// source's) nor drag.
|
||||
if (!inPack.isLinked) vMod = vMod
|
||||
@@ -539,28 +613,30 @@ internal fun PackSection(
|
||||
.onSizeChanged { inDrag.rowH[vRs] = it.height }
|
||||
// translate is draw-only (doesn't shift absoluteY), so the
|
||||
// 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) {
|
||||
// Same graphicsLayer feedback concern as the pack drag —
|
||||
// accumulate dragAmount instead of reading change.position.
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
inDrag.clear()
|
||||
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
|
||||
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
|
||||
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
|
||||
},
|
||||
onDrag = { _, dragAmount ->
|
||||
val vd = inDrag.dragReq ?: return@detectDragGestures
|
||||
inDrag.dy += dragAmount.y
|
||||
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
|
||||
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged = true
|
||||
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
|
||||
},
|
||||
onDragEnd = { inOnReqDropEnd() },
|
||||
onDragCancel = { inOnReqDropEnd() },
|
||||
)
|
||||
}
|
||||
// Same graphicsLayer feedback concern as the pack drag -
|
||||
// accumulate dragAmount instead of reading change.position.
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
inDrag.clear()
|
||||
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
|
||||
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
|
||||
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
|
||||
},
|
||||
onDrag = { _, dragAmount ->
|
||||
val vd = inDrag.dragReq ?: return@detectDragGestures
|
||||
inDrag.dy += dragAmount.y
|
||||
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
|
||||
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged =
|
||||
true
|
||||
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
|
||||
},
|
||||
onDragEnd = { inOnReqDropEnd() },
|
||||
onDragCancel = { inOnReqDropEnd() },
|
||||
)
|
||||
}
|
||||
Box(modifier = vMod) {
|
||||
RequestRow(
|
||||
inRs = vRs,
|
||||
@@ -630,7 +706,7 @@ internal fun RequestRow(
|
||||
) {
|
||||
MethodTag(vReq.method)
|
||||
Text(vReq.name, color = c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
|
||||
// Overflow (vertical-dots) menu — always laid out so the row width
|
||||
// Overflow (vertical-dots) menu - always laid out so the row width
|
||||
// never changes; only its opacity toggles on hover / while open. Delete
|
||||
// lives inside this menu (no separate button on the row).
|
||||
Box(modifier = Modifier.alpha(if (vHover || vMenu) 1f else 0f)) {
|
||||
@@ -640,10 +716,22 @@ internal fun RequestRow(
|
||||
onDismissRequest = { vMenu = false },
|
||||
modifier = Modifier.widthIn(min = 168.dp),
|
||||
) {
|
||||
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename") }, onClick = { vMenu = false; inOnRename() })
|
||||
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() })
|
||||
if (!inReadOnly) DropdownMenuItem(
|
||||
text = { MenuRow(MaterialSymbols.Edit, "Rename") },
|
||||
onClick = { vMenu = false; inOnRename() })
|
||||
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.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
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.material.symbols.MaterialSymbolsOutlined
|
||||
import com.compose.sdl.layout.x
|
||||
import com.compose.sdl.layout.y
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// ==================
|
||||
// MARK: TLS certificate-chain dialog + cert cards
|
||||
// ==================
|
||||
|
||||
/* 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. */
|
||||
/** 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. */
|
||||
@Composable
|
||||
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
|
||||
val c = LocalAppColors.current
|
||||
Dialog(onDismissRequest = inOnDismiss) {
|
||||
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.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)
|
||||
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
|
||||
}
|
||||
val vCerts = inChain?.certs.orEmpty()
|
||||
when {
|
||||
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 -> {
|
||||
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(
|
||||
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||
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(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")
|
||||
if (vChainPem.isNotEmpty()) CopyButton(vChainPem, "Copy chain")
|
||||
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
|
||||
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
|
||||
cert shows "Self-signed" in green instead of repeating its issuer. */
|
||||
/** 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
|
||||
name-only placeholder) get an accent-coloured border + a label. A self-signed
|
||||
cert shows "Self-signed" in green instead of repeating its issuer. */
|
||||
@Composable
|
||||
internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
||||
val c = LocalAppColors.current
|
||||
@@ -88,20 +104,28 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().clip(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),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
val vTitle = vSubject?.let { cnOf(it) } ?: if (inIndex == 0) "Leaf certificate" else "Issuer #$inIndex"
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
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)
|
||||
}
|
||||
vSubject?.let { CertLine("Subject", it) }
|
||||
if (vSelfSigned) CertLine("Issuer", "Self-signed", kSelfSignedColor)
|
||||
else vIssuer?.let { CertLine("Issuer", it) }
|
||||
// Hosts this cert is valid for — the field that actually has to match the URL.
|
||||
// Hosts this cert is valid for - the field that actually has to match the URL.
|
||||
(certField(vFields, "X509v3 Subject Alternative Name")
|
||||
?: certField(vFields, "Subject Alternative Name")
|
||||
?: vParsed?.sans?.takeIf { it.isNotEmpty() }?.joinToString(", "))
|
||||
@@ -117,10 +141,10 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
||||
|
||||
internal val kSelfSignedColor = Color(0xFF3FB950L)
|
||||
|
||||
/* 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
|
||||
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. */
|
||||
/** 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
|
||||
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. */
|
||||
@Composable
|
||||
internal fun CopyButton(inText: String, inLabel: String? = null) {
|
||||
val c = LocalAppColors.current
|
||||
@@ -132,7 +156,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
||||
var vHeight by remember { mutableStateOf(0) }
|
||||
val vClipboard = LocalClipboard.current
|
||||
val vScope = rememberCoroutineScope()
|
||||
LaunchedEffect(vCopied) { if (vCopied) { delay(2000); vCopied = false } }
|
||||
LaunchedEffect(vCopied) {
|
||||
if (vCopied) {
|
||||
delay(2000.milliseconds); vCopied = false
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.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))
|
||||
.hoverable(vHoverSrc)
|
||||
.clickable {
|
||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(inText)) }
|
||||
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(inText)) }
|
||||
vCopied = true
|
||||
}
|
||||
.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,
|
||||
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).
|
||||
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? =
|
||||
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
||||
|
||||
/* The CN= value out of a distinguished-name string. Handles every
|
||||
format the platform backends emit:
|
||||
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
|
||||
"/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
|
||||
default, with spaces — what
|
||||
libcurl on macOS emits)
|
||||
Null when there's no CN. */
|
||||
/** The CN= value out of a distinguished-name string. Handles every
|
||||
format the platform backends emit:
|
||||
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
|
||||
"/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
|
||||
default, with spaces - what
|
||||
libcurl on macOS emits)
|
||||
Null when there's no CN. */
|
||||
internal fun cnOf(inDn: String): String? {
|
||||
val vMatch = Regex("""(?i)\bCN\s*=\s*([^,/]+)""").find(inDn) ?: return 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 =
|
||||
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}})
|
||||
// ==================
|
||||
|
||||
/* Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
|
||||
Surrounding whitespace inside the braces is tolerated. */
|
||||
/** Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
|
||||
Surrounding whitespace inside the braces is tolerated. */
|
||||
private val kVarRegex = Regex("""\{\{\s*([A-Za-z0-9_.\-]+)\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
|
||||
ones that won't resolve. */
|
||||
/** 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
|
||||
ones that won't resolve. */
|
||||
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
|
||||
if (inText.isEmpty()) return inText
|
||||
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 }
|
||||
}
|
||||
|
||||
/* Apply variable substitution across every field that actually gets sent — URL,
|
||||
query params, headers and body — returning a fully-resolved request. The
|
||||
original (template) request is left untouched. */
|
||||
/** Apply variable substitution across every field that actually gets sent - URL,
|
||||
query params, headers and body - returning a fully-resolved request. The
|
||||
original (template) request is left untouched. */
|
||||
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
|
||||
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
|
||||
if (!vActive) return inReq
|
||||
return inReq.copy(
|
||||
url = substituteVars(inReq.url, inVars),
|
||||
params = inReq.params.map { 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)) },
|
||||
params = inReq.params.map {
|
||||
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),
|
||||
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),
|
||||
keyPath = substituteVars(inReq.keyPath, inVars),
|
||||
certPassword = substituteVars(inReq.certPassword, inVars),
|
||||
)
|
||||
}
|
||||
|
||||
/* The distinct {{names}} referenced anywhere in the request that have no enabled
|
||||
variable to fill them — surfaced as a warning in the editor. */
|
||||
/** The distinct {{names}} referenced anywhere in the request that have no enabled
|
||||
variable to fill them - surfaced as a warning in the editor. */
|
||||
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
||||
val vDefined = inVars
|
||||
.filter { it.enabled && it.key.isNotBlank() }
|
||||
@@ -49,7 +64,7 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
||||
append(inReq.url).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') }
|
||||
if (inReq.method.allowsBody) {
|
||||
if (true) {
|
||||
when (inReq.bodyType) {
|
||||
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
||||
else -> append(inReq.body)
|
||||
@@ -67,10 +82,10 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
||||
// MARK: Copy as cURL
|
||||
// ==================
|
||||
|
||||
/* 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
|
||||
are folded into the URL; a Content-Type header is added for the body type
|
||||
unless the request already sets one. */
|
||||
/** 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
|
||||
are folded into the URL; a Content-Type header is added for the body type
|
||||
unless the request already sets one. */
|
||||
fun toCurl(inReq: ApiRequest): String {
|
||||
val vSb = StringBuilder()
|
||||
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.method.allowsBody && inReq.bodyType != BodyType.NONE) {
|
||||
if (true && inReq.bodyType != BodyType.NONE) {
|
||||
val vContentType = inReq.bodyContentType()
|
||||
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
||||
if (vContentType != null && !vHasCt) {
|
||||
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
|
||||
}
|
||||
when (inReq.bodyType) {
|
||||
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ").append(shellQuote(formEncode(inReq.form)))
|
||||
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ").append(shellQuote("@${inReq.body}"))
|
||||
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ")
|
||||
.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))
|
||||
}
|
||||
}
|
||||
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 =
|
||||
inForm.filter { it.enabled && it.key.isNotBlank() }
|
||||
.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 {
|
||||
val vTrimmed = inReq.url.trim()
|
||||
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
|
||||
@@ -121,10 +140,10 @@ internal fun urlWithParams(inReq: ApiRequest): String {
|
||||
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("'", "'\\''") + "'"
|
||||
|
||||
/* 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 {
|
||||
for (vByte in inS.encodeToByteArray()) {
|
||||
val vCode = vByte.toInt() and 0xFF
|
||||
@@ -30,12 +30,12 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||
// MARK: Small UI atoms (shared across panels / dialogs / sidebar)
|
||||
// ==================
|
||||
|
||||
/* Method as a compact rounded badge (soft colour fill + coloured label), fixed
|
||||
width so request names line up. */
|
||||
/** Method as a compact rounded badge (soft colour fill + coloured label), fixed
|
||||
width so request names line up. */
|
||||
@Composable
|
||||
internal fun MethodTag(inMethod: ReqMethod) {
|
||||
val vCol = methodColor(inMethod)
|
||||
// No background — just the coloured method name, fixed width so the request
|
||||
// No background - just the coloured method name, fixed width so the request
|
||||
// names still line up in the sidebar list.
|
||||
Box(
|
||||
modifier = Modifier.width(46.dp).padding(vertical = 2.dp),
|
||||
@@ -45,16 +45,16 @@ internal fun MethodTag(inMethod: ReqMethod) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin horizontal accent line showing where a dragged request row will drop
|
||||
(the open-tab strip uses the vertical DropBar). */
|
||||
/** Thin horizontal accent line showing where a dragged request row will drop
|
||||
(the open-tab strip uses the vertical DropBar). */
|
||||
@Composable
|
||||
internal fun RowDropBar() {
|
||||
val c = LocalAppColors.current
|
||||
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
|
||||
to flag a tab that holds content (e.g. a non-empty request body). */
|
||||
/** 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). */
|
||||
@Composable
|
||||
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
|
||||
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) }
|
||||
}
|
||||
|
||||
/* Compact single-line (or fixed-height multi-line) input — a BasicTextField in
|
||||
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
|
||||
/** Compact single-line (or fixed-height multi-line) input - a BasicTextField in
|
||||
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
|
||||
@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
|
||||
// Box is an ancestor of the field, so it sees Enter the single-line field
|
||||
// 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))
|
||||
.padding(horizontal = 10.dp, vertical = 9.dp)
|
||||
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(
|
||||
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)
|
||||
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),
|
||||
),
|
||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||
handleColor = c.accent,
|
||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||
),
|
||||
) {
|
||||
BasicTextField(
|
||||
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
|
||||
tightens the tap area for slimmer rows. */
|
||||
/** Small icon-only button (burger / pack / options / save / close). inPadding
|
||||
tightens the tap area for slimmer rows. */
|
||||
@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 vHoverSrc = remember { MutableInteractionSource() }
|
||||
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
|
||||
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
|
||||
val c = LocalAppColors.current
|
||||
OutlinedButton(onClick = inOnClick) { BtnContent(inIcon, inLabel, c.accent) }
|
||||
}
|
||||
|
||||
/* Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
|
||||
Uses the m3 Button so its size + ripple + interaction match every other
|
||||
Button in the app — previously handrolled Box + background + clickable + padding
|
||||
gave it a different height / horizontal padding, and the Discard button sat
|
||||
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
|
||||
/** Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
|
||||
Uses the m3 Button so its size + ripple + interaction match every other
|
||||
Button in the app - previously handrolled Box + background + clickable + padding
|
||||
gave it a different height / horizontal padding, and the Discard button sat
|
||||
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
|
||||
@Composable
|
||||
internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
||||
val vRed = methodColor(ReqMethod.DELETE)
|
||||
@@ -173,17 +189,7 @@ internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
||||
) { BtnContent(inIcon, inLabel, Color.White) }
|
||||
}
|
||||
|
||||
/* Bordered icon+label chip (Copy / Save as / Cancel). */
|
||||
@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. */
|
||||
/** Icon + label row used inside buttons. */
|
||||
@Composable
|
||||
internal fun BtnContent(inIcon: Int, inLabel: String, inColor: Color, inSize: Dp = 16.dp) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -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
|
||||
@@ -1,38 +1,34 @@
|
||||
package com.compose.sdl.scrollbar
|
||||
// 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.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.hoverable
|
||||
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.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
@@ -42,24 +38,24 @@ 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:
|
||||
/** 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(),
|
||||
)
|
||||
}
|
||||
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. */
|
||||
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(
|
||||
@@ -71,8 +67,8 @@ class ScrollbarStyle(
|
||||
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. */
|
||||
/** 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,
|
||||
@@ -88,9 +84,9 @@ 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). */
|
||||
/** 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
|
||||
@@ -98,29 +94,16 @@ interface ScrollbarAdapter {
|
||||
suspend fun scrollTo(scrollOffset: Double)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(scrollState: ScrollState): ScrollbarAdapter =
|
||||
remember(scrollState) { ScrollStateScrollbarAdapter(scrollState) }
|
||||
// (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.)
|
||||
|
||||
private class ScrollStateScrollbarAdapter(private val state: ScrollState) : ScrollbarAdapter {
|
||||
override val scrollOffset: Double get() = state.value.toDouble()
|
||||
override val contentSize: Double get() = state.contentSize.toDouble()
|
||||
override val viewportSize: Double get() = state.viewportSize.toDouble()
|
||||
override suspend fun scrollTo(scrollOffset: Double) {
|
||||
// Scrollbar thumb drag → instant scroll-to. dispatchRawDelta takes a
|
||||
// delta in pixels; convert the target offset into a delta from where
|
||||
// we are now (clamping at the scroll edges is the dispatchRawDelta
|
||||
// contract, so we don't have to coerce here).
|
||||
state.dispatchRawDelta((scrollOffset - state.value.toDouble()).toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/** 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) }
|
||||
@@ -148,7 +131,7 @@ private class LazyListScrollbarAdapter(private val state: LazyListState) : Scrol
|
||||
// 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
|
||||
// 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) {
|
||||
@@ -180,21 +163,12 @@ fun VerticalScrollbar(
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
|
||||
|
||||
@Composable
|
||||
fun HorizontalScrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean = false,
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
|
||||
|
||||
/* 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. */
|
||||
/** 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,
|
||||
@@ -210,7 +184,7 @@ private fun Scrollbar(
|
||||
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
|
||||
// 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.
|
||||
@@ -247,22 +221,22 @@ private fun Scrollbar(
|
||||
|
||||
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)
|
||||
)
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
@@ -272,7 +246,7 @@ private fun Scrollbar(
|
||||
val vDown = awaitFirstDown(requireUnconsumed = false)
|
||||
vDown.consume()
|
||||
|
||||
// Live geometry — stable for the drag's duration (content /
|
||||
// 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
|
||||
@@ -283,12 +257,12 @@ private fun Scrollbar(
|
||||
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||
val vTravelPx = vTrackPx - vLen
|
||||
val vRaw = if (vTravelPx <= 0) 0
|
||||
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
|
||||
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
|
||||
// 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).
|
||||
@@ -334,9 +308,9 @@ private fun Scrollbar(
|
||||
}
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
/** 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,35 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** The JVM leg would otherwise inherit a SYSTEM font here; load the same bundled
|
||||
NotoSans the native build uses so the two stacks are comparable. */
|
||||
actual val defaultFontFamily: FontFamily? by lazy {
|
||||
val vBytes = object {}.javaClass.getResourceAsStream("/font/NotoSans.ttf")?.use { it.readBytes() }
|
||||
if (vBytes == null) {
|
||||
println("apidemo: NotoSans.ttf not on the classpath - using the platform default font")
|
||||
null
|
||||
} else {
|
||||
FontFamily(Font(identity = kDefaultFamily, data = vBytes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.platform.InfiniteAnimationPolicy
|
||||
import androidx.compose.ui.res.useResource
|
||||
import androidx.compose.ui.unit.Density
|
||||
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 apidemo.compat.jvmOnCloseRequest
|
||||
import apidemo.compat.jvmOnKeyShortcut
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||
import org.jetbrains.skia.EncodedImageFormat
|
||||
import java.io.File
|
||||
|
||||
// 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.
|
||||
//
|
||||
// --screenshot=<path.png> render headlessly to quiescence, write, exit
|
||||
//
|
||||
// The screenshot mode is the JVM half of the native-vs-JVM drift check; the
|
||||
// native half is `apidemo.exe --screenshot=<path.bmp>`. Both render offscreen
|
||||
// (ImageComposeScene here, a GPU readback there), so neither needs a visible
|
||||
// window and the comparison is unaffected by whatever is on screen.
|
||||
fun main(args: Array<String>) {
|
||||
val screenshot = args.firstOrNull { it.startsWith("--screenshot=") }?.substringAfter('=')
|
||||
if (screenshot != null) {
|
||||
screenshotApp(File(screenshot), kWidth, kHeight)
|
||||
return
|
||||
}
|
||||
runInteractive()
|
||||
}
|
||||
|
||||
private const val kWidth = 1240
|
||||
private const val kHeight = 820
|
||||
|
||||
private fun runInteractive() = 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 = kWidth.dp, height = kHeight.dp),
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: Headless screenshot (drift check vs the native leg)
|
||||
// ==================
|
||||
|
||||
private const val kFrameNanos = 16_666_667L
|
||||
private const val kMaxFrames = 300
|
||||
|
||||
/** Infinite animations would never let the scene go quiet; upstream's test policy
|
||||
freezes them at their initial value (same trick :demo's parity leg uses). */
|
||||
private object CancelInfiniteAnimations : InfiniteAnimationPolicy {
|
||||
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
|
||||
throw CancellationException("infinite animations disabled for the drift screenshot")
|
||||
}
|
||||
|
||||
/** Render [App] offscreen at density 1 - matching the native leg, which lays out in
|
||||
physical pixels - stepping a virtual 60fps clock until the scene stops invalidating. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun screenshotApp(outFile: File, width: Int, height: Int) {
|
||||
outFile.parentFile?.mkdirs()
|
||||
val scene = ImageComposeScene(
|
||||
width, height, density = Density(1f),
|
||||
coroutineContext = Dispatchers.Unconfined + CancelInfiniteAnimations,
|
||||
) {
|
||||
App()
|
||||
}
|
||||
try {
|
||||
var nanos = 0L
|
||||
var frames = 0
|
||||
var image = scene.render(nanos)
|
||||
while (scene.hasInvalidations() && frames < kMaxFrames) {
|
||||
nanos += kFrameNanos
|
||||
image = scene.render(nanos)
|
||||
frames++
|
||||
}
|
||||
val png = image.encodeToData(EncodedImageFormat.PNG)
|
||||
if (png == null) {
|
||||
println("jvm screenshot: PNG encode failed")
|
||||
return
|
||||
}
|
||||
outFile.writeBytes(png.bytes)
|
||||
println("jvm screenshot: ${outFile.path} (settled after $frames frame(s))")
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
@@ -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,165 @@
|
||||
@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 apidemo.monoFontFamily
|
||||
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 mTabWidth by mutableStateOf(4)
|
||||
actual var editorTabWidth: Int
|
||||
get() = mTabWidth
|
||||
set(value) {
|
||||
mTabWidth = value
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
package apidemo
|
||||
|
||||
// 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 =
|
||||
PreparedCert(
|
||||
sslCert = inReq.certPath,
|
||||
sslCertType = inReq.certFormat.curlName,
|
||||
sslKey = inReq.keyPath.ifBlank { null },
|
||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||
cleanup = {},
|
||||
)
|
||||
PreparedCert(
|
||||
sslCert = inReq.certPath,
|
||||
sslCertType = inReq.certFormat.curlName,
|
||||
sslKey = inReq.keyPath.ifBlank { null },
|
||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||
cleanup = {},
|
||||
)
|
||||
|
||||
/* No temporary certificate store off Windows. */
|
||||
/** No temporary certificate store off Windows. */
|
||||
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> =
|
||||
serverChainWithIssuerName(inServerCerts)
|
||||
serverChainWithIssuerName(inServerCerts)
|
||||
|
||||
@@ -3,7 +3,6 @@ package apidemo
|
||||
import kotlinx.cinterop.*
|
||||
import platform.windows.*
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
// ==================
|
||||
// MARK: Windows client-certificate store import (Schannel mTLS)
|
||||
@@ -12,14 +11,14 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
// Windows certificate store, referenced by SHA-1 thumbprint. So for a request
|
||||
// that carries a certificate we import the cert + private key into the standard
|
||||
// CurrentUser\MY ("Personal") store, hand curl "CurrentUser\MY\<thumbprint>",
|
||||
// and delete it again the instant the request finishes — nothing accumulates.
|
||||
// and delete it again the instant the request finishes - nothing accumulates.
|
||||
//
|
||||
// Our entries are tagged (friendly name + key-container name) with a unique
|
||||
// prefix so the startup sweep can clear crash leftovers without ever touching
|
||||
// the user's own certificates.
|
||||
//
|
||||
// Kotlin/Native maps several CryptoAPI LPCSTR params to String?, which makes the
|
||||
// CryptDecodeObjectEx route (numeric struct-type sentinels) unusable — so the
|
||||
// CryptDecodeObjectEx route (numeric struct-type sentinels) unusable - so the
|
||||
// RSA private key is parsed here (ASN.1) and laid out as a CryptoAPI
|
||||
// PRIVATEKEYBLOB directly, then handed to CryptImportKey. RSA (PKCS#1 or PKCS#8)
|
||||
// and PKCS#12 are handled; EC PEM/DER is reported as unsupported with a hint.
|
||||
@@ -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()
|
||||
|
||||
/* Import the request's certificate into CurrentUser\MY and return a libcurl
|
||||
store reference plus a cleanup that removes it again. */
|
||||
/** Import the request's certificate into CurrentUser\MY and return a libcurl
|
||||
store reference plus a cleanup that removes it again. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
||||
{
|
||||
val vCertBytes = readFileBytes(inReq.certPath)
|
||||
val (vThumb, vContainer) = when (inReq.certFormat)
|
||||
{
|
||||
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
|
||||
else -> importPemDerRsa(vCertBytes, inReq)
|
||||
}
|
||||
return PreparedCert(
|
||||
sslCert = "$kStorePath\\$vThumb",
|
||||
sslCertType = null, // store reference — not a file type
|
||||
sslKey = null,
|
||||
sslKeyType = null,
|
||||
keyPassword = null,
|
||||
cleanup = { deleteTempCert(vThumb, vContainer) },
|
||||
)
|
||||
}
|
||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
|
||||
val vCertBytes = readFileBytes(inReq.certPath)
|
||||
val (vThumb, vContainer) = when (inReq.certFormat) {
|
||||
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
|
||||
else -> importPemDerRsa(vCertBytes, inReq)
|
||||
}
|
||||
return PreparedCert(
|
||||
sslCert = "$kStorePath\\$vThumb",
|
||||
sslCertType = null, // store reference - not a file type
|
||||
sslKey = null,
|
||||
sslKeyType = null,
|
||||
keyPassword = null,
|
||||
cleanup = { deleteTempCert(vThumb, vContainer) },
|
||||
)
|
||||
}
|
||||
|
||||
// ============
|
||||
// PEM / DER (RSA) → store
|
||||
// ============
|
||||
|
||||
/* 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
|
||||
(thumbprintHex, containerName). */
|
||||
/** 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
|
||||
(thumbprintHex, containerName). */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?>
|
||||
{
|
||||
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
|
||||
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
|
||||
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).")
|
||||
val vBlob = try { 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") }
|
||||
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?> {
|
||||
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
|
||||
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
|
||||
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).")
|
||||
val vBlob = try {
|
||||
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")
|
||||
}
|
||||
|
||||
val vCert = vCertDer.usePinned { vPin ->
|
||||
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
|
||||
} ?: winError("CertCreateCertificateContext")
|
||||
val vCert = vCertDer.usePinned { vPin ->
|
||||
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
|
||||
} ?: winError("CertCreateCertificateContext")
|
||||
|
||||
try
|
||||
{
|
||||
val vThumb = thumbprintHex(vCert)
|
||||
val vContainer = kTagPrefix + vThumb
|
||||
importRsaIntoContainer(vContainer, vBlob)
|
||||
try
|
||||
{
|
||||
linkKeyToCert(vCert, vContainer)
|
||||
tagFriendlyName(vCert, kTagPrefix + vThumb)
|
||||
addToStore(vCert)
|
||||
return vThumb to vContainer
|
||||
}
|
||||
catch (e: Throwable) { deleteContainer(vContainer); throw e }
|
||||
}
|
||||
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) }
|
||||
try {
|
||||
val vThumb = thumbprintHex(vCert)
|
||||
val vContainer = kTagPrefix + vThumb
|
||||
importRsaIntoContainer(vContainer, vBlob)
|
||||
try {
|
||||
linkKeyToCert(vCert, vContainer)
|
||||
tagFriendlyName(vCert, kTagPrefix + vThumb)
|
||||
addToStore(vCert)
|
||||
return vThumb to vContainer
|
||||
} catch (e: Throwable) {
|
||||
deleteContainer(vContainer); throw e
|
||||
}
|
||||
} finally {
|
||||
CertFreeCertificateContext(vCert)
|
||||
}
|
||||
}
|
||||
|
||||
/* 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)
|
||||
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
|
||||
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
|
||||
vInfo.pwszContainerName = inContainer.wcstr.ptr
|
||||
vInfo.pwszProvName = kProvName.wcstr.ptr
|
||||
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
|
||||
vInfo.dwFlags = 0u
|
||||
vInfo.cProvParam = 0u
|
||||
vInfo.rgProvParam = null
|
||||
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
|
||||
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
|
||||
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
|
||||
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
|
||||
vInfo.pwszContainerName = inContainer.wcstr.ptr
|
||||
vInfo.pwszProvName = kProvName.wcstr.ptr
|
||||
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
|
||||
vInfo.dwFlags = 0u
|
||||
vInfo.cProvParam = 0u
|
||||
vInfo.rgProvParam = null
|
||||
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
|
||||
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
|
||||
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
|
||||
}
|
||||
|
||||
// ============
|
||||
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
|
||||
// ============
|
||||
|
||||
/* Minimal DER reader for the RSA key structures. */
|
||||
private class Der(val b: ByteArray)
|
||||
{
|
||||
var p = 0
|
||||
fun tag(): Int = b[p++].toInt() and 0xFF
|
||||
fun len(): Int
|
||||
{
|
||||
val vFirst = b[p++].toInt() and 0xFF
|
||||
if (vFirst and 0x80 == 0) return vFirst
|
||||
var vLen = 0
|
||||
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)
|
||||
}
|
||||
}
|
||||
/** Minimal DER reader for the RSA key structures. */
|
||||
private class Der(val b: ByteArray) {
|
||||
var p = 0
|
||||
fun tag(): Int = b[p++].toInt() and 0xFF
|
||||
fun len(): Int {
|
||||
val vFirst = b[p++].toInt() and 0xFF
|
||||
if (vFirst and 0x80 == 0) return vFirst
|
||||
var vLen = 0
|
||||
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
|
||||
return vLen
|
||||
}
|
||||
|
||||
/* 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())
|
||||
}
|
||||
fun sequence() {
|
||||
require(tag() == 0x30) { "SEQUENCE expected" }; len()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
fun peek(): Int = b[p].toInt() and 0xFF
|
||||
fun skip() {
|
||||
tag()
|
||||
val vL = len(); p += vL
|
||||
}
|
||||
|
||||
/* 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(
|
||||
inN: ByteArray, inE: ByteArray, inD: ByteArray,
|
||||
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
|
||||
): ByteArray
|
||||
{
|
||||
val vMod = inN.size // modulus length in bytes
|
||||
val vHalf = vMod / 2
|
||||
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.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
|
||||
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
|
||||
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
|
||||
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
|
||||
vOut.addAll(leField(inN, vMod).toList())
|
||||
vOut.addAll(leField(inP, vHalf).toList())
|
||||
vOut.addAll(leField(inQ, vHalf).toList())
|
||||
vOut.addAll(leField(inDp, vHalf).toList())
|
||||
vOut.addAll(leField(inDq, vHalf).toList())
|
||||
vOut.addAll(leField(inIq, vHalf).toList())
|
||||
vOut.addAll(leField(inD, vMod).toList())
|
||||
return vOut.toByteArray()
|
||||
}
|
||||
inN: ByteArray, inE: ByteArray, inD: ByteArray,
|
||||
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
|
||||
): ByteArray {
|
||||
val vMod = inN.size // modulus length in bytes
|
||||
val vHalf = vMod / 2
|
||||
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.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
|
||||
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
|
||||
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
|
||||
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
|
||||
vOut.addAll(leField(inN, vMod).toList())
|
||||
vOut.addAll(leField(inP, vHalf).toList())
|
||||
vOut.addAll(leField(inQ, vHalf).toList())
|
||||
vOut.addAll(leField(inDp, vHalf).toList())
|
||||
vOut.addAll(leField(inDq, vHalf).toList())
|
||||
vOut.addAll(leField(inIq, vHalf).toList())
|
||||
vOut.addAll(leField(inD, vMod).toList())
|
||||
return vOut.toByteArray()
|
||||
}
|
||||
|
||||
/* A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
|
||||
private fun leField(inBe: ByteArray, inSize: Int): ByteArray
|
||||
{
|
||||
val vOut = ByteArray(inSize)
|
||||
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
|
||||
return vOut
|
||||
}
|
||||
/** A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
|
||||
private fun leField(inBe: ByteArray, inSize: Int): ByteArray {
|
||||
val vOut = ByteArray(inSize)
|
||||
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
|
||||
return vOut
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
var vV = 0u
|
||||
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
|
||||
return vV
|
||||
}
|
||||
private fun beToUInt(inBe: ByteArray): UInt {
|
||||
var vV = 0u
|
||||
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
|
||||
return vV
|
||||
}
|
||||
|
||||
// ============
|
||||
// PKCS#12 → store
|
||||
// ============
|
||||
|
||||
/* Import a .p12/.pfx into a memory store, then copy its cert (with the key
|
||||
container PFXImportCertStore created) into CurrentUser\MY. */
|
||||
/** Import a .p12/.pfx into a memory store, then copy its cert (with the key
|
||||
container PFXImportCertStore created) into CurrentUser\MY. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
|
||||
inBytes.usePinned { vPin ->
|
||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||
vBlob.cbData = inBytes.size.convert()
|
||||
vBlob.pbData = vPin.addressOf(0).reinterpret()
|
||||
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
|
||||
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
|
||||
try
|
||||
{
|
||||
val vSrc = CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
|
||||
val vThumb = thumbprintHex(vSrc)
|
||||
val vContainer = containerNameOf(vSrc)
|
||||
tagFriendlyName(vSrc, kTagPrefix + vThumb)
|
||||
addToStore(vSrc)
|
||||
vThumb to vContainer
|
||||
}
|
||||
finally { CertCloseStore(vMem, 0u) }
|
||||
}
|
||||
inBytes.usePinned { vPin ->
|
||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||
vBlob.cbData = inBytes.size.convert()
|
||||
vBlob.pbData = vPin.addressOf(0).reinterpret()
|
||||
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
|
||||
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
|
||||
try {
|
||||
val vSrc =
|
||||
CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
|
||||
val vThumb = thumbprintHex(vSrc)
|
||||
val vContainer = containerNameOf(vSrc)
|
||||
tagFriendlyName(vSrc, kTagPrefix + vThumb)
|
||||
addToStore(vSrc)
|
||||
vThumb to vContainer
|
||||
} finally {
|
||||
CertCloseStore(vMem, 0u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The key-container name PFXImportCertStore assigned to a cert (from its
|
||||
CERT_KEY_PROV_INFO), so cleanup can delete it. */
|
||||
/** The key-container name PFXImportCertStore assigned to a cert (from its
|
||||
CERT_KEY_PROV_INFO), so cleanup can delete it. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||
val vLen = alloc<UIntVar>()
|
||||
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 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()
|
||||
val vLen = alloc<UIntVar>()
|
||||
if (CertGetCertificateContextProperty(
|
||||
inCert,
|
||||
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
|
||||
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
|
||||
// ============
|
||||
|
||||
/* 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)
|
||||
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
|
||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||
vBlob.cbData = ((inName.length + 1) * 2).convert()
|
||||
vBlob.pbData = inName.wcstr.ptr.reinterpret()
|
||||
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
|
||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||
vBlob.cbData = ((inName.length + 1) * 2).convert()
|
||||
vBlob.pbData = inName.wcstr.ptr.reinterpret()
|
||||
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)
|
||||
private fun addToStore(inCert: CPointer<CERT_CONTEXT>)
|
||||
{
|
||||
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
|
||||
try
|
||||
{
|
||||
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
|
||||
winError("CertAddCertificateContextToStore")
|
||||
}
|
||||
finally { CertCloseStore(vStore, 0u) }
|
||||
}
|
||||
private fun addToStore(inCert: CPointer<CERT_CONTEXT>) {
|
||||
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
|
||||
try {
|
||||
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
|
||||
winError("CertAddCertificateContextToStore")
|
||||
} 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)
|
||||
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)
|
||||
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
|
||||
val vLen = alloc<UIntVar>().apply { value = 20u }
|
||||
val vBuf = allocArray<UByteVar>(20)
|
||||
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
|
||||
winError("CertGetCertificateContextProperty(SHA1)")
|
||||
buildString { for (vI in 0 until vLen.value.toInt()) append(vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')) }
|
||||
val vLen = alloc<UIntVar>().apply { value = 20u }
|
||||
val vBuf = allocArray<UByteVar>(20)
|
||||
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
|
||||
winError("CertGetCertificateContextProperty(SHA1)")
|
||||
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
|
||||
key container. Called per-request in finally; ignores what's already gone. */
|
||||
/** Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
|
||||
key container. Called per-request in finally; ignores what's already gone. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun deleteTempCert(inThumbHex: String, inContainer: String?)
|
||||
{
|
||||
val vStore = openMyStore()
|
||||
if (vStore != null) memScoped {
|
||||
val vHash = hexToBytes(inThumbHex)
|
||||
val vBuf = allocArray<UByteVar>(vHash.size)
|
||||
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
|
||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||
vBlob.cbData = vHash.size.convert()
|
||||
vBlob.pbData = vBuf
|
||||
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
|
||||
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
|
||||
CertCloseStore(vStore, 0u)
|
||||
}
|
||||
if (inContainer != null) deleteContainer(inContainer)
|
||||
}
|
||||
private fun deleteTempCert(inThumbHex: String, inContainer: String?) {
|
||||
val vStore = openMyStore()
|
||||
if (vStore != null) memScoped {
|
||||
val vHash = hexToBytes(inThumbHex)
|
||||
val vBuf = allocArray<UByteVar>(vHash.size)
|
||||
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
|
||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||
vBlob.cbData = vHash.size.convert()
|
||||
vBlob.pbData = vBuf
|
||||
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
|
||||
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
|
||||
CertCloseStore(vStore, 0u)
|
||||
}
|
||||
if (inContainer != null) deleteContainer(inContainer)
|
||||
}
|
||||
|
||||
/* Delete a named key container (best-effort). */
|
||||
/** Delete a named key container (best-effort). */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun deleteContainer(inContainer: String) = memScoped {
|
||||
val vProv = alloc<ULongVar>()
|
||||
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
|
||||
val vProv = alloc<ULongVar>()
|
||||
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
|
||||
clearing crash leftovers at startup. The user's own certs are never matched. */
|
||||
/** 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. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun sweepTempClientCerts()
|
||||
{
|
||||
val vStore = openMyStore() ?: return
|
||||
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
|
||||
var vCert = CertEnumCertificatesInStore(vStore, null)
|
||||
while (vCert != null)
|
||||
{
|
||||
val vFriendly = friendlyNameOf(vCert)
|
||||
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
|
||||
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
|
||||
vCert = CertEnumCertificatesInStore(vStore, vCert)
|
||||
}
|
||||
CertCloseStore(vStore, 0u)
|
||||
vDoomed.forEach { deleteTempCert(it.first, it.second) }
|
||||
}
|
||||
actual fun sweepTempClientCerts() {
|
||||
val vStore = openMyStore() ?: return
|
||||
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
|
||||
var vCert = CertEnumCertificatesInStore(vStore, null)
|
||||
while (vCert != null) {
|
||||
val vFriendly = friendlyNameOf(vCert)
|
||||
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
|
||||
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
|
||||
vCert = CertEnumCertificatesInStore(vStore, vCert)
|
||||
}
|
||||
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)
|
||||
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||
val vLen = alloc<UIntVar>()
|
||||
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 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()
|
||||
val vLen = alloc<UIntVar>()
|
||||
if (CertGetCertificateContextProperty(
|
||||
inCert,
|
||||
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
|
||||
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
|
||||
// ============
|
||||
|
||||
/* 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
|
||||
a name-only issuer if the OS can't (or the leaf can't be parsed). */
|
||||
/** 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
|
||||
a name-only issuer if the OS can't (or the leaf can't be parsed). */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
|
||||
{
|
||||
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
||||
?: return serverChainWithIssuerName(inServerCerts)
|
||||
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
|
||||
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
||||
}
|
||||
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
|
||||
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
||||
?: return serverChainWithIssuerName(inServerCerts)
|
||||
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
|
||||
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)
|
||||
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
|
||||
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
|
||||
val vLeaf = vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
|
||||
?: return@memScoped emptyList()
|
||||
try
|
||||
{
|
||||
val vPara = alloc<CERT_CHAIN_PARA>()
|
||||
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
|
||||
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
|
||||
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
|
||||
return@memScoped emptyList()
|
||||
val vChain = vChainPtr.value ?: return@memScoped emptyList()
|
||||
try
|
||||
{
|
||||
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
|
||||
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
|
||||
val vResult = ArrayList<ChainCert>()
|
||||
for (vI in 0 until vSimple.cElement.toInt())
|
||||
{
|
||||
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 (vLast != null)
|
||||
{
|
||||
val vSub = certFieldOf(vLast.fields, "Subject")
|
||||
val vIss = certFieldOf(vLast.fields, "Issuer")
|
||||
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
|
||||
}
|
||||
vResult
|
||||
}
|
||||
finally { CertFreeCertificateChain(vChain) }
|
||||
}
|
||||
finally { CertFreeCertificateContext(vLeaf) }
|
||||
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
|
||||
val vLeaf =
|
||||
vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
|
||||
?: return@memScoped emptyList()
|
||||
try {
|
||||
val vPara = alloc<CERT_CHAIN_PARA>()
|
||||
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
|
||||
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
|
||||
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
|
||||
return@memScoped emptyList()
|
||||
val vChain = vChainPtr.value ?: return@memScoped emptyList()
|
||||
try {
|
||||
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
|
||||
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
|
||||
val vResult = ArrayList<ChainCert>()
|
||||
for (vI in 0 until vSimple.cElement.toInt()) {
|
||||
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 (vLast != null) {
|
||||
val vSub = certFieldOf(vLast.fields, "Subject")
|
||||
val vIss = certFieldOf(vLast.fields, "Issuer")
|
||||
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
|
||||
}
|
||||
vResult
|
||||
} finally {
|
||||
CertFreeCertificateChain(vChain)
|
||||
}
|
||||
} 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)
|
||||
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>>
|
||||
{
|
||||
val vFields = ArrayList<Pair<String, String>>()
|
||||
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
|
||||
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
|
||||
certPem(inCtx)?.let { vFields.add("Cert" to it) }
|
||||
return vFields
|
||||
}
|
||||
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>> {
|
||||
val vFields = ArrayList<Pair<String, String>>()
|
||||
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
|
||||
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
|
||||
certPem(inCtx)?.let { vFields.add("Cert" to it) }
|
||||
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)
|
||||
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
|
||||
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)
|
||||
if (vLen <= 1u) return@memScoped null
|
||||
val vBuf = allocArray<ByteVar>(vLen.toInt())
|
||||
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
|
||||
vBuf.toKString()
|
||||
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)
|
||||
if (vLen <= 1u) return@memScoped null
|
||||
val vBuf = allocArray<ByteVar>(vLen.toInt())
|
||||
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
|
||||
vBuf.toKString()
|
||||
}
|
||||
|
||||
/* The PEM (base64-with-header) of a cert context. */
|
||||
/** The PEM (base64-with-header) of a cert context. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
|
||||
val vSize = inCtx.pointed.cbCertEncoded
|
||||
val vLen = alloc<UIntVar>()
|
||||
if (CryptBinaryToStringA(vData, vSize, 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()
|
||||
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
|
||||
val vSize = inCtx.pointed.cbCertEncoded
|
||||
val vLen = alloc<UIntVar>()
|
||||
if (CryptBinaryToStringA(
|
||||
vData,
|
||||
vSize,
|
||||
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
|
||||
// ============
|
||||
|
||||
/* 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
|
||||
{
|
||||
val vText = inBytes.decodeToString()
|
||||
if (!vText.contains("-----BEGIN")) return inBytes
|
||||
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
|
||||
val vEnd = vText.indexOf("-----END $inLabel-----")
|
||||
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
|
||||
return Base64.Default.decode(pemBody(vBlock))
|
||||
}
|
||||
/** Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
|
||||
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray {
|
||||
val vText = inBytes.decodeToString()
|
||||
if (!vText.contains("-----BEGIN")) return inBytes
|
||||
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
|
||||
val vEnd = vText.indexOf("-----END $inLabel-----")
|
||||
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
|
||||
return Base64.decode(pemBody(vBlock))
|
||||
}
|
||||
|
||||
/* Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
|
||||
bytes through. Null if no key. */
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun privateKeyDer(inBytes: ByteArray): ByteArray?
|
||||
{
|
||||
val vText = inBytes.decodeToString()
|
||||
if (!vText.contains("-----BEGIN")) return inBytes // already DER
|
||||
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
|
||||
?: return null
|
||||
return Base64.Default.decode(pemBody(vMatch.value))
|
||||
}
|
||||
/** Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
|
||||
bytes through. Null if no key. */
|
||||
private fun privateKeyDer(inBytes: ByteArray): ByteArray? {
|
||||
val vText = inBytes.decodeToString()
|
||||
if (!vText.contains("-----BEGIN")) return inBytes // already DER
|
||||
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
|
||||
?: return null
|
||||
return Base64.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 =
|
||||
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
|
||||
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
|
||||
|
||||
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)
|
||||
private fun CPointer<UShortVar>.toKStringFromUtf16(): String
|
||||
{
|
||||
val vSb = StringBuilder()
|
||||
var vI = 0
|
||||
while (true) { val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++ }
|
||||
return vSb.toString()
|
||||
}
|
||||
private fun CPointer<UShortVar>.toKStringFromUtf16(): String {
|
||||
val vSb = StringBuilder()
|
||||
var vI = 0
|
||||
while (true) {
|
||||
val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++
|
||||
}
|
||||
return vSb.toString()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
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
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.*
|
||||
import kotlinx.cinterop.*
|
||||
import libcurl.*
|
||||
import okio.Buffer
|
||||
@@ -11,7 +11,7 @@ import kotlin.time.TimeSource
|
||||
// MARK: Client-certificate (mTLS) sending via libcurl
|
||||
// ==================
|
||||
// Ktor's native engines expose no client-certificate API, so a request that
|
||||
// carries a client certificate is sent here instead — straight through the
|
||||
// carries a client certificate is sent here instead - straight through the
|
||||
// libcurl that ktor-client-curl already bundles (package `libcurl`, embedded
|
||||
// static archive; Schannel on Windows, OpenSSL on macOS/Linux). This is the
|
||||
// same TLS stack the default engine uses, so behaviour matches; we just get to
|
||||
@@ -19,323 +19,351 @@ import kotlin.time.TimeSource
|
||||
//
|
||||
// The transfer is synchronous (curl_easy_perform) and runs on Dispatchers.Default
|
||||
// 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
|
||||
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
|
||||
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
|
||||
cleanup removes it again so the store never accumulates entries. */
|
||||
/** 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.
|
||||
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
|
||||
cleanup removes it again so the store never accumulates entries. */
|
||||
class PreparedCert(
|
||||
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
|
||||
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
|
||||
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
|
||||
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
|
||||
val keyPassword: String?, // CURLOPT_KEYPASSWD
|
||||
val cleanup: () -> Unit,
|
||||
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
|
||||
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
|
||||
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
|
||||
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
|
||||
val keyPassword: String?, // CURLOPT_KEYPASSWD
|
||||
val cleanup: () -> Unit,
|
||||
)
|
||||
|
||||
/* Ready the request's client certificate for libcurl (platform-specific).
|
||||
Throws with a user-facing message if the cert/key can't be loaded. */
|
||||
/** Ready the request's client certificate for libcurl (platform-specific).
|
||||
Throws with a user-facing message if the cert/key can't be loaded. */
|
||||
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
||||
|
||||
/* Remove any temporary client certs (and their key containers) a previous run
|
||||
left behind in the Windows store after a crash — identified by our prefix, so
|
||||
the user's own certificates are never touched. No-op off Windows. Call once
|
||||
at startup. */
|
||||
expect fun sweepTempClientCerts()
|
||||
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
|
||||
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) - the UI
|
||||
// consumes them from shared code; the per-OS ClientCert*.kt actuals below the
|
||||
// native tree actualize sweepTempClientCerts directly.
|
||||
|
||||
/* 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?)
|
||||
|
||||
/* 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). */
|
||||
/** 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>
|
||||
|
||||
/* 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? =
|
||||
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
|
||||
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. */
|
||||
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert>
|
||||
{
|
||||
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
|
||||
val vLast = inServer.lastOrNull()
|
||||
if (vLast != null)
|
||||
{
|
||||
val vSubject = certFieldOf(vLast, "Subject")
|
||||
val vIssuer = certFieldOf(vLast, "Issuer")
|
||||
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
|
||||
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
|
||||
}
|
||||
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
|
||||
/** 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
|
||||
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> {
|
||||
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
|
||||
val vLast = inServer.lastOrNull()
|
||||
if (vLast != null) {
|
||||
val vSubject = certFieldOf(vLast, "Subject")
|
||||
val vIssuer = certFieldOf(vLast, "Issuer")
|
||||
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
|
||||
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
|
||||
}
|
||||
return vOut
|
||||
}
|
||||
|
||||
/* Send a client-certificate request through libcurl and adapt the result into
|
||||
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
|
||||
/** 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)
|
||||
fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
||||
{
|
||||
val vMark = TimeSource.Monotonic.markNow()
|
||||
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) }
|
||||
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()
|
||||
}
|
||||
|
||||
val vSink = CurlSink()
|
||||
val vSinkRef = StableRef.create(vSink)
|
||||
var vHeaders: CPointer<curl_slist>? = null
|
||||
/** 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()
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
||||
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
|
||||
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
|
||||
/** 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)
|
||||
|
||||
// ============
|
||||
// Method + body
|
||||
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
|
||||
if (inReq.method == ReqMethod.HEAD)
|
||||
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
|
||||
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)) }
|
||||
}
|
||||
/** 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)
|
||||
actual 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Headers (+ Content-Type for the body type if the user didn't set one)
|
||||
inReq.headers
|
||||
.filter { it.enabled && it.key.isNotBlank() }
|
||||
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
|
||||
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) }
|
||||
/** 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
|
||||
}
|
||||
|
||||
// ============
|
||||
// Response capture callbacks
|
||||
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
|
||||
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
|
||||
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
|
||||
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
|
||||
/** Send a client-certificate request through libcurl and adapt the result into
|
||||
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
|
||||
val vMark = TimeSource.Monotonic.markNow()
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// ============
|
||||
// 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) }
|
||||
val vSink = CurlSink()
|
||||
val vSinkRef = StableRef.create(vSink)
|
||||
var vHeaders: CPointer<curl_slist>? = null
|
||||
|
||||
// ============
|
||||
// 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)
|
||||
try {
|
||||
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
||||
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
|
||||
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
|
||||
|
||||
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)
|
||||
// ============
|
||||
// Method + body
|
||||
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
|
||||
if (inReq.method == ReqMethod.HEAD)
|
||||
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
|
||||
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).
|
||||
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() }
|
||||
// ============
|
||||
// Headers (+ Content-Type for the body type if the user didn't set one)
|
||||
inReq.headers
|
||||
.filter { it.enabled && it.key.isNotBlank() }
|
||||
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
|
||||
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,
|
||||
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()
|
||||
}
|
||||
}
|
||||
// ============
|
||||
// Response capture callbacks
|
||||
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
|
||||
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
|
||||
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
|
||||
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
|
||||
|
||||
/* 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 =
|
||||
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
|
||||
should send (null when there's no body). Mirrors HttpRunner.run(). */
|
||||
/** 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(). */
|
||||
private class CurlBody(val contentType: String?, val bytes: ByteArray)
|
||||
|
||||
private fun requestBodyBytes(inReq: ApiRequest): CurlBody?
|
||||
{
|
||||
if (inReq.bodyType == BodyType.NONE) return null
|
||||
return when (inReq.bodyType)
|
||||
{
|
||||
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
|
||||
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
|
||||
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(inReq.bodyContentType(), readFileBytes(inReq.body))
|
||||
BodyType.NONE -> null
|
||||
}
|
||||
}
|
||||
private fun requestBodyBytes(inReq: ApiRequest): CurlBody? {
|
||||
if (inReq.bodyType == BodyType.NONE) return null
|
||||
return when (inReq.bodyType) {
|
||||
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
|
||||
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
|
||||
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(
|
||||
inReq.bodyContentType(),
|
||||
readFileBytes(inReq.body)
|
||||
)
|
||||
|
||||
/* 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() }
|
||||
}
|
||||
BodyType.NONE -> null
|
||||
}
|
||||
}
|
||||
|
||||
/* 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)
|
||||
private fun httpVersionName(inCode: Long): String = when (inCode)
|
||||
{
|
||||
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
|
||||
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
|
||||
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
|
||||
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
|
||||
else -> "HTTP/1.1"
|
||||
}
|
||||
private fun httpVersionName(inCode: Long): String = when (inCode) {
|
||||
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
|
||||
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
|
||||
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
|
||||
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
|
||||
else -> "HTTP/1.1"
|
||||
}
|
||||
|
||||
@@ -6,20 +6,13 @@ import com.compose.sdl.loadComposeResourceBytes
|
||||
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
|
||||
// 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 {
|
||||
actual val monoFontFamilyName: String? by lazy {
|
||||
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
|
||||
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")
|
||||
null
|
||||
} else {
|
||||
IconFont.register(kMonoFamily, vBytes)
|
||||
@@ -27,9 +20,19 @@ val monoFontFamilyName: String? by lazy {
|
||||
}
|
||||
}
|
||||
|
||||
// The material3 Text / BasicTextField-shaped FontFamily wrapper. Wraps the
|
||||
// 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 {
|
||||
actual val monoFontFamily: FontFamily? by lazy {
|
||||
monoFontFamilyName?.let { namedFontFamily(it) }
|
||||
}
|
||||
|
||||
/** Native already resolves FontFamily.Default to the bundled NotoSans, but the
|
||||
family is registered by name so both stacks go through the same lookup. */
|
||||
actual val defaultFontFamily: FontFamily? by lazy {
|
||||
val vBytes = loadComposeResourceBytes("font/NotoSans.ttf")
|
||||
if (vBytes == null) {
|
||||
println("apidemo: NotoSans.ttf not bundled - using the platform default font")
|
||||
null
|
||||
} else {
|
||||
IconFont.register(kDefaultFamily, vBytes)
|
||||
namedFontFamily(kDefaultFamily)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package utils
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.refTo
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fwrite
|
||||
|
||||
// ==================
|
||||
// MARK: BMP save
|
||||
// ==================
|
||||
// Copy of demo/src/nativeMain/kotlin/utils/Bmp.kt. Kept duplicated rather than
|
||||
// promoted into a library module: it is app-level tooling for the drift-check
|
||||
// screenshots, and :ui-graphics deliberately does not re-export skiko to apps,
|
||||
// so there is no natural shared home that would not widen the published API.
|
||||
// ==================
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun writeFile(inPath: String, inBytes: ByteArray) {
|
||||
val vFile = fopen(inPath, "wb") ?: run {
|
||||
println("fopen failed for $inPath")
|
||||
return
|
||||
}
|
||||
fwrite(inBytes.refTo(0), 1u, inBytes.size.toULong(), vFile)
|
||||
fclose(vFile)
|
||||
}
|
||||
|
||||
/** Minimal BMP writer: 24-bit BGR (drop alpha) with the classic 40-byte
|
||||
BITMAPINFOHEADER. Universally readable, including `sips`. Rows are
|
||||
bottom-up (positive height) and padded to 4 bytes. */
|
||||
internal fun encodeBmpBgra32(inWidth: Int, inHeight: Int, inBgra: ByteArray): ByteArray {
|
||||
val kFileHeader = 14
|
||||
val kInfoHeader = 40
|
||||
val vRowBytes = inWidth * 3
|
||||
val vRowPad = (4 - vRowBytes % 4) % 4
|
||||
val vStride = vRowBytes + vRowPad
|
||||
val vPixelBytes = vStride * inHeight
|
||||
val vTotal = kFileHeader + kInfoHeader + vPixelBytes
|
||||
val vOut = ByteArray(vTotal)
|
||||
|
||||
fun putU16LE(off: Int, v: Int) {
|
||||
vOut[off] = (v and 0xFF).toByte()
|
||||
vOut[off + 1] = ((v ushr 8) and 0xFF).toByte()
|
||||
}
|
||||
fun putU32LE(off: Int, v: Int) {
|
||||
vOut[off] = (v and 0xFF).toByte()
|
||||
vOut[off + 1] = ((v ushr 8) and 0xFF).toByte()
|
||||
vOut[off + 2] = ((v ushr 16) and 0xFF).toByte()
|
||||
vOut[off + 3] = ((v ushr 24) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
// BITMAPFILEHEADER
|
||||
vOut[0] = 'B'.code.toByte(); vOut[1] = 'M'.code.toByte()
|
||||
putU32LE(2, vTotal)
|
||||
putU32LE(10, kFileHeader + kInfoHeader)
|
||||
|
||||
// BITMAPINFOHEADER
|
||||
val info = kFileHeader
|
||||
putU32LE(info + 0, kInfoHeader)
|
||||
putU32LE(info + 4, inWidth)
|
||||
putU32LE(info + 8, inHeight) // positive → bottom-up
|
||||
putU16LE(info + 12, 1)
|
||||
putU16LE(info + 14, 24)
|
||||
putU32LE(info + 16, 0) // BI_RGB
|
||||
putU32LE(info + 20, vPixelBytes)
|
||||
putU32LE(info + 24, 2835)
|
||||
putU32LE(info + 28, 2835)
|
||||
putU32LE(info + 32, 0)
|
||||
putU32LE(info + 36, 0)
|
||||
|
||||
// Source is top-down BGRA; convert to bottom-up 24-bit BGR.
|
||||
val vPixelOffset = kFileHeader + kInfoHeader
|
||||
for (y in 0 until inHeight) {
|
||||
val vSrcRow = (inHeight - 1 - y) * inWidth * 4
|
||||
val vDstRow = vPixelOffset + y * vStride
|
||||
for (x in 0 until inWidth) {
|
||||
vOut[vDstRow + x * 3 + 0] = inBgra[vSrcRow + x * 4 + 0] // B
|
||||
vOut[vDstRow + x * 3 + 1] = inBgra[vSrcRow + x * 4 + 1] // G
|
||||
vOut[vDstRow + x * 3 + 2] = inBgra[vSrcRow + x * 4 + 2] // R
|
||||
}
|
||||
}
|
||||
return vOut
|
||||
}
|
||||
@@ -1,43 +1,92 @@
|
||||
// Root build — declare the Kotlin/Compose plugins once (apply false) so every
|
||||
// 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.
|
||||
import kotlinx.validation.ExperimentalBCVApi
|
||||
|
||||
plugins {
|
||||
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.serialization) 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)
|
||||
}
|
||||
|
||||
// Generate klib ABI dumps; the apps and icon-font modules carry no public API
|
||||
// surface worth tracking, so skip them.
|
||||
apiValidation {
|
||||
@OptIn(ExperimentalBCVApi::class)
|
||||
klib { enabled = true }
|
||||
ignoredProjects.addAll(listOf("demo", "apidemo"))
|
||||
// demo/apidemo: apps, no published API. sdl-core: naked SDL cinterop - its
|
||||
// "API" is the generated sdl3.* bindings (per-OS, can't infer macos), not our
|
||||
// surface. material-symbols: auto-generated icon codepoint maps + its jvm
|
||||
// dump trips BCV's ASM on newer JDKs (class file major 69). Track the
|
||||
// hand-authored Compose surface, not generated/binding code.
|
||||
ignoredProjects.addAll(listOf("demo", "apidemo", "sdl-core", "material-symbols"))
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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")
|
||||
|
||||
// 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(
|
||||
":compose:ui:ui" to "com.bitsycore.compose.ui",
|
||||
":compose:ui:ui-graphics" to "com.bitsycore.compose.ui",
|
||||
":compose:ui:ui-text" to "com.bitsycore.compose.ui",
|
||||
":compose:ui:ui-unit" to "com.bitsycore.compose.ui",
|
||||
":compose:ui:ui-geometry" to "com.bitsycore.compose.ui",
|
||||
":compose:ui:ui-util" to "com.bitsycore.compose.ui",
|
||||
":compose:ui:ui-backhandler" to "com.bitsycore.compose.ui",
|
||||
":compose:ui:ui-tooling-preview" to "com.bitsycore.compose.ui",
|
||||
":compose:foundation:foundation" to "com.bitsycore.compose.foundation",
|
||||
":compose:foundation:foundation-layout" to "com.bitsycore.compose.foundation",
|
||||
":compose:animation:animation" to "com.bitsycore.compose.animation",
|
||||
":compose:animation:animation-core" to "com.bitsycore.compose.animation",
|
||||
":compose:animation:animation-graphics" to "com.bitsycore.compose.animation",
|
||||
":compose:material3:material3" to "com.bitsycore.compose.material3",
|
||||
":compose:material3:adaptive:adaptive" to "com.bitsycore.compose.material3.adaptive",
|
||||
":compose:material3:adaptive:adaptive-layout" to "com.bitsycore.compose.material3.adaptive",
|
||||
":compose:material3:adaptive:adaptive-navigation" to "com.bitsycore.compose.material3.adaptive",
|
||||
":compose:material3:adaptive:adaptive-navigation3" to "com.bitsycore.compose.material3.adaptive",
|
||||
":compose:material:material-ripple" to "com.bitsycore.compose.material",
|
||||
":components:resources:components-resources" to "com.bitsycore.compose.components",
|
||||
":navigation3:navigation3-ui" to "com.bitsycore.navigation3",
|
||||
":koin:koin-core-viewmodel" to "com.bitsycore.koin",
|
||||
":koin:koin-compose" to "com.bitsycore.koin",
|
||||
":koin:koin-compose-viewmodel" to "com.bitsycore.koin",
|
||||
":koin:koin-compose-navigation3" to "com.bitsycore.koin",
|
||||
":coil:coil-core" to "com.bitsycore.coil3",
|
||||
":coil:coil" to "com.bitsycore.coil3",
|
||||
":coil:coil-compose-core" to "com.bitsycore.coil3",
|
||||
":coil:coil-compose" to "com.bitsycore.coil3",
|
||||
":coil:coil-svg" to "com.bitsycore.coil3",
|
||||
":coil:coil-network-core" to "com.bitsycore.coil3",
|
||||
":coil:coil-network-ktor3" to "com.bitsycore.coil3",
|
||||
":pulse:pulse" to "com.bitsycore.compose.desktop.native.pulse",
|
||||
":pulse:pulse-viewmodel" to "com.bitsycore.compose.desktop.native.pulse",
|
||||
":pulse:pulse-savedstate" to "com.bitsycore.compose.desktop.native.pulse",
|
||||
":pulse:pulse-compose" to "com.bitsycore.compose.desktop.native.pulse",
|
||||
":compose:desktop:native:desktop-native-window" to "com.bitsycore.compose",
|
||||
)
|
||||
fun groupFor(path: String): String = kAreaGroups[path] ?: kDefaultGroup
|
||||
|
||||
// The published artifactId is always the leaf project name - settings.gradle.kts
|
||||
// keeps the leaf equal to the artifactId even where the directory differs.
|
||||
fun artifactIdFor(path: String): String = path.substringAfterLast(':')
|
||||
|
||||
allprojects {
|
||||
group = "com.bitsycore.compose.sdl"
|
||||
group = groupFor(path)
|
||||
version = vPublishVersion
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: Publish to GitHub Packages
|
||||
// ==================
|
||||
|
||||
// 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
|
||||
// three hosts (macOS / Linux / Windows) and each invokes only the publication
|
||||
// tasks Gradle actually generated for its own targets, so the group of hosts
|
||||
@@ -46,37 +95,73 @@ allprojects {
|
||||
|
||||
val kAppModules = setOf(":demo", ":apidemo")
|
||||
val kPublishedLibs = setOf(
|
||||
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
|
||||
":animation-core", ":animation", ":animation-graphics",
|
||||
":foundation", ":foundation-layout",
|
||||
":material3", ":material-ripple",
|
||||
":window", ":material-symbols",
|
||||
":navigation3-ui", ":components-resources",
|
||||
":sdl:sdl-core",
|
||||
":compose:ui:ui", ":compose:ui:ui-util", ":compose:ui:ui-geometry",
|
||||
":compose:ui:ui-graphics", ":compose:ui:ui-text",
|
||||
":compose:ui:ui-unit", ":compose:ui:ui-backhandler", ":compose:ui:ui-tooling-preview",
|
||||
":compose:animation:animation-core", ":compose:animation:animation",
|
||||
":compose:animation:animation-graphics",
|
||||
":compose:foundation:foundation", ":compose:foundation:foundation-layout",
|
||||
":compose:material3:material3", ":compose:material:material-ripple",
|
||||
":compose:material3:adaptive:adaptive", ":compose:material3:adaptive:adaptive-layout", ":compose:material3:adaptive:adaptive-navigation", ":compose:material3:adaptive:adaptive-navigation3",
|
||||
":compose:desktop:native:desktop-native-window", ":utils:material-symbols",
|
||||
":navigation3:navigation3-ui", ":components:resources:components-resources",
|
||||
":koin:koin-core-viewmodel", ":koin:koin-compose",
|
||||
":koin:koin-compose-viewmodel", ":koin:koin-compose-navigation3",
|
||||
":coil:coil-core", ":coil:coil", ":coil:coil-compose-core", ":coil:coil-compose",
|
||||
":coil:coil-svg", ":coil:coil-network-core", ":coil:coil-network-ktor3",
|
||||
":pulse:pulse", ":pulse:pulse-viewmodel", ":pulse:pulse-savedstate", ":pulse:pulse-compose",
|
||||
)
|
||||
|
||||
// -PuseGithubPackages=true swaps every `project(":<lib>")` reference the demo
|
||||
// apps make for the published Maven coordinate. Library modules keep resolving
|
||||
// each other as `project(...)` — the substitution only fires at the
|
||||
// each other as `project(...)` - the substitution only fires at the
|
||||
// app→library boundary, so the swap validates end-to-end consumption of the
|
||||
// published klibs without touching the source of `implementation(project(...))`.
|
||||
// Version defaults to 0.1.0 (matches the git tag) but can be pinned via -PconsumeVersion=….
|
||||
val kUseGhPackages = (findProperty("useGithubPackages") as? String)?.toBoolean() == true
|
||||
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 {
|
||||
if (kUseGhPackages && path in kAppModules) {
|
||||
configurations.configureEach {
|
||||
resolutionStrategy.dependencySubstitution {
|
||||
kPublishedLibs.forEach { modulePath ->
|
||||
val vArtifactId = modulePath.removePrefix(":")
|
||||
val vArtifactId = artifactIdFor(modulePath)
|
||||
substitute(project(modulePath))
|
||||
.using(module("com.bitsycore.compose.sdl:$vArtifactId:$kConsumeVersion"))
|
||||
.using(module("${groupFor(modulePath)}:$vArtifactId:$kConsumeVersion"))
|
||||
.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
|
||||
}
|
||||
// Nesting the paths under compose/ / sdl/ / utils/ … materialises container
|
||||
// projects (:compose, :compose:ui, …) that hold no code - skip them.
|
||||
if (path !in kPublishedLibs) return@subprojects
|
||||
plugins.apply("maven-publish")
|
||||
afterEvaluate {
|
||||
extensions.configure<PublishingExtension> {
|
||||
@@ -90,11 +175,19 @@ subprojects {
|
||||
password = System.getenv("GITHUB_TOKEN")
|
||||
}
|
||||
}
|
||||
maven {
|
||||
name = "Bitsycore"
|
||||
url = uri("https://maven.bitsycore.com/releases")
|
||||
credentials {
|
||||
username = System.getenv("BITSYCORE_MAVEN_USER")
|
||||
password = System.getenv("BITSYCORE_MAVEN_TOKEN")
|
||||
}
|
||||
}
|
||||
}
|
||||
publications.withType<MavenPublication>().configureEach {
|
||||
pom {
|
||||
name.set("${rootProject.name} ${project.name}")
|
||||
description.set("Compose Multiplatform on SDL3 (Kotlin/Native, no JVM) — ${project.name}")
|
||||
description.set("Compose Multiplatform on SDL3 (Kotlin/Native, no JVM) - ${project.name}")
|
||||
url.set("https://github.com/${System.getenv("GITHUB_REPOSITORY") ?: "bitsycore/ComposeDesktopNative"}")
|
||||
licenses {
|
||||
license {
|
||||
@@ -111,29 +204,76 @@ subprojects {
|
||||
// FULL-COMMONIZATION BRIDGE (repo-wide): a module may declare the OFFICIAL
|
||||
// Maven Compose artifacts in its commonMain so metadata + jvm resolve them
|
||||
// (e.g. :material-symbols' common API); every NATIVE target configuration
|
||||
// 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
|
||||
// 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")
|
||||
allprojects {
|
||||
configurations.configureEach {
|
||||
if (vNativeTargetTokens.any { name.contains(it, ignoreCase = true) }) {
|
||||
resolutionStrategy.dependencySubstitution {
|
||||
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":ui"))
|
||||
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":foundation"))
|
||||
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":material3"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":compose:ui: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(":compose:ui:ui-graphics"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":compose:ui:ui-text"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-unit")).using(project(":compose:ui:ui-unit"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-geometry")).using(project(":compose:ui:ui-geometry"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-util")).using(project(":compose:ui:ui-util"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-tooling-preview")).using(project(":compose:ui:ui-tooling-preview"))
|
||||
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":compose:foundation:foundation"))
|
||||
substitute(module("org.jetbrains.compose.foundation:foundation-layout")).using(project(":compose:foundation:foundation-layout"))
|
||||
substitute(module("org.jetbrains.compose.animation:animation")).using(project(":compose:animation:animation"))
|
||||
substitute(module("org.jetbrains.compose.animation:animation-core")).using(project(":compose:animation:animation-core"))
|
||||
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":compose:material3:material3"))
|
||||
// material3-adaptive: upstream publishes ios + macosArm64 only - no
|
||||
// linux, no mingw (issues/4). Vendored as :compose:material3:adaptive:*.
|
||||
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive")).using(project(":compose:material3:adaptive:adaptive"))
|
||||
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive-layout")).using(project(":compose:material3:adaptive:adaptive-layout"))
|
||||
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive-navigation")).using(project(":compose:material3:adaptive:adaptive-navigation"))
|
||||
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive-navigation3")).using(project(":compose:material3:adaptive:adaptive-navigation3"))
|
||||
// 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: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:components-resources"))
|
||||
// Koin: koin-core itself publishes mingwX64 + linux and is used
|
||||
// from Maven; these four stop at apple+android upstream.
|
||||
substitute(module("io.insert-koin:koin-core-viewmodel")).using(project(":koin:koin-core-viewmodel"))
|
||||
substitute(module("io.insert-koin:koin-compose")).using(project(":koin:koin-compose"))
|
||||
substitute(module("io.insert-koin:koin-compose-viewmodel")).using(project(":koin:koin-compose-viewmodel"))
|
||||
substitute(module("io.insert-koin:koin-compose-navigation3")).using(project(":koin:koin-compose-navigation3"))
|
||||
// Coil 3: no mingwX64 upstream anywhere, and no linux for the
|
||||
// compose layer either.
|
||||
substitute(module("io.coil-kt.coil3:coil-core")).using(project(":coil:coil-core"))
|
||||
substitute(module("io.coil-kt.coil3:coil")).using(project(":coil:coil"))
|
||||
substitute(module("io.coil-kt.coil3:coil-compose-core")).using(project(":coil:coil-compose-core"))
|
||||
substitute(module("io.coil-kt.coil3:coil-compose")).using(project(":coil:coil-compose"))
|
||||
substitute(module("io.coil-kt.coil3:coil-svg")).using(project(":coil:coil-svg"))
|
||||
substitute(module("io.coil-kt.coil3:coil-network-core")).using(project(":coil:coil-network-core"))
|
||||
substitute(module("io.coil-kt.coil3:coil-network-ktor3")).using(project(":coil:coil-network-ktor3"))
|
||||
// Pulse MVI: upstream publishes com.bitsycore.lib:* with no desktop
|
||||
// native targets. Pulse is itself a com.bitsycore library, so the port's
|
||||
// republish is namespaced under THIS project rather than a bare
|
||||
// com.bitsycore.pulse, which would read like the upstream artifact. The
|
||||
// two coordinates never collide, and substitutes the official one here.
|
||||
substitute(module("com.bitsycore.lib:pulse")).using(project(":pulse:pulse"))
|
||||
substitute(module("com.bitsycore.lib:pulse-viewmodel")).using(project(":pulse:pulse-viewmodel"))
|
||||
substitute(module("com.bitsycore.lib:pulse-savedstate")).using(project(":pulse:pulse-savedstate"))
|
||||
substitute(module("com.bitsycore.lib:pulse-compose")).using(project(":pulse:pulse-compose"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the current host can build the mingwX64 target. Kotlin/Native can
|
||||
// only cross-compile mingwX64 cinterops from a Windows host — the sdl3_ttf /
|
||||
// sdl3_image cinterops need Windows SDL3 headers under libs/ (produced by
|
||||
// scripts/build-sdl/build-all.py run on a Windows host). Declaring `mingwX64()` on
|
||||
// a non-Windows host is safe for pure-Kotlin modules but blows up the moment
|
||||
// a `depends = sdl3` cinterop tries to include SDL3_ttf/SDL_ttf.h.
|
||||
// only cross-compile the mingwX64 sdl3 cinterop from a Windows host - it needs
|
||||
// the Windows SDL3 headers under libs/ (produced by scripts/build-sdl/build-all.py
|
||||
// run on a Windows host). Declaring `mingwX64()` on a non-Windows host is safe
|
||||
// for pure-Kotlin modules but blows up the moment the sdl3 cinterop tries to
|
||||
// include SDL3's headers.
|
||||
// Override with `-PforceMingw=true` if you actually have the headers wired.
|
||||
val vHostSupportsMingw = System.getProperty("os.name").startsWith("Windows") ||
|
||||
(findProperty("forceMingw") as? String)?.toBoolean() == true
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Subset each bundled Material Symbols font to the glyphs the sources reference
|
||||
* (scripts/subset-material-symbols.py picks the codepoints; the subset task uses
|
||||
* hb-subset when present, else fontTools via Python - auto-installed - and only
|
||||
* bundles the full font if neither is available). 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(":utils: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(":utils: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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset inStyle's downloaded TTF to the codepoints in usage-codepoint.txt, into this
|
||||
* app's build/icons/. Prefers hb-subset (harfbuzz); falls back to fontTools via Python
|
||||
* (variable-font-aware, auto-installed via pip) when hb-subset is absent; bundles the
|
||||
* full font only if neither subsetter is available.
|
||||
*/
|
||||
private fun Project.registerSubsetTask(
|
||||
inStyle: String,
|
||||
inIconsBuildDir: Provider<Directory>,
|
||||
inFindUsage: TaskProvider<*>,
|
||||
): TaskProvider<*> {
|
||||
val vSymbolsProject = rootProject.project(":utils: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()
|
||||
// project.exec isn't usable inside doLast on Gradle 9, so shell out via
|
||||
// ProcessBuilder. Returns (exitedZero, combinedOutput); a missing binary
|
||||
// (IOException) yields (false, message) so callers can fall through.
|
||||
fun runCmd(vararg inCmd: String): Pair<Boolean, String> =
|
||||
try {
|
||||
val vProc = ProcessBuilder(*inCmd).redirectErrorStream(true).start()
|
||||
val vText = vProc.inputStream.bufferedReader().readText()
|
||||
(vProc.waitFor() == 0) to vText
|
||||
} catch (e: java.io.IOException) {
|
||||
false to (e.message ?: "not found")
|
||||
}
|
||||
|
||||
fun logShrink(inTool: String) {
|
||||
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 [$inTool]"
|
||||
)
|
||||
}
|
||||
|
||||
// 1. hb-subset (harfbuzz) - fastest path when it is on PATH.
|
||||
if (runCmd("hb-subset", vInputFile.absolutePath, "-o", vOut.absolutePath, "--unicodes=$vUnicodes").first
|
||||
&& vOut.length() > 0L
|
||||
) {
|
||||
logShrink("hb-subset"); return@doLast
|
||||
}
|
||||
|
||||
// 2. fontTools (pyftsubset) via Python - the portable fallback. Python is
|
||||
// already a build prerequisite (SDL build + vendor sync) and fontTools is
|
||||
// variable-font-aware, so the FILL/wght/GRAD/opsz axes survive. It is
|
||||
// auto-installed if missing, so no manual harfbuzz install is ever required.
|
||||
val vPython = listOf("python3", "python", "py").firstOrNull { runCmd(it, "--version").first }
|
||||
if (vPython != null) {
|
||||
if (!runCmd(vPython, "-c", "import fontTools").first) {
|
||||
logger.lifecycle("[subset $inStyle] fontTools not present - installing it via pip…")
|
||||
// --user (no root) → --break-system-packages (PEP 668) → plain.
|
||||
listOf(listOf("--user"), listOf("--break-system-packages"), emptyList<String>())
|
||||
.firstOrNull { vExtra ->
|
||||
runCmd(
|
||||
vPython, "-m", "pip", "install", "-q", "--disable-pip-version-check",
|
||||
*vExtra.toTypedArray(), "fonttools"
|
||||
).first
|
||||
}
|
||||
}
|
||||
val (vFtOk, vFtOut) = runCmd(
|
||||
vPython, "-m", "fontTools.subset", vInputFile.absolutePath,
|
||||
"--unicodes=$vUnicodes", "--output-file=${vOut.absolutePath}"
|
||||
)
|
||||
if (vFtOk && vOut.length() > 0L) { logShrink("fontTools"); return@doLast }
|
||||
logger.warn("[subset $inStyle] fontTools subset failed :\n$vFtOut")
|
||||
}
|
||||
|
||||
// 3. Last resort : the full font - correct, just larger.
|
||||
vInputFile.copyTo(vOut, overwrite = true)
|
||||
logger.warn(
|
||||
"[subset $inStyle] no working subsetter found (hb-subset or python3+fontTools) : " +
|
||||
"bundling the full font (${vBefore / 1024}KB). Install harfbuzz " +
|
||||
"(brew install harfbuzz / apt install harfbuzz-utils / " +
|
||||
"pacman -S mingw-w64-x86_64-harfbuzz) or put python3 on PATH."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.coil3:coil-compose-core>
|
||||
abstract fun interface coil3.compose/AsyncImagePreviewHandler { // coil3.compose/AsyncImagePreviewHandler|null[0]
|
||||
abstract suspend fun handle(coil3/ImageLoader, coil3.request/ImageRequest): coil3.compose/AsyncImagePainter.State // coil3.compose/AsyncImagePreviewHandler.handle|handle(coil3.ImageLoader;coil3.request.ImageRequest){}[0]
|
||||
|
||||
final object Companion { // coil3.compose/AsyncImagePreviewHandler.Companion|null[0]
|
||||
final val Default // coil3.compose/AsyncImagePreviewHandler.Companion.Default|{}Default[0]
|
||||
final fun <get-Default>(): coil3.compose/AsyncImagePreviewHandler // coil3.compose/AsyncImagePreviewHandler.Companion.Default.<get-Default>|<get-Default>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface coil3.compose/AsyncImageModelEqualityDelegate { // coil3.compose/AsyncImageModelEqualityDelegate|null[0]
|
||||
abstract fun equals(kotlin/Any?, kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImageModelEqualityDelegate.equals|equals(kotlin.Any?;kotlin.Any?){}[0]
|
||||
abstract fun hashCode(kotlin/Any?): kotlin/Int // coil3.compose/AsyncImageModelEqualityDelegate.hashCode|hashCode(kotlin.Any?){}[0]
|
||||
|
||||
final object Companion { // coil3.compose/AsyncImageModelEqualityDelegate.Companion|null[0]
|
||||
final val AllProperties // coil3.compose/AsyncImageModelEqualityDelegate.Companion.AllProperties|{}AllProperties[0]
|
||||
final fun <get-AllProperties>(): coil3.compose/AsyncImageModelEqualityDelegate // coil3.compose/AsyncImageModelEqualityDelegate.Companion.AllProperties.<get-AllProperties>|<get-AllProperties>(){}[0]
|
||||
final val Default // coil3.compose/AsyncImageModelEqualityDelegate.Companion.Default|{}Default[0]
|
||||
final fun <get-Default>(): coil3.compose/AsyncImageModelEqualityDelegate // coil3.compose/AsyncImageModelEqualityDelegate.Companion.Default.<get-Default>|<get-Default>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface coil3.compose/DrawScopeSizeResolver : coil3.size/SizeResolver { // coil3.compose/DrawScopeSizeResolver|null[0]
|
||||
abstract fun connect(kotlinx.coroutines.flow/Flow<androidx.compose.ui.geometry/Size>) // coil3.compose/DrawScopeSizeResolver.connect|connect(kotlinx.coroutines.flow.Flow<androidx.compose.ui.geometry.Size>){}[0]
|
||||
}
|
||||
|
||||
abstract interface coil3.compose/SubcomposeAsyncImageScope : androidx.compose.foundation.layout/BoxScope { // coil3.compose/SubcomposeAsyncImageScope|null[0]
|
||||
abstract val alignment // coil3.compose/SubcomposeAsyncImageScope.alignment|{}alignment[0]
|
||||
abstract fun <get-alignment>(): androidx.compose.ui/Alignment // coil3.compose/SubcomposeAsyncImageScope.alignment.<get-alignment>|<get-alignment>(){}[0]
|
||||
abstract val alpha // coil3.compose/SubcomposeAsyncImageScope.alpha|{}alpha[0]
|
||||
abstract fun <get-alpha>(): kotlin/Float // coil3.compose/SubcomposeAsyncImageScope.alpha.<get-alpha>|<get-alpha>(){}[0]
|
||||
abstract val clipToBounds // coil3.compose/SubcomposeAsyncImageScope.clipToBounds|{}clipToBounds[0]
|
||||
abstract fun <get-clipToBounds>(): kotlin/Boolean // coil3.compose/SubcomposeAsyncImageScope.clipToBounds.<get-clipToBounds>|<get-clipToBounds>(){}[0]
|
||||
abstract val colorFilter // coil3.compose/SubcomposeAsyncImageScope.colorFilter|{}colorFilter[0]
|
||||
abstract fun <get-colorFilter>(): androidx.compose.ui.graphics/ColorFilter? // coil3.compose/SubcomposeAsyncImageScope.colorFilter.<get-colorFilter>|<get-colorFilter>(){}[0]
|
||||
abstract val contentDescription // coil3.compose/SubcomposeAsyncImageScope.contentDescription|{}contentDescription[0]
|
||||
abstract fun <get-contentDescription>(): kotlin/String? // coil3.compose/SubcomposeAsyncImageScope.contentDescription.<get-contentDescription>|<get-contentDescription>(){}[0]
|
||||
abstract val contentScale // coil3.compose/SubcomposeAsyncImageScope.contentScale|{}contentScale[0]
|
||||
abstract fun <get-contentScale>(): androidx.compose.ui.layout/ContentScale // coil3.compose/SubcomposeAsyncImageScope.contentScale.<get-contentScale>|<get-contentScale>(){}[0]
|
||||
abstract val painter // coil3.compose/SubcomposeAsyncImageScope.painter|{}painter[0]
|
||||
abstract fun <get-painter>(): coil3.compose/AsyncImagePainter // coil3.compose/SubcomposeAsyncImageScope.painter.<get-painter>|<get-painter>(){}[0]
|
||||
}
|
||||
|
||||
final class coil3.compose/AsyncImagePainter : androidx.compose.runtime/RememberObserver, androidx.compose.ui.graphics.painter/Painter { // coil3.compose/AsyncImagePainter|null[0]
|
||||
final val input // coil3.compose/AsyncImagePainter.input|{}input[0]
|
||||
final fun <get-input>(): kotlinx.coroutines.flow/StateFlow<coil3.compose/AsyncImagePainter.Input> // coil3.compose/AsyncImagePainter.input.<get-input>|<get-input>(){}[0]
|
||||
final val intrinsicSize // coil3.compose/AsyncImagePainter.intrinsicSize|{}intrinsicSize[0]
|
||||
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // coil3.compose/AsyncImagePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
|
||||
final val state // coil3.compose/AsyncImagePainter.state|{}state[0]
|
||||
final fun <get-state>(): kotlinx.coroutines.flow/StateFlow<coil3.compose/AsyncImagePainter.State> // coil3.compose/AsyncImagePainter.state.<get-state>|<get-state>(){}[0]
|
||||
|
||||
final fun onAbandoned() // coil3.compose/AsyncImagePainter.onAbandoned|onAbandoned(){}[0]
|
||||
final fun onForgotten() // coil3.compose/AsyncImagePainter.onForgotten|onForgotten(){}[0]
|
||||
final fun onRemembered() // coil3.compose/AsyncImagePainter.onRemembered|onRemembered(){}[0]
|
||||
final fun restart() // coil3.compose/AsyncImagePainter.restart|restart(){}[0]
|
||||
|
||||
sealed interface State { // coil3.compose/AsyncImagePainter.State|null[0]
|
||||
abstract val painter // coil3.compose/AsyncImagePainter.State.painter|{}painter[0]
|
||||
abstract fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.painter.<get-painter>|<get-painter>(){}[0]
|
||||
|
||||
final class Error : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Error|null[0]
|
||||
constructor <init>(androidx.compose.ui.graphics.painter/Painter?, coil3.request/ErrorResult) // coil3.compose/AsyncImagePainter.State.Error.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?;coil3.request.ErrorResult){}[0]
|
||||
|
||||
final val painter // coil3.compose/AsyncImagePainter.State.Error.painter|{}painter[0]
|
||||
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Error.painter.<get-painter>|<get-painter>(){}[0]
|
||||
final val result // coil3.compose/AsyncImagePainter.State.Error.result|{}result[0]
|
||||
final fun <get-result>(): coil3.request/ErrorResult // coil3.compose/AsyncImagePainter.State.Error.result.<get-result>|<get-result>(){}[0]
|
||||
|
||||
final fun component1(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Error.component1|component1(){}[0]
|
||||
final fun component2(): coil3.request/ErrorResult // coil3.compose/AsyncImagePainter.State.Error.component2|component2(){}[0]
|
||||
final fun copy(androidx.compose.ui.graphics.painter/Painter? = ..., coil3.request/ErrorResult = ...): coil3.compose/AsyncImagePainter.State.Error // coil3.compose/AsyncImagePainter.State.Error.copy|copy(androidx.compose.ui.graphics.painter.Painter?;coil3.request.ErrorResult){}[0]
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Error.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Error.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Error.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final class Loading : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Loading|null[0]
|
||||
constructor <init>(androidx.compose.ui.graphics.painter/Painter?) // coil3.compose/AsyncImagePainter.State.Loading.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?){}[0]
|
||||
|
||||
final val painter // coil3.compose/AsyncImagePainter.State.Loading.painter|{}painter[0]
|
||||
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Loading.painter.<get-painter>|<get-painter>(){}[0]
|
||||
|
||||
final fun component1(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Loading.component1|component1(){}[0]
|
||||
final fun copy(androidx.compose.ui.graphics.painter/Painter? = ...): coil3.compose/AsyncImagePainter.State.Loading // coil3.compose/AsyncImagePainter.State.Loading.copy|copy(androidx.compose.ui.graphics.painter.Painter?){}[0]
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Loading.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Loading.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Loading.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final class Success : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Success|null[0]
|
||||
constructor <init>(androidx.compose.ui.graphics.painter/Painter, coil3.request/SuccessResult) // coil3.compose/AsyncImagePainter.State.Success.<init>|<init>(androidx.compose.ui.graphics.painter.Painter;coil3.request.SuccessResult){}[0]
|
||||
|
||||
final val painter // coil3.compose/AsyncImagePainter.State.Success.painter|{}painter[0]
|
||||
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter // coil3.compose/AsyncImagePainter.State.Success.painter.<get-painter>|<get-painter>(){}[0]
|
||||
final val result // coil3.compose/AsyncImagePainter.State.Success.result|{}result[0]
|
||||
final fun <get-result>(): coil3.request/SuccessResult // coil3.compose/AsyncImagePainter.State.Success.result.<get-result>|<get-result>(){}[0]
|
||||
|
||||
final fun component1(): androidx.compose.ui.graphics.painter/Painter // coil3.compose/AsyncImagePainter.State.Success.component1|component1(){}[0]
|
||||
final fun component2(): coil3.request/SuccessResult // coil3.compose/AsyncImagePainter.State.Success.component2|component2(){}[0]
|
||||
final fun copy(androidx.compose.ui.graphics.painter/Painter = ..., coil3.request/SuccessResult = ...): coil3.compose/AsyncImagePainter.State.Success // coil3.compose/AsyncImagePainter.State.Success.copy|copy(androidx.compose.ui.graphics.painter.Painter;coil3.request.SuccessResult){}[0]
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Success.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Success.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Success.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final object Empty : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Empty|null[0]
|
||||
final val painter // coil3.compose/AsyncImagePainter.State.Empty.painter|{}painter[0]
|
||||
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Empty.painter.<get-painter>|<get-painter>(){}[0]
|
||||
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Empty.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Empty.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Empty.toString|toString(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final class Input { // coil3.compose/AsyncImagePainter.Input|null[0]
|
||||
constructor <init>(coil3/ImageLoader, coil3.request/ImageRequest, coil3.compose/AsyncImageModelEqualityDelegate) // coil3.compose/AsyncImagePainter.Input.<init>|<init>(coil3.ImageLoader;coil3.request.ImageRequest;coil3.compose.AsyncImageModelEqualityDelegate){}[0]
|
||||
|
||||
final val imageLoader // coil3.compose/AsyncImagePainter.Input.imageLoader|{}imageLoader[0]
|
||||
final fun <get-imageLoader>(): coil3/ImageLoader // coil3.compose/AsyncImagePainter.Input.imageLoader.<get-imageLoader>|<get-imageLoader>(){}[0]
|
||||
final val modelEqualityDelegate // coil3.compose/AsyncImagePainter.Input.modelEqualityDelegate|{}modelEqualityDelegate[0]
|
||||
final fun <get-modelEqualityDelegate>(): coil3.compose/AsyncImageModelEqualityDelegate // coil3.compose/AsyncImagePainter.Input.modelEqualityDelegate.<get-modelEqualityDelegate>|<get-modelEqualityDelegate>(){}[0]
|
||||
final val request // coil3.compose/AsyncImagePainter.Input.request|{}request[0]
|
||||
final fun <get-request>(): coil3.request/ImageRequest // coil3.compose/AsyncImagePainter.Input.request.<get-request>|<get-request>(){}[0]
|
||||
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.Input.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.Input.hashCode|hashCode(){}[0]
|
||||
}
|
||||
|
||||
final object Companion { // coil3.compose/AsyncImagePainter.Companion|null[0]
|
||||
final val DefaultTransform // coil3.compose/AsyncImagePainter.Companion.DefaultTransform|{}DefaultTransform[0]
|
||||
final fun <get-DefaultTransform>(): kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State> // coil3.compose/AsyncImagePainter.Companion.DefaultTransform.<get-DefaultTransform>|<get-DefaultTransform>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final class coil3.compose/ConstraintsSizeResolver : androidx.compose.ui.layout/LayoutModifier, coil3.size/SizeResolver { // coil3.compose/ConstraintsSizeResolver|null[0]
|
||||
constructor <init>() // coil3.compose/ConstraintsSizeResolver.<init>|<init>(){}[0]
|
||||
|
||||
final fun (androidx.compose.ui.layout/MeasureScope).measure(androidx.compose.ui.layout/Measurable, androidx.compose.ui.unit/Constraints): androidx.compose.ui.layout/MeasureResult // coil3.compose/ConstraintsSizeResolver.measure|measure@androidx.compose.ui.layout.MeasureScope(androidx.compose.ui.layout.Measurable;androidx.compose.ui.unit.Constraints){}[0]
|
||||
final fun setConstraints(androidx.compose.ui.unit/Constraints) // coil3.compose/ConstraintsSizeResolver.setConstraints|setConstraints(androidx.compose.ui.unit.Constraints){}[0]
|
||||
final suspend fun size(): coil3.size/Size // coil3.compose/ConstraintsSizeResolver.size|size(){}[0]
|
||||
}
|
||||
|
||||
final class coil3.compose/CrossfadePainter : androidx.compose.ui.graphics.painter/Painter { // coil3.compose/CrossfadePainter|null[0]
|
||||
constructor <init>(androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.layout/ContentScale = ..., kotlin.time/Duration = ..., kotlin.time/TimeSource = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.compose/CrossfadePainter.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.layout.ContentScale;kotlin.time.Duration;kotlin.time.TimeSource;kotlin.Boolean;kotlin.Boolean){}[0]
|
||||
constructor <init>(androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.layout/ContentScale = ..., kotlin.time/Duration = ..., kotlin.time/TimeSource = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.compose/CrossfadePainter.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.layout.ContentScale;kotlin.time.Duration;kotlin.time.TimeSource;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
|
||||
|
||||
final val contentScale // coil3.compose/CrossfadePainter.contentScale|{}contentScale[0]
|
||||
final fun <get-contentScale>(): androidx.compose.ui.layout/ContentScale // coil3.compose/CrossfadePainter.contentScale.<get-contentScale>|<get-contentScale>(){}[0]
|
||||
final val duration // coil3.compose/CrossfadePainter.duration|{}duration[0]
|
||||
final fun <get-duration>(): kotlin.time/Duration // coil3.compose/CrossfadePainter.duration.<get-duration>|<get-duration>(){}[0]
|
||||
final val end // coil3.compose/CrossfadePainter.end|{}end[0]
|
||||
final fun <get-end>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/CrossfadePainter.end.<get-end>|<get-end>(){}[0]
|
||||
final val fadeStart // coil3.compose/CrossfadePainter.fadeStart|{}fadeStart[0]
|
||||
final fun <get-fadeStart>(): kotlin/Boolean // coil3.compose/CrossfadePainter.fadeStart.<get-fadeStart>|<get-fadeStart>(){}[0]
|
||||
final val intrinsicSize // coil3.compose/CrossfadePainter.intrinsicSize|{}intrinsicSize[0]
|
||||
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // coil3.compose/CrossfadePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
|
||||
final val preferEndFirstIntrinsicSize // coil3.compose/CrossfadePainter.preferEndFirstIntrinsicSize|{}preferEndFirstIntrinsicSize[0]
|
||||
final fun <get-preferEndFirstIntrinsicSize>(): kotlin/Boolean // coil3.compose/CrossfadePainter.preferEndFirstIntrinsicSize.<get-preferEndFirstIntrinsicSize>|<get-preferEndFirstIntrinsicSize>(){}[0]
|
||||
final val preferExactIntrinsicSize // coil3.compose/CrossfadePainter.preferExactIntrinsicSize|{}preferExactIntrinsicSize[0]
|
||||
final fun <get-preferExactIntrinsicSize>(): kotlin/Boolean // coil3.compose/CrossfadePainter.preferExactIntrinsicSize.<get-preferExactIntrinsicSize>|<get-preferExactIntrinsicSize>(){}[0]
|
||||
final val timeSource // coil3.compose/CrossfadePainter.timeSource|{}timeSource[0]
|
||||
final fun <get-timeSource>(): kotlin.time/TimeSource // coil3.compose/CrossfadePainter.timeSource.<get-timeSource>|<get-timeSource>(){}[0]
|
||||
|
||||
final var start // coil3.compose/CrossfadePainter.start|{}start[0]
|
||||
final fun <get-start>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/CrossfadePainter.start.<get-start>|<get-start>(){}[0]
|
||||
}
|
||||
|
||||
final class coil3.compose/ImagePainter : androidx.compose.ui.graphics.painter/Painter { // coil3.compose/ImagePainter|null[0]
|
||||
constructor <init>(coil3/Image) // coil3.compose/ImagePainter.<init>|<init>(coil3.Image){}[0]
|
||||
|
||||
final val image // coil3.compose/ImagePainter.image|{}image[0]
|
||||
final fun <get-image>(): coil3/Image // coil3.compose/ImagePainter.image.<get-image>|<get-image>(){}[0]
|
||||
final val intrinsicSize // coil3.compose/ImagePainter.intrinsicSize|{}intrinsicSize[0]
|
||||
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // coil3.compose/ImagePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
|
||||
}
|
||||
|
||||
final val coil3.compose/LocalAsyncImageModelEqualityDelegate // coil3.compose/LocalAsyncImageModelEqualityDelegate|{}LocalAsyncImageModelEqualityDelegate[0]
|
||||
final fun <get-LocalAsyncImageModelEqualityDelegate>(): androidx.compose.runtime/ProvidableCompositionLocal<coil3.compose/AsyncImageModelEqualityDelegate> // coil3.compose/LocalAsyncImageModelEqualityDelegate.<get-LocalAsyncImageModelEqualityDelegate>|<get-LocalAsyncImageModelEqualityDelegate>(){}[0]
|
||||
final val coil3.compose/LocalAsyncImagePreviewHandler // coil3.compose/LocalAsyncImagePreviewHandler|{}LocalAsyncImagePreviewHandler[0]
|
||||
final fun <get-LocalAsyncImagePreviewHandler>(): androidx.compose.runtime/ProvidableCompositionLocal<coil3.compose/AsyncImagePreviewHandler> // coil3.compose/LocalAsyncImagePreviewHandler.<get-LocalAsyncImagePreviewHandler>|<get-LocalAsyncImagePreviewHandler>(){}[0]
|
||||
final val coil3.compose/LocalPlatformContext // coil3.compose/LocalPlatformContext|{}LocalPlatformContext[0]
|
||||
final fun <get-LocalPlatformContext>(): androidx.compose.runtime/ProvidableCompositionLocal<coil3/PlatformContext> // coil3.compose/LocalPlatformContext.<get-LocalPlatformContext>|<get-LocalPlatformContext>(){}[0]
|
||||
final val coil3.compose/coil3_compose_AsyncImagePainter$stableprop // coil3.compose/coil3_compose_AsyncImagePainter$stableprop|#static{}coil3_compose_AsyncImagePainter$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop|#static{}coil3_compose_AsyncImagePainter_Input$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Empty$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Error$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Loading$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Success$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop // coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop|#static{}coil3_compose_ConstraintsSizeResolver$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_CrossfadePainter$stableprop // coil3.compose/coil3_compose_CrossfadePainter$stableprop|#static{}coil3_compose_CrossfadePainter$stableprop[0]
|
||||
final val coil3.compose/coil3_compose_ImagePainter$stableprop // coil3.compose/coil3_compose_ImagePainter$stableprop|#static{}coil3_compose_ImagePainter$stableprop[0]
|
||||
final val coil3.compose/preferEndFirstIntrinsicSize // coil3.compose/preferEndFirstIntrinsicSize|@coil3.Extras.Key.Companion{}preferEndFirstIntrinsicSize[0]
|
||||
final fun (coil3/Extras.Key.Companion).<get-preferEndFirstIntrinsicSize>(): coil3/Extras.Key<kotlin/Boolean> // coil3.compose/preferEndFirstIntrinsicSize.<get-preferEndFirstIntrinsicSize>|<get-preferEndFirstIntrinsicSize>@coil3.Extras.Key.Companion(){}[0]
|
||||
final val coil3.compose/preferEndFirstIntrinsicSize // coil3.compose/preferEndFirstIntrinsicSize|@coil3.request.ImageRequest{}preferEndFirstIntrinsicSize[0]
|
||||
final fun (coil3.request/ImageRequest).<get-preferEndFirstIntrinsicSize>(): kotlin/Boolean // coil3.compose/preferEndFirstIntrinsicSize.<get-preferEndFirstIntrinsicSize>|<get-preferEndFirstIntrinsicSize>@coil3.request.ImageRequest(){}[0]
|
||||
final val coil3.compose/useExistingImageAsPlaceholder // coil3.compose/useExistingImageAsPlaceholder|@coil3.Extras.Key.Companion{}useExistingImageAsPlaceholder[0]
|
||||
final fun (coil3/Extras.Key.Companion).<get-useExistingImageAsPlaceholder>(): coil3/Extras.Key<kotlin/Boolean> // coil3.compose/useExistingImageAsPlaceholder.<get-useExistingImageAsPlaceholder>|<get-useExistingImageAsPlaceholder>@coil3.Extras.Key.Companion(){}[0]
|
||||
final val coil3.compose/useExistingImageAsPlaceholder // coil3.compose/useExistingImageAsPlaceholder|@coil3.request.ImageRequest{}useExistingImageAsPlaceholder[0]
|
||||
final fun (coil3.request/ImageRequest).<get-useExistingImageAsPlaceholder>(): kotlin/Boolean // coil3.compose/useExistingImageAsPlaceholder.<get-useExistingImageAsPlaceholder>|<get-useExistingImageAsPlaceholder>@coil3.request.ImageRequest(){}[0]
|
||||
|
||||
final fun (coil3.compose/SubcomposeAsyncImageScope).coil3.compose/SubcomposeAsyncImageContent(androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, kotlin/String?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImageContent|SubcomposeAsyncImageContent@coil3.compose.SubcomposeAsyncImageScope(androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;kotlin.String?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun (coil3.request/ImageRequest.Builder).coil3.compose/preferEndFirstIntrinsicSize(kotlin/Boolean): coil3.request/ImageRequest.Builder // coil3.compose/preferEndFirstIntrinsicSize|preferEndFirstIntrinsicSize@coil3.request.ImageRequest.Builder(kotlin.Boolean){}[0]
|
||||
final fun (coil3.request/ImageRequest.Builder).coil3.compose/useExistingImageAsPlaceholder(kotlin/Boolean): coil3.request/ImageRequest.Builder // coil3.compose/useExistingImageAsPlaceholder|useExistingImageAsPlaceholder@coil3.request.ImageRequest.Builder(kotlin.Boolean){}[0]
|
||||
final fun (coil3/Image).coil3.compose/asPainter(coil3/PlatformContext, androidx.compose.ui.graphics/FilterQuality = ...): androidx.compose.ui.graphics.painter/Painter // coil3.compose/asPainter|asPainter@coil3.Image(coil3.PlatformContext;androidx.compose.ui.graphics.FilterQuality){}[0]
|
||||
final fun (coil3/ImageLoader.Builder).coil3.compose/preferEndFirstIntrinsicSize(kotlin/Boolean): coil3/ImageLoader.Builder // coil3.compose/preferEndFirstIntrinsicSize|preferEndFirstIntrinsicSize@coil3.ImageLoader.Builder(kotlin.Boolean){}[0]
|
||||
final fun (coil3/ImageLoader.Builder).coil3.compose/useExistingImageAsPlaceholder(kotlin/Boolean): coil3/ImageLoader.Builder // coil3.compose/useExistingImageAsPlaceholder|useExistingImageAsPlaceholder@coil3.ImageLoader.Builder(kotlin.Boolean){}[0]
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/DrawScopeSizeResolver(): coil3.compose/DrawScopeSizeResolver // coil3.compose/DrawScopeSizeResolver|DrawScopeSizeResolver(){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/coil3_compose_AsyncImagePainter$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter$stableprop_getter|coil3_compose_AsyncImagePainter$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop_getter|coil3_compose_AsyncImagePainter_Input$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop_getter|coil3_compose_AsyncImagePainter_State_Empty$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop_getter|coil3_compose_AsyncImagePainter_State_Error$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop_getter|coil3_compose_AsyncImagePainter_State_Loading$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop_getter|coil3_compose_AsyncImagePainter_State_Success$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop_getter|coil3_compose_ConstraintsSizeResolver$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_CrossfadePainter$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_CrossfadePainter$stableprop_getter|coil3_compose_CrossfadePainter$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/coil3_compose_ImagePainter$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_ImagePainter$stableprop_getter|coil3_compose_ImagePainter$stableprop_getter(){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberConstraintsSizeResolver(androidx.compose.runtime/Composer?, kotlin/Int): coil3.compose/ConstraintsSizeResolver // coil3.compose/rememberConstraintsSizeResolver|rememberConstraintsSizeResolver(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberDrawScopeSizeResolver(androidx.compose.runtime/Composer?, kotlin/Int): coil3.compose/DrawScopeSizeResolver // coil3.compose/rememberDrawScopeSizeResolver|rememberDrawScopeSizeResolver(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
final inline fun coil3.compose/AsyncImagePreviewHandler(crossinline kotlin.coroutines/SuspendFunction1<coil3.request/ImageRequest, coil3/Image>): coil3.compose/AsyncImagePreviewHandler // coil3.compose/AsyncImagePreviewHandler|AsyncImagePreviewHandler(kotlin.coroutines.SuspendFunction1<coil3.request.ImageRequest,coil3.Image>){}[0]
|
||||
final inline fun coil3.compose/AsyncImagePreviewHandler(crossinline kotlin.coroutines/SuspendFunction1<coil3.request/ImageRequest, coil3/Image?>): coil3.compose/AsyncImagePreviewHandler // coil3.compose/AsyncImagePreviewHandler|AsyncImagePreviewHandler(kotlin.coroutines.SuspendFunction1<coil3.request.ImageRequest,coil3.Image?>){}[0]
|
||||
@@ -0,0 +1,69 @@
|
||||
// :coil:coil-compose-core - AsyncImage / rememberAsyncImagePainter internals, vendored
|
||||
// VERBATIM from coil-kt/coil. Upstream publishes NO desktop-native klibs for the
|
||||
// compose layer at all (neither mingwX64 nor linux), so this whole module exists to
|
||||
// give the port its targets. Compose coords substitute to the port's project modules
|
||||
// on every native configuration (root build's commonization bridge).
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api(project(":coil:coil-core"))
|
||||
// Declared DIRECTLY, not leaned on transitively: these coords are
|
||||
// SUBSTITUTED to the port's project modules on native configurations,
|
||||
// and KGP's granular-metadata visibility rule then drops the substituted
|
||||
// module's transitives from the commonMain metadata classpath (CLAUDE.md
|
||||
// "Common pitfalls"). Every artifact the vendored common code touches
|
||||
// needs its own declaration + its own bridge rule.
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
api("org.jetbrains.compose.ui:ui:${libs.versions.compose.get()}")
|
||||
api("org.jetbrains.compose.ui:ui-graphics:${libs.versions.compose.get()}")
|
||||
api("org.jetbrains.compose.ui:ui-text:${libs.versions.compose.get()}")
|
||||
api("org.jetbrains.compose.ui:ui-unit:${libs.versions.compose.get()}")
|
||||
api("org.jetbrains.compose.ui:ui-geometry:${libs.versions.compose.get()}")
|
||||
api("org.jetbrains.compose.ui:ui-util:${libs.versions.compose.get()}")
|
||||
api("org.jetbrains.compose.foundation:foundation:${libs.versions.compose.get()}")
|
||||
api("org.jetbrains.compose.foundation:foundation-layout:${libs.versions.compose.get()}")
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
// Upstream's nonAndroid compose layer reaches straight into org.jetbrains.skia
|
||||
// (Canvas / Bitmap.asComposeImageBitmap), so it needs skiko on its own compile
|
||||
// classpath - official for macOS/Linux, the bitsycore FORK for mingwX64, on two
|
||||
// PARALLEL branches. See coil/coil-core/build.gradle.kts.
|
||||
fun nonAndroidBranch(inName: String, inSkiko: Any) = create("${inName}NonAndroidMain") {
|
||||
dependsOn(commonMain.get())
|
||||
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
|
||||
dependencies { implementation(inSkiko) }
|
||||
}
|
||||
|
||||
val vOfficial = nonAndroidBranch("coilComposeOfficial", libs.skiko)
|
||||
get("macosArm64Main").dependsOn(vOfficial)
|
||||
get("linuxX64Main").dependsOn(vOfficial)
|
||||
get("linuxArm64Main").dependsOn(vOfficial)
|
||||
|
||||
if (vHostSupportsMingw) {
|
||||
val vFork = nonAndroidBranch(
|
||||
"coilComposeFork",
|
||||
"com.bitsycore.skiko:skiko:${providers.gradleProperty("skikoMingwVersion").getOrElse(libs.versions.skikoMingw.get())}",
|
||||
)
|
||||
get("mingwX64Main").dependsOn(vFork)
|
||||
}
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-opt-in=coil3.annotation.InternalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.DelicateCoilApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# :coil:coil-compose-core - AsyncImage / rememberAsyncImagePainter internals.
|
||||
# Upstream publishes NEITHER mingwX64 NOR linux for the compose layer.
|
||||
# Upstream source sets collapse onto this port: it has NO android, jvm or js
|
||||
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
|
||||
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
|
||||
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
|
||||
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
|
||||
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
|
||||
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
|
||||
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
|
||||
# hierarchy template).
|
||||
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
|
||||
|
||||
SET_FOLDER=coil-compose-core/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/coil3/compose/AsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/AsyncImage.kt
|
||||
# | commonMain/kotlin/coil3/compose/AsyncImagePainter.kt -> src/vendor/common/kotlin/coil3/compose/AsyncImagePainter.kt
|
||||
# | commonMain/kotlin/coil3/compose/ConstraintsSizeResolver.kt -> src/vendor/common/kotlin/coil3/compose/ConstraintsSizeResolver.kt
|
||||
# | commonMain/kotlin/coil3/compose/CrossfadePainter.kt -> src/vendor/common/kotlin/coil3/compose/CrossfadePainter.kt
|
||||
# | commonMain/kotlin/coil3/compose/DrawScopeSizeResolver.kt -> src/vendor/common/kotlin/coil3/compose/DrawScopeSizeResolver.kt
|
||||
# | commonMain/kotlin/coil3/compose/ImagePainter.kt -> src/vendor/common/kotlin/coil3/compose/ImagePainter.kt
|
||||
# | commonMain/kotlin/coil3/compose/LocalAsyncImageModelEqualityDelegate.kt -> src/vendor/common/kotlin/coil3/compose/LocalAsyncImageModelEqualityDelegate.kt
|
||||
# | commonMain/kotlin/coil3/compose/LocalAsyncImagePreviewHandler.kt -> src/vendor/common/kotlin/coil3/compose/LocalAsyncImagePreviewHandler.kt
|
||||
# | commonMain/kotlin/coil3/compose/LocalPlatformContext.kt -> src/vendor/common/kotlin/coil3/compose/LocalPlatformContext.kt
|
||||
# | commonMain/kotlin/coil3/compose/SubcomposeAsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/SubcomposeAsyncImage.kt
|
||||
# | commonMain/kotlin/coil3/compose/imageRequests.kt -> src/vendor/common/kotlin/coil3/compose/imageRequests.kt
|
||||
# | commonMain/kotlin/coil3/compose/internal/ContentPainterModifier.kt -> src/vendor/common/kotlin/coil3/compose/internal/ContentPainterModifier.kt
|
||||
# | commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt -> src/vendor/common/kotlin/coil3/compose/internal/DeferredDispatch.kt
|
||||
# | commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt -> src/vendor/common/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt
|
||||
# | commonMain/kotlin/coil3/compose/internal/utils.kt -> src/vendor/common/kotlin/coil3/compose/internal/utils.kt
|
||||
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonAndroidMain/kotlin/coil3/compose/AsyncImagePainter.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/AsyncImagePainter.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/compose/ImagePainter.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/ImagePainter.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/compose/LocalPlatformContext.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/LocalPlatformContext.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/compose/internal/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/internal/utils.nonAndroid.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# androidMain (4)
|
||||
# | androidMain/kotlin/coil3/compose/AsyncImagePainter.android.kt -> src/vendor/android/kotlin/coil3/compose/AsyncImagePainter.android.kt
|
||||
# | androidMain/kotlin/coil3/compose/ImagePainter.android.kt -> src/vendor/android/kotlin/coil3/compose/ImagePainter.android.kt
|
||||
# | androidMain/kotlin/coil3/compose/LocalPlatformContext.android.kt -> src/vendor/android/kotlin/coil3/compose/LocalPlatformContext.android.kt
|
||||
# | androidMain/kotlin/coil3/compose/internal/utils.android.kt -> src/vendor/android/kotlin/coil3/compose/internal/utils.android.kt
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -0,0 +1,21 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.coil3:coil-compose>
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||
final fun coil3.compose/setSingletonImageLoaderFactory(kotlin/Function1<coil3/PlatformContext, coil3/ImageLoader>, androidx.compose.runtime/Composer?, kotlin/Int) // coil3.compose/setSingletonImageLoaderFactory|setSingletonImageLoaderFactory(kotlin.Function1<coil3.PlatformContext,coil3.ImageLoader>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
@@ -0,0 +1,31 @@
|
||||
// :coil:coil-compose - the public compose entry points (AsyncImage, SubcomposeAsyncImage),
|
||||
// vendored VERBATIM from coil-kt/coil. commonMain only. Upstream publishes no
|
||||
// desktop-native klibs for the compose layer.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api(project(":coil:coil"))
|
||||
api(project(":coil:coil-compose-core"))
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-opt-in=coil3.annotation.InternalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.DelicateCoilApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# :coil:coil-compose - the public compose entry points. commonMain only.
|
||||
# Upstream publishes NEITHER mingwX64 NOR linux for the compose layer.
|
||||
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
|
||||
|
||||
SET_FOLDER=coil-compose/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/coil3/compose/SingletonAsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/SingletonAsyncImage.kt
|
||||
# | commonMain/kotlin/coil3/compose/SingletonAsyncImagePainter.kt -> src/vendor/common/kotlin/coil3/compose/SingletonAsyncImagePainter.kt
|
||||
# | commonMain/kotlin/coil3/compose/SingletonSubcomposeAsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/SingletonSubcomposeAsyncImage.kt
|
||||
# | commonMain/kotlin/coil3/compose/singletonImageLoaders.kt -> src/vendor/common/kotlin/coil3/compose/singletonImageLoaders.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# (none - every source set under SET_FOLDER is vendored)
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -0,0 +1,90 @@
|
||||
// :coil:coil-core - coil3.* core, vendored VERBATIM from coil-kt/coil into src/vendor.
|
||||
//
|
||||
// Upstream publishes linux + apple klibs but NOT mingwX64, and the code is
|
||||
// target-agnostic, so the port mostly just re-declares the targets.
|
||||
//
|
||||
// SOURCE-SET COLLAPSE: this module has no android, jvm or js target, so upstream's
|
||||
// nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all apply to every
|
||||
// target here and are vendored into ONE tree (src/vendor/native). Only the
|
||||
// apple-vs-rest split survives: appleMain is macosArm64, nonAppleMain is linux+mingw.
|
||||
//
|
||||
// SKIA: upstream's non-android decoder is skiko-based (SkiaImageDecoder), which is
|
||||
// this port's renderer already. macOS/Linux take the official skiko; mingwX64 takes
|
||||
// the bitsycore FORK, the same swap :compose:ui:ui-graphics makes.
|
||||
//
|
||||
// Provenance = compose-fork.txt + scripts/compose-fork/compose.properties. Never
|
||||
// hand-edit src/vendor - change the manifest + re-run
|
||||
// `python scripts/compose-fork/sync.py coil/coil-core`.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.okio)
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
// SKIKO BRANCHES. Upstream's nonAndroid files use org.jetbrains.skia.*, so the
|
||||
// intermediate source set that holds them needs skiko on its own compile
|
||||
// classpath (the shared-metadata compilation is a real compile). macOS/Linux
|
||||
// take the OFFICIAL skiko; mingwX64 takes the bitsycore FORK - and the two
|
||||
// cannot share an intermediate, so mingw gets a PARALLEL branch over the same
|
||||
// srcDirs. Same shape as :compose:ui:ui-graphics.
|
||||
//
|
||||
// Each branch is two levels deep because upstream's nonAndroid sets declare
|
||||
// `expect`s whose `actual`s live in nativeMain - siblings in one set is an error.
|
||||
fun skikoBranch(inName: String, inSkiko: Any): org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet {
|
||||
val vNonAndroid = create("${inName}NonAndroidMain") {
|
||||
dependsOn(commonMain.get())
|
||||
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
|
||||
dependencies { implementation(inSkiko) }
|
||||
}
|
||||
return create("${inName}NativeMain") {
|
||||
dependsOn(vNonAndroid)
|
||||
kotlin.srcDir("src/vendor/native/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
val vOfficial = skikoBranch("coilOfficial", libs.skiko)
|
||||
val vAppleMain = create("coilAppleMain") {
|
||||
dependsOn(vOfficial)
|
||||
kotlin.srcDir("src/vendor/apple/kotlin")
|
||||
}
|
||||
val vLinuxMain = create("coilLinuxMain") {
|
||||
dependsOn(vOfficial)
|
||||
kotlin.srcDir("src/vendor/nonApple/kotlin")
|
||||
}
|
||||
get("macosArm64Main").dependsOn(vAppleMain)
|
||||
get("linuxX64Main").dependsOn(vLinuxMain)
|
||||
get("linuxArm64Main").dependsOn(vLinuxMain)
|
||||
|
||||
if (vHostSupportsMingw) {
|
||||
val vFork = skikoBranch(
|
||||
"coilFork",
|
||||
"com.bitsycore.skiko:skiko:${providers.gradleProperty("skikoMingwVersion").getOrElse(libs.versions.skikoMingw.get())}",
|
||||
)
|
||||
val vMingwMain = create("coilMingwMain") {
|
||||
dependsOn(vFork)
|
||||
kotlin.srcDir("src/vendor/nonApple/kotlin")
|
||||
}
|
||||
get("mingwX64Main").dependsOn(vMingwMain)
|
||||
}
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
|
||||
"-opt-in=coil3.annotation.InternalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.DelicateCoilApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
# :coil:coil-core - coil3.* core, vendored verbatim from coil-kt/coil.
|
||||
# Upstream publishes linux + apple but NOT mingwX64, so the port re-declares the
|
||||
# targets and rebuilds the same sources. Decoding is Skia-based upstream
|
||||
# (SkiaImageDecoder), which is exactly this port's renderer - on mingwX64 the
|
||||
# skiko dependency is the bitsycore FORK, as in :compose:ui:ui-graphics.
|
||||
# Upstream source sets collapse onto this port: it has NO android, jvm or js
|
||||
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
|
||||
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
|
||||
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
|
||||
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
|
||||
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
|
||||
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
|
||||
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
|
||||
# hierarchy template).
|
||||
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
|
||||
|
||||
SET_FOLDER=coil-core/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/coil3/ColorImage.kt -> src/vendor/common/kotlin/coil3/ColorImage.kt
|
||||
# | commonMain/kotlin/coil3/ComponentRegistry.kt -> src/vendor/common/kotlin/coil3/ComponentRegistry.kt
|
||||
# | commonMain/kotlin/coil3/EventListener.kt -> src/vendor/common/kotlin/coil3/EventListener.kt
|
||||
# | commonMain/kotlin/coil3/Extras.kt -> src/vendor/common/kotlin/coil3/Extras.kt
|
||||
# | commonMain/kotlin/coil3/Image.kt -> src/vendor/common/kotlin/coil3/Image.kt
|
||||
# | commonMain/kotlin/coil3/ImageLoader.kt -> src/vendor/common/kotlin/coil3/ImageLoader.kt
|
||||
# | commonMain/kotlin/coil3/PlatformContext.kt -> src/vendor/common/kotlin/coil3/PlatformContext.kt
|
||||
# | commonMain/kotlin/coil3/RealImageLoader.kt -> src/vendor/common/kotlin/coil3/RealImageLoader.kt
|
||||
# | commonMain/kotlin/coil3/Uri.kt -> src/vendor/common/kotlin/coil3/Uri.kt
|
||||
# | commonMain/kotlin/coil3/annotation/annotations.kt -> src/vendor/common/kotlin/coil3/annotation/annotations.kt
|
||||
# | commonMain/kotlin/coil3/decode/BlackholeDecoder.kt -> src/vendor/common/kotlin/coil3/decode/BlackholeDecoder.kt
|
||||
# | commonMain/kotlin/coil3/decode/DataSource.kt -> src/vendor/common/kotlin/coil3/decode/DataSource.kt
|
||||
# | commonMain/kotlin/coil3/decode/DecodeResult.kt -> src/vendor/common/kotlin/coil3/decode/DecodeResult.kt
|
||||
# | commonMain/kotlin/coil3/decode/DecodeUtils.kt -> src/vendor/common/kotlin/coil3/decode/DecodeUtils.kt
|
||||
# | commonMain/kotlin/coil3/decode/Decoder.kt -> src/vendor/common/kotlin/coil3/decode/Decoder.kt
|
||||
# | commonMain/kotlin/coil3/decode/ImageSource.kt -> src/vendor/common/kotlin/coil3/decode/ImageSource.kt
|
||||
# | commonMain/kotlin/coil3/disk/DiskCache.kt -> src/vendor/common/kotlin/coil3/disk/DiskCache.kt
|
||||
# | commonMain/kotlin/coil3/disk/DiskLruCache.kt -> src/vendor/common/kotlin/coil3/disk/DiskLruCache.kt
|
||||
# | commonMain/kotlin/coil3/disk/FaultHidingSink.kt -> src/vendor/common/kotlin/coil3/disk/FaultHidingSink.kt
|
||||
# | commonMain/kotlin/coil3/disk/RealDiskCache.kt -> src/vendor/common/kotlin/coil3/disk/RealDiskCache.kt
|
||||
# | commonMain/kotlin/coil3/disk/utils.kt -> src/vendor/common/kotlin/coil3/disk/utils.kt
|
||||
# | commonMain/kotlin/coil3/fetch/BitmapFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/BitmapFetcher.kt
|
||||
# | commonMain/kotlin/coil3/fetch/ByteArrayFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/ByteArrayFetcher.kt
|
||||
# | commonMain/kotlin/coil3/fetch/DataUriFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/DataUriFetcher.kt
|
||||
# | commonMain/kotlin/coil3/fetch/FetchResult.kt -> src/vendor/common/kotlin/coil3/fetch/FetchResult.kt
|
||||
# | commonMain/kotlin/coil3/fetch/Fetcher.kt -> src/vendor/common/kotlin/coil3/fetch/Fetcher.kt
|
||||
# | commonMain/kotlin/coil3/fetch/FileUriFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/FileUriFetcher.kt
|
||||
# | commonMain/kotlin/coil3/imageLoaders.kt -> src/vendor/common/kotlin/coil3/imageLoaders.kt
|
||||
# | commonMain/kotlin/coil3/intercept/EngineInterceptor.kt -> src/vendor/common/kotlin/coil3/intercept/EngineInterceptor.kt
|
||||
# | commonMain/kotlin/coil3/intercept/Interceptor.kt -> src/vendor/common/kotlin/coil3/intercept/Interceptor.kt
|
||||
# | commonMain/kotlin/coil3/intercept/RealInterceptorChain.kt -> src/vendor/common/kotlin/coil3/intercept/RealInterceptorChain.kt
|
||||
# | commonMain/kotlin/coil3/key/FileUriKeyer.kt -> src/vendor/common/kotlin/coil3/key/FileUriKeyer.kt
|
||||
# | commonMain/kotlin/coil3/key/Keyer.kt -> src/vendor/common/kotlin/coil3/key/Keyer.kt
|
||||
# | commonMain/kotlin/coil3/key/UriKeyer.kt -> src/vendor/common/kotlin/coil3/key/UriKeyer.kt
|
||||
# | commonMain/kotlin/coil3/map/Mapper.kt -> src/vendor/common/kotlin/coil3/map/Mapper.kt
|
||||
# | commonMain/kotlin/coil3/map/PathMapper.kt -> src/vendor/common/kotlin/coil3/map/PathMapper.kt
|
||||
# | commonMain/kotlin/coil3/map/StringMapper.kt -> src/vendor/common/kotlin/coil3/map/StringMapper.kt
|
||||
# | commonMain/kotlin/coil3/memory/MemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/MemoryCache.kt
|
||||
# | commonMain/kotlin/coil3/memory/MemoryCacheService.kt -> src/vendor/common/kotlin/coil3/memory/MemoryCacheService.kt
|
||||
# | commonMain/kotlin/coil3/memory/RealMemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/RealMemoryCache.kt
|
||||
# | commonMain/kotlin/coil3/memory/StrongMemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/StrongMemoryCache.kt
|
||||
# | commonMain/kotlin/coil3/memory/WeakMemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/WeakMemoryCache.kt
|
||||
# | commonMain/kotlin/coil3/request/CachePolicy.kt -> src/vendor/common/kotlin/coil3/request/CachePolicy.kt
|
||||
# | commonMain/kotlin/coil3/request/Disposable.kt -> src/vendor/common/kotlin/coil3/request/Disposable.kt
|
||||
# | commonMain/kotlin/coil3/request/ImageRequest.kt -> src/vendor/common/kotlin/coil3/request/ImageRequest.kt
|
||||
# | commonMain/kotlin/coil3/request/ImageResult.kt -> src/vendor/common/kotlin/coil3/request/ImageResult.kt
|
||||
# | commonMain/kotlin/coil3/request/NullRequestData.kt -> src/vendor/common/kotlin/coil3/request/NullRequestData.kt
|
||||
# | commonMain/kotlin/coil3/request/NullRequestDataException.kt -> src/vendor/common/kotlin/coil3/request/NullRequestDataException.kt
|
||||
# | commonMain/kotlin/coil3/request/Options.kt -> src/vendor/common/kotlin/coil3/request/Options.kt
|
||||
# | commonMain/kotlin/coil3/request/RequestDelegate.kt -> src/vendor/common/kotlin/coil3/request/RequestDelegate.kt
|
||||
# | commonMain/kotlin/coil3/request/RequestService.kt -> src/vendor/common/kotlin/coil3/request/RequestService.kt
|
||||
# | commonMain/kotlin/coil3/request/imageRequests.kt -> src/vendor/common/kotlin/coil3/request/imageRequests.kt
|
||||
# | commonMain/kotlin/coil3/size/Dimension.kt -> src/vendor/common/kotlin/coil3/size/Dimension.kt
|
||||
# | commonMain/kotlin/coil3/size/Precision.kt -> src/vendor/common/kotlin/coil3/size/Precision.kt
|
||||
# | commonMain/kotlin/coil3/size/RealSizeResolver.kt -> src/vendor/common/kotlin/coil3/size/RealSizeResolver.kt
|
||||
# | commonMain/kotlin/coil3/size/Scale.kt -> src/vendor/common/kotlin/coil3/size/Scale.kt
|
||||
# | commonMain/kotlin/coil3/size/Size.kt -> src/vendor/common/kotlin/coil3/size/Size.kt
|
||||
# | commonMain/kotlin/coil3/size/SizeResolver.kt -> src/vendor/common/kotlin/coil3/size/SizeResolver.kt
|
||||
# | commonMain/kotlin/coil3/target/Target.kt -> src/vendor/common/kotlin/coil3/target/Target.kt
|
||||
# | commonMain/kotlin/coil3/transform/Transformation.kt -> src/vendor/common/kotlin/coil3/transform/Transformation.kt
|
||||
# | commonMain/kotlin/coil3/util/LruCache.kt -> src/vendor/common/kotlin/coil3/util/LruCache.kt
|
||||
# | commonMain/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt -> src/vendor/common/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt
|
||||
# | commonMain/kotlin/coil3/util/SystemCallbacks.kt -> src/vendor/common/kotlin/coil3/util/SystemCallbacks.kt
|
||||
# | commonMain/kotlin/coil3/util/collections.kt -> src/vendor/common/kotlin/coil3/util/collections.kt
|
||||
# | commonMain/kotlin/coil3/util/contexts.kt -> src/vendor/common/kotlin/coil3/util/contexts.kt
|
||||
# | commonMain/kotlin/coil3/util/coroutines.kt -> src/vendor/common/kotlin/coil3/util/coroutines.kt
|
||||
# | commonMain/kotlin/coil3/util/fileSystems.kt -> src/vendor/common/kotlin/coil3/util/fileSystems.kt
|
||||
# | commonMain/kotlin/coil3/util/logging.kt -> src/vendor/common/kotlin/coil3/util/logging.kt
|
||||
# | commonMain/kotlin/coil3/util/mimeTypes.kt -> src/vendor/common/kotlin/coil3/util/mimeTypes.kt
|
||||
# | commonMain/kotlin/coil3/util/utils.kt -> src/vendor/common/kotlin/coil3/util/utils.kt
|
||||
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonAndroidMain/kotlin/coil3/ColorImage.kt -> src/vendor/nonAndroid/kotlin/coil3/ColorImage.kt
|
||||
# | nonAndroidMain/kotlin/coil3/EventListener.kt -> src/vendor/nonAndroid/kotlin/coil3/EventListener.kt
|
||||
# | nonAndroidMain/kotlin/coil3/Image.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/Image.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/PlatformContext.kt -> src/vendor/nonAndroid/kotlin/coil3/PlatformContext.kt
|
||||
# | nonAndroidMain/kotlin/coil3/RealImageLoader.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/RealImageLoader.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/decode/SkiaImageDecoder.kt -> src/vendor/nonAndroid/kotlin/coil3/decode/SkiaImageDecoder.kt
|
||||
# | nonAndroidMain/kotlin/coil3/intercept/EngineInterceptor.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/intercept/EngineInterceptor.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/request/RequestService.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/request/RequestService.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/request/imageRequests.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/request/imageRequests.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/util/SystemCallbacks.kt -> src/vendor/nonAndroid/kotlin/coil3/util/SystemCallbacks.kt
|
||||
# | nonAndroidMain/kotlin/coil3/util/contexts.kt -> src/vendor/nonAndroid/kotlin/coil3/util/contexts.kt
|
||||
# | nonAndroidMain/kotlin/coil3/util/mimeTypes.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/util/mimeTypes.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/util/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/util/utils.nonAndroid.kt
|
||||
nonJvmCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonJvmCommonMain/kotlin/coil3/RealImageLoader.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/RealImageLoader.nonJvmCommon.kt
|
||||
# | nonJvmCommonMain/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt -> src/vendor/nonAndroid/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt
|
||||
# | nonJvmCommonMain/kotlin/coil3/util/collections.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/collections.nonJvmCommon.kt
|
||||
# | nonJvmCommonMain/kotlin/coil3/util/contexts.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/contexts.nonJvmCommon.kt
|
||||
# | nonJvmCommonMain/kotlin/coil3/util/fileSystems.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/fileSystems.nonJvmCommon.kt
|
||||
nonJsCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonJsCommonMain/kotlin/coil3/disk/utils.kt -> src/vendor/nonAndroid/kotlin/coil3/disk/utils.kt
|
||||
# | nonJsCommonMain/kotlin/coil3/imageLoaders.nonJsCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/imageLoaders.nonJsCommon.kt
|
||||
# | nonJsCommonMain/kotlin/coil3/util/coroutines.nonJsCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/coroutines.nonJsCommon.kt
|
||||
# | nonJsCommonMain/kotlin/coil3/util/fileSystems.nonJsCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/fileSystems.nonJsCommon.kt
|
||||
nativeMain/kotlin/ -> src/vendor/native/kotlin/
|
||||
# | nativeMain/kotlin/coil3/decode/SkiaImageDecoder.native.kt -> src/vendor/native/kotlin/coil3/decode/SkiaImageDecoder.native.kt
|
||||
# | nativeMain/kotlin/coil3/util/utils.native.kt -> src/vendor/native/kotlin/coil3/util/utils.native.kt
|
||||
appleMain/kotlin/ -> src/vendor/apple/kotlin/
|
||||
# | appleMain/kotlin/coil3/RealImageLoader.apple.kt -> src/vendor/apple/kotlin/coil3/RealImageLoader.apple.kt
|
||||
# | appleMain/kotlin/coil3/map/NSURLMapper.kt -> src/vendor/apple/kotlin/coil3/map/NSURLMapper.kt
|
||||
nonAppleMain/kotlin/ -> src/vendor/nonApple/kotlin/
|
||||
# | nonAppleMain/kotlin/coil3/RealImageLoader.nonApple.kt -> src/vendor/nonApple/kotlin/coil3/RealImageLoader.nonApple.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# androidMain (49)
|
||||
# | androidMain/kotlin/coil3/ColorImage.kt -> src/vendor/android/kotlin/coil3/ColorImage.kt
|
||||
# | androidMain/kotlin/coil3/EventListener.kt -> src/vendor/android/kotlin/coil3/EventListener.kt
|
||||
# | androidMain/kotlin/coil3/Image.android.kt -> src/vendor/android/kotlin/coil3/Image.android.kt
|
||||
# | androidMain/kotlin/coil3/PlatformContext.kt -> src/vendor/android/kotlin/coil3/PlatformContext.kt
|
||||
# | androidMain/kotlin/coil3/RealImageLoader.android.kt -> src/vendor/android/kotlin/coil3/RealImageLoader.android.kt
|
||||
# | androidMain/kotlin/coil3/Uri.android.kt -> src/vendor/android/kotlin/coil3/Uri.android.kt
|
||||
# | androidMain/kotlin/coil3/decode/BitmapFactoryDecoder.kt -> src/vendor/android/kotlin/coil3/decode/BitmapFactoryDecoder.kt
|
||||
# | androidMain/kotlin/coil3/decode/ExifOrientationStrategy.kt -> src/vendor/android/kotlin/coil3/decode/ExifOrientationStrategy.kt
|
||||
# | androidMain/kotlin/coil3/decode/ExifUtils.kt -> src/vendor/android/kotlin/coil3/decode/ExifUtils.kt
|
||||
# | androidMain/kotlin/coil3/decode/ImageSource.kt -> src/vendor/android/kotlin/coil3/decode/ImageSource.kt
|
||||
# | androidMain/kotlin/coil3/decode/StaticImageDecoder.kt -> src/vendor/android/kotlin/coil3/decode/StaticImageDecoder.kt
|
||||
# | androidMain/kotlin/coil3/fetch/AssetUriFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/AssetUriFetcher.kt
|
||||
# | androidMain/kotlin/coil3/fetch/ContentUriFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/ContentUriFetcher.kt
|
||||
# | androidMain/kotlin/coil3/fetch/DrawableFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/DrawableFetcher.kt
|
||||
# | androidMain/kotlin/coil3/fetch/ResourceUriFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/ResourceUriFetcher.kt
|
||||
# | androidMain/kotlin/coil3/imageLoaders.android.kt -> src/vendor/android/kotlin/coil3/imageLoaders.android.kt
|
||||
# | androidMain/kotlin/coil3/intercept/EngineInterceptor.android.kt -> src/vendor/android/kotlin/coil3/intercept/EngineInterceptor.android.kt
|
||||
# | androidMain/kotlin/coil3/key/AndroidResourceUriKeyer.kt -> src/vendor/android/kotlin/coil3/key/AndroidResourceUriKeyer.kt
|
||||
# | androidMain/kotlin/coil3/map/AndroidUriMapper.kt -> src/vendor/android/kotlin/coil3/map/AndroidUriMapper.kt
|
||||
# | androidMain/kotlin/coil3/map/ResourceIntMapper.kt -> src/vendor/android/kotlin/coil3/map/ResourceIntMapper.kt
|
||||
# | androidMain/kotlin/coil3/request/RequestDelegate.android.kt -> src/vendor/android/kotlin/coil3/request/RequestDelegate.android.kt
|
||||
# | androidMain/kotlin/coil3/request/RequestService.android.kt -> src/vendor/android/kotlin/coil3/request/RequestService.android.kt
|
||||
# | androidMain/kotlin/coil3/request/ViewTargetDisposable.kt -> src/vendor/android/kotlin/coil3/request/ViewTargetDisposable.kt
|
||||
# | androidMain/kotlin/coil3/request/ViewTargetRequestManager.kt -> src/vendor/android/kotlin/coil3/request/ViewTargetRequestManager.kt
|
||||
# | androidMain/kotlin/coil3/request/imageRequests.android.kt -> src/vendor/android/kotlin/coil3/request/imageRequests.android.kt
|
||||
# | androidMain/kotlin/coil3/size/RealViewSizeResolver.kt -> src/vendor/android/kotlin/coil3/size/RealViewSizeResolver.kt
|
||||
# | androidMain/kotlin/coil3/size/ScaleDrawable.kt -> src/vendor/android/kotlin/coil3/size/ScaleDrawable.kt
|
||||
# | androidMain/kotlin/coil3/size/ViewSizeResolver.kt -> src/vendor/android/kotlin/coil3/size/ViewSizeResolver.kt
|
||||
# | androidMain/kotlin/coil3/target/GenericViewTarget.kt -> src/vendor/android/kotlin/coil3/target/GenericViewTarget.kt
|
||||
# | androidMain/kotlin/coil3/target/ImageViewTarget.kt -> src/vendor/android/kotlin/coil3/target/ImageViewTarget.kt
|
||||
# | androidMain/kotlin/coil3/target/ViewTarget.kt -> src/vendor/android/kotlin/coil3/target/ViewTarget.kt
|
||||
# | androidMain/kotlin/coil3/transform/CircleCropTransformation.kt -> src/vendor/android/kotlin/coil3/transform/CircleCropTransformation.kt
|
||||
# | androidMain/kotlin/coil3/transform/RoundedCornersTransformation.kt -> src/vendor/android/kotlin/coil3/transform/RoundedCornersTransformation.kt
|
||||
# | androidMain/kotlin/coil3/transform/transformations.kt -> src/vendor/android/kotlin/coil3/transform/transformations.kt
|
||||
# | androidMain/kotlin/coil3/transition/CrossfadeDrawable.kt -> src/vendor/android/kotlin/coil3/transition/CrossfadeDrawable.kt
|
||||
# | androidMain/kotlin/coil3/transition/CrossfadeTransition.kt -> src/vendor/android/kotlin/coil3/transition/CrossfadeTransition.kt
|
||||
# | androidMain/kotlin/coil3/transition/NoneTransition.kt -> src/vendor/android/kotlin/coil3/transition/NoneTransition.kt
|
||||
# | androidMain/kotlin/coil3/transition/Transition.kt -> src/vendor/android/kotlin/coil3/transition/Transition.kt
|
||||
# | androidMain/kotlin/coil3/transition/TransitionTarget.kt -> src/vendor/android/kotlin/coil3/transition/TransitionTarget.kt
|
||||
# | androidMain/kotlin/coil3/util/CoilUtils.kt -> src/vendor/android/kotlin/coil3/util/CoilUtils.kt
|
||||
# | androidMain/kotlin/coil3/util/DrawableUtils.kt -> src/vendor/android/kotlin/coil3/util/DrawableUtils.kt
|
||||
# | androidMain/kotlin/coil3/util/SystemCallbacks.kt -> src/vendor/android/kotlin/coil3/util/SystemCallbacks.kt
|
||||
# | androidMain/kotlin/coil3/util/bitmaps.kt -> src/vendor/android/kotlin/coil3/util/bitmaps.kt
|
||||
# | androidMain/kotlin/coil3/util/contexts.kt -> src/vendor/android/kotlin/coil3/util/contexts.kt
|
||||
# | androidMain/kotlin/coil3/util/fileSystems.android.kt -> src/vendor/android/kotlin/coil3/util/fileSystems.android.kt
|
||||
# | androidMain/kotlin/coil3/util/hardwareBitmaps.kt -> src/vendor/android/kotlin/coil3/util/hardwareBitmaps.kt
|
||||
# | androidMain/kotlin/coil3/util/lifecycles.kt -> src/vendor/android/kotlin/coil3/util/lifecycles.kt
|
||||
# | androidMain/kotlin/coil3/util/mimeTypes.android.kt -> src/vendor/android/kotlin/coil3/util/mimeTypes.android.kt
|
||||
# | androidMain/kotlin/coil3/util/utils.android.kt -> src/vendor/android/kotlin/coil3/util/utils.android.kt
|
||||
# jsCommonMain (7)
|
||||
# | jsCommonMain/kotlin/coil3/decode/SkiaImageDecoder.jsCommon.kt -> src/vendor/jsCommon/kotlin/coil3/decode/SkiaImageDecoder.jsCommon.kt
|
||||
# | jsCommonMain/kotlin/coil3/decode/WebWorker.kt -> src/vendor/jsCommon/kotlin/coil3/decode/WebWorker.kt
|
||||
# | jsCommonMain/kotlin/coil3/disk/utils.kt -> src/vendor/jsCommon/kotlin/coil3/disk/utils.kt
|
||||
# | jsCommonMain/kotlin/coil3/fetch/BlobUriFetcher.kt -> src/vendor/jsCommon/kotlin/coil3/fetch/BlobUriFetcher.kt
|
||||
# | jsCommonMain/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.kt -> src/vendor/jsCommon/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.kt
|
||||
# | jsCommonMain/kotlin/coil3/util/coroutines.kt -> src/vendor/jsCommon/kotlin/coil3/util/coroutines.kt
|
||||
# | jsCommonMain/kotlin/coil3/util/fileSystems.kt -> src/vendor/jsCommon/kotlin/coil3/util/fileSystems.kt
|
||||
# jsMain (3)
|
||||
# | jsMain/kotlin/coil3/fetch/BlobUriFetcher.js.kt -> src/vendor/js/kotlin/coil3/fetch/BlobUriFetcher.js.kt
|
||||
# | jsMain/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.js.kt -> src/vendor/js/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.js.kt
|
||||
# | jsMain/kotlin/coil3/util/utils.js.kt -> src/vendor/js/kotlin/coil3/util/utils.js.kt
|
||||
# jvmCommonMain (10)
|
||||
# | jvmCommonMain/kotlin/coil3/RealImageLoader.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/RealImageLoader.jvmCommon.kt
|
||||
# | jvmCommonMain/kotlin/coil3/decode/ImageSource.kt -> src/vendor/jvmCommon/kotlin/coil3/decode/ImageSource.kt
|
||||
# | jvmCommonMain/kotlin/coil3/disk/diskCache.kt -> src/vendor/jvmCommon/kotlin/coil3/disk/diskCache.kt
|
||||
# | jvmCommonMain/kotlin/coil3/fetch/ByteBufferFetcher.kt -> src/vendor/jvmCommon/kotlin/coil3/fetch/ByteBufferFetcher.kt
|
||||
# | jvmCommonMain/kotlin/coil3/fetch/JarFileFetcher.kt -> src/vendor/jvmCommon/kotlin/coil3/fetch/JarFileFetcher.kt
|
||||
# | jvmCommonMain/kotlin/coil3/imageLoaders.hidden.kt -> src/vendor/jvmCommon/kotlin/coil3/imageLoaders.hidden.kt
|
||||
# | jvmCommonMain/kotlin/coil3/map/FileMapper.kt -> src/vendor/jvmCommon/kotlin/coil3/map/FileMapper.kt
|
||||
# | jvmCommonMain/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt -> src/vendor/jvmCommon/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt
|
||||
# | jvmCommonMain/kotlin/coil3/util/collections.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/util/collections.jvmCommon.kt
|
||||
# | jvmCommonMain/kotlin/coil3/util/utils.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/util/utils.jvmCommon.kt
|
||||
# jvmMain (3)
|
||||
# | jvmMain/kotlin/coil3/decode/SkiaImageDecoder.jvm.kt -> src/vendor/jvm/kotlin/coil3/decode/SkiaImageDecoder.jvm.kt
|
||||
# | jvmMain/kotlin/coil3/util/contexts.jvm.kt -> src/vendor/jvm/kotlin/coil3/util/contexts.jvm.kt
|
||||
# | jvmMain/kotlin/coil3/util/fileSystems.jvm.kt -> src/vendor/jvm/kotlin/coil3/util/fileSystems.jvm.kt
|
||||
# wasmJsMain (3)
|
||||
# | wasmJsMain/kotlin/coil3/fetch/BlobUriFetcher.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/fetch/BlobUriFetcher.wasmJs.kt
|
||||
# | wasmJsMain/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.wasmJs.kt
|
||||
# | wasmJsMain/kotlin/coil3/util/utils.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/util/utils.wasmJs.kt
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -0,0 +1,197 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.coil3:coil-network-core>
|
||||
abstract fun interface coil3.network/ConnectivityChecker { // coil3.network/ConnectivityChecker|null[0]
|
||||
abstract fun isOnline(): kotlin/Boolean // coil3.network/ConnectivityChecker.isOnline|isOnline(){}[0]
|
||||
|
||||
final object Companion { // coil3.network/ConnectivityChecker.Companion|null[0]
|
||||
final val ONLINE // coil3.network/ConnectivityChecker.Companion.ONLINE|{}ONLINE[0]
|
||||
final fun <get-ONLINE>(): coil3.network/ConnectivityChecker // coil3.network/ConnectivityChecker.Companion.ONLINE.<get-ONLINE>|<get-ONLINE>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface coil3.network/CacheStrategy { // coil3.network/CacheStrategy|null[0]
|
||||
abstract suspend fun read(coil3.network/NetworkResponse, coil3.network/NetworkRequest, coil3.request/Options): coil3.network/CacheStrategy.ReadResult // coil3.network/CacheStrategy.read|read(coil3.network.NetworkResponse;coil3.network.NetworkRequest;coil3.request.Options){}[0]
|
||||
abstract suspend fun write(coil3.network/NetworkResponse?, coil3.network/NetworkRequest, coil3.network/NetworkResponse, coil3.request/Options): coil3.network/CacheStrategy.WriteResult // coil3.network/CacheStrategy.write|write(coil3.network.NetworkResponse?;coil3.network.NetworkRequest;coil3.network.NetworkResponse;coil3.request.Options){}[0]
|
||||
|
||||
final class ReadResult { // coil3.network/CacheStrategy.ReadResult|null[0]
|
||||
constructor <init>(coil3.network/NetworkRequest) // coil3.network/CacheStrategy.ReadResult.<init>|<init>(coil3.network.NetworkRequest){}[0]
|
||||
constructor <init>(coil3.network/NetworkResponse) // coil3.network/CacheStrategy.ReadResult.<init>|<init>(coil3.network.NetworkResponse){}[0]
|
||||
|
||||
final val request // coil3.network/CacheStrategy.ReadResult.request|{}request[0]
|
||||
final fun <get-request>(): coil3.network/NetworkRequest? // coil3.network/CacheStrategy.ReadResult.request.<get-request>|<get-request>(){}[0]
|
||||
final val response // coil3.network/CacheStrategy.ReadResult.response|{}response[0]
|
||||
final fun <get-response>(): coil3.network/NetworkResponse? // coil3.network/CacheStrategy.ReadResult.response.<get-response>|<get-response>(){}[0]
|
||||
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.network/CacheStrategy.ReadResult.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.network/CacheStrategy.ReadResult.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // coil3.network/CacheStrategy.ReadResult.toString|toString(){}[0]
|
||||
}
|
||||
|
||||
final class WriteResult { // coil3.network/CacheStrategy.WriteResult|null[0]
|
||||
constructor <init>(coil3.network/NetworkResponse) // coil3.network/CacheStrategy.WriteResult.<init>|<init>(coil3.network.NetworkResponse){}[0]
|
||||
|
||||
final val response // coil3.network/CacheStrategy.WriteResult.response|{}response[0]
|
||||
final fun <get-response>(): coil3.network/NetworkResponse? // coil3.network/CacheStrategy.WriteResult.response.<get-response>|<get-response>(){}[0]
|
||||
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.network/CacheStrategy.WriteResult.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.network/CacheStrategy.WriteResult.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // coil3.network/CacheStrategy.WriteResult.toString|toString(){}[0]
|
||||
|
||||
final object Companion { // coil3.network/CacheStrategy.WriteResult.Companion|null[0]
|
||||
final val DISABLED // coil3.network/CacheStrategy.WriteResult.Companion.DISABLED|{}DISABLED[0]
|
||||
final fun <get-DISABLED>(): coil3.network/CacheStrategy.WriteResult // coil3.network/CacheStrategy.WriteResult.Companion.DISABLED.<get-DISABLED>|<get-DISABLED>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final object Companion { // coil3.network/CacheStrategy.Companion|null[0]
|
||||
final val DEFAULT // coil3.network/CacheStrategy.Companion.DEFAULT|{}DEFAULT[0]
|
||||
final fun <get-DEFAULT>(): coil3.network/CacheStrategy // coil3.network/CacheStrategy.Companion.DEFAULT.<get-DEFAULT>|<get-DEFAULT>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface coil3.network/ConcurrentRequestStrategy { // coil3.network/ConcurrentRequestStrategy|null[0]
|
||||
abstract suspend fun apply(kotlin/String, kotlin.coroutines/SuspendFunction0<coil3.fetch/FetchResult>): coil3.fetch/FetchResult // coil3.network/ConcurrentRequestStrategy.apply|apply(kotlin.String;kotlin.coroutines.SuspendFunction0<coil3.fetch.FetchResult>){}[0]
|
||||
|
||||
final object Companion { // coil3.network/ConcurrentRequestStrategy.Companion|null[0]
|
||||
final val UNCOORDINATED // coil3.network/ConcurrentRequestStrategy.Companion.UNCOORDINATED|{}UNCOORDINATED[0]
|
||||
final fun <get-UNCOORDINATED>(): coil3.network/ConcurrentRequestStrategy // coil3.network/ConcurrentRequestStrategy.Companion.UNCOORDINATED.<get-UNCOORDINATED>|<get-UNCOORDINATED>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface coil3.network/NetworkClient { // coil3.network/NetworkClient|null[0]
|
||||
abstract suspend fun <#A1: kotlin/Any?> executeRequest(coil3.network/NetworkRequest, kotlin.coroutines/SuspendFunction1<coil3.network/NetworkResponse, #A1>): #A1 // coil3.network/NetworkClient.executeRequest|executeRequest(coil3.network.NetworkRequest;kotlin.coroutines.SuspendFunction1<coil3.network.NetworkResponse,0:0>){0§<kotlin.Any?>}[0]
|
||||
}
|
||||
|
||||
abstract interface coil3.network/NetworkRequestBody { // coil3.network/NetworkRequestBody|null[0]
|
||||
abstract suspend fun writeTo(okio/BufferedSink) // coil3.network/NetworkRequestBody.writeTo|writeTo(okio.BufferedSink){}[0]
|
||||
}
|
||||
|
||||
abstract interface coil3.network/NetworkResponseBody : kotlin/AutoCloseable { // coil3.network/NetworkResponseBody|null[0]
|
||||
abstract suspend fun writeTo(okio/BufferedSink) // coil3.network/NetworkResponseBody.writeTo|writeTo(okio.BufferedSink){}[0]
|
||||
abstract suspend fun writeTo(okio/FileSystem, okio/Path) // coil3.network/NetworkResponseBody.writeTo|writeTo(okio.FileSystem;okio.Path){}[0]
|
||||
}
|
||||
|
||||
final class coil3.network/DeDupeConcurrentRequestStrategy : coil3.network/ConcurrentRequestStrategy { // coil3.network/DeDupeConcurrentRequestStrategy|null[0]
|
||||
constructor <init>() // coil3.network/DeDupeConcurrentRequestStrategy.<init>|<init>(){}[0]
|
||||
|
||||
final suspend fun apply(kotlin/String, kotlin.coroutines/SuspendFunction0<coil3.fetch/FetchResult>): coil3.fetch/FetchResult // coil3.network/DeDupeConcurrentRequestStrategy.apply|apply(kotlin.String;kotlin.coroutines.SuspendFunction0<coil3.fetch.FetchResult>){}[0]
|
||||
}
|
||||
|
||||
final class coil3.network/HttpException : kotlin/RuntimeException { // coil3.network/HttpException|null[0]
|
||||
constructor <init>(coil3.network/NetworkResponse) // coil3.network/HttpException.<init>|<init>(coil3.network.NetworkResponse){}[0]
|
||||
|
||||
final val response // coil3.network/HttpException.response|{}response[0]
|
||||
final fun <get-response>(): coil3.network/NetworkResponse // coil3.network/HttpException.response.<get-response>|<get-response>(){}[0]
|
||||
}
|
||||
|
||||
final class coil3.network/NetworkFetcher : coil3.fetch/Fetcher { // coil3.network/NetworkFetcher|null[0]
|
||||
constructor <init>(kotlin/String, coil3.request/Options, kotlin/Lazy<coil3.network/NetworkClient>, kotlin/Lazy<coil3.disk/DiskCache?>, kotlin/Lazy<coil3.network/CacheStrategy>, coil3.network/ConnectivityChecker) // coil3.network/NetworkFetcher.<init>|<init>(kotlin.String;coil3.request.Options;kotlin.Lazy<coil3.network.NetworkClient>;kotlin.Lazy<coil3.disk.DiskCache?>;kotlin.Lazy<coil3.network.CacheStrategy>;coil3.network.ConnectivityChecker){}[0]
|
||||
constructor <init>(kotlin/String, coil3.request/Options, kotlin/Lazy<coil3.network/NetworkClient>, kotlin/Lazy<coil3.disk/DiskCache?>, kotlin/Lazy<coil3.network/CacheStrategy>, kotlin/Lazy<coil3.network/ConnectivityChecker>, kotlin/Lazy<coil3.network/ConcurrentRequestStrategy>) // coil3.network/NetworkFetcher.<init>|<init>(kotlin.String;coil3.request.Options;kotlin.Lazy<coil3.network.NetworkClient>;kotlin.Lazy<coil3.disk.DiskCache?>;kotlin.Lazy<coil3.network.CacheStrategy>;kotlin.Lazy<coil3.network.ConnectivityChecker>;kotlin.Lazy<coil3.network.ConcurrentRequestStrategy>){}[0]
|
||||
|
||||
final fun getMimeType(kotlin/String, kotlin/String?): kotlin/String? // coil3.network/NetworkFetcher.getMimeType|getMimeType(kotlin.String;kotlin.String?){}[0]
|
||||
final suspend fun fetch(): coil3.fetch/FetchResult // coil3.network/NetworkFetcher.fetch|fetch(){}[0]
|
||||
|
||||
final class Factory : coil3.fetch/Fetcher.Factory<coil3/Uri> { // coil3.network/NetworkFetcher.Factory|null[0]
|
||||
constructor <init>(kotlin/Function0<coil3.network/NetworkClient>, kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ...) // coil3.network/NetworkFetcher.Factory.<init>|<init>(kotlin.Function0<coil3.network.NetworkClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>){}[0]
|
||||
constructor <init>(kotlin/Function0<coil3.network/NetworkClient>, kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ..., kotlin/Function0<coil3.network/ConcurrentRequestStrategy> = ...) // coil3.network/NetworkFetcher.Factory.<init>|<init>(kotlin.Function0<coil3.network.NetworkClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>;kotlin.Function0<coil3.network.ConcurrentRequestStrategy>){}[0]
|
||||
|
||||
final fun create(coil3/Uri, coil3.request/Options, coil3/ImageLoader): coil3.fetch/Fetcher? // coil3.network/NetworkFetcher.Factory.create|create(coil3.Uri;coil3.request.Options;coil3.ImageLoader){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final class coil3.network/NetworkHeaders { // coil3.network/NetworkHeaders|null[0]
|
||||
final fun asMap(): kotlin.collections/Map<kotlin/String, kotlin.collections/List<kotlin/String>> // coil3.network/NetworkHeaders.asMap|asMap(){}[0]
|
||||
final fun get(kotlin/String): kotlin/String? // coil3.network/NetworkHeaders.get|get(kotlin.String){}[0]
|
||||
final fun getAll(kotlin/String): kotlin.collections/List<kotlin/String> // coil3.network/NetworkHeaders.getAll|getAll(kotlin.String){}[0]
|
||||
final fun newBuilder(): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.newBuilder|newBuilder(){}[0]
|
||||
|
||||
final class Builder { // coil3.network/NetworkHeaders.Builder|null[0]
|
||||
constructor <init>() // coil3.network/NetworkHeaders.Builder.<init>|<init>(){}[0]
|
||||
constructor <init>(coil3.network/NetworkHeaders) // coil3.network/NetworkHeaders.Builder.<init>|<init>(coil3.network.NetworkHeaders){}[0]
|
||||
|
||||
final fun add(kotlin/String, kotlin/String): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.Builder.add|add(kotlin.String;kotlin.String){}[0]
|
||||
final fun build(): coil3.network/NetworkHeaders // coil3.network/NetworkHeaders.Builder.build|build(){}[0]
|
||||
final fun set(kotlin/String, kotlin.collections/List<kotlin/String>): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.Builder.set|set(kotlin.String;kotlin.collections.List<kotlin.String>){}[0]
|
||||
final fun set(kotlin/String, kotlin/String): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.Builder.set|set(kotlin.String;kotlin.String){}[0]
|
||||
}
|
||||
|
||||
final object Companion { // coil3.network/NetworkHeaders.Companion|null[0]
|
||||
final val EMPTY // coil3.network/NetworkHeaders.Companion.EMPTY|{}EMPTY[0]
|
||||
final fun <get-EMPTY>(): coil3.network/NetworkHeaders // coil3.network/NetworkHeaders.Companion.EMPTY.<get-EMPTY>|<get-EMPTY>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final class coil3.network/NetworkRequest { // coil3.network/NetworkRequest|null[0]
|
||||
constructor <init>(kotlin/String, kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ...) // coil3.network/NetworkRequest.<init>|<init>(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?){}[0]
|
||||
constructor <init>(kotlin/String, kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ..., coil3/Extras = ...) // coil3.network/NetworkRequest.<init>|<init>(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?;coil3.Extras){}[0]
|
||||
|
||||
final val body // coil3.network/NetworkRequest.body|{}body[0]
|
||||
final fun <get-body>(): coil3.network/NetworkRequestBody? // coil3.network/NetworkRequest.body.<get-body>|<get-body>(){}[0]
|
||||
final val extras // coil3.network/NetworkRequest.extras|{}extras[0]
|
||||
final fun <get-extras>(): coil3/Extras // coil3.network/NetworkRequest.extras.<get-extras>|<get-extras>(){}[0]
|
||||
final val headers // coil3.network/NetworkRequest.headers|{}headers[0]
|
||||
final fun <get-headers>(): coil3.network/NetworkHeaders // coil3.network/NetworkRequest.headers.<get-headers>|<get-headers>(){}[0]
|
||||
final val method // coil3.network/NetworkRequest.method|{}method[0]
|
||||
final fun <get-method>(): kotlin/String // coil3.network/NetworkRequest.method.<get-method>|<get-method>(){}[0]
|
||||
final val url // coil3.network/NetworkRequest.url|{}url[0]
|
||||
final fun <get-url>(): kotlin/String // coil3.network/NetworkRequest.url.<get-url>|<get-url>(){}[0]
|
||||
|
||||
final fun copy(kotlin/String = ..., kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ...): coil3.network/NetworkRequest // coil3.network/NetworkRequest.copy|copy(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?){}[0]
|
||||
final fun copy(kotlin/String = ..., kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ..., coil3/Extras = ...): coil3.network/NetworkRequest // coil3.network/NetworkRequest.copy|copy(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?;coil3.Extras){}[0]
|
||||
}
|
||||
|
||||
final class coil3.network/NetworkResponse { // coil3.network/NetworkResponse|null[0]
|
||||
constructor <init>(kotlin/Int = ..., kotlin/Long = ..., kotlin/Long = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkResponseBody? = ..., kotlin/Any? = ...) // coil3.network/NetworkResponse.<init>|<init>(kotlin.Int;kotlin.Long;kotlin.Long;coil3.network.NetworkHeaders;coil3.network.NetworkResponseBody?;kotlin.Any?){}[0]
|
||||
|
||||
final val body // coil3.network/NetworkResponse.body|{}body[0]
|
||||
final fun <get-body>(): coil3.network/NetworkResponseBody? // coil3.network/NetworkResponse.body.<get-body>|<get-body>(){}[0]
|
||||
final val code // coil3.network/NetworkResponse.code|{}code[0]
|
||||
final fun <get-code>(): kotlin/Int // coil3.network/NetworkResponse.code.<get-code>|<get-code>(){}[0]
|
||||
final val delegate // coil3.network/NetworkResponse.delegate|{}delegate[0]
|
||||
final fun <get-delegate>(): kotlin/Any? // coil3.network/NetworkResponse.delegate.<get-delegate>|<get-delegate>(){}[0]
|
||||
final val headers // coil3.network/NetworkResponse.headers|{}headers[0]
|
||||
final fun <get-headers>(): coil3.network/NetworkHeaders // coil3.network/NetworkResponse.headers.<get-headers>|<get-headers>(){}[0]
|
||||
final val requestMillis // coil3.network/NetworkResponse.requestMillis|{}requestMillis[0]
|
||||
final fun <get-requestMillis>(): kotlin/Long // coil3.network/NetworkResponse.requestMillis.<get-requestMillis>|<get-requestMillis>(){}[0]
|
||||
final val responseMillis // coil3.network/NetworkResponse.responseMillis|{}responseMillis[0]
|
||||
final fun <get-responseMillis>(): kotlin/Long // coil3.network/NetworkResponse.responseMillis.<get-responseMillis>|<get-responseMillis>(){}[0]
|
||||
|
||||
final fun copy(kotlin/Int = ..., kotlin/Long = ..., kotlin/Long = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkResponseBody? = ..., kotlin/Any? = ...): coil3.network/NetworkResponse // coil3.network/NetworkResponse.copy|copy(kotlin.Int;kotlin.Long;kotlin.Long;coil3.network.NetworkHeaders;coil3.network.NetworkResponseBody?;kotlin.Any?){}[0]
|
||||
}
|
||||
|
||||
final object coil3.network/CacheNetworkResponse { // coil3.network/CacheNetworkResponse|null[0]
|
||||
final fun readFrom(okio/BufferedSource): coil3.network/NetworkResponse // coil3.network/CacheNetworkResponse.readFrom|readFrom(okio.BufferedSource){}[0]
|
||||
final fun writeTo(coil3.network/NetworkResponse, okio/BufferedSink) // coil3.network/CacheNetworkResponse.writeTo|writeTo(coil3.network.NetworkResponse;okio.BufferedSink){}[0]
|
||||
}
|
||||
|
||||
final val coil3.network/httpBody // coil3.network/httpBody|@coil3.Extras.Key.Companion{}httpBody[0]
|
||||
final fun (coil3/Extras.Key.Companion).<get-httpBody>(): coil3/Extras.Key<coil3.network/NetworkRequestBody?> // coil3.network/httpBody.<get-httpBody>|<get-httpBody>@coil3.Extras.Key.Companion(){}[0]
|
||||
final val coil3.network/httpBody // coil3.network/httpBody|@coil3.request.ImageRequest{}httpBody[0]
|
||||
final fun (coil3.request/ImageRequest).<get-httpBody>(): coil3.network/NetworkRequestBody? // coil3.network/httpBody.<get-httpBody>|<get-httpBody>@coil3.request.ImageRequest(){}[0]
|
||||
final val coil3.network/httpBody // coil3.network/httpBody|@coil3.request.Options{}httpBody[0]
|
||||
final fun (coil3.request/Options).<get-httpBody>(): coil3.network/NetworkRequestBody? // coil3.network/httpBody.<get-httpBody>|<get-httpBody>@coil3.request.Options(){}[0]
|
||||
final val coil3.network/httpHeaders // coil3.network/httpHeaders|@coil3.Extras.Key.Companion{}httpHeaders[0]
|
||||
final fun (coil3/Extras.Key.Companion).<get-httpHeaders>(): coil3/Extras.Key<coil3.network/NetworkHeaders> // coil3.network/httpHeaders.<get-httpHeaders>|<get-httpHeaders>@coil3.Extras.Key.Companion(){}[0]
|
||||
final val coil3.network/httpHeaders // coil3.network/httpHeaders|@coil3.request.ImageRequest{}httpHeaders[0]
|
||||
final fun (coil3.request/ImageRequest).<get-httpHeaders>(): coil3.network/NetworkHeaders // coil3.network/httpHeaders.<get-httpHeaders>|<get-httpHeaders>@coil3.request.ImageRequest(){}[0]
|
||||
final val coil3.network/httpHeaders // coil3.network/httpHeaders|@coil3.request.Options{}httpHeaders[0]
|
||||
final fun (coil3.request/Options).<get-httpHeaders>(): coil3.network/NetworkHeaders // coil3.network/httpHeaders.<get-httpHeaders>|<get-httpHeaders>@coil3.request.Options(){}[0]
|
||||
final val coil3.network/httpMethod // coil3.network/httpMethod|@coil3.Extras.Key.Companion{}httpMethod[0]
|
||||
final fun (coil3/Extras.Key.Companion).<get-httpMethod>(): coil3/Extras.Key<kotlin/String> // coil3.network/httpMethod.<get-httpMethod>|<get-httpMethod>@coil3.Extras.Key.Companion(){}[0]
|
||||
final val coil3.network/httpMethod // coil3.network/httpMethod|@coil3.request.ImageRequest{}httpMethod[0]
|
||||
final fun (coil3.request/ImageRequest).<get-httpMethod>(): kotlin/String // coil3.network/httpMethod.<get-httpMethod>|<get-httpMethod>@coil3.request.ImageRequest(){}[0]
|
||||
final val coil3.network/httpMethod // coil3.network/httpMethod|@coil3.request.Options{}httpMethod[0]
|
||||
final fun (coil3.request/Options).<get-httpMethod>(): kotlin/String // coil3.network/httpMethod.<get-httpMethod>|<get-httpMethod>@coil3.request.Options(){}[0]
|
||||
|
||||
final fun (coil3.request/ImageRequest.Builder).coil3.network/httpBody(coil3.network/NetworkRequestBody): coil3.request/ImageRequest.Builder // coil3.network/httpBody|httpBody@coil3.request.ImageRequest.Builder(coil3.network.NetworkRequestBody){}[0]
|
||||
final fun (coil3.request/ImageRequest.Builder).coil3.network/httpHeaders(coil3.network/NetworkHeaders): coil3.request/ImageRequest.Builder // coil3.network/httpHeaders|httpHeaders@coil3.request.ImageRequest.Builder(coil3.network.NetworkHeaders){}[0]
|
||||
final fun (coil3.request/ImageRequest.Builder).coil3.network/httpMethod(kotlin/String): coil3.request/ImageRequest.Builder // coil3.network/httpMethod|httpMethod@coil3.request.ImageRequest.Builder(kotlin.String){}[0]
|
||||
final fun coil3.network/ConnectivityChecker(coil3/PlatformContext): coil3.network/ConnectivityChecker // coil3.network/ConnectivityChecker|ConnectivityChecker(coil3.PlatformContext){}[0]
|
||||
final fun coil3.network/NetworkRequestBody(okio/ByteString): coil3.network/NetworkRequestBody // coil3.network/NetworkRequestBody|NetworkRequestBody(okio.ByteString){}[0]
|
||||
final fun coil3.network/NetworkResponseBody(okio/BufferedSource): coil3.network/NetworkResponseBody // coil3.network/NetworkResponseBody|NetworkResponseBody(okio.BufferedSource){}[0]
|
||||
@@ -0,0 +1,34 @@
|
||||
// :coil:coil-network-core - vendored VERBATIM from coil-kt/coil into src/vendor.
|
||||
// Shared network-fetcher plumbing. Upstream: linux + apple, no mingwX64.
|
||||
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core's build file
|
||||
// and compose-fork.txt for why). Provenance = compose-fork.txt; never hand-edit
|
||||
// src/vendor - change the manifest + `python scripts/compose-fork/sync.py coil/coil-network-core`.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies { api(project(":coil:coil-core")) }
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
val vNonAndroidMain = create("coilNonAndroidMain") {
|
||||
dependsOn(commonMain.get())
|
||||
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
|
||||
}
|
||||
nativeMain { dependsOn(vNonAndroidMain) }
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-opt-in=coil3.annotation.InternalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.DelicateCoilApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# :coil:coil-network-core - shared network fetcher plumbing.
|
||||
# Upstream: linux + apple, no mingwX64.
|
||||
# Upstream source sets collapse onto this port: it has NO android, jvm or js
|
||||
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
|
||||
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
|
||||
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
|
||||
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
|
||||
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
|
||||
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
|
||||
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
|
||||
# hierarchy template).
|
||||
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
|
||||
|
||||
SET_FOLDER=coil-network-core/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/coil3/network/CacheNetworkResponse.kt -> src/vendor/common/kotlin/coil3/network/CacheNetworkResponse.kt
|
||||
# | commonMain/kotlin/coil3/network/CacheStrategy.kt -> src/vendor/common/kotlin/coil3/network/CacheStrategy.kt
|
||||
# | commonMain/kotlin/coil3/network/ConcurrentRequestStrategy.kt -> src/vendor/common/kotlin/coil3/network/ConcurrentRequestStrategy.kt
|
||||
# | commonMain/kotlin/coil3/network/ConnectivityChecker.kt -> src/vendor/common/kotlin/coil3/network/ConnectivityChecker.kt
|
||||
# | commonMain/kotlin/coil3/network/HttpException.kt -> src/vendor/common/kotlin/coil3/network/HttpException.kt
|
||||
# | commonMain/kotlin/coil3/network/NetworkClient.kt -> src/vendor/common/kotlin/coil3/network/NetworkClient.kt
|
||||
# | commonMain/kotlin/coil3/network/NetworkFetcher.kt -> src/vendor/common/kotlin/coil3/network/NetworkFetcher.kt
|
||||
# | commonMain/kotlin/coil3/network/NetworkHeaders.kt -> src/vendor/common/kotlin/coil3/network/NetworkHeaders.kt
|
||||
# | commonMain/kotlin/coil3/network/imageRequests.kt -> src/vendor/common/kotlin/coil3/network/imageRequests.kt
|
||||
# | commonMain/kotlin/coil3/network/internal/DefaultCacheStrategy.kt -> src/vendor/common/kotlin/coil3/network/internal/DefaultCacheStrategy.kt
|
||||
# | commonMain/kotlin/coil3/network/internal/SingleParameterLazy.kt -> src/vendor/common/kotlin/coil3/network/internal/SingleParameterLazy.kt
|
||||
# | commonMain/kotlin/coil3/network/internal/utils.kt -> src/vendor/common/kotlin/coil3/network/internal/utils.kt
|
||||
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonAndroidMain/kotlin/coil3/network/ConnectivityChecker.kt -> src/vendor/nonAndroid/kotlin/coil3/network/ConnectivityChecker.kt
|
||||
# | nonAndroidMain/kotlin/coil3/network/internal/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/network/internal/utils.nonAndroid.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# androidMain (2)
|
||||
# | androidMain/kotlin/coil3/network/ConnectivityChecker.kt -> src/vendor/android/kotlin/coil3/network/ConnectivityChecker.kt
|
||||
# | androidMain/kotlin/coil3/network/internal/utils.android.kt -> src/vendor/android/kotlin/coil3/network/internal/utils.android.kt
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -0,0 +1,18 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.coil3:coil-network-ktor3>
|
||||
final val coil3.network.ktor3.internal/initHook // coil3.network.ktor3.internal/initHook|{}initHook[0]
|
||||
final fun <get-initHook>(): kotlin/Any // coil3.network.ktor3.internal/initHook.<get-initHook>|<get-initHook>(){}[0]
|
||||
|
||||
final fun (io.ktor.client/HttpClient).coil3.network.ktor3/asNetworkClient(): coil3.network/NetworkClient // coil3.network.ktor3/asNetworkClient|asNetworkClient@io.ktor.client.HttpClient(){}[0]
|
||||
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(){}[0]
|
||||
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(io.ktor.client/HttpClient): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(io.ktor.client.HttpClient){}[0]
|
||||
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(io.ktor.client/HttpClient, coil3.network/ConcurrentRequestStrategy = ...): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(io.ktor.client.HttpClient;coil3.network.ConcurrentRequestStrategy){}[0]
|
||||
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(kotlin/Function0<io.ktor.client/HttpClient> = ..., kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ...): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(kotlin.Function0<io.ktor.client.HttpClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>){}[0]
|
||||
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(kotlin/Function0<io.ktor.client/HttpClient> = ..., kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ..., kotlin/Function0<coil3.network/ConcurrentRequestStrategy> = ...): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(kotlin.Function0<io.ktor.client.HttpClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>;kotlin.Function0<coil3.network.ConcurrentRequestStrategy>){}[0]
|
||||
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(kotlin/Function0<io.ktor.client/HttpClient>): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(kotlin.Function0<io.ktor.client.HttpClient>){}[0]
|
||||
@@ -0,0 +1,42 @@
|
||||
// :coil:coil-network-ktor3 - vendored VERBATIM from coil-kt/coil into src/vendor.
|
||||
// Ktor 3 network fetcher, on the port's existing ktor pin.
|
||||
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core's build file
|
||||
// and compose-fork.txt for why). Provenance = compose-fork.txt; never hand-edit
|
||||
// src/vendor - change the manifest + `python scripts/compose-fork/sync.py coil/coil-network-ktor3`.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api(project(":coil:coil-core"))
|
||||
api(project(":coil:coil-network-core"))
|
||||
api(libs.ktor.client.core)
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
val vNonAndroidMain = create("coilNonAndroidMain") {
|
||||
dependsOn(commonMain.get())
|
||||
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
|
||||
dependencies { implementation(libs.kotlinx.io.okio) }
|
||||
}
|
||||
nativeMain {
|
||||
dependsOn(vNonAndroidMain)
|
||||
kotlin.srcDir("src/vendor/native/kotlin")
|
||||
}
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-opt-in=coil3.annotation.InternalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.DelicateCoilApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# :coil:coil-network-ktor3 - Ktor 3 network fetcher. Upstream: linux + apple, no mingwX64.
|
||||
# Pairs with the port's existing ktor pin (the `ktor` entry in libs.versions.toml).
|
||||
# Upstream source sets collapse onto this port: it has NO android, jvm or js
|
||||
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
|
||||
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
|
||||
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
|
||||
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
|
||||
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
|
||||
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
|
||||
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
|
||||
# hierarchy template).
|
||||
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
|
||||
|
||||
SET_FOLDER=coil-network-ktor3/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/coil3/network/ktor3/KtorNetworkFetcher.kt -> src/vendor/common/kotlin/coil3/network/ktor3/KtorNetworkFetcher.kt
|
||||
# | commonMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.kt -> src/vendor/common/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.kt
|
||||
# | commonMain/kotlin/coil3/network/ktor3/internal/utils.kt -> src/vendor/common/kotlin/coil3/network/ktor3/internal/utils.kt
|
||||
nonJvmCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonJvmCommonMain/kotlin/coil3/network/ktor3/internal/utils.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/network/ktor3/internal/utils.nonJvmCommon.kt
|
||||
nativeMain/kotlin/ -> src/vendor/native/kotlin/
|
||||
# | nativeMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.native.kt -> src/vendor/native/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.native.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# jsMain (1)
|
||||
# | jsMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.js.kt -> src/vendor/js/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.js.kt
|
||||
# jvmCommonMain (1)
|
||||
# | jvmCommonMain/kotlin/coil3/network/ktor3/internal/utils.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/network/ktor3/internal/utils.jvmCommon.kt
|
||||
# wasmJsMain (1)
|
||||
# | wasmJsMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.wasmJs.kt
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -0,0 +1,123 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.coil3:coil-svg>
|
||||
abstract interface coil3.svg/Svg { // coil3.svg/Svg|null[0]
|
||||
abstract val height // coil3.svg/Svg.height|{}height[0]
|
||||
abstract fun <get-height>(): kotlin/Float // coil3.svg/Svg.height.<get-height>|<get-height>(){}[0]
|
||||
abstract val width // coil3.svg/Svg.width|{}width[0]
|
||||
abstract fun <get-width>(): kotlin/Float // coil3.svg/Svg.width.<get-width>|<get-width>(){}[0]
|
||||
|
||||
abstract var viewBox // coil3.svg/Svg.viewBox|{}viewBox[0]
|
||||
abstract fun <get-viewBox>(): coil3.svg/Svg.ViewBox? // coil3.svg/Svg.viewBox.<get-viewBox>|<get-viewBox>(){}[0]
|
||||
abstract fun <set-viewBox>(coil3.svg/Svg.ViewBox?) // coil3.svg/Svg.viewBox.<set-viewBox>|<set-viewBox>(coil3.svg.Svg.ViewBox?){}[0]
|
||||
|
||||
abstract fun asImage(kotlin/Int = ..., kotlin/Int = ...): coil3/Image // coil3.svg/Svg.asImage|asImage(kotlin.Int;kotlin.Int){}[0]
|
||||
abstract fun height(kotlin/String) // coil3.svg/Svg.height|height(kotlin.String){}[0]
|
||||
abstract fun options(coil3.request/Options) // coil3.svg/Svg.options|options(coil3.request.Options){}[0]
|
||||
abstract fun width(kotlin/String) // coil3.svg/Svg.width|width(kotlin.String){}[0]
|
||||
|
||||
abstract fun interface Parser { // coil3.svg/Svg.Parser|null[0]
|
||||
abstract fun parse(okio/BufferedSource): coil3.svg/Svg // coil3.svg/Svg.Parser.parse|parse(okio.BufferedSource){}[0]
|
||||
|
||||
final object Companion { // coil3.svg/Svg.Parser.Companion|null[0]
|
||||
final val DEFAULT // coil3.svg/Svg.Parser.Companion.DEFAULT|{}DEFAULT[0]
|
||||
final fun <get-DEFAULT>(): coil3.svg/Svg.Parser // coil3.svg/Svg.Parser.Companion.DEFAULT.<get-DEFAULT>|<get-DEFAULT>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final class ViewBox { // coil3.svg/Svg.ViewBox|null[0]
|
||||
constructor <init>(kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // coil3.svg/Svg.ViewBox.<init>|<init>(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
|
||||
|
||||
final val bottom // coil3.svg/Svg.ViewBox.bottom|{}bottom[0]
|
||||
final fun <get-bottom>(): kotlin/Float // coil3.svg/Svg.ViewBox.bottom.<get-bottom>|<get-bottom>(){}[0]
|
||||
final val left // coil3.svg/Svg.ViewBox.left|{}left[0]
|
||||
final fun <get-left>(): kotlin/Float // coil3.svg/Svg.ViewBox.left.<get-left>|<get-left>(){}[0]
|
||||
final val right // coil3.svg/Svg.ViewBox.right|{}right[0]
|
||||
final fun <get-right>(): kotlin/Float // coil3.svg/Svg.ViewBox.right.<get-right>|<get-right>(){}[0]
|
||||
final val top // coil3.svg/Svg.ViewBox.top|{}top[0]
|
||||
final fun <get-top>(): kotlin/Float // coil3.svg/Svg.ViewBox.top.<get-top>|<get-top>(){}[0]
|
||||
|
||||
final fun component1(): kotlin/Float // coil3.svg/Svg.ViewBox.component1|component1(){}[0]
|
||||
final fun component2(): kotlin/Float // coil3.svg/Svg.ViewBox.component2|component2(){}[0]
|
||||
final fun component3(): kotlin/Float // coil3.svg/Svg.ViewBox.component3|component3(){}[0]
|
||||
final fun component4(): kotlin/Float // coil3.svg/Svg.ViewBox.component4|component4(){}[0]
|
||||
final fun copy(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...): coil3.svg/Svg.ViewBox // coil3.svg/Svg.ViewBox.copy|copy(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
|
||||
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.svg/Svg.ViewBox.equals|equals(kotlin.Any?){}[0]
|
||||
final fun hashCode(): kotlin/Int // coil3.svg/Svg.ViewBox.hashCode|hashCode(){}[0]
|
||||
final fun toString(): kotlin/String // coil3.svg/Svg.ViewBox.toString|toString(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final class coil3.svg/SvgDecoder : coil3.decode/Decoder { // coil3.svg/SvgDecoder|null[0]
|
||||
constructor <init>(coil3.decode/ImageSource, coil3.request/Options, coil3.svg/Svg.Parser = ..., kotlin/Function1<coil3/PlatformContext, kotlin/Float> = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.<init>|<init>(coil3.decode.ImageSource;coil3.request.Options;coil3.svg.Svg.Parser;kotlin.Function1<coil3.PlatformContext,kotlin.Float>;kotlin.Boolean;kotlin.Boolean){}[0]
|
||||
constructor <init>(coil3.decode/ImageSource, coil3.request/Options, kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.<init>|<init>(coil3.decode.ImageSource;coil3.request.Options;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
|
||||
|
||||
final val density // coil3.svg/SvgDecoder.density|{}density[0]
|
||||
final fun <get-density>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.density.<get-density>|<get-density>(){}[0]
|
||||
final val parser // coil3.svg/SvgDecoder.parser|{}parser[0]
|
||||
final fun <get-parser>(): coil3.svg/Svg.Parser // coil3.svg/SvgDecoder.parser.<get-parser>|<get-parser>(){}[0]
|
||||
final val renderToBitmap // coil3.svg/SvgDecoder.renderToBitmap|{}renderToBitmap[0]
|
||||
final fun <get-renderToBitmap>(): kotlin/Boolean // coil3.svg/SvgDecoder.renderToBitmap.<get-renderToBitmap>|<get-renderToBitmap>(){}[0]
|
||||
final val scaleToDensity // coil3.svg/SvgDecoder.scaleToDensity|{}scaleToDensity[0]
|
||||
final fun <get-scaleToDensity>(): kotlin/Boolean // coil3.svg/SvgDecoder.scaleToDensity.<get-scaleToDensity>|<get-scaleToDensity>(){}[0]
|
||||
final val useViewBoundsAsIntrinsicSize // coil3.svg/SvgDecoder.useViewBoundsAsIntrinsicSize|{}useViewBoundsAsIntrinsicSize[0]
|
||||
final fun <get-useViewBoundsAsIntrinsicSize>(): kotlin/Boolean // coil3.svg/SvgDecoder.useViewBoundsAsIntrinsicSize.<get-useViewBoundsAsIntrinsicSize>|<get-useViewBoundsAsIntrinsicSize>(){}[0]
|
||||
|
||||
final suspend fun decode(): coil3.decode/DecodeResult // coil3.svg/SvgDecoder.decode|decode(){}[0]
|
||||
|
||||
final class Factory : coil3.decode/Decoder.Factory { // coil3.svg/SvgDecoder.Factory|null[0]
|
||||
constructor <init>(coil3.svg/Svg.Parser = ..., kotlin/Function1<coil3/PlatformContext, kotlin/Float> = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.Factory.<init>|<init>(coil3.svg.Svg.Parser;kotlin.Function1<coil3.PlatformContext,kotlin.Float>;kotlin.Boolean;kotlin.Boolean){}[0]
|
||||
constructor <init>(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.Factory.<init>|<init>(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
|
||||
|
||||
final val density // coil3.svg/SvgDecoder.Factory.density|{}density[0]
|
||||
final fun <get-density>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.Factory.density.<get-density>|<get-density>(){}[0]
|
||||
final val parser // coil3.svg/SvgDecoder.Factory.parser|{}parser[0]
|
||||
final fun <get-parser>(): coil3.svg/Svg.Parser // coil3.svg/SvgDecoder.Factory.parser.<get-parser>|<get-parser>(){}[0]
|
||||
final val renderToBitmap // coil3.svg/SvgDecoder.Factory.renderToBitmap|{}renderToBitmap[0]
|
||||
final fun <get-renderToBitmap>(): kotlin/Boolean // coil3.svg/SvgDecoder.Factory.renderToBitmap.<get-renderToBitmap>|<get-renderToBitmap>(){}[0]
|
||||
final val scaleToDensity // coil3.svg/SvgDecoder.Factory.scaleToDensity|{}scaleToDensity[0]
|
||||
final fun <get-scaleToDensity>(): kotlin/Boolean // coil3.svg/SvgDecoder.Factory.scaleToDensity.<get-scaleToDensity>|<get-scaleToDensity>(){}[0]
|
||||
final val useViewBoundsAsIntrinsicSize // coil3.svg/SvgDecoder.Factory.useViewBoundsAsIntrinsicSize|{}useViewBoundsAsIntrinsicSize[0]
|
||||
final fun <get-useViewBoundsAsIntrinsicSize>(): kotlin/Boolean // coil3.svg/SvgDecoder.Factory.useViewBoundsAsIntrinsicSize.<get-useViewBoundsAsIntrinsicSize>|<get-useViewBoundsAsIntrinsicSize>(){}[0]
|
||||
|
||||
final fun create(coil3.fetch/SourceFetchResult, coil3.request/Options, coil3/ImageLoader): coil3.decode/Decoder? // coil3.svg/SvgDecoder.Factory.create|create(coil3.fetch.SourceFetchResult;coil3.request.Options;coil3.ImageLoader){}[0]
|
||||
}
|
||||
|
||||
final object Companion { // coil3.svg/SvgDecoder.Companion|null[0]
|
||||
final val NO_DENSITY // coil3.svg/SvgDecoder.Companion.NO_DENSITY|{}NO_DENSITY[0]
|
||||
final fun <get-NO_DENSITY>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.Companion.NO_DENSITY.<get-NO_DENSITY>|<get-NO_DENSITY>(){}[0]
|
||||
final val PLATFORM_DENSITY // coil3.svg/SvgDecoder.Companion.PLATFORM_DENSITY|{}PLATFORM_DENSITY[0]
|
||||
final fun <get-PLATFORM_DENSITY>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.Companion.PLATFORM_DENSITY.<get-PLATFORM_DENSITY>|<get-PLATFORM_DENSITY>(){}[0]
|
||||
}
|
||||
}
|
||||
|
||||
final class coil3.svg/SvgImage : coil3/Image { // coil3.svg/SvgImage|null[0]
|
||||
constructor <init>(org.jetbrains.skia.svg/SVGDOM, kotlin/Int, kotlin/Int) // coil3.svg/SvgImage.<init>|<init>(org.jetbrains.skia.svg.SVGDOM;kotlin.Int;kotlin.Int){}[0]
|
||||
|
||||
final val height // coil3.svg/SvgImage.height|{}height[0]
|
||||
final fun <get-height>(): kotlin/Int // coil3.svg/SvgImage.height.<get-height>|<get-height>(){}[0]
|
||||
final val shareable // coil3.svg/SvgImage.shareable|{}shareable[0]
|
||||
final fun <get-shareable>(): kotlin/Boolean // coil3.svg/SvgImage.shareable.<get-shareable>|<get-shareable>(){}[0]
|
||||
final val size // coil3.svg/SvgImage.size|{}size[0]
|
||||
final fun <get-size>(): kotlin/Long // coil3.svg/SvgImage.size.<get-size>|<get-size>(){}[0]
|
||||
final val svg // coil3.svg/SvgImage.svg|{}svg[0]
|
||||
final fun <get-svg>(): org.jetbrains.skia.svg/SVGDOM // coil3.svg/SvgImage.svg.<get-svg>|<get-svg>(){}[0]
|
||||
final val width // coil3.svg/SvgImage.width|{}width[0]
|
||||
final fun <get-width>(): kotlin/Int // coil3.svg/SvgImage.width.<get-width>|<get-width>(){}[0]
|
||||
|
||||
final fun draw(org.jetbrains.skia/Canvas) // coil3.svg/SvgImage.draw|draw(org.jetbrains.skia.Canvas){}[0]
|
||||
}
|
||||
|
||||
final val coil3.svg.internal/initHook // coil3.svg.internal/initHook|{}initHook[0]
|
||||
final fun <get-initHook>(): kotlin/Any // coil3.svg.internal/initHook.<get-initHook>|<get-initHook>(){}[0]
|
||||
final val coil3.svg/height // coil3.svg/height|@coil3.svg.Svg.ViewBox{}height[0]
|
||||
final fun (coil3.svg/Svg.ViewBox).<get-height>(): kotlin/Float // coil3.svg/height.<get-height>|<get-height>@coil3.svg.Svg.ViewBox(){}[0]
|
||||
final val coil3.svg/width // coil3.svg/width|@coil3.svg.Svg.ViewBox{}width[0]
|
||||
final fun (coil3.svg/Svg.ViewBox).<get-width>(): kotlin/Float // coil3.svg/width.<get-width>|<get-width>@coil3.svg.Svg.ViewBox(){}[0]
|
||||
|
||||
final fun (coil3.decode/DecodeUtils).coil3.svg/isSvg(okio/BufferedSource): kotlin/Boolean // coil3.svg/isSvg|isSvg@coil3.decode.DecodeUtils(okio.BufferedSource){}[0]
|
||||
@@ -0,0 +1,55 @@
|
||||
// :coil:coil-svg - SVG decoding, vendored VERBATIM from coil-kt/coil into src/vendor.
|
||||
// Upstream: linux + apple, no mingwX64. The non-android decoder goes through skiko's
|
||||
// SVGDOM, so mingwX64 binds the bitsycore skiko FORK like :compose:ui:ui-graphics.
|
||||
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core).
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies { api(project(":coil:coil-core")) }
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
// Two PARALLEL skiko branches (official for macOS/Linux, the bitsycore fork for
|
||||
// mingwX64), each two levels deep so nonAndroid `expect`s and nativeMain
|
||||
// `actual`s are not siblings. See coil/coil-core/build.gradle.kts for the why.
|
||||
fun skikoBranch(inName: String, inSkiko: Any): org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet {
|
||||
val vNonAndroid = create("${inName}NonAndroidMain") {
|
||||
dependsOn(commonMain.get())
|
||||
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
|
||||
dependencies { implementation(inSkiko) }
|
||||
}
|
||||
return create("${inName}NativeMain") {
|
||||
dependsOn(vNonAndroid)
|
||||
kotlin.srcDir("src/vendor/native/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
val vOfficial = skikoBranch("coilSvgOfficial", libs.skiko)
|
||||
get("macosArm64Main").dependsOn(vOfficial)
|
||||
get("linuxX64Main").dependsOn(vOfficial)
|
||||
get("linuxArm64Main").dependsOn(vOfficial)
|
||||
|
||||
if (vHostSupportsMingw) {
|
||||
val vFork = skikoBranch(
|
||||
"coilSvgFork",
|
||||
"com.bitsycore.skiko:skiko:${providers.gradleProperty("skikoMingwVersion").getOrElse(libs.versions.skikoMingw.get())}",
|
||||
)
|
||||
get("mingwX64Main").dependsOn(vFork)
|
||||
}
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-opt-in=coil3.annotation.InternalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.DelicateCoilApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# :coil:coil-svg - SVG decoding. Upstream: linux + apple, no mingwX64.
|
||||
# The non-android decoder goes through skiko's SVGDOM.
|
||||
# Upstream source sets collapse onto this port: it has NO android, jvm or js
|
||||
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
|
||||
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
|
||||
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
|
||||
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
|
||||
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
|
||||
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
|
||||
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
|
||||
# hierarchy template).
|
||||
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
|
||||
|
||||
SET_FOLDER=coil-svg/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/coil3/svg/Svg.kt -> src/vendor/common/kotlin/coil3/svg/Svg.kt
|
||||
# | commonMain/kotlin/coil3/svg/SvgDecoder.kt -> src/vendor/common/kotlin/coil3/svg/SvgDecoder.kt
|
||||
# | commonMain/kotlin/coil3/svg/decodeUtils.kt -> src/vendor/common/kotlin/coil3/svg/decodeUtils.kt
|
||||
# | commonMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.kt -> src/vendor/common/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.kt
|
||||
# | commonMain/kotlin/coil3/svg/internal/parseSvg.kt -> src/vendor/common/kotlin/coil3/svg/internal/parseSvg.kt
|
||||
# | commonMain/kotlin/coil3/svg/internal/utils.kt -> src/vendor/common/kotlin/coil3/svg/internal/utils.kt
|
||||
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonAndroidMain/kotlin/coil3/svg/SvgImage.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/SvgImage.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/svg/internal/parseSvg.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/internal/parseSvg.nonAndroid.kt
|
||||
# | nonAndroidMain/kotlin/coil3/svg/internal/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/internal/utils.nonAndroid.kt
|
||||
nonJvmCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonJvmCommonMain/kotlin/coil3/svg/internal/utils.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/internal/utils.nonJvmCommon.kt
|
||||
nativeMain/kotlin/ -> src/vendor/native/kotlin/
|
||||
# | nativeMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.native.kt -> src/vendor/native/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.native.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# androidMain (4)
|
||||
# | androidMain/kotlin/coil3/svg/SvgImage.android.kt -> src/vendor/android/kotlin/coil3/svg/SvgImage.android.kt
|
||||
# | androidMain/kotlin/coil3/svg/imageRequests.android.kt -> src/vendor/android/kotlin/coil3/svg/imageRequests.android.kt
|
||||
# | androidMain/kotlin/coil3/svg/internal/parseSvg.android.kt -> src/vendor/android/kotlin/coil3/svg/internal/parseSvg.android.kt
|
||||
# | androidMain/kotlin/coil3/svg/internal/utils.android.kt -> src/vendor/android/kotlin/coil3/svg/internal/utils.android.kt
|
||||
# jsMain (1)
|
||||
# | jsMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.js.kt -> src/vendor/js/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.js.kt
|
||||
# jvmCommonMain (1)
|
||||
# | jvmCommonMain/kotlin/coil3/svg/internal/utils.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/svg/internal/utils.jvmCommon.kt
|
||||
# wasmJsMain (1)
|
||||
# | wasmJsMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.wasmJs.kt
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -0,0 +1,19 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.coil3:coil>
|
||||
final object coil3/SingletonImageLoader { // coil3/SingletonImageLoader|null[0]
|
||||
final fun get(coil3/PlatformContext): coil3/ImageLoader // coil3/SingletonImageLoader.get|get(coil3.PlatformContext){}[0]
|
||||
final fun reset() // coil3/SingletonImageLoader.reset|reset(){}[0]
|
||||
final fun setSafe(coil3/SingletonImageLoader.Factory) // coil3/SingletonImageLoader.setSafe|setSafe(coil3.SingletonImageLoader.Factory){}[0]
|
||||
final fun setUnsafe(coil3/ImageLoader) // coil3/SingletonImageLoader.setUnsafe|setUnsafe(coil3.ImageLoader){}[0]
|
||||
final fun setUnsafe(coil3/SingletonImageLoader.Factory) // coil3/SingletonImageLoader.setUnsafe|setUnsafe(coil3.SingletonImageLoader.Factory){}[0]
|
||||
|
||||
abstract fun interface Factory { // coil3/SingletonImageLoader.Factory|null[0]
|
||||
abstract fun newImageLoader(coil3/PlatformContext): coil3/ImageLoader // coil3/SingletonImageLoader.Factory.newImageLoader|newImageLoader(coil3.PlatformContext){}[0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// :coil:coil - vendored VERBATIM from coil-kt/coil into src/vendor.
|
||||
// The singleton ImageLoader facade. Upstream: linux + apple, no mingwX64.
|
||||
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core's build file
|
||||
// and compose-fork.txt for why). Provenance = compose-fork.txt; never hand-edit
|
||||
// src/vendor - change the manifest + `python scripts/compose-fork/sync.py coil/coil`.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies { api(project(":coil:coil-core")) }
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
val vNonAndroidMain = create("coilNonAndroidMain") {
|
||||
dependsOn(commonMain.get())
|
||||
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
|
||||
}
|
||||
nativeMain { dependsOn(vNonAndroidMain) }
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-opt-in=coil3.annotation.InternalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.DelicateCoilApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# :coil:coil - the singleton ImageLoader facade. Upstream: linux + apple, no mingwX64.
|
||||
# Upstream source sets collapse onto this port: it has NO android, jvm or js
|
||||
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
|
||||
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
|
||||
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
|
||||
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
|
||||
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
|
||||
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
|
||||
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
|
||||
# hierarchy template).
|
||||
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
|
||||
|
||||
SET_FOLDER=coil/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/coil3/SingletonImageLoader.kt -> src/vendor/common/kotlin/coil3/SingletonImageLoader.kt
|
||||
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
|
||||
# | nonAndroidMain/kotlin/coil3/SingletonImageLoader.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/SingletonImageLoader.nonAndroid.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# androidMain (2)
|
||||
# | androidMain/kotlin/coil3/SingletonImageLoader.android.kt -> src/vendor/android/kotlin/coil3/SingletonImageLoader.android.kt
|
||||
# | androidMain/kotlin/coil3/singletonImageLoaders.android.kt -> src/vendor/android/kotlin/coil3/singletonImageLoaders.android.kt
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Show manifest properties: 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]
|
||||
constructor <init>() // org.jetbrains.compose.resources/ExperimentalResourceApi.<init>|<init>(){}[0]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// :components-resources - the official Compose Multiplatform resources runtime
|
||||
// (org.jetbrains.compose.components:components-resources), vendored because the
|
||||
// Maven artifact ships no mingwX64/linux klibs. Public API is byte-for-byte
|
||||
// upstream (painterResource / stringResource / Font / qualifiers / Res codegen
|
||||
// compatibility); the platform actuals are this port's: data.kres reading,
|
||||
// image decode via :ui's Skia decoder, SDL locale/theme environment. Apps' JVM
|
||||
// targets keep using the official Maven artifact - this module is native-only.
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64()
|
||||
linuxX64()
|
||||
macosArm64()
|
||||
if (vHostSupportsMingw) mingwX64()
|
||||
|
||||
applyDefaultHierarchyTemplate()
|
||||
|
||||
sourceSets {
|
||||
commonMain {
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
dependencies {
|
||||
// foundation (isSystemInDarkTheme in ResourceEnvironment) pulls the
|
||||
// whole ui surface + loadComposeResourceBytes + the sdl3 cinterop
|
||||
// types transitively (all edges are api).
|
||||
api(project(":compose:foundation:foundation"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
}
|
||||
nativeMain {
|
||||
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
|
||||
// (decodeEncodedImageBitmap). Skiko-free, so a single native set covers
|
||||
// every target.
|
||||
}
|
||||
}
|
||||
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
// Upstream's library build opts the whole module into its own
|
||||
// annotations; the vendored files rely on that.
|
||||
"-opt-in=org.jetbrains.compose.resources.InternalResourceApi",
|
||||
"-opt-in=org.jetbrains.compose.resources.ExperimentalResourceApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
# Files vendored VERBATIM from JetBrains/compose-multiplatform (the umbrella repo —
|
||||
# Files vendored VERBATIM from JetBrains/compose-multiplatform (the umbrella repo -
|
||||
# NOT compose-multiplatform-core) into :components-resources. The official
|
||||
# org.jetbrains.compose.components:components-resources artifact publishes NO
|
||||
# mingwX64/linux variants, so the runtime library is vendored and the platform
|
||||
# actuals are project code:
|
||||
# src/nativeMain — ResourceReader (data.kres), getSystemEnvironment (SDL),
|
||||
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser)
|
||||
# src/sdlRendererMain — image decode via the :ui SDL3_image hook, SVG unsupported,
|
||||
# Font via the project font registry
|
||||
# vendor/skikoRenderer — upstream skikoMain Skia-bound files, verbatim
|
||||
# src/nativeMain - ResourceReader (data.kres), getSystemEnvironment (SDL),
|
||||
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser),
|
||||
# and the Font/Image resource actuals: renderer-agnostic
|
||||
# project code (image decode via the :ui-graphics Skia
|
||||
# decoder, Font via the project font registry)
|
||||
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
|
||||
|
||||
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
|
||||
@@ -43,21 +43,20 @@ blockingMain/kotlin/ -> src/vendor/native/kotlin/
|
||||
nativeMain/kotlin/ -> src/vendor/native/kotlin/
|
||||
# | nativeMain/kotlin/org/jetbrains/compose/resources/ImageResources.native.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ImageResources.native.kt
|
||||
# | nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt
|
||||
# Darwin-only (Foundation NSXMLParser) — replaced by a pure-Kotlin project parser
|
||||
# Darwin-only (Foundation NSXMLParser) - replaced by a pure-Kotlin project parser
|
||||
# in src/nativeMain so mingwX64/linux compile.
|
||||
!nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt
|
||||
|
||||
# Portable pieces of upstream skikoMain (no Skia references — pure delegation /
|
||||
# runtime-only) — every native target gets them.
|
||||
# Portable pieces of upstream skikoMain (no Skia references - pure delegation /
|
||||
# runtime-only) - every native target gets them.
|
||||
skikoMain/kotlin/org/jetbrains/compose/resources/ImageDecoders.skiko.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ImageDecoders.skiko.kt
|
||||
skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt
|
||||
|
||||
# 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
|
||||
# doesn't expose. Since the sdlRenderer actuals (src/sdlRendererMain/kotlin/) use
|
||||
# pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap) that work
|
||||
# on either renderer, the module's build shares those with skikoRendererMain
|
||||
# instead of vendoring the upstream files. Opt them out of the sync.
|
||||
# doesn't expose. The module's own Font/Image actuals (src/nativeMain/kotlin/)
|
||||
# use pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap)
|
||||
# instead, so opt the upstream Skia-bound files out.
|
||||
!skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
|
||||
!skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
|
||||
!skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt
|
||||
@@ -1,5 +1,11 @@
|
||||
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
|
||||
|
||||
// VENDOR-REIMPL(COMPOSE_REF): components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt @ v1.12.0
|
||||
// Project REIMPLEMENTATION, not a derived copy: same package + signatures,
|
||||
// different body (the port replaces upstream's skiko scene/platform layer).
|
||||
// Upstream edits to the counterpart do NOT need reconciling here - re-check
|
||||
// only if the expect/actual SIGNATURES change.
|
||||
|
||||
package org.jetbrains.compose.resources
|
||||
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
@@ -15,16 +21,16 @@ import com.compose.sdl.icons.IconFont
|
||||
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
|
||||
registry (IconFont.registerIcon — the general byte-font table, not just
|
||||
/** The Skia text stack resolves fonts by FAMILY NAME through the project font
|
||||
registry (IconFont.registerIcon - the general byte-font table, not just
|
||||
icons). A font resource therefore loads its bytes through the resource
|
||||
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;
|
||||
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>")
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
|
||||
|
||||
// VENDOR-REIMPL(COMPOSE_REF): components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt @ v1.12.0
|
||||
// Project REIMPLEMENTATION, not a derived copy: same package + signatures,
|
||||
// different body (the port replaces upstream's skiko scene/platform layer).
|
||||
// Upstream edits to the counterpart do NOT need reconciling here - re-check
|
||||
// only if the expect/actual SIGNATURES change.
|
||||
|
||||
package org.jetbrains.compose.resources
|
||||
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.compose.sdl.graphics.decodeEncodedImageBitmap
|
||||
import com.compose.sdl.graphics.decodeSvgAt
|
||||
import com.compose.sdl.graphics.svgIntrinsicSize
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// ==================
|
||||
// MARK: Image actuals - Skia decode via the :ui-graphics hook
|
||||
// ==================
|
||||
|
||||
/** Decoding goes through the :ui-graphics Skia hook the SDL backend registers
|
||||
at init (decodeEncodedImageBitmap). resourceDensity/targetDensity are ignored:
|
||||
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. */
|
||||
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
|
||||
decodeEncodedImageBitmap(this)
|
||||
?: error("Image decode failed - is the render backend initialised before painterResource ran?")
|
||||
|
||||
/** SVG element = the raw document bytes; rendering is size-driven (see SvgPainter). */
|
||||
internal actual class SvgElement(val bytes: ByteArray)
|
||||
|
||||
internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
||||
|
||||
/** Resolution-independent SVG painter: reports the document's intrinsic size for
|
||||
layout, but RE-RASTERISES the vector at the actual draw size each time the draw
|
||||
size changes (1-entry size cache), so a scaled-up SVG stays crisp instead of
|
||||
upscaling one intrinsic-size bitmap. Mirrors upstream desktop's SVGPainter. */
|
||||
internal actual fun SvgElement.toSvgPainter(density: Density): Painter = SvgPainter(bytes)
|
||||
|
||||
private class SvgPainter(private val bytes: ByteArray) : Painter() {
|
||||
|
||||
override val intrinsicSize: Size = svgIntrinsicSize(bytes) ?: Size.Unspecified
|
||||
|
||||
private var mAlpha: Float = 1f
|
||||
private var mColorFilter: ColorFilter? = null
|
||||
private var mCacheKey: Long = -1L
|
||||
private var mCacheBitmap: ImageBitmap? = null
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean { mAlpha = alpha; return true }
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { mColorFilter = colorFilter; return true }
|
||||
|
||||
private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
|
||||
val vKey = (inWidthPx.toLong() shl 32) or inHeightPx.toLong()
|
||||
if (vKey == mCacheKey && mCacheBitmap != null) return mCacheBitmap
|
||||
val vBitmap = decodeSvgAt(bytes, inWidthPx, inHeightPx) ?: return null
|
||||
mCacheKey = vKey
|
||||
mCacheBitmap = vBitmap
|
||||
return vBitmap
|
||||
}
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
val vWpx = size.width.roundToInt()
|
||||
val vHpx = size.height.roundToInt()
|
||||
if (vWpx <= 0 || vHpx <= 0) return
|
||||
val vBitmap = rasterFor(vWpx, vHpx) ?: return
|
||||
drawImage(
|
||||
vBitmap,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(vBitmap.width, vBitmap.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(vWpx, vHpx),
|
||||
alpha = mAlpha,
|
||||
colorFilter = mColorFilter,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
|
||||
|
||||
package org.jetbrains.compose.resources
|
||||
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
import com.compose.sdl.res.preferredLocaleProvider
|
||||
import com.compose.sdl.res.systemThemeIsDarkProvider
|
||||
|
||||
// ==================
|
||||
// MARK: ResourceEnvironment - platform-env-seam actual
|
||||
// ==================
|
||||
|
||||
/** Non-composable system environment for qualifier resolution (values-fr,
|
||||
drawable-dark, …): locale + theme come from the platform-env seams installed
|
||||
by the SDL layer, so :components-resources carries no dependency on the sdl3
|
||||
cinterop. Density is reported as 1f - under this port's Option-B density flow
|
||||
layout runs in physical pixels and drawables are bundled at a single density,
|
||||
so the mdpi bucket is always the right one. The COMPOSABLE path
|
||||
(rememberResourceEnvironment) doesn't use this: it reads LocalDensity /
|
||||
isSystemInDarkTheme from the composition. */
|
||||
internal actual fun getSystemEnvironment(): ResourceEnvironment {
|
||||
val (vLanguage, vRegion) = preferredLocaleProvider?.invoke() ?: ("" to "")
|
||||
val vDark = systemThemeIsDarkProvider?.invoke() ?: false
|
||||
return ResourceEnvironment(
|
||||
language = LanguageQualifier(vLanguage),
|
||||
// SDL locales carry language+country only - no script subtag.
|
||||
script = ScriptQualifier(""),
|
||||
region = RegionQualifier(vRegion),
|
||||
theme = ThemeQualifier.selectByValue(isDark = vDark),
|
||||
density = DensityQualifier.selectByDensity(1f),
|
||||
)
|
||||
}
|
||||