diff --git a/.cargo/config.toml b/.cargo/config.toml index 9741f113..756d01c2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,3 @@ [alias] xtask = "run --quiet --package xtask --" +vendored-phosh = "run --quiet --config .cargo/vendor.toml --package xtask -- vendored-phosh" diff --git a/.cargo/vendor-runner.sh b/.cargo/vendor-runner.sh new file mode 100755 index 00000000..2083f7a6 --- /dev/null +++ b/.cargo/vendor-runner.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Cargo invokes this for vendored test binaries and `cargo run --config ...`. +set -euo pipefail + +# The dispatcher must start before libphosh (and its schemas) has been built. +if [[ $(basename "$1") == xtask ]]; then + exec "$@" +fi + +profile_dir=$(dirname "$1") +if [[ $(basename "$profile_dir") == deps ]]; then + profile_dir=$(dirname "$profile_dir") +fi +schemas=("$profile_dir"/build/libphosh-sys-*/out/phosh/data/gschemas.compiled) +for schema in "${schemas[@]}"; do + if [[ ! -f "$schema" ]] || ! cmp -s "${schemas[0]}" "$schema"; then + echo 'Missing or inconsistent vendored Phosh schemas; clean libphosh-sys with the vendor configuration and rebuild.' >&2 + exit 1 + fi +done +export GSETTINGS_SCHEMA_DIR +GSETTINGS_SCHEMA_DIR=$(cd "$(dirname "${schemas[0]}")" && pwd) +exec "$@" diff --git a/.cargo/vendor.toml b/.cargo/vendor.toml new file mode 100644 index 00000000..1b44429b --- /dev/null +++ b/.cargo/vendor.toml @@ -0,0 +1,13 @@ +# Opt in from the repository root. Ordinary builds and publication use crates.io. +[patch.crates-io] +libphosh = { path = "libphosh-rs/libphosh" } +libphosh-sys = { path = "libphosh-rs/libphosh/sys" } + +[env] +PHROG_LIBPHOSH_BUILD_INTERNAL = { value = "always", force = true } + +[build] +target-dir = "target/vendor" + +[target.'cfg(unix)'] +runner = ".cargo/vendor-runner.sh" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b1e594a..72546a29 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ on: push: branches: [main] pull_request_target: - branches: [main] + branches: [main, vendor-base] types: [labeled, opened, reopened, synchronize] workflow_dispatch: {} @@ -51,6 +51,18 @@ jobs: defaults: run: shell: bash + strategy: + fail-fast: false + matrix: + include: + - libphosh: system + cargo: cargo + build_flags: --locked + target: target + - libphosh: bundled + cargo: cargo vendored-phosh + build_flags: '' + target: target/vendor steps: - if: | github.event_name == 'pull_request_target' && @@ -68,17 +80,38 @@ jobs: - name: Build run: | set -uexo pipefail - cargo build --all-targets --verbose + ${{ matrix.cargo }} build ${{ matrix.build_flags }} --all-targets --verbose + - name: Check generic vendor commands + if: matrix.libphosh == 'bundled' + run: | + cargo vendored-phosh tree --locked -i libphosh + cargo vendored-phosh run --locked -- --help + cargo vendored-phosh clippy --locked --package xtask --bin xtask --no-deps -- -D warnings + - name: Verify libphosh link mode + run: | + set -euo pipefail + dependencies="$(LC_ALL=C readelf -d ${{ matrix.target }}/debug/phrog)" + case "$dependencies" in + *'Shared library: [libphosh-'*) actual=system ;; + *) actual=bundled ;; + esac + test "$actual" = '${{ matrix.libphosh }}' - name: Lint run: | - cargo clippy --all-targets --verbose + ${{ matrix.cargo }} clippy --locked --all-targets --no-deps --verbose -- -D warnings + - name: Test embedding build support + if: matrix.libphosh == 'bundled' + run: cargo vendored-phosh test --locked --manifest-path libphosh-rs/Cargo.toml -p libphosh-sys --test native_source + - name: Verify registry package + if: matrix.libphosh == 'system' + run: cargo package --locked --no-verify - name: Lint (fmt) run: | - cargo fmt --all --check + ${{ matrix.cargo }} fmt --all --check - uses: actions/upload-artifact@v4 with: - name: x86-64-debug - path: target/debug/phrog + name: x86-64-debug${{ matrix.libphosh == 'bundled' && '-bundled' || '' }} + path: ${{ matrix.target }}/debug/phrog - name: Test run: | set -uexo pipefail @@ -86,16 +119,18 @@ jobs: export XDG_RUNTIME_DIR=/tmp # run tests - export RECORD_TESTS=`pwd`/demo-video/recordings/ + if [ '${{ matrix.libphosh }}' = system ]; then + export RECORD_TESTS="$PWD/demo-video/recordings/" + fi export G_MESSAGES_DEBUG=all cat > phoc.ini <` + - GTK4 Phosh: use `https://gitlab.gnome.org/guidog/phosh.git` with the same command. + - Bindings: `git subtree pull --prefix=libphosh-rs --squash https://gitlab.gnome.org/World/Phosh/libphosh-rs.git ` +- Export downstream fixes with `git format-patch --stdout --relative= ..HEAD -- `. Plain `git subtree split` traverses obsolete imports in phrog's old history and can fail on unavailable fork commits. diff --git a/Cargo.toml b/Cargo.toml index 846309fb..946e1cc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ description = "Mobile-friendly greeter for greetd" version = "0.53.0" edition = "2021" license = "GPL-3.0-only" -exclude = ["/demo-video"] +exclude = ["/demo-video", "/phosh", "/libphosh-rs", "/.cargo/vendor.toml", "/.cargo/vendor-runner.sh"] repository = "https://github.com/samcday/phrog" [workspace] diff --git a/README.md b/README.md index 18f9018e..1445ac7a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ Once `libphosh` is installed, building and running 🐸 should be quite simple: phoc -S -E "cargo run -- --fake" phoc -S -E "cargo test" + +# Embed the vendored libphosh (requires Phosh build dependencies): +cargo vendored-phosh build # Also accepts run, test, fmt, tree, clippy, etc. ``` ## Getting help diff --git a/libphosh-rs/Cargo.lock b/libphosh-rs/Cargo.lock index 4f3d8242..da4df8cd 100644 --- a/libphosh-rs/Cargo.lock +++ b/libphosh-rs/Cargo.lock @@ -460,6 +460,7 @@ dependencies = [ "libc", "libhandy-sys", "pango-sys", + "pkg-config", "shell-words", "system-deps 7.0.5", "tempfile", diff --git a/libphosh-rs/libphosh/sys/Cargo.toml b/libphosh-rs/libphosh/sys/Cargo.toml index 58d9ee71..124ba4ed 100644 --- a/libphosh-rs/libphosh/sys/Cargo.toml +++ b/libphosh-rs/libphosh/sys/Cargo.toml @@ -51,6 +51,7 @@ package = "libhandy-sys" version = "0.11" [build-dependencies] +pkg-config = "0.3.30" system-deps = "7" [dev-dependencies] diff --git a/libphosh-rs/libphosh/sys/build.rs b/libphosh-rs/libphosh/sys/build.rs index 67349f15..7be80592 100644 --- a/libphosh-rs/libphosh/sys/build.rs +++ b/libphosh-rs/libphosh/sys/build.rs @@ -1,18 +1,184 @@ -// Generated by gir (https://github.com/gtk-rs/gir @ be9aa145267c) -// from ../.. (@ b4c1dbc27547+) -// from ../../gir-files (@ 6cd7b656acd6) -// DO NOT EDIT +#[cfg(not(docsrs))] +mod native_source; #[cfg(not(docsrs))] -use std::process; +use std::{collections::HashSet, env, path::PathBuf, process::Command}; #[cfg(docsrs)] -fn main() {} // prevent linking libraries to avoid documentation failure +fn main() {} #[cfg(not(docsrs))] fn main() { - if let Err(s) = system_deps::Config::new().probe() { - println!("cargo:warning={s}"); - process::exit(1); + println!("cargo:rerun-if-env-changed=PHOSH_SRC"); + println!("cargo:rerun-if-env-changed=PHROG_LIBPHOSH_BUILD_INTERNAL"); + match env::var("PHROG_LIBPHOSH_BUILD_INTERNAL").as_deref() { + Ok("always") => build_bundled(), + Err(env::VarError::NotPresent) | Ok("never") => { + system_deps::Config::new() + .probe() + .expect("system libphosh-0.45 is required (or set PHROG_LIBPHOSH_BUILD_INTERNAL=always)"); + } + _ => panic!("PHROG_LIBPHOSH_BUILD_INTERNAL must be always or never; implicit fallback is not supported"), + } +} + +#[cfg(not(docsrs))] +fn run(command: &mut Command) { + let status = command + .status() + .unwrap_or_else(|err| panic!("{command:?}: {err}")); + assert!(status.success(), "{command:?} failed: {status}"); +} + +#[cfg(not(docsrs))] +fn build_bundled() { + assert_eq!(env::var("HOST").unwrap(), env::var("TARGET").unwrap(), + "bundled libphosh currently supports native builds only; use system libphosh for cross builds"); + let manifest = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let source = env::var_os("PHOSH_SRC") + .map(PathBuf::from) + .unwrap_or_else(|| manifest.join("../../../phosh")); + let source = source + .canonicalize() + .expect("Phosh source tree not found; set PHOSH_SRC"); + assert!( + source.join("meson.build").is_file(), + "PHOSH_SRC must point to a Phosh source tree" + ); + println!("cargo:rerun-if-changed={}", source.display()); + let mut configuration = format!("source={source:?}\n"); + for name in [ + "CC", + "CFLAGS", + "CPPFLAGS", + "LDFLAGS", + "PKG_CONFIG_PATH", + "PKG_CONFIG_LIBDIR", + ] { + println!("cargo:rerun-if-env-changed={name}"); + configuration.push_str(&format!("{name}={:?}\n", env::var_os(name))); + } + let output = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + let staged_source = output.join("phosh-source"); + native_source::stage(&source, &staged_source).expect("failed to stage Phosh sources"); + println!("cargo:rerun-if-env-changed=PHROG_VENDOR_OFFLINE"); + match env::var("PHROG_VENDOR_OFFLINE").as_deref() { + Ok("1") => (), + Err(env::VarError::NotPresent) | Ok("0") => { + run(Command::new("meson") + .args(["subprojects", "download", "--sourcedir"]) + .arg(&staged_source) + .args(["gvc", "libcall-ui"])); + } + _ => panic!("PHROG_VENDOR_OFFLINE must be 0 or 1"), + } + let build = output.join("phosh"); + let inputs = output.join("phosh-build-inputs"); + // Meson caches the source directory, compiler and environment flags at setup. + // Recreate only our generated build tree when those inputs change. + if build.exists() && std::fs::read_to_string(&inputs).ok().as_ref() != Some(&configuration) { + std::fs::remove_dir_all(&build).expect("failed to reset the native build directory"); + } + run(Command::new("meson") + .args([ + "setup", + "--reconfigure", + "--backend=ninja", + "--wrap-mode=nodownload", + "--prefix=/usr", + // Do not load plugins belonging to the system Phosh ABI. + "--libdir=lib/phrog", + "-Dbindings-lib=true", + "-Dtests=false", + "-Dphoc_tests=disabled", + "-Dlockscreen-plugins=false", + "-Dquick-setting-plugins=false", + ]) + .arg(&build) + .arg(&staged_source)); + run(Command::new("meson") + .args(["compile", "-C"]) + .arg(&build) + .args([ + "phosh-0.45:static_library", + "phosh-tool", + "glib-compile-schemas", + ])); + + std::fs::write(inputs, configuration).expect("failed to record native build inputs"); + + // Promote only libphosh's private dependencies. A recursive --static probe + // also exposes private dependencies of shared libraries (e.g. PulseAudio's + // libpulsecommon), which the executable must not link directly. + let metadata_dir = build.join("meson-uninstalled"); + let metadata = std::fs::read_to_string(metadata_dir.join("libphosh-0.45-uninstalled.pc")) + .expect("failed to read Meson's uninstalled libphosh metadata"); + let mut shared_metadata = metadata.clone(); + for field in ["Requires", "Libs"] { + let public = format!("{field}:"); + let private = format!("{field}.private:"); + let values: Vec<_> = metadata + .lines() + .filter_map(|line| { + line.strip_prefix(&public) + .or_else(|| line.strip_prefix(&private)) + }) + .collect(); + shared_metadata = shared_metadata + .lines() + .filter(|line| !line.starts_with(&public) && !line.starts_with(&private)) + .map(|line| format!("{line}\n")) + .collect(); + shared_metadata.push_str(&format!("{field}: {}\n", values.join(" "))); + } + std::fs::write(metadata_dir.join("phrog-libphosh.pc"), shared_metadata) + .expect("failed to write embedding metadata"); + + // This build's metadata must win over any installed libphosh. + let mut paths = vec![metadata_dir]; + if let Some(existing) = env::var_os("PKG_CONFIG_PATH") { + paths.extend(env::split_paths(&existing)); + } + env::set_var("PKG_CONFIG_PATH", env::join_paths(paths).unwrap()); + let lib = pkg_config::Config::new() + .cargo_metadata(false) + .print_system_libs(false) + .statik(false) + .probe("phrog-libphosh") + .expect("failed to read bundled libphosh pkg-config metadata"); + // pkg-config can repeat transitive flags many times. Preserve their + // order while avoiding GCC's per-argument/environment size limit. + let mut seen_paths = HashSet::new(); + for path in &lib.link_paths { + if !seen_paths.insert(path) { + continue; + } + println!("cargo:rustc-link-search=native={}", path.display()); + } + let mut seen_libs = HashSet::new(); + for name in &lib.libs { + if !seen_libs.insert(name) { + continue; + } + let bundled = lib + .link_paths + .iter() + .any(|path| path.starts_with(&build) && path.join(format!("lib{name}.a")).is_file()); + assert!( + name != "phosh-0.45" || bundled, + "bundled libphosh archive not found" + ); + println!( + "cargo:rustc-link-lib={}{}", + if bundled { "static=" } else { "" }, + name + ); + } + let mut seen_args = HashSet::new(); + for args in &lib.ld_args { + if !seen_args.insert(args) { + continue; + } + println!("cargo:rustc-link-arg=-Wl,{}", args.join(",")); } } diff --git a/libphosh-rs/libphosh/sys/native_source.rs b/libphosh-rs/libphosh/sys/native_source.rs new file mode 100644 index 00000000..653369f9 --- /dev/null +++ b/libphosh-rs/libphosh/sys/native_source.rs @@ -0,0 +1,76 @@ +use std::{fs, io, path::Path}; + +const SUBPROJECTS: [&str; 2] = ["gvc", "libcall-ui"]; + +// Meson's downloader writes into the source tree. Stage a private copy so a +// build can use a read-only checkout and concurrent Cargo profiles stay isolated. +pub fn stage(source: &Path, output: &Path) -> io::Result<()> { + let next = output.with_extension("next"); + let previous = output.with_extension("previous"); + // Recover an interrupted swap before discarding any incomplete new tree. + if previous.exists() { + if output.exists() { + fs::remove_dir_all(&previous)?; + } else { + fs::rename(&previous, output)?; + } + } + if next.exists() { + fs::remove_dir_all(&next)?; + } + copy_tree(source, &next)?; + for name in SUBPROJECTS { + let relative = Path::new("subprojects").join(name); + let prepared = source.join(&relative); + let cached = output.join(&relative); + let destination = next.join(&relative); + let wrap = format!("subprojects/{name}.wrap"); + if prepared.is_dir() { + copy_tree(&prepared, &destination)?; + } else if cached.is_dir() && fs::read(source.join(&wrap))? == fs::read(output.join(&wrap))? + { + copy_tree(&cached, &destination)?; + } + } + if output.exists() { + fs::rename(output, &previous)?; + } + if let Err(error) = fs::rename(next, output) { + if previous.exists() { + fs::rename(&previous, output)?; + } + return Err(error); + } + if previous.exists() { + fs::remove_dir_all(previous)?; + } + Ok(()) +} + +fn copy_tree(source: &Path, destination: &Path) -> io::Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let name = entry.file_name(); + if name == ".git" || name == "_build" { + continue; + } + // These are copied from prepared inputs or recovered from the cache + // separately, after checking that the wrap revision has not changed. + if source.file_name().is_some_and(|name| name == "subprojects") + && SUBPROJECTS.iter().any(|project| name == *project) + { + continue; + } + let target = destination.join(&name); + let kind = entry.file_type()?; + if kind.is_symlink() { + std::os::unix::fs::symlink(fs::read_link(entry.path())?, target)?; + } else if kind.is_dir() { + copy_tree(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), target)?; + } + } + Ok(()) +} diff --git a/libphosh-rs/libphosh/sys/src/lib.rs b/libphosh-rs/libphosh/sys/src/lib.rs index 289a2129..73958c1a 100644 --- a/libphosh-rs/libphosh/sys/src/lib.rs +++ b/libphosh-rs/libphosh/sys/src/lib.rs @@ -12,9 +12,6 @@ use gobject_sys as gobject; use gio_sys as gio; use gtk_sys as gtk; use gdk_sys as gdk; -use gdk_pixbuf_sys as gdk_pixbuf; -use pango_sys as pango; -use handy_sys as handy; #[allow(unused_imports)] use std::ffi::{c_int, c_char, c_uchar, c_float, c_uint, c_double, diff --git a/libphosh-rs/libphosh/sys/tests/native_source.rs b/libphosh-rs/libphosh/sys/tests/native_source.rs new file mode 100644 index 00000000..1e166a78 --- /dev/null +++ b/libphosh-rs/libphosh/sys/tests/native_source.rs @@ -0,0 +1,51 @@ +#[path = "../native_source.rs"] +mod native_source; + +use std::fs; + +#[test] +fn refreshes_sources_and_reuses_only_matching_downloads() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("source"); + let staged = root.path().join("staged"); + fs::create_dir_all(source.join("subprojects")).unwrap(); + for name in ["gvc", "libcall-ui"] { + fs::write(source.join(format!("subprojects/{name}.wrap")), "pin-1").unwrap(); + } + fs::write(source.join("obsolete.c"), "old").unwrap(); + std::os::unix::fs::symlink("obsolete.c", source.join("link")).unwrap(); + native_source::stage(&source, &staged).unwrap(); + assert_eq!( + fs::read_link(staged.join("link")).unwrap(), + std::path::Path::new("obsolete.c") + ); + + // Simulate downloads in the private source tree, then edit the checkout. + for name in ["gvc", "libcall-ui"] { + fs::create_dir_all(staged.join(format!("subprojects/{name}"))).unwrap(); + fs::write( + staged.join(format!("subprojects/{name}/download")), + "cached", + ) + .unwrap(); + } + fs::remove_file(source.join("obsolete.c")).unwrap(); + fs::write(source.join("new.c"), "new").unwrap(); + fs::write(source.join("subprojects/gvc.wrap"), "pin-2").unwrap(); + native_source::stage(&source, &staged).unwrap(); + assert!(!staged.join("obsolete.c").exists()); + assert_eq!(fs::read_to_string(staged.join("new.c")).unwrap(), "new"); + assert!(!staged.join("subprojects/gvc").exists()); + assert!(staged.join("subprojects/libcall-ui/download").exists()); + assert!(!source.join("subprojects/libcall-ui").exists()); + + // A killed build may leave the old tree in its backup location and an + // incomplete replacement. The next offline build must retain its downloads. + fs::rename(&staged, staged.with_extension("previous")).unwrap(); + fs::create_dir_all(staged.with_extension("next")).unwrap(); + fs::write(staged.with_extension("next").join("partial"), "incomplete").unwrap(); + native_source::stage(&source, &staged).unwrap(); + assert!(staged.join("subprojects/libcall-ui/download").exists()); + assert!(!staged.join("partial").exists()); + assert!(!staged.with_extension("previous").exists()); +} diff --git a/phosh/src/meson.build b/phosh/src/meson.build index 0434518f..d160d6cc 100644 --- a/phosh/src/meson.build +++ b/phosh/src/meson.build @@ -51,6 +51,7 @@ phosh_resources = gnome.compile_resources( 'phosh-resources', 'phosh.gresources.xml', c_name: 'phosh', + extra_args: '--manual-register', ) libphosh_enum_headers = files('lockscreen.h') diff --git a/phosh/src/shell.c b/phosh/src/shell.c index b836cd36..e40be204 100644 --- a/phosh/src/shell.c +++ b/phosh/src/shell.c @@ -106,6 +106,7 @@ #include "wall-clock.h" #include "phosh-settings-enums.h" +#include "phosh-resources.h" #define WWAN_BACKEND_KEY "wwan-backend" #define OSD_HIDE_TIMEOUT 1 /* seconds */ @@ -1441,6 +1442,7 @@ static GDebugKey debug_keys[] = static void phosh_shell_init (PhoshShell *self) { + static gsize resources_initialized = FALSE; const char *messages_debug; PhoshShellPrivate *priv = phosh_shell_get_instance_private (self); @@ -1448,6 +1450,11 @@ phosh_shell_init (PhoshShell *self) if (messages_debug) priv->log_domains = g_strsplit (messages_debug, " ", -1); + if (g_once_init_enter (&resources_initialized)) { + phosh_register_resource (); + g_once_init_leave (&resources_initialized, TRUE); + } + cui_init (TRUE); gtk_icon_theme_add_resource_path (gtk_icon_theme_get_default (), "/mobi/phosh/icons"); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index aac16d1f..e5080b20 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -29,6 +29,12 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Run Cargo with the vendored libphosh configuration. + #[command(disable_help_flag = true)] + VendoredPhosh { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Bump version across all packaging files. Bump { /// Version to bump to (X.Y.Z or X.Y.Z-rc.N). @@ -66,6 +72,7 @@ fn main() { fn run() -> Result<()> { match Cli::parse().command { + Commands::VendoredPhosh { args } => vendored_phosh(args), Commands::Bump { version } => bump(&version), Commands::DistData { file_name, @@ -77,6 +84,27 @@ fn run() -> Result<()> { } } +fn vendored_phosh(args: Vec) -> Result<()> { + use std::os::unix::process::CommandExt; + let config = project_root()?.join(".cargo/vendor.toml"); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + let mut args = args.into_iter(); + match args.next() { + // cargo-fmt rejects --config; cargo-clippy must receive and forward it. + Some(subcommand) if subcommand == "fmt" => { + command.arg("--config").arg(&config).arg(subcommand); + } + Some(subcommand) => { + command.arg(subcommand).arg("--config").arg(&config); + } + None => { + command.arg("--config").arg(&config).arg("--help"); + } + } + Err(command.args(args).exec().into()) +} + fn bump(version: &str) -> Result<()> { let version = parse_version(version)?; let root = project_root()?; @@ -142,7 +170,7 @@ fn dist_data( .parent() .ok_or_else(|| format!("output path '{}' has no parent", out_path.display()))?; - fs::create_dir_all(&out_dir)?; + fs::create_dir_all(out_dir)?; fs::write( &out_path, render_greetd_config(greetd_vt, greetd_user, greetd_general_service),