[CmdletBinding()] param([string]$DeviceId) $ErrorActionPreference = 'Stop' $setupStage = 'checking the local installation' $setupFailure = $null trap { Add-Type -AssemblyName System.Windows.Forms [System.Windows.Forms.MessageBox]::Show( ('Setup stopped while {0}.{1}{2}{1}No successful pairing has been confirmed.' -f $setupStage, [Environment]::NewLine, $(if ($setupFailure) { $setupFailure } else { 'Error type: ' + $_.Exception.GetType().Name + '. The PowerShell window contains the details.' })), 'Tung Time - Setup needs attention', 'OK', 'Error') | Out-Null break } $completeEndpoint = 'https://us-central1-tung-time-parent-control-34544.cloudfunctions.net/completeDeviceEnrollment' $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') $adminsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') function Set-PrivateDirectory([string]$Path) { $acl = New-Object Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner($adminsSid) $inherit = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit foreach ($identity in @($systemSid, $adminsSid)) { $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', $inherit, 'None', 'Allow')) } [IO.Directory]::SetAccessControl($Path, $acl) } function Set-PrivateFile([string]$Path) { $acl = New-Object Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner($adminsSid) foreach ($identity in @($systemSid, $adminsSid)) { $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'Allow')) } [IO.File]::SetAccessControl($Path, $acl) } if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Right-click PowerShell and choose Run as administrator, then run this downloaded script again.' } $root = Join-Path $env:ProgramData 'FamilyTime' $profilesPath = Join-Path $root 'profiles.json' if (-not (Test-Path -LiteralPath $profilesPath)) { throw 'Tung Time is not installed yet, or its child profile list has not been created.' } $profileIndex = Get-Content -LiteralPath $profilesPath -Raw | ConvertFrom-Json if (-not $profileIndex.PrimaryChildSid -or -not $profileIndex.Profiles) { throw 'Tung Time could not read the child profile list.' } $enabledProfiles = @($profileIndex.Profiles | Where-Object { $_.Enabled -eq $true }) if (-not $enabledProfiles.Count) { throw 'There are no enabled child profiles to enroll.' } $selectedProfile = $null if ($enabledProfiles.Count -eq 1) { $selectedProfile = $enabledProfiles[0] } else { Write-Host 'Choose the child profile for this remote pairing:' for ($index = 0; $index -lt $enabledProfiles.Count; $index++) { Write-Host (" {0}. {1}" -f ($index + 1), $enabledProfiles[$index].DisplayName) } $defaultSid = ([Security.Principal.SecurityIdentifier]::new($profileIndex.PrimaryChildSid)).Value $defaultIndex = 0 for ($index = 0; $index -lt $enabledProfiles.Count; $index++) { if (([Security.Principal.SecurityIdentifier]::new($enabledProfiles[$index].ChildSid)).Value -eq $defaultSid) { $defaultIndex = $index; break } } $defaultChoice = if ($defaultIndex -ge 0) { $defaultIndex + 1 } else { 1 } $choice = Read-Host ("Enter a number, or press Enter for {0}" -f $enabledProfiles[$defaultChoice - 1].DisplayName) if ([string]::IsNullOrWhiteSpace($choice)) { $choice = [string]$defaultChoice } $number = 0 if (-not [int]::TryParse($choice, [ref]$number) -or $number -lt 1 -or $number -gt $enabledProfiles.Count) { throw 'That profile number is not in the list.' } $selectedProfile = $enabledProfiles[$number - 1] } $childSid = ([Security.Principal.SecurityIdentifier]::new($selectedProfile.ChildSid)).Value Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing $setupStage = 'checking existing enrollment' $primarySid = ([Security.Principal.SecurityIdentifier]::new($profileIndex.PrimaryChildSid)).Value $existingPath = if ($childSid -eq $primarySid) { Join-Path $root 'remote-sync.json' } else { Join-Path $root ('remote-sync\' + $childSid + '.json') } if (Test-Path -LiteralPath $existingPath) { $savedEnrollment = Get-Content -LiteralPath $existingPath -Raw | ConvertFrom-Json if ($savedEnrollment.DeviceId -and $savedEnrollment.EnrollmentSecret) { [System.Windows.Forms.MessageBox]::Show( ('Enrollment is already saved for {0}. Do not enter another setup code. Open Tung Time Parent Controls, select this child, and choose Remote pairing. This does not yet confirm cloud connectivity or pairing.' -f $selectedProfile.DisplayName), 'Tung Time - Enrollment found', 'OK', 'Information') | Out-Null $savedEnrollment = $null return } throw 'An incomplete enrollment file already exists. It was preserved for review.' } $setupStage = 'checking connectivity before requesting a setup code' try { Invoke-RestMethod -Method Get -Uri $completeEndpoint -TimeoutSec 45 | Out-Null } catch { if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 405) { $setupFailure = 'Connection check failed: ' + $_.Exception.GetType().Name + ' / ' + $_.Exception.Status + '. No setup code was sent.' throw } } $setupStage = 'reading the device ID and setup code' $entryForm = New-Object System.Windows.Forms.Form $entryForm.Text = 'Tung Time - Connect parent dashboard' $entryForm.ClientSize = New-Object System.Drawing.Size(560, 290) $entryForm.StartPosition = 'CenterScreen' $entryForm.FormBorderStyle = 'FixedDialog' $entryForm.MaximizeBox = $false $entryForm.MinimizeBox = $false $entryForm.Font = New-Object System.Drawing.Font('Segoe UI', 10) $instructions = New-Object System.Windows.Forms.Label $instructions.SetBounds(20, 15, 520, 48) $instructions.Text = 'Copy both values from the same setup code on the parent dashboard. Click each box and press Ctrl+V to paste.' $idLabel = New-Object System.Windows.Forms.Label $idLabel.SetBounds(20, 70, 520, 24) $idLabel.Text = 'Device ID' $idBox = New-Object System.Windows.Forms.TextBox $idBox.SetBounds(20, 96, 520, 28) $idBox.Text = $DeviceId $codeLabel = New-Object System.Windows.Forms.Label $codeLabel.SetBounds(20, 138, 520, 24) $codeLabel.Text = 'One-time setup code (normally 32 characters)' $codeBox = New-Object System.Windows.Forms.TextBox $codeBox.SetBounds(20, 164, 520, 28) $codeBox.UseSystemPasswordChar = $true $entryStatus = New-Object System.Windows.Forms.Label $entryStatus.SetBounds(20, 198, 520, 35) $entryStatus.Text = 'No code entered yet.' $codeBox.Add_TextChanged({ $entryStatus.Text = ('Code entered: {0} characters (spaces at the ends ignored).' -f $codeBox.Text.Trim().Length) }) $connectButton = New-Object System.Windows.Forms.Button $connectButton.SetBounds(400, 242, 140, 32) $connectButton.Text = 'Connect' $connectButton.Add_Click({ if ($idBox.Text.Trim() -notmatch '^TT-[A-Z0-9_-]{16,64}$') { $entryStatus.Text = 'Paste the complete Device ID beginning with TT-.'; return } $enteredLength = $codeBox.Text.Trim().Length if ($enteredLength -lt 24 -or $enteredLength -gt 128) { $entryStatus.Text = ('Only {0} code characters received. Paste the complete setup code.' -f $enteredLength); return } $entryForm.DialogResult = [System.Windows.Forms.DialogResult]::OK $entryForm.Close() }) $entryForm.Controls.AddRange(@($instructions, $idLabel, $idBox, $codeLabel, $codeBox, $entryStatus, $connectButton)) $entryForm.AcceptButton = $connectButton try { if ($entryForm.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { throw 'Setup cancelled; no enrollment request was sent.' } $DeviceId = $idBox.Text.Trim() $setupCode = $codeBox.Text.Trim() } finally { $codeBox.Clear() $entryForm.Dispose() } Write-Host 'Connecting to the parent dashboard. Please wait...' $setupStage = 'contacting the enrollment service' try { $request = @{ deviceId = $DeviceId; setupCode = $setupCode } | ConvertTo-Json -Compress $response = Invoke-RestMethod -Method Post -Uri $completeEndpoint -ContentType 'application/json' -Body $request -TimeoutSec 45 } catch { $connectionErrorType = $_.Exception.GetType().Name $connectionErrorStatus = $_.Exception.Status $httpStatus = $null $serverError = $null if ($_.Exception.Response) { $httpStatus = [int]$_.Exception.Response.StatusCode } try { $errorBody = $_.ErrorDetails.Message if (-not $errorBody -and $_.Exception.Response) { $errorReader = New-Object IO.StreamReader($_.Exception.Response.GetResponseStream()) try { $errorBody = $errorReader.ReadToEnd() } finally { $errorReader.Dispose() } } $reportedError = ($errorBody | ConvertFrom-Json).error if ($reportedError -in @('setup-code-invalid-or-expired', 'enrollment-not-completed', 'origin-not-allowed', 'post-required')) { $serverError = $reportedError } } catch { } $setupFailure = if ($httpStatus) { 'Server response: HTTP ' + $httpStatus + $(if ($serverError) { ' / ' + $serverError } else { ' (no recognized error detail)' }) } else { 'No HTTP response. Connection error: ' + $connectionErrorType + ' / ' + $connectionErrorStatus } $setupCode = $null; $request = $null throw $setupFailure } $setupCode = $null; $request = $null $config = $response.config $setupStage = 'validating the enrollment response' if ($null -eq $config -or $config.DeviceId -ne $DeviceId -or [string]::IsNullOrWhiteSpace($config.EnrollmentSecret) -or -not ([string]$config.DatabaseUrl).StartsWith('https://') -or -not ([string]$config.EnrollmentUrl).StartsWith('https://') -or [string]::IsNullOrWhiteSpace($config.FirebaseApiKey)) { throw 'The enrollment service returned an incomplete device setup. No configuration was saved.' } $config | Add-Member -NotePropertyName ChildSid -NotePropertyValue $childSid -Force $setupStage = 'saving the protected enrollment on this PC' $primarySid = ([Security.Principal.SecurityIdentifier]::new($profileIndex.PrimaryChildSid)).Value if ($childSid -eq $primarySid) { $target = Join-Path $root 'remote-sync.json' } else { $remoteRoot = Join-Path $root 'remote-sync' if (-not (Test-Path -LiteralPath $remoteRoot)) { New-Item -ItemType Directory -Path $remoteRoot | Out-Null } Set-PrivateDirectory $remoteRoot $target = Join-Path $remoteRoot ($childSid + '.json') } if (Test-Path -LiteralPath $target) { $existing = Get-Content -LiteralPath $target -Raw | ConvertFrom-Json if ($existing.DeviceId -ne $DeviceId) { throw 'This child already has a different remote device enrollment. Do not overwrite it; ask a parent administrator to review the existing pairing first.' } } $temporary = Join-Path (Split-Path -Parent $target) ('.remote-sync-' + [Guid]::NewGuid().ToString('N') + '.tmp') try { $stream = [IO.File]::Open($temporary, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $stream.Dispose() Set-PrivateFile $temporary [IO.File]::WriteAllText($temporary, ($config | ConvertTo-Json -Compress), [Text.Encoding]::UTF8) if (Test-Path -LiteralPath $target) { [IO.File]::Replace($temporary, $target, $null) } else { [IO.File]::Move($temporary, $target) } Set-PrivateFile $target } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } } $setupStage = 'restarting Tung Time after saving enrollment' Restart-Service -Name FamilyTimeGuard -Force Write-Host ("Remote enrollment was saved for {0}. The service restarted. Open Parent Controls and choose Remote pairing for this child." -f $selectedProfile.DisplayName) [System.Windows.Forms.MessageBox]::Show( ('Remote enrollment was saved for {0} and Tung Time restarted. Next: open Parent Controls, select this child, and choose Remote pairing.' -f $selectedProfile.DisplayName), 'Tung Time - Enrollment saved', 'OK', 'Information') | Out-Null