[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Device, [Parameter(Mandatory = $true)] [string]$BaseUrl, [string]$AdbPath = "adb", [switch]$AllowInsecureHttp, [switch]$PromptForToken, [string]$TokenFile, [switch]$NoLaunch ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" if ($PromptForToken -and $PSBoundParameters.ContainsKey("TokenFile")) { throw "Use either -PromptForToken or -TokenFile, not both." } function Resolve-AdbExecutable { param([string]$RequestedPath) if (Test-Path -LiteralPath $RequestedPath -PathType Leaf) { return (Resolve-Path -LiteralPath $RequestedPath).Path } $command = Get-Command -Name $RequestedPath -CommandType Application -ErrorAction SilentlyContinue if ($null -eq $command) { throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe." } return $command.Source } function Invoke-Adb { param( [Parameter(Mandatory = $true)] [string[]]$Arguments, [AllowNull()] [string]$StandardInput = $null ) $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $script:ResolvedAdbPath $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true foreach ($argument in $Arguments) { [void]$startInfo.ArgumentList.Add($argument) } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo if (-not $process.Start()) { throw "ADB could not be started." } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() if ($null -ne $StandardInput) { $process.StandardInput.Write($StandardInput) } $process.StandardInput.Close() $process.WaitForExit() $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() if ($process.ExitCode -ne 0) { $detail = $stderr.Trim() if ([string]::IsNullOrWhiteSpace($detail)) { $detail = $stdout.Trim() } throw "ADB failed with exit code $($process.ExitCode): $detail" } return $stdout.Trim() } function Set-AppPrivateFile { param( [Parameter(Mandatory = $true)] [ValidateSet("base_url", "access_token", "allow_insecure_http")] [string]$Name, [Parameter(Mandatory = $true)] [string]$Value ) $remoteCommand = "run-as com.dayline.tv sh -c 'umask 077; cat > files/.$Name.new && chmod 600 files/.$Name.new && mv files/.$Name.new files/$Name'" [void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand) -StandardInput $Value) } function Remove-AppPrivateFile { param( [Parameter(Mandatory = $true)] [ValidateSet("access_token", "allow_insecure_http")] [string]$Name ) $remoteCommand = "run-as com.dayline.tv sh -c 'rm -f files/$Name files/.$Name.new'" [void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand)) } function Test-PrivateLanHost { param([string]$HostName) $normalized = $HostName.Trim().Trim("[", "]").ToLowerInvariant() if ($normalized -eq "localhost" -or $normalized.EndsWith(".local") -or $normalized.EndsWith(".lan") -or $normalized.EndsWith(".home.arpa")) { return $true } $address = $null if (-not [System.Net.IPAddress]::TryParse($normalized, [ref]$address)) { return $false } $bytes = $address.GetAddressBytes() if ($address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { return $bytes[0] -eq 10 -or $bytes[0] -eq 127 -or ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or ($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or ($bytes[0] -eq 169 -and $bytes[1] -eq 254) } if ($address.Equals([System.Net.IPAddress]::IPv6Loopback)) { return $true } return (($bytes[0] -band 0xFE) -eq 0xFC) -or ($bytes[0] -eq 0xFE -and ($bytes[1] -band 0xC0) -eq 0x80) } $script:ResolvedAdbPath = Resolve-AdbExecutable -RequestedPath $AdbPath $dashboardUri = $null if (-not [Uri]::TryCreate($BaseUrl.Trim(), [UriKind]::Absolute, [ref]$dashboardUri)) { throw "BaseUrl is not a valid absolute URL." } if ($dashboardUri.Scheme -notin @("http", "https")) { throw "BaseUrl must use http or https." } if (-not [string]::IsNullOrEmpty($dashboardUri.UserInfo) -or -not [string]::IsNullOrEmpty($dashboardUri.Query) -or -not [string]::IsNullOrEmpty($dashboardUri.Fragment)) { throw "BaseUrl cannot contain credentials, a query, or a fragment." } if ($dashboardUri.AbsolutePath -notin @("", "/")) { throw "The organizer must be served from the URL root path." } if ($dashboardUri.Scheme -eq "http") { if (-not $AllowInsecureHttp) { throw "HTTP requires the explicit -AllowInsecureHttp switch. HTTPS is recommended." } if (-not (Test-PrivateLanHost -HostName $dashboardUri.Host)) { throw "HTTP is restricted to private LAN IP addresses or .local, .lan, and .home.arpa hosts." } } $normalizedBaseUrl = $dashboardUri.GetLeftPart([UriPartial]::Authority) + "/" $plainToken = $null try { if ($PSBoundParameters.ContainsKey("TokenFile")) { if (-not (Test-Path -LiteralPath $TokenFile -PathType Leaf)) { throw "TokenFile was not found." } $plainToken = (Get-Content -LiteralPath $TokenFile -Raw).Trim() } elseif ($PromptForToken) { $secureToken = Read-Host "Local bearer token" -AsSecureString $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken) try { $plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } if ($null -ne $plainToken -and $plainToken -notmatch "^[A-Za-z0-9._~-]{32,512}$") { throw "The bearer token must contain 32 to 512 URL-safe characters." } [void](Invoke-Adb -Arguments @("connect", $Device)) $state = Invoke-Adb -Arguments @("-s", $Device, "get-state") if ($state -ne "device") { throw "The Shield is not connected and authorized." } $packagePath = Invoke-Adb -Arguments @("-s", $Device, "shell", "pm path com.dayline.tv") if (-not $packagePath.StartsWith("package:")) { throw "Hobbs' Family Organizer App is not installed on the selected Shield." } Set-AppPrivateFile -Name "base_url" -Value $normalizedBaseUrl if ($null -ne $plainToken) { Set-AppPrivateFile -Name "access_token" -Value $plainToken } else { Remove-AppPrivateFile -Name "access_token" } if ($dashboardUri.Scheme -eq "http") { Set-AppPrivateFile -Name "allow_insecure_http" -Value "enabled" } else { Remove-AppPrivateFile -Name "allow_insecure_http" } if (-not $NoLaunch) { [void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am force-stop com.dayline.tv")) [void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am start -W -n com.dayline.tv/.MainActivity")) } Write-Host "Configured Hobbs' Family Organizer App on $Device" Write-Host "Dashboard: $normalizedBaseUrl" Write-Host "Bearer token: $(if ($null -eq $plainToken) { 'not configured' } else { 'configured' })" Write-Host "Insecure HTTP: $(if ($dashboardUri.Scheme -eq 'http') { 'enabled for this private LAN origin' } else { 'disabled' })" Write-Host "The legacy hosted service_token was preserved for rollback." } finally { $plainToken = $null }