📘

Microsoft Intune 用に Web から MSI ファイルを取得&展開できるスクリプトを作った

に公開

これは何?

Intune ではアプリケーションの展開において、MSI インストーラ形式で配布されているアプリケーションを管理デバイスに展開することができます。

今回、運用のやりやすさを上げるために実施した取り組みをここにまとめます。

想定する読者

以下を満たす人を対象としています。

  • Intune で「Windows アプリ(Win32)」のアプリケーション登録ができる人
  • Intune のアプリケーション配布の手間を減らしたい人

課題1:アプリケーションのバージョンが上がるたびに配布パッケージを Intune に登録する必要がある

これは Intune に限らず MDM 関連ではあるあるな話ですが、アプリケーションの配布において MDM にインストーラを直接アップロードするとデバイスに配布されるアプリケーションのバージョンが固定化されてしまう課題があります。

アプリケーションによっては自動でアップデートされるにしても、バージョンが古くなったものを配布することはわずかながらもセキュリティのリスクを伴います。

これを解決するにはアプリケーションのバージョンが上がった際には都度 MDM にアップロードする必要があります。が、Intune においてはアップロードファイル(.intunewinファイルとか)を用意するのは手間ですよね……。

課題2:「Windows アプリ(Win32)」と「基幹業務アプリ」が同時利用できない

これ個人的には Intune の罠ポイントと思っているのですが、Intune では「Windows アプリ(Win32)」形式でアプリケーションを配布する場合、同じタイミングに別形式(基幹業務アプリなど)を使ったアプリケーションを配布することは非推奨とされています。

Win32 アプリを展開する場合、特に複数ファイルの Win32 アプリ インストーラーがある場合は、Intune 管理拡張機能の方法を排他的に使用することを検討してください。 Autopilot 登録中に Win32 アプリと基幹業務 (LOB) アプリのインストールを混在させる場合、アプリのインストールが失敗する可能性があります。

おそらくインストールの排他処理が正しく処理されていないのではと思っているのですが、AutoPilot などデバイスのセットアップにて多量のインストール処理をさせたいケースで排他処理が期待通りに動かないのはちょっと苦しいですね。

つまるところ、本問題を回避するには MSI 形式のインストーラでも「Windows アプリ(Win32)」で作成する必要があるわけです。結局、「基幹業務アプリ」だったら MSI をサクッと差し替えができるところが、課題1で挙げたように運用が重たい「Windows アプリ(Win32)」になってしまうわけです…。

本スクリプトでできること

この記事で紹介するスクリプトは以下の機能を有します。

  • 指定した URL から MSI インストーラを自動取得&インストール
  • アンインストール

事前準備で必要な情報

本スクリプトを使う場合は以下の情報が予め必要です。

MSI インストーラーの公開 URL

導入したいアプリケーションの MSI インストーラーの配布先です。
本スクリプトはこの URL からダウンロード、インストールを実行します。

この URL は気合いで探す必要がありますが、私が調査したものをいくつか紹介します。

アプリケーション 配布 URL
Google Chrome (64bit) https://dl.google.com/tag/s/appguid%3D%7B8A69D345-D564-463C-AFF1-A69D9E530F96%7D%26iid%3D%7B68387BA0-557D-F80F-58A1-9E5EDDE82361%7D%26browser%3D5%26usagestats%3D0%26appname%3DGoogle%2520Chrome%26needsadmin%3Dtrue%26ap%3Dx64-stable-statsdef_0%26brand%3DGCPK/dl/chrome/install/googlechromestandaloneenterprise64.msi
Zoom (64bit) https://zoom.us/client/latest/ZoomInstallerFull.msi?archType=x64
1Password https://downloads.1password.com/win/1PasswordSetup-latest.msi

アプリケーションの名称

本スクリプトを使用してアプリケーションをアンインストールするとき、アプリケーションの特定のために使用します。

MSI 形式でインストールした場合 Product ID と呼ばれる識別子が Windows に登録され、その識別子を用いてアンインストールをすることができます。しかし、アプリケーションがバージョンアップするごとに Product ID が変化するらしい(情報求む)ので、アプリケーション名を用いて Product ID を特定します。

