chore: update native packaging

This commit is contained in:
Tomás Romero committed 2026-09-10 18:58:39 +00:00
1 parent d0abbaaa4f
commit 4a32a2c4f2
22 files changed
+466 -7

No files matched your search

+12
View File
@@ -2,6 +2,10 @@
# one disposable build tree. Cargo resolves this relative path to <repo>/target.
[build]
target-dir = "target"
# Compiling Slint's generated library and test harness together can exhaust
# memory on a 16 GiB development host, including editor background checks.
# Larger builders can opt into more parallelism with Cargo's -j option.
jobs = 1
# Slint's desktop guide recommends matching Linux's larger main-thread stack on
# Windows. This prevents stack overflows in debug builds with deep UI trees.
@@ -10,3 +14,11 @@ rustflags = ["-C", "link-arg=/STACK:8000000"]
[target.aarch64-pc-windows-msvc]
rustflags = ["-C", "link-arg=/STACK:8000000"]
# NDK r27 and newer devices must agree on 16 KiB ELF load alignment. This also
# remains compatible with 4 KiB Android devices and matches bundled PDFium.
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
[target.x86_64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
+4
View File
@@ -210,6 +210,8 @@ jobs:
New-Item -ItemType Directory -Force target/package/windows | Out-Null
New-Item -ItemType Directory -Force "target/package/windows/Licenses/Google Sans Flex" | Out-Null
New-Item -ItemType Directory -Force "target/package/windows/Licenses/Noto Emoji" | Out-Null
python scripts/stage-pdfium.py win-x64 target/package/windows
if ($LASTEXITCODE -ne 0) { throw "PDFium staging failed" }
Copy-Item target/release/flectar-mail.exe target/package/windows/
Copy-Item README.md target/package/windows/
Copy-Item LICENSE target/package/windows/
@@ -219,6 +221,8 @@ jobs:
Copy-Item resources/fonts/google-sans-flex/README.md "target/package/windows/Licenses/Google Sans Flex/"
Copy-Item resources/fonts/noto-emoji/OFL.txt "target/package/windows/Licenses/Noto Emoji/"
Copy-Item resources/fonts/noto-emoji/README.md "target/package/windows/Licenses/Noto Emoji/"
python scripts/test-pdf-preview.py target/package/windows/flectar-mail.exe
if ($LASTEXITCODE -ne 0) { throw "Packaged PDF preview test failed" }
Compress-Archive -Path target/package/windows/* -DestinationPath target/flectar-mail-windows-x64.zip -Force
- name: Install pinned Inno Setup compiler
+1
View File
@@ -7,6 +7,7 @@
*.AppImage
/platform/android/gradle/.gradle/
/platform/android/gradle/app/build/
/platform/android/gradle/build/
/platform/ios/*.xcodeproj/
# Local OAuth application registrations (see .env.example)
+17
View File
@@ -36,6 +36,23 @@ notices and license texts; the Apache-2.0 license is also included in
| percent-encoding | 2.3.2 | [servo/rust-url](https://github.com/servo/rust-url/) | Fragment and mailto decoding |
| base64 | 0.22.1 | [marshallpierce/rust-base64](https://github.com/marshallpierce/rust-base64) | Export CSP hash encoding |
## PDF preview
Desktop, Android, and iOS packages include a non-V8 PDFium runtime from
[bblanchon/pdfium-binaries](https://github.com/bblanchon/pdfium-binaries), built
from [PDFium](https://pdfium.googlesource.com/pdfium/). The selected release and
per-platform SHA-256 digests are pinned in `scripts/stage-pdfium.py`. Every
package retains the archive's `LICENSE`, complete `licenses/` directory, and
build provenance under `pdfium-licenses` beside the private desktop library,
Android assets at `licenses/PDFium`, and iOS bundle resources at `Licenses/PDFium`.
PDFium's third-party components retain their respective licenses.
The Rust interface is [pdfium-render](https://github.com/ajrcarey/pdfium-render)
0.9.4, used under its Apache-2.0 option. Linux process restrictions use
[Landlock](https://github.com/landlock-lsm/rust-landlock) and
[seccompiler](https://github.com/rust-vmm/seccompiler), under their Apache-2.0
options. Rust package versions and source checksums are recorded in `Cargo.lock`.
## Phosphor Icons
The bundled SVG icons in `ui/icons/phosphor` are sourced from
+25 -1
View File
@@ -26,6 +26,10 @@ android {
layout.buildDirectory.dir("generated/flectar-assets"),
)
// PDFium is loaded by its absolute installed nativeLibraryDir path. Let the
// package manager extract and protect it; no temporary executable files.
packaging.jniLibs.useLegacyPackaging = true
signingConfigs {
create("releaseFromEnvironment") {
val keystore = providers.environmentVariable("FLECTAR_ANDROID_KEYSTORE").orNull
@@ -56,8 +60,28 @@ val generateFlectarLicenseAssets by tasks.registering(Copy::class) {
}
}
val generatePdfiumLicenseAssets by tasks.registering(Copy::class) {
into(layout.buildDirectory.dir("generated/flectar-assets/licenses/PDFium"))
from(repositoryAndroidTarget.dir("gradle-jni")) {
include("*/pdfium-licenses/**")
}
}
val verifyPdfiumRuntime by tasks.registering {
doLast {
fileTree(repositoryAndroidTarget.dir("gradle-jni")).matching {
include("*/libflectar_mail_android.so")
}.forEach { appLibrary ->
check(appLibrary.resolveSibling("libpdfium.so").isFile) {
"Missing bundled PDFium for ${appLibrary.parentFile.name}; run scripts/stage-pdfium.py for this ABI."
}
check(appLibrary.parentFile.resolve("pdfium-licenses/build.json").isFile) {
"Missing PDFium provenance and license notices."
}
}
}
}
tasks.named("preBuild").configure {
dependsOn(generateFlectarLicenseAssets)
dependsOn(generateFlectarLicenseAssets, generatePdfiumLicenseAssets, verifyPdfiumRuntime)
}
dependencies {
+40 -1
View File
@@ -15,11 +15,12 @@ export CARGO_PROFILE_RELEASE_DEBUG="${CARGO_PROFILE_RELEASE_DEBUG:-1}"
export CARGO_TARGET_DIR="${DERIVED_FILE_DIR:?}/cargo"
is_simulator=0
if [[ "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
if [[ "${PLATFORM_NAME:-}" == "iphonesimulator" || "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
is_simulator=1
fi
executables=()
pdfium_libraries=()
for arch in $ARCHS; do
case "$arch" in
arm64)
@@ -47,10 +48,48 @@ for arch in $ARCHS; do
--features remote-content,ios \
"${release_args[@]}"
executables+=("$CARGO_TARGET_DIR/$cargo_target/$profile/$binary_name")
case "$cargo_target" in
aarch64-apple-ios) pdfium_target=ios-device-arm64 ;;
aarch64-apple-ios-sim) pdfium_target=ios-simulator-arm64 ;;
x86_64-apple-ios) pdfium_target=ios-simulator-x64 ;;
esac
pdfium_stage="$DERIVED_FILE_DIR/pdfium/$pdfium_target"
python3 "$project_dir/scripts/stage-pdfium.py" "$pdfium_target" "$pdfium_stage" \
--cache "$project_dir/target/pdfium-downloads"
pdfium_libraries+=("$pdfium_stage/libpdfium.dylib")
pdfium_notices="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Licenses/PDFium/$pdfium_target"
mkdir -p "$pdfium_notices"
cp -R "$pdfium_stage/pdfium-licenses/." "$pdfium_notices/"
done
lipo -create -output "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" "${executables[@]}"
# iOS permits embedded dynamic frameworks, not loose third-party dylibs.
# Device and simulator slices are never mixed; Xcode chooses the SDK above.
framework="$TARGET_BUILD_DIR/${FRAMEWORKS_FOLDER_PATH:?}/PDFium.framework"
mkdir -p "$framework"
lipo -create -output "$framework/PDFium" "${pdfium_libraries[@]}"
chmod 755 "$framework/PDFium"
install_name_tool -id '@rpath/PDFium.framework/PDFium' "$framework/PDFium"
python3 "$project_dir/scripts/write-pdfium-framework-plist.py" "$framework/Info.plist" "$is_simulator"
if [[ "${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]]; then
# Simulator frameworks also need an ad-hoc signature after install_name_tool.
if [[ "$is_simulator" -eq 1 ]]; then
framework_identity="${EXPANDED_CODE_SIGN_IDENTITY:--}"
else
framework_identity="${EXPANDED_CODE_SIGN_IDENTITY:?A device build requires an Xcode signing identity}"
fi
codesign --force --sign "$framework_identity" "$framework"
codesign --verify --strict "$framework"
fi
# Fail packaging if an unexpected dependency or install name escaped staging.
framework_dependencies="$(otool -L "$framework/PDFium")"
if printf '%s\n' "$framework_dependencies" | awk '/^[[:space:]]/ {print $1}' \
| grep -Ev '^(@rpath/PDFium.framework/PDFium|/System/Library/|/usr/lib/)'; then
printf 'PDFium framework has an unbundled dependency.\n' >&2
exit 1
fi
# The UI fonts are embedded in the executable. Ship their OFL notices in the
# application bundle as required for redistributed copies of the fonts.
font_license_root="$TARGET_BUILD_DIR/${UNLOCALIZED_RESOURCES_FOLDER_PATH:?}/Licenses"
+1 -1
View File
@@ -10,7 +10,7 @@ targets:
FlectarMail:
type: application
platform: iOS
deploymentTarget: "12.0"
deploymentTarget: "17.0"
info:
path: Info.plist
properties:
+4
View File
@@ -85,6 +85,10 @@ fi
export MALLOC_ARENA_MAX="${MALLOC_ARENA_MAX:-1}"
export MALLOC_TRIM_THRESHOLD_="${MALLOC_TRIM_THRESHOLD_:-65536}"
# Preserve the host search path for external programs such as the browser.
export FLECTAR_MAIL_HOST_LD_LIBRARY_PATH="${LD_LIBRARY_PATH-}"
export FLECTAR_MAIL_BUNDLED=1
# linuxdeploy places any non-system ELF dependencies here.
export LD_LIBRARY_PATH="$app_dir/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
# The Linux binary supports Slint's Wayland and X11 winit integrations. Slint
+1 -1
View File
@@ -5,7 +5,7 @@ Priority: optional
Architecture: amd64
Maintainer: Flectar <flectar@users.noreply.github.com>
Depends: @DEPENDS@
Recommends: xdg-desktop-portal
Recommends: xdg-desktop-portal, gnupg, pinentry-gnome3 | pinentry-qt
Homepage: https://flectar.com
Description: Native Slint mail client
Flectar Mail is a native desktop mail client built with Rust and Slint.
+1 -1
View File
@@ -8,7 +8,7 @@ Homepage: https://flectar.com
Package: flectar-mail
Architecture: amd64
Depends: ${shlibs:Depends}, libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme
Depends: ${shlibs:Depends}, libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme, xdg-utils
Recommends: xdg-desktop-portal
Description: Native Slint mail client
Flectar Mail is a native desktop mail client built with Rust and Slint.
+2
View File
@@ -91,6 +91,8 @@ native_lib="$target_dir/aarch64-linux-android/release/libflectar_mail_android.so
jni_dir="$target_dir/android/gradle-jni/arm64-v8a"
mkdir -p "$jni_dir"
cp "$native_lib" "$jni_dir/libflectar_mail_android.so"
python3 "$project_dir/scripts/stage-pdfium.py" android-arm64 "$jni_dir" \
--cache "$target_dir/pdfium-downloads"
"$gradle_command" --no-daemon \
--project-cache-dir "$target_dir/android/gradle-cache" \
+2
View File
@@ -87,6 +87,8 @@ native_lib="$target_dir/aarch64-linux-android/release/libflectar_mail_android.so
jni_dir="$target_dir/android/gradle-jni/arm64-v8a"
mkdir -p "$jni_dir"
cp "$native_lib" "$jni_dir/libflectar_mail_android.so"
python3 "$project_dir/scripts/stage-pdfium.py" android-arm64 "$jni_dir" \
--cache "$target_dir/pdfium-downloads"
export FLECTAR_ANDROID_KEYSTORE="$keystore"
export FLECTAR_ANDROID_KEYSTORE_PASSWORD=android
+5
View File
@@ -54,6 +54,7 @@ mkdir -p \
"$app_dir/usr/share/icons/hicolor/scalable/apps" \
"$app_dir/usr/share/metainfo"
cp "$project_dir/target/release/flectar-mail" "$app_dir/usr/bin/flectar-mail"
python3 "$project_dir/scripts/stage-pdfium.py" linux-x64 "$app_dir/usr/lib/flectar-mail"
app_version="$(python3 "$project_dir/scripts/stage-linux-metadata.py" "$app_dir")"
cp "$project_dir/resources/app-icon/flectar-mail-masked-512.png" "$app_dir/usr/share/icons/hicolor/512x512/apps/com.flectar.mail.png"
cp "$project_dir/resources/app-icon/flectar-mail-masked.svg" "$app_dir/usr/share/icons/hicolor/scalable/apps/com.flectar.mail.svg"
@@ -87,6 +88,10 @@ VERSION="$app_version" APPIMAGE_EXTRACT_AND_RUN="${APPIMAGE_EXTRACT_AND_RUN:-1}"
cp "$project_dir/resources/AppRun" "$app_dir/AppRun"
chmod +x "$app_dir/AppRun"
# The pinned PDFium runtime depends only on the ordinary glibc runtime. Keep
# its private path intact and verify it after deployment has adjusted the ELF files.
python3 "$project_dir/scripts/test-pdf-preview.py" "$app_dir/usr/bin/flectar-mail"
# AppDir specifies a PNG .DirIcon for file-manager thumbnails. linuxdeploy
# prefers the scalable icon for the root entry, so restore the PNG thumbnail
# explicitly while retaining the SVG and themed icon copies for integration.
+4 -2
View File
@@ -33,6 +33,8 @@ mkdir -p \
install -m755 "$project_dir/target/release/flectar-mail" \
"$package_root/usr/bin/flectar-mail"
python3 "$project_dir/scripts/stage-pdfium.py" linux-x64 "$package_root/usr/lib/flectar-mail"
python3 "$project_dir/scripts/test-pdf-preview.py" "$package_root/usr/bin/flectar-mail"
python3 "$project_dir/scripts/stage-linux-metadata.py" "$package_root"
install -m644 "$project_dir/resources/app-icon/flectar-mail-masked-512.png" \
"$package_root/usr/share/icons/hicolor/512x512/apps/com.flectar.mail.png"
@@ -55,10 +57,10 @@ install -m644 "$project_dir/resources/fonts/google-sans-flex/README.md" \
cp "$project_dir/resources/debian/source-control.in" "$build_dir/debian/control"
shlib_substitution="$(
cd "$build_dir"
dpkg-shlibdeps -O -edebian/flectar-mail/usr/bin/flectar-mail
dpkg-shlibdeps -O -edebian/flectar-mail/usr/bin/flectar-mail -edebian/flectar-mail/usr/lib/flectar-mail/libpdfium.so
)"
runtime_dependencies="${shlib_substitution#shlibs:Depends=}"
runtime_dependencies+=", libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme"
runtime_dependencies+=", libfontconfig1, libwayland-client0, libx11-6, libx11-xcb1, libxkbcommon0, libxkbcommon-x11-0, hicolor-icon-theme, xdg-utils"
sed \
-e "s/@VERSION@/$version/g" \
-e "s/@DEPENDS@/$runtime_dependencies/g" \
+3
View File
@@ -19,6 +19,8 @@ mkdir -p \
"$app_dir/Contents/Resources/Licenses/Noto Emoji"
cp "$binary_path" "$app_dir/Contents/MacOS/flectar-mail"
python3 "$project_dir/scripts/stage-pdfium.py" mac-arm64 "$app_dir/Contents/MacOS"
icon_source="$project_dir/resources/app-icon/flectar-mail-masked.png"
iconset_dir="$output_dir/FlectarMail.iconset"
rm -rf "$iconset_dir"
@@ -58,6 +60,7 @@ sed \
# An ad-hoc signature makes the CI artifact internally consistent. Distribution
# signing and notarization replace it when Apple credentials are configured.
codesign --force --deep --sign - "$app_dir"
python3 "$project_dir/scripts/test-pdf-preview.py" "$app_dir/Contents/MacOS/flectar-mail"
mkdir -p "$output_dir"
ditto -c -k --sequesterRsrc --keepParent "$app_dir" "$output_dir/flectar-mail-macos-arm64.zip"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Stage a checksum-pinned, non-V8 PDFium runtime for desktop and mobile app packaging.
No downloads happen at application startup or during cargo build. Release
packaging calls this explicitly; developers can target target/debug.
"""
import argparse
import hashlib
import io
import json
import pathlib
import shutil
import tarfile
import urllib.request
RELEASE = "chromium/8044"
ASSETS = {
"linux-x64": ("eb142f416aed3a72fc5a02dbd5884868a16cb99dc0cf53e6bdd64afbf67b05f4", "lib/libpdfium.so"),
"mac-arm64": ("61424884d4a7f153b808deba6437848e4400834ce30aaf95d3050da44df8f420", "lib/libpdfium.dylib"),
"android-arm64": ("37686e64fa005484d619550a78805500de4c5a6f4df7aa06d8576145bfbee98f", "lib/libpdfium.so"),
"android-x64": ("e7e3072b8ad6f36ef58db999c2d91a22fa165a904d69385f13db4537c233d736", "lib/libpdfium.so"),
"ios-device-arm64": ("52be735d07b6c498ed8ce68b7f0cb5a209bbe61593c7a86c556756b80abbb938", "lib/libpdfium.dylib"),
"ios-simulator-arm64": ("544537fce5257146e0df501c632d490d1aa95768737770f1912c8e23b1dd238d", "lib/libpdfium.dylib"),
"ios-simulator-x64": ("8d4e33e415e29894aa98481bc9a9162a42039a1672796db14c9fed9cd545c881", "lib/libpdfium.dylib"),
"win-x64": ("78a17d9a5f14467631c26a3ac8741b27a0471ecc05bd6a119b523598160a0537", "bin/pdfium.dll"),
}
def stage(target, destination, cache=None):
checksum, library = ASSETS[target]
url = f"https://github.com/bblanchon/pdfium-binaries/releases/download/{RELEASE}/pdfium-{target}.tgz"
cached = cache / f"{checksum}.tgz" if cache else None
if cached and cached.is_file():
data = cached.read_bytes()
else:
with urllib.request.urlopen(url, timeout=60) as response:
data = response.read(64 * 1024 * 1024 + 1)
if hashlib.sha256(data).hexdigest() != checksum:
raise ValueError("PDFium archive checksum mismatch")
if cached and not cached.is_file():
cache.mkdir(parents=True, exist_ok=True)
# Concurrent Xcode architecture builds must never observe a partial cache.
import tempfile
with tempfile.NamedTemporaryFile(dir=cache, delete=False) as temp:
temp.write(data)
temporary = pathlib.Path(temp.name)
temporary.replace(cached)
destination.mkdir(parents=True, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
members = {member.name.removeprefix("./"): member for member in archive.getmembers()}
for name, member in members.items():
if name != library and not name.startswith("licenses/") and name != "LICENSE":
continue
if not member.isfile() or ".." in pathlib.PurePosixPath(name).parts:
raise ValueError("Invalid PDFium archive member")
relative = pathlib.Path(library).name if name == library else pathlib.Path("pdfium-licenses") / name
output = destination / relative
output.parent.mkdir(parents=True, exist_ok=True)
with archive.extractfile(member) as source, output.open("wb") as sink:
shutil.copyfileobj(source, sink)
if not (destination / pathlib.Path(library).name).is_file():
raise ValueError("PDFium runtime missing from archive")
(destination / "pdfium-licenses" / "build.json").write_text(json.dumps({"release": RELEASE, "sha256": checksum, "url": url}, indent=2) + "\n")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target", choices=ASSETS)
parser.add_argument("destination", type=pathlib.Path)
parser.add_argument("--cache", type=pathlib.Path, help="Verified archive cache for repeated packaging builds")
args = parser.parse_args()
stage(args.target, args.destination, args.cache)
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Offline regressions for PDFium staging and native package validation."""
import hashlib
import importlib.util
import io
import pathlib
import plistlib
import struct
import sys
import tarfile
import tempfile
import unittest
import zipfile
from unittest.mock import patch
sys.dont_write_bytecode = True
SCRIPTS = pathlib.Path(__file__).resolve().parent
def module(name):
spec = importlib.util.spec_from_file_location(name, SCRIPTS / (name + ".py"))
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
stage = module("stage-pdfium")
native = module("verify-android-native")
framework = module("write-pdfium-framework-plist")
def archive(files):
result = io.BytesIO()
with tarfile.open(fileobj=result, mode="w:gz") as output:
for name, data in files.items():
entry = tarfile.TarInfo(name)
entry.size = len(data)
output.addfile(entry, io.BytesIO(data))
return result.getvalue()
def elf(alignment=16384):
data = bytearray(120)
data[:6] = b"\x7fELF\x02\x01"
struct.pack_into("<Q", data, 32, 64)
struct.pack_into("<HH", data, 54, 56, 1)
struct.pack_into("<I", data, 64, 1)
struct.pack_into("<Q", data, 112, alignment)
return bytes(data)
class PackagingTests(unittest.TestCase):
def test_cached_archive_is_verified_before_writing(self):
data = archive({"lib/libpdfium.so": b"library", "LICENSE": b"license", "licenses/notice": b"notice"})
checksum = hashlib.sha256(data).hexdigest()
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
cache = root / "cache"
cache.mkdir()
cached = cache / (checksum + ".tgz")
cached.write_bytes(data)
with patch.dict(stage.ASSETS, {"fixture": (checksum, "lib/libpdfium.so")}), patch.object(stage.urllib.request, "urlopen", side_effect=AssertionError("cached staging must be offline")):
stage.stage("fixture", root / "output", cache)
self.assertEqual((root / "output/libpdfium.so").read_bytes(), b"library")
self.assertTrue((root / "output/pdfium-licenses/build.json").is_file())
cached.write_bytes(b"corrupted archive")
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
stage.stage("fixture", root / "rejected", cache)
self.assertFalse((root / "rejected").exists())
def test_archive_traversal_is_rejected(self):
data = archive({"licenses/../../outside": b"unexpected"})
checksum = hashlib.sha256(data).hexdigest()
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
with patch.dict(stage.ASSETS, {"fixture": (checksum, "lib/libpdfium.so")}), patch.object(stage.urllib.request, "urlopen", return_value=io.BytesIO(data)):
with self.assertRaisesRegex(ValueError, "Invalid PDFium archive member"):
stage.stage("fixture", root / "output")
self.assertFalse((root / "outside").exists())
def test_apk_requires_pdfium_notices_and_aligned_native_libraries(self):
with tempfile.TemporaryDirectory() as directory:
apk = pathlib.Path(directory) / "fixture.apk"
files = {
"lib/arm64-v8a/libflectar_mail_android.so": elf(),
"lib/arm64-v8a/libpdfium.so": elf(),
"assets/licenses/PDFium/arm64-v8a/pdfium-licenses/build.json": b"{}",
"assets/licenses/PDFium/arm64-v8a/pdfium-licenses/LICENSE": b"license",
}
def write(values):
with zipfile.ZipFile(apk, "w") as output:
for name, data in values.items():
output.writestr(name, data)
write(files)
native.verify_apk(apk)
write({k: v for k, v in files.items() if not k.endswith("libpdfium.so")})
with self.assertRaisesRegex(ValueError, "missing bundled PDFium"):
native.verify_apk(apk)
write({**files, "lib/arm64-v8a/libpdfium.so": elf(4096)})
with self.assertRaisesRegex(ValueError, "not aligned"):
native.verify_apk(apk)
def test_ios_framework_metadata_distinguishes_device_and_simulator(self):
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "Info.plist"
for simulator in (False, True):
framework.write(path, simulator)
info = plistlib.loads(path.read_bytes())
self.assertEqual(info["CFBundleExecutable"], "PDFium")
self.assertEqual(info["CFBundlePackageType"], "FMWK")
self.assertEqual(info["MinimumOSVersion"], "17.0")
self.assertEqual(info["CFBundleSupportedPlatforms"], ["iPhoneSimulator" if simulator else "iPhoneOS"])
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Exercise the packaged PDF worker without initializing UI or account databases.
Usage: python3 scripts/test-pdf-preview.py target/debug/flectar-mail [output.png]
Creates an original two-page vector/text fixture and checks page selection,
colors, dimensions, malformed inputs and protocol limits. Requires staged PDFium.
"""
import pathlib
import struct
import subprocess
import sys
import time
import zlib
def fixture():
streams = [b"0.1 0.4 0.8 rg 20 20 160 100 re f BT /F1 16 Tf 20 150 Td (Flectar PDF preview) Tj ET", b"0.8 0.2 0.1 rg 20 20 160 100 re f BT /F1 16 Tf 20 150 Td (Second page) Tj ET"]
objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"]
for content in (6, 7):
objects.append(f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents {content} 0 R >>".encode())
objects.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
objects += [b"<< /Length " + str(len(s)).encode() + b" >>\nstream\n" + s + b"\nendstream" for s in streams]
data = b"%PDF-1.4\n"; offsets = [0]
for i, obj in enumerate(objects, 1):
offsets.append(len(data)); data += f"{i} 0 obj\n".encode() + obj + b"\nendobj\n"
xref = len(data)
data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode()
data += b"".join(f"{offset:010} 00000 n \n".encode() for offset in offsets[1:])
return data + f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
def png(path, width, height, pixels):
def chunk(kind, value):
return struct.pack(">I", len(value)) + kind + value + struct.pack(">I", zlib.crc32(kind + value))
raw = b"".join(b"\0" + pixels[y * width * 4:(y + 1) * width * 4] for y in range(height))
pathlib.Path(path).write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
def check(binary, output=None):
data = fixture()
for index, zoom in [(0, 100), (1, 100), (0, 200)]:
start = time.monotonic()
result = subprocess.run([binary, "--render-pdf-page"], input=struct.pack("<III", index, zoom, len(data)) + data, capture_output=True, timeout=22)
assert result.returncode == 0, (result.returncode, result.stderr.decode(errors="replace"))
magic, count, width, height = struct.unpack("<4sIII", result.stdout[:16])
pixels = result.stdout[16:]
assert magic == b"FPD1" and count == 2
assert 0 < width <= 2880 and 0 < height <= 2880
assert len(pixels) == width * height * 4
center = pixels[(height // 2 * width + width // 2) * 4:][:4]
expected = (26, 102, 204, 255) if index == 0 else (204, 51, 26, 255)
assert all(abs(a - b) <= 1 for a, b in zip(center, expected)), center
print(f"Page {index + 1} at {zoom}%: {width}x{height}, {time.monotonic() - start:.3f}s")
if output and index == 0 and zoom == 100: png(output, width, height, pixels)
for payload in [struct.pack("<III", 9, 100, len(data)) + data, struct.pack("<III", 0, 100, 4) + b"oops", struct.pack("<III", 0, 100, 16 * 1024 * 1024 + 1), struct.pack("<III", 0, 999, 0), b"short"]:
result = subprocess.run([binary, "--render-pdf-page"], input=payload, capture_output=True, timeout=22)
assert result.returncode != 0 and not result.stdout
print("PASS: pages, zoom, bitmap bounds, colors, malformed input and oversized requests")
if __name__ == "__main__":
if sys.argv[1] == "--fixture":
pathlib.Path(sys.argv[2]).write_bytes(fixture())
else:
check(str(pathlib.Path(sys.argv[1]).resolve()), sys.argv[2] if len(sys.argv) > 2 else None)
+13
View File
@@ -145,6 +145,19 @@ class ReleaseTests(unittest.TestCase):
(root / "Cargo.toml").write_text('[package]\nversion = "0.1.0-alpha.1"\n')
(root / "target/release").mkdir(parents=True)
shutil.copyfile("/bin/true", root / "target/release/flectar-mail")
# This test checks package metadata using fixture ELFs. Stub only
# the download/renderer helpers in the disposable checkout; actual
# native PDF rendering is exercised by the dedicated smoke tests.
(root / "scripts/stage-pdfium.py").write_text(
"import pathlib, shutil, sys\n"
"destination = pathlib.Path(sys.argv[2])\n"
"destination.mkdir(parents=True, exist_ok=True)\n"
"shutil.copyfile('/bin/true', destination / 'libpdfium.so')\n"
)
(root / "scripts/test-pdf-preview.py").write_text(
"import pathlib, sys\n"
"assert pathlib.Path(sys.argv[1]).is_file()\n"
)
(root / "bin").mkdir()
cargo = root / "bin/cargo"
cargo.write_text("#!/bin/sh\nexit 0\n")
+2
View File
@@ -71,6 +71,8 @@ if [[ "$archive_listing" != *'classes.dex'* ]]; then
exit 1
fi
python3 "$project_dir/scripts/verify-android-native.py" "$apk"
signing="$($apksigner verify --verbose --print-certs "$apk")"
printf '%s\n' "$signing"
signing_args=("$signing_mode")
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Verify packaged native ABI completeness and 16 KiB ELF load alignment."""
import argparse
import struct
import zipfile
def verify_elf(data, name):
if data[:6] != b"\x7fELF\x02\x01":
raise ValueError(f"{name}: expected little-endian ELF64")
offset = struct.unpack_from("<Q", data, 32)[0]
entry_size, count = struct.unpack_from("<HH", data, 54)
if entry_size < 56 or count == 0 or offset + count * entry_size > len(data):
raise ValueError(f"{name}: invalid ELF program headers")
loads = 0
for i in range(count):
entry = offset + i * entry_size
if struct.unpack_from("<I", data, entry)[0] != 1:
continue
file_offset, address = struct.unpack_from("<QQ", data, entry + 8)
alignment = struct.unpack_from("<Q", data, entry + 48)[0]
if alignment < 16384 or file_offset % 16384 != address % 16384:
raise ValueError(f"{name}: PT_LOAD is not aligned for 16 KiB Android pages")
loads += 1
if not loads:
raise ValueError(f"{name}: missing ELF load segments")
def verify_apk(path):
with zipfile.ZipFile(path) as archive:
names = set(archive.namelist())
app_libraries = sorted(name for name in names if name.endswith("/libflectar_mail_android.so"))
if not app_libraries:
raise ValueError("APK contains no Flectar native application")
for app in app_libraries:
abi = app.split("/")[1]
pdfium = f"lib/{abi}/libpdfium.so"
provenance = f"assets/licenses/PDFium/{abi}/pdfium-licenses/build.json"
license_name = f"assets/licenses/PDFium/{abi}/pdfium-licenses/LICENSE"
if not {pdfium, provenance, license_name} <= names:
raise ValueError(f"{abi}: missing bundled PDFium runtime, provenance or license")
for name in sorted(names):
if name.startswith("lib/") and name.endswith(".so"):
verify_elf(archive.read(name), name)
print("PASS: native libraries include PDFium and licenses, with 16 KiB ELF alignment")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("apk")
verify_apk(parser.parse_args().apk)
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Write the metadata for our embedded, signed iOS PDFium framework."""
import pathlib
import plistlib
import sys
def write(path, simulator):
metadata = {
"CFBundleDevelopmentRegion": "en",
"CFBundleExecutable": "PDFium",
"CFBundleIdentifier": "com.flectar.pdfium",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleName": "PDFium",
"CFBundlePackageType": "FMWK",
"CFBundleShortVersionString": "8044.0.0",
"CFBundleVersion": "8044",
"CFBundleSupportedPlatforms": ["iPhoneSimulator" if simulator else "iPhoneOS"],
"MinimumOSVersion": "17.0",
}
path.write_bytes(plistlib.dumps(metadata))
if __name__ == "__main__":
write(pathlib.Path(sys.argv[1]), bool(int(sys.argv[2])))