collective

Browse / github.com/storytold/artcraft-services / patch

feat(craft_apps): scaffold Flatpak repository endpoints and release metadata

feat(craft_apps): scaffold Flatpak repository endpoints and release metadata ## Summary Adds first-class data models and HTTP endpoints to `artcraft-services` to support self-hosted Flatpak OSTree repositories and GitHub Releases asset direct download redirection for ArtCraft Suite and individual Craft applications, with built-in version tracking and downgrade prevention when repositories lag behind releases: - **Direct Asset Download Redirection (`GET /v1/craft_apps/download/{app}/{kind}` & `GET /v1/craft_apps/{app}/download/{kind}`)**: - Directs clients, browsers, curl/scripts, and launchers straight to the appropriate GitHub Releases asset download URL via `302

Status
open
Score
0.00
Adopters
0 other operators
Author
dylanmtaylor (imported from pull request #160, open)
Base
e57ee3d63ac478a24149384bf1473ef97aa067f8
Size
+1199 −9 in 13 files
Review
pending
Shared
2026-10-28
Id
9a1ce0aaa715ac15b5ff1598

How it went

No other operator has reported using it yet.

Diff

raw · crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rs crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rs crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs crates/service/web/storyteller_web/src/docs/api_doc.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs

diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rsnew file mode 100644--- /dev/null+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rs@@ -0,0 +1,20 @@+use serde::{Deserialize, Serialize};+use utoipa::{IntoParams, ToSchema};++/// Query parameters for downloading a Craft App release asset.+#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema, IntoParams)]+pub struct CraftAppDownloadQuery {+  /// Target CPU architecture, e.g. "x86_64", "x64", "aarch64", "arm64", "universal".+  pub arch: Option<String>,+  /// Target operating system, e.g. "linux", "windows", "macos", "freebsd", "web".+  pub os: Option<String>,+}++/// Error response returned when an application or asset cannot be found.+#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]+pub struct CraftAppDownloadErrorResponse {+  pub success: bool,+  pub error: String,+  #[serde(skip_serializing_if = "Option::is_none")]+  pub available_assets: Option<Vec<String>>,+}diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rsnew file mode 100644--- /dev/null+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rs@@ -0,0 +1,291 @@+//! Flatpak repository configuration and .flatpakrepo metadata definitions for ArtCraft apps.+//!+//! Provides structured models and `.flatpakrepo` file generation for both the overarching+//! ArtCraft Suite repository and individual Craft application remotes.++use serde_derive::{Deserialize, Serialize};+use utoipa::ToSchema;++pub const DEFAULT_FLATPAK_BRANCH: &str = "stable";+pub const DEFAULT_FLATPAK_ID_PREFIX: &str = "ai.storyteller.";++/// Configuration defining a Flatpak OSTree repository remote.+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]+pub struct CraftAppFlatpakRepoConfig {+  /// The app key (e.g. `photocraft`), or `artcraft` for the complete suite repository.+  pub app_key: String,++  /// Flatpak Application ID (e.g. `ai.storyteller.photocraft`).+  pub app_id: String,++  /// Display title for the repository remote (e.g. `PhotoCraft` or `ArtCraft`).+  pub title: String,++  /// Short comment/summary describing the remote.+  pub comment: String,++  /// Longer description of the repository.+  pub description: String,++  /// URL of the OSTree repository (where `summary` and `objects/` are fetched).+  pub repo_url: String,++  /// Public homepage URL.+  pub homepage: String,++  /// Default OSTree branch (usually `stable`).+  pub default_branch: String,++  /// Optional GPG verification key URL or base64 key data.+  pub gpg_key_url: Option<String>,+}++impl CraftAppFlatpakRepoConfig {+  /// Generates the standard INI-formatted `.flatpakrepo` file content parsed by Flatpak.+  pub fn to_flatpakrepo_content(&self) -> String {+    let mut out = String::with_capacity(512);+    out.push_str("[Flatpak Repo]\n");+    out.push_str(&format!("Title={}\n", self.title));+    out.push_str(&format!("Url={}\n", self.repo_url));+    out.push_str(&format!("Homepage={}\n", self.homepage));+    out.push_str(&format!("Comment={}\n", self.comment));+    out.push_str(&format!("Description={}\n", self.description));+    out.push_str(&format!("DefaultBranch={}\n", self.default_branch));+    if let Some(ref gpg_key) = self.gpg_key_url {+      out.push_str(&format!("GPGKey={}\n", gpg_key));+    }+    out+  }++  /// Builds a repository configuration for the complete ArtCraft suite.+  pub fn suite_config(repo_url: &str, homepage: &str, gpg_key_url: Option<String>) -> Self {+    Self { app_key: "artcraft".to_string(), app_id: "ai.storyteller.artcraft".to_string(), title: "ArtCraft".to_string(), comment: "Official ArtCraft Suite releases".to_string(), description: "Official Flatpak repository for ArtCraft suite applications".to_string(), repo_url: repo_url.trim_end_matches('/').to_string() + "/", homepage: homepage.to_string(), default_branch: DEFAULT_FLATPAK_BRANCH.to_string(), gpg_key_url }+  }++  /// Builds a repository configuration for a single Craft application.+  pub fn app_config(app_key: &str, display_name: &str, repo_url: &str, homepage: &str, gpg_key_url: Option<String>) -> Self {+    let clean_key = app_key.trim().to_lowercase();+    let app_id = format!("{DEFAULT_FLATPAK_ID_PREFIX}{clean_key}");+    Self { app_key: clean_key.clone(), app_id, title: display_name.to_string(), comment: format!("Official {display_name} releases"), description: format!("Official Flatpak repository for {display_name}"), repo_url: repo_url.trim_end_matches('/').to_string() + "/", homepage: homepage.to_string(), default_branch: DEFAULT_FLATPAK_BRANCH.to_string(), gpg_key_url }+  }+}++use std::cmp::Ordering;++/// Metadata advertising Flatpak repository availability in release info responses.+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]+pub struct CraftAppFlatpakRepoInfo {+  /// Flatpak Application ID (e.g. `ai.storyteller.photocraft`).+  pub app_id: String,++  /// Primary repository URL to add as a remote.+  pub repo_url: String,++  /// Direct URL to download the `.flatpakrepo` configuration file.+  pub flatpakrepo_url: String,++  /// Default tracking branch (e.g. `stable`).+  pub branch: String,++  /// Version of the application currently available in this Flatpak repository.+  ///+  /// Clients and launchers must verify that this version is strictly greater than+  /// the installed version before triggering repo-based updates (`flatpak update`)+  /// or switching remotes, preventing accidental downgrades when the Flatpak repository+  /// lags behind GitHub releases or installed packages.+  #[serde(default, skip_serializing_if = "Option::is_none")]+  pub version: Option<String>,++  /// Minimum installed version required before attempting a repository update or migration.+  /// If the client has an installed version below `min_version`, or if the repository+  /// version is older than what is currently installed, the client must skip the update.+  #[serde(default, skip_serializing_if = "Option::is_none")]+  pub min_version: Option<String>,++  /// Whether the Flatpak repository is verified to be up-to-date with `latest_release`.+  /// When `false` or absent while repository version is older, clients should not downgrade.+  #[serde(default, skip_serializing_if = "Option::is_none")]+  pub is_synced: Option<bool>,++  /// Direct URL to download the repository GPG verification key.+  #[serde(default, skip_serializing_if = "Option::is_none")]+  pub gpg_key_url: Option<String>,+}++impl CraftAppFlatpakRepoInfo {+  /// Checks whether updating from `installed_version` to this repository would cause+  /// an accidental downgrade.+  ///+  /// Returns `true` if:+  /// - `version` is known and strictly older than `installed_version`.+  /// - `min_version` is specified and `installed_version` is strictly older than `min_version`.+  ///+  /// Returns `false` if `version` is newer than or equal to `installed_version`,+  /// or if versions cannot be compared.+  pub fn would_downgrade(&self, installed_version: &str) -> bool {+    if let Some(ref min_v) = self.min_version {+      if let Some(ord) = compare_versions(installed_version, min_v) {+        if ord == Ordering::Less {+          return true;+        }+      }+    }++    if let Some(ref repo_v) = self.version {+      if let Some(ord) = compare_versions(repo_v, installed_version) {+        if ord == Ordering::Less {+          return true;+        }+      }+    }++    false+  }++  /// Returns `true` if this repository's version is strictly newer than `installed_version`.+  pub fn is_newer_than(&self, installed_version: &str) -> bool {+    let Some(ref repo_v) = self.version else {+      return false;+    };+    compare_versions(repo_v, installed_version) == Some(Ordering::Greater)+  }+}++/// Parsed representation of a version triplet `(major, minor, patch, Option<prerelease>)`.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct SimpleSemver {+  pub major: u64,+  pub minor: u64,+  pub patch: u64,+  pub prerelease: Option<String>,+}++impl SimpleSemver {+  /// Parses a version string like "v1.2.3", "0.5.0-rc.1", "0.4.1", "1.2".+  pub fn parse(text: &str) -> Option<Self> {+    let t = text.trim();+    let t = t.strip_prefix(['v', 'V']).unwrap_or(t);+    if t.is_empty() {+      return None;+    }+    let (core, pre) = match t.find(['-', '+']) {+      Some(idx) => (&t[..idx], Some(t[idx + 1..].to_string())),+      None => (t, None),+    };+    let parts: Vec<&str> = core.split('.').collect();+    let major = parts.first()?.parse::<u64>().ok()?;+    let minor = parts.get(1).map_or(Ok(0), |p| p.parse::<u64>()).ok()?;+    let patch = parts.get(2).map_or(Ok(0), |p| p.parse::<u64>()).ok()?;+    Some(Self { major, minor, patch, prerelease: pre })+  }++  /// Compares two versions following SemVer precedence rules:+  /// 1. Compare major, minor, patch numerically.+  /// 2. If equal, a version with a prerelease is *older* than a version without one.+  /// 3. If both have a prerelease, compare prerelease strings lexicographically.+  pub fn compare(&self, other: &Self) -> Ordering {+    match self.major.cmp(&other.major) {+      Ordering::Equal => {},+      non_eq => return non_eq,+    }+    match self.minor.cmp(&other.minor) {+      Ordering::Equal => {},+      non_eq => return non_eq,+    }+    match self.patch.cmp(&other.patch) {+      Ordering::Equal => {},+      non_eq => return non_eq,+    }+    match (&self.prerelease, &other.prerelease) {+      (None, None) => Ordering::Equal,+      (Some(_), None) => Ordering::Less,+      (None, Some(_)) => Ordering::Greater,+      (Some(a), Some(b)) => a.cmp(b),+    }+  }+}++/// Compares version string `a` to `b`.+/// Returns `Some(Ordering)` or `None` if either string cannot be parsed.+pub fn compare_versions(a: &str, b: &str) -> Option<Ordering> {+  let v_a = SimpleSemver::parse(a)?;+  let v_b = SimpleSemver::parse(b)?;+  Some(v_a.compare(&v_b))+}++#[cfg(test)]+mod tests {+  use super::*;++  #[test]+  fn serializes_suite_flatpakrepo() {+    let config = CraftAppFlatpakRepoConfig::suite_config("https://repo.getartcraft.com", "https://getartcraft.com", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+    let content = config.to_flatpakrepo_content();+    assert!(content.contains("[Flatpak Repo]"));+    assert!(content.contains("Title=ArtCraft"));+    assert!(content.contains("Url=https://repo.getartcraft.com/"));+    assert!(content.contains("Homepage=https://getartcraft.com"));+    assert!(content.contains("DefaultBranch=stable"));+    assert!(content.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));+  }++  #[test]+  fn serializes_app_flatpakrepo_with_gpg() {+    let config = CraftAppFlatpakRepoConfig::app_config("photocraft", "PhotoCraft", "https://repo.getartcraft.com/photocraft", "https://getartcraft.com/apps/photocraft", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+    let content = config.to_flatpakrepo_content();+    assert!(content.contains("Title=PhotoCraft"));+    assert!(content.contains("Url=https://repo.getartcraft.com/photocraft/"));+    assert!(content.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));+    assert_eq!(config.app_id, "ai.storyteller.photocraft");+  }++  #[test]+  fn semver_comparison_and_downgrade_prevention() {+    assert_eq!(compare_versions("0.5.0", "0.4.0"), Some(Ordering::Greater));+    assert_eq!(compare_versions("0.4.0", "0.5.0"), Some(Ordering::Less));+    assert_eq!(compare_versions("v0.5.0", "0.5.0"), Some(Ordering::Equal));+    assert_eq!(compare_versions("0.5.0", "0.5.0-rc.1"), Some(Ordering::Greater));+    assert_eq!(compare_versions("0.5.0-rc.1", "0.5.0"), Some(Ordering::Less));++    let repo_lagging = CraftAppFlatpakRepoInfo {+      app_id: "ai.storyteller.photocraft".to_string(),+      repo_url: "https://repo.getartcraft.com/photocraft/".to_string(),+      flatpakrepo_url: "https://api.getartcraft.com/v1/craft_apps/flatpak/photocraft.flatpakrepo".to_string(),+      branch: "stable".to_string(),+      version: Some("0.4.0".to_string()),+      min_version: None,+      is_synced: Some(false),+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+    };++    // If installed is 0.5.0 and repo is 0.4.0, update would downgrade:+    assert!(repo_lagging.would_downgrade("0.5.0"));+    assert!(!repo_lagging.is_newer_than("0.5.0"));++    // If installed is 0.4.0, repo is equal, not a downgrade:+    assert!(!repo_lagging.would_downgrade("0.4.0"));+    assert!(!repo_lagging.is_newer_than("0.4.0"));++    // If installed is 0.3.0, repo is newer:+    assert!(!repo_lagging.would_downgrade("0.3.0"));+    assert!(repo_lagging.is_newer_than("0.3.0"));++    // Testing min_version constraint:+    let repo_with_min = CraftAppFlatpakRepoInfo {+      app_id: "ai.storyteller.cadcraft".to_string(),+      repo_url: "https://repo.getartcraft.com/cadcraft/".to_string(),+      flatpakrepo_url: "https://api.getartcraft.com/v1/craft_apps/flatpak/cadcraft.flatpakrepo".to_string(),+      branch: "stable".to_string(),+      version: Some("0.6.0".to_string()),+      min_version: Some("0.5.0".to_string()),+      is_synced: Some(true),+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+    };+    // Installed is 0.4.0, which is below min_version 0.5.0:+    assert!(repo_with_min.would_downgrade("0.4.0"));+    // Installed is 0.5.0, meets min_version and 0.6.0 is newer:+    assert!(!repo_with_min.would_downgrade("0.5.0"));+    assert!(repo_with_min.is_newer_than("0.5.0"));+  }+}+diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs--- a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs@@ -1 +1,3 @@+pub mod download;+pub mod flatpak; pub mod release_info;diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs--- a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs@@ -5,6 +5,8 @@ use serde_derive::{Deserialize, Serialize}; use url::Url; use utoipa::ToSchema; +use crate::craft_apps::flatpak::CraftAppFlatpakRepoInfo;+ // ── GET /v1/craft_apps/release_info ── // // The latest release of every Craft App (PhotoCraft, VectorCraft, …, and ArtCraft Launcher@@ -27,6 +29,10 @@ pub struct GetCraftAppsReleaseInfoResponse {   pub repo_renames: Vec<CraftAppRepoRename>,    pub important_notices: CraftAppsImportantNotices,++  /// Flatpak repository configuration for the complete ArtCraft Suite (if configured).+  #[serde(default, skip_serializing_if = "Option::is_none")]+  pub suite_flatpak_repo: Option<CraftAppFlatpakRepoInfo>, }  #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]@@ -56,6 +62,10 @@ pub struct CraftAppReleaseInfo {   /// When this app's release data was last fetched successfully. Absent if it never was; then   /// `latest_release` says nothing and clients should ask GitHub themselves.   pub app_last_fetched: Option<DateTime<Utc>>,++  /// Flatpak repository configuration for this application (if available).+  #[serde(default, skip_serializing_if = "Option::is_none")]+  pub flatpak_repo: Option<CraftAppFlatpakRepoInfo>, }  #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs--- a/crates/service/web/storyteller_web/src/docs/api_doc.rs+++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs@@ -162,6 +162,8 @@ use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*; use artcraft_api_defs::moderation::top_spenders::list::*; use artcraft_api_defs::moderation::user_spend_events::list::*; use artcraft_api_defs::folders::subfolder::*;+use artcraft_api_defs::craft_apps::download::*;+use artcraft_api_defs::craft_apps::flatpak::*; use artcraft_api_defs::craft_apps::release_info::*; use artcraft_api_defs::tags::add_media_file_tags::*; use artcraft_api_defs::tags::bulk_add_tags::*;@@ -737,6 +739,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux      // Craft Apps     crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler,+    crate::http_server::endpoints::craft_apps::get_craft_apps_flatpakrepo_handler::get_craft_apps_flatpakrepo_handler,+    crate::http_server::endpoints::craft_apps::get_craft_apps_flatpak_repo_redirect_handler::get_craft_apps_flatpak_repo_redirect_handler,+    crate::http_server::endpoints::craft_apps::get_craft_apps_download_handler::get_craft_apps_download_handler,      // Tags     crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler,@@ -1624,6 +1629,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux     CraftAppsImportantNotices,     CraftAppsAnnouncement,     CraftAppsAlert,+    CraftAppFlatpakRepoConfig,+    CraftAppFlatpakRepoInfo,+    CraftAppDownloadErrorResponse,+    CraftAppDownloadQuery,     ListUntaggedMediaFilesQueryParams,     ListUntaggedMediaFilesSuccessResponse,     MediaFileTagsEntry,diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rsnew file mode 100644--- /dev/null+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rs@@ -0,0 +1,338 @@+//! Directs clients and browsers to the appropriate GitHub Releases asset download URL+//! via HTTP 302 redirect.++use std::sync::Arc;++use actix_web::http::header::{CacheControl, CacheDirective, LOCATION};+use actix_web::{web, HttpResponse};+use artcraft_api_defs::craft_apps::download::{CraftAppDownloadErrorResponse, CraftAppDownloadQuery};+use artcraft_api_defs::craft_apps::release_info::{CraftAppArch, CraftAppOs, CraftAppPackageType, CraftAppReleaseAsset};+use serde::Deserialize;+use utoipa::ToSchema;++use crate::state::server_state::ServerState;++const REDIRECT_CACHE_SECS: u32 = 60;+const NOT_FOUND_CACHE_SECS: u32 = 30;++#[derive(Debug, Deserialize, ToSchema)]+pub struct CraftAppDownloadPath {+  /// App machine name or alias (e.g. `photocraft`, `cadcraft`, `artcraft-launcher`).+  pub app: String,+  /// Package kind, extension, or exact asset filename (e.g. `flatpak`, `appimage`, `deb`, `msi`, `dmg`).+  pub kind: String,+}++/// Redirects to the direct GitHub Release asset download URL for the requested application+/// and package type, avoiding the need to host binaries on web application pods or R2 storage.+#[utoipa::path(+  get,+  tag = "Craft Apps",+  path = "/v1/craft_apps/download/{app}/{kind}",+  params(+    ("app" = String, Path, description = "Craft application identifier (e.g. 'photocraft', 'cadcraft', 'artcraft-launcher')"),+    ("kind" = String, Path, description = "Package kind, extension, or filename (e.g. 'flatpak', 'appimage', 'deb', 'rpm', 'dmg', 'msi', 'tar.gz', 'zip')"),+    CraftAppDownloadQuery,+  ),+  responses(+    (status = 302, description = "Redirect to GitHub Release asset download URL"),+    (status = 404, description = "Unknown app, no releases published yet, or no matching asset", body = CraftAppDownloadErrorResponse),+  ),+)]+pub async fn get_craft_apps_download_handler(server_state: web::Data<Arc<ServerState>>, path: web::Path<CraftAppDownloadPath>, query: web::Query<CraftAppDownloadQuery>) -> HttpResponse {+  let path = path.into_inner();+  let app_key = path.app.trim();+  let kind = path.kind.trim();++  let cached_app = match server_state.craft_apps_release_cache.get_app(app_key) {+    Some(app) => app,+    None => {+      return HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("Unknown application '{app_key}'"), available_assets: None });+    },+  };++  let release = match cached_app.latest_release {+    Some(rel) => rel,+    None => {+      return HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("No releases currently available for application '{app_key}'"), available_assets: None });+    },+  };++  let arch_filter = query.arch.as_deref();+  let os_filter = query.os.as_deref();++  match find_matching_asset(&release.assets, kind, arch_filter, os_filter) {+    Some(asset) => HttpResponse::Found().insert_header((LOCATION, asset.download_url.clone())).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(REDIRECT_CACHE_SECS)])).finish(),+    None => {+      let available_assets = release.assets.iter().map(|a| a.name.clone()).collect();+      HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("No asset found matching '{kind}' for application '{app_key}'"), available_assets: Some(available_assets) })+    },+  }+}++/// Finds the most appropriate asset in `assets` matching the specified `kind`,+/// `arch_filter`, and `os_filter`.+pub fn find_matching_asset<'a>(assets: &'a [CraftAppReleaseAsset], kind: &str, arch_filter: Option<&str>, os_filter: Option<&str>) -> Option<&'a CraftAppReleaseAsset> {+  let kind_trimmed = kind.trim();+  if kind_trimmed.is_empty() {+    return None;+  }++  // 1. Direct exact filename match+  if let Some(exact) = assets.iter().find(|a| a.name.eq_ignore_ascii_case(kind_trimmed)) {+    return Some(exact);+  }++  // 2. Filter candidates matching kind and OS+  let matching_kind: Vec<&'a CraftAppReleaseAsset> = assets.iter().filter(|a| matches_kind(a, kind_trimmed)).filter(|a| os_filter.is_none_or(|os| matches_os(a, os))).collect();++  if matching_kind.is_empty() {+    return None;+  }++  // 3. Filter or score by architecture+  if let Some(arch_str) = arch_filter.map(str::trim).filter(|s| !s.is_empty()) {+    if let Some(target_arch) = parse_arch(arch_str) {+      // Prioritize exact arch match over Universal fallback+      if let Some(exact_arch) = matching_kind.iter().find(|a| a.platform.as_ref().and_then(|p| p.arch).is_some_and(|arch| arch == target_arch)) {+        return Some(*exact_arch);+      }+      if let Some(compatible) = matching_kind.iter().find(|a| matches_arch(a, target_arch)) {+        return Some(*compatible);+      }+    } else {+      let lower = arch_str.to_lowercase();+      if let Some(matched) = matching_kind.iter().find(|a| a.name.to_lowercase().contains(&lower)) {+        return Some(*matched);+      }+    }+  }++  // 4. Default architecture selection when arch is omitted:+  // Prefer x86_64 / universal, then arm64, then x86.+  matching_kind.into_iter().max_by_key(|a| arch_priority_score(a))+}++fn matches_kind(asset: &CraftAppReleaseAsset, kind: &str) -> bool {+  let kind_lower = kind.to_lowercase();+  let k = kind_lower.trim_start_matches('.');+  let name_lower = asset.name.to_lowercase();++  match k {+    "flatpak" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Flatpak) || name_lower.ends_with(".flatpak"),+    "appimage" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::AppImage) || (name_lower.ends_with(".appimage") && !name_lower.ends_with(".appimage.zsync")),+    "zsync" | "appimage.zsync" | "appimagezsync" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::AppImageZsync) || name_lower.ends_with(".appimage.zsync"),+    "deb" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Deb) || name_lower.ends_with(".deb"),+    "rpm" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Rpm) || name_lower.ends_with(".rpm"),+    "dmg" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Dmg) || name_lower.ends_with(".dmg"),+    "msi" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Msi) || name_lower.ends_with(".msi"),+    "tar.gz" | "targz" | "tar" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::TarGz) || name_lower.ends_with(".tar.gz"),+    "portable" | "portable-zip" | "portable.zip" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::PortableZip) || name_lower.ends_with("-portable.zip"),+    "web" | "wasm" | "webzip" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::WebZip) || (name_lower.contains("-web-") && name_lower.ends_with(".zip")),+    "zip" => name_lower.ends_with(".zip"),+    _ => name_lower.ends_with(&format!(".{k}")) || name_lower.ends_with(kind) || name_lower.contains(kind),+  }+}++fn matches_os(asset: &CraftAppReleaseAsset, os_str: &str) -> bool {+  let target_os = match os_str.to_lowercase().as_str() {+    "linux" => CraftAppOs::Linux,+    "windows" | "win" | "win64" | "win32" => CraftAppOs::Windows,+    "macos" | "mac" | "darwin" | "osx" => CraftAppOs::Macos,+    "freebsd" => CraftAppOs::Freebsd,+    "web" | "wasm" => CraftAppOs::Web,+    _ => return false,+  };++  if let Some(platform) = &asset.platform {+    platform.os == target_os+  } else {+    matches_os_name_fallback(&asset.name.to_lowercase(), target_os)+  }+}++fn matches_os_name_fallback(name_lower: &str, target_os: CraftAppOs) -> bool {+  match target_os {+    CraftAppOs::Windows if name_lower.ends_with(".msi") || name_lower.ends_with(".exe") => return true,+    CraftAppOs::Macos if name_lower.ends_with(".dmg") || name_lower.ends_with(".pkg") => return true,+    CraftAppOs::Linux if name_lower.ends_with(".deb") || name_lower.ends_with(".rpm") || name_lower.ends_with(".flatpak") => return true,+    _ => {}+  }++  let tokens: Vec<&str> = name_lower.split(|c: char| !c.is_ascii_alphanumeric()).filter(|s| !s.is_empty()).collect();++  match target_os {+    CraftAppOs::Linux => tokens.contains(&"linux"),+    CraftAppOs::Windows => tokens.iter().any(|&t| t == "windows" || t == "win" || t == "win64" || t == "win32"),+    CraftAppOs::Macos => tokens.iter().any(|&t| t == "macos" || t == "darwin" || t == "osx" || t == "mac"),+    CraftAppOs::Freebsd => tokens.contains(&"freebsd"),+    CraftAppOs::Web => tokens.contains(&"web") || tokens.contains(&"wasm"),+  }+}++fn parse_arch(arch_str: &str) -> Option<CraftAppArch> {+  match arch_str.to_lowercase().as_str() {+    "x64" | "x86_64" | "amd64" => Some(CraftAppArch::X64),+    "x86" | "i386" | "i686" => Some(CraftAppArch::X86),+    "arm64" | "aarch64" => Some(CraftAppArch::Arm64),+    "universal" => Some(CraftAppArch::Universal),+    _ => None,+  }+}++fn matches_arch(asset: &CraftAppReleaseAsset, target_arch: CraftAppArch) -> bool {+  if let Some(platform) = &asset.platform {+    if let Some(arch) = platform.arch {+      if arch == target_arch {+        return true;+      }+      // macOS Universal packages run on both X64 and Arm64+      if arch == CraftAppArch::Universal && (target_arch == CraftAppArch::X64 || target_arch == CraftAppArch::Arm64) {+        return true;+      }+    }+  }+  let name_lower = asset.name.to_lowercase();+  match target_arch {+    CraftAppArch::X64 => name_lower.contains("x86_64") || name_lower.contains("x64") || name_lower.contains("amd64") || name_lower.contains("universal"),+    CraftAppArch::Arm64 => name_lower.contains("aarch64") || name_lower.contains("arm64") || name_lower.contains("universal"),+    CraftAppArch::X86 => name_lower.contains("x86") || name_lower.contains("i386") || name_lower.contains("i686"),+    CraftAppArch::Universal => name_lower.contains("universal"),+  }+}++fn arch_priority_score(asset: &CraftAppReleaseAsset) -> u8 {+  if let Some(platform) = &asset.platform {+    match platform.arch {+      Some(CraftAppArch::X64) | Some(CraftAppArch::Universal) => 10,+      Some(CraftAppArch::Arm64) => 5,+      Some(CraftAppArch::X86) => 2,+      None => 1,+    }+  } else {+    let name = asset.name.to_lowercase();+    if name.contains("x86_64") || name.contains("x64") || name.contains("universal") {+      8+    } else if name.contains("aarch64") || name.contains("arm64") {+      4+    } else {+      1+    }+  }+}++#[cfg(test)]+mod tests {+  use super::*;+  use artcraft_api_defs::craft_apps::release_info::CraftAppAssetPlatform;++  fn test_asset(name: &str, os: CraftAppOs, arch: Option<CraftAppArch>, package_type: CraftAppPackageType) -> CraftAppReleaseAsset {+    CraftAppReleaseAsset { name: name.to_string(), size_bytes: 12345, download_url: format!("https://github.com/storytold/photocraft/releases/download/v0.5.0/{name}"), sha256: None, platform: Some(CraftAppAssetPlatform { os, arch, package_type }) }+  }++  #[test]+  fn exact_filename_match_takes_priority() {+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak)];+    let matched = find_matching_asset(&assets, "photocraft-0.5.0-linux-aarch64.flatpak", None, None);+    assert_eq!(matched.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");+  }++  #[test]+  fn default_flatpak_selects_x86_64() {+    let assets = vec![test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak)];+    let matched = find_matching_asset(&assets, "flatpak", None, None);+    assert_eq!(matched.unwrap().name, "photocraft-0.5.0-linux-x86_64.flatpak");+  }++  #[test]+  fn flatpak_respects_arch_filter() {+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak)];+    let matched_arm = find_matching_asset(&assets, "flatpak", Some("aarch64"), None);+    assert_eq!(matched_arm.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");++    let matched_arm64 = find_matching_asset(&assets, "flatpak", Some("arm64"), None);+    assert_eq!(matched_arm64.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");++    let matched_x64 = find_matching_asset(&assets, "flatpak", Some("x86_64"), None);+    assert_eq!(matched_x64.unwrap().name, "photocraft-0.5.0-linux-x86_64.flatpak");+  }++  #[test]+  fn distinguishes_appimage_from_zsync() {+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.AppImage.zsync", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::AppImageZsync), test_asset("photocraft-0.5.0-linux-x86_64.AppImage", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::AppImage)];+    let matched_ai = find_matching_asset(&assets, "appimage", None, None);+    assert_eq!(matched_ai.unwrap().name, "photocraft-0.5.0-linux-x86_64.AppImage");++    let matched_zsync = find_matching_asset(&assets, "zsync", None, None);+    assert_eq!(matched_zsync.unwrap().name, "photocraft-0.5.0-linux-x86_64.AppImage.zsync");+  }++  #[test]+  fn universal_macos_dmg_serves_arm64_and_x64() {+    let assets = vec![test_asset("photocraft-0.5.0-macos-universal.dmg", CraftAppOs::Macos, Some(CraftAppArch::Universal), CraftAppPackageType::Dmg), test_asset("photocraft-0.5.0-windows-x64.msi", CraftAppOs::Windows, Some(CraftAppArch::X64), CraftAppPackageType::Msi)];+    let matched_mac = find_matching_asset(&assets, "dmg", None, None);+    assert_eq!(matched_mac.unwrap().name, "photocraft-0.5.0-macos-universal.dmg");++    let matched_arm = find_matching_asset(&assets, "dmg", Some("arm64"), None);+    assert_eq!(matched_arm.unwrap().name, "photocraft-0.5.0-macos-universal.dmg");++    let matched_msi = find_matching_asset(&assets, "msi", None, None);+    assert_eq!(matched_msi.unwrap().name, "photocraft-0.5.0-windows-x64.msi");+  }++  #[test]+  fn package_types_and_tarball_lookup() {+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.deb", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Deb), test_asset("photocraft-0.5.0-linux-x86_64.rpm", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Rpm), test_asset("photocraft-0.5.0-linux-x86_64.tar.gz", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::TarGz), test_asset("photocraft-0.5.0-windows-x64-portable.zip", CraftAppOs::Windows, Some(CraftAppArch::X64), CraftAppPackageType::PortableZip)];+    assert_eq!(find_matching_asset(&assets, "deb", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.deb");+    assert_eq!(find_matching_asset(&assets, "rpm", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.rpm");+    assert_eq!(find_matching_asset(&assets, "tar.gz", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.tar.gz");+    assert_eq!(find_matching_asset(&assets, "portable", None, None).unwrap().name, "photocraft-0.5.0-windows-x64-portable.zip");+  }++  #[test]+  fn returns_none_on_unknown_kind_or_empty_assets() {+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak)];+    assert!(find_matching_asset(&assets, "exe", None, None).is_none());+    assert!(find_matching_asset(&[], "flatpak", None, None).is_none());+    assert!(find_matching_asset(&assets, "", None, None).is_none());+  }++  #[test]+  fn os_fallback_distinguishes_windows_and_darwin() {+    let darwin_asset = CraftAppReleaseAsset {+      name: "photocraft-0.5.0-darwin-x86_64.tar.gz".to_string(),+      size_bytes: 12345,+      download_url: "https://example.com/darwin".to_string(),+      sha256: None,+      platform: None,+    };+    let win_asset = CraftAppReleaseAsset {+      name: "photocraft-0.5.0-win-x64.zip".to_string(),+      size_bytes: 12345,+      download_url: "https://example.com/win".to_string(),+      sha256: None,+      platform: None,+    };+    let macro_asset = CraftAppReleaseAsset {+      name: "photocraft-macro-tools-0.5.0-linux.tar.gz".to_string(),+      size_bytes: 12345,+      download_url: "https://example.com/macro".to_string(),+      sha256: None,+      platform: None,+    };++    assert!(matches_os(&darwin_asset, "macos"));+    assert!(matches_os(&darwin_asset, "darwin"));+    assert!(!matches_os(&darwin_asset, "windows"));+    assert!(!matches_os(&darwin_asset, "win"));++    assert!(matches_os(&win_asset, "windows"));+    assert!(matches_os(&win_asset, "win"));+    assert!(!matches_os(&win_asset, "macos"));+    assert!(!matches_os(&win_asset, "darwin"));++    assert!(matches_os(&macro_asset, "linux"));+    assert!(!matches_os(&macro_asset, "macos"));+    assert!(!matches_os(&macro_asset, "mac"));+  }+}diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rsnew file mode 100644--- /dev/null+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rs@@ -0,0 +1,64 @@+//! Redirects Flatpak OSTree repository requests to the backing cloud object storage (S3/R2).++use actix_web::http::header::{CacheControl, CacheDirective, LOCATION};+use actix_web::{web, HttpResponse};++const DEFAULT_OBJECT_CACHE_SECS: u32 = 86400;+const METADATA_CACHE_SECS: u32 = 60;++/// Redirects Flatpak repository file requests (summary, refs, objects, deltas)+/// to the backing S3 or Cloudflare R2 object storage CDN.+#[utoipa::path(+  get,+  tag = "Craft Apps",+  path = "/v1/craft_apps/flatpak/repo/{tail:.*}",+  params(+    ("tail" = String, Path, description = "Relative path within the OSTree repository (e.g. 'summary', 'config', 'objects/..')"),+  ),+  responses(+    (status = 307, description = "Redirect to the backing OSTree storage CDN"),+  ),+)]+pub async fn get_craft_apps_flatpak_repo_redirect_handler(tail: web::Path<String>) -> HttpResponse {+  let relative_path = tail.into_inner();+  if relative_path.ends_with(".flatpakrepo") {+    return HttpResponse::NotFound().finish();+  }++  let base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");+  let trimmed_base = base_url.trim_end_matches('/');++  let destination_url = if relative_path.is_empty() { format!("{trimmed_base}/") } else { format!("{trimmed_base}/{}", relative_path.trim_start_matches('/')) };++  let max_age = if relative_path.starts_with("objects/") || relative_path.starts_with("deltas/") { DEFAULT_OBJECT_CACHE_SECS } else { METADATA_CACHE_SECS };++  HttpResponse::TemporaryRedirect().insert_header((LOCATION, destination_url)).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(max_age)])).finish()+}++#[cfg(test)]+mod tests {+  use super::*;+  use actix_web::http::StatusCode;++  #[actix_web::test]+  async fn redirects_with_307_and_location() {+    let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("summary".to_string())).await;+    assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);+    let loc = resp.headers().get(LOCATION).expect("Location header");+    assert_eq!(loc, "https://repo.getartcraft.com/summary");+  }++  #[actix_web::test]+  async fn redirects_objects_path_with_307() {+    let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("objects/01/23.filez".to_string())).await;+    assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);+    let loc = resp.headers().get(LOCATION).expect("Location header");+    assert_eq!(loc, "https://repo.getartcraft.com/objects/01/23.filez");+  }++  #[actix_web::test]+  async fn rejects_flatpakrepo_under_repo_path() {+    let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("artcraft.flatpakrepo".to_string())).await;+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);+  }+}diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rsnew file mode 100644--- /dev/null+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rs@@ -0,0 +1,144 @@+//! Serves .flatpakrepo configuration files for ArtCraft Suite and individual Craft Apps.++use std::sync::Arc;++use actix_web::http::header::{CacheControl, CacheDirective, ContentDisposition, DispositionParam, DispositionType};+use actix_web::{web, HttpResponse};+use artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoConfig;++use crate::state::server_state::ServerState;++const FLATPAK_MIME_TYPE: &str = "application/vnd.flatpak.repo; charset=utf-8";+const CACHE_MAX_AGE_SECS: u32 = 3600;++/// Serves a `.flatpakrepo` configuration file for the entire ArtCraft Suite+/// (`artcraft.flatpakrepo`) or an individual Craft app (`<app>.flatpakrepo`).+#[utoipa::path(+  get,+  tag = "Craft Apps",+  path = "/v1/craft_apps/flatpak/{target}",+  params(+    ("target" = String, Path, description = "Target .flatpakrepo filename, e.g. 'artcraft.flatpakrepo' or 'photocraft.flatpakrepo'"),+  ),+  responses(+    (status = 200, description = "Flatpak repository definition file (.flatpakrepo)"),+    (status = 404, description = "Unknown app or invalid filename extension"),+  ),+)]+pub async fn get_craft_apps_flatpakrepo_handler(server_state: web::Data<Arc<ServerState>>, target: web::Path<String>) -> HttpResponse {+  handle_craft_apps_flatpakrepo(&server_state.craft_apps_release_cache, target.as_str())+}++/// Core logic for handling `.flatpakrepo` file requests.+pub fn handle_craft_apps_flatpakrepo(cache: &crate::state::craft_apps_release_cache::CraftAppsReleaseCache, target_filename: &str) -> HttpResponse {+  if !target_filename.ends_with(".flatpakrepo") {+    return HttpResponse::NotFound().finish();+  }++  let stem = target_filename.strip_suffix(".flatpakrepo").unwrap_or("");+  if !is_valid_stem(stem) {+    return HttpResponse::NotFound().finish();+  }++  let repo_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");+  let website_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_WEBSITE_BASE_URL", "https://getartcraft.com");+  let default_gpg_key = format!("{}/keys/flatpak.gpg", website_base_url.trim_end_matches('/'));+  let gpg_key_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_GPG_KEY_URL", &default_gpg_key);++  let content = if stem == "artcraft" || stem == "suite" {+    CraftAppFlatpakRepoConfig::suite_config(&repo_base_url, &website_base_url, Some(gpg_key_url)).to_flatpakrepo_content()+  } else {+    let (app_key, app) = match cache.get_app_with_key(stem) {+      Some(entry) => entry,+      None => return HttpResponse::NotFound().finish(),+    };++    let app_env_key = app_key.to_uppercase().replace('-', "_");+    let app_gpg_key = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_GPG_KEY_URL_{app_env_key}")).unwrap_or(gpg_key_url);+    let app_repo_url = format!("{}/{}", repo_base_url.trim_end_matches('/'), app_key);+    let app_homepage = format!("{}/apps/{}", website_base_url.trim_end_matches('/'), app_key);+    CraftAppFlatpakRepoConfig::app_config(&app_key, &app.name, &app_repo_url, &app_homepage, Some(app_gpg_key)).to_flatpakrepo_content()+  };++  let disposition = ContentDisposition { disposition: DispositionType::Inline, parameters: vec![DispositionParam::Filename(target_filename.to_string())] };++  HttpResponse::Ok().content_type(FLATPAK_MIME_TYPE).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(CACHE_MAX_AGE_SECS)])).insert_header(disposition).body(content)+}++fn is_valid_stem(stem: &str) -> bool {+  !stem.is_empty() && stem.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')+}++#[cfg(test)]+mod tests {+  use super::*;+  use actix_web::http::StatusCode;+  use crate::state::craft_apps_release_cache::CraftAppsReleaseCache;++  #[test]+  fn validates_stems() {+    assert!(is_valid_stem("photocraft"));+    assert!(is_valid_stem("artcraft-launcher"));+    assert!(is_valid_stem("cadcraft-1"));+    assert!(!is_valid_stem(""));+    assert!(!is_valid_stem("PhotoCraft"));+    assert!(!is_valid_stem("photo\ncraft"));+    assert!(!is_valid_stem("photo craft"));+    assert!(!is_valid_stem("photo/craft"));+    assert!(!is_valid_stem("photo%0Acraft"));+  }++  #[test]+  fn generates_suite_and_app_repo_content() {+    let suite = CraftAppFlatpakRepoConfig::suite_config("https://repo.getartcraft.com", "https://getartcraft.com", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+    let suite_ini = suite.to_flatpakrepo_content();+    assert!(suite_ini.contains("[Flatpak Repo]"));+    assert!(suite_ini.contains("Title=ArtCraft"));+    assert!(suite_ini.contains("Url=https://repo.getartcraft.com/"));+    assert!(suite_ini.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));++    let app = CraftAppFlatpakRepoConfig::app_config("cadcraft", "CADCraft", "https://repo.getartcraft.com/cadcraft", "https://getartcraft.com/apps/cadcraft", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+    let app_ini = app.to_flatpakrepo_content();+    assert!(app_ini.contains("Title=CADCraft"));+    assert!(app_ini.contains("Url=https://repo.getartcraft.com/cadcraft/"));+    assert!(app_ini.contains("Homepage=https://getartcraft.com/apps/cadcraft"));+    assert!(app_ini.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));+  }++  #[actix_web::test]+  async fn flatpakrepo_suite_and_app_responses() {+    let cache = CraftAppsReleaseCache::new();+    cache.register_apps(&[+      ("photocraft".into(), "PhotoCraft".into(), "storytold/photocraft".into()),+      ("artcraft-launcher".into(), "ArtCraft Launcher".into(), "storytold/craft-launcher".into()),+    ]);++    // Suite returns 200 with GPGKey+    let resp = handle_craft_apps_flatpakrepo(&cache, "artcraft.flatpakrepo");+    assert_eq!(resp.status(), StatusCode::OK);+    let body_bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();+    let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();+    assert!(body_str.contains("Title=ArtCraft"));+    assert!(body_str.contains("GPGKey="));++    // Known app returns 200 with GPGKey and cached app name+    let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft.flatpakrepo");+    assert_eq!(resp.status(), StatusCode::OK);+    let body_bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();+    let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();+    assert!(body_str.contains("Title=PhotoCraft"));+    assert!(body_str.contains("GPGKey="));++    // Unknown app returns 404+    let resp = handle_craft_apps_flatpakrepo(&cache, "anything.flatpakrepo");+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);++    // Missing .flatpakrepo extension returns 404+    let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft");+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);++    // Newline injection attempt returns 404+    let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft\nGPGKey=evil.flatpakrepo");+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);+  }+}diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs@@ -25,7 +25,24 @@ const CACHE_MAX_AGE_SECS: u32 = 60; pub async fn get_craft_apps_release_info_handler(   server_state: web::Data<Arc<ServerState>>, ) -> HttpResponse {-  let release_info = server_state.craft_apps_release_cache.snapshot();+  let flatpak_config = server_state.craft_apps_release_cache.load_flatpak_config_from_env();++  let suite_flatpak_repo = if flatpak_config.enabled {+    Some(artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoInfo {+      app_id: "ai.storyteller.artcraft".to_string(),+      repo_url: format!("{}/", flatpak_config.repo_base_url.trim_end_matches('/')),+      flatpakrepo_url: format!("{}/v1/craft_apps/flatpak/artcraft.flatpakrepo", flatpak_config.api_base_url.trim_end_matches('/')),+      branch: "stable".to_string(),+      version: None,+      min_version: None,+      is_synced: None,+      gpg_key_url: flatpak_config.gpg_key_url.clone(),+    })+  } else {+    None+  };++  let release_info = server_state.craft_apps_release_cache.snapshot_with_flatpak_config(&flatpak_config);   let cache_control = cache_control_for(&release_info);   let response = GetCraftAppsReleaseInfoResponse {     success: true,@@ -34,6 +51,7 @@ pub async fn get_craft_apps_release_info_handler(     app_renames: Vec::new(),     repo_renames: Vec::new(),     important_notices: CraftAppsImportantNotices::default(),+    suite_flatpak_repo,   };   HttpResponse::Ok()       .insert_header(cache_control)diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs@@ -1 +1,4 @@+pub mod get_craft_apps_download_handler;+pub mod get_craft_apps_flatpak_repo_redirect_handler;+pub mod get_craft_apps_flatpakrepo_handler; pub mod get_craft_apps_release_info_handler;diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs--- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs+++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs@@ -3,6 +3,9 @@ use actix_service::ServiceFactory; use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{web, App, Error, HttpResponse}; +use crate::http_server::endpoints::craft_apps::get_craft_apps_download_handler::get_craft_apps_download_handler;+use crate::http_server::endpoints::craft_apps::get_craft_apps_flatpak_repo_redirect_handler::get_craft_apps_flatpak_repo_redirect_handler;+use crate::http_server::endpoints::craft_apps::get_craft_apps_flatpakrepo_handler::get_craft_apps_flatpakrepo_handler; use crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler;  pub fn add_craft_apps_routes<T, B>(app: App<T>) -> App<T>@@ -23,6 +26,29 @@ where         web::resource("/release_info")           .route(web::get().to(get_craft_apps_release_info_handler))           .route(web::head().to(|| HttpResponse::Ok())),+      )+      // Flatpak repository redirection to S3/R2 storage backend+      .service(+        web::resource("/flatpak/repo/{tail:.*}")+          .route(web::get().to(get_craft_apps_flatpak_repo_redirect_handler))+          .route(web::head().to(get_craft_apps_flatpak_repo_redirect_handler)),+      )+      // Flatpak repository definitions (.flatpakrepo) for suite or apps+      .service(+        web::resource("/flatpak/{target}")+          .route(web::get().to(get_craft_apps_flatpakrepo_handler))+          .route(web::head().to(get_craft_apps_flatpakrepo_handler)),+      )+      // Asset download redirection to GitHub Releases+      .service(+        web::resource("/download/{app}/{kind}")+          .route(web::get().to(get_craft_apps_download_handler))+          .route(web::head().to(get_craft_apps_download_handler)),+      )+      .service(+        web::resource("/{app}/download/{kind}")+          .route(web::get().to(get_craft_apps_download_handler))+          .route(web::head().to(get_craft_apps_download_handler)),       ),   ) }diff --git a/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs b/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs--- a/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs+++ b/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs@@ -159,25 +159,163 @@ impl CraftAppsReleaseCache {     }   } +  /// Returns the canonical app key and cached app info for the specified key or alias, if known.+  pub fn get_app_with_key(&self, key: &str) -> Option<(String, CachedCraftApp)> {+    let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);+    let lower = key.to_lowercase();+    if let Some((k, app)) = map.get_key_value(key).or_else(|| map.get_key_value(&lower)) {+      return Some((k.clone(), app.clone()));+    }+    for (k, app) in map.iter() {+      if k.eq_ignore_ascii_case(key)+        || app.name.eq_ignore_ascii_case(key)+        || app.repo.eq_ignore_ascii_case(key)+        || app.repo.split('/').last().is_some_and(|r| r.eq_ignore_ascii_case(key))+        || (key.eq_ignore_ascii_case("launcher") && k == "artcraft-launcher")+        || (key.eq_ignore_ascii_case("craft-launcher") && k == "artcraft-launcher")+      {+        return Some((k.clone(), app.clone()));+      }+    }+    None+  }++  /// Returns the cached app info for the specified key or alias, if known.+  pub fn get_app(&self, key: &str) -> Option<CachedCraftApp> {+    self.get_app_with_key(key).map(|(_, app)| app)+  }++  /// Returns the latest release for the specified app key (e.g. `photocraft`, `artcraft-launcher`),+  /// if fetched.+  pub fn get_app_release(&self, key: &str) -> Option<CraftAppRelease> {+    self.get_app(key).and_then(|app| app.latest_release)+  }++  /// Helper to load Flatpak config from environment once using registered app keys.+  pub fn load_flatpak_config_from_env(&self) -> CraftAppsFlatpakConfig {+    let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);+    CraftAppsFlatpakConfig::from_env(map.keys())+  }+   /// Everything, as the endpoint serves it: `apps` keyed by app key.+  /// Uses default (disabled) flatpak config without reading environment variables.   pub fn snapshot(&self) -> CraftAppsReleaseInfo {+    self.snapshot_with_flatpak_config(&CraftAppsFlatpakConfig::default())+  }++  /// Takes explicit flatpak config / overrides rather than reading environment variables+  /// inside `snapshot()`.+  pub fn snapshot_with_flatpak_config(&self, config: &CraftAppsFlatpakConfig) -> CraftAppsReleaseInfo {     let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);+     let apps: BTreeMap<String, CraftAppReleaseInfo> = map-        .iter()-        .map(|(key, app)| {-          (key.clone(), CraftAppReleaseInfo {-            name: app.name.clone(),-            repo: app.repo.clone(),-            latest_release: app.latest_release.clone(),-            app_last_fetched: app.app_last_fetched,+      .iter()+      .map(|(key, app)| {+        let flatpak_repo = if config.enabled {+          let app_override = config.app_overrides.get(key);+          let app_version_override = app_override.and_then(|o| o.version.clone());+          let min_version = app_override.and_then(|o| o.min_version.clone());+          let latest_v = app.latest_release.as_ref().map(|r| r.version.clone());++          let (version, is_synced) = match (app_version_override, latest_v) {+            (Some(override_v), Some(latest)) => {+              let synced = artcraft_api_defs::craft_apps::flatpak::compare_versions(&override_v, &latest) != Some(std::cmp::Ordering::Less);+              (Some(override_v), Some(synced))+            }+            (Some(override_v), None) => (Some(override_v), None),+            (None, _) => (None, None),+          };++          Some(artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoInfo {+            app_id: format!("ai.storyteller.{key}"),+            repo_url: format!("{}/{}", config.repo_base_url.trim_end_matches('/'), key),+            flatpakrepo_url: format!("{}/v1/craft_apps/flatpak/{key}.flatpakrepo", config.api_base_url.trim_end_matches('/')),+            branch: "stable".to_string(),+            version,+            min_version,+            is_synced,+            gpg_key_url: config.gpg_key_url.clone(),           })+        } else {+          None+        };++        (key.clone(), CraftAppReleaseInfo {+          name: app.name.clone(),+          repo: app.repo.clone(),+          latest_release: app.latest_release.clone(),+          app_last_fetched: app.app_last_fetched,+          flatpak_repo,         })-        .collect();+      })+      .collect();     let last_fetched = least_recently_fetched(&apps);     CraftAppsReleaseInfo { apps, last_fetched }   } } +/// Configuration for Flatpak repository advertising in release info responses.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct CraftAppsFlatpakConfig {+  pub enabled: bool,+  pub repo_base_url: String,+  pub api_base_url: String,+  pub gpg_key_url: Option<String>,+  pub app_overrides: BTreeMap<String, CraftAppFlatpakOverride>,+}++#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct CraftAppFlatpakOverride {+  pub version: Option<String>,+  pub min_version: Option<String>,+}++impl Default for CraftAppsFlatpakConfig {+  fn default() -> Self {+    Self {+      enabled: false,+      repo_base_url: "https://repo.getartcraft.com".to_string(),+      api_base_url: "https://api.getartcraft.com".to_string(),+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+      app_overrides: BTreeMap::new(),+    }+  }+}++impl CraftAppsFlatpakConfig {+  /// Reads flatpak configuration and per-app overrides from environment variables once,+  /// outside of snapshot().+  pub fn from_env(known_app_keys: impl IntoIterator<Item = impl AsRef<str>>) -> Self {+    let enabled = easyenv::get_env_bool_or_default("CRAFT_APPS_FLATPAK_REPO_ENABLED", false);+    let repo_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");+    let api_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_API_BASE_URL", "https://api.getartcraft.com");+    let website_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_WEBSITE_BASE_URL", "https://getartcraft.com");+    let default_gpg_key = format!("{}/keys/flatpak.gpg", website_base_url.trim_end_matches('/'));+    let gpg_key_url = Some(easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_GPG_KEY_URL", &default_gpg_key));++    let mut app_overrides = BTreeMap::new();+    if enabled {+      for app_key in known_app_keys {+        let key_str = app_key.as_ref();+        let app_env_key = key_str.to_uppercase().replace('-', "_");+        let version = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_VERSION_{app_env_key}"));+        let min_version = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_MIN_VERSION_{app_env_key}"));+        if version.is_some() || min_version.is_some() {+          app_overrides.insert(key_str.to_string(), CraftAppFlatpakOverride { version, min_version });+        }+      }+    }++    Self {+      enabled,+      repo_base_url,+      api_base_url,+      gpg_key_url,+      app_overrides,+    }+  }+}+ impl CachedCraftApp {   /// Whether the last request for this app failed (no success since).   pub fn is_failing(&self) -> bool {@@ -276,4 +414,130 @@ mod tests {     cache.merge_failure("photocraft", t(5), 4);     assert_eq!(cache.app("photocraft").unwrap().consecutive_failures, 4);   }++  #[test]+  fn snapshot_populates_flatpak_repo_info() {+    let cache = CraftAppsReleaseCache::new();+    let apps = [("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string())];+    cache.register_apps(&apps);++    // Default snapshot has flatpak disabled:+    let snapshot = cache.snapshot();+    assert!(snapshot.apps["photocraft"].flatpak_repo.is_none());++    // With flatpak enabled:+    let config = CraftAppsFlatpakConfig {+      enabled: true,+      repo_base_url: "https://repo.getartcraft.com".to_string(),+      api_base_url: "https://api.getartcraft.com".to_string(),+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+      app_overrides: BTreeMap::new(),+    };+    let snapshot = cache.snapshot_with_flatpak_config(&config);+    let flatpak = snapshot.apps["photocraft"].flatpak_repo.as_ref().unwrap();+    assert_eq!(flatpak.app_id, "ai.storyteller.photocraft");+    assert!(flatpak.repo_url.contains("photocraft"));+    assert!(flatpak.flatpakrepo_url.ends_with("photocraft.flatpakrepo"));+    assert_eq!(flatpak.branch, "stable");+    assert_eq!(flatpak.gpg_key_url.as_deref(), Some("https://getartcraft.com/keys/flatpak.gpg"));+  }++  #[test]+  fn snapshot_detects_lagging_flatpak_repo() {+    let cache = CraftAppsReleaseCache::new();+    let apps = [("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string())];+    cache.register_apps(&apps);+    let release = artcraft_api_defs::craft_apps::release_info::CraftAppRelease {+      tag: "v0.5.0".to_string(),+      version: "0.5.0".to_string(),+      name: "PhotoCraft 0.5.0".to_string(),+      published_at: None,+      html_url: "https://github.com/storytold/photocraft/releases/tag/v0.5.0".to_string(),+      prerelease: false,+      notes_markdown: String::new(),+      assets: Vec::new(),+    };+    cache.store("photocraft", Some(release), None, Utc::now());++    // Without override, version and is_synced are None (unknown repo state):+    let config_no_override = CraftAppsFlatpakConfig {+      enabled: true,+      repo_base_url: "https://repo.getartcraft.com".to_string(),+      api_base_url: "https://api.getartcraft.com".to_string(),+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+      app_overrides: BTreeMap::new(),+    };+    let snapshot = cache.snapshot_with_flatpak_config(&config_no_override);+    let flatpak = snapshot.apps["photocraft"].flatpak_repo.as_ref().unwrap();+    assert_eq!(flatpak.version, None);+    assert_eq!(flatpak.is_synced, None);++    // With explicit override simulating repo lag (0.4.0 < 0.5.0):+    let mut overrides = BTreeMap::new();+    overrides.insert("photocraft".to_string(), CraftAppFlatpakOverride {+      version: Some("0.4.0".to_string()),+      min_version: None,+    });+    let config_lag = CraftAppsFlatpakConfig {+      enabled: true,+      repo_base_url: "https://repo.getartcraft.com".to_string(),+      api_base_url: "https://api.getartcraft.com".to_string(),+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+      app_overrides: overrides,+    };+    let snapshot_lag = cache.snapshot_with_flatpak_config(&config_lag);+    let flatpak_lag = snapshot_lag.apps["photocraft"].flatpak_repo.as_ref().unwrap();+    assert_eq!(flatpak_lag.version.as_deref(), Some("0.4.0"));+    assert_eq!(flatpak_lag.is_synced, Some(false));+    assert!(flatpak_lag.would_downgrade("0.5.0"));+  }++  #[test]+  fn get_app_and_release_lookup() {+    let cache = CraftAppsReleaseCache::new();+    let apps = [+      ("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string()),+      ("artcraft-launcher".to_string(), "ArtCraft Launcher".to_string(), "storytold/craft-launcher".to_string()),+    ];+    cache.register_apps(&apps);++    // Initial state: app known, but no release+    let app_meta = cache.get_app("photocraft");+    assert!(app_meta.is_some());+    assert_eq!(app_meta.as_ref().unwrap().name, "PhotoCraft");+    assert!(cache.get_app_release("photocraft").is_none());++    let (canonical_key, app_entry) = cache.get_app_with_key("photocraft").unwrap();+    assert_eq!(canonical_key, "photocraft");+    assert_eq!(app_entry.name, "PhotoCraft");++    // Alias lookups+    assert!(cache.get_app("PhotoCraft").is_some());+    assert!(cache.get_app("launcher").is_some());+    assert!(cache.get_app("craft-launcher").is_some());+    let (alias_key, _) = cache.get_app_with_key("craft-launcher").unwrap();+    assert_eq!(alias_key, "artcraft-launcher");+    assert!(cache.get_app("unknown-app").is_none());++    // Once release stored:+    let release = artcraft_api_defs::craft_apps::release_info::CraftAppRelease {+      tag: "v0.5.0".to_string(),+      version: "0.5.0".to_string(),+      name: "PhotoCraft 0.5.0".to_string(),+      published_at: None,+      html_url: "https://github.com/storytold/photocraft/releases/tag/v0.5.0".to_string(),+      prerelease: false,+      notes_markdown: String::new(),+      assets: Vec::new(),+    };+    cache.store("photocraft", Some(release), None, Utc::now());++    let fetched = cache.get_app_release("photocraft");+    assert!(fetched.is_some());+    assert_eq!(fetched.unwrap().version, "0.5.0");++    let fetched_alias = cache.get_app_release("PhotoCraft");+    assert!(fetched_alias.is_some());+    assert_eq!(fetched_alias.unwrap().version, "0.5.0");+  } }diff --git a/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs b/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs--- a/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs+++ b/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs@@ -326,6 +326,7 @@ mod tests {       app_renames: Vec::new(),       repo_renames: Vec::new(),       important_notices: CraftAppsImportantNotices::default(),+      suite_flatpak_repo: None,     };     let json = serde_json::to_string_pretty(&response).unwrap();     if let Ok(path) = std::env::var("CRAFT_APPS_SAMPLE_OUT") {