feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340

- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 18:27:22 -06:00
parent 122ea392d1
commit e9c7e9df1a
68 changed files with 12831 additions and 0 deletions
@@ -0,0 +1,227 @@
[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
}