Windows にインストールされたアプリケーション名をそのまま使います。厳密に名称を調べたいときは以下のレジストリキーを開いて DisplayName プロパティの値を調べます。

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}

ここでも私が調査したものをいくつか紹介します。

アプリケーション アプリケーションの名称
Google Chrome (64bit) Google Chrome
Zoom (64bit) Zoom Workplace (64-bit)
1Password 1Password

アプリケーション本体のファイルパス

後述の「検出ルール」などで使うためにアプリケーション本体が保存されるパスを調べておきます。

スクリプトの作成

下記のスクリプトを作成して .intunewin 形式にパッケージングしてください。ファイル名、ファイル形式は以下で作成してください。

  • ファイル名:msiInstaller.ps1
  • 文字コード:スクリプトに日本語が含まれているため UTF-8 with BOM
  • 改行コード:CRLF
param(
    [string]$label = "",
    [string]$installerUrl = "",
    [string]$appendArguments = "",
    [switch]$uninstall = $false,
    [switch]$isDebug = $false
)

# パラメータ検証を関数内で実行
function Validate-Parameters {
    if ([string]::IsNullOrWhiteSpace($label)) {
        Write-Error "アプリケーション名(-label)を指定してください。"
        exit 1
    }

    if (-not $uninstall -and [string]::IsNullOrWhiteSpace($installerUrl)) {
        Write-Error "インストールモードではダウンロードURL(-installerUrl)を指定してください。"
        exit 1
    }
}

# メイン処理の開始時に検証
Validate-Parameters

$ProgressPreference = "SilentlyContinue"

# ログ出力関数
function Write-Log {
    param([string]$Message)

    if ($isDebug) {
        # デバッグモードの場合のみファイル出力
        if (-not $script:logInitialized) {
            $script:logPath = "C:\IntuneLog"
            if (!(Test-Path $script:logPath)) {
                New-Item -ItemType Directory -Path $script:logPath -Force | Out-Null
            }
            $script:logFile = Join-Path $script:logPath "msiInstaller_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
            $script:logInitialized = $true
        }
        $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
        "$timestamp - $Message" | Out-File -FilePath $script:logFile -Append
    }

    # コンソール出力は常に行う
    Write-Host $Message
}

# 処理情報を出力
Write-Log "====== INFO ======"
Write-Log "PowerShell Version: $($PSVersionTable.PSVersion)"
Write-Log "Execution Policy: $(Get-ExecutionPolicy)"
Write-Log "Script Path: $($MyInvocation.MyCommand.Path)"
Write-Log "Current User: $env:USERNAME"
Write-Log "Current Directory: $(Get-Location)"
Write-Log "Process Architecture: $([Environment]::Is64BitProcess)"
Write-Log "OS Architecture: $([Environment]::Is64BitOperatingSystem)"
Write-Log "Parameters received:"
Write-Log "  label: '$label'"
Write-Log "  installerUrl: '$installerUrl'"
Write-Log "  appendArguments: '$appendArguments'"
Write-Log "  uninstall: $uninstall"
Write-Log "  isDebug: $isDebug"
Write-Log "=================="

