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 )