diff --git a/Cargo.lock b/Cargo.lock index f0089412bd5..2c7a42884fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4880,6 +4880,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-subscriber", "zeroize", ] diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index d99ef64db0d..1a96817351d 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -40,6 +40,7 @@ hex = "0.4" # corrupt rows so operators can detect snapshot drift without a # native debugger attached. tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # anyhow surfaces from `KeyType::try_from` / `Purpose::try_from` # / `SecurityLevel::try_from` in dpp; we need the From impl in diff --git a/packages/rs-platform-wallet-ffi/build.rs b/packages/rs-platform-wallet-ffi/build.rs index 29549edbb55..50f47ea57d0 100644 --- a/packages/rs-platform-wallet-ffi/build.rs +++ b/packages/rs-platform-wallet-ffi/build.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::process::Command; use std::{env, fs}; fn main() { @@ -9,6 +10,23 @@ fn main() { println!("cargo:rerun-if-changed=cbindgen.toml"); println!("cargo:rerun-if-changed=src/"); + let commit = run_git(&["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=PLATFORM_WALLET_GIT_COMMIT={commit}"); + + let dirty = match run_git(&["status", "--porcelain"]) { + Some(out) if !out.is_empty() => "1", + Some(_) => "0", + None => "unknown", + }; + println!("cargo:rustc-env=PLATFORM_WALLET_GIT_DIRTY={dirty}"); + + if let Some(head) = run_git(&["rev-parse", "--git-path", "HEAD"]) { + println!("cargo:rerun-if-changed={head}"); + } + if let Some(index) = run_git(&["rev-parse", "--git-path", "index"]) { + println!("cargo:rerun-if-changed={index}"); + } + let target_dir = Path::new(&out_dir) .ancestors() .nth(3) // This line moves up to the target/ directory @@ -30,3 +48,15 @@ fn main() { .expect("Unable to generate bindings") .write_to_file(&output_path); } + +fn run_git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + + if !output.status.success() { + return None; + } + + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 6f770ed142d..a8848bf65d4 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -42,6 +42,7 @@ pub mod identity_top_up; pub mod identity_transfer; pub mod identity_update; pub mod identity_withdrawal; +pub mod logging; pub mod managed_identity; pub mod manager; pub mod manager_diagnostics; @@ -104,6 +105,7 @@ pub use identity_top_up::*; pub use identity_transfer::*; pub use identity_update::*; pub use identity_withdrawal::*; +pub use logging::*; pub use managed_identity::*; pub use manager::*; pub use manager_diagnostics::*; diff --git a/packages/rs-platform-wallet-ffi/src/logging.rs b/packages/rs-platform-wallet-ffi/src/logging.rs new file mode 100644 index 00000000000..835ef9ae5b0 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/logging.rs @@ -0,0 +1,170 @@ +use std::ffi::CStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::Layer; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const GIT_COMMIT: &str = env!("PLATFORM_WALLET_GIT_COMMIT"); +const GIT_DIRTY: &str = env!("PLATFORM_WALLET_GIT_DIRTY"); + +/// Install the global `tracing` subscriber. Returns +/// `true` only when this call installed the subscriber. +/// +/// A `false` return covers both (a) a subscriber was already +/// installed (first init wins) and (b) path couldn't be opened. +/// +/// # Safety +/// - `path` must be a valid pointer to a null terminated string +/// or null. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_enable_file_logging( + level: u8, + path: *const std::ffi::c_char, +) -> bool { + if path.is_null() { + return false; + } + + let Some(path) = CStr::from_ptr(path).to_str().ok().map(PathBuf::from) else { + return false; + }; + + enable_file_logging(level_to_directive(level), &path) +} + +fn enable_file_logging(log_level: &str, path: &Path) -> bool { + let Some(f_sdk) = open_file(path.join("dash_sdk").join("run.log")) else { + return false; + }; + let Some(f_pw) = open_file(path.join("platform_wallet").join("run.log")) else { + return false; + }; + let Some(f_spv) = open_file(path.join("dash_spv").join("run.log")) else { + return false; + }; + let Some(f_kw) = open_file(path.join("key_wallet").join("run.log")) else { + return false; + }; + let Some(f_grpc) = open_file(path.join("grpc").join("run.log")) else { + return false; + }; + + let l_sdk = tracing_subscriber::fmt::layer() + .with_writer(Mutex::new(f_sdk)) + .with_ansi(false) + .with_filter(tracing_subscriber::EnvFilter::new(format!( + "dash_sdk={log_level},rs_sdk_ffi={log_level}" + ))); + + let l_pw = tracing_subscriber::fmt::layer() + .with_writer(Mutex::new(f_pw)) + .with_ansi(false) + .with_filter(tracing_subscriber::EnvFilter::new(format!( + "platform_wallet={log_level},platform_wallet_ffi={log_level}" + ))); + + let l_spv = tracing_subscriber::fmt::layer() + .with_writer(Mutex::new(f_spv)) + .with_ansi(false) + .with_filter(tracing_subscriber::EnvFilter::new(format!( + "dash_spv={log_level}" + ))); + + let l_kw = tracing_subscriber::fmt::layer() + .with_writer(Mutex::new(f_kw)) + .with_ansi(false) + .with_filter(tracing_subscriber::EnvFilter::new(format!( + "key_wallet={log_level}" + ))); + + let l_grpc = tracing_subscriber::fmt::layer() + .with_writer(Mutex::new(f_grpc)) + .with_ansi(false) + .with_filter(tracing_subscriber::EnvFilter::new(format!( + "dapi_grpc={log_level},tonic={log_level},h2={log_level},\ + hyper={log_level},tower={log_level}" + ))); + + if fs::write(path.join("build_info.txt"), build_info_string()).is_err() { + return false; + } + + let stdout_layer = tracing_subscriber::fmt::layer().with_filter(broad_env_filter(log_level)); + + if tracing_subscriber::registry() + .with(stdout_layer) + .with(l_sdk) + .with(l_pw) + .with(l_spv) + .with(l_kw) + .with(l_grpc) + .try_init() + .is_err() + { + return false; + } + + tracing::info!(level = log_level, "file logging enabled"); + true +} + +fn open_file(path: PathBuf) -> Option { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok()?; + } + + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ok() +} + +fn broad_env_filter(log_level: &str) -> tracing_subscriber::EnvFilter { + let directives = format!( + "dash_sdk={log_level},rs_sdk={log_level},rs_sdk_ffi={log_level},\ + platform_wallet={log_level},platform_wallet_ffi={log_level},\ + dash_spv={log_level},key_wallet={log_level},\ + dapi_grpc={log_level},h2={log_level},tower={log_level},\ + hyper={log_level},tonic={log_level}" + ); + + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(directives)) +} + +fn level_to_directive(level: u8) -> &'static str { + match level { + 0 => "error", + 1 => "warn", + 2 => "info", + 3 => "debug", + 4 => "trace", + _ => "info", + } +} + +fn build_info_string() -> String { + let dirty = match GIT_DIRTY { + "0" => "no", + "1" => "yes", + _ => "unknown", + }; + let mut out = format!( + "platform-wallet-version: {VERSION}\n\ + git-commit: {GIT_COMMIT}\n\ + git-dirty: {dirty}\n\ + # to reproduce: git checkout {GIT_COMMIT}\n" + ); + if dirty == "yes" { + out.push_str( + "# WARNING: this build had uncommitted changes; the commit hash above does NOT \ + fully describe the source state.\n", + ); + } + out +} diff --git a/packages/swift-sdk/.gitignore b/packages/swift-sdk/.gitignore new file mode 100644 index 00000000000..dac85252109 --- /dev/null +++ b/packages/swift-sdk/.gitignore @@ -0,0 +1 @@ +logs-*/ \ No newline at end of file diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index f7fe4f55275..f5c5e43734b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -29,7 +29,16 @@ public enum LoggingPreferences { let preset = loadPreset() let enableSwiftVerbose: Bool + // File logging is gated to the iOS Simulator + #if targetEnvironment(simulator) + if let sessionRoot = launchLogPaths(), + SDK.enableFileLogging(level: .info, sessionRoot: sessionRoot) { + } else { + SDK.enableLogging(level: .info) + } + #else SDK.enableLogging(level: .info) + #endif switch preset { case .high: @@ -45,6 +54,26 @@ public enum LoggingPreferences { return preset } + private static func launchLogPaths() -> String? { + guard + let libraryURL = FileManager.default + .urls(for: .libraryDirectory, in: .userDomainMask).first + else { + return nil + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss'Z'" + + return libraryURL + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("SwiftDashSDK", isDirectory: true) + .appendingPathComponent(formatter.string(from: Date()), isDirectory: true) + .path + } + public static var preset: LoggingPreset { loadPreset() } public static var shouldEmitDefaultLogs: Bool { preset == .high } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 29b0eefd736..b09ba248ee0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -81,6 +81,21 @@ public final class SDK: @unchecked Sendable { print("🔵 SDK: Logging enabled at level: \(level)") } + /// Route the global tracing subscriber to per-bucket files under + /// `sessionRoot`. Returns `false` if a subscriber was already + /// installed or the path couldn't be written. + @discardableResult + public static func enableFileLogging( + level: LogLevel = .debug, + sessionRoot: String + ) -> Bool { + let installed = sessionRoot.withCString { ptr in + platform_wallet_enable_file_logging(level.rawValue, ptr) + } + + return installed + } + /// Local Platform DAPI addresses; override via UserDefaults key "platformDAPIAddresses" private static var platformDAPIAddresses: String { if let override = UserDefaults.standard.string(forKey: "platformDAPIAddresses"), !override.isEmpty { diff --git a/packages/swift-sdk/get_logs.sh b/packages/swift-sdk/get_logs.sh new file mode 100755 index 00000000000..236626afca9 --- /dev/null +++ b/packages/swift-sdk/get_logs.sh @@ -0,0 +1,250 @@ +#!/bin/bash +set -euo pipefail + +# Extract a single SwiftDashSDK session's logs from an iOS Simulator +# +# `LoggingPreferences.configure()` (SDKLogger.swift) calls +# `platform_wallet_enable_file_logging` at app startup, which lays +# out one file per `tracing` bucket under a session-stamped root: +# +# /Library/Logs/SwiftDashSDK// +# dash-sdk/run.log +# platform-wallet/run.log +# dash-spv/run.log +# key-wallet/run.log +# grpc/run.log +# +# This script picks a simulator + app container, then lets the +# developer pick which session to extract (defaults to the latest). +# +# Usage: +# ./get_logs.sh [--bundle-id ] [--out ] +# [--device ] +# [--session ] +# +# Defaults: +# bundle-id: org.dashfoundation.SwiftExampleApp +# out: ./logs-- +# device: interactive picker when more than one is available +# session: interactive picker when more than one is available + +BUNDLE_ID="org.dashfoundation.SwiftExampleApp" +OUT_DIR="" +DEVICE="" +SESSION="" + +need_value() { + if [ "$#" -lt 2 ]; then + echo "Missing value for $1" >&2; exit 2 + fi +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --bundle-id) need_value "$@"; BUNDLE_ID="$2"; shift 2 ;; + --out) need_value "$@"; OUT_DIR="$2"; shift 2 ;; + --device) need_value "$@"; DEVICE="$2"; shift 2 ;; + --session) need_value "$@"; SESSION="$2"; shift 2 ;; + -h|--help) + sed -n '3,30p' "$0"; exit 0 ;; + *) + echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +# ---------- simulator picker ---------- + +json="$(xcrun simctl list devices --json available)" +table="$(printf '%s' "$json" | jq -r ' + .devices + | to_entries[] + | .key as $rt + | .value[] + | select(.isAvailable) + | [.udid, .name, .state, ($rt | sub("com.apple.CoreSimulator.SimRuntime."; ""))] + | @tsv +')" + +if [ -z "$table" ]; then + echo "No available simulators found." >&2 + exit 1 +fi + +pick_device() { + if [ -n "$DEVICE" ]; then + matched="$(printf '%s\n' "$table" | awk -F'\t' -v q="$DEVICE" '$1==q || index($2,q)')" + + if [ -z "$matched" ]; then + echo "No simulator matches '$DEVICE'." >&2 + exit 1 + fi + + booted="$(printf '%s\n' "$matched" | awk -F'\t' '$3=="Booted"')" + + if [ -n "$booted" ]; then + printf '%s\n' "$booted" | head -1 + else + printf '%s\n' "$matched" | head -1 + fi + + return + fi + + sorted="$(printf '%s\n' "$table" | awk -F'\t' ' + BEGIN { OFS="\t" } + { print ($3=="Booted" ? "0" : "1"), $0 } + ' | sort -k1,1 | cut -f2-)" + + booted_count="$(printf '%s\n' "$sorted" | awk -F'\t' '$3=="Booted"' | wc -l | tr -d ' ')" + if [ "$booted_count" = "1" ]; then + printf '%s\n' "$sorted" | awk -F'\t' '$3=="Booted"' | head -1 + return + fi + + echo "Available simulators:" >&2 + + i=1 + while IFS=$'\t' read -r udid name state runtime; do + printf " [%d] %-32s %-9s %-22s %s\n" "$i" "$name" "$state" "$runtime" "$udid" >&2 + i=$((i + 1)) + done <<< "$sorted" + + printf "Pick one [1]: " >&2 + + read -r choice + choice="${choice:-1}" + + if ! [[ "$choice" =~ ^[0-9]+$ ]]; then + echo "Not a number: $choice" >&2; exit 1 + fi + + row="$(printf '%s\n' "$sorted" | sed -n "${choice}p")" + + if [ -z "$row" ]; then + echo "Choice out of range." >&2; exit 1 + fi + + printf '%s\n' "$row" +} + +row="$(pick_device)" +UDID="$(printf '%s' "$row" | cut -f1)" +NAME="$(printf '%s' "$row" | cut -f2)" +STATE="$(printf '%s' "$row" | cut -f3)" +echo "Using simulator: $NAME ($UDID, $STATE)" + +# ---------- resolve sandbox ---------- + +if ! container="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data 2>/dev/null)"; then + echo "Could not resolve the data container for $BUNDLE_ID on $NAME." >&2 + echo "Is the app installed? Try installing + launching it first" >&2 + exit 1 +fi + +root="$container/Library/Logs/SwiftDashSDK" +if [ ! -d "$root" ]; then + echo "No SwiftDashSDK log directory at $root." >&2 + echo "The app hasn't completed a launch that called" >&2 + echo " LoggingPreferences.configure()" >&2 + echo "yet. Launch the app once and re-run." >&2 + exit 1 +fi + +# ---------- session picker ---------- + +# Sessions are timestamped directories directly under the SDK root. +# Sort newest-first so the picker default ([1]) matches what the +# developer most likely wants right after a run. +# +# For macOS compability: +# macOS still ships Bash 3.2 where `mapfile` is unavailable, use an +# explicit loop. +sessions=() +while IFS= read -r s; do + sessions+=("$s") +done < <(find "$root" -mindepth 1 -maxdepth 1 -type d \ + -name '[0-9]*' -exec basename {} \; | sort -r) + +if [ "${#sessions[@]}" -eq 0 ]; then + echo "No session directories under $root." >&2 + echo "Check that the app actually called LoggingPreferences.configure()" >&2 + exit 1 +fi + +pick_session() { + if [ "$SESSION" = "latest" ] || { [ -z "$SESSION" ] && [ "${#sessions[@]}" -eq 1 ]; }; then + printf '%s\n' "${sessions[0]}" + return + fi + + if [ -n "$SESSION" ]; then + # Match exactly or by unique prefix (handy for short stamps + # like just "2026-06-01"). + matched=() + for s in "${sessions[@]}"; do + case "$s" in + "$SESSION"|"$SESSION"*) matched+=("$s") ;; + esac + done + + if [ "${#matched[@]}" -eq 0 ]; then + echo "No session matches '$SESSION'." >&2; exit 1 + fi + + if [ "${#matched[@]}" -gt 1 ]; then + echo "Session prefix '$SESSION' is ambiguous; matches:" >&2 + printf ' %s\n' "${matched[@]}" >&2 + exit 1 + fi + + printf '%s\n' "${matched[0]}" + + return + fi + + echo "Available sessions (newest first):" >&2 + + i=1 + for s in "${sessions[@]}"; do + # Annotate with the latest mtime of any contained file so the + # picker doubles as a "how recent is this session" view. + last="$(find "$root/$s" -type f -name 'run.log' \ + -exec stat -f '%m %Sm' -t '%H:%M:%S' {} \; 2>/dev/null \ + | sort -n | tail -1 | cut -d' ' -f2- || true)" + printf " [%d] %s (last write: %s)\n" "$i" "$s" "${last:-}" >&2 + i=$((i + 1)) + done + + printf "Pick one [1]: " >&2 + + read -r choice + choice="${choice:-1}" + + if ! [[ "$choice" =~ ^[0-9]+$ ]]; then + echo "Not a number: $choice" >&2; exit 1 + fi + + pick="${sessions[$((choice - 1))]:-}" + + if [ -z "$pick" ]; then + echo "Choice out of range." >&2; exit 1 + fi + + printf '%s\n' "$pick" +} + +SESSION_ID="$(pick_session)" +src="$root/$SESSION_ID" + +# ---------- copy ---------- + +if [ -z "$OUT_DIR" ]; then + safe_name="$(printf '%s' "$NAME" | tr ' /' '__')" + OUT_DIR="logs-${safe_name}-${SESSION_ID}" +fi + +mkdir -p "$OUT_DIR" +cp -R "$src/." "$OUT_DIR/" + +echo +echo "Copied session to $OUT_DIR/"