# レジストリ検索関数(reg.exe + .NETメソッド使用)
function Search-RegistryForProduct {
    param([string]$ProductName)

    $productIds = @()

    Write-Log "レジストリを検索中..."

    # 64bitシステムかチェック
    if ([Environment]::Is64BitOperatingSystem) {
        # 32bitプロセスから実行されている場合の処理
        if (-not [Environment]::Is64BitProcess) {
            Write-Log "32bitプロセスから実行されています。WOW64リダイレクトを回避します。"

            # 方法1: reg.exeを使用(64bit版が自動的に使われる)
            Write-Log "reg.exeを使用して64bitレジストリを検索中..."
            $regOutput = & reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" /s 2>&1
            $currentKey = ""
            foreach ($line in $regOutput) {
                if ($line -match "^HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\(.+)$") {
                    $currentKey = $matches[1]
                } elseif ($line -match "^\s+DisplayName\s+REG_SZ\s+(.+)$") {
                    $displayName = $matches[1].Trim()
                    if ($displayName -eq $ProductName) {
                        Write-Log "  64bit: 見つかりました - $displayName (ID: $currentKey)"
                        $productIds += [PSCustomObject]@{
                            ProductId = $currentKey
                            DisplayName = $displayName
                            Architecture = "64bit"
                        }
                    }
                }
            }

            # 方法2: PowerShellの.NETメソッドを使用
            Write-Log ".NETメソッドを使用して64bitレジストリを検索中..."
            try {
                $key64 = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
                $uninstallKey64 = $key64.OpenSubKey("SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")

                foreach ($subKeyName in $uninstallKey64.GetSubKeyNames()) {
                    $subKey = $uninstallKey64.OpenSubKey($subKeyName)
                    $displayName = $subKey.GetValue("DisplayName")
                    if ($displayName -and $displayName -eq $ProductName) {
                        Write-Log "  64bit (.NET): 見つかりました - $displayName (ID: $subKeyName)"
                        if (-not ($productIds | Where-Object { $_.ProductId -eq $subKeyName })) {
                            $productIds += [PSCustomObject]@{
                                ProductId = $subKeyName
                                DisplayName = $displayName
                                Architecture = "64bit"
                            }
                        }
                    }
                    $subKey.Close()
                }
                $uninstallKey64.Close()
                $key64.Close()
            } catch {
                Write-Log "ERROR: .NETメソッドでの64bitレジストリアクセスに失敗: $_"
            }
        } else {
            # 64bitプロセスから実行されている場合
            Write-Log "64bitプロセスから実行されています。通常のPowerShellレジストリアクセスを使用します。"
            $reg64Items = Get-ChildItem -Path "Registry::HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" -ErrorAction SilentlyContinue
            foreach ($item in $reg64Items) {
                $prop = Get-ItemProperty -Path $item.PSPath -ErrorAction SilentlyContinue
                if ($prop.DisplayName -and $prop.DisplayName -eq $ProductName) {
                    Write-Log "  64bit: 見つかりました - $($prop.DisplayName) (ID: $($item.PSChildName))"
                    $productIds += [PSCustomObject]@{
                        ProductId = $item.PSChildName
                        DisplayName = $prop.DisplayName
                        Architecture = "64bit"
                    }
                }
            }
        }

        # 32bitレジストリも検索(WOW6432Node)
        Write-Log "32bitレジストリを検索中..."
        $reg32Items = Get-ChildItem -Path "Registry::HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" -ErrorAction SilentlyContinue
        foreach ($item in $reg32Items) {
            $prop = Get-ItemProperty -Path $item.PSPath -ErrorAction SilentlyContinue
            if ($prop.DisplayName -and $prop.DisplayName -eq $ProductName) {
                Write-Log "  32bit: 見つかりました - $($prop.DisplayName) (ID: $($item.PSChildName))"
                $productIds += [PSCustomObject]@{
                    ProductId = $item.PSChildName
                    DisplayName = $prop.DisplayName
                    Architecture = "32bit"
                }
            }
        }
    } else {
        # 32bitシステムの場合
        Write-Log "32bitシステムで実行されています"
        $regItems = Get-ChildItem -Path "Registry::HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" -ErrorAction SilentlyContinue
        foreach ($item in $regItems) {
            $prop = Get-ItemProperty -Path $item.PSPath -ErrorAction SilentlyContinue
            if ($prop.DisplayName -and $prop.DisplayName -eq $ProductName) {
                Write-Log "  見つかりました - $($prop.DisplayName) (ID: $($item.PSChildName))"
                $productIds += [PSCustomObject]@{
                    ProductId = $item.PSChildName
                    DisplayName = $prop.DisplayName
                    Architecture = "32bit"
                }
            }
        }
    }

    return $productIds
}

