mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
1099 lines
35 KiB
Rust
1099 lines
35 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::desktop_modules::{ModuleCoordinate, host_module_platform, publish_renderer_modules};
|
|
use crate::paths::{DESKTOP_DIR, DEV_STATE_DIR, ROOT};
|
|
use crate::proc::{PNPM_INSTALL_ENV, RunOptions, run_command};
|
|
use anyhow::{Context, Result, bail, ensure};
|
|
use sha2::{Digest, Sha256};
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::thread::sleep;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
pub const DEVELOPMENT_APP_NAME: &str = "Fluxer Development";
|
|
pub const DEFAULT_INSTALL_DIR: &str = "/Applications";
|
|
const DEVELOPMENT_CHANNEL: &str = "development";
|
|
const DEVELOPMENT_BUNDLE_ID: &str = "app.fluxer.development";
|
|
const DEVELOPMENT_PROTOCOL_SCHEME: &str = "fluxer-development";
|
|
const PRODUCTION_PROTOCOL_SCHEME: &str = "fluxer";
|
|
const RENDERER_BUILD_VERSION: &str = "dev";
|
|
const RENDERER_SOURCE_PATHS: &[&str] = &["fluxer_app", "packages", "pnpm-lock.yaml"];
|
|
const RENDERER_FORBIDDEN_ENTRIES: &[&str] = &["sw.js", "sw.js.map"];
|
|
const DEVELOPER_ID_PREFIX: &str = "Developer ID Application: ";
|
|
const APP_QUIT_TIMEOUT: Duration = Duration::from_secs(15);
|
|
const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
const MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS: &[(&str, &str)] = &[
|
|
(
|
|
"NSMicrophoneUsageDescription",
|
|
"Fluxer needs access to your microphone to enable voice chat features.",
|
|
),
|
|
(
|
|
"NSCameraUsageDescription",
|
|
"Fluxer needs access to your camera to enable video chat features.",
|
|
),
|
|
(
|
|
"NSAppleEventsUsageDescription",
|
|
"Fluxer needs access to Apple Events for automation features.",
|
|
),
|
|
(
|
|
"NSAudioCaptureUsageDescription",
|
|
"Fluxer captures audio from the screen or window you choose to share.",
|
|
),
|
|
(
|
|
"NSScreenCaptureUsageDescription",
|
|
"Fluxer captures the screen or window you choose to share.",
|
|
),
|
|
];
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum MacSigning {
|
|
DeveloperId(String),
|
|
AdHoc,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct DesktopAppOptions {
|
|
pub rebuild_renderer: bool,
|
|
pub ad_hoc: bool,
|
|
pub install_dir: PathBuf,
|
|
pub install: bool,
|
|
pub launch: bool,
|
|
}
|
|
|
|
fn desktop_app_state_dir() -> PathBuf {
|
|
DEV_STATE_DIR.join("desktop-app")
|
|
}
|
|
|
|
fn renderer_cache_dir() -> PathBuf {
|
|
desktop_app_state_dir().join("renderer")
|
|
}
|
|
|
|
fn renderer_stamp_path() -> PathBuf {
|
|
desktop_app_state_dir().join("renderer.stamp")
|
|
}
|
|
|
|
fn package_output_dir() -> PathBuf {
|
|
desktop_app_state_dir().join("package")
|
|
}
|
|
|
|
fn installed_app_path(install_dir: &Path) -> PathBuf {
|
|
install_dir.join(format!("{DEVELOPMENT_APP_NAME}.app"))
|
|
}
|
|
|
|
fn app_executable_path(app: &Path) -> PathBuf {
|
|
app.join("Contents/MacOS").join(DEVELOPMENT_APP_NAME)
|
|
}
|
|
|
|
pub fn install_desktop_dependencies() -> Result<()> {
|
|
run_command(
|
|
&["pnpm", "install", "--frozen-lockfile"],
|
|
RunOptions {
|
|
cwd: ROOT.as_path(),
|
|
env: PNPM_INSTALL_ENV
|
|
.iter()
|
|
.map(|(k, v)| ((*k).to_owned(), Some((*v).to_owned())))
|
|
.collect(),
|
|
..RunOptions::default()
|
|
},
|
|
)
|
|
.map(drop)
|
|
}
|
|
|
|
fn ensure_desktop_dependencies() -> Result<()> {
|
|
if DESKTOP_DIR
|
|
.join("node_modules/.bin/electron-builder")
|
|
.exists()
|
|
{
|
|
return Ok(());
|
|
}
|
|
install_desktop_dependencies()
|
|
}
|
|
|
|
pub fn development_version(now: SystemTime) -> String {
|
|
let now = chrono::DateTime::<chrono::Utc>::from(now);
|
|
use chrono::{Datelike, Timelike};
|
|
let micro = now.hour() * 10_000 + now.minute() * 100 + now.second();
|
|
format!("{}.{}{:02}.{micro}", now.year(), now.month(), now.day())
|
|
}
|
|
|
|
fn target_electron_arch() -> Result<String> {
|
|
if let Ok(arch) = env::var("ELECTRON_ARCH")
|
|
&& !arch.trim().is_empty()
|
|
{
|
|
return Ok(arch.trim().to_owned());
|
|
}
|
|
match env::consts::ARCH {
|
|
"x86_64" => Ok("x64".to_owned()),
|
|
"aarch64" => Ok("arm64".to_owned()),
|
|
other => bail!(
|
|
"cannot build the desktop app on host architecture {other}. Set ELECTRON_ARCH to x64 or arm64"
|
|
),
|
|
}
|
|
}
|
|
|
|
fn git_output(args: &[&str]) -> Result<Vec<u8>> {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(ROOT.as_path())
|
|
.env("GIT_OPTIONAL_LOCKS", "0")
|
|
.stdin(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
|
|
if !output.status.success() {
|
|
bail!(
|
|
"git {} failed: {}",
|
|
args.join(" "),
|
|
String::from_utf8_lossy(&output.stderr).trim_end()
|
|
);
|
|
}
|
|
Ok(output.stdout)
|
|
}
|
|
|
|
fn renderer_source_fingerprint() -> Result<String> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(format!("{DEVELOPMENT_CHANNEL}\n{RENDERER_BUILD_VERSION}\n"));
|
|
let mut tree_args = vec!["rev-parse".to_owned()];
|
|
tree_args.extend(
|
|
RENDERER_SOURCE_PATHS
|
|
.iter()
|
|
.map(|path| format!("HEAD:{path}")),
|
|
);
|
|
hasher.update(git_output(
|
|
&tree_args.iter().map(String::as_str).collect::<Vec<_>>(),
|
|
)?);
|
|
let mut diff_args = vec!["diff", "--no-ext-diff", "--binary", "HEAD", "--"];
|
|
diff_args.extend(RENDERER_SOURCE_PATHS);
|
|
hasher.update(git_output(&diff_args)?);
|
|
let mut untracked_args = vec!["ls-files", "--others", "--exclude-standard", "-z", "--"];
|
|
untracked_args.extend(RENDERER_SOURCE_PATHS);
|
|
for path in git_output(&untracked_args)?
|
|
.split(|byte| *byte == 0)
|
|
.filter(|path| !path.is_empty())
|
|
{
|
|
hasher.update(path);
|
|
if let Ok(metadata) = fs::metadata(ROOT.join(String::from_utf8_lossy(path).as_ref())) {
|
|
hasher.update(metadata.len().to_le_bytes());
|
|
if let Ok(modified) = metadata.modified()
|
|
&& let Ok(elapsed) = modified.duration_since(UNIX_EPOCH)
|
|
{
|
|
hasher.update(elapsed.as_nanos().to_le_bytes());
|
|
}
|
|
}
|
|
}
|
|
Ok(hex::encode(hasher.finalize()))
|
|
}
|
|
|
|
pub fn verify_renderer_tree(root: &Path) -> Result<()> {
|
|
ensure!(
|
|
root.join("index.html").is_file(),
|
|
"renderer at {} has no index.html",
|
|
root.display()
|
|
);
|
|
ensure!(
|
|
root.join("assets").is_dir(),
|
|
"renderer at {} has no assets directory",
|
|
root.display()
|
|
);
|
|
for entry in RENDERER_FORBIDDEN_ENTRIES {
|
|
ensure!(
|
|
!root.join(entry).exists(),
|
|
"the desktop renderer must not ship a service worker, found {} in {}",
|
|
entry,
|
|
root.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn copy_tree(
|
|
source: &Path,
|
|
destination: &Path,
|
|
skip: &dyn Fn(&Path) -> bool,
|
|
) -> Result<()> {
|
|
fs::create_dir_all(destination)
|
|
.with_context(|| format!("failed to create {}", destination.display()))?;
|
|
for entry in
|
|
fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
|
|
{
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if skip(&path) {
|
|
continue;
|
|
}
|
|
let target = destination.join(entry.file_name());
|
|
if entry.file_type()?.is_dir() {
|
|
copy_tree(&path, &target, skip)?;
|
|
} else {
|
|
fs::copy(&path, &target).with_context(|| {
|
|
format!("failed to copy {} to {}", path.display(), target.display())
|
|
})?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn remove_path(path: &Path) -> Result<()> {
|
|
match fs::symlink_metadata(path) {
|
|
Ok(metadata) if metadata.is_dir() => {
|
|
fs::remove_dir_all(path).with_context(|| format!("failed to remove {}", path.display()))
|
|
}
|
|
Ok(_) => {
|
|
fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(error) => Err(error).with_context(|| format!("failed to inspect {}", path.display())),
|
|
}
|
|
}
|
|
|
|
fn is_source_map(path: &Path) -> bool {
|
|
path.extension().is_some_and(|extension| extension == "map")
|
|
}
|
|
|
|
fn ensure_renderer(rebuild: bool) -> Result<(PathBuf, String)> {
|
|
let cache = renderer_cache_dir();
|
|
let stamp_path = renderer_stamp_path();
|
|
let fingerprint = renderer_source_fingerprint()?;
|
|
let cached = fs::read_to_string(&stamp_path).unwrap_or_default();
|
|
if !rebuild && cached.trim() == fingerprint && verify_renderer_tree(&cache).is_ok() {
|
|
println!(
|
|
"Reusing the renderer built from unchanged sources ({})",
|
|
&fingerprint[..12]
|
|
);
|
|
return Ok((cache, fingerprint));
|
|
}
|
|
println!("Building the renderer for the development app...");
|
|
run_command(
|
|
&["pnpm", "--filter", "fluxer_app", "build:desktop"],
|
|
RunOptions {
|
|
cwd: ROOT.as_path(),
|
|
env: vec![
|
|
("NODE_ENV".to_owned(), Some("production".to_owned())),
|
|
(
|
|
"PUBLIC_BUILD_VERSION".to_owned(),
|
|
Some(RENDERER_BUILD_VERSION.to_owned()),
|
|
),
|
|
(
|
|
"PUBLIC_RELEASE_CHANNEL".to_owned(),
|
|
Some(DEVELOPMENT_CHANNEL.to_owned()),
|
|
),
|
|
],
|
|
load_default_env: false,
|
|
..RunOptions::default()
|
|
},
|
|
)?;
|
|
let app_dist = ROOT.join("fluxer_app/dist");
|
|
verify_renderer_tree(&app_dist)?;
|
|
remove_path(&stamp_path)?;
|
|
remove_path(&cache)?;
|
|
copy_tree(&app_dist, &cache, &is_source_map)?;
|
|
verify_renderer_tree(&cache)?;
|
|
fs::write(&stamp_path, format!("{fingerprint}\n"))
|
|
.with_context(|| format!("failed to write {}", stamp_path.display()))?;
|
|
Ok((cache, fingerprint))
|
|
}
|
|
|
|
struct BuildChannelFileGuard {
|
|
path: PathBuf,
|
|
original: Option<String>,
|
|
}
|
|
|
|
impl BuildChannelFileGuard {
|
|
fn capture() -> Self {
|
|
let path = DESKTOP_DIR.join("src/common/BuildChannel.ts");
|
|
let original = fs::read_to_string(&path).ok();
|
|
Self { path, original }
|
|
}
|
|
}
|
|
|
|
impl Drop for BuildChannelFileGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(original) = &self.original
|
|
&& fs::read_to_string(&self.path).ok().as_deref() != Some(original.as_str())
|
|
{
|
|
let _ = fs::write(&self.path, original);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DesktopBuildInfo {
|
|
build_version: String,
|
|
build_channel: String,
|
|
offline_build: bool,
|
|
modules_enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RendererDelivery {
|
|
Offline,
|
|
Modules,
|
|
}
|
|
|
|
fn verify_development_build_info(
|
|
raw: &str,
|
|
version: &str,
|
|
delivery: RendererDelivery,
|
|
) -> Result<()> {
|
|
let info: DesktopBuildInfo =
|
|
serde_json::from_str(raw).context("failed to parse dist/build-info.json")?;
|
|
ensure!(
|
|
info.build_channel == DEVELOPMENT_CHANNEL,
|
|
"the desktop build compiled the {} channel instead of {DEVELOPMENT_CHANNEL}",
|
|
info.build_channel
|
|
);
|
|
match delivery {
|
|
RendererDelivery::Offline => {
|
|
ensure!(
|
|
info.offline_build && !info.modules_enabled,
|
|
"the unpackaged development shell must carry its renderer offline"
|
|
);
|
|
}
|
|
RendererDelivery::Modules => {
|
|
ensure!(
|
|
info.modules_enabled && !info.offline_build,
|
|
"the development app must load its renderer from the module feed like a release"
|
|
);
|
|
}
|
|
}
|
|
ensure!(
|
|
info.build_version == version,
|
|
"dist/build-info.json records version {} instead of {version}",
|
|
info.build_version
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn build_desktop_shell() -> Result<()> {
|
|
let env = vec![
|
|
(
|
|
"BUILD_CHANNEL".to_owned(),
|
|
Some(env::var("BUILD_CHANNEL").unwrap_or_else(|_| "canary".to_owned())),
|
|
),
|
|
(
|
|
"PUBLIC_BUILD_VERSION".to_owned(),
|
|
Some(env::var("PUBLIC_BUILD_VERSION").unwrap_or_else(|_| "dev".to_owned())),
|
|
),
|
|
(
|
|
"PUBLIC_RELEASE_CHANNEL".to_owned(),
|
|
Some(env::var("PUBLIC_RELEASE_CHANNEL").unwrap_or_else(|_| "canary".to_owned())),
|
|
),
|
|
];
|
|
run_command(
|
|
&["pnpm", "build"],
|
|
RunOptions {
|
|
cwd: DESKTOP_DIR.as_path(),
|
|
env,
|
|
..RunOptions::default()
|
|
},
|
|
)
|
|
.map(drop)
|
|
}
|
|
|
|
pub fn build_desktop(
|
|
rebuild_renderer: bool,
|
|
skip_native: bool,
|
|
delivery: RendererDelivery,
|
|
) -> Result<String> {
|
|
ensure_desktop_dependencies()?;
|
|
let (renderer, fingerprint) = ensure_renderer(rebuild_renderer)?;
|
|
let version = development_version(SystemTime::now());
|
|
let arch = target_electron_arch()?;
|
|
if delivery == RendererDelivery::Modules {
|
|
publish_renderer_modules(
|
|
&renderer,
|
|
&fingerprint,
|
|
&version,
|
|
&ModuleCoordinate {
|
|
channel: DEVELOPMENT_CHANNEL.to_owned(),
|
|
platform: host_module_platform()?.to_owned(),
|
|
arch: arch.clone(),
|
|
},
|
|
)?;
|
|
}
|
|
let dist_renderer = DESKTOP_DIR.join("dist/renderer");
|
|
remove_path(&dist_renderer)?;
|
|
copy_tree(&renderer, &dist_renderer, &|_| false)?;
|
|
if delivery == RendererDelivery::Modules {
|
|
let version_file = dist_renderer.join("version.json");
|
|
fs::write(
|
|
&version_file,
|
|
serde_json::to_vec(&serde_json::json!({ "version": version }))?,
|
|
)
|
|
.with_context(|| format!("failed to write {}", version_file.display()))?;
|
|
}
|
|
{
|
|
let _guard = BuildChannelFileGuard::capture();
|
|
run_command(
|
|
&[
|
|
"pnpm",
|
|
"exec",
|
|
"node",
|
|
"scripts/build.mjs",
|
|
"--use-shared-renderer",
|
|
],
|
|
RunOptions {
|
|
cwd: DESKTOP_DIR.as_path(),
|
|
env: vec![
|
|
(
|
|
"BUILD_CHANNEL".to_owned(),
|
|
Some(DEVELOPMENT_CHANNEL.to_owned()),
|
|
),
|
|
(
|
|
"FLUXER_OFFLINE".to_owned(),
|
|
(delivery == RendererDelivery::Offline).then(|| "1".to_owned()),
|
|
),
|
|
(
|
|
"FLUXER_MODULES".to_owned(),
|
|
(delivery == RendererDelivery::Modules).then(|| "1".to_owned()),
|
|
),
|
|
(
|
|
"FLUXER_SKIP_NATIVE".to_owned(),
|
|
skip_native.then(|| "true".to_owned()),
|
|
),
|
|
("NODE_ENV".to_owned(), Some("production".to_owned())),
|
|
(
|
|
"FLUXER_DESKTOP_PRODUCTION".to_owned(),
|
|
Some("true".to_owned()),
|
|
),
|
|
("PUBLIC_BUILD_VERSION".to_owned(), Some(version.clone())),
|
|
("BUILD_VERSION".to_owned(), Some(version.clone())),
|
|
(
|
|
"PUBLIC_RELEASE_CHANNEL".to_owned(),
|
|
Some(DEVELOPMENT_CHANNEL.to_owned()),
|
|
),
|
|
(
|
|
"RELEASE_CHANNEL".to_owned(),
|
|
Some(DEVELOPMENT_CHANNEL.to_owned()),
|
|
),
|
|
("ELECTRON_ARCH".to_owned(), Some(arch)),
|
|
("WORKDIR".to_owned(), Some(ROOT.display().to_string())),
|
|
],
|
|
load_default_env: false,
|
|
..RunOptions::default()
|
|
},
|
|
)?;
|
|
}
|
|
let build_info_path = DESKTOP_DIR.join("dist/build-info.json");
|
|
let build_info = fs::read_to_string(&build_info_path)
|
|
.with_context(|| format!("failed to read {}", build_info_path.display()))?;
|
|
verify_development_build_info(&build_info, &version, delivery)?;
|
|
Ok(version)
|
|
}
|
|
|
|
pub fn parse_developer_id_identity(find_identity_output: &str) -> Option<String> {
|
|
find_identity_output.lines().find_map(|line| {
|
|
let start = line.find(&format!("\"{DEVELOPER_ID_PREFIX}"))? + 1;
|
|
let rest = &line[start..];
|
|
let name = &rest[..rest.find('"')?];
|
|
Some(name.trim_start_matches(DEVELOPER_ID_PREFIX).to_owned())
|
|
})
|
|
}
|
|
|
|
pub fn resolve_mac_signing(force_ad_hoc: bool) -> MacSigning {
|
|
if force_ad_hoc {
|
|
return MacSigning::AdHoc;
|
|
}
|
|
if let Ok(name) = env::var("CSC_NAME")
|
|
&& !name.trim().is_empty()
|
|
{
|
|
return MacSigning::DeveloperId(
|
|
name.trim()
|
|
.trim_start_matches(DEVELOPER_ID_PREFIX)
|
|
.to_owned(),
|
|
);
|
|
}
|
|
let output = Command::new("security")
|
|
.args(["find-identity", "-v", "-p", "codesigning"])
|
|
.stdin(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.output();
|
|
if let Ok(output) = output
|
|
&& let Some(name) = parse_developer_id_identity(&String::from_utf8_lossy(&output.stdout))
|
|
{
|
|
return MacSigning::DeveloperId(name);
|
|
}
|
|
MacSigning::AdHoc
|
|
}
|
|
|
|
fn builder_target_args(arch: &str) -> Vec<String> {
|
|
if cfg!(target_os = "macos") {
|
|
vec!["--mac".to_owned(), format!("--{arch}")]
|
|
} else {
|
|
vec![format!("--{arch}")]
|
|
}
|
|
}
|
|
|
|
pub fn electron_builder_args(
|
|
output_dir: &Path,
|
|
arch: &str,
|
|
signing: &MacSigning,
|
|
extra_args: &[String],
|
|
) -> Vec<String> {
|
|
let mut args = vec![
|
|
"pnpm".to_owned(),
|
|
"exec".to_owned(),
|
|
"electron-builder".to_owned(),
|
|
"--config".to_owned(),
|
|
"electron-builder.config.cjs".to_owned(),
|
|
"--dir".to_owned(),
|
|
format!("-c.directories.output={}", output_dir.display()),
|
|
"-c.mac.notarize=false".to_owned(),
|
|
];
|
|
if *signing == MacSigning::AdHoc {
|
|
args.push("-c.mac.sign.identity=-".to_owned());
|
|
}
|
|
if !extra_args
|
|
.iter()
|
|
.any(|arg| arg == "--mac" || arg == "--win" || arg == "--linux")
|
|
{
|
|
args.extend(builder_target_args(arch));
|
|
}
|
|
args.extend(extra_args.iter().cloned());
|
|
args
|
|
}
|
|
|
|
pub fn package_desktop(
|
|
rebuild_renderer: bool,
|
|
ad_hoc: bool,
|
|
extra_args: &[String],
|
|
) -> Result<PathBuf> {
|
|
let version = build_desktop(rebuild_renderer, false, RendererDelivery::Modules)?;
|
|
let arch = target_electron_arch()?;
|
|
let signing = if cfg!(target_os = "macos") {
|
|
resolve_mac_signing(ad_hoc)
|
|
} else {
|
|
MacSigning::AdHoc
|
|
};
|
|
if cfg!(target_os = "macos") {
|
|
match &signing {
|
|
MacSigning::DeveloperId(name) => {
|
|
println!("Signing {DEVELOPMENT_APP_NAME} with Developer ID Application: {name}")
|
|
}
|
|
MacSigning::AdHoc => println!(
|
|
"No Developer ID Application identity found in the keychain, signing {DEVELOPMENT_APP_NAME} ad hoc. macOS will ask for camera, microphone and screen access again after every rebuild. Set CSC_NAME to a Developer ID identity to keep the grants."
|
|
),
|
|
}
|
|
}
|
|
let output_dir = package_output_dir();
|
|
remove_path(&output_dir)?;
|
|
let mut env = vec![
|
|
(
|
|
"BUILD_CHANNEL".to_owned(),
|
|
Some(DEVELOPMENT_CHANNEL.to_owned()),
|
|
),
|
|
("VERSION".to_owned(), Some(version.clone())),
|
|
("ELECTRON_ARCH".to_owned(), Some(arch.clone())),
|
|
("NODE_ENV".to_owned(), Some("production".to_owned())),
|
|
];
|
|
match &signing {
|
|
MacSigning::DeveloperId(name) => env.push(("CSC_NAME".to_owned(), Some(name.clone()))),
|
|
MacSigning::AdHoc => env.push((
|
|
"CSC_IDENTITY_AUTO_DISCOVERY".to_owned(),
|
|
Some("false".to_owned()),
|
|
)),
|
|
}
|
|
let args = electron_builder_args(&output_dir, &arch, &signing, extra_args);
|
|
let refs: Vec<_> = args.iter().map(String::as_str).collect();
|
|
run_command(
|
|
&refs,
|
|
RunOptions {
|
|
cwd: DESKTOP_DIR.as_path(),
|
|
env,
|
|
load_default_env: false,
|
|
..RunOptions::default()
|
|
},
|
|
)?;
|
|
if !cfg!(target_os = "macos") {
|
|
println!(
|
|
"Packaged {DEVELOPMENT_APP_NAME} {version} into {}",
|
|
output_dir.display()
|
|
);
|
|
return Ok(output_dir);
|
|
}
|
|
let app = find_packaged_app(&output_dir)?;
|
|
verify_packaged_app(&app)?;
|
|
println!(
|
|
"Packaged {DEVELOPMENT_APP_NAME} {version} at {}",
|
|
app.display()
|
|
);
|
|
Ok(app)
|
|
}
|
|
|
|
fn find_packaged_app(output_dir: &Path) -> Result<PathBuf> {
|
|
let bundle_name = format!("{DEVELOPMENT_APP_NAME}.app");
|
|
if output_dir.is_dir() {
|
|
for entry in fs::read_dir(output_dir)? {
|
|
let candidate = entry?.path().join(&bundle_name);
|
|
if candidate.is_dir() {
|
|
return Ok(candidate);
|
|
}
|
|
}
|
|
}
|
|
bail!(
|
|
"no packaged {bundle_name} under {}. Run `pnpm dev:desktop:package` first",
|
|
output_dir.display()
|
|
)
|
|
}
|
|
|
|
fn plist_value(info_plist: &Path, key: &str, format: &str) -> Result<String> {
|
|
let output = Command::new("plutil")
|
|
.args(["-extract", key, format, "-o", "-"])
|
|
.arg(info_plist)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.with_context(|| format!("failed to read {key} from {}", info_plist.display()))?;
|
|
if !output.status.success() {
|
|
bail!(
|
|
"{} has no {key}: {}",
|
|
info_plist.display(),
|
|
String::from_utf8_lossy(&output.stderr).trim_end()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
|
}
|
|
|
|
pub fn verify_url_schemes(url_types_json: &str) -> Result<()> {
|
|
let url_types: Vec<serde_json::Value> =
|
|
serde_json::from_str(url_types_json).context("CFBundleURLTypes is not a JSON array")?;
|
|
let schemes: Vec<&str> = url_types
|
|
.iter()
|
|
.filter_map(|entry| entry.get("CFBundleURLSchemes")?.as_array())
|
|
.flatten()
|
|
.filter_map(serde_json::Value::as_str)
|
|
.collect();
|
|
ensure!(
|
|
!schemes
|
|
.iter()
|
|
.any(|scheme| scheme.eq_ignore_ascii_case(PRODUCTION_PROTOCOL_SCHEME)),
|
|
"{DEVELOPMENT_APP_NAME} must not register {PRODUCTION_PROTOCOL_SCHEME}://, it registers {schemes:?}"
|
|
);
|
|
ensure!(
|
|
schemes.contains(&DEVELOPMENT_PROTOCOL_SCHEME),
|
|
"{DEVELOPMENT_APP_NAME} must register {DEVELOPMENT_PROTOCOL_SCHEME}://, it registers {schemes:?}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_packaged_app(app: &Path) -> Result<()> {
|
|
let info_plist = app.join("Contents/Info.plist");
|
|
let bundle_id = plist_value(&info_plist, "CFBundleIdentifier", "raw")?;
|
|
ensure!(
|
|
bundle_id == DEVELOPMENT_BUNDLE_ID,
|
|
"{} has bundle id {bundle_id}, expected {DEVELOPMENT_BUNDLE_ID}",
|
|
app.display()
|
|
);
|
|
verify_url_schemes(&plist_value(&info_plist, "CFBundleURLTypes", "json")?)?;
|
|
ensure!(
|
|
app_executable_path(app).is_file(),
|
|
"{} has no {DEVELOPMENT_APP_NAME} executable",
|
|
app.display()
|
|
);
|
|
let status = Command::new("codesign")
|
|
.args(["--verify", "--deep", "--strict"])
|
|
.arg(app)
|
|
.status()
|
|
.context("failed to run codesign --verify")?;
|
|
ensure!(
|
|
status.success(),
|
|
"{} failed code signature verification",
|
|
app.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn parse_pids_for_executable(ps_output: &str, executable: &str) -> Vec<i32> {
|
|
ps_output
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let line = line.trim_start();
|
|
let (pid, command) = line.split_once(char::is_whitespace)?;
|
|
(command.trim() == executable)
|
|
.then(|| pid.parse().ok())
|
|
.flatten()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn running_pids(executable: &Path) -> Result<Vec<i32>> {
|
|
let output = Command::new("ps")
|
|
.args(["-axww", "-o", "pid=,comm="])
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.context("failed to list running processes")?;
|
|
Ok(parse_pids_for_executable(
|
|
&String::from_utf8_lossy(&output.stdout),
|
|
&executable.to_string_lossy(),
|
|
))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn process_alive(pid: i32) -> bool {
|
|
unsafe { libc::kill(pid, 0) == 0 }
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn signal_process(pid: i32, signal: libc::c_int) {
|
|
unsafe {
|
|
libc::kill(pid, signal);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn stop_running_app(app: &Path) -> Result<bool> {
|
|
let executable = app_executable_path(app);
|
|
let pids = running_pids(&executable)?;
|
|
if pids.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
for pid in &pids {
|
|
println!("Asking the running {DEVELOPMENT_APP_NAME} (pid {pid}) to quit...");
|
|
signal_process(*pid, libc::SIGTERM);
|
|
}
|
|
let deadline = Instant::now() + APP_QUIT_TIMEOUT;
|
|
while pids.iter().any(|pid| process_alive(*pid)) && Instant::now() < deadline {
|
|
sleep(Duration::from_millis(200));
|
|
}
|
|
for pid in pids.iter().filter(|pid| process_alive(**pid)) {
|
|
println!("{DEVELOPMENT_APP_NAME} (pid {pid}) did not quit in time, stopping it");
|
|
signal_process(*pid, libc::SIGKILL);
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn stop_running_app(_app: &Path) -> Result<bool> {
|
|
Ok(false)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn swap_paths(first: &Path, second: &Path) -> Result<()> {
|
|
use std::ffi::CString;
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let first_c = CString::new(first.as_os_str().as_bytes())?;
|
|
let second_c = CString::new(second.as_os_str().as_bytes())?;
|
|
let rc = unsafe { libc::renamex_np(first_c.as_ptr(), second_c.as_ptr(), libc::RENAME_SWAP) };
|
|
if rc != 0 {
|
|
return Err(std::io::Error::last_os_error()).with_context(|| {
|
|
format!(
|
|
"failed to swap {} with {}",
|
|
first.display(),
|
|
second.display()
|
|
)
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn swap_paths(first: &Path, second: &Path) -> Result<()> {
|
|
let parked = second.with_extension("app.previous");
|
|
remove_path(&parked)?;
|
|
fs::rename(second, &parked)?;
|
|
fs::rename(first, second)?;
|
|
fs::rename(&parked, first)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_host_macos(command: &str) -> Result<()> {
|
|
if !cfg!(target_os = "macos") || Path::new("/.dockerenv").exists() {
|
|
bail!(
|
|
"`desktop {command}` installs a macOS app. Run it on the Mac host, not inside the devcontainer."
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn install_desktop_app(
|
|
source: Option<&Path>,
|
|
install_dir: &Path,
|
|
launch: bool,
|
|
) -> Result<PathBuf> {
|
|
ensure_host_macos("install")?;
|
|
let source = match source {
|
|
Some(source) => source.to_path_buf(),
|
|
None => find_packaged_app(&package_output_dir())?,
|
|
};
|
|
verify_packaged_app(&source)?;
|
|
let destination = installed_app_path(install_dir);
|
|
let staging = install_dir.join(format!(".{DEVELOPMENT_APP_NAME}.app.installing"));
|
|
remove_path(&staging)?;
|
|
let status = Command::new("ditto")
|
|
.arg(&source)
|
|
.arg(&staging)
|
|
.status()
|
|
.context("failed to run ditto")?;
|
|
ensure!(
|
|
status.success(),
|
|
"failed to copy {} to {}",
|
|
source.display(),
|
|
staging.display()
|
|
);
|
|
stop_running_app(&destination)?;
|
|
if destination.exists() {
|
|
swap_paths(&staging, &destination)?;
|
|
remove_path(&staging)?;
|
|
} else {
|
|
fs::rename(&staging, &destination)
|
|
.with_context(|| format!("failed to move {} into place", staging.display()))?;
|
|
}
|
|
let _ = Command::new(LSREGISTER)
|
|
.args(["-f"])
|
|
.arg(&destination)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
println!(
|
|
"Installed {DEVELOPMENT_APP_NAME} at {}",
|
|
destination.display()
|
|
);
|
|
if launch {
|
|
let status = Command::new("open")
|
|
.arg(&destination)
|
|
.status()
|
|
.context("failed to open the installed app")?;
|
|
ensure!(status.success(), "failed to open {}", destination.display());
|
|
}
|
|
Ok(destination)
|
|
}
|
|
|
|
pub fn desktop_app(options: &DesktopAppOptions) -> Result<()> {
|
|
if options.install {
|
|
ensure_host_macos("app")?;
|
|
}
|
|
let app = package_desktop(options.rebuild_renderer, options.ad_hoc, &[])?;
|
|
if options.install {
|
|
install_desktop_app(Some(&app), &options.install_dir, options.launch)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn electron_args(args: &[String]) -> Vec<String> {
|
|
let mut runtime_args = Vec::new();
|
|
if cfg!(target_os = "linux")
|
|
&& Path::new("/.dockerenv").exists()
|
|
&& env::var("FLUXER_ELECTRON_NO_SANDBOX").as_deref() != Ok("0")
|
|
{
|
|
runtime_args.push("--no-sandbox".to_owned());
|
|
}
|
|
if linux_wayland_session() && !args.iter().any(|arg| arg.starts_with("--ozone-platform")) {
|
|
runtime_args.push("--ozone-platform=wayland".to_owned());
|
|
}
|
|
runtime_args.extend(args.iter().cloned());
|
|
runtime_args
|
|
}
|
|
|
|
fn linux_wayland_session() -> bool {
|
|
if !cfg!(target_os = "linux") {
|
|
return false;
|
|
}
|
|
let Some(display) = env::var_os("WAYLAND_DISPLAY") else {
|
|
return false;
|
|
};
|
|
if display.is_empty() {
|
|
return false;
|
|
}
|
|
let display_path = PathBuf::from(&display);
|
|
let socket = if display_path.is_absolute() {
|
|
display_path
|
|
} else {
|
|
let Some(runtime_dir) = env::var_os("XDG_RUNTIME_DIR") else {
|
|
return false;
|
|
};
|
|
Path::new(&runtime_dir).join(display_path)
|
|
};
|
|
socket.exists()
|
|
}
|
|
|
|
pub fn electron_command(args: &[String]) -> Vec<String> {
|
|
let mut command = base_electron_command();
|
|
command.extend(electron_args(args));
|
|
command
|
|
}
|
|
|
|
fn base_electron_command() -> Vec<String> {
|
|
if cfg!(target_os = "macos") && !Path::new("/.dockerenv").exists() {
|
|
let electron_binary = dev_electron_binary_path();
|
|
if electron_binary.is_file()
|
|
&& let Ok(launcher) = env::current_exe()
|
|
{
|
|
return disclaimed_electron_command(&launcher, &electron_binary);
|
|
}
|
|
}
|
|
vec![
|
|
"pnpm".to_owned(),
|
|
"exec".to_owned(),
|
|
"electron".to_owned(),
|
|
".".to_owned(),
|
|
]
|
|
}
|
|
|
|
fn disclaimed_electron_command(launcher: &Path, electron_binary: &Path) -> Vec<String> {
|
|
vec![
|
|
launcher.to_string_lossy().into_owned(),
|
|
"desktop".to_owned(),
|
|
"exec-disclaimed".to_owned(),
|
|
electron_binary.to_string_lossy().into_owned(),
|
|
".".to_owned(),
|
|
]
|
|
}
|
|
|
|
pub fn run_desktop(
|
|
extra_args: &[String],
|
|
build: bool,
|
|
rebuild_renderer: bool,
|
|
skip_native: bool,
|
|
) -> Result<()> {
|
|
if build {
|
|
build_desktop(rebuild_renderer, skip_native, RendererDelivery::Offline)?;
|
|
} else {
|
|
ensure_desktop_dependencies()?;
|
|
}
|
|
patch_macos_dev_electron_info_plist()?;
|
|
let mut args = vec!["--fluxer-log-renderer-console".to_owned()];
|
|
args.extend(extra_args.iter().cloned());
|
|
let command = electron_command(&args);
|
|
let refs: Vec<_> = command.iter().map(String::as_str).collect();
|
|
println!(
|
|
"Starting the unpackaged {DEVELOPMENT_APP_NAME} shell. It shares the installed app's profile, so only one of them runs at a time."
|
|
);
|
|
run_command(
|
|
&refs,
|
|
RunOptions {
|
|
cwd: DESKTOP_DIR.as_path(),
|
|
load_default_env: false,
|
|
..RunOptions::default()
|
|
},
|
|
)
|
|
.map(drop)
|
|
}
|
|
|
|
fn patch_macos_dev_electron_info_plist() -> Result<()> {
|
|
if !cfg!(target_os = "macos") || Path::new("/.dockerenv").exists() {
|
|
return Ok(());
|
|
}
|
|
let app_bundle = dev_electron_app_bundle_path();
|
|
let info_plist = dev_electron_info_plist_path();
|
|
if !info_plist.is_file() {
|
|
bail!(
|
|
"missing dev Electron Info.plist at {}. Run `pnpm dev:desktop:deps` first",
|
|
info_plist.display()
|
|
);
|
|
}
|
|
|
|
let mut changed = false;
|
|
for (key, value) in MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS {
|
|
changed |= set_or_add_plist_string(&info_plist, key, value)?;
|
|
}
|
|
|
|
if changed {
|
|
println!(
|
|
"Patched dev Electron Info.plist for macOS capture permissions: {}",
|
|
info_plist.display()
|
|
);
|
|
}
|
|
if changed || !codesign_verify(&app_bundle) {
|
|
codesign_ad_hoc(&app_bundle)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn dev_electron_app_bundle_path() -> PathBuf {
|
|
DESKTOP_DIR.join("node_modules/electron/dist/Electron.app")
|
|
}
|
|
|
|
fn dev_electron_info_plist_path() -> PathBuf {
|
|
dev_electron_app_bundle_path().join("Contents/Info.plist")
|
|
}
|
|
|
|
fn dev_electron_binary_path() -> PathBuf {
|
|
dev_electron_app_bundle_path().join("Contents/MacOS/Electron")
|
|
}
|
|
|
|
fn set_or_add_plist_string(info_plist: &Path, key: &str, value: &str) -> Result<bool> {
|
|
if read_plist_string(info_plist, key)?.as_deref() == Some(value) {
|
|
return Ok(false);
|
|
}
|
|
if plist_key_exists(info_plist, key)? {
|
|
run_plist_buddy(info_plist, &format!("Set :{key} {value}"))?;
|
|
} else {
|
|
run_plist_buddy(info_plist, &format!("Add :{key} string {value}"))?;
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
fn plist_key_exists(info_plist: &Path, key: &str) -> Result<bool> {
|
|
Ok(read_plist_string(info_plist, key)?.is_some())
|
|
}
|
|
|
|
fn read_plist_string(info_plist: &Path, key: &str) -> Result<Option<String>> {
|
|
let output = Command::new("/usr/libexec/PlistBuddy")
|
|
.args(["-c", &format!("Print :{key}")])
|
|
.arg(info_plist)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.with_context(|| format!("failed to read {key} from {}", info_plist.display()))?;
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.trim_end()
|
|
.to_owned(),
|
|
))
|
|
}
|
|
|
|
fn run_plist_buddy(info_plist: &Path, command: &str) -> Result<()> {
|
|
let output = Command::new("/usr/libexec/PlistBuddy")
|
|
.args(["-c", command])
|
|
.arg(info_plist)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.with_context(|| format!("failed to update {}", info_plist.display()))?;
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
bail!(
|
|
"failed to update {}: {}",
|
|
info_plist.display(),
|
|
String::from_utf8_lossy(&output.stderr).trim_end()
|
|
)
|
|
}
|
|
|
|
fn codesign_verify(app_bundle: &Path) -> bool {
|
|
Command::new("codesign")
|
|
.args(["--verify", "--deep", "--strict"])
|
|
.arg(app_bundle)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|status| status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn codesign_ad_hoc(app_bundle: &Path) -> Result<()> {
|
|
println!("Re-signing dev Electron.app after macOS permission plist patch...");
|
|
let output = Command::new("codesign")
|
|
.args(["--force", "--deep", "--sign", "-"])
|
|
.arg(app_bundle)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.with_context(|| format!("failed to re-sign {}", app_bundle.display()))?;
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
bail!(
|
|
"failed to re-sign {}: {}",
|
|
app_bundle.display(),
|
|
String::from_utf8_lossy(&output.stderr).trim_end()
|
|
)
|
|
}
|