diff --git a/Makefile b/Makefile index 5b185ca..39d160a 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk .PHONY: format test check check-rust audit-rust test-rust test-rust-all .PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink .PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop -.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core +.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core .PHONY: prepare-release check-version check-release check-localization localization localization-migrate .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics @@ -71,8 +71,9 @@ check-version: ## Validate the canonical version and its platform mappings. cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS) check-release: ## Validate coordinated release scripts and workflow YAML. - cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh + cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh cd $(ROOT) && apple/scripts/tests/test-notarize.sh + cd $(ROOT) && apple/scripts/tests/test-generate-appconfig.sh cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh cd $(ROOT) && packaging/release/test-assemble-release.sh @@ -135,7 +136,10 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings. apple-version-config: ## Generate derived Store and Direct Apple build settings. cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all -apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project. +apple-app-config: ## Generate AppConfig.swift from the shared app.properties. + cd $(ROOT) && apple/scripts/generate-appconfig.sh + +apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project. cd $(ROOT)/apple && $(XCODEGEN) generate open-apple-project: apple-project ## Generate and open the native Apple Xcode project. diff --git a/app.properties b/app.properties new file mode 100644 index 0000000..8e11d09 --- /dev/null +++ b/app.properties @@ -0,0 +1,4 @@ +# Public, app-wide configuration shared by every platform (Apple + KMP). +# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen. +# Injected into the apps at build time — never hardcode these values in app code. +PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/ diff --git a/apple/Tests/AppConfigTests.swift b/apple/Tests/AppConfigTests.swift new file mode 100644 index 0000000..93bdcf9 --- /dev/null +++ b/apple/Tests/AppConfigTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import VniDrop + +/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`) +/// exposes the expected, well-formed values to the app. +final class AppConfigTests: XCTestCase { + func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() { + let url = AppConfig.privacyPolicyURL + XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https") + XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/") + } + + func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws { + // Cross-check the generated constant against the single source of truth so a + // broken generator (or drift) is caught, not just a hardcoded copy. + let expected = try Self.privacyURLFromAppProperties() + XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected) + } + + /// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up + /// from this source file's location to the repository root. + private static func privacyURLFromAppProperties() throws -> String { + var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = dir.appendingPathComponent("app.properties") + if FileManager.default.fileExists(atPath: candidate.path) { + let contents = try String(contentsOf: candidate, encoding: .utf8) + for line in contents.split(whereSeparator: \.isNewline) { + if line.hasPrefix("PRIVACY_POLICY_URL=") { + return String(line.dropFirst("PRIVACY_POLICY_URL=".count)) + } + } + throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)") + } + dir.deleteLastPathComponent() + } + throw XCTSkip("app.properties not found from \(#filePath)") + } +} diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 88f21af..e8c5a1a 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -375,7 +375,7 @@ struct StorageSettings: View { struct AboutSettings: View { @ObservedObject var model: SettingsModel - private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")! + private static let privacyPolicyURL = AppConfig.privacyPolicyURL var body: some View { Section { diff --git a/apple/VniDrop/Resources/AppIcon.icon/icon.json b/apple/VniDrop/Resources/AppIcon.icon/icon.json index b790233..76af636 100644 --- a/apple/VniDrop/Resources/AppIcon.icon/icon.json +++ b/apple/VniDrop/Resources/AppIcon.icon/icon.json @@ -1,62 +1,57 @@ { - "fill": { - "linear-gradient": [ - "extended-gray:1.00000,1.00000", - "display-p3:0.55433,0.59923,0.92884,1.00000" - ] - }, - "groups": [ - { - "blend-mode": "normal", - "blur-material": null, - "layers": [ - { - "image-name": "Mask.svg", - "name": "Mask" - } - ], - "lighting": "individual", - "refractivity": { - "depth": 0.5, - "enabled": true, - "strength": 0 - }, - "shadow": { - "kind": "neutral", - "opacity": 0.6 - }, - "specular": true, - "translucency": { - "enabled": true, - "value": 0.8 - } - }, - { - "layers": [ - { - "image-name": "Drop.svg", - "name": "Drop" - }, - { - "image-name": "U.svg", - "name": "U" - } - ], - "lighting": "combined", - "shadow": { - "kind": "neutral", - "opacity": 0.6 - }, - "translucency": { - "enabled": true, - "value": 0.4 - } - } - ], - "supported-platforms": { - "circles": [ - "watchOS" - ], - "squares": "shared" - } -} + "fill" : { + "linear-gradient" : [ + "extended-gray:1.00000,1.00000", + "srgb:0.84942,0.81480,0.95401,1.00000" + ] + }, + "groups" : [ + { + "blend-mode" : "normal", + "blur-material" : null, + "layers" : [ + { + "image-name" : "Mask.svg", + "name" : "Mask" + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.6 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.8 + } + }, + { + "layers" : [ + { + "image-name" : "Drop.svg", + "name" : "Drop" + }, + { + "image-name" : "U.svg", + "name" : "U" + } + ], + "lighting" : "combined", + "shadow" : { + "kind" : "layer-color", + "opacity" : 0.8 + }, + "translucency" : { + "enabled" : true, + "value" : 0.4 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/apple/scripts/build-core.sh b/apple/scripts/build-core.sh index 77f89c6..380d5bb 100755 --- a/apple/scripts/build-core.sh +++ b/apple/scripts/build-core.sh @@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" # This never touches the Rust crate — it only changes how the build is invoked. export CARGO_PROFILE_DEV_STRIP=none +# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build +# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo +# forbids overriding `lto` per build-override, so disable thin LTO for the whole +# release build here — the crate is still fully optimized (opt-level 3, debuginfo +# stripped), which is what shrinks the static lib. This never edits the Cargo crate. +export CARGO_PROFILE_RELEASE_LTO=false + IOS_TARGET="aarch64-apple-ios" SIM_ARM_TARGET="aarch64-apple-ios-sim" SIM_X64_TARGET="x86_64-apple-ios" diff --git a/apple/scripts/generate-appconfig.sh b/apple/scripts/generate-appconfig.sh new file mode 100755 index 0000000..9ff9537 --- /dev/null +++ b/apple/scripts/generate-appconfig.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties +# so app-wide constants (privacy policy URL, …) have a single source of truth +# across Apple and KMP. Regenerate instead of editing the output. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}" +output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}" + +read_property() { + local key=$1 + local value + value="$(sed -n "s/^${key}=//p" "$config_file")" + [[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; } + [[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] || + { printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; } + printf '%s' "$value" +} + +# Escape for a Swift string literal. +swift_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +privacy_url="$(read_property PRIVACY_POLICY_URL)" + +mkdir -p "$output_dir" +tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")" +cat > "$tmp" </dev/null 2>&1; then + printf 'Expected command to fail: %s\n' "$*" >&2 + exit 1 + fi +} + +assert_contains() { + local file=$1 needle=$2 + grep -qF "$needle" "$file" || + { printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; } +} + +out="$scratch/out/AppConfig.swift" + +# 1. Nominal value is emitted verbatim as a Swift URL literal. +printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties" +generate +assert_contains "$out" 'URL(string: "https://example.test/privacy/")!' +assert_contains "$out" 'enum AppConfig' + +# 2. Characters special to a Swift string literal are escaped. +printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties" +generate +assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!' + +# 3. A missing key fails instead of emitting an empty value. +printf 'OTHER_KEY=value\n' > "$scratch/app.properties" +expect_failure generate + +# 4. A duplicated key fails. +printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties" +expect_failure generate + +# 5. The real committed app.properties produces an https URL. +VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator" +assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://' + +printf 'generate-appconfig tests passed.\n' diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f6baf59..6c66e72 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -110,6 +110,55 @@ val generateDiagnosticsBuildConfig by tasks.registering { } } +// App-wide public constants (privacy policy URL, …) from the shared app.properties, +// so Apple and KMP read one source of truth instead of hardcoding values. +val appProperties = java.util.Properties().apply { + rootProject.file("app.properties").inputStream().use(::load) +} +val privacyPolicyUrl: String = appProperties.getProperty("PRIVACY_POLICY_URL")?.trim().orEmpty() +check(privacyPolicyUrl.isNotEmpty()) { "PRIVACY_POLICY_URL must be set in app.properties" } + +val appConfigDir = layout.buildDirectory.dir("generated/appconfig/commonMain/kotlin") +val generateAppConfig by tasks.registering { + group = "build" + description = "Generates AppConfig from the shared app.properties" + val outputDir = appConfigDir + val privacy = privacyPolicyUrl + inputs.property("PRIVACY_POLICY_URL", privacy) + outputs.dir(outputDir) + doLast { + val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app") + packageDir.mkdirs() + fun esc(value: String): String = buildString { + for (ch in value) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '$' -> append("\\$") + else -> append(ch) + } + } + } + packageDir.resolve("AppConfig.kt").writeText( + """ + |package com.vnidrop.app + | + |/** + | * Generated by shared/build.gradle.kts from the shared app.properties. + | * Single source of truth for app-wide public constants (also used by Apple). + | */ + |object AppConfig { + | const val PRIVACY_POLICY_URL: String = "${esc(privacy)}" + |} + | + """.trimMargin(), + ) + } +} + kotlin { androidTarget { compilerOptions { @@ -122,6 +171,7 @@ kotlin { sourceSets { commonMain { kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig)) + kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig)) } androidMain.dependencies { implementation(libs.androidx.activity.compose) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt index 3c4562a..c4067bb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt @@ -52,7 +52,7 @@ import vnidrop.shared.generated.resources.os_version_title import vnidrop.shared.generated.resources.value_unavailable import vnidrop.shared.generated.resources.version_title -private const val PrivacyPolicyUrl = "https://github.com/vnidrop/vnidrop" +private val PrivacyPolicyUrl = com.vnidrop.app.AppConfig.PRIVACY_POLICY_URL @Composable internal fun AboutSettings( diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt new file mode 100644 index 0000000..01df9fc --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/AppConfigTest.kt @@ -0,0 +1,40 @@ +package com.vnidrop.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies the build-time [AppConfig] (generated from the shared `app.properties`) + * exposes the expected, well-formed values to the shared UI. + */ +class AppConfigTest { + @Test + fun privacyPolicyUrlIsTheExpectedHttpsEndpoint() { + assertTrue(AppConfig.PRIVACY_POLICY_URL.startsWith("https://"), "must be https") + assertEquals("https://vnidrop.sudosy.fr/privacy/", AppConfig.PRIVACY_POLICY_URL) + } + + @Test + fun privacyPolicyUrlMatchesTheSharedConfigFile() { + // Cross-check the generated constant against the single source of truth so a + // broken codegen (or drift) is caught, not just a hardcoded copy. + assertEquals(privacyUrlFromAppProperties(), AppConfig.PRIVACY_POLICY_URL) + } + + private fun privacyUrlFromAppProperties(): String { + var dir: File? = File(System.getProperty("user.dir")).absoluteFile + repeat(8) { + val candidate = File(dir, "app.properties") + if (candidate.isFile) { + return candidate.readLines() + .firstOrNull { it.startsWith("PRIVACY_POLICY_URL=") } + ?.substringAfter("PRIVACY_POLICY_URL=") + ?: error("PRIVACY_POLICY_URL missing in ${candidate.path}") + } + dir = dir?.parentFile + } + error("app.properties not found from ${System.getProperty("user.dir")}") + } +}