package Perl::Dist::Strawberry::Step::OutputMSM_MSI; use 5.012; use warnings; use base 'Perl::Dist::Strawberry::Step'; use File::Slurp qw(read_file write_file); use File::Copy qw(copy); use File::Spec::Functions qw(canonpath catdir catfile); use File::Path qw(make_path remove_tree); use File::Find::Rule; use File::Basename; use Data::Dump qw(pp); use Data::UUID; use Template; use IPC::Run3; use Digest::SHA1; use Win32::TieRegistry qw( KEY_READ ); sub new { my $class = shift; my $self = $class->SUPER::new(@_); $self->{data_uuid} = Data::UUID->new(); $self->{id_counter} = 1000; return $self; } sub check { my $self = shift; #global: app_version #global: app_name my $bdir = canonpath(catdir($self->global->{build_dir}, 'msm_msi')); -d $bdir or make_path($bdir) or die "ERROR: cannot create '$bdir'"; my $d = $self->global->{wixbin_dir} // $self->_detect_wix_dir; if (!$d) { warn "ERROR: cannot find WiX utils (candle.exe+light.exe) installed\n"; warn " you need to install WiX toolset v3.5 from http://wix.sourceforge.net\n"; warn " or consider using option -wixbin_dir=\n\n"; die; } $self->{candle_exe} = canonpath("$d/candle.exe"); $self->{light_exe} = canonpath("$d/light.exe"); } sub run { my $self = shift; my $bdir = catdir($self->global->{build_dir}, 'msm_msi'); # compute MSM id from MSM guid my $msi_guid = $self->{data_uuid}->create_str(); # get random GUID my $msm_guid = $self->{data_uuid}->create_str(); # get random GUID (my $msm_id = $msm_guid) =~ s/-/_/g; # create WXS parts to be inserted into MSM_main.wxs.tt & MSI_main.wxs.tt my $xml_env = $self->_generate_wxml_for_environment(); my ($xml_start_menu, $xml_start_menu_icons) = $self->_generate_wxml_for_start_menu($msm_id); my ($xml_msm, $xml_msi, $id_list_msm, $id_list_msi) = $self->_generate_wxml_for_directory($self->global->{image_dir}); #debug: write_file("$bdir/debug.xml_msi.xml", $xml_msi); write_file("$bdir/debug.xml_msm.xml", $xml_msm); write_file("$bdir/debug.xml_start_menu.xml", $xml_start_menu); write_file("$bdir/debug.xml_start_menu_icons.xml", $xml_start_menu_icons); # prepare MSI/MSM filenames my $output_basename = $self->global->{output_basename} // 'perl-output'; my $msm_file = catfile($self->global->{output_dir}, "$output_basename.msm"); my $msi_file = catfile($self->global->{output_dir}, "$output_basename.msi"); my $wixpdb_file = catfile($self->global->{output_dir}, "$output_basename.wixpdb"); # compute msi_version which has to be 3-numbers (otherwise major upgrade feature does not work) my ($v1, $v2, $v3, $v4) = split /\./, $self->global->{app_version}; $v3 = $v3*1000 + $v4 if defined $v4; #turn 5.14.2.1 to 5.12.2001 # resolve values (only scalars) from config for (keys %{$self->{config}}) { if (!ref $self->{config}->{$_}) { $self->{config}->{$_} = $self->boss->resolve_name($self->{config}->{$_}); } } my %vars = ( # global info taken from 'boss' %{$self->global}, # OutputMSM_MSI config info %{$self->{config}}, # the following items are computed msi_product_guid => $msi_guid, msm_package_guid => $msm_guid, msi_random_upgrade_code => $self->{data_uuid}->create_str(), # get random GUID msm_package_id => $msm_id, msi_version => sprintf("%d.%d.%d", $v1, $v2, $v3), # e.g. 5.12.2001 msi_upgr_version => sprintf("%d.%d.%d", $v1, $v2, 0), # e.g. 5.12.0 msm_filename => $msm_file, # WXS data xml_msm_dirtree => $xml_msm, xml_msi_dirtree => $xml_msi, xml_env => $xml_env, xml_startmenu => $xml_start_menu, xml_startmenu_icons => $xml_start_menu_icons, ); my $f1 = catfile($self->global->{dist_sharedir}, 'msi\MSM_main.wxs.tt'); my $f2 = catfile($self->global->{dist_sharedir}, 'msi\MSI_main.wxs.tt'); my $f3 = catfile($self->global->{dist_sharedir}, 'msi\Variables.wxi.tt'); my $f4 = catfile($self->global->{dist_sharedir}, 'msi\MSI_strings.wxl.tt'); my $t = Template->new(ABSOLUTE=>1); write_file(catfile($self->global->{debug_dir}, 'TTvars_OutputMSM_MSI_'.time.'.txt'), pp(\%vars)); #debug dump $t->process($f1, \%vars, catfile($bdir, 'MSM_main.wxs')) || die $t->error(); $t->process($f2, \%vars, catfile($bdir, 'MSI_main.wxs')) || die $t->error(); $t->process($f3, \%vars, catfile($bdir, 'Variables.wxi')) || die $t->error(); $t->process($f4, \%vars, catfile($bdir, 'MSI_strings.wxl')) || die $t->error(); my $rv; my $candle_exe = $self->{candle_exe}; my $light_exe = $self->{light_exe}; #XXX-FIXME -sice:ICE08|09|32|61 is a hack to handle: #light.exe : error LGHT0217 : Error executing ICE action 'ICE32'. The most common cause of this kind of ICE failure is an incorrectly registered # scripting engine. See http://wix.sourceforge.net/faq.html#Error217 for details and how to solve this problem. The following string # format was not expected by the external UI message logger: "Pýi instalaci tohoto bal¡Ÿku zjistil instalaŸn¡ program neoŸek vanou # chybu. M…§e to znamenat, §e u tohoto bal¡Ÿku nastaly pot¡§e. K¢d chyby je 2738. ". #light.exe : error LGHT0217 : Error executing ICE action 'ICE08'. The most common cause of this kind of ICE failure is an incorrectly registered # scripting engine. See http://wix.sourceforge.net/faq.html#Error217 for details and how to solve this problem. The following string # format was not expected by the external UI message logger: "Pýi instalaci tohoto bal¡Ÿku zjistil instalaŸn¡ program neoŸek vanou # chybu. M…§e to znamenat, §e u tohoto bal¡Ÿku nastaly pot¡§e. K¢d chyby je 2738. ". #light.exe : error LGHT0217 : Error executing ICE action 'ICE61'. The most common cause of this kind of ICE failure is an incorrectly registered # scripting engine. See http://wix.sourceforge.net/faq.html#Error217 for details and how to solve this problem. The following string # format was not expected by the external UI message logger: "The installer has encountered an unexpected error installing this # package. This may indicate a problem with this package. The error code is 2738. ". my $candle1_cmd = [$candle_exe, "$bdir\\MSM_main.wxs", '-out', "$bdir\\MSM_main.wixobj", '-v']; my $light1_cmd = [$light_exe, "$bdir\\MSM_main.wixobj", '-out', $msm_file, '-pdbout', "$bdir\\MSM_main.wixpdb", qw/-ext WixUIExtension -ext WixUtilExtension -v -sice:ICE32 -sice:ICE08/]; my $candle2_cmd = [$candle_exe, "$bdir\\MSI_main.wxs", '-out', "$bdir\\MSI_main.wixobj", '-v']; my $light2_cmd = [$light_exe, "$bdir\\MSI_main.wixobj", '-out', $msi_file, '-pdbout', "$bdir\\MSI_main.wixpdb", '-loc', "$bdir\\MSI_strings.wxl", qw/-ext WixUIExtension -ext WixUtilExtension -sice:ICE38 -sice:ICE43 -sice:ICE48 -sice:ICE47 -v -sice:ICE32 -sice:ICE08 -sice:ICE09 -sice:ICE61/]; # backup already existing /*.msm and /*.msi $self->backup_file($msi_file); $self->backup_file($msm_file); $self->boss->message(2, "MSM: gonna run $candle1_cmd->[0]"); $rv = $self->execute_standard($candle1_cmd, catfile($self->global->{debug_dir}, "MSM_candle.log.txt")); die "ERROR: MSM candle" unless(defined $rv && $rv == 0); $self->boss->message(2, "MSM: gonna run $light1_cmd->[0]"); $rv = $self->execute_standard($light1_cmd, catfile($self->global->{debug_dir}, "MSM_light.log.txt")); die "ERROR: MSM light" unless(defined $rv && $rv == 0); $self->boss->message(2, "MSI: gonna run $candle2_cmd->[0]"); $rv = $self->execute_standard($candle2_cmd, catfile($self->global->{debug_dir}, "MSI_candle.log.txt")); die "ERROR: MSI candle" unless(defined $rv && $rv == 0); $self->boss->message(2, "MSI: gonna run $light2_cmd->[0]"); $rv = $self->execute_standard($light2_cmd, catfile($self->global->{debug_dir}, "MSI_light.log.txt")); die "ERROR: MSI light" unless(defined $rv && $rv == 0); #store results $self->{data}->{output}->{msi} = $msi_file; $self->{data}->{output}->{msm} = $msm_file; $self->{data}->{output}->{msm_sha1} = $self->sha1_file($msm_file); $self->{data}->{output}->{msi_sha1} = $self->sha1_file($msi_file); # will change after we sign MSI $self->{data}->{output}->{msi_guid} = $msi_guid; $self->{data}->{output}->{msm_guid} = $msm_guid; $self->{data}->{output}->{msm_id} = $msm_id; } sub _generate_wxml_for_environment { my ($self) = @_; my $result = ""; my $id = 1; for (keys %{$self->{config}->{env}}) { $result .= sprintf(" \n", $id++, $_, $self->{config}->{env}->{$_}); } return $result } sub _generate_wxml_for_start_menu { my ($self, $msm_id) = @_; my $menu_result = ""; my $ico_result = ""; my $id = 1; $self->{start_menu_icons} = {}; my ($component_id, $component_guid) = $self->_gen_component_id("start_menu_main.shortcut"); $menu_result .= " \n"; $menu_result .= " \n"; $menu_result .= " \n"; for my $item (@{$self->{config}->{start_menu}}) { $menu_result .= $self->_generate_start_menu_folder($item, 0, $msm_id) if $item->{type} eq 'folder'; $menu_result .= $self->_generate_start_menu_shortcut($item, 0, $msm_id) if $item->{type} eq 'shortcut'; } for (keys %{$self->{start_menu_icons}}) { $ico_result .= " \n"; } return ($menu_result, $ico_result); } sub _generate_wxml_for_directory { my ($self, $rootdir) = @_; my $t = $self->_prepare_marked_tree($rootdir); my $msi = $self->_tree2xml($t, 'MSI'); my $id_list_msi = [ @{$self->{component_id_list}} ]; $self->{component_id_list} = []; my $msm = $self->_tree2xml($t, 'MSM'); my $id_list_msm = [ @{$self->{component_id_list}} ]; $self->{component_id_list} = []; return ($msm, $msi, $id_list_msm, $id_list_msi); } sub _generate_start_menu_shortcut { # !!!BEWARE!!! this sub is called recursively my ($self, $item, $depth, $msm_id) = @_; $depth //= 0; my $result = ""; my $ident = " " . (" " x $depth); my ($component_id, $component_guid) = $self->_gen_component_id($item->{name}.$depth."start.shortcut"); my $attr_description = defined $item->{description} ? "Description='$item->{description}'" : "Description='$item->{name}'"; my $attr_workingdir = defined $item->{workingdir} ? "WorkingDirectory='$item->{workingdir}'" : ""; my $attr_target = $item->{target}; $attr_workingdir =~ s//$msm_id/g; #XXX-FIXME this is a hack $attr_target =~ s//$msm_id/g; #XXX-FIXME this is a hack my $attr_icon = ""; if (defined $item->{icon}) { my $i_file = canonpath($self->boss->resolve_name($item->{icon})); my $i_short = "ico_$component_id"; $self->{start_menu_icons}->{$i_file} //= $i_short; $attr_icon = "Icon='$self->{start_menu_icons}->{$i_file}'"; } $result .= "$ident\n"; $result .= "$ident \n"; $result .= "$ident \n"; # This is strange but for some reason necessary $result .= "$ident\n"; return $result; } sub _generate_start_menu_folder { # !!!BEWARE!!! this sub is called recursively my ($self, $item, $depth, $msm_id) = @_; $depth //= 0; my $result = ""; my $ident = " " . (" " x $depth); my ($component_id, $component_guid) = $self->_gen_component_id($item->{name}.$depth."start.folder"); $result .= "$ident\n"; $result .= "$ident \n"; $result .= "$ident \n"; $result .= "$ident \n"; for my $m (@{$item->{members}}) { $result .= $self->_generate_start_menu_folder($m, $depth+1, $msm_id) if $m->{type} eq 'folder'; $result .= $self->_generate_start_menu_shortcut($m, $depth+1, $msm_id) if $m->{type} eq 'shortcut'; } $result .= "$ident\n"; return $result; } sub _generate_tree { # !!!BEWARE!!! this sub is called recursively my ($self, $rootdir, $depth) = @_; my $image_dir = canonpath($self->global->{image_dir}); $rootdir = canonpath($rootdir); (my $short = $rootdir) =~ s/^\Q$image_dir\E[\\]*//; $depth = 1 unless defined $depth; my $h = { type=>'D', full_name=>$rootdir, short_name=>$short, files=>[], dirs=>[], depth=>$depth }; my @directories = File::Find::Rule->directory->maxdepth(1)->mindepth(1)->in($rootdir); for my $d (sort map { canonpath($_) } @directories) { (my $short = $d) =~ s/^\Q$image_dir\E[\\]*//; $self->{global_hash}->{$short} = $self->_generate_tree($d, $depth+1); push @{$h->{dirs}}, $self->{global_hash}->{$short}; } my @files = File::Find::Rule->file->maxdepth(1)->mindepth(1)->in($rootdir); for my $f (sort map { canonpath($_) } @files) { (my $short = $f) =~ s/^\Q$image_dir\E[\\]*//; $self->{global_hash}->{$short} = { type=>'F', full_name=>$f, short_name=>$short, depth=>$depth }; push @{$h->{files}}, $self->{global_hash}->{$short}; } return $h; } sub _mark_tree { # !!!BEWARE!!! this sub is called recursively my ($self, $root, $mark) = @_; $root->{mark} = $mark; if ($root->{type} eq 'D') { $self->_mark_tree($_, $mark) for (@{$root->{dirs}}); $self->_mark_tree($_, $mark) for (@{$root->{files}}); } } sub _prepare_marked_tree { my ($self, $rootdir, $type) = @_; $self->boss->message(3, "generate tree - started (takes some time)"); my $t = $self->_generate_tree($rootdir); $self->boss->message(3, "generate tree - items=", scalar(keys $self->{global_hash})); # by default all go to MSM $self->_mark_tree($t, 'MSM'); # let us move items matching 'exclude_msm' from MSM to MSI my @e; for my $i (@{$self->{config}->{exclude_msm}}) { if (ref($i) eq 'Regexp') { push @e, grep {/$i/} (keys $self->{global_hash}); } else { push @e, grep {lc($_) eq lc($i)} (keys $self->{global_hash}); } } $self->_mark_tree($self->{global_hash}->{$_}, 'MSI') for (@e); # let us completely drop items matching 'exclude' these will be neither in MSM nor MSI my @s; for my $i (@{$self->{config}->{exclude}}) { if (ref($i) eq 'Regexp') { push @s, grep {/$i/} (keys $self->{global_hash}); } else { push @s, grep {lc($_) eq lc($i)} (keys $self->{global_hash}); } } $self->_mark_tree($self->{global_hash}->{$_}, 'EXCLUDE') for (@s); return $t; } sub _tree2xml { # !!!BEWARE!!! this sub is called recursively my ($self, $root, $mark, $not_root) = @_; my ($component_id, $component_guid, $dir_id); my $result = ""; my $ident = " " . " " x $root->{depth}; # dir-start if ($not_root && $root->{mark} eq $mark) { $dir_id = $self->_gen_dir_id($root->{short_name}); my $dir_basename = basename($root->{full_name}); my $dir_shortname = $self->_get_short_basename($root->{full_name}); $result .= $ident . qq[\n]; } my @f = grep { $_->{mark} eq $mark } @{$root->{files}}; my @d = grep { $_->{mark} eq $mark } @{$root->{dirs}}; my $feat = $mark eq 'MSM' ? '' : "Feature='feat_$mark'"; if (defined $dir_id) { ($component_id, $component_guid) = $self->_gen_component_id($root->{short_name}."create"); # put KeyPath to the component as Directory does not have KeyPath attribute # if a Component has KeyPath="yes", then the directory this component is installed to becomes a key path # see: http://stackoverflow.com/questions/10358989/wix-using-keypath-on-components-directories-files-registry-etc-etc $result .= $ident ." ". qq[\n]; $result .= $ident ." ". qq[ \n]; $result .= $ident ." ". qq[ \n]; #XXX-TODO not sure about this $result .= $ident ." ". qq[\n]; } if (scalar(@f) > 0) { for my $f (@f) { my $file_id = $self->_gen_file_id($f->{short_name}); my $file_basename = basename($f->{full_name}); my $file_shortname = $self->_get_short_basename($f->{full_name}); ($component_id, $component_guid) = $self->_gen_component_id($file_shortname."files"); # in 1file/component scenario set KeyPath on file, not on Component # see: http://stackoverflow.com/questions/10358989/wix-using-keypath-on-components-directories-files-registry-etc-etc $result .= $ident ." ". qq[\n]; $result .= $ident ." ". qq[ \n]; # XXX-TODO consider ReadOnly="yes" $result .= $ident ." ". qq[\n]; } } $result .= $self->_tree2xml($_, $mark, 1) for (@d); $result .= $ident . qq[\n] if $not_root && $root->{mark} eq $mark; return $result; } #XXX-FIXME occasionally Win32::GetShortPathName does not produce valid 8.3 name!!! #sub _get_short_basename { # my ($self, $name) = @_; # my $result = basename(Win32::GetShortPathName($name));; # $result =~ s/~/!/g; # this replacement is necessary, otherwise wix3 will croak # # return $result; #} sub _random_shortname { my $self = shift; my @ch = ('A'..'Z', 0..9, split(//,'!@#^(){}_-')); my $r; $r .= $ch[int(rand(scalar(@ch)))] for (1..8); return $r; } sub _get_short_basename { my ($self, $name) = @_; my $base = basename($name);; my ($n, $e) = $base =~ /^(.*?)(\..*)?$/; if ($n =~ /^[A-Z0-9\Q!#@^(){}_-\E]{1,8}$/i && (!defined $e || $e =~ /^\.[A-Z0-9\Q!#@^(){}_-\E]{1,3}$/i)) { return $base; } else { $n =~ s/[^A-Z0-9\Q!#@^(){}_-\E]//gi; $n = substr(substr($n, 0, 4) . $self->_random_shortname, 0, 8); if (defined $e) { $e =~ s/[^A-Z0-9\Q!#@^(){}_-\E]//gi; $e = substr(substr($e, 0, 3) . $self->_random_shortname, 0, 3); return "$n.$e"; } return $n; } } sub _gen_component_id { my ($self, $subj) = @_; my $i = "i" . $self->{id_counter}++; my $g = $self->{data_uuid}->create_str(); # get random GUID push @{$self->{component_id_list}}, $i; return ($i, $g); } sub _gen_file_id { my ($self, $file) = @_; my $r; $r = "f_perl_bin_perl_exe" if lc($file) eq 'perl\bin\perl.exe'; $r = "f_perl_bin_wperl_exe" if lc($file) eq 'perl\bin\wperl.exe'; $r = "f_perl1_reloc_txt" if lc($file) eq 'perl1.reloc.txt'; $r = "f_perl2_reloc_txt" if lc($file) eq 'perl2.reloc.txt'; $r = "f_readme_txt" if lc($file) eq 'readme.txt'; $r = "f_relocation_pl" if lc($file) eq 'relocation.pl.bat'; return $r // "f" . $self->{id_counter}++; } sub _gen_dir_id { my ($self, $dir) = @_; my $r; $r = "d_c" if lc($dir) eq 'c'; $r = "d_c_bin" if lc($dir) eq 'c\bin'; $r = "d_perl" if lc($dir) eq 'perl'; $r = "d_perl_bin" if lc($dir) eq 'perl\bin'; $r = "d_perl_site" if lc($dir) eq 'perl\site'; $r = "d_perl_vendor" if lc($dir) eq 'perl\vendor'; $r = "d_win32" if lc($dir) eq 'win32'; return $r // "d" . $self->{id_counter}++; } sub _detect_wix_dir { my $self = shift; for my $v (qw/3.0 3.5 3.6/) { my $WIX_REGISTRY_KEY = "HKEY_LOCAL_MACHINE/SOFTWARE/Microsoft/Windows Installer XML/$v"; # 0x200 = KEY_WOW64_32KEY my $r = Win32::TieRegistry->new($WIX_REGISTRY_KEY => { Access => KEY_READ|0x200, Delimiter => q{/} }); next unless $r; my $d = $r->TiedRef->{'InstallRoot'}; next unless $d && -d $d && -f "$d/candle.exe" && -f "$d/light.exe"; return $d; } return; } 1;