try {
    #
    # アンインストール処理ここから
    #
    if ($uninstall)
    {
        Write-Log "アンインストール処理を開始します"
        Write-Log "検索対象のDisplayName: '$label'"

        # レジストリ検索
        $productIds = @(Search-RegistryForProduct -ProductName $label)

        Write-Log "検索結果: $($productIds.Count)個の製品が見つかりました"

        if ($productIds.Count -eq 0) {
            Write-Error "レジストリにアプリケーションが登録されていません。"
            exit 1
        }

        # 複数見つかった場合の処理
        if ($productIds.Count -gt 1) {
            Write-Log "複数の製品が見つかりました:"
            $productIds | ForEach-Object {
                Write-Log "  - $($_.DisplayName) [ID: $($_.ProductId), Arch: $($_.Architecture)]"
            }
            # 64bit版を優先
            $selected = $productIds | Where-Object { $_.Architecture -eq "64bit" } | Select-Object -First 1
            if (-not $selected) {
                $selected = $productIds[0]
            }
            Write-Log "選択: $($selected.Architecture)版を使用します"
        } else {
            $selected = $productIds[0]
        }

        Write-Log "アンインストール対象: $($selected.DisplayName) [ID: $($selected.ProductId)]"

        # 取得した製品ID(Product ID)を使ってアンインストール実行
        $process = Start-Process -NoNewWindow -PassThru -Wait -FilePath "msiexec" -ArgumentList "/x", $selected.ProductId, "/qn"

        Write-Log "アンインストール終了コード: $($process.ExitCode)"

        # 正常終了または既にアンインストール済みの場合は成功とする
        if ($process.ExitCode -eq 0 -or $process.ExitCode -eq 1605) {
            Write-Log "アンインストールが正常に完了しました"
            exit 0
        } else {
            Write-Error "アンインストールに失敗しました。終了コード: $($process.ExitCode)"
            exit $process.ExitCode
        }
    }
    #
    # アンインストール処理ここまで
    #

    #
    # 以下インストール処理
    #
    Write-Log "インストール処理を開始します"

    $installerLabel = $label.replace(" ","")
    $installerName = "$installerLabel.msi"

    Write-Log "ダウンロード先: $installerName"
    Write-Log "ダウンロード元: $installerUrl"

    # プロキシ設定を取得(システムプロキシを使用)
    $proxy = [System.Net.WebRequest]::GetSystemWebProxy()
    $proxyUri = $proxy.GetProxy($installerUrl)

    if ($proxyUri -ne $installerUrl) {
        Write-Log "プロキシを使用します: $proxyUri"
        Invoke-WebRequest -UseBasicParsing -Uri $installerUrl -OutFile $installerName -Proxy $proxyUri -ProxyUseDefaultCredentials
    } else {
        Write-Log "直接接続でダウンロードします"
        Invoke-WebRequest -UseBasicParsing -Uri $installerUrl -OutFile $installerName
    }

    if (!(Test-Path $installerName)) {
        Write-Error "MSIファイルのダウンロードに失敗しました"
        exit 1
    }

    Write-Log "MSIファイルのダウンロードが完了しました"

    # MSI ファイルのインストールをサイレントモードで実行
    $arguments = @("/i", $installerName)

    # 追加引数が指定されている場合は追加
    if ($appendArguments -and $appendArguments.Trim() -ne "") {
        $additionalArgs = $appendArguments -split '\s+' | Where-Object { $_ }
        if ($additionalArgs -and $additionalArgs.Count -gt 0) {
            $arguments += $additionalArgs
            Write-Log "追加引数: $($additionalArgs -join ' ')"
        }
    }
    # 最後に /qn を追加
    $arguments += "/qn"

    Write-Log "インストールを実行します: msiexec $($arguments -join ' ')"
    $process = Start-Process -NoNewWindow -PassThru -Wait -FilePath "msiexec" -ArgumentList $arguments

    Write-Log "インストール終了コード: $($process.ExitCode)"

    # 一時ファイルを削除
    if (Test-Path $installerName) {
        Remove-Item $installerName -Force
        Write-Log "一時ファイルを削除しました"
    }

    # 終了コードをチェック
    if ($process.ExitCode -eq 0) {
        Write-Log "インストールが正常に完了しました"
        exit 0
    } elseif ($process.ExitCode -eq 3010) {
        Write-Log "インストールが完了しました(再起動が必要)"
        exit 3010
    } else {
        Write-Error "インストールに失敗しました。終了コード: $($process.ExitCode)"
        exit $process.ExitCode
    }

} catch {
    Write-Log "ERROR: 予期しないエラーが発生しました: $_"
    Write-Log "スタックトレース: $($_.ScriptStackTrace)"
    Write-Error "予期しないエラーが発生しました: $_"
    exit 1
} finally {
    Write-Log "=== SCRIPT END ==="
}

