Chandra
view release on metacpan or search on metacpan
lib/Chandra/Pack.pm view on Meta::CPAN
}
# Setter: config(key => val, ...)
my %args = ref $_[0] eq 'HASH' ? %{$_[0]} : @_;
# Load from env vars if not provided
$args{identity} //= $ENV{CHANDRA_IDENTITY};
$args{apple_id} //= $ENV{CHANDRA_APPLE_ID};
$args{team_id} //= $ENV{CHANDRA_TEAM_ID};
$args{notary_keychain} //= $ENV{CHANDRA_NOTARY_KEYCHAIN};
$args{sign_cert} //= $ENV{CHANDRA_SIGN_CERT};
$args{sign_password} //= $ENV{CHANDRA_SIGN_PASSWORD};
for my $key (keys %args) {
if (exists $CONFIG{$key}) {
$CONFIG{$key} = $args{$key};
} else {
Carp::carp("Unknown config key: $key");
}
}
# Save to file if requested
if (delete $args{save}) {
_save_config();
}
return $class;
}
sub _config_file {
my $home = $ENV{HOME};
if (!$home && $^O ne 'MSWin32') {
$home = (getpwuid($<))[7];
}
$home ||= $ENV{USERPROFILE} || '.';
return file_join($home, '.chandra', 'pack.conf');
}
sub _load_config {
my $file = _config_file();
return unless file_is_file($file);
my $content = file_slurp($file);
for my $line (split /\n/, $content) {
next if $line =~ /^\s*#/ || $line =~ /^\s*$/;
if ($line =~ /^(\w+)\s*=\s*(.*)$/) {
$CONFIG{$1} = $2 if exists $CONFIG{$1};
}
}
}
sub _save_config {
my $file = _config_file();
my $dir = file_dirname($file);
file_mkpath($dir) unless file_is_dir($dir);
my $content = "# Chandra::Pack configuration\n";
for my $key (sort keys %CONFIG) {
next unless defined $CONFIG{$key};
$content .= "$key = $CONFIG{$key}\n";
}
file_spew($file, $content);
chmod 0600, $file; # Protect credentials
}
# Load config on module load
_load_config();
sub new {
my ($class, %args) = @_;
Carp::croak("'script' is required") unless $args{script};
Carp::croak("Script '$args{script}' not found") unless file_exists($args{script});
Carp::croak("Script '$args{script}' is not a file") unless file_is_file($args{script});
my $name = $args{name} || _name_from_script($args{script});
bless {
script => Cwd::abs_path($args{script}),
name => $name,
version => $args{version} || '0.0.1',
icon => $args{icon},
assets => $args{assets},
output => $args{output} || '.',
platform => $args{platform} || _detect_platform(),
identifier => $args{identifier} || _default_identifier($name),
perl => $args{perl} || $^X,
include => $args{include} || [],
exclude => $args{exclude} || [],
distribute => $args{distribute} || 0,
_deps => undef,
}, $class;
}
# ââ Accessors ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
sub script { $_[0]->{script} }
sub name { $_[0]->{name} }
sub version { $_[0]->{version} }
sub icon { $_[0]->{icon} }
sub assets { $_[0]->{assets} }
sub output { $_[0]->{output} }
sub platform { $_[0]->{platform} }
sub identifier { $_[0]->{identifier} }
sub perl { $_[0]->{perl} }
sub distribute { $_[0]->{distribute} }
# ââ Dependency Scanning ââââââââââââââââââââââââââââââââââââââââââââââ
sub scan_deps {
my ($self) = @_;
return @{ $self->{_deps} } if $self->{_deps};
my %seen;
my %exclude = map { $_ => 1 } @{ $self->{exclude} };
my @queue = ($self->{script});
my @deps;
# Add explicit includes
for my $mod (@{ $self->{include} }) {
my $file = _mod_to_file($mod);
my $path = _find_in_inc($file);
if ($path) {
push @deps, { module => $mod, file => $path };
push @queue, $path;
lib/Chandra/Pack.pm view on Meta::CPAN
file_mkpath($macos);
file_mkpath($resources);
file_mkpath($lib_dir);
# Info.plist
file_spew(file_join($contents, 'Info.plist'), $self->_generate_plist());
# Launcher (compiled C binary so macOS Launch Services accepts it)
my $launcher = file_join($macos, lc($safe));
$self->_compile_launcher_macos($launcher);
# Copy script
file_copy($self->{script}, file_join($resources, 'script.pl'));
# Copy deps
$self->_copy_deps($lib_dir);
# Icon
if ($self->{icon} && file_exists($self->{icon})) {
my $ext = file_extname($self->{icon});
if ($ext eq '.icns') {
file_copy($self->{icon}, file_join($resources, 'app.icns'));
} elsif ($ext eq '.png') {
$self->_convert_icon_macos($self->{icon}, file_join($resources, 'app.icns'));
}
}
# Assets
$self->_copy_assets(file_join($resources, 'assets')) if $self->{assets};
my $result = {
success => 1,
path => $app_dir,
platform => 'macos',
size => _dir_size($app_dir),
};
# Distribution pipeline: codesign â notarize â DMG
if ($self->{distribute}) {
$result = $self->_distribute_macos($result);
}
return $result;
}
sub build_linux {
my ($self) = @_;
my $safe = _safe_name($self->{name});
my $app_dir = file_join($self->{output}, $safe);
my $usr = file_join($app_dir, 'usr');
my $lib_dir = file_join($usr, 'lib', 'perl5');
my $share = file_join($usr, 'share');
# Create structure
file_mkpath($lib_dir);
file_mkpath($share);
# AppRun
my $apprun = file_join($app_dir, 'AppRun');
file_spew($apprun, $self->_generate_launcher_linux());
chmod 0755, $apprun;
# Desktop entry
file_spew(file_join($app_dir, lc($safe) . '.desktop'), $self->_generate_desktop());
# Copy script
file_copy($self->{script}, file_join($usr, 'share', 'script.pl'));
# Copy deps
$self->_copy_deps($lib_dir);
# Icon
if ($self->{icon} && file_exists($self->{icon})) {
file_copy($self->{icon}, file_join($app_dir, lc($safe) . file_extname($self->{icon})));
}
# Assets
$self->_copy_assets(file_join($share, 'assets')) if $self->{assets};
my $result = {
success => 1,
path => $app_dir,
platform => 'linux',
size => _dir_size($app_dir),
};
# Distribution pipeline: AppImage
if ($self->{distribute}) {
$result = $self->_distribute_linux($result);
}
return $result;
}
sub build_windows {
my ($self) = @_;
my $safe = _safe_name($self->{name});
my $app_dir = file_join($self->{output}, $safe);
my $lib_dir = file_join($app_dir, 'lib');
# Create structure
file_mkpath($lib_dir);
# Launcher bat
file_spew(file_join($app_dir, lc($safe) . '.bat'), $self->_generate_launcher_windows());
# Copy script
file_copy($self->{script}, file_join($app_dir, 'script.pl'));
# Copy deps
$self->_copy_deps($lib_dir);
# Assets
$self->_copy_assets(file_join($app_dir, 'assets')) if $self->{assets};
# Authenticode signing if cert provided
if ($self->{sign_cert}) {
my $exe = file_join($app_dir, lc($safe) . '.bat');
my $cert = $self->{sign_cert};
my $pass = $self->{sign_password};
my @cmd = ('signtool', 'sign', '/f', $cert, '/fd', 'SHA256', '/tr',
lib/Chandra/Pack.pm view on Meta::CPAN
'-volname', $vol_name,
'-srcfolder', $app_path,
'-ov',
'-format', 'UDBZ', # Compressed
$dmg_path
);
my $output = `@hdiutil 2>&1`;
if ($? != 0) {
return { success => 0, error => "DMG creation failed: $output" };
}
return { success => 1, path => $dmg_path };
}
sub _distribute_linux {
my ($self, $result) = @_;
my $app_dir = $result->{path};
# Check for appimagetool
my $appimagetool = _find_command('appimagetool');
unless ($appimagetool) {
Carp::carp("appimagetool not found; skipping AppImage creation");
return $result;
}
# Create AppImage
my $appimage_result = $self->_create_appimage($app_dir, $appimagetool);
unless ($appimage_result->{success}) {
return { %$result, success => 0, error => $appimage_result->{error} };
}
$result->{appimage_path} = $appimage_result->{path};
$result->{size} = file_size($appimage_result->{path});
return $result;
}
sub _create_appimage {
my ($self, $app_dir, $appimagetool) = @_;
my $safe = _safe_name($self->{name});
my $appimage_path = file_join($self->{output}, "$safe.AppImage");
# Ensure proper AppDir structure
# AppRun should already exist from build_linux
# Ensure .desktop file is at root level
my $desktop_file = file_join($app_dir, lc($safe) . '.desktop');
unless (file_is_file($desktop_file)) {
return { success => 0, error => "Missing .desktop file" };
}
# Run appimagetool
my $cmd = "\Q$appimagetool\E \Q$app_dir\E \Q$appimage_path\E 2>&1";
my $output = `$cmd`;
if ($? != 0) {
return { success => 0, error => "appimagetool failed: $output" };
}
# Make executable
chmod 0755, $appimage_path;
return { success => 1, path => $appimage_path };
}
sub _find_command {
my ($cmd) = @_;
my $path = `which $cmd 2>/dev/null`;
chomp $path;
return $path if $path && -x $path;
# Check common locations
for my $dir ('/usr/local/bin', '/usr/bin', "$ENV{HOME}/bin", "$ENV{HOME}/.local/bin") {
my $full = "$dir/$cmd";
return $full if -x $full;
}
return undef;
}
# ââ Template Generators ââââââââââââââââââââââââââââââââââââââââââââââ
sub _generate_plist {
my ($self) = @_;
my $safe = _safe_name($self->{name});
my $has_icon = ($self->{icon} && file_exists($self->{icon})) ? 1 : 0;
my $plist = <<PLIST;
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>$self->{name}</string>
<key>CFBundleIdentifier</key><string>$self->{identifier}</string>
<key>CFBundleVersion</key><string>$self->{version}</string>
<key>CFBundleShortVersionString</key><string>$self->{version}</string>
<key>CFBundleExecutable</key><string>${\ lc($safe) }</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>NSHighResolutionCapable</key><true/>
<key>LSEnvironment</key>
<dict>
<key>PATH</key><string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
PLIST
if ($has_icon) {
$plist .= " <key>CFBundleIconFile</key><string>app</string>\n";
}
$plist .= "</dict>\n</plist>\n";
return $plist;
}
sub _compile_launcher_macos {
my ($self, $output) = @_;
my $perl = $self->{perl};
chomp(my $full_perl = `which $perl` || $perl);
my $c_src = <<'CSRC';
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <libgen.h>
#include <limits.h>
( run in 1.921 second using v1.01-cache-2.11-cpan-cdf2f3d4e48 )