diff --git a/Changes b/Changes index 115d81db89..e6031dc36e 100644 --- a/Changes +++ b/Changes @@ -4,6 +4,7 @@ Revision history for FusionInventory agent inventory: * Fix physical memory error correction detection via WMI under win32 +* Fix #299: Added UWP/APPX/Windows Store software inventory deploy: * Bump Deploy task version to 2.7 diff --git a/lib/FusionInventory/Agent/Task/Inventory/Win32/Softwares.pm b/lib/FusionInventory/Agent/Task/Inventory/Win32/Softwares.pm index 31815ed654..e2249b5237 100644 --- a/lib/FusionInventory/Agent/Task/Inventory/Win32/Softwares.pm +++ b/lib/FusionInventory/Agent/Task/Inventory/Win32/Softwares.pm @@ -7,10 +7,14 @@ use parent 'FusionInventory::Agent::Task::Inventory::Module'; use English qw(-no_match_vars); use File::Basename; +use File::Temp; +use UNIVERSAL::require; +use Encode qw(decode); use FusionInventory::Agent::Tools; use FusionInventory::Agent::Tools::Win32; use FusionInventory::Agent::Tools::Win32::Constants; +use FusionInventory::Agent::Tools::Win32::LoadIndirectString; my $seen = {}; @@ -31,6 +35,7 @@ sub doInventory { my $inventory = $params{inventory}; my $logger = $params{logger}; + my $remotewmi = $inventory->getRemote(); my $is64bit = is64bit(); @@ -86,6 +91,14 @@ sub doInventory { _addSoftware(inventory => $inventory, entry => $hotfix); } + # Lookup for UWP/Windows Store packages (not supported by WMI task) + unless ($remotewmi) { + my $packages = _getAppxPackages( logger => $logger ) || []; + foreach my $package (@{$packages}) { + _addSoftware(inventory => $inventory, entry => $package); + } + } + # Reset seen hash so we can see softwares in later same run inventory $seen = {}; } @@ -404,4 +417,244 @@ sub _getSqlInstancesVersions { return $sqlinstanceVersions->{'/Edition'}; } +sub _getAppxPackages { + my (%params) = @_; + + return unless canRun('powershell'); + + XML::TreePP->require(); + + my $logger = $params{logger}; + + my @lines; + { + # Temp file will be deleted out of this scope + my $fh = File::Temp->new( + TEMPLATE => 'appx-getpackages-XXXXXX', + SUFFIX => '.ps1' + ); + print $fh ; + close($fh); + my $file = $fh->filename; + + return unless ($file && -f $file); + + @lines = getAllLines( + command => "powershell -NonInteractive -ExecutionPolicy ByPass -File $file", + %params + ); + } + + my ($list, $package); + my %manifest_mapping = qw( + DisplayName DISPLAYNAME + Description COMMENTS + PublisherDisplayName PUBLISHERDISPLAYNAME + ); + + foreach my $line (@lines) { + chomp($line); + + # Add package on empty line + if (!$line && $package && $package->{NAME}) { + push @{$list}, $package; + undef $package; + next; + } + + my ($key, $value) = $line =~ /^([A-Z_]+):\s*(.*)\s*$/; + next unless ($key && defined($value)); + $package->{$key} = decode('UTF-8', $value); + + # Read manifest + if ($key eq 'FOLDER' && $value && -d $value) { + my $xml = $value . '/appxmanifest.xml'; + if (-f $xml) { + my $tpp = XML::TreePP->new() + or next; + my $tree = $tpp->parsefile($xml); + foreach my $property (keys(%manifest_mapping)) { + my $key = $manifest_mapping{$property}; + my $value = $tree->{Package}->{Properties}->{$property} + or next; + $package->{$key} = decode('UTF-8', $value); + } + } + } + } + + # Add last package if still not added + push @{$list}, $package if ($package && $package->{NAME}); + + # Extract publishers + my $publishers = _parsePackagePublishers($list); + + # Cleanup list and fix localized strings + foreach my $package (@{$list}) { + my $name = $package->{NAME}; + my $pubid = delete $package->{PUBLISHERID}; + $package->{PUBLISHER} = $publishers->{$pubid} + if ($pubid && $publishers->{$pubid}); + + if (!$package->{PUBLISHER} && $name =~ /^Microsoft/i) { + $package->{PUBLISHER} = "Microsoft Corp."; + } elsif (!$package->{PUBLISHER}) { + $logger->debug2("no publisher found for $name package") if $logger; + } + + my $pkgname = delete $package->{PACKAGE}; + + my $installdate = delete $package->{INSTALLDATE}; + if ($installdate) { + my ($date) = $installdate =~ m|^([0-9/]+)|; + $installdate = _dateFormat($date); + $package->{INSTALLDATE} = $installdate if $installdate; + } + + my $dn = delete $package->{DISPLAYNAME}; + if ($dn && $dn =~ /^ms-resource:/) { + my $res = SHLoadIndirectString(_canonicalResourceURI( + $pkgname, $package->{FOLDER}, $dn + )); + $logger->debug2("$name package name" . ($res ? + "resolved to '$res'" : "can't be resolved from '$dn'")) + if $logger; + $dn = $res; + } + if (!$dn) { + $dn = _canonicalPackageName($package->{NAME}); + } + $package->{NAME} = $dn if $dn; + + my $comments = delete $package->{COMMENTS}; + if ($comments && $comments =~ /^ms-resource:/) { + my $res = SHLoadIndirectString(_canonicalResourceURI( + $pkgname, $package->{FOLDER}, $comments + )); + $logger->debug2("$name package comments" . ($res ? + "resolved to '$res'" : "can't be resolved from '$comments'")) + if $logger; + $comments = $res; + } + $package->{COMMENTS} = $comments if $comments; + + $package->{FROM} = 'uwp'; + } + + return $list; +} + +sub _canonicalPackageName { + my ($name) = @_; + # Fix up name for well-know cases if the case display name is missing + if ($name =~ /^Microsoft\.NET\./i) { + $name =~ s/\./ /g; + $name =~ s/Microsoft NET/Microsoft .Net/; + } elsif ($name =~ /^(Microsoft|windows)\./i) { + $name =~ s/\./ /g; + } + return $name; +} + +sub _canonicalResourceURI { + my ($package, $folder, $resource) = @_; + my $file = $folder.'\resources.pri'; + my $base = -f $file ? $file : $package; + my ($prefix, $respath) = $resource =~ /^(ms-resource:)(.*)$/ + or return; + if ($respath =~ m|^//|) { + # Keep resource as is + } elsif ($respath =~ m|^/|) { + $resource = $prefix.'//'.$respath; + } else { + $resource = $prefix.'///'.($respath =~ /resources/i ? '':'resources/').$respath; + } + return '@{'.$base.'?'.$resource.'}'; +} + +sub _parsePackagePublishers { + my $list = shift(@_); + + my %publishers = qw( + tf1gferkr813w AutoDesk + ); + + my @localized_publisher_packages = (); + + foreach my $package (@{$list}) { + my $publisher = delete $package->{PUBLISHERDISPLAYNAME}; + my $pubid = $package->{PUBLISHERID} + or next; + next unless $publisher; + next if ($publishers{$pubid} && $publishers{$pubid} !~ /^ms-resource:/); + if ($publisher =~ /^ms-resource:/) { + push @localized_publisher_packages, $package; + } + $publishers{$pubid} = $publisher; + } + + # Fix publishers with ms-resource: + foreach my $package (@localized_publisher_packages) { + my $pubid = $package->{PUBLISHERID} + or next; + next if ($publishers{$pubid} && $publishers{$pubid} !~ /^ms-resource:/); + my $string = SHLoadIndirectString(_canonicalResourceURI( + $package->{PACKAGE}, $package->{FOLDER}, $publishers{$pubid} + )); + if ($string) { + $publishers{$pubid} = $string; + } else { + delete $publishers{$pubid}; + } + } + + return \%publishers; +} + 1; + +__DATA__ +# Script PowerShell +[Windows.Management.Deployment.PackageManager,Windows.Management.Deployment,ContentType=WindowsRuntime] >$null + +$packages = New-Object Windows.Management.Deployment.PackageManager + +foreach ( $package in $packages.FindPackages() ) +{ + # Check install state for each user and break if an installation is found + $state = "Installed" + foreach ( $user in $packages.FindUsers($package.Id.FullName) ) + { + $state = $user.InstallState + if ($user.InstallState -Like "Installed") { + break + $p = $packages.FindPackageForUser($user.UserSecurityId, $package.Id.FullName) + if ($p.InstalledLocation.DateCreated -NotLike "") { + $installedDate = $p.InstalledLocation.DateCreated + break + } + } + } + if ($state -NotLike "Installed") { continue } + + # Use installeddate if found otherwise use installation folder creation date + $installedDate = "" + if ($package.InstalledDate -NotLike "") { + $installedDate = $package.InstalledDate + } elseif ($package.InstalledLocation.DateCreated -NotLike "") { + $installedDate = $package.InstalledLocation.DateCreated + } + + Write-host "NAME: $($package.Id.Name)" + Write-host "PACKAGE: $($package.Id.FullName)" + Write-host "ARCH: $($package.Id.Architecture.ToString().ToLowerInvariant())" + Write-host "VERSION: $($package.Id.Version.Major).$($package.Id.Version.Minor).$($package.Id.Version.Build).$($package.Id.Version.Revision)" + Write-host "FOLDER: $($package.InstalledLocation.Path)" + if ($installedDate -NotLike "") { + Write-host "INSTALLDATE: $($installedDate)" + } + Write-host "PUBLISHER: $($package.Id.Publisher)" + Write-host "PUBLISHERID: $($package.Id.PublisherId)" + Write-host "SYSTEM_CATEGORY: $($package.SignatureKind.ToString().ToLowerInvariant())" + Write-Host +} diff --git a/lib/FusionInventory/Agent/Tools/Win32/LoadIndirectString.pm b/lib/FusionInventory/Agent/Tools/Win32/LoadIndirectString.pm new file mode 100644 index 0000000000..ddef5852a5 --- /dev/null +++ b/lib/FusionInventory/Agent/Tools/Win32/LoadIndirectString.pm @@ -0,0 +1,59 @@ +package FusionInventory::Agent::Tools::Win32::LoadIndirectString; + +use warnings; +use strict; + +use parent 'Exporter'; + +use FusionInventory::Agent::Tools::Win32::WideChar; + +our @EXPORT = qw( + SHLoadIndirectString +); + +my $apiSHLoadIndirectString; + +sub SHLoadIndirectString { + my ($string) = @_; + + return unless $string; + + my $wstring = MultiByteToWideChar($string) + or return; + + # Load Win32::API as late as possible + Win32::API->require() or return; + + unless ($apiSHLoadIndirectString) { + eval { + $apiSHLoadIndirectString = Win32::API->new( + 'shlwapi', + 'SHLoadIndirectString', + [ 'P', 'P', 'I', 'I' ], + 'N' + ); + }; + } + + return unless $apiSHLoadIndirectString; + + # Buffer size should be sufficient for our purpose + my $buffer = '\0' x 4096; + my $ret = $apiSHLoadIndirectString->Call( + $wstring, + $buffer, + 4096, + 0 + ); + + return if ($ret || !$buffer); + + $buffer = WideCharToMultiByte($buffer); + + # api returns the same string in buffer is no indirect string was found + return unless ($buffer && $buffer ne $string); + + return $buffer ; +} + +1; diff --git a/lib/FusionInventory/Agent/Tools/Win32/WideChar.pm b/lib/FusionInventory/Agent/Tools/Win32/WideChar.pm new file mode 100644 index 0000000000..03f57c8982 --- /dev/null +++ b/lib/FusionInventory/Agent/Tools/Win32/WideChar.pm @@ -0,0 +1,94 @@ +package FusionInventory::Agent::Tools::Win32::WideChar; + +use warnings; +use strict; + +use parent 'Exporter'; + +use Encode qw(encode decode); + +# UTF-8 code page +use constant CP_UTF8 => 65001; + +our @EXPORT = qw( + MultiByteToWideChar + WideCharToMultiByte +); + +my $apiMultiByteToWideChar; +my $apiWideCharToMultiByte; + +sub MultiByteToWideChar { + my ($string) = @_; + + return unless $string; + + # Load Win32::API as late as possible + Win32::API->require() or return; + + unless ($apiMultiByteToWideChar) { + eval { + $apiMultiByteToWideChar = Win32::API->new( + 'kernel32', + 'MultiByteToWideChar', + [ 'I', 'I', 'P', 'I', 'P', 'I' ], + 'I' + ); + }; + } + + return unless $apiMultiByteToWideChar; + + # Encode string as UTF-8 before conversion + $string = encode('UTF-8', $string); + + my $len = length($string); + my $lenbuf = 2 * $len; + my $buffer = "\0" x $lenbuf; + + my $ret = $apiMultiByteToWideChar->Call( + CP_UTF8, 0, $string, $len, $buffer, $lenbuf + ); + return unless $ret; + return $buffer; +} + +sub WideCharToMultiByte { + my ($string) = @_; + + return unless $string; + + # Load Win32::API as late as possible + Win32::API->require() or return; + + unless ($apiWideCharToMultiByte) { + eval { + $apiWideCharToMultiByte = Win32::API->new( + 'kernel32', + 'WideCharToMultiByte', + [ 'I', 'I', 'P', 'I', 'P', 'I', 'P', 'P' ], + 'I' + ); + }; + } + + return unless $apiWideCharToMultiByte; + + my $lpDefaultChar = 0; + my $lpUsedDefaultChar = 0; + my $len = length($string); + my $buffer = "\0" x $len; + + my $ret = $apiWideCharToMultiByte->Call( + CP_UTF8, 0, $string, -1, $buffer, $len, + $lpDefaultChar, $lpUsedDefaultChar + ); + return unless $ret; + + # Cleanup buffer + $buffer =~ s/\0+$//; + + return decode('UTF-8', $buffer); +} + +1;