Intune の配布設定

上記スクリプトを格納した .intunewin ファイルを Intune にアップロードします。

配布設定は以下のように設定します。

プログラム

プログラム画面の入力例

インストールコマンド

下記文字列を入力します。一部書き換えないといけない箇所があるので下表から置き換えてください。

powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "$LABEL" -installerUrl "$URL" -appendArguments "$APPEND_ARGUMENTS"
コマンド内の文字列 置き換える値
$LABEL 事前準備で確認した「アプリケーションのタイトル」
$URL 事前準備で確認した「MSI インストーラーの公開 URL」
$APPEND_ARGUMENTS MSI に追加で引数を渡したい場合はスペース区切りで入力。必要ない場合は -appendArguments ごと削除でOK

例えば、例に挙げた Google Chrome や Zoom ではこんな感じです。

アプリケーション コマンド例
Google Chrome (64bit) powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "Google Chrome" -installerUrl "https://dl.google.com/tag/s/appguid%3D%7B8A69D345-D564-463C-AFF1-A69D9E530F96%7D%26iid%3D%7B68387BA0-557D-F80F-58A1-9E5EDDE82361%7D%26browser%3D5%26usagestats%3D0%26appname%3DGoogle%2520Chrome%26needsadmin%3Dtrue%26ap%3Dx64-stable-statsdef_0%26brand%3DGCPK/dl/chrome/install/googlechromestandaloneenterprise64.msi"
Zoom (64bit) powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "Zoom Workplace (64-bit)" -installerUrl "https://zoom.us/client/latest/ZoomInstallerFull.msi?archType=x64"
1Password powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "1Password" -installerUrl "https://downloads.1password.com/win/1PasswordSetup-latest.msi" -appendArguments "MANAGED_INSTALL=1 /norestart"

アンインストールコマンド

下記文字列を入力します。一部書き換えないといけない箇所があるので、「インストールコマンド」項で挙げた対応表と同じものを入れてください。

powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "$LABEL" -uninstall

例えば、例に挙げた Google Chrome や Zoom ではこんな感じです。

アプリケーション コマンド例
Google Chrome (64bit) powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "Google Chrome" -uninstall
Zoom (64bit) powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "Zoom Workplace (64-bit)" -uninstall
1Password powershell -ExecutionPolicy Bypass -File ".\msiInstaller.ps1" -label "1Password" -uninstall

インストールの処理

事前準備で確認した「アプリケーション本体のファイルパス」によって値を変えます。

  • パスが C:¥Users¥ 配下である場合:ユーザー
  • パスが上記以外の場合:システム

検出ルール

前述の通り、アプリケーションのバージョンアップで Product ID が変わるので MSI ではなくファイルの有無で検出するように設定します。

その他の設定

特に特筆することがないので割愛します。

トラブルシューティング

スクリプトの実行に失敗し、-isDebug オプションをつけてもログファイルが生成されないときは文字コードが正しくないことが多いです。

最後に

というわけで、MSI ファイルを Intune にアップロードすることなく、自動で配布先から取得できるようにする方法を紹介しました。

ここまで書いて気づいたんですが、PowerShell を実行できる環境なら Intune じゃなくても動きますね…w。

記載した内容について何かありましたら記事コメントか X/Twitter までご連絡ください。

GitHubで編集を提案

Discussion